-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbridge_test.go
More file actions
1616 lines (1483 loc) · 58 KB
/
Copy pathbridge_test.go
File metadata and controls
1616 lines (1483 loc) · 58 KB
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
package main
import (
"bytes"
"context"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
)
func TestExtractText(t *testing.T) {
if got := extractText(" hi "); got != "hi" {
t.Errorf("string content = %q, want hi", got)
}
content := []any{
map[string]any{"type": "text", "text": "line one"},
map[string]any{"type": "thinking", "thinking": "should be dropped"},
map[string]any{"type": "text", "text": "line two"},
}
if got := extractText(content); got != "line one\nline two" {
t.Errorf("array content = %q, want two joined text blocks (thinking dropped)", got)
}
if got := extractText(nil); got != "" {
t.Errorf("nil content = %q, want empty", got)
}
// Non-text-only content yields empty.
onlyThinking := []any{map[string]any{"type": "thinking", "thinking": "x"}}
if got := extractText(onlyThinking); got != "" {
t.Errorf("thinking-only content = %q, want empty", got)
}
}
func TestSplitCommand(t *testing.T) {
cases := []struct {
in string
name, arg string
}{
{"/new", "new", ""},
{"/model anthropic/claude", "model", "anthropic/claude"},
{"/think high", "think", "high"},
{"/COMPACT keep the api notes ", "compact", "keep the api notes"},
{"!new", "new", ""},
{"!session", "session", ""},
{"!model deepseek/", "model", "deepseek/"},
{"!", "", ""}, // bare prefix → empty name; handleCommand special-cases it
}
for _, c := range cases {
name, arg := splitCommand(c.in)
if name != c.name || arg != c.arg {
t.Errorf("splitCommand(%q) = (%q,%q), want (%q,%q)", c.in, name, arg, c.name, c.arg)
}
}
}
// TestBareBangIsAbort verifies a lone "!" maps to the abort path, not a
// literal prompt: splitCommand yields an empty name, which handleCommand's
// special case turns into "abort" (without it, the empty name falls through
// the default as text). Non-bang inputs are unmodified.
func TestBareBangIsAbort(t *testing.T) {
cases := []struct {
in string
name string
}{
{"!", "abort"}, // lone bang → handleCommand maps to abort
{"!!", "!"}, // two bangs → name "!", falls through as text
{"! abort", ""}, // space after bang → empty name, falls through
{"!abort", "abort"},
}
for _, c := range cases {
name, _ := splitCommand(c.in)
if c.in == "!" {
name = "abort" // the handleCommand special case under test
}
if name != c.name {
t.Errorf("command for %q → %q, want %q", c.in, name, c.name)
}
}
}
func TestCommaInt(t *testing.T) {
cases := map[int64]string{
0: "0",
999: "999",
1000: "1,000",
12345: "12,345",
1e9: "1,000,000,000",
}
for in, want := range cases {
if got := commaInt(in); got != want {
t.Errorf("commaInt(%d) = %q, want %q", in, got, want)
}
}
}
func TestMatchModel(t *testing.T) {
res := Event{"data": map[string]any{"models": []any{
map[string]any{"provider": "anthropic", "id": "claude-sonnet-5"},
map[string]any{"provider": "google", "id": "gemini-2.5-pro"},
}}}
provider, id, ok := matchModel(res, "sonnet")
if !ok || provider != "anthropic" || id != "claude-sonnet-5" {
t.Errorf("matchModel(sonnet) = (%q,%q,%v), want anthropic/claude-sonnet-5", provider, id, ok)
}
if _, _, ok := matchModel(res, "nonesuch"); ok {
t.Error("matchModel(nonesuch) matched unexpectedly")
}
}
func TestToolLabel(t *testing.T) {
cases := []struct {
name string
ev Event
want string
}{
{"bash with command", Event{"toolName": "bash", "args": map[string]any{"command": "npm test"}}, "! npm test"},
{"bash collapses whitespace", Event{"toolName": "bash", "args": map[string]any{"command": "go build\n./..."}}, "! go build ./..."},
{"non-bash tool", Event{"toolName": "read_file"}, "! read_file"},
{"missing name", Event{}, "running a tool…"},
{"bash no command", Event{"toolName": "bash"}, "! bash"},
}
for _, c := range cases {
if got := toolLabel(c.ev); got != c.want {
t.Errorf("%s: toolLabel = %q, want %q", c.name, got, c.want)
}
}
}
func TestTruncateLabel(t *testing.T) {
if got := truncateLabel("short", 40); got != "short" {
t.Errorf("short = %q, want unchanged", got)
}
long := "abcdefghij" // 10 runes
if got := truncateLabel(long, 5); got != "abcd…" {
t.Errorf("long = %q, want abcd…", got)
}
if got := truncateLabel("a\tb\nc d", 40); got != "a b c d" {
t.Errorf("whitespace = %q, want single-spaced", got)
}
}
func TestSplitReplySegments(t *testing.T) {
seg := func(dest, body string) replySegment {
return replySegment{dest: dest, body: body}
}
cases := []struct {
name string
in string
wantSegs []replySegment
wantLeading string
}{
{"single newline form", "to: room@muc.x\nhere are headlines",
[]replySegment{seg("room@muc.x", "here are headlines")}, ""},
{"no space after colon", "to:zach@x\nhi",
[]replySegment{seg("zach@x", "hi")}, ""},
{"inline body", "to: alice@x hello there",
[]replySegment{seg("alice@x", "hello there")}, ""},
{"two segments", "to: a@x.com\nblah blah\nto: b@x.com\nmore stuff",
[]replySegment{seg("a@x.com", "blah blah"), seg("b@x.com", "more stuff")}, ""},
{"multiline body per segment", "to: a@x\nl1\nl2\nto: b@x\nm1",
[]replySegment{seg("a@x", "l1\nl2"), seg("b@x", "m1")}, ""},
{"case insensitive", "TO: zach@x\nyo",
[]replySegment{seg("zach@x", "yo")}, ""},
{"leading junk before first to", "oops forgot\nto: a@x\nbody",
[]replySegment{seg("a@x", "body")}, "oops forgot"},
{"prose to: without @ is not a route", "to: whom it may concern\nhello",
nil, "to: whom it may concern\nhello"},
{"no routing at all", "just a reply", nil, "just a reply"},
}
for _, c := range cases {
gotSegs, gotLeading := splitReplySegments(c.in)
if gotLeading != c.wantLeading {
t.Errorf("%s: leading = %q, want %q", c.name, gotLeading, c.wantLeading)
}
if !reflect.DeepEqual(gotSegs, c.wantSegs) {
t.Errorf("%s: segs = %+v, want %+v", c.name, gotSegs, c.wantSegs)
}
}
}
func TestClassifyDest(t *testing.T) {
x := NewXMPPBridge(
ResolvedAccount{Rooms: []string{"team@muc.x"}, Owner: "zach@x"},
func(InboundMessage) {}, func(string, string) {},
)
x.occupants["team@muc.x"] = map[string]string{"alice": "alice@x"}
cases := []struct {
dest string
want destKind
}{
{"team@muc.x", destRoom},
{"team@muc.x/somenick", destRoom},
{"zach@x", destUser},
{"zach@x/phone", destUser},
{"alice@x", destUser},
{"stranger@x", destBlocked},
{"", destBlocked},
}
for _, c := range cases {
if got := x.classifyDest(c.dest); got != c.want {
t.Errorf("classifyDest(%q) = %d, want %d", c.dest, got, c.want)
}
}
}
// TestStreamTypingTarget pins the room-mode typing decision (issue #44): the
// indicator is withheld while the reply is still streaming / has not yet
// written a routing line, and once a completed "to:" line appears it points at
// that line's 1:1 recipient — or stays dark for a room, noop, or blocked target.
// delivers reports whether that line will emit a stanza (the presence-label
// upgrade from "muttering…" to "replying…").
func TestStreamTypingTarget(t *testing.T) {
x := NewXMPPBridge(
ResolvedAccount{Rooms: []string{"team@muc.x"}, Owner: "zach@x"},
func(InboundMessage) {}, func(string, string) {},
)
x.occupants["team@muc.x"] = map[string]string{"alice": "alice@x"}
cases := []struct {
buf string
target string
decided bool
delivers bool
}{
// Not yet a complete routing line → keep waiting.
{"", "", false, false},
{"to:", "", false, false},
{"to: zach", "", false, false},
{"to: zach@x", "", false, false},
// Owner 1:1 → indicator on the owner, delivers.
{"to: zach@x\n", "zach@x", true, true},
{"to: zach@x/phone\n", "zach@x", true, true},
// Known occupant → indicator on the occupant, delivers.
{"to: alice@x\n", "alice@x", true, true},
// A leading non-routing line is skipped; the routing still resolves.
{"sure\nto: zach@x\n", "zach@x", true, true},
// Room deliveries never light the owner's bubble but DO deliver.
{"to: team@muc.x\n", "", true, true},
// Noop and unknown targets send nothing and never light the bubble.
{"to: noop\n", "", true, false},
{"to: stranger@x\n", "", true, false},
{"to: zach@x\ncommentary only", "zach@x", true, true},
}
for _, c := range cases {
got, decided, delivers := streamTypingTarget(c.buf, x)
if got != c.target || decided != c.decided || delivers != c.delivers {
t.Errorf("streamTypingTarget(%q) = (%q,%v,%v), want (%q,%v,%v)",
c.buf, got, decided, delivers, c.target, c.decided, c.delivers)
}
}
}
// TestErrorRoomInvisibleToAgent verifies the write-only error room is NOT in
// roomBares (so dispatch ignores it) and is NOT an allowed reply/send
// destination — agents can't read it or route to it.
func TestErrorRoomInvisibleToAgent(t *testing.T) {
x := NewXMPPBridge(
ResolvedAccount{Rooms: []string{"team@muc.x"}, ErrorRoom: "errors@muc.x", Owner: "zach@x"},
func(InboundMessage) {}, func(string, string) {},
)
if x.isRoomJID("errors@muc.x") {
t.Error("error room must NOT be an agent-visible (dispatched) room")
}
if !x.isRoomJID("team@muc.x") {
t.Error("normal room should still be agent-visible")
}
if got := x.classifyDest("errors@muc.x"); got != destBlocked {
t.Errorf("error room should be blocked for replies, got %v", got)
}
if got := x.classifyDest("team@muc.x"); got != destRoom {
t.Errorf("normal room should remain an allowed destination, got %v", got)
}
}
func TestRoutingNudgeBound(t *testing.T) {
b := NewBridge(ResolvedAccount{}, false)
for i := 1; i <= maxRoutingNudges; i++ {
if !b.bumpRoutingNudge() {
t.Errorf("nudge %d should be allowed (cap %d)", i, maxRoutingNudges)
}
}
if b.bumpRoutingNudge() {
t.Error("nudge past the cap should be denied")
}
b.resetRoutingNudges()
if !b.bumpRoutingNudge() {
t.Error("after reset, a nudge should be allowed again")
}
}
// TestStagedNudgeLifecycle verifies issue #16's core flow: rejectReply stages
// a correction that is only fired at settle if a later message didn't route.
func TestStagedNudgeLifecycle(t *testing.T) {
b := NewBridge(ResolvedAccount{}, false)
// Nothing staged → nothing to fire.
if got := b.takeStagedNudge(); got != "" {
t.Errorf("empty staged nudge → got %q, want empty", got)
}
// Stage a correction (as rejectReply does), then a later message routes
// fine → the staged nudge is cleared and never fires.
b.stageNudge("dropped body", "no to: line")
b.clearPendingNudge()
if got := b.takeStagedNudge(); got != "" {
t.Errorf("staged nudge after clear → got %q, want empty", got)
}
// Stage a correction and fire at settle → reason fires exactly once.
b.stageNudge("dropped body", "no to: line")
if got := b.takeStagedNudge(); got != "no to: line" {
t.Errorf("settled nudge reason = %q, want %q", got, "no to: line")
}
if got := b.takeStagedNudge(); got != "" {
t.Errorf("staged nudge should fire once, got %q on second take", got)
}
// Later staging replaces earlier — only the final reason is nudged.
b.stageNudge("a", "reason one")
b.stageNudge("b", "reason two")
if got := b.takeStagedNudge(); got != "reason two" {
t.Errorf("latest staged reason = %q, want %q", got, "reason two")
}
}
// TestStagedNudgeRespectsBudget verifies the per-turn cap still bounds the
// settle-time reminder even with a single staging point.
func TestStagedNudgeRespectsBudget(t *testing.T) {
b := NewBridge(ResolvedAccount{}, false)
b.stageNudge("a", "r1")
b.stageNudge("b", "r2")
if got := b.takeStagedNudge(); got != "r2" {
t.Fatalf("first staged nudge = %q, want r2", got)
}
// Both staged nudges consumed the budget now; a fresh turn resets it.
b.resetRoutingNudges()
b.stageNudge("c", "r3")
if got := b.takeStagedNudge(); got != "r3" {
t.Errorf("post-reset staged nudge = %q, want r3", got)
}
}
// TestFirePendingNudgeReportsLaunch pins the banner-suppression contract: a
// settle that launches the routing nudge holds the "done (no reply)" banner,
// because the resend arrives moments later. firePendingNudge must report
// whether it actually launched so the caller can gate on it.
func TestFirePendingNudgeReportsLaunch(t *testing.T) {
b := roomBridge()
b.rpc = &RPCClient{} // fire-and-forget send to nowhere; avoids a nil deref
// Nothing staged → no nudge launches.
if b.firePendingNudge() {
t.Error("no staged nudge → firePendingNudge must report false")
}
// A staged correction → the nudge fires and is reported.
b.stageNudge("dropped body", "no to: line")
if !b.firePendingNudge() {
t.Error("staged nudge → firePendingNudge must report true")
}
// Consumed on fire → nothing left to launch.
if b.firePendingNudge() {
t.Error("after firing, no second nudge may launch")
}
}
func TestPrettyDump(t *testing.T) {
jsonl := strings.Join([]string{
`{"type":"session","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/proj"}`,
`{"type":"message","timestamp":"2024-12-03T14:00:01.000Z","message":{"role":"user","content":"fix the build"}}`,
`{"type":"message","timestamp":"2024-12-03T14:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"on it"},{"type":"toolCall","toolName":"bash"}]}}`,
`{"type":"message","timestamp":"2024-12-03T14:00:03.000Z","message":{"role":"toolResult","toolName":"bash","content":[{"type":"text","text":"exit 0"}]}}`,
`{"type":"model_change","timestamp":"2024-12-03T14:05:00.000Z","provider":"anthropic","modelId":"claude"}`,
}, "\n")
out := prettyDump([]byte(jsonl))
for _, want := range []string{
"TIME", "KIND", "DETAIL",
"14:00:01", "user", "fix the build",
"assistant", "on it ⚙ bash",
"toolResult", "↳ bash: exit 0",
"model", "anthropic/claude",
} {
if !strings.Contains(out, want) {
t.Errorf("prettyDump missing %q in:\n%s", want, out)
}
}
}
func TestReactionEmojis(t *testing.T) {
// Build the token stream directly (ASCII content) to exercise the reaction
// extraction logic without any note-of-tool mangling of embedded emoji or
// XML-in-string. The emoji round-trip itself is covered by the real XML
// decode path (see xmpp.go handle / reactionEmojis) and TestInboundReactionAck.
toks := []xml.Token{
xml.StartElement{Name: xml.Name{Local: "message"}},
xml.StartElement{Name: xml.Name{Local: "reactions", Space: reactionsNS}},
xml.StartElement{Name: xml.Name{Local: "reaction"}},
xml.CharData("ACK"),
xml.EndElement{Name: xml.Name{Local: "reaction"}},
xml.StartElement{Name: xml.Name{Local: "reaction"}},
xml.CharData("OK"),
xml.EndElement{Name: xml.Name{Local: "reaction"}},
xml.StartElement{Name: xml.Name{Local: "reaction"}},
xml.CharData(" "),
xml.EndElement{Name: xml.Name{Local: "reaction"}},
xml.EndElement{Name: xml.Name{Local: "reactions"}},
}
got := reactionEmojis(toks)
if len(got) != 2 || got[0] != "ACK" || got[1] != "OK" {
t.Errorf("reactionEmojis = %v, want [ACK OK]", got)
}
if got := reactionEmojis(nil); len(got) != 0 {
t.Errorf("reactionEmojis(nil) = %v, want empty", got)
}
}
func TestInboundReactionAck(t *testing.T) {
// Path 1: a run is in flight → the ack is buffered to ambient, not a wake.
b := roomBridge() // room-mode, owner zach@x.com
b.setStreaming(true)
b.onInbound(InboundMessage{
Nick: "peppy", Room: "team@muc.x.com",
From: "peppy@x.com/peppy", Reactions: []string{"\U0001FAE1"}, ReactionID: "target-123",
})
amb := b.drainAmbient()
if !strings.Contains(amb, "peppy") || !strings.Contains(amb, "\U0001FAE1") || !strings.Contains(amb, "XEP-0444") {
t.Errorf("streaming ack not buffered as ambient: %q", amb)
}
if b.reactionAckRun {
t.Error("streaming path should not set reactionAckRun")
}
// Path 2: idle → the ack wakes the agent (reactionAckRun set, turnDest = room).
b2 := roomBridge()
b2.rpc = &RPCClient{} // fire-and-forget send to nowhere; avoids a nil deref
b2.onInbound(InboundMessage{
Nick: "peppy", Room: "team@muc.x.com",
From: "peppy@x.com/peppy", Reactions: []string{"\U0001FAE1"}, ReactionID: "target-123",
})
if !b2.reactionAckRun {
t.Error("idle reaction should set reactionAckRun")
}
if b2.currentTurnDest() != "team@muc.x.com" {
t.Errorf("idle room reaction turnDest = %q, want room", b2.currentTurnDest())
}
if got := b2.drainAmbient(); got != "" {
t.Errorf("idle reaction should not buffer ambient: %q", got)
}
// Owner reacting on 1:1 renders as "owner" and turns to the owner.
b3 := NewBridge(ResolvedAccount{Owner: "zach@x.com", Nick: "pi"}, false)
b3.rpc = &RPCClient{}
b3.onInbound(InboundMessage{
Direct: true, FromOwner: true, From: "zach@x.com/res",
Reactions: []string{"\u2705"}, ReactionID: "out-1",
})
if !b3.reactionAckRun {
t.Error("owner 1:1 reaction should wake (set reactionAckRun)")
}
if b3.currentTurnDest() != "zach@x.com" {
t.Errorf("owner 1:1 reaction turnDest = %q, want owner", b3.currentTurnDest())
}
}
func TestIdleAwayClock(t *testing.T) {
b := roomBridge()
b.markActive()
if !b.idleSince.IsZero() {
t.Error("markActive should clear idleSince")
}
b.markIdle()
if b.idleSince.IsZero() {
t.Error("markIdle should set idleSince")
}
// An inbound message marks active again, even a non-canonical one.
b.markActive()
if !b.idleSince.IsZero() {
t.Error("markActive after activity should clear idleSince")
}
}
// TestInboundRearmsIdleClock guards against the "busy-room bot never goes
// away" regression: an inbound message that never becomes a run (ambient room
// chatter, buffered with no prompt) previously left idleSince cleared by
// markActive, and since agent_settled never fires for it, the idle watcher had
// no way to ever drift the agent back to "away". onInbound must re-arm the
// clock so a quiet stretch still produces an away transition.
func TestInboundRearmsIdleClock(t *testing.T) {
b := roomBridge()
b.idleSince = time.Time{} // e.g. just cleared by a prior markActive
b.awayAnnounced = false
// Ambient room message: not from the owner, not addressed to the bot →
// buffered, no run, no agent_settled.
b.onInbound(InboundMessage{
Nick: "falco", Room: "team@muc.x.com",
From: "falco@x.com/falco", Body: "some ambient chatter",
})
if b.idleSince.IsZero() {
t.Fatal("ambient inbound should re-arm the idle clock; zero idleSince = can never go away")
}
if elapsed := time.Since(b.idleSince); elapsed > time.Second {
t.Errorf("idleSince should be restarted to ~now, got %v old", elapsed)
}
if b.awayAnnounced {
t.Error("onInbound should leave awayAnnounced false so a fresh away can be announced")
}
}
// TestIdleTickNoSelfDeadlockWhileStreaming guards against a regression where
// idleTick held b.mu and then called streaming() (which itself locks b.mu),
// self-deadlocking the idle-watcher goroutine — and, since idleTick's handler
// runs in the same goroutine as the XMPP read loop for other callers of b.mu,
// wedging the whole bridge until a manual restart. A buggy idleTick would hang
// forever here instead of returning.
func TestIdleTickNoSelfDeadlockWhileStreaming(t *testing.T) {
b := roomBridge()
b.idleSince = time.Now().Add(-idleAwayTimeout - time.Minute)
b.streamingRun = true
done := make(chan struct{})
go func() {
b.idleTick()
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("idleTick deadlocked while a run was streaming")
}
}
func TestLoadAwayActivities(t *testing.T) {
b := roomBridge()
b.loadAwayActivities()
b.mu.Lock()
defer b.mu.Unlock()
if len(awayActivities) < 400 {
t.Errorf("embedded away-activities.txt parsed to %d entries, want >= 400", len(awayActivities))
}
for _, a := range awayActivities {
if a == "" {
t.Error("empty activity line in pool")
}
if len(a) > 90 {
t.Errorf("activity too long (%d): %q", len(a), a)
}
}
}
// TestToolRelayCarriesReason: a relayed tool action must come back as a string
// — "ok" on success, or the failure reason — not a bare boolean, so the model
// learns *why* something failed (issue #34: e.g. an upload rejected by the
// server as too large never reached the agent).
func TestToolRelayCarriesReason(t *testing.T) {
stdin := &nopClose{buf: &bytes.Buffer{}}
b := roomBridge()
b.rpc = &RPCClient{stdin: stdin, mu: sync.Mutex{}}
b.xmpp = &XMPPBridge{ownerBare: "zach@x.com"} // owner allowlisted; SendFile fails fast ("not online")
readLine := func(t *testing.T) map[string]any {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
if line, err := stdin.readString('\n'); err == nil {
var resp map[string]any
if err := json.Unmarshal([]byte(line), &resp); err != nil {
t.Fatalf("bad relay response line %q: %v", line, err)
}
return resp
}
if time.Now().After(deadline) {
t.Fatalf("no relay response within 2s (buf=%q)", stdin.contents())
}
time.Sleep(5 * time.Millisecond)
}
}
// Bad payload: the parse error is the reason.
b.handleToolRelay("r-bad", `{not json`)
resp := readLine(t)
if resp["id"] != "r-bad" {
t.Errorf("bad-payload response id = %v, want r-bad", resp["id"])
}
if v, _ := resp["value"].(string); !strings.Contains(v, "bad tool-relay payload") {
t.Errorf("bad-payload response value = %q, want a reason", v)
}
// Blocked destination: the allowlist refusal is the reason.
b.handleToolRelay("r-block", `{"action":"file","path":"/tmp/a.apk","to":"stranger@x.com"}`)
resp = readLine(t)
if v, _ := resp["value"].(string); !strings.Contains(v, "not an allowed destination") {
t.Errorf("blocked-dest response value = %q, want allowlist reason", v)
}
// Failed upload: SendFile's error text, not a plain false (issue #34).
b.handleToolRelay("r-file", `{"action":"file","path":"/tmp/a.apk","to":"zach@x.com"}`)
resp = readLine(t)
if v, _ := resp["value"].(string); !strings.Contains(v, "not online") {
t.Errorf("file-failure response value = %q, want SendFile's reason (not online)", v)
}
if _, hasConfirmed := resp["confirmed"]; hasConfirmed {
t.Errorf("file-failure response = %v, unexpected confirm-style boolean field", resp)
}
}
// TestToolRelaySuccessOk: a successful relay answers "ok", which the extension
// maps to a clean tool result (as opposed to a generic failure).
func TestToolRelaySuccessOk(t *testing.T) {
stdin := &nopClose{buf: &bytes.Buffer{}}
b := roomBridge()
b.rpc = &RPCClient{stdin: stdin, mu: sync.Mutex{}}
b.xmpp = &XMPPBridge{ownerBare: "zach@x.com"}
// The react path is synchronous and doesn't need a live session: a missing
// target is the only failure mode reachable here, so feed one and assert
// the reason names the missing stanza.
b.handleToolRelay("r-react", `{"action":"react","emoji":"✅","messageId":"nonexistent-1"}`)
deadline := time.Now().Add(2 * time.Second)
var line string
for {
if l, err := stdin.readString('\n'); err == nil {
line = l
break
}
if time.Now().After(deadline) {
t.Fatalf("no relay response within 2s (buf=%q)", stdin.contents())
}
time.Sleep(5 * time.Millisecond)
}
if !strings.Contains(line, "\"value\"") || !strings.Contains(line, "not found in message history") {
t.Errorf("react-miss response = %q, want a reason naming the missing target", line)
}
}
// nopClose adapts a bytes.Buffer to io.WriteCloser for RPCClient.stdin.
//
// The mutex is not decoration: the relay handler writes from its own goroutine
// while the test reads the same buffer, so both sides must go through the lock.
// Read the buffer with readString/contents, never through the wrapped buffer.
type nopClose struct {
mu sync.Mutex
buf *bytes.Buffer
}
func (n *nopClose) Write(p []byte) (int, error) {
n.mu.Lock()
defer n.mu.Unlock()
return n.buf.Write(p)
}
func (n *nopClose) Close() error { return nil }
// readString consumes one delimited line, mirroring bytes.Buffer.ReadString.
func (n *nopClose) readString(delim byte) (string, error) {
n.mu.Lock()
defer n.mu.Unlock()
return n.buf.ReadString(delim)
}
// contents returns everything still buffered, for failure messages.
func (n *nopClose) contents() string {
n.mu.Lock()
defer n.mu.Unlock()
return n.buf.String()
}
func TestOpenRouterCreditsParse(t *testing.T) {
// Build a throwaway HTTP server to avoid hitting the real endpoint.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer sk-or-test" {
t.Errorf("auth header = %q, want Bearer sk-or-test", got)
}
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"data":{"total_credits":85,"total_usage":81.11}}`)
}))
defer srv.Close()
orig := creditEndpoint
creditEndpoint = srv.URL
defer func() { creditEndpoint = orig }()
total, used, err := openRouterCredits("sk-or-test")
if err != nil {
t.Fatalf("openRouterCredits: %v", err)
}
if total != 85 || used != 81.11 {
t.Fatalf("got total=%v used=%v, want 85 / 81.11", total, used)
}
}
// newTestBridge builds an offline bridge: sends return "" (not online), which
// is exactly the "nothing reached a destination" case deliverReply must report.
func newTestBridge(acct ResolvedAccount) *Bridge {
b := NewBridge(acct, false)
b.xmpp = NewXMPPBridge(acct, func(InboundMessage) {}, b.log)
return b
}
// TestRepliedOnlyOnDelivery pins the core of the dropped-reply fix: "replied"
// must mean "reached a destination", not "text existed". A malformed reply goes
// to the write-only error room, which the owner never reads, so counting it as
// a reply would suppress the settle-time banner and leave the owner in silence.
func TestRepliedOnlyOnDelivery(t *testing.T) {
room := ResolvedAccount{Rooms: []string{"team@muc.x"}, ErrorRoom: "errors@muc.x", Owner: "zach@x"}
b := newTestBridge(room)
if b.deliverReply("no routing line here") {
t.Error("a reply with no \"to:\" line must not count as delivered")
}
if b.deliverReply("to: errors@muc.x\n\nsneaky") {
t.Error("a blocked destination must not count as delivered")
}
// Offline: a well-formed reply still can't reach anyone.
if b.deliverReply("to: zach@x\n\nhello") {
t.Error("an offline send must not count as delivered")
}
// Pure 1:1 accounts take the other branch; offline is still not delivered.
solo := newTestBridge(ResolvedAccount{Owner: "zach@x"})
if solo.deliverReply("hello") {
t.Error("an offline 1:1 send must not count as delivered")
}
}
// TestNoopStillCountsAsReplied verifies deliberate silence stays an answer:
// "to: noop" emits no stanza but must never look like a run that died before
// writing a reply, or the empty-tail recovery would argue with it.
func TestNoopStillCountsAsReplied(t *testing.T) {
b := newTestBridge(ResolvedAccount{Rooms: []string{"team@muc.x"}, Owner: "zach@x"})
if !b.deliverReply("to: noop\n\nnothing to add") {
t.Error("to: noop must count as delivered")
}
if !b.replied() {
t.Error("to: noop must set replied")
}
}
// TestPreambleDoesNotDisarmNoReplyNet covers the failure that dropped replies
// live: the agent writes a preamble alongside its tool call, the tool returns,
// and the run ends with no further text. The delivered preamble used to mark
// the run as answered, so no banner and no retry fired — the owner just got a
// "running…" line and then silence.
func TestPreambleDoesNotDisarmNoReplyNet(t *testing.T) {
b := newTestBridge(ResolvedAccount{Rooms: []string{"team@muc.x"}, Owner: "zach@x"})
b.resetTailTracking()
// Preamble message: text delivered, then the tool starts.
b.setFinalMsgHadText(true)
b.clearToolSinceDelivery()
b.markToolSinceDelivery()
// The tool result arrives and the run ends with a tool-only message.
b.setFinalMsgHadText(false)
if !b.needsEmptyTailRecovery() {
t.Error("a run ending on a tool call after a preamble needs recovery")
}
// The winning shape: the reply text comes after the tool result.
b.setFinalMsgHadText(true)
b.clearToolSinceDelivery()
if b.needsEmptyTailRecovery() {
t.Error("a run that replied after its tool needs no recovery")
}
}
// TestNoRecoveryWithoutTool verifies a run that never ran a tool is left alone:
// an empty final message there is the model saying nothing, which the existing
// "done (no reply)" banner already covers.
func TestNoRecoveryWithoutTool(t *testing.T) {
b := newTestBridge(ResolvedAccount{Owner: "zach@x"})
b.resetTailTracking()
if b.needsEmptyTailRecovery() {
t.Error("no tool ran — recovery must not fire")
}
// Volunteer and reaction-ack runs are allowed to end quietly even mid-work.
b.markToolSinceDelivery()
b.volunteered = true
if b.needsEmptyTailRecovery() {
t.Error("a volunteer run must not trigger recovery")
}
b.volunteered = false
b.reactionAckRun = true
if b.needsEmptyTailRecovery() {
t.Error("a reaction-ack run must not trigger recovery")
}
}
// TestEmptyTailRecoveryBounded verifies the retry can't loop against a model
// that keeps ending its runs on a tool call: one prompt per user turn, then the
// banner takes over and tells the owner nothing came back.
func TestEmptyTailRecoveryBounded(t *testing.T) {
b := NewBridge(ResolvedAccount{}, false)
for i := 1; i <= maxTailNudges; i++ {
if !b.bumpTailNudge() {
t.Errorf("recovery %d should be allowed (cap %d)", i, maxTailNudges)
}
}
if b.bumpTailNudge() {
t.Error("recovery past the cap should be denied")
}
b.resetTailNudges()
if !b.bumpTailNudge() {
t.Error("after a fresh user turn, recovery should be allowed again")
}
}
// TestSettleLocallyClearsTailTracking verifies an aborted or replaced run can't
// leave state behind that fires a recovery prompt for cancelled work.
func TestSettleLocallyClearsTailTracking(t *testing.T) {
b := newTestBridge(ResolvedAccount{Owner: "zach@x"})
b.markToolSinceDelivery()
b.setFinalMsgHadText(false)
if !b.needsEmptyTailRecovery() {
t.Fatal("precondition: mid-work run should need recovery")
}
b.settleLocally()
if b.needsEmptyTailRecovery() {
t.Error("settleLocally must clear the empty-tail bookkeeping")
}
}
// TestUnansweredRunCounts covers the steer drop seen live: five messages went
// into one run and only the last one got an answer, because pi injects each
// queued message the instant a tool yields and the model moves on to it.
func TestUnansweredRunCounts(t *testing.T) {
b := newTestBridge(ResolvedAccount{Owner: "zach@x"})
// The live A–E burst: 5 messages in, 1 reply out.
for range 5 {
b.countInbound("zach", "", "hello")
}
b.recordDelivery("id-r", "answered")
in, out, ok := b.unansweredRun()
if !ok || in != 5 || out != 1 {
t.Errorf("A–E burst = (%d,%d,%v), want (5,1,true)", in, out, ok)
}
// Every message answered → silent.
b.resetRunCounts()
for range 3 {
b.countInbound("zach", "", "hello")
b.recordDelivery("id-r", "answered")
}
if _, _, ok := b.unansweredRun(); ok {
t.Error("a run that answered every message must not hint")
}
// A single unanswered message is the empty-tail case, not this one: the
// "done (no reply)" banner and the tail recovery already cover it.
b.resetRunCounts()
b.countInbound("zach", "", "hello")
if _, _, ok := b.unansweredRun(); ok {
t.Error("a single-message run must not hint")
}
// Volunteer and reaction-ack runs stay quiet even when unbalanced.
b.resetRunCounts()
b.countInbound("zach", "", "hello")
b.countInbound("zach", "", "hello")
b.volunteered = true
if _, _, ok := b.unansweredRun(); ok {
t.Error("a volunteer run must not hint")
}
b.volunteered = false
b.reactionAckRun = true
if _, _, ok := b.unansweredRun(); ok {
t.Error("a reaction-ack run must not hint")
}
}
// TestUnansweredHintBounded verifies the hint can't loop: one per user turn,
// refilled when the next message arrives.
func TestUnansweredHintBounded(t *testing.T) {
b := NewBridge(ResolvedAccount{}, false)
for i := 1; i <= maxHintNudges; i++ {
if !b.bumpHintNudge() {
t.Errorf("hint %d should be allowed (cap %d)", i, maxHintNudges)
}
}
if b.bumpHintNudge() {
t.Error("hint past the cap should be denied")
}
b.resetHintNudges()
if !b.bumpHintNudge() {
t.Error("after a fresh user turn, a hint should be allowed again")
}
}
// TestNoopCountsTowardAnsweredRun verifies deliberate silence balances the
// tally, so a run the agent answered with "to: noop" is never nagged.
func TestNoopCountsTowardAnsweredRun(t *testing.T) {
b := newTestBridge(ResolvedAccount{Rooms: []string{"team@muc.x"}, Owner: "zach@x"})
b.countInbound("zach", "", "hello")
b.countInbound("zach", "", "hello")
if !b.deliverReply("to: zach@x\n\nanswered one") {
// Offline, so this send reports undelivered; count it by hand to model
// the online case.
b.recordDelivery("id-r", "answered")
}
if b.deliverReply("to: noop\n\nnothing more to add") {
b.recordDelivery("id-r", "answered")
}
if _, _, ok := b.unansweredRun(); ok {
t.Error("a run answered with a reply plus to: noop must not hint")
}
}
// TestNoopWorksInOneToOne pins that a pure 1:1 account can decline to speak.
// It has no routing contract, but "to: noop" is how the agent says "nothing to
// send" — without it a deliberate silence looks like a reply that went missing,
// and the bridge argues with it.
func TestNoopWorksInOneToOne(t *testing.T) {
b := newTestBridge(ResolvedAccount{Owner: "zach@x"})
if !b.deliverReply("to: noop\n\nnothing more to add") {
t.Error("a 1:1 \"to: noop\" must count as an answer")
}
if !b.replied() {
t.Error("a 1:1 \"to: noop\" must mark the run as replied")
}
if _, out, _ := b.unansweredRun(); out != 1 {
t.Errorf("deliveries = %d, want 1", out)
}
// Only the FIRST non-empty line routes. Prose that merely mentions the form
// is an ordinary reply, and offline it reaches nobody.
b.resetRunCounts()
if b.deliverReply("Sure.\nto: noop") {
t.Error("a \"to: noop\" after the first line must not be a route")
}
if leadingNoop("to be fair, noop is a word") {
t.Error("prose beginning with \"to\" must not be a route")
}
}
// TestFanOutCountsEachSegment pins that one reply answering several messages is
// counted as several answers. Counting per assistant message made a run that
// answered everything look unbalanced, and the unanswered-message hint then
// fired for work that was already done.
func TestFanOutCountsEachSegment(t *testing.T) {
b := newTestBridge(ResolvedAccount{Rooms: []string{"team@muc.x"}, Owner: "zach@x"})
b.countInbound("zach", "id-a", "first question")
b.countInbound("zach", "id-b", "second question")
// Two segments in ONE message. "to: noop" is used because an offline send
// reaches nobody and so is not an answer.
if !b.deliverReply("to: noop\n\nanswer to A\nto: noop\n\nanswer to B") {
t.Fatal("precondition: a noop segment must deliver")
}
if _, out, _ := b.unansweredRun(); out != 2 {
t.Errorf("deliveries = %d, want 2 (one per \"to:\" segment)", out)
}
if _, _, ok := b.unansweredRun(); ok {
t.Error("a run that answered both messages in one reply must not hint")
}
}
// TestSettleClearsRunCounts verifies an aborted run can't carry its tally into
// the next one and fire a hint for cancelled work.
func TestSettleClearsRunCounts(t *testing.T) {
b := newTestBridge(ResolvedAccount{Owner: "zach@x"})
b.countInbound("zach", "", "hello")
b.countInbound("zach", "", "hello")
if _, _, ok := b.unansweredRun(); !ok {
t.Fatal("precondition: 2 in / 0 out should hint")
}
b.settleLocally()
if _, _, ok := b.unansweredRun(); ok {
t.Error("settleLocally must clear the run tally")
}
}
// TestHintNotRepeatedOnTheCatchUpRun verifies the run that answers a hint is
// never hinted about in turn. The agent has just been told to catch up, so
// whatever it sends IS the catch-up — asking again would have it check its own
// correction.
func TestHintNotRepeatedOnTheCatchUpRun(t *testing.T) {
b := newTestBridge(ResolvedAccount{Owner: "zach@x"})
b.markHintPending() // as fireUnansweredHint does
// The catch-up run: even an unbalanced tally must not hint again.
b.countInbound("zach", "", "hello")
b.countInbound("zach", "", "hello")
b.countInbound("zach", "", "hello")
if !b.takeHintPending() {
t.Fatal("the run after a hint must be marked as the catch-up run")
}
// The mark is one-shot: the run after the catch-up is judged normally.
if b.takeHintPending() {
t.Error("the catch-up mark must clear after one settle")
}
if _, _, ok := b.unansweredRun(); !ok {
t.Error("a later unbalanced run should hint again")
}
}
// TestAbortClearsHintPending verifies an aborted run drops the catch-up mark:
// no catch-up is coming, so the next real run must be judged on its own tally.
func TestAbortClearsHintPending(t *testing.T) {
b := newTestBridge(ResolvedAccount{Owner: "zach@x"})
b.markHintPending()
b.settleLocally()
if b.takeHintPending() {