tests/e2e.sh (view raw)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 |
#!/usr/bin/env bash
# End-to-end test of dingger's status model against a real, isolated tmux
# server. Nothing here touches the user's tmux server or hook socket.
#
# tests/e2e.sh [path/to/dingger]
#
# The test daemon polls only every 30s, so every update asserted here must
# arrive through events (hook reports, focus pokes, tmux focus hooks), the
# same paths that make the real UI react immediately.
#
# Layout:
# session a: window wa1 (pane A1, fake pi), window wa2 (pane A2, fake pi)
# session b: window wb1 (pane B1, fake pi)
# session viewer: a pane running a real tmux client attached to session b,
# so "what the user is looking at" is real tmux state.
set -u
ROOT=$(cd "$(dirname "$0")/.." && pwd)
DINGGER="${1:-$ROOT/target/release/dingger}"
MENUBAR="${2:-$ROOT/target/dingger-menu}"
# tmux hooks run these from other directories: make them absolute.
case "$DINGGER" in /*) ;; *) DINGGER="$PWD/$DINGGER" ;; esac
case "$MENUBAR" in /*) ;; *) MENUBAR="$PWD/$MENUBAR" ;; esac
SOCK=dingger-e2e
DIR=$(mktemp -d /tmp/dingger-e2e.XXXXXX)
HOOK="$DIR/agent.sock"
FAILS=0
PASSES=0
# Every dingger invocation below (daemon, focus, poke, menu bar) targets the
# isolated server and the test daemon's hook socket.
export DINGGER_TMUX_SOCKET="$SOCK" DINGGER_AGENT_SOCKET="$HOOK" DINGGER_STATE_DIR="$DIR/state"
# The daemon updates an installed pi extension when it starts: not the user's.
export PI_CODING_AGENT_DIR="$DIR/pi-agent"
t() { tmux -u -L "$SOCK" "$@"; }
cleanup() {
[ -n "${DAEMON_PID:-}" ] && kill "$DAEMON_PID" 2>/dev/null
[ -n "${DAEMON2_PID:-}" ] && kill "$DAEMON2_PID" 2>/dev/null
t kill-server 2>/dev/null
tmux -L "$SOCK-restore-$$" kill-server 2>/dev/null
if [ -n "${E2E_KEEP:-}" ]; then echo "kept $DIR"; else rm -rf "$DIR"; fi
}
trap cleanup EXIT
# Notifications are on, delivered by a fake notifier that takes 3s like the
# real one can; the UI must not wait for it. Sounds are on, played by a fake
# player that logs them.
NOTES="$DIR/notifications"
SOUNDS="$DIR/sounds"
start_daemon() {
DINGGER_NOTIFIER="sleep 3; echo \"\$0|\$1\" >> $NOTES" DINGGER_SOUND="echo \"\$0\" >> $SOUNDS" RUST_LOG=debug \
"$DINGGER" watch --interval-secs 30 >>"$DIR/daemon.log" 2>&1 &
DAEMON_PID=$!
for _ in $(seq 50); do [ -S "$HOOK" ] && return; sleep 0.1; done
echo "daemon did not start"; cat "$DIR/daemon.log"; exit 1
}
hook() { # pane state
python3 - "$HOOK" "$1" "$2" <<'PY'
import json, socket, sys
s = socket.socket(socket.AF_UNIX)
s.connect(sys.argv[1])
s.sendall((json.dumps({"method": "pane.report_agent",
"params": {"pane_id": sys.argv[2], "agent": "pi", "state": sys.argv[3]}}) + "\n").encode())
s.close()
PY
}
opt() { t show -pv -t "$1" "$2" 2>/dev/null; } # pane option
gopt() { t show -gv "$1" 2>/dev/null; } # global option
client_window() { t display -p -c "$CLIENT" '#{window_name}'; }
pass() { PASSES=$((PASSES + 1)); echo " ok $1"; }
fail() { FAILS=$((FAILS + 1)); echo " FAIL $1"; }
expect() { # description command expected [tries] (polls every 0.1s, default 4s)
local desc=$1 cmd=$2 want=$3 tries=${4:-40} got
for _ in $(seq "$tries"); do
got=$(eval "$cmd")
[ "$got" = "$want" ] && { pass "$desc"; return; }
sleep 0.1
done
fail "$desc: want [$want] got [$got]"
}
# --- setup -------------------------------------------------------------------
mkdir -p "$DIR/bin"
printf '#!/bin/sh\nexec sleep 100000\n' >"$DIR/bin/pi"
chmod +x "$DIR/bin/pi"
t -f /dev/null new-session -d -s a -n wa1 -c "$DIR" -x 120 -y 40 "$DIR/bin/pi"
t new-window -d -t a: -n wa2 -c "$DIR" "$DIR/bin/pi"
t new-session -d -s b -n wb1 -c "$DIR" -x 120 -y 40 "$DIR/bin/pi"
t new-session -d -s c -n wc1 -c "$DIR" -x 120 -y 40 "$DIR/bin/pi"
t set -g mouse on
A1=$(t display -p -t a:wa1 '#{pane_id}')
A2=$(t display -p -t a:wa2 '#{pane_id}')
B1=$(t display -p -t b:wb1 '#{pane_id}')
C1=$(t display -p -t c:wc1 '#{pane_id}')
# pi titles its pane "π - <session name> - <cwd basename>", or "π - <dir>"
D=$(basename "$DIR")
t select-pane -t "$A1" -T "π - Deploy the widget - $D"
t select-pane -t "$A2" -T "π - $D"
t select-pane -t "$B1" -T "π - Review PR 42 - $D"
t new-session -d -s viewer -x 120 -y 40 "env -u TMUX tmux -u -L $SOCK attach -t b"
for _ in $(seq 50); do CLIENT=$(t list-clients -F '#{client_name}' | head -1); [ -n "$CLIENT" ] && break; sleep 0.1; done
[ -z "$CLIENT" ] && { echo "no client attached"; exit 1; }
echo "panes A1=$A1 A2=$A2 B1=$B1, client $CLIENT on $(client_window)"
# tmux focus hooks, as in packaging/tmux/dingger.tmux, pointed at the test daemon
POKE="run-shell -b \"DINGGER_AGENT_SOCKET=$HOOK $DINGGER poke\""
[ -z "${E2E_NO_FOCUS_HOOKS:-}" ] && for h in client-session-changed session-window-changed window-pane-changed; do t set-hook -g "$h[77]" "$POKE"; done
start_daemon
# --- 1. first observation is idle, not done (herdr: initial idle stays seen) --
echo "1. first observation"
for p in "$A1" "$A2" "$B1" "$C1"; do hook "$p" idle; done
expect "A1 idle on first sight" "opt $A1 @agent_state" idle
expect "A2 idle on first sight" "opt $A2 @agent_state" idle
expect "B1 idle on first sight" "opt $B1 @agent_state" idle
expect "queue empty" "gopt @dingger_queue" ""
expect "A1 name from pi's title" "opt $A1 @agent_name" "Deploy the widget"
expect "A2 unnamed pi session has no name" "opt $A2 @agent_name" ""
expect "B1 name from pi's title" "opt $B1 @agent_name" "Review PR 42"
# --- 2. working, then completion away from the window -> done ----------------
echo "2. completion while away"
hook "$A1" working
expect "A1 working within 0.5s (event-driven)" "opt $A1 @agent_state" working 5
expect "working count 1" "gopt @dingger_working" 1
hook "$A1" idle
expect "A1 done within 0.5s despite a 3s notifier" "opt $A1 @agent_state" done 5
expect "done count 1" "gopt @dingger_done" 1
expect "queue = A1" "gopt @dingger_queue" "$A1"
expect "'pi finished' notification for a:wa1" "grep -c 'pi finished|a:wa1 (pi)' $NOTES 2>/dev/null" 1 60
# --- 3. completion in the window the user is looking at -> idle --------------
echo "3. completion while looking"
hook "$B1" working
expect "B1 working" "opt $B1 @agent_state" working
hook "$B1" idle
expect "B1 idle (user is looking at wb1)" "opt $B1 @agent_state" idle
expect "queue still = A1" "gopt @dingger_queue" "$A1"
sleep 3.5
expect "no notification for a completion the user watched" "grep -c 'b:wb1' $NOTES" 0
# --- 4. FIFO and blocked -------------------------------------------------------
echo "4. fifo and blocked"
hook "$A2" working
hook "$A2" blocked
expect "A2 blocked within 0.5s" "opt $A2 @agent_state" blocked 5
expect "blocked count 1" "gopt @dingger_blocked" 1
expect "'pi needs attention' notification for a:wa2" "grep -c 'pi needs attention|a:wa2 (pi)' $NOTES 2>/dev/null" 1 60
expect "queue = A2,A1 (blocked before done)" "gopt @dingger_queue" "$A2,$A1"
hook "$C1" working; hook "$C1" idle
expect "C1 done" "opt $C1 @agent_state" done
expect "queue = A2,A1,C1 (oldest done first)" "gopt @dingger_queue" "$A2,$A1,$C1"
DINGGER_TMUX_SOCKET="$SOCK" "$DINGGER" focus --client "$CLIENT" --pane "$C1"
expect "client shows wc1" "client_window" wc1
expect "C1 idle after the jump" "opt $C1 @agent_state" idle
# --- 5. attending by navigating ---------------------------------------------
echo "5. navigate to a done agent"
t switch-client -c "$CLIENT" -t "$A1"
expect "client shows wa1" "client_window" wa1
expect "A1 idle within 1s of looking (tmux focus hook)" "opt $A1 @agent_state" idle 10
expect "queue = A2" "gopt @dingger_queue" "$A2"
expect "done count 0" "gopt @dingger_done" 0
# --- 6. status-line click: focus with no args jumps to the queue head --------
echo "6. dingger focus (status-line click)"
DINGGER_TMUX_SOCKET="$SOCK" "$DINGGER" focus
expect "client jumped to wa2" "client_window" wa2
expect "A2 still blocked (needs input even when seen)" "opt $A2 @agent_state" blocked
expect "ack consumed" "opt $A2 @dingger_ack" ""
hook "$A2" working
hook "$A2" idle
expect "A2 idle (completed while looked at)" "opt $A2 @agent_state" idle
expect "queue empty" "gopt @dingger_queue" ""
# --- 7. menu row click: focus --pane -----------------------------------------
echo "7. dingger focus --pane (menu row click)"
t switch-client -c "$CLIENT" -t "$B1"
expect "client back on wb1" "client_window" wb1
hook "$A1" working
hook "$A1" idle
expect "A1 done again" "opt $A1 @agent_state" done
DINGGER_TMUX_SOCKET="$SOCK" "$DINGGER" focus --client "$CLIENT" --pane "$A1"
expect "client shows wa1" "client_window" wa1
expect "A1 idle after the jump" "opt $A1 @agent_state" idle
# --- 8. a whole turn between two polls is still a completion ----------------
echo "8. sub-second turn"
t switch-client -c "$CLIENT" -t "$B1"
expect "client back on wb1" "client_window" wb1
hook "$A1" working; hook "$A1" idle # both within one poll interval
expect "A1 done after a sub-second turn" "opt $A1 @agent_state" done
t switch-client -c "$CLIENT" -t "$A1"
expect "A1 idle once looked at" "opt $A1 @agent_state" idle
# --- 9. restart keeps state -----------------------------------------------------
echo "9. daemon restart"
t switch-client -c "$CLIENT" -t "$B1"
expect "client back on wb1" "client_window" wb1
hook "$A2" working
hook "$A2" idle
expect "A2 done before restart" "opt $A2 @agent_state" done
kill "$DAEMON_PID"; wait "$DAEMON_PID" 2>/dev/null
start_daemon
sleep 1.5
expect "A2 still done after restart" "opt $A2 @agent_state" done
expect "A1 still idle after restart" "opt $A1 @agent_state" idle
expect "B1 still idle after restart" "opt $B1 @agent_state" idle
expect "queue = A2 after restart" "gopt @dingger_queue" "$A2"
hook "$A2" working
expect "hook state still authoritative after restart" "opt $A2 @agent_state" working
# --- 10. the tmux status-line segment and its click binding -----------------
echo "10. status line"
t switch-client -c "$CLIENT" -t "$B1"
expect "client back on wb1" "client_window" wb1
hook "$A2" blocked
hook "$A1" working; hook "$A1" idle
expect "A2 blocked" "opt $A2 @agent_state" blocked
expect "A1 done" "opt $A1 @agent_state" done
t source-file "$ROOT/packaging/tmux/dingger.tmux"
# The counts are published right after the state: poll, like expect.
for _ in $(seq 40); do
segment=$(t display -p -c "$CLIENT" '#{T:status-right}' | sed 's/#\[[^]]*\]//g')
case "$segment" in *"×1 ✓1") break ;; esac
sleep 0.1
done
case "$segment" in *"×1"*"✓1"*) pass "segment shows ×1 ✓1 ($segment)";; *) fail "segment: [$segment]";; esac
expect "mouse binding installed" "t list-keys -T root | grep MouseDown1StatusRight | grep -c 'dingger focus'" 1
expect "focus hook installed: learns the terminal window" "t show-hooks -g client-focus-in | grep -c 'dingger terminal focused'" 1
expect "with the terminal's focus reports" "t show -gv focus-events" on
expect "clicking the restore offer runs restore" "t list-keys -T root | grep MouseDown1StatusRight | grep -c 'restore --report'" 1
t set -g @dingger_restore 3
segment=$(t display -p -c "$CLIENT" '#{T:status-right}' | sed 's/#\[[^]]*\]//g')
case "$segment" in "↺3 "*"×1"*) pass "segment offers to restore 3 sessions ($segment)";; *) fail "restore segment: [$segment]";; esac
t set -gu @dingger_restore
# same run-shell + #{client_name} expansion as the mouse binding, via a key
t bind -n F12 run-shell "DINGGER_TMUX_SOCKET=$SOCK $DINGGER focus --client '#{client_name}'"
t send-keys -t viewer: F12
expect "key binding jumped to the top waiter (A2, blocked)" "client_window" wa2
t send-keys -t viewer: F12
expect "next press jumps to the next waiter (A1)" "client_window" wa1
expect "A1 idle once jumped to" "opt $A1 @agent_state" idle
# --- 11. the menu bar binary: rendering and row click ------------------------
echo "11. menu bar"
if [ -x "$MENUBAR" ]; then
mb() { DINGGER_TMUX_SOCKET="$SOCK" DINGGER_BIN="$DINGGER" "$MENUBAR" "$@"; }
t switch-client -c "$CLIENT" -t "$B1"
expect "client back on wb1" "client_window" wb1
hook "$A1" working; hook "$A1" idle
expect "A1 done" "opt $A1 @agent_state" done
expect "menu title" "mb --print | head -1" "title: ×1 ✓1"
expect "menu rows: herdr priority; idle most recent first (C1 since its jump in step 4, B1 since the start)" \
"mb --print | tail -n +2 | grep -v '^⟳' | cut -f1,2 | tr '\t\n' '|;'" "× blocked|pi;✓ done|Deploy the widget;○ idle|pi;○ idle|Review PR 42;"
# Wait for working: B1 was idle before, so idle alone would not show the turn.
hook "$B1" working
expect "B1 working" "opt $B1 @agent_state" working
hook "$B1" idle
expect "B1 finishes a turn while watched: idle" "opt $B1 @agent_state" idle
expect "and is now the most recent idle agent: listed first" \
"mb --print | tail -n +2 | grep -v '^⟳' | cut -f1,2 | tr '\t\n' '|;'" "× blocked|pi;✓ done|Deploy the widget;○ idle|Review PR 42;○ idle|pi;"
expect "subtitle: location, harness, waiting time" "mb --print | grep 'Deploy the widget' | cut -f3" "a · wa1 · pi · done now"
expect "idle subtitle has no waiting time" "mb --print | grep 'Review PR 42' | cut -f3" "b · wb1 · pi"
mb --click "$A1"
expect "row click jumped to wa1" "client_window" wa1
expect "row click marked A1 idle" "opt $A1 @agent_state" idle
expect "menu title after the click" "mb --print | head -1" "title: ×1"
else
fail "menu bar binary not found at $MENUBAR"
fi
# --- 12. the shipped pi extension reports for its own pane -------------------
echo "12. pi extension"
cat >"$DIR/ext-harness.mjs" <<'JS'
// Loads the shipped extension with a fake pi and fires lifecycle events
// ("agent_start"), runs commands ("/importance high"), and asks for argument
// completions ("complete:importance h"). Custom session entries persist in
// $SESSION_ENTRIES, so a later run reads them back as the session; what the
// extension shows and emits goes to $EXT_LOG.
import fs from "node:fs";
const ext = await import(process.env.EXTENSION);
const on = {}, commands = {};
const store = process.env.SESSION_ENTRIES;
const entries = store && fs.existsSync(store) ? JSON.parse(fs.readFileSync(store, "utf8")) : [];
const log = (line) => process.env.EXT_LOG && fs.appendFileSync(process.env.EXT_LOG, line + "\n");
ext.default({
on: (name, fn) => (on[name] = fn),
registerCommand: (name, command) => (commands[name] = command),
appendEntry: (customType, data) => {
entries.push({ type: "custom", customType, data });
if (store) fs.writeFileSync(store, JSON.stringify(entries));
},
events: { on: () => {}, emit: (name, data) => log(`event ${name} ${JSON.stringify(data)}`) },
});
const ctx = {
mode: "tui",
isIdle: () => true,
sessionManager: { getEntries: () => entries, getSessionFile: () => undefined },
ui: { setStatus: (key, text) => log(`status ${key} ${text ?? ""}`), notify: (text) => log(`notify ${text}`) },
};
for (const arg of process.argv.slice(2)) {
if (arg.startsWith("/")) {
const [name, ...rest] = arg.slice(1).split(" ");
await commands[name]?.handler(rest.join(" "), ctx);
} else if (arg.startsWith("complete:")) {
const [name, ...rest] = arg.slice("complete:".length).split(" ");
const items = await commands[name]?.getArgumentCompletions?.(rest.join(" "));
log(`complete ${(items ?? []).map((item) => item.value).join(" ")}`);
} else {
await on[arg]?.({ type: arg, reason: "reload" }, ctx);
}
await new Promise((r) => setTimeout(r, 300));
}
JS
# The extension as installed: stamped with its version.
mkdir -p "$DIR/pi-harness"
PI_CODING_AGENT_DIR="$DIR/pi-harness" "$DINGGER" install pi >/dev/null
ext() { # pane events... (run "inside" a pane: tmux sets TMUX_PANE there)
local pane=$1; shift
TMUX_PANE="$pane" EXTENSION="$DIR/pi-harness/extensions/dingger-agent-state.ts" \
node --experimental-strip-types --no-warnings "$DIR/ext-harness.mjs" "$@"
}
if command -v node >/dev/null; then
# A2 is not the active pane of session a (wa1 is the active window), the
# case where asking tmux for "the current pane" picks the wrong pane.
[ "$(t display -p -t a: '#{pane_id}')" != "$A2" ] && pass "A2 is not session a's active pane" || fail "test setup: A2 is active"
before_a1=$(opt "$A1" @agent_state)
ext "$A2" session_start agent_start
expect "extension report lands on A2 (working)" "opt $A2 @agent_state" working
expect "A1 untouched" "opt $A1 @agent_state" "$before_a1"
ext "$A2" agent_settled
expect "extension completion away from the window: A2 done" "opt $A2 @agent_state" done
else
fail "node not found; cannot test the pi extension"
fi
# --- 13. the floating panel: visibility rules and compact content -----------
echo "13. floating panel"
if [ -x "$MENUBAR" ]; then
mb() { DINGGER_TMUX_SOCKET="$SOCK" DINGGER_BIN="$DINGGER" "$MENUBAR" "$@"; }
expect "panel visibility rules (selftest)" "mb --selftest | tail -1" "selftest ok"
expect "panel lists the waiting agent" "mb --print-panel | head -1 | cut -f1,2 | tr '\t' '|'" "✓ done|pi"
expect "panel summarizes the rest" "mb --print-panel | tail -1" "○ 3 idle"
DINGGER_TMUX_SOCKET="$SOCK" "$DINGGER" focus --client "$CLIENT" --pane "$A2"
expect "A2 idle after the jump" "opt $A2 @agent_state" idle
expect "panel with nothing waiting" "mb --print-panel" "Nothing waiting · ○ 4 idle"
# Most recent first: A2 (just jumped to), A1 (clicked in step 11), B1 (finished
# in step 11, before that click), C1.
expect "full list: every agent, then the summary with the toggle" \
"mb --print-panel --full | cut -f1,2 | tr '\t\n' '|;'" \
"○ idle|pi;○ idle|Deploy the widget;○ idle|Review PR 42;○ idle|pi;Nothing waiting · ○ 4 idle|[Show less ▴];"
expect "compact in Always mode offers 'Show all'" "mb --print-panel --toggle" "Nothing waiting · ○ 4 idle [Show all ▾]"
hook "$A1" working; hook "$A1" idle
expect "A1 done" "opt $A1 @agent_state" done
expect "full list separates waiting from the rest" "mb --print-panel --full | sed -n 2p" "---"
else
fail "menu bar binary not found at $MENUBAR"
fi
# --- 14. clicks work with launchd's environment ------------------------------
# launchd starts the menu bar app with PATH=/usr/bin:/bin:/usr/sbin:/sbin,
# which has no Homebrew tmux; every click runs `dingger focus` from there.
echo "14. launchd environment"
launchd_env() {
env -i HOME="$HOME" PATH=/usr/bin:/bin:/usr/sbin:/sbin DINGGER_TMUX_SOCKET="$SOCK" \
DINGGER_AGENT_SOCKET="$HOOK" DINGGER_BIN="$DINGGER" "$@"
}
t switch-client -c "$CLIENT" -t "$B1"
expect "client back on wb1" "client_window" wb1
hook "$A1" working; hook "$A1" idle
expect "A1 done" "opt $A1 @agent_state" done
if [ -x "$MENUBAR" ]; then
launchd_env "$MENUBAR" --click "$A1" 2>"$DIR/click.err"
expect "menu bar row click jumps, under launchd's PATH" "client_window" wa1
expect "A1 idle after that click" "opt $A1 @agent_state" idle
fi
t switch-client -c "$CLIENT" -t "$B1"
expect "client back on wb1" "client_window" wb1
hook "$A2" working; hook "$A2" idle
expect "A2 done" "opt $A2 @agent_state" done
launchd_env "$DINGGER" focus
expect "dingger focus (jump shortcut) jumps, under launchd's PATH" "client_window" wa2
# --- 15. agent search ----------------------------------------------------------
echo "15. agent search"
if [ -x "$MENUBAR" ]; then
mb() { DINGGER_TMUX_SOCKET="$SOCK" DINGGER_BIN="$DINGGER" "$MENUBAR" "$@"; }
expect "empty query lists every agent" "mb --print-search '' | tail -1 | cut -d' ' -f1,2" "4 agents"
expect "fuzzy name match, selected" "mb --print-search 'rvw42' | head -1 | cut -f1,2" "› ○ idle Review PR 42"
expect "every word must match" "mb --print-search 'deploy 42' | head -1" "No agents match 'deploy 42'"
expect "finds an agent by its window" "mb --print-search wc1 | head -1 | cut -f3" "c · wc1 · pi"
expect "finds agents by directory" "mb --print-search \"$(basename "$DIR")\" | tail -1 | cut -d' ' -f1,2" "4 agents"
hook "$A2" working
expect "status word ranks first" "mb --print-search working | head -1 | cut -f1" "› ◐ working"
else
fail "menu bar binary not found at $MENUBAR"
fi
# --- 16. restore after the tmux server goes away ------------------------------
# A second server and daemon. "viewer" hosts a client the way a terminal
# does: Ghostty runs `tmux new-session -As 0`.
echo "16. restore"
R="$SOCK-restore-$$"
STATE2="$DIR/state2"
# tmux gives new windows the environment of the client that creates them:
# both the test and restore run with the fake pi first on PATH.
r() { env PATH="$DIR/rbin:$PATH" tmux -u -L "$R" "$@"; }
# A server restore starts gets the same plain shells as the test's own: the
# user's login shell would read their profile, which puts the real agents
# first on PATH.
printf 'set -g default-shell /bin/sh\nset -g default-command /bin/sh\n' >"$DIR/tmux-test.conf"
renv() { env PATH="$DIR/rbin:$PATH" DINGGER_TMUX_SOCKET="$R" DINGGER_AGENT_SOCKET="$DIR/hook2.sock" \
DINGGER_STATE_DIR="$STATE2" DINGGER_TMUX_CONFIG="$DIR/tmux-test.conf" "$@"; }
# Every pi or claude in a test pane must be a fake (they run from $DIR).
real_agents() {
for tty in $(r list-panes -a -F '#{pane_tty}' 2>/dev/null | sed 's|/dev/||'); do
ps -o comm=,args= -t "$tty" | awk -v dir="$DIR/" '($1 ~ /(^|\/)(pi|claude)$/ || $2 ~ /^(pi|claude)$/) && index($2, dir) != 1'
done
}
W="$DIR/w"; mkdir -p "$W/one" "$W/two" "$W/three"
# A fake pi named pi (the daemon finds agents by process name) that records
# its arguments.
mkdir -p "$DIR/rbin/real"
cp /bin/sleep "$DIR/rbin/real/pi" && codesign --force --sign - "$DIR/rbin/real/pi" 2>/dev/null
printf '#!/bin/sh\necho "$*" >> %s\nexec %s 100000\n' "$DIR/pi-args" "$DIR/rbin/real/pi" >"$DIR/rbin/pi"
chmod +x "$DIR/rbin/pi"
start_viewer() {
env PATH="$DIR/rbin:$PATH" tmux -u -L "$R" -f /dev/null new-session -d -s viewer -x 120 -y 40 \; set -g default-shell /bin/sh \; \
set -g default-command /bin/sh \; \
send-keys -t viewer "exec env -u TMUX tmux -u -L $R new-session -As 0 -c $W/one" Enter
for _ in $(seq 50); do r has-session -t =0 2>/dev/null && [ -n "$(r list-clients)" ] && return; sleep 0.1; done
echo "viewer client did not attach"
}
start_viewer
P0=$(r display -p -t '=0:' '#{pane_id}')
r rename-window -t "$P0" edit
P1=$(r split-window -h -t "$P0" -c "$W/two" -P -F '#{pane_id}')
r split-window -v -t "$P1" -c "$W/three"
r resize-pane -t "$P0" -x 30
r new-window -d -t '=0:5' -c "$W/two"
r split-window -d -t '=0:5' -c "$W/three"
r resize-pane -Z -t '=0:5.1'
r select-window -t '=0:edit'
r new-session -d -s work -n grid -c "$W/three" -x 100 -y 30
for d in one two three; do r split-window -d -t '=work:grid' -c "$W/$d"; r select-layout -t '=work:grid' tiled; done
r select-pane -t '=work:grid.2'
r new-window -d -t '=work:7' -n logs -c "$W/one"
r select-window -t '=work:7'
r new-session -d -s keep -n saved -c "$W/one"
r new-window -d -t '=keep:' -n saved2
# Agents, a running command, and a command the zsh hook recorded.
r new-window -d -t '=work:3' -n agent -c "$W/two"
r new-window -d -t '=work:4' -n gone -c "$W/one"
r new-window -d -t '=work:5' -n cmd -c "$W/one"
r new-window -d -t '=work:6' -n hooked -c "$W/one"
# Never start the real pi: the panes must find the fake one first.
r send-keys -t '=work:agent' "command -v pi > $DIR/which-pi" Enter
for _ in $(seq 30); do [ -s "$DIR/which-pi" ] && break; sleep 0.1; done
if [ "$(cat "$DIR/which-pi" 2>/dev/null)" != "$DIR/rbin/pi" ]; then
fail "the restore server's panes would run $(cat "$DIR/which-pi" 2>/dev/null), not the fake pi; stopping"
echo "$PASSES passed, $FAILS failed"; exit 1
fi
r send-keys -t '=work:agent' 'clear; pi' Enter
r send-keys -t '=work:gone' 'pi' Enter
r send-keys -t '=work:cmd' "sh -c 'sleep 100000; :' 'two words'" Enter
r send-keys -t '=work:hooked' 'sleep 200000' Enter
fingerprint() {
r list-panes -a -F '#{session_name}|#{window_index}|#{?automatic-rename,(auto),#{window_name}}|#{window_active}|#{window_zoomed_flag}|#{automatic-rename}|#{pane_index}|#{pane_active}|#{pane_left},#{pane_top},#{pane_width}x#{pane_height}|#{pane_current_path}' \
| grep -E '^(0|work)\|'
}
BEFORE=$(fingerprint)
# Started directly, not through a function: $! must be the daemon itself.
env PATH="$DIR/rbin:$PATH" DINGGER_TMUX_SOCKET="$R" DINGGER_AGENT_SOCKET="$DIR/hook2.sock" \
DINGGER_STATE_DIR="$STATE2" DINGGER_TMUX_CONFIG="$DIR/tmux-test.conf" \
"$DINGGER" watch --interval-secs 1 --no-sounds --no-notifications >>"$DIR/daemon2.log" 2>&1 &
DAEMON2_PID=$!
latest_snapshot() { ls "$STATE2"/snapshots/*.json 2>/dev/null | tail -1; }
expect "a snapshot of both sessions" "grep -c '\"name\": \"work\"' \"\$(latest_snapshot)\" 2>/dev/null" 1 80
hook2() { # pane state session-file
python3 - "$DIR/hook2.sock" "$1" "$2" "$3" <<'PY'
import json, socket, sys
s = socket.socket(socket.AF_UNIX)
s.connect(sys.argv[1])
s.sendall((json.dumps({"method": "pane.report_agent", "params": {"pane_id": sys.argv[2],
"agent": "pi", "state": sys.argv[3], "agent_session_path": sys.argv[4]}}) + "\n").encode())
s.close()
PY
}
AGENT=$(r display -p -t '=work:agent' '#{pane_id}')
GONE=$(r display -p -t '=work:gone' '#{pane_id}')
expect "the fake pi agents are detected" "r show -pv -t $AGENT @agent 2>/dev/null; r show -pv -t $GONE @agent 2>/dev/null" "pi
pi" 80
# Session files exist: restore resumes only sessions with one.
mkdir -p "$W/sessions" && touch "$W/sessions/a b.jsonl" "$W/sessions/gone.jsonl"
hook2 "$AGENT" working "$W/sessions/a b.jsonl"
hook2 "$GONE" idle "$W/sessions/gone.jsonl"
r set-option -p -t '=work:hooked' @dingger_cmd 'slp 200000 | cat'
expect "an agent's session file is kept on its pane" "r show -pv -t $AGENT @agent_session 2>/dev/null" "$W/sessions/a b.jsonl"
in_snapshot() { python3 -c 'import sys; print(sys.argv[2] in open(sys.argv[1]).read())' "$(latest_snapshot)" "$1"; }
expect "snapshot: the agent's session" "in_snapshot '\"session\": \"$W/sessions/a b.jsonl\"'" True 80
expect "snapshot: a running command's exact arguments" "in_snapshot \"'sleep 100000; :' 'two words'\"" True 80
expect "snapshot: the zsh hook's text" "in_snapshot '\"command\": \"slp 200000 | cat\"'" True 80
# An agent that exits just before tmux dies (a shutdown stops programs first).
kill "$(ps -o tpgid= -p "$(r display -p -t "$GONE" '#{pane_pid}')" | tr -d ' ')"
expect "the exited agent is noticed" "r show -pv -t $GONE @agent 2>/dev/null" ""
sleep 1.5
expect "snapshot: the exited agent is kept for a while" "in_snapshot gone.jsonl" True
r kill-server
expect "the dead server's layout becomes the restore candidate" \
"python3 -c 'import json,sys; c=json.load(open(sys.argv[1])); print(c[\"status\"], len(c[\"snapshot\"][\"sessions\"]))' $STATE2/restore.json 2>/dev/null" "offered 4" 80
expect "the last snapshot is kept while tmux is down" "grep -c '\"name\": \"work\"' \"\$(latest_snapshot)\"" 1
start_viewer
r new-session -d -s keep -n mine "sleep 100000"
expect "the new server has a bare 0" "r list-windows -t =0 -F '#{window_panes}' | tr '\n' ' '" "1 "
expect "the candidate survives the new server's snapshots" \
"python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[\"snapshot\"][\"server\"] != json.load(open(sys.argv[2]))[\"server\"])' $STATE2/restore.json \"\$(latest_snapshot)\"" True 80
expect "dry run plans: replace 0, keep keep and viewer, create work" \
"renv \"$DINGGER\" restore --dry-run | grep -v '^ ' | cut -c1-14 | sort | tr '\n' ';'" "create work:;keep keep:;keep viewe;replace 0: 2 ;"
mbr() { renv DINGGER_BIN="$DINGGER" DINGGER_RESTORE_DIRECT=1 \
DINGGER_NOTIFIER="echo \"\$0|\$1\" >> $DIR/notes2" "$MENUBAR" "$@"; }
expect "the daemon offers to restore 0 and work" \
"python3 -c 'import json,sys; o=json.load(open(sys.argv[1])); print([s[\"name\"] for s in o[\"sessions\"]], o[\"agents\"], o[\"interrupted\"])' $STATE2/offer.json 2>/dev/null" \
"['0', 'work'] 2 1" 80
expect "and tells the status line" "r show -gv @dingger_restore" 2
expect "the panel offers it first" "mbr --print-panel | head -1 | cut -f1,3" "↺ Restore 2 sessions · 2 agents [Not now]"
expect "with what was interrupted" "mbr --print-panel | head -1 | cut -f2 | grep -o '1 was mid-turn'" "1 was mid-turn"
mbr --restore
cp "$STATE2/restore.log" "$DIR/restore.out" 2>/dev/null
expect "restore succeeds" "grep -c FAILED $DIR/restore.out" 0
expect "agents resumed with their session files" "grep -- --session $DIR/pi-args | sort" "--session $W/sessions/a b.jsonl
--session $W/sessions/gone.jsonl"
expect "no real agent runs in a test pane" "real_agents" ""
expect "both agents run" "r display -p -t '=work:agent' '#{pane_current_command}'; r display -p -t '=work:gone' '#{pane_current_command}'" "pi
pi"
expect "restore reports them" "grep -c '^resumed 2 of 2 agents' $DIR/restore.out" 1
expect "the interrupted turn is reported" "grep -c '^interrupted work:agent' $DIR/restore.out" 1
typed_line() { r capture-pane -p -t "$1" | grep -v '^ *$' | tail -1; }
expect "a command is retyped with its quoting" "typed_line '=work:cmd' | grep -o \"sh -c 'sleep 100000; :' 'two words'\$\"" "sh -c 'sleep 100000; :' 'two words'"
expect "the zsh hook's text is retyped" "typed_line '=work:hooked' | grep -o 'slp 200000 | cat\$'" "slp 200000 | cat"
expect "a notification summarizes" "cat $DIR/notes2" "Restored 2 tmux sessions|resumed 2 of 2 agents · 1 was mid-turn · 2 commands typed, not run"
expect "the offer is gone" "ls $STATE2/offer.json 2>/dev/null; r show -gv @dingger_restore 2>/dev/null; mbr --print-panel | grep -c '↺'" "0"
expect "neither runs: the shell waits for Enter" \
"r display -p -t '=work:cmd' '#{pane_current_command}'; r display -p -t '=work:hooked' '#{pane_current_command}'" "bash
bash"
expect "windows, panes, layouts, directories, focus, zoom are back" "fingerprint" "$BEFORE"
expect "the terminal's client moved to the restored 0, still attached" "r list-clients -F '#{session_name}'" 0
expect "a used session is left alone" "r list-windows -t =keep -F '#{window_name}'" mine
expect "the candidate is marked restored" \
"python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[\"status\"])' $STATE2/restore.json" restored
r resize-window -t '=viewer:' -x 100 -y 30
expect "restored windows follow the terminal's size again" "r display -p -t '=0:edit' '#{window_width}'" 100
r resize-window -t '=viewer:' -x 120 -y 40
expect "and back" "r display -p -t '=0:edit' '#{window_width}'" 120
renv "$DINGGER" restore >"$DIR/restore2.out" 2>&1
expect "restoring again changes nothing" "fingerprint" "$BEFORE"
expect "restoring again plans nothing" "grep -cvE '^keep ' $DIR/restore2.out" 0
[ "$FAILS" -gt 0 ] && { echo "--- restore output:"; cat "$DIR/restore.out"; echo "--- before:"; echo "$BEFORE"; echo "--- after:"; fingerprint; }
# Again, now with no tmux running at all (after a reboot, before a terminal
# opens): restoring starts the server. Then "Not now" stays dismissed.
names() { r list-panes -a -F '#{session_name}|#{window_index}|#{?automatic-rename,(auto),#{window_name}}|#{pane_index}|#{pane_current_path}' 2>/dev/null | grep "^$1|"; }
WORK_BEFORE=$(names work)
r kill-server
expect "a new offer: all four sessions, tmux is not running" \
"python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))[\"sessions\"]))' $STATE2/offer.json 2>/dev/null" 4 80
mbr --restore work
expect "restoring with no server starts it and rebuilds the session" "names work" "$WORK_BEFORE"
expect "with the test's shell, not the user's login shell" "r display -p -t '=work:agent' '#{pane_current_command}'" bash
expect "no real agent runs in a test pane (no server)" "real_agents" ""
expect "the offer shrinks to what is still missing" \
"python3 -c 'import json,sys; print(sorted(s[\"name\"] for s in json.load(open(sys.argv[1]))[\"sessions\"]))' $STATE2/offer.json 2>/dev/null" \
"['0', 'keep', 'viewer']" 80
mbr --dismiss-offer >/dev/null
expect "Not now: the offer is gone" "ls $STATE2/offer.json 2>/dev/null | wc -l | tr -d ' '" 0
expect "and the status line is told" "r show -gv @dingger_restore 2>/dev/null" ""
start_viewer
sleep 2
expect "it stays dismissed when a terminal starts tmux" "ls $STATE2/offer.json 2>/dev/null | wc -l | tr -d ' '" 0
expect "the layout can still be restored by hand" "renv \"$DINGGER\" restore --dry-run | grep -c '^create keep'" 1
# --- 17. a daemon killed mid-write leaves consistent state -------------------
echo "17. kill during a write"
expect "20 kills right after done: hook and display state agree" \
"\"$ROOT/tests/kill-race.sh\" \"$DINGGER\" 20 | grep -o '^[^:]*: [0-9]* of' | sed 's/.*: //'" "0 of" 1
# --- 18. claude: install its hook; restore resumes its session ---------------
echo "18. claude"
CC="$DIR/cc-config"; mkdir -p "$CC/real"
cat >"$CC/real/settings.json" <<'JSON'
{
"env": {
"TRACE_TO_LANGSMITH": "true"
},
"model": "sonnet",
"hooks": {
"SessionStart": [
{
"matcher": "^(startup|resume|clear|compact|fork)$",
"hooks": [
{
"type": "command",
"command": "bash '/x/herdr-agent-state.sh' session",
"timeout": 10
}
]
}
]
},
"theme": "auto"
}
JSON
cp "$CC/real/settings.json" "$CC/original.json"
ln -s "$CC/real/settings.json" "$CC/settings.json"
ccinstall() { CLAUDE_CONFIG_DIR="$CC" "$DINGGER" install claude; }
ccinstall >"$DIR/ccinstall.out" 2>&1
expect "install claude succeeds" "grep -c '^installed in' $DIR/ccinstall.out" 1
expect "settings: the same keys in the same order, herdr's hook first, dingger's added" \
"python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(list(d), [h[\"command\"].rsplit(\"/\", 1)[-1] for g in d[\"hooks\"][\"SessionStart\"] for h in g[\"hooks\"]])' $CC/settings.json" \
"['env', 'model', 'hooks', 'theme'] [\"herdr-agent-state.sh' session\", 'dingger hook claude']"
expect "everything else unchanged" \
"python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); d[\"hooks\"][\"SessionStart\"].pop(); print(d == json.load(open(sys.argv[2])))' $CC/settings.json $CC/original.json" True
expect "written through the symlink" "[ -L $CC/settings.json ] && cmp -s $CC/settings.json $CC/real/settings.json && echo yes" yes
expect "the original is backed up" "cmp -s $CC/real/settings.json.bak-dingger $CC/original.json && echo same" same
ccinstall >"$DIR/ccinstall2.out" 2>&1
expect "installing again changes nothing" "cat $DIR/ccinstall2.out" "already installed in $(cd "$CC/real" && pwd -P)/settings.json"
echo '{"model": ' >"$CC/broken.json"; cp "$CC/broken.json" "$CC/broken-copy.json"
expect "invalid JSON is not touched" \
"CLAUDE_CONFIG_DIR=$CC/b $DINGGER install claude >/dev/null 2>&1; mkdir -p $CC/b; cp $CC/broken.json $CC/b/settings.json; CLAUDE_CONFIG_DIR=$CC/b $DINGGER install claude >/dev/null 2>&1; echo \$?; cmp -s $CC/b/settings.json $CC/broken-copy.json && echo unchanged" "1
unchanged"
# The exact command Claude would run, from the installed settings.
HOOK_CMD=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["hooks"]["SessionStart"][-1]["hooks"][0]["command"])' "$CC/settings.json")
expect "the hook prints nothing and exits 0, even with no daemon or bad input" \
"printf '{\"hook_event_name\":\"SessionStart\",\"transcript_path\":\"/x.jsonl\"}' | TMUX_PANE=%99 DINGGER_AGENT_SOCKET=$DIR/none.sock sh -c \"$HOOK_CMD\"; echo garbage | sh -c \"$HOOK_CMD\"; echo \"exit \$?\"" "exit 0"
# A fake claude named claude: records its arguments, runs the SessionStart
# hook with Claude's input (`claude <id>` starts session <id>, `claude
# --resume <id>` resumes it), then waits.
cp /bin/sleep "$DIR/rbin/real/claude" && codesign --force --sign - "$DIR/rbin/real/claude" 2>/dev/null
cat >"$DIR/rbin/claude" <<EOF
#!/bin/sh
echo "\$*" >> $DIR/claude-args
id=\${2:-\$1}
printf '{"session_id":"%s","transcript_path":"%s","cwd":"%s","hook_event_name":"SessionStart","source":"startup"}' \
"\$id" "$W/claude/\$id.jsonl" "\$PWD" | DINGGER_AGENT_SOCKET=$DIR/hook2.sock sh -c "$HOOK_CMD"
exec $DIR/rbin/real/claude 100000
EOF
chmod +x "$DIR/rbin/claude"
# "talked" has a transcript; "fresh" never got a message, so it has none.
mkdir -p "$W/claude" && touch "$W/claude/talked.jsonl"
r new-session -d -s cc -n talked -c "$W/two"
r new-window -d -t '=cc:' -n fresh -c "$W/three"
r send-keys -t '=cc:talked' 'claude talked' Enter
r send-keys -t '=cc:fresh' 'claude fresh' Enter
r set-option -p -t '=cc:fresh' @dingger_cmd 'claude fresh'
TALKED=$(r display -p -t '=cc:talked' '#{pane_id}')
expect "claude is detected" "r show -pv -t '=cc:talked' @agent 2>/dev/null; r show -pv -t '=cc:fresh' @agent 2>/dev/null" "claude
claude" 80
expect "its hook's session is kept on its pane" "r show -pv -t $TALKED @agent_session 2>/dev/null" "$W/claude/talked.jsonl" 80
expect "snapshot: claude's session" "in_snapshot '\"session\": \"$W/claude/talked.jsonl\"'" True 80
expect "snapshot: the command line of the claude without a transcript" "in_snapshot '\"command\": \"claude fresh\"'" True 80
r kill-server
expect "a new restore candidate" \
"python3 -c 'import json,sys; print(\"cc\" in [s[\"name\"] for s in json.load(open(sys.argv[1]))[\"snapshot\"][\"sessions\"]])' $STATE2/restore.json 2>/dev/null" True 80
: >"$DIR/claude-args"
renv "$DINGGER" restore --session cc >"$DIR/restore-cc.out" 2>&1
expect "no real agent runs in a test pane (claude)" "real_agents" ""
expect "claude resumed by id" "cat $DIR/claude-args" "--resume talked"
expect "in its directory" "r display -p -t '=cc:talked' '#{pane_current_path}'" "$(cd "$W/two" && pwd -P)"
expect "restore reports it" "grep -c '^resumed 1 of 1 agents' $DIR/restore-cc.out" 1
expect "no transcript: its command line is typed, not run" \
"typed_line '=cc:fresh' | grep -o 'claude fresh\$'; r display -p -t '=cc:fresh' '#{pane_current_command}'" "claude fresh
bash"
expect "the resumed claude reports its session again" \
"r show -pv -t '=cc:talked' @agent_session 2>/dev/null" "$W/claude/talked.jsonl" 80
[ "$FAILS" -gt 0 ] && { echo "--- restore-cc:"; cat "$DIR/restore-cc.out"; }
# --- 19. a session group: its panes once, its windows saved once -------------
echo "19. session group"
start_viewer
r new-session -d -s grp -n agent -c "$W/one"
r new-window -d -t '=grp:' -n other -c "$W/two"
r new-session -d -t grp -s grp-2
r send-keys -t '=grp:agent' 'pi' Enter
GA=$(r display -p -t '=grp:agent' '#{pane_id}')
expect "the group's agent is detected" "r show -pv -t $GA @agent 2>/dev/null" pi 80
expect "dingger status lists it once" "renv \"$DINGGER\" status | grep -c ' grp'" 1
expect "the menu lists it once" "mbr --print | grep -c 'grp'" 1
# Watched only through grp-2: grp shows another window, nobody attaches it.
r select-window -t '=grp:other'
r select-window -t '=grp-2:agent'
r switch-client -c "$(r list-clients -F '#{client_name}' | head -1)" -t '=grp-2'
touch "$W/sessions/grp.jsonl"
# Wait for working: the agent was idle before, so idle alone would not show the turn.
hook2 "$GA" working "$W/sessions/grp.jsonl"
expect "the group's agent working" "r show -pv -t $GA @agent_state" working 80
hook2 "$GA" idle "$W/sessions/grp.jsonl"
expect "a turn finished while watched through grp-2 is idle, not done" "r show -pv -t $GA @agent_state" idle 80
expect "snapshot: the group is saved once" \
"python3 -c 'import json,sys; print(sorted(s[\"name\"] for s in json.load(open(sys.argv[1]))[\"sessions\"] if s[\"name\"].startswith(\"grp\")))' \"\$(latest_snapshot)\"" \
"['grp']" 80
expect "snapshot: with the agent's session" "in_snapshot '\"session\": \"$W/sessions/grp.jsonl\"'" True 80
r kill-server
expect "the candidate: the group once" \
"python3 -c 'import json,sys; print(sorted(s[\"name\"] for s in json.load(open(sys.argv[1]))[\"snapshot\"][\"sessions\"] if s[\"name\"].startswith(\"grp\")))' $STATE2/restore.json 2>/dev/null" "['grp']" 80
expect "dry run plans the group once" "renv \"$DINGGER\" restore --dry-run 2>/dev/null | grep -E '^[a-z]+ +grp' | cut -c1-14" "create grp: "
: >"$DIR/pi-args"
renv "$DINGGER" restore --session grp >"$DIR/restore-grp.out" 2>&1
expect "restore: the agent is resumed once" "grep -c -- '--session $W/sessions/grp.jsonl' $DIR/pi-args" 1
expect "with both windows" "r list-windows -t '=grp' -F '#{window_name}' | tr '\n' ' '" "agent other "
expect "no real agent runs in a test pane (group)" "real_agents" ""
# --- 20. raise the right terminal window ---------------------------------------
# Two terminal windows on one session (Ghostty's `new-session -As 0` does
# that): nested clients, focused by typing a terminal's focus reports into
# their outer panes. A fake osascript plays Ghostty: it answers the focused
# terminal from a file and logs what it is told to focus. `open` is a fake
# too: tests never activate a real app.
echo "20. terminal windows"
cat >"$DIR/osascript" <<FAKE
#!/bin/sh
case "\$2" in
*"focused terminal"*)
echo query >>$DIR/ghostty-queries
# Focus moves on while Ghostty answers.
[ -f $DIR/ghostty-race ] && env -u TMUX tmux -u -L $R send-keys -t "\$(cat $DIR/ghostty-race):" -H 1b 5b 4f && sleep 0.3
cat $DIR/ghostty-focused ;;
*"focus terminal id"*)
echo "\$2" | grep -o 'id "[^"]*"' | cut -d'"' -f2 >>$DIR/ghostty-raised
[ ! -f $DIR/ghostty-gone ] ;;
esac
FAKE
printf '#!/bin/sh\necho "$2" >> %s\n' "$DIR/opened" >"$DIR/open"
chmod +x "$DIR/osascript" "$DIR/open"
FAKE_APP="$DIR/Fake/Ghostty.app"
start_viewer
r set-environment -g DINGGER_OSASCRIPT "$DIR/osascript"
r set-environment -g DINGGER_OPEN "$DIR/open"
r set-environment -g DINGGER_TERMINAL_APP "$FAKE_APP"
r set-environment -g DINGGER_TMUX_SOCKET "$R"
r set -g focus-events on
# The snippet's own focus hook, pointed at the binary under test.
grep "client-focus-in" "$ROOT/packaging/tmux/dingger.tmux" | sed "s|\$HOME/.local/bin/dingger|$DINGGER|" >"$DIR/focus-hook.tmux"
r source-file "$DIR/focus-hook.tmux"
expect "the snippet's focus hook, on the binary under test" "r show-hooks -g client-focus-in | grep -c \"$DINGGER terminal focused\"" 1
r new-session -d -s viewer2 -x 120 -y 40
r send-keys -t viewer2 "exec env -u TMUX tmux -u -L $R attach -t 0" Enter
expect "two terminal windows on session 0" "r list-clients -F '#{session_name}' | sort | tr '\n' ' '" "0 0 " 50
TTY1=$(r display -p -t viewer: '#{pane_tty}'); TTY2=$(r display -p -t viewer2: '#{pane_tty}')
r new-window -d -t '=0:' -n agent2 "$DIR/rbin/real/pi 100000"
AG=$(r display -p -t '=0:agent2' '#{pane_id}')
r new-window -d -t '=0:' -n other
r new-session -d -s elsewhere
focus_in() { echo "$2" >"$DIR/ghostty-focused"; r send-keys -t "$1:" -H 1b 5b 49; }
focus_out() { r send-keys -t "$1:" -H 1b 5b 4f; }
paired() { r show -gv "@dingger_terminal_$(basename "$1")" 2>/dev/null | cut -d' ' -f2; }
client_session() { r list-clients -F '#{client_tty} #{session_name}' | awk -v t="$1" '$1==t {print $2}'; }
focus_out viewer; focus_out viewer2
focus_in viewer AAAA-1111
expect "focusing a window pairs its client with Ghostty's focused terminal" "paired $TTY1" AAAA-1111 50
focus_out viewer; focus_in viewer2 BBBB-2222
expect "and the other window's" "paired $TTY2" BBBB-2222 50
: >"$DIR/ghostty-raised"
renv "$DINGGER" focus --pane "$AG"
expect "a jump raises the focused window's terminal" "tail -1 $DIR/ghostty-raised" BBBB-2222 50
expect "which shows the agent" "r display -p -c $TTY2 '#{window_name}'" agent2
# Focus held past learn's settling second.
focus_out viewer2; focus_in viewer AAAA-1111; sleep 1.5; focus_out viewer
r select-window -t '=0:other'
renv "$DINGGER" focus --pane "$AG"
expect "nothing focused: the most recently focused window's" "tail -1 $DIR/ghostty-raised" AAAA-1111 50
r switch-client -c "$TTY1" -t '=elsewhere'
renv "$DINGGER" focus --pane "$AG"
expect "a window already on the agent's session wins over a more recent one" "tail -1 $DIR/ghostty-raised" BBBB-2222 50
expect "and the other window is not moved" "client_session $TTY1" elsewhere
# Across a Space switch Ghostty still names the terminal that had focus
# for a moment: the other window's.
focus_out viewer; focus_in viewer2 AAAA-1111
sleep 0.5; echo BBBB-2222 >"$DIR/ghostty-focused"
sleep 1
expect "Ghostty lagging behind tmux's focus: the right terminal" "paired $TTY2" BBBB-2222 50
expect "and the other window keeps its own" "paired $TTY1" AAAA-1111
# Pairings crossed (as the lag used to leave them): Ghostty's lagging
# answer is then the client's own wrong terminal.
r set -g "@dingger_terminal_$(basename "$TTY1")" "1 BBBB-2222 $FAKE_APP"
r set -g "@dingger_terminal_$(basename "$TTY2")" "1 AAAA-1111 $FAKE_APP"
focus_out viewer2; focus_in viewer2 AAAA-1111
sleep 0.5; echo BBBB-2222 >"$DIR/ghostty-focused"
expect "crossed pairings heal: the focused client's" "paired $TTY2" BBBB-2222 50
expect "and the other's wrong one is dropped" "paired $TTY1" ""
focus_out viewer2; focus_in viewer AAAA-1111
expect "until its next focus" "paired $TTY1" AAAA-1111 50
r set -g @dingger_terminal_ttysgone "1 DDDD-4444 $FAKE_APP"
focus_out viewer; focus_in viewer2 BBBB-2222
expect "a client no longer attached loses its pairing" "r show -gv @dingger_terminal_ttysgone 2>/dev/null" "" 50
echo viewer >"$DIR/ghostty-race"; : >"$DIR/ghostty-queries"
focus_in viewer CCCC-3333
expect "Ghostty asked" "wc -l <$DIR/ghostty-queries | tr -d ' '" 1 50
sleep 1
expect "focus moved on while Ghostty answered: not paired" "paired $TTY1" AAAA-1111
expect "which tmux does not report as an error (view mode over a pane)" "r list-panes -a -F '#{pane_mode}' | grep -c view-mode" 0
rm -f "$DIR/ghostty-race"
focus_in viewer2 AAAA-1111
expect "a terminal holds one client: the newer pairing" "paired $TTY2" AAAA-1111 50
expect "replaces the older one" "paired $TTY1" ""
focus_out viewer2
touch "$DIR/ghostty-gone"; : >"$DIR/opened"
renv "$DINGGER" focus --pane "$AG"
expect "a terminal that is gone is forgotten" "paired $TTY2" "" 50
expect "and the app is brought forward instead" "cat $DIR/opened" "$FAKE_APP" 50
expect "nor that" "r list-panes -a -F '#{pane_mode}' | grep -c view-mode" 0
rm -f "$DIR/ghostty-gone"; : >"$DIR/opened"; : >"$DIR/ghostty-raised"
renv DINGGER_TERMINAL_APP="$FAKE_APP" DINGGER_OPEN="$DIR/open" "$DINGGER" focus --pane "$AG"
expect "no pairing: the app" "cat $DIR/opened" "$FAKE_APP" 50
expect "and no terminal raised" "cat $DIR/ghostty-raised" ""
# tmux answers for the current client when asked about one that is gone.
r set -g "@dingger_terminal_$(basename "$TTY1")" "$(date +%s)000 AAAA-1111 $FAKE_APP"
r set -g "@dingger_terminal_$(basename "$TTY2")" "$(date +%s)000 BBBB-2222 $FAKE_APP"
r run-shell "$DINGGER terminal raise --client /dev/gone"
sleep 0.5
expect "a client that is gone raises no other client's terminal" "cat $DIR/ghostty-raised" ""
# A window on a session of a group stays on it: a pane target alone lets
# tmux pick a session of the group, the most recently used.
r new-session -d -t grp -s grp-b
r switch-client -c "$TTY1" -t '=grp-b'
r switch-client -c "$TTY2" -t '=grp'; sleep 1.1; r switch-client -c "$TTY2" -t '=0'
renv "$DINGGER" focus --client "$TTY1" --pane "$(r display -p -t '=grp:agent' '#{pane_id}')"
expect "a window on a session group stays on its own session" "client_session $TTY1" grp-b
expect "showing the agent" "r display -p -c $TTY1 '#{window_name}'" agent
# --- 21. importance --------------------------------------------------------
# Fresh agents on the first server (its daemon polls every 30 s: updates
# arrive by reports and pokes). Nobody looks at session imp.
echo "21. importance"
report() { # pane state importance (either may be empty)
python3 - "$HOOK" "$1" "$2" "$3" <<'PY'
import json, socket, sys
params = {"pane_id": sys.argv[2], "agent": "pi"}
if sys.argv[3]: params["state"] = sys.argv[3]
if sys.argv[4]: params["importance"] = sys.argv[4]
s = socket.socket(socket.AF_UNIX)
s.connect(sys.argv[1])
s.sendall((json.dumps({"method": "pane.report_agent", "params": params}) + "\n").encode())
s.close()
PY
}
t new-session -d -s imp -n i1 -c "$DIR" -x 120 -y 40 "$DIR/bin/pi"
t new-window -d -t imp: -n i2 -c "$DIR" "$DIR/bin/pi"
t new-window -d -t imp: -n i3 -c "$DIR" "$DIR/bin/pi"
t new-window -d -t imp: -n i4 -c "$DIR" "$DIR/bin/pi"
I1=$(t display -p -t imp:i1 '#{pane_id}'); I2=$(t display -p -t imp:i2 '#{pane_id}')
I3=$(t display -p -t imp:i3 '#{pane_id}'); I4=$(t display -p -t imp:i4 '#{pane_id}')
iqueue() { gopt @dingger_queue | tr ',' '\n' | grep -xE "$I1|$I2|$I3" | tr '\n' ' '; }
for p in "$I1" "$I2" "$I3"; do report "$p" working ""; expect "imp: $p working" "opt $p @agent_state" working; done
for p in "$I1" "$I2" "$I3"; do report "$p" idle ""; expect "imp: $p done" "opt $p @agent_state" done; sleep 0.05; done
expect "same importance: oldest done first" "iqueue" "$I1 $I2 $I3 "
report "$I3" "" high
expect "a high agent's pane option" "opt $I3 @dingger_importance" high
expect "importance before time: the high agent first" "iqueue" "$I3 $I1 $I2 "
expect "and its status unchanged (a report without state)" "opt $I3 @agent_state" done
report "$I1" "" low
expect "a low agent last" "iqueue" "$I3 $I2 $I1 "
report "$I3" "" normal
expect "normal leaves the option unset" "opt $I3 @dingger_importance" ""
expect "and the order by time again" "iqueue" "$I2 $I3 $I1 "
report "$I3" "" high
expect "imp: high again" "iqueue" "$I3 $I2 $I1 "
report "$I1" blocked ""
expect "imp: a low agent blocked" "opt $I1 @agent_state" blocked
expect "status before importance: the blocked low agent first" "iqueue" "$I1 $I3 $I2 "
report "$I1" working ""
expect "imp: working again" "opt $I1 @agent_state" working
# Let every alert so far land (the fake notifier takes 3 s).
sleep 3.5; : >"$SOUNDS"; : >"$NOTES"
report "$I1" working ""; report "$I1" idle ""
expect "a low agent finishing" "opt $I1 @agent_state" done
sleep 0.5
expect "makes no sound" "cat $SOUNDS" ""
expect "but still notifies" "grep -c '^pi finished|imp:i1' $NOTES" 1 60
report "$I2" working ""; report "$I2" idle ""
expect "a normal agent finishing sounds" "cat $SOUNDS" done
mb() { DINGGER_TMUX_SOCKET="$SOCK" DINGGER_BIN="$DINGGER" "$MENUBAR" "$@"; }
expect "menu: a high agent is marked" "mb --print | grep -F 'imp · i3' | cut -f2" "! pi"
expect "menu: a low agent says so" "mb --print | grep -F 'imp · i1' | cut -f3" "imp · i1 · pi · low · done now"
expect "menu: high, normal, low among the done" \
"mb --print | grep -F 'imp · ' | grep '^✓' | cut -f3 | cut -d' ' -f3 | tr '\n' ' '" "i3 i2 i1 "
expect "dingger importance shows it" "$DINGGER importance --pane $I1" low
expect "a pi that reports itself is set in pi" \
"$DINGGER importance high --pane $I1 2>&1" "dingger: this pi reports to dingger: use /importance in it (pi saves it with the session)"
expect "dingger importance needs an agent" "$DINGGER importance high --pane \$(t display -p -t viewer: '#{pane_id}') 2>&1 | cut -c1-24" "dingger: no agent in pan"
$DINGGER poke; expect "imp: the plain pi is identified" "opt $I4 @agent" pi
expect "dingger importance sets another agent's" "$DINGGER importance high --pane $I4" "importance: high"
expect "the daemon writes it" "opt $I4 @dingger_importance" high
t respawn-pane -k -t "$I4" "sleep 100000"; $DINGGER poke
expect "it goes with the agent" "opt $I4 @dingger_importance" ""
kill "$DAEMON_PID"; sleep 0.3; start_daemon; $DINGGER poke
sleep 1
expect "a daemon restart keeps it" "opt $I3 @dingger_importance" high
expect "and the order" "iqueue" "$I3 $I2 $I1 "
if command -v node >/dev/null; then
ENTRIES="$DIR/entries.json"; EXTLOG="$DIR/ext.log"
iext() { SESSION_ENTRIES="$ENTRIES" EXT_LOG="$EXTLOG" ext "$@"; }
iext "$I2" session_start "/importance high"
expect "/importance high reaches dingger" "opt $I2 @dingger_importance" high
expect "saved in the session" "grep -c '\"customType\":\"dingger-importance\",\"data\":{\"level\":\"high\"}' $ENTRIES" 1
expect "no footer status in pi" "grep -c '^status' $EXTLOG" 0
expect "announced to other extensions" "grep -c '^event dingger:importance {\"level\":\"high\"}$' $EXTLOG" 1
report "$I2" "" normal
expect "imp: set back by hand" "opt $I2 @dingger_importance" ""
# Unanswered, the extension sends a report again after 0.5 s, and gives
# up on it after 2 s.
working() { sed 's/\x1b\[[0-9;]*m//g' "$DIR/daemon.log" | grep -c "hook report pane=$I2 state=Working"; }
before=$(working)
start=$(python3 -c 'import time; print(time.time())')
iext "$I2" agent_start
took=$(python3 -c 'import time,sys; print(round(time.time() - float(sys.argv[1]), 2))' "$start")
sleep 0.6
expect "the daemon answers a report: the extension sends it once" "echo \$((\$(working) - before))" 1
expect "and is done with it at once ($took s, with the harness's 0.3 s pause)" \
"python3 -c 'import sys; print(float(sys.argv[1]) < 1.5)' $took" True 1
iext "$I2" session_start
expect "a new pi on the session reports it again" "opt $I2 @dingger_importance" high
# pi starts a session before any command runs.
: >"$EXTLOG"; iext "$I2" session_start "/importance urgent" "complete:importance h" "/importance normal"
expect "an unknown level is refused" "grep -c '^notify importance is high, normal, or low$' $EXTLOG" 1
expect "levels complete" "grep '^complete' $EXTLOG" "complete high"
expect "normal reaches dingger" "opt $I2 @dingger_importance" ""
fi
# --- 22. mark as unread ----------------------------------------------------
# An idle agent marked unread is done again, last among done agents of its
# importance, until the user comes back to it.
echo "22. mark as unread"
t new-session -d -s unr -n u1 -c "$DIR" -x 120 -y 40 "$DIR/bin/pi"
t new-window -d -t unr: -n u2 -c "$DIR" "$DIR/bin/pi"
t new-window -d -t unr: -n u3 -c "$DIR" "$DIR/bin/pi"
t new-window -d -t unr: -n away -c "$DIR" "sleep 100000"
U1=$(t display -p -t unr:u1 '#{pane_id}'); U2=$(t display -p -t unr:u2 '#{pane_id}')
U3=$(t display -p -t unr:u3 '#{pane_id}'); AWAY=$(t display -p -t unr:away '#{pane_id}')
uqueue() { gopt @dingger_queue | tr ',' '\n' | grep -xE "$U1|$U2|$U3" | tr '\n' ' '; }
look() { t switch-client -c "$CLIENT" -t "$1"; $DINGGER poke; }
look "$AWAY"
report "$U3" "" low
for p in "$U3" "$U1"; do report "$p" working ""; report "$p" idle ""; expect "unr: $p done" "opt $p @agent_state" done; sleep 0.05; done
look "$U1"
expect "unr: looking at u1 sees it" "opt $U1 @agent_state" idle
look "$AWAY"
# u2 finishes after u1 was seen: by its old time, u1 would come first.
report "$U2" working ""; report "$U2" idle ""; expect "unr: $U2 done" "opt $U2 @agent_state" done
expect "unr: the queue without it" "uqueue" "$U2 $U3 "
look "$U1"
sleep 3.5; : >"$SOUNDS"; : >"$NOTES"
expect "dingger unread marks it" "$DINGGER unread --pane $U1" "marked unread"
expect "done again" "opt $U1 @agent_state" done
expect "last among done agents of its importance, before a low one" "uqueue" "$U2 $U1 $U3 "
expect "menu: shown as done" "mb --print | grep -F 'unr · u1' | cut -d' ' -f1" "✓"
$DINGGER poke; sleep 1; $DINGGER poke
expect "still looking at it: still done" "opt $U1 @agent_state" done
sleep 1
expect "marking makes no sound" "cat $SOUNDS" ""
expect "and no notification" "cat $NOTES" ""
look "$AWAY"
expect "looking away: still done" "opt $U1 @agent_state" done 10
look "$U1"
expect "coming back sees it" "opt $U1 @agent_state" idle
expect "unr: marked again" "$DINGGER unread --pane $U1" "marked unread"
$DINGGER focus --client "$CLIENT" --pane "$U1"
expect "a jump to it sees it" "opt $U1 @agent_state" idle
look "$AWAY"
expect "marked while away" "$DINGGER unread --pane $U1" "marked unread"
look "$U1"
expect "seen on arrival" "opt $U1 @agent_state" idle
expect "a done agent is refused" "$DINGGER unread --pane $U2 2>&1" "dingger: it is unread already (done)"
report "$U3" working ""; expect "unr: u3 working" "opt $U3 @agent_state" working
expect "a working agent is refused" "$DINGGER unread --pane $U3 2>&1" \
"dingger: only an idle agent can be marked unread; this one is working"
expect "a pane without an agent is refused" "$DINGGER unread --pane $AWAY 2>&1" "dingger: no agent in pane $AWAY"
if command -v node >/dev/null; then
: >"$EXTLOG"; iext "$U1" session_start "/unread"
expect "/unread marks it" "opt $U1 @agent_state" done
expect "and says so" "grep -c '^notify marked unread: dingger shows it as done until you come back to it$' $EXTLOG" 1
# A held mark clears only once a tick sees the user away (a tick here
# takes ~0.3 s; two looks within one would never be seen apart).
look "$AWAY"; sleep 1; look "$U1"
expect "unr: seen again" "opt $U1 @agent_state" idle
: >"$EXTLOG"; iext "$U1" session_start agent_start "/unread"
expect "/unread while working is refused" "grep -c '^notify only an idle agent can be marked unread$' $EXTLOG" 1
expect "and changes nothing" "opt $U1 @agent_state" working
fi
# --- 23. agents on an old extension ------------------------------------------
# pi's extension reports its version and control socket; the daemon counts
# the agents on another version, which the menu offers to reload. (The
# reload itself, against a real pi: tests/pi-live.sh.)
echo "23. agents on an old extension"
ext_report() { # pane version control
python3 - "$HOOK" "$1" "$2" "$3" <<'PY'
import json, socket, sys
params = {"pane_id": sys.argv[2], "agent": "pi", "state": "idle", "extension": sys.argv[3], "control": sys.argv[4]}
s = socket.socket(socket.AF_UNIX)
s.connect(sys.argv[1])
s.sendall((json.dumps({"method": "pane.report_agent", "params": params}) + "\n").encode())
s.close()
PY
}
mkdir -p "$DIR/pi-current"
PI_CODING_AGENT_DIR="$DIR/pi-current" $DINGGER install pi >/dev/null
CURRENT=$(grep -o 'extensionVersion = "[0-9a-f]*"' "$DIR/pi-current/extensions/dingger-agent-state.ts" | cut -d'"' -f2)
t new-session -d -s ext -n e1 -c "$DIR" "$DIR/bin/pi"
E1=$(t display -p -t ext:e1 '#{pane_id}')
# The agents reported so far sent no version: an extension from before
# versions, which only a /reload by hand updates.
expect "ext: the others are by hand" "[ \"\$(gopt @dingger_outdated_by_hand)\" -gt 0 ] && echo yes" yes
BY_HAND=$(gopt @dingger_outdated_by_hand)
ext_report "$E1" 0000000000000000 "$DIR/pi-0.sock"
expect "ext: the version is on the pane" "opt $E1 @dingger_extension" 0000000000000000
expect "and its control socket" "opt $E1 @dingger_control" "$DIR/pi-0.sock"
expect "another version: reloadable" "gopt @dingger_outdated" 1
expect "the menu offers to reload it" "mb --print | grep '^⟳'" \
"$(printf '⟳ Reload 1 Agent on an Old Extension\teach when idle and off screen · %s more need /reload' "$BY_HAND")"
expect "reload-agents refuses without the current extension installed" "$DINGGER reload-agents 2>&1 | grep -c 'run .dingger install pi. first'" 1
ext_report "$E1" "$CURRENT" "$DIR/pi-1.sock"
expect "the current version: not counted" "gopt @dingger_outdated" 0
expect "the menu: only those by hand, not clickable" "mb --print | grep '^⟳'" \
"$(printf '⟳ %s Agents Need /reload\ttheir dingger extension is too old to reload them\t(disabled)' "$BY_HAND")"
t kill-session -t ext
# Every daemon this run started must stop with it.
kill "$DAEMON_PID" "$DAEMON2_PID" 2>/dev/null
expect "the test daemons stop" "ps -o pid= -p $DAEMON_PID,$DAEMON2_PID | wc -l | tr -d ' '" 0
echo
echo "$PASSES passed, $FAILS failed"
[ "$FAILS" -eq 0 ] || { echo "--- daemon log tail"; tail -30 "$DIR/daemon.log"; exit 1; }
|