16 "github.com/stretchr/testify/require"
19 func TestNotificationWaitsForTheNextTurnAfterEveryActiveToolRequest(t *testing.T) {
20 server := newModelFake(t)
21 arrived := make(chan struct{})
22 release := make(chan struct{})
23 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
27 modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "date", `{"id":0}`)}, 100, 0, 5))
32 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
35 test := newDaemonMessageTest(t, t.TempDir(), server)
36 test.Start(t, 0, 1, 2)
38 _, err := test.loops[0].Submit(terminalSource, "Start a tool chain.")
39 require.NoError(t, err)
42 before := test.agents[0].memory.Len()
43 input := &NotifyInput{
46 Text: "Unique queued external data.",
49 receipt, err := test.messages.Notify(input)
50 require.NoError(t, err)
51 require.Equal(t, "accepted", receipt.Status)
52 require.Equal(t, before, test.agents[0].memory.Len())
53 require.True(t, test.loops[0].Busy())
55 test.events[0].WaitFor(t, "idle", 2)
56 require.Equal(t, 3, server.Calls())
57 require.NotContains(t, strings.Join(turnInputTexts(t, server.Call(1)), "\n"), input.Text)
58 require.Contains(t, strings.Join(turnInputTexts(t, server.Call(2)), "\n"), input.Text)
59 require.Equal(t, notifySource, test.events[0].LastEvent("work").Source)
60 require.Empty(t, test.Queue(t).Pending)
61 require.Len(t, test.Queue(t).Notifications, 1)
62 require.Zero(t, test.agents[1].memory.Len())
63 require.Zero(t, test.agents[2].memory.Len())
64 require.NotContains(t, test.logs.String(), input.Source)
65 require.NotContains(t, test.logs.String(), input.Text)
66 require.NotContains(t, test.logs.String(), input.ID)
69 func TestNotificationRetriesSurviveRestartBeforeAndAfterDelivery(t *testing.T) {
70 directory := t.TempDir()
71 server := newModelFake(t)
72 server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
73 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
76 input := &NotifyInput{
79 Text: "A new result.",
82 first := newDaemonMessageTest(t, directory, server)
83 receipt, err := first.messages.Notify(input)
84 require.NoError(t, err)
85 require.Zero(t, first.agents[0].memory.Len())
88 second := newDaemonMessageTest(t, directory, server)
89 duplicate, err := second.messages.Notify(input)
90 require.NoError(t, err)
91 require.Equal(t, receipt.ID, duplicate.ID)
92 require.Equal(t, "duplicate", duplicate.Status)
93 require.Zero(t, second.agents[0].memory.Len())
95 second.events[0].WaitFor(t, "idle", 1)
96 require.Empty(t, second.Queue(t).Pending)
99 third := newDaemonMessageTest(t, directory, server)
100 duplicate, err = third.messages.Notify(input)
101 require.NoError(t, err)
102 require.Equal(t, "duplicate", duplicate.Status)
103 require.Equal(t, receipt.ID, duplicate.ID)
104 require.False(t, third.loops[0].waiting())
105 require.Equal(t, 1, server.Calls())
108 changed.Text = "A different result."
109 _, err = third.messages.Notify(&changed)
110 require.ErrorContains(t, err, "conflicts")
111 require.Len(t, third.Queue(t).Notifications, 1)
114 func TestNotificationAcceptanceIsSerializedForConcurrentRetries(t *testing.T) {
115 server := newModelFake(t)
116 test := newDaemonMessageTest(t, t.TempDir(), server)
117 input := &NotifyInput{
123 results := make(chan Event, 20)
124 failures := make(chan error, 20)
126 var workers sync.WaitGroup
134 receipt, err := test.messages.Notify(input)
146 for receipt := range results {
147 require.Equal(t, 1, receipt.ID)
149 if receipt.Status == "accepted" {
152 require.Equal(t, "duplicate", receipt.Status)
156 for err := range failures {
157 require.NoError(t, err)
160 require.Equal(t, 1, accepted)
161 require.Len(t, test.Queue(t).Pending, 1)
162 require.Len(t, test.Queue(t).Notifications, 1)
165 func TestNotificationStorageFailureReturnsNoAcceptedReceipt(t *testing.T) {
166 server := newModelFake(t)
167 test := newDaemonMessageTest(t, t.TempDir(), server)
168 test.messages.path = filepath.Join(t.TempDir(), "missing", "messages.json")
169 receipt, err := test.messages.Notify(&NotifyInput{
174 require.ErrorContains(t, err, "accept external event")
175 require.Empty(t, receipt.Kind)
176 require.False(t, test.loops[0].waiting())
177 require.Zero(t, test.agents[0].memory.Len())
179 _, err = test.messages.Notify(&NotifyInput{
182 Text: "Another result.",
184 require.ErrorContains(t, err, "queue stopped")
187 func TestNotificationFullStoreRejectsNewKeysWithoutRemovingReceipts(t *testing.T) {
188 server := newModelFake(t)
189 test := newDaemonMessageTest(t, t.TempDir(), server)
191 for number := range notifyReceipts {
192 test.messages.state.Notifications = append(test.messages.state.Notifications, NotifyReceipt{
194 ID: fmt.Sprint(number),
195 Digest: notifyDigest("data"),
200 test.messages.state.Next = notifyReceipts
201 receipt, err := test.messages.Notify(&NotifyInput{
206 require.ErrorContains(t, err, "store is full")
207 require.Empty(t, receipt.Kind)
208 require.Len(t, test.messages.state.Notifications, notifyReceipts)
209 require.Empty(t, test.messages.state.Pending)
211 receipt, err = test.messages.Notify(&NotifyInput{
216 require.NoError(t, err)
217 require.Equal(t, "duplicate", receipt.Status)
220 func TestNotificationBatchesKeepAcceptanceOrder(t *testing.T) {
221 server := newModelFake(t)
222 server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
223 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
226 test := newDaemonMessageTest(t, t.TempDir(), server)
227 test.agents[0].memory.summarizer = newFakeSummarizer()
228 for number := range 21 {
229 _, err := test.messages.Notify(&NotifyInput{
231 ID: fmt.Sprint(number),
232 Text: fmt.Sprintf("result-%02d", number),
234 require.NoError(t, err)
238 test.events[0].WaitFor(t, "idle", 2)
239 require.Equal(t, 2, server.Calls())
241 first := turnInputTexts(t, server.Call(0))
242 require.Len(t, first, 22)
244 for number := range 20 {
245 require.Contains(t, first[number+2], fmt.Sprintf("result-%02d", number))
248 second := turnInputTexts(t, server.Call(1))
249 require.Contains(t, second[len(second)-1], "result-20")
250 require.Empty(t, test.Queue(t).Pending)
253 func TestNotificationRecoversItsPreparedLogIDWithoutAnotherAppend(t *testing.T) {
254 testCases := []struct {
259 name: "recovers a saved ID before the log append",
262 name: "reuses the log entry after the append",
267 for _, tc := range testCases {
268 t.Run(tc.name, func(t *testing.T) {
269 directory := t.TempDir()
270 server := newModelFake(t)
271 server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
272 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
275 first := newDaemonMessageTest(t, directory, server)
276 _, err := first.messages.Notify(&NotifyInput{
281 require.NoError(t, err)
284 require.NoError(t, first.messages.Notifications(first.loops[0]))
287 first.messages.state.Pending[0].LogID = &id
288 require.NoError(t, first.messages.save())
293 second := newDaemonMessageTest(t, directory, server)
295 second.events[0].WaitFor(t, "idle", 1)
296 require.Equal(t, 1, server.Calls())
297 require.Equal(t, 1, second.agents[0].memory.Len())
298 require.Empty(t, second.Queue(t).Pending)
303 func TestPreparedNotificationKeepsItsLogIDBeforeStartupWriters(t *testing.T) {
304 testCases := []struct {
310 name: "keeps its reservation before a startup user message",
313 name: "keeps its reservation before a worker recovery note",
317 name: "keeps a child reservation before startup input",
321 name: "keeps a child reservation before a worker recovery note",
327 for _, tc := range testCases {
328 t.Run(tc.name, func(t *testing.T) {
334 directory := t.TempDir()
335 server := newModelFake(t)
336 server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
337 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
340 first := newDaemonMessageTest(t, directory, server)
341 input := &NotifyInput{
342 AgentID: first.agents[index].agent.config.ID,
345 Text: "A result with a prepared log ID.",
348 receipt, err := first.messages.Notify(input)
349 require.NoError(t, err)
352 first.messages.state.Pending[0].LogID = &id
353 require.NoError(t, first.messages.save())
355 expected := first.Queue(t).Pending[0].Text
358 second := newDaemonMessageTest(t, directory, server)
359 require.False(t, second.loops[index].waiting())
360 require.Empty(t, second.events[index].Kinds())
363 path := filepath.Join(second.agents[index].agent.dir, subagentWorkFileName)
364 require.NoError(t, os.WriteFile(path, []byte(`{"running":["BlueFox"]}`), 0o600))
365 require.NoError(t, second.loops[index].runs.Resume(t.Context()))
367 note, err := second.agents[index].memory.Message(1)
368 require.NoError(t, err)
369 require.Equal(t, "note", note.Kind)
370 require.Contains(t, note.Text, "The daemon restarted, cutting BlueFox")
372 submitted, err := second.loops[index].Submit(terminalSource, "A user message during startup.")
373 require.NoError(t, err)
374 require.Equal(t, 1, submitted.ID)
377 restored, err := second.agents[index].memory.Message(id)
378 require.NoError(t, err)
379 require.Equal(t, "work", restored.Kind)
380 require.Equal(t, expected, restored.Text)
381 require.Len(t, second.Queue(t).Pending, 1)
383 second.Start(t, index)
384 second.events[index].WaitFor(t, "idle", 1)
385 require.Empty(t, second.Queue(t).Pending)
386 require.Equal(t, 1, server.Calls())
387 require.Equal(t, id, second.events[index].LastEvent("work").ID)
389 duplicate, err := second.messages.Notify(input)
390 require.NoError(t, err)
391 require.Equal(t, "duplicate", duplicate.Status)
392 require.Equal(t, receipt.ID, duplicate.ID)
393 require.Len(t, second.Queue(t).Notifications, 1)
398 func TestNotificationBatchMakesProgressWithEscapedControlCharacters(t *testing.T) {
399 server := newModelFake(t)
400 server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
401 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
404 test := newDaemonMessageTest(t, t.TempDir(), server)
405 _, err := test.messages.Notify(&NotifyInput{
408 Text: "data" + strings.Repeat("\x00", notifyTextBytes-4),
410 require.NoError(t, err)
411 require.Greater(t, len(test.Queue(t).Pending[0].Text), 64<<10)
413 test.events[0].WaitFor(t, "idle", 1)
414 require.Empty(t, test.Queue(t).Pending)
417 func TestNotificationSelectorsBindOnePermanentIdentity(t *testing.T) {
418 server := newModelFake(t)
419 test := newDaemonMessageTest(t, t.TempDir(), server)
420 input := &NotifyInput{
421 AgentID: test.agents[1].agent.config.ID,
424 Text: "A result for one child.",
427 accepted, err := test.messages.Notify(input)
428 require.NoError(t, err)
430 queue := test.Queue(t)
431 require.Equal(t, test.agents[1].agent.config.ID, queue.Pending[0].TargetID)
432 require.Equal(t, queue.Pending[0].TargetID, queue.Notifications[0].TargetID)
433 require.Equal(t, "Garden", queue.Pending[0].Target)
434 require.Zero(t, test.agents[0].memory.Len())
435 require.Zero(t, test.agents[1].memory.Len())
436 require.Zero(t, test.agents[2].memory.Len())
438 input.AgentID = strings.ToUpper(test.agents[1].agent.config.ID)
439 duplicate, err := test.messages.Notify(input)
440 require.NoError(t, err)
441 require.Equal(t, accepted.ID, duplicate.ID)
442 require.Equal(t, "duplicate", duplicate.Status)
444 input.AgentID = test.agents[2].agent.config.ID
445 _, err = test.messages.Notify(input)
446 require.ErrorContains(t, err, "conflicts")
449 _, err = test.messages.Notify(input)
450 require.ErrorContains(t, err, "conflicts")
451 require.Equal(t, queue, test.Queue(t))
454 func TestNotificationRejectsUnknownWorkerAndMalformedIdentities(t *testing.T) {
455 server := newModelFake(t)
456 test := newDaemonMessageTest(t, t.TempDir(), server)
457 test.loops[0].runs.subagents.draw = func() string {
461 worker, err := test.loops[0].runs.subagents.Create(t.Context())
462 require.NoError(t, err)
464 testCases := []NotifyInput{
465 {AgentID: worker.name},
466 {AgentID: "Missing"},
468 {AgentID: "../Garden"},
469 {AgentID: strings.Repeat("f", 2*agentIDBytes)},
470 {AgentID: "invalid"},
473 for _, tc := range testCases {
476 tc.Text = "Unchanged data."
477 receipt, err := test.messages.Notify(&tc)
478 require.Error(t, err)
479 require.Empty(t, receipt.Kind)
482 require.Empty(t, test.messages.state.Pending)
483 require.Empty(t, test.messages.state.Notifications)
484 require.Zero(t, server.Calls())
487 func TestChildNotificationWaitsForTheWholeActiveChain(t *testing.T) {
488 server := newModelFake(t)
489 arrived := make(chan struct{})
490 release := make(chan struct{})
491 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
492 if call.number == 0 {
495 modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "date", `{"id":0}`)}, 100, 0, 5))
500 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
503 test := newDaemonMessageTest(t, t.TempDir(), server)
504 test.Start(t, 0, 1, 2)
505 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Start the first chain."))
508 _, err := test.messages.Notify(&NotifyInput{
509 AgentID: test.agents[1].agent.config.ID,
512 Text: "Only the next child turn sees this.",
514 require.NoError(t, err)
515 require.Equal(t, 1, test.agents[1].memory.Len())
517 test.events[1].WaitFor(t, "idle", 2)
518 require.Equal(t, 3, server.Calls())
519 require.NotContains(t, strings.Join(turnInputTexts(t, server.Call(1)), "\n"), "Only the next child turn")
520 require.Contains(t, strings.Join(turnInputTexts(t, server.Call(2)), "\n"), "Only the next child turn")
521 require.Equal(t, notifySource, test.events[1].LastEvent("work").Source)
522 require.Zero(t, test.agents[0].memory.Len())
523 require.Zero(t, test.agents[2].memory.Len())
524 require.Empty(t, test.Queue(t).Pending)
527 func TestNotificationStableIdentitySurvivesOfflineNameChange(t *testing.T) {
528 directory := t.TempDir()
529 server := newModelFake(t)
530 server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
531 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
534 first := newDaemonMessageTest(t, directory, server)
535 input := &NotifyInput{
536 AgentID: first.agents[1].agent.config.ID,
539 Text: "A saved child result.",
542 receipt, err := first.messages.Notify(input)
543 require.NoError(t, err)
546 first.messages.state.Pending[0].LogID = &id
547 require.NoError(t, first.messages.save())
549 envelope := first.Queue(t).Pending[0].Text
550 config := first.agents[1].agent.config
551 oldPath := first.agents[1].agent.dir
554 // Change only test fixtures to exercise recovery under the same
556 newPath := filepath.Join(filepath.Dir(oldPath), "Orchard")
557 require.NoError(t, os.Rename(oldPath, newPath))
559 config.Name = "Orchard"
560 agentWriteTestConfig(t, newPath, config)
562 second := newDaemonMessageTest(t, directory, server)
563 child := second.messages.Child("OneChat", "Orchard")
564 require.NotNil(t, child)
565 require.False(t, child.waiting())
567 restored, err := child.memory.Message(id)
568 require.NoError(t, err)
569 require.Equal(t, envelope, restored.Text)
571 duplicate, err := second.messages.Notify(input)
572 require.NoError(t, err)
573 require.Equal(t, receipt.ID, duplicate.ID)
574 require.Equal(t, "duplicate", duplicate.Status)
575 require.NoError(t, second.messages.Notifications(child))
576 require.NoError(t, child.turn(t.Context()))
577 require.Empty(t, second.Queue(t).Pending)
578 require.Zero(t, second.agents[0].memory.Len())
582 third := newDaemonMessageTest(t, directory, server)
583 duplicate, err = third.messages.Notify(input)
584 require.NoError(t, err)
585 require.Equal(t, "duplicate", duplicate.Status)
586 require.Empty(t, third.Queue(t).Pending)
587 require.Equal(t, 1, server.Calls())
590 func TestMissingNotificationIdentityStopsStartupWithoutFallback(t *testing.T) {
591 directory := t.TempDir()
592 server := newModelFake(t)
593 first := newDaemonMessageTest(t, directory, server)
594 _, err := first.messages.Notify(&NotifyInput{
595 AgentID: first.agents[1].agent.config.ID,
598 Text: "Do not redirect this result.",
600 require.NoError(t, err)
602 path := first.messages.path
603 before, err := os.ReadFile(path)
604 require.NoError(t, err)
606 childPath := first.agents[1].agent.dir
609 require.NoError(t, os.RemoveAll(childPath))
610 // A new agent with the same name must not receive the old identity's
612 daemonChildConfig(t, directory, "Garden")
614 agents, err := openDaemonAgents(t.Context(), directory, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, newTestClock().Now, discardLog())
615 require.NoError(t, err)
617 var loops []*TurnLoop
619 for _, agent := range agents {
620 loop, err := agent.Loop(nil, func(Event) {
622 require.NoError(t, err)
624 loops = append(loops, loop)
627 _, err = openDaemonMessages(loops)
628 require.ErrorContains(t, err, "notification target is missing")
630 after, err := os.ReadFile(path)
631 require.NoError(t, err)
632 require.Equal(t, before, after)
633 require.Zero(t, agents[0].memory.Len())
634 require.Zero(t, agents[1].memory.Len())
635 require.NoError(t, agents.Close())
638 func TestCompletedNotificationReceiptDoesNotRequireItsTargetAtStartup(t *testing.T) {
639 directory := t.TempDir()
640 server := newModelFake(t)
641 server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
642 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
645 first := newDaemonMessageTest(t, directory, server)
646 _, err := first.messages.Notify(&NotifyInput{
647 AgentID: first.agents[1].agent.config.ID,
649 ID: "completed-child",
650 Text: "A completed result.",
652 require.NoError(t, err)
653 require.NoError(t, first.messages.Notifications(first.loops[1]))
654 require.NoError(t, first.loops[1].turn(t.Context()))
655 require.Empty(t, first.Queue(t).Pending)
657 childPath := first.agents[1].agent.dir
659 require.NoError(t, os.RemoveAll(childPath))
661 second := newDaemonMessageTest(t, directory, server)
662 require.Empty(t, second.Queue(t).Pending)
663 require.Len(t, second.Queue(t).Notifications, 1)
664 require.Zero(t, second.agents[0].memory.Len())
667 func TestLegacyNotificationReceiptsStillBindTheRoot(t *testing.T) {
668 directory := t.TempDir()
669 server := newModelFake(t)
670 first := newDaemonMessageTest(t, directory, server)
671 input := &NotifyInput{
674 Text: "A root result.",
677 receipt, err := first.messages.Notify(input)
678 require.NoError(t, err)
680 first.messages.state.Pending[0].TargetID = ""
681 first.messages.state.Notifications[0].TargetID = ""
682 require.NoError(t, first.messages.save())
684 before, err := os.ReadFile(first.messages.path)
685 require.NoError(t, err)
688 second := newDaemonMessageTest(t, directory, server)
689 after, err := os.ReadFile(second.messages.path)
690 require.NoError(t, err)
691 require.Equal(t, before, after)
693 duplicate, err := second.messages.Notify(input)
694 require.NoError(t, err)
695 require.Equal(t, receipt.ID, duplicate.ID)
696 require.Equal(t, "duplicate", duplicate.Status)
698 input.AgentID = second.agents[0].agent.config.ID
699 duplicate, err = second.messages.Notify(input)
700 require.NoError(t, err)
701 require.Equal(t, "duplicate", duplicate.Status)
703 input.AgentID = second.agents[1].agent.config.ID
704 _, err = second.messages.Notify(input)
705 require.ErrorContains(t, err, "conflicts")
708 func TestChildNotificationRunsWhileTheRootIsBusy(t *testing.T) {
709 server := newModelFake(t)
710 arrived := make(chan struct{})
711 release := make(chan struct{})
712 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
713 if call.number == 0 {
718 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
721 test := newDaemonMessageTest(t, t.TempDir(), server)
724 _, err := test.loops[0].Submit(terminalSource, "Keep the root busy.")
725 require.NoError(t, err)
728 _, err = test.messages.Notify(&NotifyInput{
729 AgentID: test.agents[1].agent.config.ID,
732 Text: "A result for the idle child.",
734 require.NoError(t, err)
735 test.events[1].WaitFor(t, "idle", 1)
736 require.True(t, test.loops[0].Busy())
737 require.Equal(t, 1, test.agents[0].memory.Len())
738 require.Empty(t, test.events[0].Last("work"))
740 test.events[0].WaitFor(t, "idle", 1)
741 require.Empty(t, test.Queue(t).Pending)
742 require.Equal(t, 2, server.Calls())
745 func TestNotificationBatchesStayOrderedForEachChild(t *testing.T) {
746 server := newModelFake(t)
747 server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
748 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
751 test := newDaemonMessageTest(t, t.TempDir(), server)
752 test.agents[1].memory.summarizer = newFakeSummarizer()
753 test.agents[2].memory.summarizer = newFakeSummarizer()
755 for number := range 21 {
756 for index := 1; index <= 2; index++ {
757 _, err := test.messages.Notify(&NotifyInput{
758 AgentID: test.agents[index].agent.config.ID,
760 ID: fmt.Sprintf("%d-%d", index, number),
761 Text: fmt.Sprintf("target-%d-result-%02d", index, number),
763 require.NoError(t, err)
767 test.Start(t, 0, 1, 2)
768 test.events[1].WaitFor(t, "idle", 2)
769 test.events[2].WaitFor(t, "idle", 2)
770 require.Equal(t, 4, server.Calls())
771 require.Zero(t, test.agents[0].memory.Len())
772 require.Empty(t, test.Queue(t).Pending)
774 for index := 1; index <= 2; index++ {
775 for number := range 21 {
776 logged, err := test.agents[index].memory.Message(number)
777 require.NoError(t, err)
778 require.Contains(t, logged.Text, fmt.Sprintf("target-%d-result-%02d", index, number))
783 func TestParentMessageWakesChildAndFinalReplyWakesParent(t *testing.T) {
784 server := newModelFake(t)
785 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
786 text := "I will ask the user."
787 if call.body["model"] == "gpt-test-garden" {
788 text = "Approve the gate size."
791 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5))
794 test := newDaemonMessageTest(t, t.TempDir(), server)
795 test.Start(t, 0, 1, 2)
796 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Check the gate size."))
797 test.events[0].WaitFor(t, "idle", 1)
798 test.events[1].WaitFor(t, "idle", 1)
800 require.Equal(t, "OneChat", test.events[1].LastEvent("user").Source)
801 require.Equal(t, "Check the gate size.", test.events[1].Last("user"))
802 require.Equal(t, "Garden", test.events[0].LastEvent("work").Source)
803 require.Equal(t, "[Garden] Approve the gate size.", test.events[0].Last("work"))
804 require.Equal(t, []string{"user", "busy", "reply", "idle"}, test.events[1].Kinds())
805 require.Equal(t, []string{"work", "busy", "reply", "idle"}, test.events[0].Kinds())
806 require.Equal(t, 2, server.Calls())
807 require.Zero(t, test.agents[2].memory.Len())
808 require.Empty(t, test.events[2].Kinds())
810 first, err := test.agents[1].memory.Message(0)
811 require.NoError(t, err)
812 require.Equal(t, "user", first.Kind)
814 report, err := test.agents[0].memory.Message(0)
815 require.NoError(t, err)
816 require.Equal(t, "work", report.Kind)
817 require.Equal(t, "[Garden] Approve the gate size.", report.Text)
818 require.Contains(t, test.logs.String(), "turn started name=Garden")
819 require.Contains(t, test.logs.String(), "turn done name=Garden")
820 require.NotContains(t, test.logs.String(), "gate size")
821 require.Empty(t, test.Queue(t).Pending)
823 info, err := os.Stat(test.messages.path)
824 require.NoError(t, err)
825 require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
828 func TestChildReportSendsATelegramCompletionBeforeTheParentReply(t *testing.T) {
829 server := newModelFake(t)
830 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
831 text := "I will ask the user."
832 if call.body["model"] == "gpt-test-garden" {
833 text = "Approve the gate size."
836 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5))
839 test := newDaemonMessageTest(t, t.TempDir(), server)
840 telegram := newTelegramFake(t)
841 bot := newTelegramBot(newTelegramAPI(telegram.URL, telegramTestToken), telegramTestOwner, filepath.Join(t.TempDir(), telegramOffsetFileName), discardLog())
842 test.loops[0].notify = func(event Event) {
843 test.events[0].Add(event)
847 ctx, cancel := context.WithCancel(t.Context())
850 done := make(chan struct{})
863 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Check the gate size."))
864 test.events[0].WaitFor(t, "idle", 1)
865 require.Eventually(t, func() bool {
866 return len(telegram.Sent()) >= 2
867 }, 10*time.Second, time.Millisecond)
869 require.Equal(t, []string{"[Garden] finished", "I will ask the user."}, telegram.Sent())
870 require.Equal(t, subagentFinished, test.events[0].LastEvent("work").Status)
871 require.Equal(t, "[Garden] Approve the gate size.", test.events[0].Last("work"))
874 func TestQueuedAgentMessageFailureKeepsItsOperationAndCause(t *testing.T) {
875 server := newModelFake(t)
876 test := newDaemonMessageTest(t, t.TempDir(), server)
877 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Keep the plan."))
879 failure := errors.New("fake queued message failure")
880 test.loops[1].fail(failure)
882 err := test.loops[1].Run(t.Context())
883 require.ErrorIs(t, err, failure)
884 require.ErrorContains(t, err, "check turn loop failure")
885 require.Zero(t, server.Calls())
888 func TestEmptyChildReplySendsNothingAndWakesNobody(t *testing.T) {
889 testCases := []struct {
894 name: "sends nothing for an empty final reply",
898 name: "sends nothing for a whitespace final reply",
903 for _, tc := range testCases {
904 t.Run(tc.name, func(t *testing.T) {
905 server := newModelFake(t)
906 server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
907 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(tc.text)}, 100, 0, 5))
910 test := newDaemonMessageTest(t, t.TempDir(), server)
911 test.Start(t, 0, 1, 2)
912 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Keep the plan."))
913 test.events[1].WaitFor(t, "idle", 1)
914 require.Equal(t, 1, server.Calls())
915 require.Zero(t, test.agents[0].memory.Len())
916 require.Empty(t, test.events[0].Kinds())
917 require.False(t, test.loops[0].waiting())
918 require.Empty(t, test.events[2].Kinds())
919 require.Empty(t, test.Queue(t).Pending)
924 func TestBusyChildQueuesTheNextParentMessage(t *testing.T) {
925 server := newModelFake(t)
926 arrived := make(chan struct{})
927 release := make(chan struct{})
928 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
929 if call.number == 0 {
934 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
937 test := newDaemonMessageTest(t, t.TempDir(), server)
939 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Check the first plan."))
941 require.True(t, test.loops[1].Busy())
942 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Check the second plan too."))
943 require.Equal(t, 1, server.Calls())
944 require.Len(t, test.Queue(t).Pending, 2)
946 test.events[1].WaitFor(t, "idle", 2)
947 require.Equal(t, 2, server.Calls())
949 texts := turnInputTexts(t, server.Call(1))
950 require.Equal(t, "Check the second plan too.", texts[len(texts)-1])
951 require.Empty(t, test.Queue(t).Pending)
954 func TestBusyParentQueuesItsChildReport(t *testing.T) {
955 server := newModelFake(t)
956 arrived := make(chan struct{})
957 release := make(chan struct{})
958 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
959 text := "The plan is ready."
960 if call.number == 0 {
964 text = "I will continue."
967 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5))
970 test := newDaemonMessageTest(t, t.TempDir(), server)
973 _, err := test.loops[0].Submit("terminal", "Start the work.")
974 require.NoError(t, err)
976 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Prepare the plan."))
977 test.events[1].WaitFor(t, "idle", 1)
978 require.Equal(t, "[Garden] The plan is ready.", test.events[0].Last("work"))
979 require.True(t, test.loops[0].Busy())
980 require.Equal(t, 2, server.Calls())
982 test.events[0].WaitFor(t, "idle", 2)
983 require.Equal(t, 3, server.Calls())
985 texts := turnInputTexts(t, server.Call(2))
986 require.Equal(t, "[Garden] The plan is ready.", texts[len(texts)-1])
987 require.Empty(t, test.Queue(t).Pending)
990 func TestChildReportsOnlyTheFinalReplyAndTakesQueuedToolInput(t *testing.T) {
991 server := newModelFake(t)
992 arrived := make(chan struct{})
993 release := make(chan struct{})
994 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
995 if call.number == 0 {
998 modelWriteEvents(w, modelCompletedEvent([]any{
999 modelMessageItem("I will check the plan."),
1000 modelFunctionCallItem("fc_1", "call_1", "date", `{"id":0}`),
1006 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("The plan needs approval.")}, 100, 0, 5))
1009 test := newDaemonMessageTest(t, t.TempDir(), server)
1011 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Check the plan."))
1013 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Also check the date."))
1015 test.events[1].WaitFor(t, "idle", 1)
1016 require.Equal(t, 2, server.Calls())
1017 require.Equal(t, 1, test.events[1].Count("busy"))
1018 require.Equal(t, 1, test.events[0].Count("work"))
1019 require.Equal(t, "[Garden] The plan needs approval.", test.events[0].Last("work"))
1020 require.NotContains(t, test.events[0].Last("work"), "I will check")
1022 texts := turnInputTexts(t, server.Call(1))
1023 require.Equal(t, "Also check the date.", texts[len(texts)-1])
1025 queue := test.Queue(t)
1026 require.Len(t, queue.Pending, 1)
1027 require.Equal(t, "work", queue.Pending[0].Kind)
1030 func TestQueuedParentMessageSurvivesDaemonRestart(t *testing.T) {
1032 server := newModelFake(t)
1033 server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
1034 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
1037 first := newDaemonMessageTest(t, dir, server)
1038 require.NoError(t, first.messages.Tell("OneChat", "Garden", "Remember the garden plan."))
1039 require.Equal(t, 1, first.agents[1].memory.Len())
1042 second := newDaemonMessageTest(t, dir, server)
1043 require.Equal(t, 1, second.agents[1].memory.Len())
1044 require.Equal(t, "OneChat", second.events[1].LastEvent("user").Source)
1045 second.Start(t, 0, 1)
1046 second.events[1].WaitFor(t, "idle", 1)
1047 require.Equal(t, 1, server.Calls())
1048 require.Empty(t, second.Queue(t).Pending)
1051 third := newDaemonMessageTest(t, dir, server)
1052 require.Equal(t, 1, third.agents[1].memory.Len())
1053 require.False(t, third.loops[1].waiting())
1054 require.Empty(t, third.events[1].Kinds())
1057 func TestQueuedChildReportSurvivesDaemonRestart(t *testing.T) {
1059 server := newModelFake(t)
1060 first := newDaemonMessageTest(t, dir, server)
1062 require.NoError(t, first.messages.Tell("OneChat", "Garden", "Review the plan."))
1063 first.events[1].WaitFor(t, "idle", 1)
1064 require.Equal(t, "[Garden] a line", first.events[0].Last("work"))
1065 require.Len(t, first.Queue(t).Pending, 1)
1068 second := newDaemonMessageTest(t, dir, server)
1069 require.Equal(t, 1, second.agents[0].memory.Len())
1070 require.False(t, second.loops[1].waiting())
1071 second.Start(t, 0, 1)
1072 second.events[0].WaitFor(t, "idle", 1)
1073 require.Equal(t, 2, server.Calls())
1074 require.Equal(t, 2, second.agents[0].memory.Len())
1075 require.Empty(t, second.Queue(t).Pending)
1078 third := newDaemonMessageTest(t, dir, server)
1079 require.Equal(t, 2, third.agents[0].memory.Len())
1080 require.False(t, third.loops[0].waiting())
1081 require.Empty(t, third.events[0].Kinds())
1084 func TestInterruptedChildDeliveryResumesAfterDaemonRestart(t *testing.T) {
1086 server := newModelFake(t)
1087 arrived := make(chan struct{})
1088 release := make(chan struct{})
1089 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
1090 if call.number == 0 {
1095 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
1098 first := newDaemonMessageTest(t, dir, server)
1100 require.NoError(t, first.messages.Tell("OneChat", "Garden", "Finish the garden plan."))
1105 second := newDaemonMessageTest(t, dir, server)
1106 require.Equal(t, 1, second.agents[1].memory.Len())
1108 second.events[1].WaitFor(t, "idle", 1)
1109 require.Equal(t, 2, server.Calls())
1110 require.Equal(t, 1, second.agents[1].memory.Len())
1111 require.Empty(t, second.Queue(t).Pending)
1114 func TestInterruptedParentReportResumesAfterDaemonRestart(t *testing.T) {
1116 server := newModelFake(t)
1117 arrived := make(chan struct{})
1118 release := make(chan struct{})
1119 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
1120 if call.number == 1 {
1125 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("The plan is ready.")}, 100, 0, 5))
1128 first := newDaemonMessageTest(t, dir, server)
1129 first.Start(t, 0, 1)
1130 require.NoError(t, first.messages.Tell("OneChat", "Garden", "Review the plan."))
1135 second := newDaemonMessageTest(t, dir, server)
1136 require.Equal(t, 1, second.agents[0].memory.Len())
1137 require.False(t, second.loops[1].waiting())
1138 second.Start(t, 0, 1)
1139 second.events[0].WaitFor(t, "idle", 1)
1140 require.Equal(t, 3, server.Calls())
1141 require.Equal(t, 2, second.agents[0].memory.Len())
1142 require.Empty(t, second.Queue(t).Pending)
1144 texts := turnInputTexts(t, server.Call(2))
1145 require.Equal(t, "[Garden] The plan is ready.", texts[len(texts)-1])
1148 func TestAgentMessageWriteFailureStopsDelivery(t *testing.T) {
1149 server := newModelFake(t)
1150 test := newDaemonMessageTest(t, t.TempDir(), server)
1151 require.NoError(t, os.Mkdir(test.messages.path, 0o700))
1152 require.ErrorContains(t, test.messages.Tell("OneChat", "Garden", "Keep the plan."), "save agent messages")
1153 require.ErrorContains(t, test.messages.Tell("OneChat", "Garden", "Do not send again."), "save agent messages")
1155 for _, loop := range test.loops {
1156 require.ErrorContains(t, loop.failed(), "save agent messages")
1157 require.Zero(t, loop.memory.Len())
1161 func TestAgentMessageSnapshotBlocksDelivery(t *testing.T) {
1162 server := newModelFake(t)
1163 test := newDaemonMessageTest(t, t.TempDir(), server)
1164 entered := make(chan struct{})
1165 release := make(chan struct{})
1169 defer once.Do(func() {
1173 snapshot := make(chan error, 1)
1175 snapshot <- test.messages.Snapshot(func() error {
1176 return daemonSnapshot(t.Context(), test.loops, func() error {
1187 sent := make(chan error, 1)
1188 started := make(chan struct{})
1192 sent <- test.messages.Tell("OneChat", "Garden", "Record the plan after the copy.")
1196 require.Never(t, func() bool {
1197 return len(sent) > 0
1198 }, 50*time.Millisecond, time.Millisecond)
1203 require.NoError(t, <-snapshot)
1204 require.NoError(t, <-sent)
1205 require.Equal(t, 1, test.agents[1].memory.Len())
1206 require.Len(t, test.Queue(t).Pending, 1)
1209 func TestParentCanMessageOnlyItsOwnChildren(t *testing.T) {
1210 server := newModelFake(t)
1212 test := newDaemonMessageTest(t, t.TempDir(), server)
1213 testCases := []struct {
1219 name: "no sibling messages",
1224 name: "no arbitrary child reports",
1229 name: "no self messages",
1234 name: "refuses an unknown sender",
1239 name: "refuses an unknown recipient",
1245 for _, tc := range testCases {
1246 t.Run(tc.name, func(t *testing.T) {
1247 require.ErrorContains(t, test.messages.Tell(tc.source, tc.target, "Do not deliver."), "not a child")
1251 for _, agent := range test.agents {
1252 require.Zero(t, agent.memory.Len())
1256 func TestInvalidAgentMessageQueueStopsStartup(t *testing.T) {
1257 server := newModelFake(t)
1258 testCases := []struct {
1263 name: "stops startup for broken JSON",
1267 name: "stops startup for unknown sender",
1268 file: `{"next":1,"pending":[{"id":1,"source":"Other","target":"Garden","kind":"user"}]}`,
1271 name: "stops startup for sibling delivery",
1272 file: `{"next":1,"pending":[{"id":1,"source":"Library","target":"Garden","kind":"user"}]}`,
1275 name: "stops startup for invalid sequence",
1276 file: `{"next":0,"pending":[{"id":1,"source":"OneChat","target":"Garden","kind":"user"}]}`,
1280 for _, tc := range testCases {
1281 t.Run(tc.name, func(t *testing.T) {
1283 test := newDaemonMessageTest(t, dir, server)
1284 path := test.messages.path
1286 require.NoError(t, os.WriteFile(path, []byte(tc.file), 0o600))
1288 agents, err := openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, newTestClock().Now, discardLog())
1289 require.NoError(t, err)
1292 require.NoError(t, agents.Close())
1295 var loops []*TurnLoop
1297 for _, agent := range agents {
1298 loop, err := agent.Loop(nil, func(Event) {
1300 require.NoError(t, err)
1302 loops = append(loops, loop)
1305 _, err = openDaemonMessages(loops)
1306 require.Error(t, err)
1308 for _, agent := range agents {
1309 require.Zero(t, agent.memory.Len())
1315 func TestChildScheduleWakesOnlyItsOwner(t *testing.T) {
1316 server := newModelFake(t)
1317 server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
1318 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
1321 test := newDaemonMessageTest(t, t.TempDir(), server)
1322 child := test.agents[1]
1323 at := child.clock().Add(time.Minute).Format(scheduleAtLayout)
1324 output := test.loops[1].tools.run(t.Context(), "schedule_set", `{"text":"Check the plants.","at":"`+at+`","cron":null}`)
1325 require.Contains(t, output, "Schedule 1 set.")
1326 require.Equal(t, "Garden", child.schedules.sorted()[0].Owner)
1328 child.schedules.clock = func() time.Time {
1329 return child.clock().Add(time.Minute)
1331 require.ErrorContains(t, child.schedules.deliver(t.Context(), test.loops[0]), "does not match")
1332 require.NoError(t, child.schedules.deliver(t.Context(), test.loops[1]))
1333 test.Start(t, 0, 1, 2)
1334 test.events[1].WaitFor(t, "idle", 1)
1335 require.Equal(t, 1, server.Calls())
1336 require.Equal(t, child.agent.CacheKey(), server.Call(0).body["prompt_cache_key"])
1337 require.Zero(t, test.agents[0].memory.Len())
1338 require.Zero(t, test.agents[2].memory.Len())
1339 require.Contains(t, test.events[1].LastEvent("work").Text, "Check the plants.")
1340 require.Equal(t, scheduleSource, test.events[1].LastEvent("work").Source)
1343 func TestRoutedWhatsAppBatchesWakeOnlyTheirChild(t *testing.T) {
1344 testCases := []struct {
1349 name: "routes a private chat only to its child",
1351 ID: whatsappTestAna.String(),
1356 name: "routes an unmentioned group only to its child",
1358 ID: whatsappTestFamily.String(),
1359 Name: "Garden plans",
1365 for _, tc := range testCases {
1366 t.Run(tc.name, func(t *testing.T) {
1367 server := newModelFake(t)
1368 server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
1369 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
1373 test := newDaemonMessageTest(t, dir, server)
1374 inbox := whatsappRouteTestInbox(t, dir)
1375 require.NoError(t, test.messages.routes.Set("whatsapp:"+tc.chat.ID, "Garden"))
1376 whatsappRouteTestAdd(t, inbox, tc.chat, "The gate needs a new latch.")
1377 test.Start(t, 0, 1, 2)
1378 require.NoError(t, inbox.deliver(t.Context(), test.loops[0], WhatsAppLink{}, true))
1379 test.events[1].WaitFor(t, "idle", 1)
1380 require.Equal(t, 1, server.Calls())
1381 require.Equal(t, "gpt-test-garden", server.Call(0).body["model"])
1382 require.Contains(t, string(server.Call(0).raw), "The gate needs a new latch.")
1383 require.Zero(t, test.agents[0].memory.Len())
1384 require.Empty(t, test.events[0].Kinds())
1385 require.Zero(t, test.agents[2].memory.Len())
1386 require.False(t, test.loops[0].waiting())
1387 require.Equal(t, whatsappSource, test.events[1].LastEvent("work").Source)
1388 require.Empty(t, test.events[1].LastEvent("work").Status)
1389 require.Equal(t, 1, test.agents[1].memory.Len())
1390 require.Empty(t, inbox.state.Closed())
1391 require.Empty(t, test.Queue(t).Pending)
1392 require.Contains(t, test.logs.String(), "batch routed name=Garden kind=whatsapp id=1 to=Garden")
1393 require.NotContains(t, test.logs.String(), tc.chat.ID)
1394 require.NotContains(t, test.logs.String(), "new latch")
1399 func TestWhatsAppUnrouteRestoresRootDelivery(t *testing.T) {
1401 test := newDaemonMessageTest(t, dir, newModelFake(t))
1402 inbox := whatsappRouteTestInbox(t, dir)
1403 chat := WhatsAppChat{
1404 ID: whatsappTestAna.String(),
1408 source := "whatsapp:" + chat.ID
1409 require.NoError(t, test.messages.routes.Set(source, "Garden"))
1410 whatsappRouteTestAdd(t, inbox, chat, "Keep the gate plan.")
1411 require.NoError(t, inbox.deliver(t.Context(), test.loops[0], WhatsAppLink{}, true))
1412 require.Zero(t, test.agents[0].memory.Len())
1413 require.True(t, test.loops[1].waiting())
1414 require.NoError(t, test.messages.routes.Set(source, ""))
1415 whatsappRouteTestAdd(t, inbox, chat, "Please check the other plan.")
1416 require.NoError(t, inbox.deliver(t.Context(), test.loops[0], WhatsAppLink{}, true))
1417 require.Equal(t, 1, test.agents[0].memory.Len())
1418 require.Equal(t, 1, test.agents[1].memory.Len())
1419 require.True(t, test.loops[0].waiting())
1420 require.Contains(t, test.events[0].Last("work"), "Please check the other plan.")
1421 require.NotContains(t, test.events[0].Last("work"), "Keep the gate plan.")
1422 require.Empty(t, inbox.state.Closed())
1425 func TestMissingWhatsAppRouteChildFallsBackToRoot(t *testing.T) {
1427 test := newDaemonMessageTest(t, dir, newModelFake(t))
1428 inbox := whatsappRouteTestInbox(t, dir)
1429 chat := WhatsAppChat{
1430 ID: whatsappTestAna.String(),
1434 require.NoError(t, test.messages.routes.Set("whatsapp:"+chat.ID, "Missing"))
1435 whatsappRouteTestAdd(t, inbox, chat, "Please check the plan.")
1436 require.NoError(t, inbox.deliver(t.Context(), test.loops[0], WhatsAppLink{}, true))
1437 require.Equal(t, 1, test.agents[0].memory.Len())
1438 require.True(t, test.loops[0].waiting())
1439 require.Zero(t, test.agents[1].memory.Len())
1440 require.False(t, test.loops[1].waiting())
1441 require.Empty(t, test.events[0].LastEvent("work").Status)
1442 require.Equal(t, whatsappSource, test.events[0].LastEvent("work").Source)
1443 require.Empty(t, inbox.state.Closed())
1446 func TestRoutedWhatsAppRestartDoesNotDeliverTheBatchTwice(t *testing.T) {
1447 testCases := []struct {
1452 name: "queued turn resumes",
1456 name: "finished turn stays finished",
1461 for _, tc := range testCases {
1462 t.Run(tc.name, func(t *testing.T) {
1464 server := newModelFake(t)
1465 server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
1466 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
1469 first := newDaemonMessageTest(t, dir, server)
1470 inbox := whatsappRouteTestInbox(t, dir)
1471 chat := WhatsAppChat{
1472 ID: whatsappTestAna.String(),
1476 source := "whatsapp:" + chat.ID
1477 require.NoError(t, first.messages.routes.Set(source, "Garden"))
1478 whatsappRouteTestAdd(t, inbox, chat, "Remember the gate plan.")
1479 require.NoError(t, inbox.state.CloseAll())
1481 batch := inbox.state.Closed()[0]
1482 routed, err := first.messages.RouteWhatsApp(batch, batch.Text(time.UTC))
1483 require.NoError(t, err)
1484 require.True(t, routed)
1488 first.events[1].WaitFor(t, "idle", 1)
1489 require.Empty(t, first.Queue(t).Pending)
1494 second := newDaemonMessageTest(t, dir, server)
1495 require.Equal(t, source+" -> Garden", second.messages.routes.List())
1496 require.Equal(t, 1, second.agents[1].memory.Len())
1497 require.Equal(t, !tc.finish, second.loops[1].waiting())
1498 require.NoError(t, second.messages.routes.Set(source, ""))
1500 restored := whatsappRouteTestInbox(t, dir)
1501 require.Len(t, restored.state.Closed(), 1)
1502 require.NoError(t, restored.deliver(t.Context(), second.loops[0], WhatsAppLink{}, true))
1503 require.Empty(t, restored.state.Closed())
1504 require.Zero(t, second.agents[0].memory.Len())
1505 require.Equal(t, 1, second.agents[1].memory.Len())
1509 second.events[1].WaitFor(t, "idle", 1)
1512 require.Equal(t, 1, server.Calls())
1513 require.Empty(t, second.Queue(t).Pending)
1516 third := newDaemonMessageTest(t, dir, server)
1517 require.False(t, third.loops[1].waiting())
1518 require.Empty(t, third.events[1].Kinds())
1519 require.Equal(t, 1, third.agents[1].memory.Len())
1524 func TestQueuedWhatsAppBatchRecoversBeforeItsLogAppend(t *testing.T) {
1525 testCases := []struct {
1530 name: "recovers one message before its log ID is stored",
1534 name: "recovers one message after its log ID is stored",
1539 for _, tc := range testCases {
1540 t.Run(tc.name, func(t *testing.T) {
1542 server := newModelFake(t)
1543 first := newDaemonMessageTest(t, dir, server)
1544 path := first.messages.path
1547 file := DaemonMessageFile{
1549 WhatsAppBatches: []int{1},
1550 Pending: []DaemonMessage{{
1555 Text: "[WhatsApp] Keep the gate plan.",
1561 data, err := json.Marshal(file)
1562 require.NoError(t, err)
1563 require.NoError(t, os.WriteFile(path, data, 0o600))
1565 second := newDaemonMessageTest(t, dir, server)
1566 require.Equal(t, 1, second.agents[1].memory.Len())
1567 require.True(t, second.loops[1].waiting())
1568 require.Equal(t, "[WhatsApp] Keep the gate plan.", second.events[1].Last("work"))
1569 require.Equal(t, whatsappSource, second.events[1].LastEvent("work").Source)
1572 third := newDaemonMessageTest(t, dir, server)
1573 require.Equal(t, 1, third.agents[1].memory.Len())
1574 require.True(t, third.loops[1].waiting())
1575 require.Zero(t, third.agents[0].memory.Len())
1580 func TestQueuedWhatsAppBatchUsesRootIfItsChildIsRemoved(t *testing.T) {
1582 server := newModelFake(t)
1583 first := newDaemonMessageTest(t, dir, server)
1584 inbox := whatsappRouteTestInbox(t, dir)
1585 chat := WhatsAppChat{
1586 ID: whatsappTestAna.String(),
1590 require.NoError(t, first.messages.routes.Set("whatsapp:"+chat.ID, "Garden"))
1591 whatsappRouteTestAdd(t, inbox, chat, "Keep the gate plan.")
1592 require.NoError(t, inbox.deliver(t.Context(), first.loops[0], WhatsAppLink{}, true))
1594 require.NoError(t, os.RemoveAll(filepath.Join(dir, daemonAgentDirName, "children", "Garden")))
1596 second := newDaemonMessageTest(t, dir, server)
1597 require.Equal(t, 1, second.agents[0].memory.Len())
1598 require.True(t, second.loops[0].waiting())
1599 require.Equal(t, whatsappSource, second.events[0].LastEvent("work").Source)
1600 require.Empty(t, second.events[0].LastEvent("work").Status)
1601 require.Contains(t, second.events[0].Last("work"), "Keep the gate plan.")
1602 require.False(t, second.loops[1].waiting())
1605 third := newDaemonMessageTest(t, dir, server)
1606 require.Equal(t, 1, third.agents[0].memory.Len())
1607 require.True(t, third.loops[0].waiting())
1610 func TestRoutedWhatsAppBatchesCanCloseOutOfNumberOrder(t *testing.T) {
1612 test := newDaemonMessageTest(t, dir, newModelFake(t))
1613 inbox := whatsappRouteTestInbox(t, dir)
1614 first := WhatsAppChat{
1615 ID: whatsappTestAna.String(),
1619 second := WhatsAppChat{
1620 ID: whatsappTestFamily.String(),
1621 Name: "Garden plans",
1625 require.NoError(t, test.messages.routes.Set("whatsapp:"+first.ID, "Garden"))
1626 require.NoError(t, test.messages.routes.Set("whatsapp:"+second.ID, "Garden"))
1629 _, err := inbox.state.Add(first, WhatsAppMessage{
1631 Text: "The first chat is still open.",
1635 require.NoError(t, err)
1637 _, err = inbox.state.Add(second, WhatsAppMessage{
1639 Text: "The second chat is quiet.",
1642 }, now.Add(-time.Minute))
1643 require.NoError(t, err)
1644 require.NoError(t, inbox.deliver(t.Context(), test.loops[0], WhatsAppLink{}, false))
1645 require.Equal(t, 1, test.agents[1].memory.Len())
1646 require.Contains(t, test.events[1].Last("work"), "The second chat is quiet.")
1647 require.NoError(t, inbox.deliver(t.Context(), test.loops[0], WhatsAppLink{}, true))
1648 require.Equal(t, 2, test.agents[1].memory.Len())
1649 require.Contains(t, test.events[1].Last("work"), "The first chat is still open.")
1650 require.Zero(t, test.agents[0].memory.Len())
1651 require.Empty(t, test.Queue(t).WhatsAppBatches)
1654 func TestWhatsAppCatchupSeparatesChildAndRootBatches(t *testing.T) {
1656 test := newDaemonMessageTest(t, dir, newModelFake(t))
1657 inbox := whatsappRouteTestInbox(t, dir)
1658 garden := WhatsAppChat{
1659 ID: whatsappTestFamily.String(),
1660 Name: "Garden plans",
1664 library := WhatsAppChat{
1665 ID: whatsappTestBruno.String(),
1669 root := WhatsAppChat{
1670 ID: whatsappTestAna.String(),
1674 require.NoError(t, test.messages.routes.Set("whatsapp:"+garden.ID, "Garden"))
1675 require.NoError(t, test.messages.routes.Set("whatsapp:"+library.ID, "Library"))
1676 inbox.waitForCatchup()
1677 whatsappRouteTestAdd(t, inbox, garden, "Check the gate.")
1678 whatsappRouteTestAdd(t, inbox, library, "Check the book.")
1679 whatsappRouteTestAdd(t, inbox, root, "Check the mixed chat.")
1680 require.NoError(t, inbox.deliver(t.Context(), test.loops[0], WhatsAppLink{}, false))
1682 for _, agent := range test.agents {
1683 require.Zero(t, agent.memory.Len())
1687 require.NoError(t, inbox.deliver(t.Context(), test.loops[0], WhatsAppLink{}, false))
1689 for _, loop := range test.loops {
1690 require.Equal(t, 1, loop.memory.Len())
1691 require.True(t, loop.waiting())
1694 require.Contains(t, test.events[0].Last("work"), "Check the mixed chat.")
1695 require.Contains(t, test.events[1].Last("work"), "Check the gate.")
1696 require.Contains(t, test.events[2].Last("work"), "Check the book.")
1697 require.Empty(t, inbox.state.Closed())
1698 require.Empty(t, test.Queue(t).WhatsAppBatches)
1701 func TestRoutedWhatsAppQueueFailureKeepsTheInboxBatch(t *testing.T) {
1703 server := newModelFake(t)
1704 first := newDaemonMessageTest(t, dir, server)
1705 inbox := whatsappRouteTestInbox(t, dir)
1706 chat := WhatsAppChat{
1707 ID: whatsappTestAna.String(),
1711 require.NoError(t, first.messages.routes.Set("whatsapp:"+chat.ID, "Garden"))
1712 require.NoError(t, os.Mkdir(first.messages.path, 0o700))
1713 whatsappRouteTestAdd(t, inbox, chat, "Keep the gate plan.")
1714 require.ErrorContains(t, inbox.deliver(t.Context(), first.loops[0], WhatsAppLink{}, true), "save agent messages")
1715 require.Len(t, inbox.state.Closed(), 1)
1717 for _, loop := range first.loops {
1718 require.ErrorContains(t, loop.failed(), "save agent messages")
1719 require.Zero(t, loop.memory.Len())
1722 path := first.messages.path
1724 require.NoError(t, os.Remove(path))
1726 second := newDaemonMessageTest(t, dir, server)
1727 restored := whatsappRouteTestInbox(t, dir)
1728 require.NoError(t, restored.deliver(t.Context(), second.loops[0], WhatsAppLink{}, true))
1729 require.Equal(t, 1, second.agents[1].memory.Len())
1730 require.True(t, second.loops[1].waiting())
1731 require.Zero(t, second.agents[0].memory.Len())
1732 require.Empty(t, restored.state.Closed())
1735 func whatsappRouteTestInbox(t *testing.T, dir string) *WhatsAppInbox {
1738 state, err := openWhatsAppState(filepath.Join(dir, whatsappDirName, whatsappStateFileName))
1739 require.NoError(t, err)
1741 return newWhatsAppInbox(state, newTestClock().Now, newWhatsAppGroups(discardLog()), &ImageStore{dir: filepath.Join(dir, imageDirName)}, discardLog())
1744 func whatsappRouteTestAdd(t *testing.T, inbox *WhatsAppInbox, chat WhatsAppChat, text string) {
1747 at := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
1748 _, err := inbox.state.Add(chat, WhatsAppMessage{
1754 require.NoError(t, err)