package main import ( "encoding/json" "net/http" "os" "path/filepath" "testing" "time" "github.com/stretchr/testify/require" ) 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), whatsappBatchQuiet, whatsappBatchWindow) require.NoError(t, err) return newWhatsAppInbox(state, newTestClock().Now, newWhatsAppGroups(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) }