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()) } }) } } // DaemonMessageTest owns test agents and the loops selected by Start. type DaemonMessageTest struct { agents DaemonAgents loops []*TurnLoop events []*TurnEventRecorder messages *DaemonMessages logs *SyncBuffer cancel context.CancelFunc done []chan error } func (r *DaemonMessageTest) Start(t *testing.T, indices ...int) { t.Helper() ctx, cancel := context.WithCancel(t.Context()) r.cancel = cancel for _, index := range indices { done := make(chan error, 1) r.done = append(r.done, done) go func() { done <- r.loops[index].Run(ctx) }() } } func (r *DaemonMessageTest) Queue(t *testing.T) DaemonMessageFile { t.Helper() data, err := os.ReadFile(r.messages.path) require.NoError(t, err) var file DaemonMessageFile require.NoError(t, json.Unmarshal(data, &file)) return file } func (r *DaemonMessageTest) Close(t *testing.T) { t.Helper() if r.cancel != nil { r.cancel() for _, done := range r.done { require.ErrorIs(t, <-done, context.Canceled) } r.cancel = nil r.done = nil } require.NoError(t, r.agents.Close()) r.agents = nil } func newDaemonMessageTest(t *testing.T, dir string, server *ModelFake) *DaemonMessageTest { t.Helper() _, err := os.Stat(filepath.Join(dir, daemonAgentDirName, "children")) if errors.Is(err, os.ErrNotExist) { daemonChildConfig(t, dir, "Garden") daemonChildConfig(t, dir, "Library") } else { require.NoError(t, err) } test := &DaemonMessageTest{ logs: &SyncBuffer{}, } test.agents, err = openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, newTestClock().Now, daemonTestLogger(test.logs)) require.NoError(t, err) t.Cleanup(func() { test.Close(t) }) for _, agent := range test.agents { events := &TurnEventRecorder{} loop, err := agent.Loop(nil, events.Add) require.NoError(t, err) test.events = append(test.events, events) test.loops = append(test.loops, loop) } test.messages, err = openDaemonMessages(test.loops) require.NoError(t, err) return test }