14 "github.com/stretchr/testify/require"
17 func TestParentMessageWakesChildAndFinalReplyWakesParent(t *testing.T) {
18 server := newModelFake(t)
19 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
20 text := "I will ask the user."
21 if call.body["model"] == "gpt-test-garden" {
22 text = "Approve the gate size."
25 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5))
27 test := newDaemonMessageTest(t, t.TempDir(), server)
28 test.Start(t, 0, 1, 2)
29 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Check the gate size."))
30 test.events[0].WaitFor(t, "idle", 1)
31 test.events[1].WaitFor(t, "idle", 1)
33 require.Equal(t, "OneChat", test.events[1].LastEvent("user").Source)
34 require.Equal(t, "Check the gate size.", test.events[1].Last("user"))
35 require.Equal(t, "Garden", test.events[0].LastEvent("work").Source)
36 require.Equal(t, "[Garden] Approve the gate size.", test.events[0].Last("work"))
37 require.Equal(t, []string{"user", "busy", "reply", "idle"}, test.events[1].Kinds())
38 require.Equal(t, []string{"work", "busy", "reply", "idle"}, test.events[0].Kinds())
39 require.Equal(t, 2, server.Calls())
40 require.Zero(t, test.agents[2].memory.Len())
41 require.Empty(t, test.events[2].Kinds())
43 first, err := test.agents[1].memory.Message(0)
44 require.NoError(t, err)
45 require.Equal(t, "user", first.Kind)
47 report, err := test.agents[0].memory.Message(0)
48 require.NoError(t, err)
49 require.Equal(t, "work", report.Kind)
50 require.Equal(t, "[Garden] Approve the gate size.", report.Text)
51 require.Contains(t, test.logs.String(), "turn started name=Garden")
52 require.Contains(t, test.logs.String(), "turn done name=Garden")
53 require.NotContains(t, test.logs.String(), "gate size")
54 require.Empty(t, test.Queue(t).Pending)
56 info, err := os.Stat(test.messages.path)
57 require.NoError(t, err)
58 require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
61 func TestChildReportSendsATelegramCompletionBeforeTheParentReply(t *testing.T) {
62 server := newModelFake(t)
63 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
64 text := "I will ask the user."
65 if call.body["model"] == "gpt-test-garden" {
66 text = "Approve the gate size."
69 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5))
71 test := newDaemonMessageTest(t, t.TempDir(), server)
72 telegram := newTelegramFake(t)
73 bot := newTelegramBot(newTelegramAPI(telegram.URL, telegramTestToken), telegramTestOwner, filepath.Join(t.TempDir(), telegramOffsetFileName), discardLog())
74 test.loops[0].notify = func(event Event) {
75 test.events[0].Add(event)
79 ctx, cancel := context.WithCancel(t.Context())
82 done := make(chan struct{})
95 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Check the gate size."))
96 test.events[0].WaitFor(t, "idle", 1)
97 require.Eventually(t, func() bool {
98 return len(telegram.Sent()) >= 2
99 }, 10*time.Second, time.Millisecond)
100 require.Equal(t, []string{"[Garden] finished", "I will ask the user."}, telegram.Sent())
101 require.Equal(t, subagentFinished, test.events[0].LastEvent("work").Status)
102 require.Equal(t, "[Garden] Approve the gate size.", test.events[0].Last("work"))
105 func TestQueuedAgentMessageFailureKeepsItsOperationAndCause(t *testing.T) {
106 server := newModelFake(t)
107 test := newDaemonMessageTest(t, t.TempDir(), server)
108 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Keep the plan."))
110 failure := errors.New("fake queued message failure")
111 test.loops[1].fail(failure)
113 err := test.loops[1].Run(t.Context())
114 require.ErrorIs(t, err, failure)
115 require.ErrorContains(t, err, "check turn loop failure")
116 require.Zero(t, server.Calls())
119 func TestEmptyChildReplySendsNothingAndWakesNobody(t *testing.T) {
120 testCases := []struct {
125 name: "empty final reply",
129 name: "whitespace final reply",
133 for _, tc := range testCases {
134 t.Run(tc.name, func(t *testing.T) {
135 server := newModelFake(t)
136 server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
137 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(tc.text)}, 100, 0, 5))
139 test := newDaemonMessageTest(t, t.TempDir(), server)
140 test.Start(t, 0, 1, 2)
141 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Keep the plan."))
142 test.events[1].WaitFor(t, "idle", 1)
143 require.Equal(t, 1, server.Calls())
144 require.Zero(t, test.agents[0].memory.Len())
145 require.Empty(t, test.events[0].Kinds())
146 require.False(t, test.loops[0].waiting())
147 require.Empty(t, test.events[2].Kinds())
148 require.Empty(t, test.Queue(t).Pending)
153 func TestBusyChildQueuesTheNextParentMessage(t *testing.T) {
154 server := newModelFake(t)
155 arrived := make(chan struct{})
156 release := make(chan struct{})
157 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
158 if call.number == 0 {
163 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
165 test := newDaemonMessageTest(t, t.TempDir(), server)
167 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Check the first plan."))
169 require.True(t, test.loops[1].Busy())
170 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Check the second plan too."))
171 require.Equal(t, 1, server.Calls())
172 require.Len(t, test.Queue(t).Pending, 2)
174 test.events[1].WaitFor(t, "idle", 2)
175 require.Equal(t, 2, server.Calls())
177 texts := turnInputTexts(t, server.Call(1))
178 require.Equal(t, "Check the second plan too.", texts[len(texts)-1])
179 require.Empty(t, test.Queue(t).Pending)
182 func TestBusyParentQueuesItsChildReport(t *testing.T) {
183 server := newModelFake(t)
184 arrived := make(chan struct{})
185 release := make(chan struct{})
186 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
187 text := "The plan is ready."
188 if call.number == 0 {
192 text = "I will continue."
195 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5))
197 test := newDaemonMessageTest(t, t.TempDir(), server)
200 _, err := test.loops[0].Submit("terminal", "Start the work.")
201 require.NoError(t, err)
203 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Prepare the plan."))
204 test.events[1].WaitFor(t, "idle", 1)
205 require.Equal(t, "[Garden] The plan is ready.", test.events[0].Last("work"))
206 require.True(t, test.loops[0].Busy())
207 require.Equal(t, 2, server.Calls())
209 test.events[0].WaitFor(t, "idle", 2)
210 require.Equal(t, 3, server.Calls())
212 texts := turnInputTexts(t, server.Call(2))
213 require.Equal(t, "[Garden] The plan is ready.", texts[len(texts)-1])
214 require.Empty(t, test.Queue(t).Pending)
217 func TestChildReportsOnlyTheFinalReplyAndTakesQueuedToolInput(t *testing.T) {
218 server := newModelFake(t)
219 arrived := make(chan struct{})
220 release := make(chan struct{})
221 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
222 if call.number == 0 {
225 modelWriteEvents(w, modelCompletedEvent([]any{
226 modelMessageItem("I will check the plan."),
227 modelFunctionCallItem("fc_1", "call_1", "date", `{"id":0}`),
233 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("The plan needs approval.")}, 100, 0, 5))
235 test := newDaemonMessageTest(t, t.TempDir(), server)
237 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Check the plan."))
239 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Also check the date."))
241 test.events[1].WaitFor(t, "idle", 1)
242 require.Equal(t, 2, server.Calls())
243 require.Equal(t, 1, test.events[1].Count("busy"))
244 require.Equal(t, 1, test.events[0].Count("work"))
245 require.Equal(t, "[Garden] The plan needs approval.", test.events[0].Last("work"))
246 require.NotContains(t, test.events[0].Last("work"), "I will check")
248 texts := turnInputTexts(t, server.Call(1))
249 require.Equal(t, "Also check the date.", texts[len(texts)-1])
251 queue := test.Queue(t)
252 require.Len(t, queue.Pending, 1)
253 require.Equal(t, "work", queue.Pending[0].Kind)
256 func TestQueuedParentMessageSurvivesDaemonRestart(t *testing.T) {
258 server := newModelFake(t)
259 server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
260 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
262 first := newDaemonMessageTest(t, dir, server)
263 require.NoError(t, first.messages.Tell("OneChat", "Garden", "Remember the garden plan."))
264 require.Equal(t, 1, first.agents[1].memory.Len())
267 second := newDaemonMessageTest(t, dir, server)
268 require.Equal(t, 1, second.agents[1].memory.Len())
269 require.Equal(t, "OneChat", second.events[1].LastEvent("user").Source)
270 second.Start(t, 0, 1)
271 second.events[1].WaitFor(t, "idle", 1)
272 require.Equal(t, 1, server.Calls())
273 require.Empty(t, second.Queue(t).Pending)
276 third := newDaemonMessageTest(t, dir, server)
277 require.Equal(t, 1, third.agents[1].memory.Len())
278 require.False(t, third.loops[1].waiting())
279 require.Empty(t, third.events[1].Kinds())
282 func TestQueuedChildReportSurvivesDaemonRestart(t *testing.T) {
284 server := newModelFake(t)
285 first := newDaemonMessageTest(t, dir, server)
287 require.NoError(t, first.messages.Tell("OneChat", "Garden", "Review the plan."))
288 first.events[1].WaitFor(t, "idle", 1)
289 require.Equal(t, "[Garden] a line", first.events[0].Last("work"))
290 require.Len(t, first.Queue(t).Pending, 1)
293 second := newDaemonMessageTest(t, dir, server)
294 require.Equal(t, 1, second.agents[0].memory.Len())
295 require.False(t, second.loops[1].waiting())
296 second.Start(t, 0, 1)
297 second.events[0].WaitFor(t, "idle", 1)
298 require.Equal(t, 2, server.Calls())
299 require.Equal(t, 2, second.agents[0].memory.Len())
300 require.Empty(t, second.Queue(t).Pending)
303 third := newDaemonMessageTest(t, dir, server)
304 require.Equal(t, 2, third.agents[0].memory.Len())
305 require.False(t, third.loops[0].waiting())
306 require.Empty(t, third.events[0].Kinds())
309 func TestInterruptedChildDeliveryResumesAfterDaemonRestart(t *testing.T) {
311 server := newModelFake(t)
312 arrived := make(chan struct{})
313 release := make(chan struct{})
314 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
315 if call.number == 0 {
320 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
322 first := newDaemonMessageTest(t, dir, server)
324 require.NoError(t, first.messages.Tell("OneChat", "Garden", "Finish the garden plan."))
329 second := newDaemonMessageTest(t, dir, server)
330 require.Equal(t, 1, second.agents[1].memory.Len())
332 second.events[1].WaitFor(t, "idle", 1)
333 require.Equal(t, 2, server.Calls())
334 require.Equal(t, 1, second.agents[1].memory.Len())
335 require.Empty(t, second.Queue(t).Pending)
338 func TestInterruptedParentReportResumesAfterDaemonRestart(t *testing.T) {
340 server := newModelFake(t)
341 arrived := make(chan struct{})
342 release := make(chan struct{})
343 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
344 if call.number == 1 {
349 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("The plan is ready.")}, 100, 0, 5))
351 first := newDaemonMessageTest(t, dir, server)
353 require.NoError(t, first.messages.Tell("OneChat", "Garden", "Review the plan."))
358 second := newDaemonMessageTest(t, dir, server)
359 require.Equal(t, 1, second.agents[0].memory.Len())
360 require.False(t, second.loops[1].waiting())
361 second.Start(t, 0, 1)
362 second.events[0].WaitFor(t, "idle", 1)
363 require.Equal(t, 3, server.Calls())
364 require.Equal(t, 2, second.agents[0].memory.Len())
365 require.Empty(t, second.Queue(t).Pending)
367 texts := turnInputTexts(t, server.Call(2))
368 require.Equal(t, "[Garden] The plan is ready.", texts[len(texts)-1])
371 func TestAgentMessageWriteFailureStopsDelivery(t *testing.T) {
372 server := newModelFake(t)
373 test := newDaemonMessageTest(t, t.TempDir(), server)
374 require.NoError(t, os.Mkdir(test.messages.path, 0o700))
375 require.ErrorContains(t, test.messages.Tell("OneChat", "Garden", "Keep the plan."), "save agent messages")
376 require.ErrorContains(t, test.messages.Tell("OneChat", "Garden", "Do not send again."), "save agent messages")
378 for _, loop := range test.loops {
379 require.ErrorContains(t, loop.failed(), "save agent messages")
380 require.Zero(t, loop.memory.Len())
384 func TestAgentMessageSnapshotBlocksDelivery(t *testing.T) {
385 server := newModelFake(t)
386 test := newDaemonMessageTest(t, t.TempDir(), server)
387 entered := make(chan struct{})
388 release := make(chan struct{})
392 defer once.Do(func() {
396 snapshot := make(chan error, 1)
398 snapshot <- test.messages.Snapshot(func() error {
399 return daemonSnapshot(t.Context(), test.loops, func() error {
410 sent := make(chan error, 1)
411 started := make(chan struct{})
415 sent <- test.messages.Tell("OneChat", "Garden", "Record the plan after the copy.")
419 require.Never(t, func() bool {
421 }, 50*time.Millisecond, time.Millisecond)
425 require.NoError(t, <-snapshot)
426 require.NoError(t, <-sent)
427 require.Equal(t, 1, test.agents[1].memory.Len())
428 require.Len(t, test.Queue(t).Pending, 1)
431 func TestParentCanMessageOnlyItsOwnChildren(t *testing.T) {
432 server := newModelFake(t)
434 test := newDaemonMessageTest(t, t.TempDir(), server)
435 testCases := []struct {
441 name: "no sibling messages",
446 name: "no arbitrary child reports",
451 name: "no self messages",
456 name: "unknown sender",
461 name: "unknown recipient",
466 for _, tc := range testCases {
467 t.Run(tc.name, func(t *testing.T) {
468 require.ErrorContains(t, test.messages.Tell(tc.source, tc.target, "Do not deliver."), "not a child")
472 for _, agent := range test.agents {
473 require.Zero(t, agent.memory.Len())
477 func TestInvalidAgentMessageQueueStopsStartup(t *testing.T) {
478 server := newModelFake(t)
479 testCases := []struct {
488 name: "unknown sender",
489 file: `{"next":1,"pending":[{"id":1,"source":"Other","target":"Garden","kind":"user"}]}`,
492 name: "sibling delivery",
493 file: `{"next":1,"pending":[{"id":1,"source":"Library","target":"Garden","kind":"user"}]}`,
496 name: "invalid sequence",
497 file: `{"next":0,"pending":[{"id":1,"source":"OneChat","target":"Garden","kind":"user"}]}`,
500 for _, tc := range testCases {
501 t.Run(tc.name, func(t *testing.T) {
503 test := newDaemonMessageTest(t, dir, server)
504 path := test.messages.path
506 require.NoError(t, os.WriteFile(path, []byte(tc.file), 0o600))
508 agents, err := openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, newTestClock().Now, discardLog())
509 require.NoError(t, err)
512 require.NoError(t, agents.Close())
515 var loops []*TurnLoop
517 for _, agent := range agents {
518 loop, err := agent.Loop(nil, func(Event) {
520 require.NoError(t, err)
522 loops = append(loops, loop)
525 _, err = openDaemonMessages(loops)
526 require.Error(t, err)
528 for _, agent := range agents {
529 require.Zero(t, agent.memory.Len())
535 func TestChildScheduleWakesOnlyItsOwner(t *testing.T) {
536 server := newModelFake(t)
537 server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
538 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
540 test := newDaemonMessageTest(t, t.TempDir(), server)
541 child := test.agents[1]
542 at := child.clock().Add(time.Minute).Format(scheduleAtLayout)
543 output := test.loops[1].tools.run(t.Context(), "schedule_set", `{"text":"Check the plants.","at":"`+at+`","cron":null}`)
544 require.Contains(t, output, "Schedule 1 set.")
545 require.Equal(t, "Garden", child.schedules.sorted()[0].Owner)
547 child.schedules.clock = func() time.Time {
548 return child.clock().Add(time.Minute)
550 require.ErrorContains(t, child.schedules.deliver(t.Context(), test.loops[0]), "does not match")
551 require.NoError(t, child.schedules.deliver(t.Context(), test.loops[1]))
552 test.Start(t, 0, 1, 2)
553 test.events[1].WaitFor(t, "idle", 1)
554 require.Equal(t, 1, server.Calls())
555 require.Equal(t, child.agent.CacheKey(), server.Call(0).body["prompt_cache_key"])
556 require.Zero(t, test.agents[0].memory.Len())
557 require.Zero(t, test.agents[2].memory.Len())
558 require.Contains(t, test.events[1].LastEvent("work").Text, "Check the plants.")
559 require.Equal(t, scheduleSource, test.events[1].LastEvent("work").Source)
562 // DaemonMessageTest owns test agents and the loops selected by Start.
563 type DaemonMessageTest struct {
566 events []*TurnEventRecorder
567 messages *DaemonMessages
569 cancel context.CancelFunc
573 func (r *DaemonMessageTest) Start(t *testing.T, indices ...int) {
576 ctx, cancel := context.WithCancel(t.Context())
578 for _, index := range indices {
579 done := make(chan error, 1)
580 r.done = append(r.done, done)
582 done <- r.loops[index].Run(ctx)
587 func (r *DaemonMessageTest) Queue(t *testing.T) DaemonMessageFile {
590 data, err := os.ReadFile(r.messages.path)
591 require.NoError(t, err)
593 var file DaemonMessageFile
595 require.NoError(t, json.Unmarshal(data, &file))
600 func (r *DaemonMessageTest) Close(t *testing.T) {
606 for _, done := range r.done {
607 require.ErrorIs(t, <-done, context.Canceled)
614 require.NoError(t, r.agents.Close())
619 func newDaemonMessageTest(t *testing.T, dir string, server *ModelFake) *DaemonMessageTest {
622 _, err := os.Stat(filepath.Join(dir, daemonAgentDirName, "children"))
623 if errors.Is(err, os.ErrNotExist) {
624 daemonChildConfig(t, dir, "Garden")
625 daemonChildConfig(t, dir, "Library")
627 require.NoError(t, err)
630 test := &DaemonMessageTest{
633 test.agents, err = openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, newTestClock().Now, daemonTestLogger(test.logs))
634 require.NoError(t, err)
639 for _, agent := range test.agents {
640 events := &TurnEventRecorder{}
641 loop, err := agent.Loop(nil, events.Add)
642 require.NoError(t, err)
644 test.events = append(test.events, events)
645 test.loops = append(test.loops, loop)
648 test.messages, err = openDaemonMessages(test.loops)
649 require.NoError(t, err)