]> git.giacomolli.uk - onechat/blob - daemon_message_test.go
Store shared private images by content hash
[onechat] / daemon_message_test.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "net/http"
7 "os"
8 "path/filepath"
9 "sync"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/require"
14 )
15
16 func TestParentMessageWakesChildAndFinalReplyWakesParent(t *testing.T) {
17 server := newModelFake(t)
18 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
19 text := "I will ask the user."
20 if call.body["model"] == "gpt-test-garden" {
21 text = "Approve the gate size."
22 }
23
24 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5))
25 }
26 test := newDaemonMessageTest(t, t.TempDir(), server)
27 test.Start(t, 0, 1, 2)
28 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Check the gate size."))
29 test.events[0].WaitFor(t, "idle", 1)
30 test.events[1].WaitFor(t, "idle", 1)
31
32 require.Equal(t, "OneChat", test.events[1].LastEvent("user").Source)
33 require.Equal(t, "Check the gate size.", test.events[1].Last("user"))
34 require.Equal(t, "Garden", test.events[0].LastEvent("work").Source)
35 require.Equal(t, "[Garden] Approve the gate size.", test.events[0].Last("work"))
36 require.Equal(t, []string{"user", "busy", "reply", "idle"}, test.events[1].Kinds())
37 require.Equal(t, []string{"work", "busy", "reply", "idle"}, test.events[0].Kinds())
38 require.Equal(t, 2, server.Calls())
39 require.Zero(t, test.agents[2].memory.Len())
40 require.Empty(t, test.events[2].Kinds())
41
42 first, err := test.agents[1].memory.Message(0)
43 require.NoError(t, err)
44 require.Equal(t, "user", first.Kind)
45
46 report, err := test.agents[0].memory.Message(0)
47 require.NoError(t, err)
48 require.Equal(t, "work", report.Kind)
49 require.Equal(t, "[Garden] Approve the gate size.", report.Text)
50 require.Contains(t, test.logs.String(), "turn started name=Garden")
51 require.Contains(t, test.logs.String(), "turn done name=Garden")
52 require.NotContains(t, test.logs.String(), "gate size")
53 require.Empty(t, test.Queue(t).Pending)
54
55 info, err := os.Stat(test.messages.path)
56 require.NoError(t, err)
57 require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
58 }
59
60 func TestChildReportSendsATelegramCompletionBeforeTheParentReply(t *testing.T) {
61 server := newModelFake(t)
62 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
63 text := "I will ask the user."
64 if call.body["model"] == "gpt-test-garden" {
65 text = "Approve the gate size."
66 }
67
68 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5))
69 }
70 test := newDaemonMessageTest(t, t.TempDir(), server)
71 telegram := newTelegramFake(t)
72 bot := newTelegramBot(newTelegramAPI(telegram.URL, telegramTestToken), telegramTestOwner, filepath.Join(t.TempDir(), telegramOffsetFileName), discardLog())
73 test.loops[0].notify = func(event Event) {
74 test.events[0].Add(event)
75 bot.Notify(event)
76 }
77
78 ctx, cancel := context.WithCancel(t.Context())
79 defer cancel()
80
81 done := make(chan struct{})
82 go func() {
83 defer close(done)
84
85 bot.send(ctx)
86 }()
87
88 t.Cleanup(func() {
89 cancel()
90 <-done
91 })
92
93 test.Start(t, 0, 1)
94 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Check the gate size."))
95 test.events[0].WaitFor(t, "idle", 1)
96 require.Eventually(t, func() bool {
97 return len(telegram.Sent()) >= 2
98 }, 10*time.Second, time.Millisecond)
99 require.Equal(t, []string{"[Garden] finished", "I will ask the user."}, telegram.Sent())
100 require.Equal(t, subagentFinished, test.events[0].LastEvent("work").Status)
101 require.Equal(t, "[Garden] Approve the gate size.", test.events[0].Last("work"))
102 }
103
104 func TestQueuedAgentMessageFailureKeepsItsOperationAndCause(t *testing.T) {
105 server := newModelFake(t)
106 test := newDaemonMessageTest(t, t.TempDir(), server)
107 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Keep the plan."))
108
109 failure := errors.New("fake queued message failure")
110 test.loops[1].fail(failure)
111
112 err := test.loops[1].Run(t.Context())
113 require.ErrorIs(t, err, failure)
114 require.ErrorContains(t, err, "check turn loop failure")
115 require.Zero(t, server.Calls())
116 }
117
118 func TestEmptyChildReplySendsNothingAndWakesNobody(t *testing.T) {
119 testCases := []struct {
120 name string
121 text string
122 }{
123 {
124 name: "empty final reply",
125 text: "",
126 },
127 {
128 name: "whitespace final reply",
129 text: " \n\t ",
130 },
131 }
132 for _, tc := range testCases {
133 t.Run(tc.name, func(t *testing.T) {
134 server := newModelFake(t)
135 server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
136 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(tc.text)}, 100, 0, 5))
137 }
138 test := newDaemonMessageTest(t, t.TempDir(), server)
139 test.Start(t, 0, 1, 2)
140 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Keep the plan."))
141 test.events[1].WaitFor(t, "idle", 1)
142 require.Equal(t, 1, server.Calls())
143 require.Zero(t, test.agents[0].memory.Len())
144 require.Empty(t, test.events[0].Kinds())
145 require.False(t, test.loops[0].waiting())
146 require.Empty(t, test.events[2].Kinds())
147 require.Empty(t, test.Queue(t).Pending)
148 })
149 }
150 }
151
152 func TestBusyChildQueuesTheNextParentMessage(t *testing.T) {
153 server := newModelFake(t)
154 arrived := make(chan struct{})
155 release := make(chan struct{})
156 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
157 if call.number == 0 {
158 close(arrived)
159 <-release
160 }
161
162 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
163 }
164 test := newDaemonMessageTest(t, t.TempDir(), server)
165 test.Start(t, 0, 1)
166 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Check the first plan."))
167 <-arrived
168 require.True(t, test.loops[1].Busy())
169 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Check the second plan too."))
170 require.Equal(t, 1, server.Calls())
171 require.Len(t, test.Queue(t).Pending, 2)
172 close(release)
173 test.events[1].WaitFor(t, "idle", 2)
174 require.Equal(t, 2, server.Calls())
175
176 texts := turnInputTexts(t, server.Call(1))
177 require.Equal(t, "Check the second plan too.", texts[len(texts)-1])
178 require.Empty(t, test.Queue(t).Pending)
179 }
180
181 func TestBusyParentQueuesItsChildReport(t *testing.T) {
182 server := newModelFake(t)
183 arrived := make(chan struct{})
184 release := make(chan struct{})
185 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
186 text := "The plan is ready."
187 if call.number == 0 {
188 close(arrived)
189 <-release
190
191 text = "I will continue."
192 }
193
194 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5))
195 }
196 test := newDaemonMessageTest(t, t.TempDir(), server)
197 test.Start(t, 0, 1)
198
199 _, err := test.loops[0].Submit("terminal", "Start the work.")
200 require.NoError(t, err)
201 <-arrived
202 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Prepare the plan."))
203 test.events[1].WaitFor(t, "idle", 1)
204 require.Equal(t, "[Garden] The plan is ready.", test.events[0].Last("work"))
205 require.True(t, test.loops[0].Busy())
206 require.Equal(t, 2, server.Calls())
207 close(release)
208 test.events[0].WaitFor(t, "idle", 2)
209 require.Equal(t, 3, server.Calls())
210
211 texts := turnInputTexts(t, server.Call(2))
212 require.Equal(t, "[Garden] The plan is ready.", texts[len(texts)-1])
213 require.Empty(t, test.Queue(t).Pending)
214 }
215
216 func TestChildReportsOnlyTheFinalReplyAndTakesQueuedToolInput(t *testing.T) {
217 server := newModelFake(t)
218 arrived := make(chan struct{})
219 release := make(chan struct{})
220 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
221 if call.number == 0 {
222 close(arrived)
223 <-release
224 modelWriteEvents(w, modelCompletedEvent([]any{
225 modelMessageItem("I will check the plan."),
226 modelFunctionCallItem("fc_1", "call_1", "date", `{"id":0}`),
227 }, 100, 0, 5))
228
229 return
230 }
231
232 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("The plan needs approval.")}, 100, 0, 5))
233 }
234 test := newDaemonMessageTest(t, t.TempDir(), server)
235 test.Start(t, 1)
236 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Check the plan."))
237 <-arrived
238 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Also check the date."))
239 close(release)
240 test.events[1].WaitFor(t, "idle", 1)
241 require.Equal(t, 2, server.Calls())
242 require.Equal(t, 1, test.events[1].Count("busy"))
243 require.Equal(t, 1, test.events[0].Count("work"))
244 require.Equal(t, "[Garden] The plan needs approval.", test.events[0].Last("work"))
245 require.NotContains(t, test.events[0].Last("work"), "I will check")
246
247 texts := turnInputTexts(t, server.Call(1))
248 require.Equal(t, "Also check the date.", texts[len(texts)-1])
249
250 queue := test.Queue(t)
251 require.Len(t, queue.Pending, 1)
252 require.Equal(t, "work", queue.Pending[0].Kind)
253 }
254
255 func TestQueuedParentMessageSurvivesDaemonRestart(t *testing.T) {
256 dir := t.TempDir()
257 server := newModelFake(t)
258 server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
259 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
260 }
261 first := newDaemonMessageTest(t, dir, server)
262 require.NoError(t, first.messages.Tell("OneChat", "Garden", "Remember the garden plan."))
263 require.Equal(t, 1, first.agents[1].memory.Len())
264 first.Close(t)
265
266 second := newDaemonMessageTest(t, dir, server)
267 require.Equal(t, 1, second.agents[1].memory.Len())
268 require.Equal(t, "OneChat", second.events[1].LastEvent("user").Source)
269 second.Start(t, 0, 1)
270 second.events[1].WaitFor(t, "idle", 1)
271 require.Equal(t, 1, server.Calls())
272 require.Empty(t, second.Queue(t).Pending)
273 second.Close(t)
274
275 third := newDaemonMessageTest(t, dir, server)
276 require.Equal(t, 1, third.agents[1].memory.Len())
277 require.False(t, third.loops[1].waiting())
278 require.Empty(t, third.events[1].Kinds())
279 }
280
281 func TestQueuedChildReportSurvivesDaemonRestart(t *testing.T) {
282 dir := t.TempDir()
283 server := newModelFake(t)
284 first := newDaemonMessageTest(t, dir, server)
285 first.Start(t, 1)
286 require.NoError(t, first.messages.Tell("OneChat", "Garden", "Review the plan."))
287 first.events[1].WaitFor(t, "idle", 1)
288 require.Equal(t, "[Garden] a line", first.events[0].Last("work"))
289 require.Len(t, first.Queue(t).Pending, 1)
290 first.Close(t)
291
292 second := newDaemonMessageTest(t, dir, server)
293 require.Equal(t, 1, second.agents[0].memory.Len())
294 require.False(t, second.loops[1].waiting())
295 second.Start(t, 0, 1)
296 second.events[0].WaitFor(t, "idle", 1)
297 require.Equal(t, 2, server.Calls())
298 require.Equal(t, 2, second.agents[0].memory.Len())
299 require.Empty(t, second.Queue(t).Pending)
300 second.Close(t)
301
302 third := newDaemonMessageTest(t, dir, server)
303 require.Equal(t, 2, third.agents[0].memory.Len())
304 require.False(t, third.loops[0].waiting())
305 require.Empty(t, third.events[0].Kinds())
306 }
307
308 func TestInterruptedChildDeliveryResumesAfterDaemonRestart(t *testing.T) {
309 dir := t.TempDir()
310 server := newModelFake(t)
311 arrived := make(chan struct{})
312 release := make(chan struct{})
313 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
314 if call.number == 0 {
315 close(arrived)
316 <-release
317 }
318
319 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
320 }
321 first := newDaemonMessageTest(t, dir, server)
322 first.Start(t, 1)
323 require.NoError(t, first.messages.Tell("OneChat", "Garden", "Finish the garden plan."))
324 <-arrived
325 first.Close(t)
326 close(release)
327
328 second := newDaemonMessageTest(t, dir, server)
329 require.Equal(t, 1, second.agents[1].memory.Len())
330 second.Start(t, 1)
331 second.events[1].WaitFor(t, "idle", 1)
332 require.Equal(t, 2, server.Calls())
333 require.Equal(t, 1, second.agents[1].memory.Len())
334 require.Empty(t, second.Queue(t).Pending)
335 }
336
337 func TestInterruptedParentReportResumesAfterDaemonRestart(t *testing.T) {
338 dir := t.TempDir()
339 server := newModelFake(t)
340 arrived := make(chan struct{})
341 release := make(chan struct{})
342 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
343 if call.number == 1 {
344 close(arrived)
345 <-release
346 }
347
348 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("The plan is ready.")}, 100, 0, 5))
349 }
350 first := newDaemonMessageTest(t, dir, server)
351 first.Start(t, 0, 1)
352 require.NoError(t, first.messages.Tell("OneChat", "Garden", "Review the plan."))
353 <-arrived
354 first.Close(t)
355 close(release)
356
357 second := newDaemonMessageTest(t, dir, server)
358 require.Equal(t, 1, second.agents[0].memory.Len())
359 require.False(t, second.loops[1].waiting())
360 second.Start(t, 0, 1)
361 second.events[0].WaitFor(t, "idle", 1)
362 require.Equal(t, 3, server.Calls())
363 require.Equal(t, 2, second.agents[0].memory.Len())
364 require.Empty(t, second.Queue(t).Pending)
365
366 texts := turnInputTexts(t, server.Call(2))
367 require.Equal(t, "[Garden] The plan is ready.", texts[len(texts)-1])
368 }
369
370 func TestAgentMessageWriteFailureStopsDelivery(t *testing.T) {
371 server := newModelFake(t)
372 test := newDaemonMessageTest(t, t.TempDir(), server)
373 require.NoError(t, os.Mkdir(test.messages.path, 0o700))
374 require.ErrorContains(t, test.messages.Tell("OneChat", "Garden", "Keep the plan."), "save agent messages")
375 require.ErrorContains(t, test.messages.Tell("OneChat", "Garden", "Do not send again."), "save agent messages")
376
377 for _, loop := range test.loops {
378 require.ErrorContains(t, loop.failed(), "save agent messages")
379 require.Zero(t, loop.memory.Len())
380 }
381 }
382
383 func TestAgentMessageSnapshotBlocksDelivery(t *testing.T) {
384 server := newModelFake(t)
385 test := newDaemonMessageTest(t, t.TempDir(), server)
386 entered := make(chan struct{})
387 release := make(chan struct{})
388
389 var once sync.Once
390
391 defer once.Do(func() {
392 close(release)
393 })
394
395 snapshot := make(chan error, 1)
396 go func() {
397 snapshot <- test.messages.Snapshot(func() error {
398 return daemonSnapshot(t.Context(), test.loops, func() error {
399 close(entered)
400 <-release
401
402 return nil
403 })
404 })
405 }()
406
407 <-entered
408
409 sent := make(chan error, 1)
410 started := make(chan struct{})
411 go func() {
412 close(started)
413
414 sent <- test.messages.Tell("OneChat", "Garden", "Record the plan after the copy.")
415 }()
416
417 <-started
418 require.Never(t, func() bool {
419 return len(sent) > 0
420 }, 50*time.Millisecond, time.Millisecond)
421 once.Do(func() {
422 close(release)
423 })
424 require.NoError(t, <-snapshot)
425 require.NoError(t, <-sent)
426 require.Equal(t, 1, test.agents[1].memory.Len())
427 require.Len(t, test.Queue(t).Pending, 1)
428 }
429
430 func TestParentCanMessageOnlyItsOwnChildren(t *testing.T) {
431 server := newModelFake(t)
432
433 test := newDaemonMessageTest(t, t.TempDir(), server)
434 testCases := []struct {
435 name string
436 source string
437 target string
438 }{
439 {
440 name: "no sibling messages",
441 source: "Garden",
442 target: "Library",
443 },
444 {
445 name: "no arbitrary child reports",
446 source: "Garden",
447 target: "OneChat",
448 },
449 {
450 name: "no self messages",
451 source: "OneChat",
452 target: "OneChat",
453 },
454 {
455 name: "unknown sender",
456 source: "Other",
457 target: "Garden",
458 },
459 {
460 name: "unknown recipient",
461 source: "OneChat",
462 target: "Other",
463 },
464 }
465 for _, tc := range testCases {
466 t.Run(tc.name, func(t *testing.T) {
467 require.ErrorContains(t, test.messages.Tell(tc.source, tc.target, "Do not deliver."), "not a child")
468 })
469 }
470
471 for _, agent := range test.agents {
472 require.Zero(t, agent.memory.Len())
473 }
474 }
475
476 func TestInvalidAgentMessageQueueStopsStartup(t *testing.T) {
477 server := newModelFake(t)
478 testCases := []struct {
479 name string
480 file string
481 }{
482 {
483 name: "broken JSON",
484 file: "{",
485 },
486 {
487 name: "unknown sender",
488 file: `{"next":1,"pending":[{"id":1,"source":"Other","target":"Garden","kind":"user"}]}`,
489 },
490 {
491 name: "sibling delivery",
492 file: `{"next":1,"pending":[{"id":1,"source":"Library","target":"Garden","kind":"user"}]}`,
493 },
494 {
495 name: "invalid sequence",
496 file: `{"next":0,"pending":[{"id":1,"source":"OneChat","target":"Garden","kind":"user"}]}`,
497 },
498 }
499 for _, tc := range testCases {
500 t.Run(tc.name, func(t *testing.T) {
501 dir := t.TempDir()
502 test := newDaemonMessageTest(t, dir, server)
503 path := test.messages.path
504 test.Close(t)
505 require.NoError(t, os.WriteFile(path, []byte(tc.file), 0o600))
506
507 agents, err := openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, newTestClock().Now, discardLog())
508 require.NoError(t, err)
509
510 defer func() {
511 require.NoError(t, agents.Close())
512 }()
513
514 var loops []*TurnLoop
515
516 for _, agent := range agents {
517 loop, err := agent.Loop(nil, func(Event) {
518 })
519 require.NoError(t, err)
520
521 loops = append(loops, loop)
522 }
523
524 _, err = openDaemonMessages(loops)
525 require.Error(t, err)
526
527 for _, agent := range agents {
528 require.Zero(t, agent.memory.Len())
529 }
530 })
531 }
532 }
533
534 func TestChildScheduleWakesOnlyItsOwner(t *testing.T) {
535 server := newModelFake(t)
536 server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
537 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
538 }
539 test := newDaemonMessageTest(t, t.TempDir(), server)
540 child := test.agents[1]
541 at := child.clock().Add(time.Minute).Format(scheduleAtLayout)
542 output := test.loops[1].tools.run(t.Context(), "schedule_set", `{"text":"Check the plants.","at":"`+at+`","cron":null}`)
543 require.Contains(t, output, "Schedule 1 set.")
544 require.Equal(t, "Garden", child.schedules.sorted()[0].Owner)
545
546 child.schedules.clock = func() time.Time {
547 return child.clock().Add(time.Minute)
548 }
549 require.ErrorContains(t, child.schedules.deliver(t.Context(), test.loops[0]), "does not match")
550 require.NoError(t, child.schedules.deliver(t.Context(), test.loops[1]))
551 test.Start(t, 0, 1, 2)
552 test.events[1].WaitFor(t, "idle", 1)
553 require.Equal(t, 1, server.Calls())
554 require.Equal(t, child.agent.CacheKey(), server.Call(0).body["prompt_cache_key"])
555 require.Zero(t, test.agents[0].memory.Len())
556 require.Zero(t, test.agents[2].memory.Len())
557 require.Contains(t, test.events[1].LastEvent("work").Text, "Check the plants.")
558 require.Equal(t, scheduleSource, test.events[1].LastEvent("work").Source)
559 }