]> git.giacomolli.uk - onechat/blob - daemon_message_test.go
f9c25f13ad8e231e9c180a8f496bf9e818c48618
[onechat] / daemon_message_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "net/http"
8 "os"
9 "path/filepath"
10 "sync"
11 "testing"
12 "time"
13
14 "github.com/stretchr/testify/require"
15 )
16
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."
23 }
24
25 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5))
26 }
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)
32
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())
42
43 first, err := test.agents[1].memory.Message(0)
44 require.NoError(t, err)
45 require.Equal(t, "user", first.Kind)
46
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)
55
56 info, err := os.Stat(test.messages.path)
57 require.NoError(t, err)
58 require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
59 }
60
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."
67 }
68
69 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5))
70 }
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)
76 bot.Notify(event)
77 }
78
79 ctx, cancel := context.WithCancel(t.Context())
80 defer cancel()
81
82 done := make(chan struct{})
83 go func() {
84 defer close(done)
85
86 bot.send(ctx)
87 }()
88
89 t.Cleanup(func() {
90 cancel()
91 <-done
92 })
93
94 test.Start(t, 0, 1)
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"))
103 }
104
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."))
109
110 failure := errors.New("fake queued message failure")
111 test.loops[1].fail(failure)
112
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())
117 }
118
119 func TestEmptyChildReplySendsNothingAndWakesNobody(t *testing.T) {
120 testCases := []struct {
121 name string
122 text string
123 }{
124 {
125 name: "empty final reply",
126 text: "",
127 },
128 {
129 name: "whitespace final reply",
130 text: " \n\t ",
131 },
132 }
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))
138 }
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)
149 })
150 }
151 }
152
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 {
159 close(arrived)
160 <-release
161 }
162
163 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
164 }
165 test := newDaemonMessageTest(t, t.TempDir(), server)
166 test.Start(t, 0, 1)
167 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Check the first plan."))
168 <-arrived
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)
173 close(release)
174 test.events[1].WaitFor(t, "idle", 2)
175 require.Equal(t, 2, server.Calls())
176
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)
180 }
181
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 {
189 close(arrived)
190 <-release
191
192 text = "I will continue."
193 }
194
195 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5))
196 }
197 test := newDaemonMessageTest(t, t.TempDir(), server)
198 test.Start(t, 0, 1)
199
200 _, err := test.loops[0].Submit("terminal", "Start the work.")
201 require.NoError(t, err)
202 <-arrived
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())
208 close(release)
209 test.events[0].WaitFor(t, "idle", 2)
210 require.Equal(t, 3, server.Calls())
211
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)
215 }
216
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 {
223 close(arrived)
224 <-release
225 modelWriteEvents(w, modelCompletedEvent([]any{
226 modelMessageItem("I will check the plan."),
227 modelFunctionCallItem("fc_1", "call_1", "date", `{"id":0}`),
228 }, 100, 0, 5))
229
230 return
231 }
232
233 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("The plan needs approval.")}, 100, 0, 5))
234 }
235 test := newDaemonMessageTest(t, t.TempDir(), server)
236 test.Start(t, 1)
237 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Check the plan."))
238 <-arrived
239 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Also check the date."))
240 close(release)
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")
247
248 texts := turnInputTexts(t, server.Call(1))
249 require.Equal(t, "Also check the date.", texts[len(texts)-1])
250
251 queue := test.Queue(t)
252 require.Len(t, queue.Pending, 1)
253 require.Equal(t, "work", queue.Pending[0].Kind)
254 }
255
256 func TestQueuedParentMessageSurvivesDaemonRestart(t *testing.T) {
257 dir := t.TempDir()
258 server := newModelFake(t)
259 server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
260 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
261 }
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())
265 first.Close(t)
266
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)
274 second.Close(t)
275
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())
280 }
281
282 func TestQueuedChildReportSurvivesDaemonRestart(t *testing.T) {
283 dir := t.TempDir()
284 server := newModelFake(t)
285 first := newDaemonMessageTest(t, dir, server)
286 first.Start(t, 1)
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)
291 first.Close(t)
292
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)
301 second.Close(t)
302
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())
307 }
308
309 func TestInterruptedChildDeliveryResumesAfterDaemonRestart(t *testing.T) {
310 dir := t.TempDir()
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 {
316 close(arrived)
317 <-release
318 }
319
320 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
321 }
322 first := newDaemonMessageTest(t, dir, server)
323 first.Start(t, 1)
324 require.NoError(t, first.messages.Tell("OneChat", "Garden", "Finish the garden plan."))
325 <-arrived
326 first.Close(t)
327 close(release)
328
329 second := newDaemonMessageTest(t, dir, server)
330 require.Equal(t, 1, second.agents[1].memory.Len())
331 second.Start(t, 1)
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)
336 }
337
338 func TestInterruptedParentReportResumesAfterDaemonRestart(t *testing.T) {
339 dir := t.TempDir()
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 {
345 close(arrived)
346 <-release
347 }
348
349 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("The plan is ready.")}, 100, 0, 5))
350 }
351 first := newDaemonMessageTest(t, dir, server)
352 first.Start(t, 0, 1)
353 require.NoError(t, first.messages.Tell("OneChat", "Garden", "Review the plan."))
354 <-arrived
355 first.Close(t)
356 close(release)
357
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)
366
367 texts := turnInputTexts(t, server.Call(2))
368 require.Equal(t, "[Garden] The plan is ready.", texts[len(texts)-1])
369 }
370
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")
377
378 for _, loop := range test.loops {
379 require.ErrorContains(t, loop.failed(), "save agent messages")
380 require.Zero(t, loop.memory.Len())
381 }
382 }
383
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{})
389
390 var once sync.Once
391
392 defer once.Do(func() {
393 close(release)
394 })
395
396 snapshot := make(chan error, 1)
397 go func() {
398 snapshot <- test.messages.Snapshot(func() error {
399 return daemonSnapshot(t.Context(), test.loops, func() error {
400 close(entered)
401 <-release
402
403 return nil
404 })
405 })
406 }()
407
408 <-entered
409
410 sent := make(chan error, 1)
411 started := make(chan struct{})
412 go func() {
413 close(started)
414
415 sent <- test.messages.Tell("OneChat", "Garden", "Record the plan after the copy.")
416 }()
417
418 <-started
419 require.Never(t, func() bool {
420 return len(sent) > 0
421 }, 50*time.Millisecond, time.Millisecond)
422 once.Do(func() {
423 close(release)
424 })
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)
429 }
430
431 func TestParentCanMessageOnlyItsOwnChildren(t *testing.T) {
432 server := newModelFake(t)
433
434 test := newDaemonMessageTest(t, t.TempDir(), server)
435 testCases := []struct {
436 name string
437 source string
438 target string
439 }{
440 {
441 name: "no sibling messages",
442 source: "Garden",
443 target: "Library",
444 },
445 {
446 name: "no arbitrary child reports",
447 source: "Garden",
448 target: "OneChat",
449 },
450 {
451 name: "no self messages",
452 source: "OneChat",
453 target: "OneChat",
454 },
455 {
456 name: "unknown sender",
457 source: "Other",
458 target: "Garden",
459 },
460 {
461 name: "unknown recipient",
462 source: "OneChat",
463 target: "Other",
464 },
465 }
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")
469 })
470 }
471
472 for _, agent := range test.agents {
473 require.Zero(t, agent.memory.Len())
474 }
475 }
476
477 func TestInvalidAgentMessageQueueStopsStartup(t *testing.T) {
478 server := newModelFake(t)
479 testCases := []struct {
480 name string
481 file string
482 }{
483 {
484 name: "broken JSON",
485 file: "{",
486 },
487 {
488 name: "unknown sender",
489 file: `{"next":1,"pending":[{"id":1,"source":"Other","target":"Garden","kind":"user"}]}`,
490 },
491 {
492 name: "sibling delivery",
493 file: `{"next":1,"pending":[{"id":1,"source":"Library","target":"Garden","kind":"user"}]}`,
494 },
495 {
496 name: "invalid sequence",
497 file: `{"next":0,"pending":[{"id":1,"source":"OneChat","target":"Garden","kind":"user"}]}`,
498 },
499 }
500 for _, tc := range testCases {
501 t.Run(tc.name, func(t *testing.T) {
502 dir := t.TempDir()
503 test := newDaemonMessageTest(t, dir, server)
504 path := test.messages.path
505 test.Close(t)
506 require.NoError(t, os.WriteFile(path, []byte(tc.file), 0o600))
507
508 agents, err := openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, newTestClock().Now, discardLog())
509 require.NoError(t, err)
510
511 defer func() {
512 require.NoError(t, agents.Close())
513 }()
514
515 var loops []*TurnLoop
516
517 for _, agent := range agents {
518 loop, err := agent.Loop(nil, func(Event) {
519 })
520 require.NoError(t, err)
521
522 loops = append(loops, loop)
523 }
524
525 _, err = openDaemonMessages(loops)
526 require.Error(t, err)
527
528 for _, agent := range agents {
529 require.Zero(t, agent.memory.Len())
530 }
531 })
532 }
533 }
534
535 // DaemonMessageTest owns test agents and the loops selected by Start.
536 type DaemonMessageTest struct {
537 agents DaemonAgents
538 loops []*TurnLoop
539 events []*TurnEventRecorder
540 messages *DaemonMessages
541 logs *SyncBuffer
542 cancel context.CancelFunc
543 done []chan error
544 }
545
546 func (r *DaemonMessageTest) Start(t *testing.T, indices ...int) {
547 t.Helper()
548
549 ctx, cancel := context.WithCancel(t.Context())
550 r.cancel = cancel
551 for _, index := range indices {
552 done := make(chan error, 1)
553 r.done = append(r.done, done)
554 go func() {
555 done <- r.loops[index].Run(ctx)
556 }()
557 }
558 }
559
560 func (r *DaemonMessageTest) Queue(t *testing.T) DaemonMessageFile {
561 t.Helper()
562
563 data, err := os.ReadFile(r.messages.path)
564 require.NoError(t, err)
565
566 var file DaemonMessageFile
567
568 require.NoError(t, json.Unmarshal(data, &file))
569
570 return file
571 }
572
573 func (r *DaemonMessageTest) Close(t *testing.T) {
574 t.Helper()
575
576 if r.cancel != nil {
577 r.cancel()
578
579 for _, done := range r.done {
580 require.ErrorIs(t, <-done, context.Canceled)
581 }
582
583 r.cancel = nil
584 r.done = nil
585 }
586
587 require.NoError(t, r.agents.Close())
588
589 r.agents = nil
590 }
591
592 func newDaemonMessageTest(t *testing.T, dir string, server *ModelFake) *DaemonMessageTest {
593 t.Helper()
594
595 _, err := os.Stat(filepath.Join(dir, daemonAgentDirName, "children"))
596 if errors.Is(err, os.ErrNotExist) {
597 daemonChildConfig(t, dir, "Garden")
598 daemonChildConfig(t, dir, "Library")
599 } else {
600 require.NoError(t, err)
601 }
602
603 test := &DaemonMessageTest{
604 logs: &SyncBuffer{},
605 }
606 test.agents, err = openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, newTestClock().Now, daemonTestLogger(test.logs))
607 require.NoError(t, err)
608 t.Cleanup(func() {
609 test.Close(t)
610 })
611
612 for _, agent := range test.agents {
613 events := &TurnEventRecorder{}
614 loop, err := agent.Loop(nil, events.Add)
615 require.NoError(t, err)
616
617 test.events = append(test.events, events)
618 test.loops = append(test.loops, loop)
619 }
620
621 test.messages, err = openDaemonMessages(test.loops)
622 require.NoError(t, err)
623
624 return test
625 }