package main import ( "context" "encoding/json" "errors" "net/http" "os" "path/filepath" "sync" "testing" "time" "github.com/stretchr/testify/require" ) func TestParentMessageWakesChildAndFinalReplyWakesParent(t *testing.T) { server := newModelFake(t) server.reply = func(w http.ResponseWriter, call ModelFakeCall) { text := "I will ask the user." if call.body["model"] == "gpt-test-garden" { text = "Approve the gate size." } modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5)) } test := newDaemonMessageTest(t, t.TempDir(), server) test.Start(t, 0, 1, 2) require.NoError(t, test.messages.Tell("OneChat", "Garden", "Check the gate size.")) test.events[0].WaitFor(t, "idle", 1) test.events[1].WaitFor(t, "idle", 1) require.Equal(t, "OneChat", test.events[1].LastEvent("user").Source) require.Equal(t, "Check the gate size.", test.events[1].Last("user")) require.Equal(t, "Garden", test.events[0].LastEvent("work").Source) require.Equal(t, "[Garden] Approve the gate size.", test.events[0].Last("work")) require.Equal(t, []string{"user", "busy", "reply", "idle"}, test.events[1].Kinds()) require.Equal(t, []string{"work", "busy", "reply", "idle"}, test.events[0].Kinds()) require.Equal(t, 2, server.Calls()) require.Zero(t, test.agents[2].memory.Len()) require.Empty(t, test.events[2].Kinds()) first, err := test.agents[1].memory.Message(0) require.NoError(t, err) require.Equal(t, "user", first.Kind) report, err := test.agents[0].memory.Message(0) require.NoError(t, err) require.Equal(t, "work", report.Kind) require.Equal(t, "[Garden] Approve the gate size.", report.Text) require.Contains(t, test.logs.String(), "turn started name=Garden") require.Contains(t, test.logs.String(), "turn done name=Garden") require.NotContains(t, test.logs.String(), "gate size") require.Empty(t, test.Queue(t).Pending) info, err := os.Stat(test.messages.path) require.NoError(t, err) require.Equal(t, os.FileMode(0o600), info.Mode().Perm()) } func TestChildReportSendsATelegramCompletionBeforeTheParentReply(t *testing.T) { server := newModelFake(t) server.reply = func(w http.ResponseWriter, call ModelFakeCall) { text := "I will ask the user." if call.body["model"] == "gpt-test-garden" { text = "Approve the gate size." } modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5)) } test := newDaemonMessageTest(t, t.TempDir(), server) telegram := newTelegramFake(t) bot := newTelegramBot(newTelegramAPI(telegram.URL, telegramTestToken), telegramTestOwner, filepath.Join(t.TempDir(), telegramOffsetFileName), discardLog()) test.loops[0].notify = func(event Event) { test.events[0].Add(event) bot.Notify(event) } ctx, cancel := context.WithCancel(t.Context()) defer cancel() done := make(chan struct{}) go func() { defer close(done) bot.send(ctx) }() t.Cleanup(func() { cancel() <-done }) test.Start(t, 0, 1) require.NoError(t, test.messages.Tell("OneChat", "Garden", "Check the gate size.")) test.events[0].WaitFor(t, "idle", 1) require.Eventually(t, func() bool { return len(telegram.Sent()) >= 2 }, 10*time.Second, time.Millisecond) require.Equal(t, []string{"[Garden] finished", "I will ask the user."}, telegram.Sent()) require.Equal(t, subagentFinished, test.events[0].LastEvent("work").Status) require.Equal(t, "[Garden] Approve the gate size.", test.events[0].Last("work")) } func TestQueuedAgentMessageFailureKeepsItsOperationAndCause(t *testing.T) { server := newModelFake(t) test := newDaemonMessageTest(t, t.TempDir(), server) require.NoError(t, test.messages.Tell("OneChat", "Garden", "Keep the plan.")) failure := errors.New("fake queued message failure") test.loops[1].fail(failure) err := test.loops[1].Run(t.Context()) require.ErrorIs(t, err, failure) require.ErrorContains(t, err, "check turn loop failure") require.Zero(t, server.Calls()) } func TestEmptyChildReplySendsNothingAndWakesNobody(t *testing.T) { testCases := []struct { name string text string }{ { name: "empty final reply", text: "", }, { name: "whitespace final reply", text: " \n\t ", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { server := newModelFake(t) server.reply = func(w http.ResponseWriter, _ ModelFakeCall) { modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(tc.text)}, 100, 0, 5)) } test := newDaemonMessageTest(t, t.TempDir(), server) test.Start(t, 0, 1, 2) require.NoError(t, test.messages.Tell("OneChat", "Garden", "Keep the plan.")) test.events[1].WaitFor(t, "idle", 1) require.Equal(t, 1, server.Calls()) require.Zero(t, test.agents[0].memory.Len()) require.Empty(t, test.events[0].Kinds()) require.False(t, test.loops[0].waiting()) require.Empty(t, test.events[2].Kinds()) require.Empty(t, test.Queue(t).Pending) }) } } func TestBusyChildQueuesTheNextParentMessage(t *testing.T) { server := newModelFake(t) arrived := make(chan struct{}) release := make(chan struct{}) server.reply = func(w http.ResponseWriter, call ModelFakeCall) { if call.number == 0 { close(arrived) <-release } modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5)) } test := newDaemonMessageTest(t, t.TempDir(), server) test.Start(t, 0, 1) require.NoError(t, test.messages.Tell("OneChat", "Garden", "Check the first plan.")) <-arrived require.True(t, test.loops[1].Busy()) require.NoError(t, test.messages.Tell("OneChat", "Garden", "Check the second plan too.")) require.Equal(t, 1, server.Calls()) require.Len(t, test.Queue(t).Pending, 2) close(release) test.events[1].WaitFor(t, "idle", 2) require.Equal(t, 2, server.Calls()) texts := turnInputTexts(t, server.Call(1)) require.Equal(t, "Check the second plan too.", texts[len(texts)-1]) require.Empty(t, test.Queue(t).Pending) } func TestBusyParentQueuesItsChildReport(t *testing.T) { server := newModelFake(t) arrived := make(chan struct{}) release := make(chan struct{}) server.reply = func(w http.ResponseWriter, call ModelFakeCall) { text := "The plan is ready." if call.number == 0 { close(arrived) <-release text = "I will continue." } modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5)) } test := newDaemonMessageTest(t, t.TempDir(), server) test.Start(t, 0, 1) _, err := test.loops[0].Submit("terminal", "Start the work.") require.NoError(t, err) <-arrived require.NoError(t, test.messages.Tell("OneChat", "Garden", "Prepare the plan.")) test.events[1].WaitFor(t, "idle", 1) require.Equal(t, "[Garden] The plan is ready.", test.events[0].Last("work")) require.True(t, test.loops[0].Busy()) require.Equal(t, 2, server.Calls()) close(release) test.events[0].WaitFor(t, "idle", 2) require.Equal(t, 3, server.Calls()) texts := turnInputTexts(t, server.Call(2)) require.Equal(t, "[Garden] The plan is ready.", texts[len(texts)-1]) require.Empty(t, test.Queue(t).Pending) } func TestChildReportsOnlyTheFinalReplyAndTakesQueuedToolInput(t *testing.T) { server := newModelFake(t) arrived := make(chan struct{}) release := make(chan struct{}) server.reply = func(w http.ResponseWriter, call ModelFakeCall) { if call.number == 0 { close(arrived) <-release modelWriteEvents(w, modelCompletedEvent([]any{ modelMessageItem("I will check the plan."), modelFunctionCallItem("fc_1", "call_1", "date", `{"id":0}`), }, 100, 0, 5)) return } modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("The plan needs approval.")}, 100, 0, 5)) } test := newDaemonMessageTest(t, t.TempDir(), server) test.Start(t, 1) require.NoError(t, test.messages.Tell("OneChat", "Garden", "Check the plan.")) <-arrived require.NoError(t, test.messages.Tell("OneChat", "Garden", "Also check the date.")) close(release) test.events[1].WaitFor(t, "idle", 1) require.Equal(t, 2, server.Calls()) require.Equal(t, 1, test.events[1].Count("busy")) require.Equal(t, 1, test.events[0].Count("work")) require.Equal(t, "[Garden] The plan needs approval.", test.events[0].Last("work")) require.NotContains(t, test.events[0].Last("work"), "I will check") texts := turnInputTexts(t, server.Call(1)) require.Equal(t, "Also check the date.", texts[len(texts)-1]) queue := test.Queue(t) require.Len(t, queue.Pending, 1) require.Equal(t, "work", queue.Pending[0].Kind) } func TestQueuedParentMessageSurvivesDaemonRestart(t *testing.T) { dir := t.TempDir() server := newModelFake(t) server.reply = func(w http.ResponseWriter, _ ModelFakeCall) { modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5)) } first := newDaemonMessageTest(t, dir, server) require.NoError(t, first.messages.Tell("OneChat", "Garden", "Remember the garden plan.")) require.Equal(t, 1, first.agents[1].memory.Len()) first.Close(t) second := newDaemonMessageTest(t, dir, server) require.Equal(t, 1, second.agents[1].memory.Len()) require.Equal(t, "OneChat", second.events[1].LastEvent("user").Source) second.Start(t, 0, 1) second.events[1].WaitFor(t, "idle", 1) require.Equal(t, 1, server.Calls()) require.Empty(t, second.Queue(t).Pending) second.Close(t) third := newDaemonMessageTest(t, dir, server) require.Equal(t, 1, third.agents[1].memory.Len()) require.False(t, third.loops[1].waiting()) require.Empty(t, third.events[1].Kinds()) } func TestQueuedChildReportSurvivesDaemonRestart(t *testing.T) { dir := t.TempDir() server := newModelFake(t) first := newDaemonMessageTest(t, dir, server) first.Start(t, 1) require.NoError(t, first.messages.Tell("OneChat", "Garden", "Review the plan.")) first.events[1].WaitFor(t, "idle", 1) require.Equal(t, "[Garden] a line", first.events[0].Last("work")) require.Len(t, first.Queue(t).Pending, 1) first.Close(t) second := newDaemonMessageTest(t, dir, server) require.Equal(t, 1, second.agents[0].memory.Len()) require.False(t, second.loops[1].waiting()) second.Start(t, 0, 1) second.events[0].WaitFor(t, "idle", 1) require.Equal(t, 2, server.Calls()) require.Equal(t, 2, second.agents[0].memory.Len()) require.Empty(t, second.Queue(t).Pending) second.Close(t) third := newDaemonMessageTest(t, dir, server) require.Equal(t, 2, third.agents[0].memory.Len()) require.False(t, third.loops[0].waiting()) require.Empty(t, third.events[0].Kinds()) } func TestInterruptedChildDeliveryResumesAfterDaemonRestart(t *testing.T) { dir := t.TempDir() server := newModelFake(t) arrived := make(chan struct{}) release := make(chan struct{}) server.reply = func(w http.ResponseWriter, call ModelFakeCall) { if call.number == 0 { close(arrived) <-release } modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5)) } first := newDaemonMessageTest(t, dir, server) first.Start(t, 1) require.NoError(t, first.messages.Tell("OneChat", "Garden", "Finish the garden plan.")) <-arrived first.Close(t) close(release) second := newDaemonMessageTest(t, dir, server) require.Equal(t, 1, second.agents[1].memory.Len()) second.Start(t, 1) second.events[1].WaitFor(t, "idle", 1) require.Equal(t, 2, server.Calls()) require.Equal(t, 1, second.agents[1].memory.Len()) require.Empty(t, second.Queue(t).Pending) } func TestInterruptedParentReportResumesAfterDaemonRestart(t *testing.T) { dir := t.TempDir() server := newModelFake(t) arrived := make(chan struct{}) release := make(chan struct{}) server.reply = func(w http.ResponseWriter, call ModelFakeCall) { if call.number == 1 { close(arrived) <-release } modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("The plan is ready.")}, 100, 0, 5)) } first := newDaemonMessageTest(t, dir, server) first.Start(t, 0, 1) require.NoError(t, first.messages.Tell("OneChat", "Garden", "Review the plan.")) <-arrived first.Close(t) close(release) second := newDaemonMessageTest(t, dir, server) require.Equal(t, 1, second.agents[0].memory.Len()) require.False(t, second.loops[1].waiting()) second.Start(t, 0, 1) second.events[0].WaitFor(t, "idle", 1) require.Equal(t, 3, server.Calls()) require.Equal(t, 2, second.agents[0].memory.Len()) require.Empty(t, second.Queue(t).Pending) texts := turnInputTexts(t, server.Call(2)) require.Equal(t, "[Garden] The plan is ready.", texts[len(texts)-1]) } func TestAgentMessageWriteFailureStopsDelivery(t *testing.T) { server := newModelFake(t) test := newDaemonMessageTest(t, t.TempDir(), server) require.NoError(t, os.Mkdir(test.messages.path, 0o700)) require.ErrorContains(t, test.messages.Tell("OneChat", "Garden", "Keep the plan."), "save agent messages") require.ErrorContains(t, test.messages.Tell("OneChat", "Garden", "Do not send again."), "save agent messages") for _, loop := range test.loops { require.ErrorContains(t, loop.failed(), "save agent messages") require.Zero(t, loop.memory.Len()) } } func TestAgentMessageSnapshotBlocksDelivery(t *testing.T) { server := newModelFake(t) test := newDaemonMessageTest(t, t.TempDir(), server) entered := make(chan struct{}) release := make(chan struct{}) var once sync.Once defer once.Do(func() { close(release) }) snapshot := make(chan error, 1) go func() { snapshot <- test.messages.Snapshot(func() error { return daemonSnapshot(t.Context(), test.loops, func() error { close(entered) <-release return nil }) }) }() <-entered sent := make(chan error, 1) started := make(chan struct{}) go func() { close(started) sent <- test.messages.Tell("OneChat", "Garden", "Record the plan after the copy.") }() <-started require.Never(t, func() bool { return len(sent) > 0 }, 50*time.Millisecond, time.Millisecond) once.Do(func() { close(release) }) require.NoError(t, <-snapshot) require.NoError(t, <-sent) require.Equal(t, 1, test.agents[1].memory.Len()) require.Len(t, test.Queue(t).Pending, 1) } func TestParentCanMessageOnlyItsOwnChildren(t *testing.T) { server := newModelFake(t) test := newDaemonMessageTest(t, t.TempDir(), server) testCases := []struct { name string source string target string }{ { name: "no sibling messages", source: "Garden", target: "Library", }, { name: "no arbitrary child reports", source: "Garden", target: "OneChat", }, { name: "no self messages", source: "OneChat", target: "OneChat", }, { name: "unknown sender", source: "Other", target: "Garden", }, { name: "unknown recipient", source: "OneChat", target: "Other", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { require.ErrorContains(t, test.messages.Tell(tc.source, tc.target, "Do not deliver."), "not a child") }) } for _, agent := range test.agents { require.Zero(t, agent.memory.Len()) } } func TestInvalidAgentMessageQueueStopsStartup(t *testing.T) { server := newModelFake(t) testCases := []struct { name string file string }{ { name: "broken JSON", file: "{", }, { name: "unknown sender", file: `{"next":1,"pending":[{"id":1,"source":"Other","target":"Garden","kind":"user"}]}`, }, { name: "sibling delivery", file: `{"next":1,"pending":[{"id":1,"source":"Library","target":"Garden","kind":"user"}]}`, }, { name: "invalid sequence", file: `{"next":0,"pending":[{"id":1,"source":"OneChat","target":"Garden","kind":"user"}]}`, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() test := newDaemonMessageTest(t, dir, server) path := test.messages.path test.Close(t) require.NoError(t, os.WriteFile(path, []byte(tc.file), 0o600)) agents, err := openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, newTestClock().Now, discardLog()) require.NoError(t, err) defer func() { require.NoError(t, agents.Close()) }() var loops []*TurnLoop for _, agent := range agents { loop, err := agent.Loop(nil, func(Event) { }) require.NoError(t, err) loops = append(loops, loop) } _, err = openDaemonMessages(loops) require.Error(t, err) for _, agent := range agents { require.Zero(t, agent.memory.Len()) } }) } } func TestChildScheduleWakesOnlyItsOwner(t *testing.T) { server := newModelFake(t) server.reply = func(w http.ResponseWriter, _ ModelFakeCall) { modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5)) } test := newDaemonMessageTest(t, t.TempDir(), server) child := test.agents[1] at := child.clock().Add(time.Minute).Format(scheduleAtLayout) output := test.loops[1].tools.run(t.Context(), "schedule_set", `{"text":"Check the plants.","at":"`+at+`","cron":null}`) require.Contains(t, output, "Schedule 1 set.") require.Equal(t, "Garden", child.schedules.sorted()[0].Owner) child.schedules.clock = func() time.Time { return child.clock().Add(time.Minute) } require.ErrorContains(t, child.schedules.deliver(t.Context(), test.loops[0]), "does not match") require.NoError(t, child.schedules.deliver(t.Context(), test.loops[1])) test.Start(t, 0, 1, 2) test.events[1].WaitFor(t, "idle", 1) require.Equal(t, 1, server.Calls()) require.Equal(t, child.agent.CacheKey(), server.Call(0).body["prompt_cache_key"]) require.Zero(t, test.agents[0].memory.Len()) require.Zero(t, test.agents[2].memory.Len()) require.Contains(t, test.events[1].LastEvent("work").Text, "Check the plants.") require.Equal(t, scheduleSource, test.events[1].LastEvent("work").Source) } func TestRoutedWhatsAppBatchesWakeOnlyTheirChild(t *testing.T) { testCases := []struct { name string chat WhatsAppChat }{ { name: "private chat", chat: WhatsAppChat{ ID: whatsappTestAna.String(), Name: "Ana", }, }, { name: "group without a mention", chat: WhatsAppChat{ ID: whatsappTestFamily.String(), Name: "Garden plans", Group: true, }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { server := newModelFake(t) server.reply = func(w http.ResponseWriter, _ ModelFakeCall) { modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5)) } dir := t.TempDir() test := newDaemonMessageTest(t, dir, server) inbox := whatsappRouteTestInbox(t, dir) require.NoError(t, test.messages.routes.Set("whatsapp:"+tc.chat.ID, "Garden")) whatsappRouteTestAdd(t, inbox, tc.chat, "The gate needs a new latch.") test.Start(t, 0, 1, 2) require.NoError(t, inbox.deliver(t.Context(), test.loops[0], WhatsAppLink{}, true)) test.events[1].WaitFor(t, "idle", 1) require.Equal(t, 1, server.Calls()) require.Equal(t, "gpt-test-garden", server.Call(0).body["model"]) require.Contains(t, string(server.Call(0).raw), "The gate needs a new latch.") require.Zero(t, test.agents[0].memory.Len()) require.Empty(t, test.events[0].Kinds()) require.Zero(t, test.agents[2].memory.Len()) require.False(t, test.loops[0].waiting()) require.Equal(t, whatsappSource, test.events[1].LastEvent("work").Source) require.Empty(t, test.events[1].LastEvent("work").Status) require.Equal(t, 1, test.agents[1].memory.Len()) require.Empty(t, inbox.state.Closed()) require.Empty(t, test.Queue(t).Pending) require.Contains(t, test.logs.String(), "batch routed name=Garden kind=whatsapp id=1 to=Garden") require.NotContains(t, test.logs.String(), tc.chat.ID) require.NotContains(t, test.logs.String(), "new latch") }) } } func TestWhatsAppUnrouteRestoresRootDelivery(t *testing.T) { dir := t.TempDir() test := newDaemonMessageTest(t, dir, newModelFake(t)) inbox := whatsappRouteTestInbox(t, dir) chat := WhatsAppChat{ ID: whatsappTestAna.String(), Name: "Ana", } source := "whatsapp:" + chat.ID require.NoError(t, test.messages.routes.Set(source, "Garden")) whatsappRouteTestAdd(t, inbox, chat, "Keep the gate plan.") require.NoError(t, inbox.deliver(t.Context(), test.loops[0], WhatsAppLink{}, true)) require.Zero(t, test.agents[0].memory.Len()) require.True(t, test.loops[1].waiting()) require.NoError(t, test.messages.routes.Set(source, "")) whatsappRouteTestAdd(t, inbox, chat, "Please check the other plan.") require.NoError(t, inbox.deliver(t.Context(), test.loops[0], WhatsAppLink{}, true)) require.Equal(t, 1, test.agents[0].memory.Len()) require.Equal(t, 1, test.agents[1].memory.Len()) require.True(t, test.loops[0].waiting()) require.Contains(t, test.events[0].Last("work"), "Please check the other plan.") require.NotContains(t, test.events[0].Last("work"), "Keep the gate plan.") require.Empty(t, inbox.state.Closed()) } func TestMissingWhatsAppRouteChildFallsBackToRoot(t *testing.T) { dir := t.TempDir() test := newDaemonMessageTest(t, dir, newModelFake(t)) inbox := whatsappRouteTestInbox(t, dir) chat := WhatsAppChat{ ID: whatsappTestAna.String(), Name: "Ana", } require.NoError(t, test.messages.routes.Set("whatsapp:"+chat.ID, "Missing")) whatsappRouteTestAdd(t, inbox, chat, "Please check the plan.") require.NoError(t, inbox.deliver(t.Context(), test.loops[0], WhatsAppLink{}, true)) require.Equal(t, 1, test.agents[0].memory.Len()) require.True(t, test.loops[0].waiting()) require.Zero(t, test.agents[1].memory.Len()) require.False(t, test.loops[1].waiting()) require.Empty(t, test.events[0].LastEvent("work").Status) require.Equal(t, whatsappSource, test.events[0].LastEvent("work").Source) require.Empty(t, inbox.state.Closed()) } func TestRoutedWhatsAppRestartDoesNotDeliverTheBatchTwice(t *testing.T) { testCases := []struct { name string finish bool }{ { name: "queued turn resumes", finish: false, }, { name: "finished turn stays finished", finish: true, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() server := newModelFake(t) server.reply = func(w http.ResponseWriter, _ ModelFakeCall) { modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5)) } first := newDaemonMessageTest(t, dir, server) inbox := whatsappRouteTestInbox(t, dir) chat := WhatsAppChat{ ID: whatsappTestAna.String(), Name: "Ana", } source := "whatsapp:" + chat.ID require.NoError(t, first.messages.routes.Set(source, "Garden")) whatsappRouteTestAdd(t, inbox, chat, "Remember the gate plan.") require.NoError(t, inbox.state.CloseAll()) batch := inbox.state.Closed()[0] routed, err := first.messages.RouteWhatsApp(batch, batch.Text(time.UTC)) require.NoError(t, err) require.True(t, routed) if tc.finish { first.Start(t, 1) first.events[1].WaitFor(t, "idle", 1) require.Empty(t, first.Queue(t).Pending) } first.Close(t) second := newDaemonMessageTest(t, dir, server) require.Equal(t, source+" -> Garden", second.messages.routes.List()) require.Equal(t, 1, second.agents[1].memory.Len()) require.Equal(t, !tc.finish, second.loops[1].waiting()) require.NoError(t, second.messages.routes.Set(source, "")) restored := whatsappRouteTestInbox(t, dir) require.Len(t, restored.state.Closed(), 1) require.NoError(t, restored.deliver(t.Context(), second.loops[0], WhatsAppLink{}, true)) require.Empty(t, restored.state.Closed()) require.Zero(t, second.agents[0].memory.Len()) require.Equal(t, 1, second.agents[1].memory.Len()) if !tc.finish { second.Start(t, 1) second.events[1].WaitFor(t, "idle", 1) } require.Equal(t, 1, server.Calls()) require.Empty(t, second.Queue(t).Pending) second.Close(t) third := newDaemonMessageTest(t, dir, server) require.False(t, third.loops[1].waiting()) require.Empty(t, third.events[1].Kinds()) require.Equal(t, 1, third.agents[1].memory.Len()) }) } } func TestQueuedWhatsAppBatchRecoversBeforeItsLogAppend(t *testing.T) { testCases := []struct { name string id *int }{ { name: "before log ID is stored", id: nil, }, { name: "after log ID is stored", id: new(0), }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() server := newModelFake(t) first := newDaemonMessageTest(t, dir, server) path := first.messages.path first.Close(t) file := DaemonMessageFile{ Next: 1, WhatsAppBatches: []int{1}, Pending: []DaemonMessage{{ ID: 1, Source: "OneChat", Target: "Garden", Kind: "work", Text: "[WhatsApp] Keep the gate plan.", LogID: tc.id, WhatsAppBatch: 1, }}, } data, err := json.Marshal(file) require.NoError(t, err) require.NoError(t, os.WriteFile(path, data, 0o600)) second := newDaemonMessageTest(t, dir, server) require.Equal(t, 1, second.agents[1].memory.Len()) require.True(t, second.loops[1].waiting()) require.Equal(t, "[WhatsApp] Keep the gate plan.", second.events[1].Last("work")) require.Equal(t, whatsappSource, second.events[1].LastEvent("work").Source) second.Close(t) third := newDaemonMessageTest(t, dir, server) require.Equal(t, 1, third.agents[1].memory.Len()) require.True(t, third.loops[1].waiting()) require.Zero(t, third.agents[0].memory.Len()) }) } } func TestQueuedWhatsAppBatchUsesRootIfItsChildIsRemoved(t *testing.T) { dir := t.TempDir() server := newModelFake(t) first := newDaemonMessageTest(t, dir, server) inbox := whatsappRouteTestInbox(t, dir) chat := WhatsAppChat{ ID: whatsappTestAna.String(), Name: "Ana", } require.NoError(t, first.messages.routes.Set("whatsapp:"+chat.ID, "Garden")) whatsappRouteTestAdd(t, inbox, chat, "Keep the gate plan.") require.NoError(t, inbox.deliver(t.Context(), first.loops[0], WhatsAppLink{}, true)) first.Close(t) require.NoError(t, os.RemoveAll(filepath.Join(dir, daemonAgentDirName, "children", "Garden"))) second := newDaemonMessageTest(t, dir, server) require.Equal(t, 1, second.agents[0].memory.Len()) require.True(t, second.loops[0].waiting()) require.Equal(t, whatsappSource, second.events[0].LastEvent("work").Source) require.Empty(t, second.events[0].LastEvent("work").Status) require.Contains(t, second.events[0].Last("work"), "Keep the gate plan.") require.False(t, second.loops[1].waiting()) second.Close(t) third := newDaemonMessageTest(t, dir, server) require.Equal(t, 1, third.agents[0].memory.Len()) require.True(t, third.loops[0].waiting()) } func TestRoutedWhatsAppBatchesCanCloseOutOfNumberOrder(t *testing.T) { dir := t.TempDir() test := newDaemonMessageTest(t, dir, newModelFake(t)) inbox := whatsappRouteTestInbox(t, dir) first := WhatsAppChat{ ID: whatsappTestAna.String(), Name: "Ana", } second := WhatsAppChat{ ID: whatsappTestFamily.String(), Name: "Garden plans", Group: true, } require.NoError(t, test.messages.routes.Set("whatsapp:"+first.ID, "Garden")) require.NoError(t, test.messages.routes.Set("whatsapp:"+second.ID, "Garden")) now := time.Now() _, err := inbox.state.Add(first, WhatsAppMessage{ ID: "first", Text: "The first chat is still open.", Sender: "Ana", Time: now, }, now) require.NoError(t, err) _, err = inbox.state.Add(second, WhatsAppMessage{ ID: "second", Text: "The second chat is quiet.", Sender: "Bruno", Time: now, }, now.Add(-time.Minute)) require.NoError(t, err) require.NoError(t, inbox.deliver(t.Context(), test.loops[0], WhatsAppLink{}, false)) require.Equal(t, 1, test.agents[1].memory.Len()) require.Contains(t, test.events[1].Last("work"), "The second chat is quiet.") require.NoError(t, inbox.deliver(t.Context(), test.loops[0], WhatsAppLink{}, true)) require.Equal(t, 2, test.agents[1].memory.Len()) require.Contains(t, test.events[1].Last("work"), "The first chat is still open.") require.Zero(t, test.agents[0].memory.Len()) require.Empty(t, test.Queue(t).WhatsAppBatches) } func TestWhatsAppCatchupSeparatesChildAndRootBatches(t *testing.T) { dir := t.TempDir() test := newDaemonMessageTest(t, dir, newModelFake(t)) inbox := whatsappRouteTestInbox(t, dir) garden := WhatsAppChat{ ID: whatsappTestFamily.String(), Name: "Garden plans", Group: true, } library := WhatsAppChat{ ID: whatsappTestBruno.String(), Name: "Bruno", } root := WhatsAppChat{ ID: whatsappTestAna.String(), Name: "Ana", } require.NoError(t, test.messages.routes.Set("whatsapp:"+garden.ID, "Garden")) require.NoError(t, test.messages.routes.Set("whatsapp:"+library.ID, "Library")) inbox.waitForCatchup() whatsappRouteTestAdd(t, inbox, garden, "Check the gate.") whatsappRouteTestAdd(t, inbox, library, "Check the book.") whatsappRouteTestAdd(t, inbox, root, "Check the mixed chat.") require.NoError(t, inbox.deliver(t.Context(), test.loops[0], WhatsAppLink{}, false)) for _, agent := range test.agents { require.Zero(t, agent.memory.Len()) } inbox.endCatchup() require.NoError(t, inbox.deliver(t.Context(), test.loops[0], WhatsAppLink{}, false)) for _, loop := range test.loops { require.Equal(t, 1, loop.memory.Len()) require.True(t, loop.waiting()) } require.Contains(t, test.events[0].Last("work"), "Check the mixed chat.") require.Contains(t, test.events[1].Last("work"), "Check the gate.") require.Contains(t, test.events[2].Last("work"), "Check the book.") require.Empty(t, inbox.state.Closed()) require.Empty(t, test.Queue(t).WhatsAppBatches) } func TestRoutedWhatsAppQueueFailureKeepsTheInboxBatch(t *testing.T) { dir := t.TempDir() server := newModelFake(t) first := newDaemonMessageTest(t, dir, server) inbox := whatsappRouteTestInbox(t, dir) chat := WhatsAppChat{ ID: whatsappTestAna.String(), Name: "Ana", } require.NoError(t, first.messages.routes.Set("whatsapp:"+chat.ID, "Garden")) require.NoError(t, os.Mkdir(first.messages.path, 0o700)) whatsappRouteTestAdd(t, inbox, chat, "Keep the gate plan.") require.ErrorContains(t, inbox.deliver(t.Context(), first.loops[0], WhatsAppLink{}, true), "save agent messages") require.Len(t, inbox.state.Closed(), 1) for _, loop := range first.loops { require.ErrorContains(t, loop.failed(), "save agent messages") require.Zero(t, loop.memory.Len()) } path := first.messages.path first.Close(t) require.NoError(t, os.Remove(path)) second := newDaemonMessageTest(t, dir, server) restored := whatsappRouteTestInbox(t, dir) require.NoError(t, restored.deliver(t.Context(), second.loops[0], WhatsAppLink{}, true)) require.Equal(t, 1, second.agents[1].memory.Len()) require.True(t, second.loops[1].waiting()) require.Zero(t, second.agents[0].memory.Len()) require.Empty(t, restored.state.Closed()) } func whatsappRouteTestInbox(t *testing.T, dir string) *WhatsAppInbox { t.Helper() state, err := openWhatsAppState(filepath.Join(dir, whatsappDirName, whatsappStateFileName)) require.NoError(t, err) return newWhatsAppInbox(state, newTestClock().Now, newWhatsAppGroups(discardLog()), &ImageStore{dir: filepath.Join(dir, imageDirName)}, discardLog()) } func whatsappRouteTestAdd(t *testing.T, inbox *WhatsAppInbox, chat WhatsAppChat, text string) { t.Helper() at := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) _, err := inbox.state.Add(chat, WhatsAppMessage{ ID: text, Sender: "Ana", Text: text, Time: at, }, at) require.NoError(t, err) }