+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
+}