aboutsummaryrefslogtreecommitdiff
path: root/tests/e2e.sh
blob: e73dc73e049dfb7922fb7953fd26d793153c26b0 (plain)
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
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
#!/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. The input box holds
// $EDITOR_TEXT; the session file is $SESSION_FILE.
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");
let editor = process.env.EDITOR_TEXT ?? "";
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: () => process.env.SESSION_FILE },
  ui: {
    setStatus: (key, text) => log(`status ${key} ${text ?? ""}`),
    notify: (text) => log(`notify ${text}`),
    getEditorText: () => editor,
    setEditorText: (text) => { editor = text; log(`editor ${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

# --- 24. forgotten drafts -----------------------------------------------------
# pi's extension reports the draft in its input box; dingger marks it where
# the user is not looking, and keeps it in a file per session that comes back
# with the session. (Typing into a real pi: tests/pi-live.sh.)
echo "24. forgotten drafts"
draft_report() { # pane draft
  python3 - "$HOOK" "$1" "$2" <<'PY'
import json, socket, sys
params = {"pane_id": sys.argv[2], "agent": "pi", "draft": sys.argv[3]}
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 dr -n d1 -c "$DIR" "$DIR/bin/pi"
t new-window -d -t dr: -n d2 -c "$DIR" "$DIR/bin/pi"
t new-window -d -t dr: -n away -c "$DIR" "sleep 100000"
D1=$(t display -p -t dr:d1 '#{pane_id}'); D2=$(t display -p -t dr:d2 '#{pane_id}')
look "$(t display -p -t dr:away '#{pane_id}')"
for p in "$D1" "$D2"; do report "$p" working ""; report "$p" idle ""; expect "dr: $p done" "opt $p @agent_state" done; done
look "$D1"; sleep 1; look "$D2"; sleep 1; look "$(t display -p -t dr:away '#{pane_id}')"
expect "dr: both idle" "opt $D1 @agent_state; opt $D2 @agent_state" "idle
idle"
draft_report "$D2" "$(printf 'fix the\n  sidecar;')"
expect "a draft, as one line, its trailing ; kept" "opt $D2 @dingger_draft" "fix the sidecar;"
expect "pane options after it still written" "opt $D2 @agent_state" idle
drow() { mb --print | grep -F "dr · $1 ·" | cut -f2; }
expect "menu: marked ✎" "drow d2" "✎ pi"
expect "first among idle agents" "mb --print | grep -F 'dr · d' | cut -f3 | cut -d' ' -f3 | tr '\n' ' '" "d2 d1 "
expect "search finds it by its draft" "mb --print-search sidecar | head -1 | cut -f3" "dr · d2 · pi"
expect "dingger status marks it" "$DINGGER status | grep -F 'dr · d2' | grep -c '✎ '" 1
look "$D2"
expect "where you look, no mark: you may be typing it" "drow d2" "pi"
expect "nor in dingger status" "$DINGGER status | grep -F 'dr · d2' | grep -c '✎ '" 0
look "$D1"
draft_report "$D2" ""
expect "a cleared draft: gone" "opt $D2 @dingger_draft" ""
expect "menu: no mark" "drow d2" "pi"
draft_report "$D2" ";"
expect "a draft of just ;" "opt $D2 @dingger_draft" ";"
report "$D2" working ""
expect "a report without a draft keeps it" "opt $D2 @dingger_draft" ";"
if command -v node >/dev/null; then
  DRAFTS="$(dirname "$HOOK")/drafts"
  EDITOR_TEXT="half a thought" SESSION_FILE="$DIR/sessions/s1.jsonl" iext "$D1" session_start
  expect "the extension reports its draft" "opt $D1 @dingger_draft" "half a thought"
  expect "and keeps it for the session" "cat $DRAFTS/s1.jsonl.draft" "half a thought"
  : >"$EXTLOG"; SESSION_FILE="$DIR/sessions/s1.jsonl" iext "$D1" session_start
  expect "the session again: its draft back in the input box" "grep '^editor' $EXTLOG" "editor half a thought"
  expect "and reported" "opt $D1 @dingger_draft" "half a thought"
  : >"$EXTLOG"; EDITOR_TEXT="typed meanwhile" SESSION_FILE="$DIR/sessions/s1.jsonl" iext "$D1" session_start
  expect "an input box with text keeps it" "grep -c '^editor' $EXTLOG" 0
  expect "and that is the draft now" "cat $DRAFTS/s1.jsonl.draft" "typed meanwhile"
  : >"$EXTLOG"; SESSION_FILE="$DIR/sessions/s2.jsonl" iext "$D1" session_start
  expect "another session: none of it" "grep -c '^editor' $EXTLOG" 0
  expect "dr: no draft reported" "opt $D1 @dingger_draft" ""
fi
t kill-session -t dr

# 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; }