]> git.giacomolli.uk - onechat/blob - subagent_run_test.go
Give the WhatsApp batches to the agent
[onechat] / subagent_run_test.go
1 package main
2
3 import (
4         "context"
5         "io"
6         "net/http"
7         "os"
8         "path/filepath"
9         "strconv"
10         "strings"
11         "sync/atomic"
12         "testing"
13         "time"
14
15         "github.com/stretchr/testify/require"
16 )
17
18 // subagentTestFullContext is the error body of a call whose input does not fit
19 // in the context of the model.
20 const subagentTestFullContext = `{"error": {"message": "Your input exceeds the context window of this model.", "type": "invalid_request_error", "code": "context_length_exceeded"}}`
21
22 func TestSpawnRunsASubagentInTheBackground(t *testing.T) {
23         server := newModelFake(t)
24         var test *TurnTestLoop
25         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
26                 last := subagentTestLastItem(call)
27                 switch {
28                 case subagentTestCall(call) && last["type"] == "function_call_output":
29                         // The report comes after the reply of the turn of the spawn.
30                         test.events.WaitFor(t, "reply", 1)
31                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("There are 3 files.")}, 100, 0, 5))
32                 case subagentTestCall(call):
33                         modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "shell", `{"command":"pwd"}`)}, 100, 0, 5))
34                 case last["type"] == "function_call_output":
35                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Started.")}, 100, 0, 5))
36                 case strings.HasPrefix(subagentTestText(last), "["):
37                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Noted.")}, 100, 0, 5))
38                 default:
39                         modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "spawn", `{"tasks":["Count the files."],"effort":null}`)}, 100, 0, 5))
40                 }
41         }
42
43         test = newTestTurnLoop(t, server)
44         _, err := test.loop.Submit(terminalSource, "Count the files in a subagent.")
45         require.NoError(t, err)
46         test.events.WaitFor(t, "idle", 2)
47
48         name := test.events.Last("subagent")
49         require.True(t, subagentNameValid(name), name)
50         work := filepath.Join(test.loop.runs.subagents.dir, name, "work")
51         require.DirExists(t, work)
52
53         report := "[" + name + "]\nThere are 3 files.\n\nFull chat: zoom(\"" + name + "\")"
54         require.Equal(t, []string{
55                 "user: Count the files in a subagent.",
56                 `tool: spawn {"tasks":["Count the files."],"effort":null}`,
57                 "echo: Started " + name + ". Each one's report will reach you as a message when it finishes.",
58                 "reply: Started.",
59                 "work: " + report,
60                 "reply: Noted.",
61         }, test.Log(t))
62         require.Equal(t, Event{
63                 Kind:   "work",
64                 ID:     4,
65                 Text:   report,
66                 Source: name,
67                 Status: subagentFinished,
68         }, test.events.LastEvent("work"))
69
70         var first, second *ModelFakeCall
71         for n := range server.Calls() {
72                 call := server.Call(n)
73                 if !subagentTestCall(call) {
74                         continue
75                 }
76
77                 if first == nil {
78                         first = &call
79                 } else {
80                         second = &call
81                 }
82         }
83
84         require.NotNil(t, second)
85
86         // The first request of the subagent holds the view, the state, and its
87         // whole log, with its own prompt and tools.
88         texts := turnInputTexts(t, *first)
89         view := strings.Join(texts[:len(texts)-2], "")
90         require.True(t, strings.HasPrefix(view, "<chat>\n0+1|user: Count the files in a subagent.\n1+1|tool: spawn"), view)
91         require.Equal(t, "</chat>\n\nNow: Thursday 2026-01-01 12:00 +00:00", texts[len(texts)-2])
92         require.Equal(t, "0|note: subagent\n\n1|user: Count the files.", texts[len(texts)-1])
93
94         instructions, err := test.agent.SubagentPrompt()
95         require.NoError(t, err)
96         require.Equal(t, instructions, first.body["instructions"])
97
98         var tools []string
99         for _, tool := range first.body["tools"].([]any) {
100                 tools = append(tools, tool.(map[string]any)["name"].(string))
101         }
102
103         require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit"}, tools)
104         require.Equal(t, work+"\n", subagentTestLastItem(*second)["output"])
105
106         subagent, err := test.loop.runs.subagents.Get(t.Context(), name)
107         require.NoError(t, err)
108
109         log, err := subagent.Show()
110         require.NoError(t, err)
111         require.Equal(t, "0|note: subagent\n\n1|user: Count the files.\n\n2|tool: shell {\"command\":\"pwd\"}\n\n3|echo: "+work+"\n\n\n4|reply: There are 3 files.", log)
112
113         var agents []string
114         for _, record := range modelReadUsage(t, test.usagePath) {
115                 agents = append(agents, record.Agent)
116         }
117
118         require.ElementsMatch(t, []string{"", "", "", name, name}, agents)
119 }
120
121 func TestReportJoinsTheNextRequestOfARunningTurn(t *testing.T) {
122         server := newModelFake(t)
123         arrived := make(chan struct{})
124         release := make(chan struct{})
125         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
126                 if call.number == 0 {
127                         close(arrived)
128                         <-release
129                         modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "date", `{"id":0}`)}, 100, 0, 5))
130
131                         return
132                 }
133
134                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("done")}, 100, 0, 5))
135         }
136
137         test := newTestTurnLoop(t, server)
138         _, err := test.loop.Submit(terminalSource, "first")
139         require.NoError(t, err)
140
141         <-arrived
142         report := subagentReport("GreenOtter", []string{"It is done."})
143         test.loop.Report("GreenOtter", report, subagentFinished)
144         close(release)
145
146         test.events.WaitFor(t, "idle", 1)
147         require.Equal(t, report, subagentTestText(subagentTestLastItem(server.Call(1))))
148
149         time.Sleep(50 * time.Millisecond)
150         require.Equal(t, 2, server.Calls())
151         require.Equal(t, 1, test.events.Count("busy"))
152 }
153
154 func TestReportOfAStoppedSubagentStartsNoTurn(t *testing.T) {
155         server := newModelFake(t)
156         test := newTestTurnLoop(t, server)
157         report := subagentReport("GreenOtter", []string{"failed: context canceled"})
158         test.loop.Report("GreenOtter", report, subagentStopped)
159
160         test.events.WaitFor(t, "work", 1)
161         time.Sleep(50 * time.Millisecond)
162         require.Equal(t, []string{"work: " + report}, test.Log(t))
163         require.Zero(t, server.Calls())
164         require.Zero(t, test.events.Count("busy"))
165 }
166
167 func TestFullContextGivesAFailedReport(t *testing.T) {
168         server := newModelFake(t)
169         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
170                 if subagentTestCall(call) {
171                         w.WriteHeader(http.StatusBadRequest)
172                         _, _ = io.WriteString(w, subagentTestFullContext)
173
174                         return
175                 }
176
177                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("It failed.")}, 100, 0, 5))
178         }
179
180         test := newTestTurnLoop(t, server)
181         _, err := test.loop.runs.Spawn(t.Context(), []string{"Read the big file."}, nil)
182         require.NoError(t, err)
183         test.events.WaitFor(t, "idle", 1)
184
185         name := test.events.Last("subagent")
186         require.Equal(t, []string{
187                 "work: [" + name + "]\nfailed: model call: HTTP 400: " + subagentTestFullContext + "\n\nFull chat: zoom(\"" + name + "\")",
188                 "reply: It failed.",
189         }, test.Log(t))
190         require.Equal(t, subagentFailed, test.events.LastEvent("work").Status)
191 }
192
193 func TestSubagentHasItsOwnEffortAndNoSpawn(t *testing.T) {
194         server := newModelFake(t)
195         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
196                 if subagentTestCall(call) && subagentTestLastItem(call)["type"] != "function_call_output" {
197                         modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "spawn", `{"tasks":["More."],"effort":null}`)}, 100, 0, 5))
198
199                         return
200                 }
201
202                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5))
203         }
204
205         test := newTestTurnLoop(t, server)
206         runs := test.loop.runs
207         _, err := runs.Spawn(t.Context(), nil, nil)
208         require.EqualError(t, err, "no tasks")
209
210         effort := "max"
211         _, err = runs.Spawn(t.Context(), []string{"Think."}, &effort)
212         require.EqualError(t, err, `no effort "max": one of low, medium, high, xhigh`)
213
214         effort = "high"
215         _, err = runs.Spawn(t.Context(), []string{"Think hard."}, &effort)
216         require.NoError(t, err)
217         test.events.WaitFor(t, "idle", 1)
218
219         for n := range server.Calls() {
220                 call := server.Call(n)
221                 if subagentTestCall(call) {
222                         require.Equal(t, "high", call.body["reasoning"].(map[string]any)["effort"])
223                 }
224         }
225
226         subagent, err := runs.subagents.Get(t.Context(), test.events.Last("subagent"))
227         require.NoError(t, err)
228
229         log, err := subagent.Show()
230         require.NoError(t, err)
231         require.Equal(t, "0|note: subagent at effort high\n\n1|user: Think hard.\n\n2|tool: spawn {\"tasks\":[\"More.\"],\"effort\":null}\n\n3|echo: error: unknown tool \"spawn\"\n\n4|reply: ok", log)
232
233         entries, err := os.ReadDir(runs.subagents.dir)
234         require.NoError(t, err)
235         require.Len(t, entries, 1)
236 }
237
238 func TestTellReachesARunningSubagentBetweenItsToolCalls(t *testing.T) {
239         server := newModelFake(t)
240         arrived := make(chan struct{})
241         told := make(chan struct{})
242         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
243                 switch {
244                 case !subagentTestCall(call):
245                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Noted.")}, 100, 0, 5))
246                 case call.number == 0:
247                         close(arrived)
248                         <-told
249                         modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "shell", `{"command":"true"}`)}, 100, 0, 5))
250                 default:
251                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Used the second disk.")}, 100, 0, 5))
252                 }
253         }
254
255         test := newTestTurnLoop(t, server)
256         runs := test.loop.runs
257         _, err := runs.Spawn(t.Context(), []string{"Copy the files."}, nil)
258         require.NoError(t, err)
259
260         <-arrived
261         name := test.events.Last("subagent")
262         output, err := runs.Tell(t.Context(), name, "Use the second disk.")
263         require.NoError(t, err)
264         require.Equal(t, "Sent. "+name+" reads it between its tool calls, and its report answers it.", output)
265         close(told)
266
267         test.events.WaitFor(t, "idle", 1)
268         require.Equal(t, "Use the second disk.", subagentTestText(subagentTestLastItem(server.Call(1))))
269         require.Equal(t, "work: "+subagentReport(name, []string{"Used the second disk."}), test.Log(t)[0])
270 }
271
272 func TestTellAfterTheFinalAnswerStartsAnotherChain(t *testing.T) {
273         server := newModelFake(t)
274         arrived := make(chan struct{})
275         told := make(chan struct{})
276         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
277                 switch {
278                 case !subagentTestCall(call):
279                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Noted.")}, 100, 0, 5))
280                 case call.number == 0:
281                         close(arrived)
282                         <-told
283                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("First.")}, 100, 0, 5))
284                 default:
285                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Second.")}, 100, 0, 5))
286                 }
287         }
288
289         test := newTestTurnLoop(t, server)
290         runs := test.loop.runs
291         _, err := runs.Spawn(t.Context(), []string{"Count."}, nil)
292         require.NoError(t, err)
293
294         <-arrived
295         name := test.events.Last("subagent")
296         _, err = runs.Tell(t.Context(), name, "Count again.")
297         require.NoError(t, err)
298         close(told)
299
300         test.events.WaitFor(t, "idle", 1)
301         require.Equal(t, "0|note: subagent\n\n1|user: Count.\n\n2|user: Count again.\n\n3|reply: First.", subagentTestText(subagentTestLastItem(server.Call(1))))
302         require.Equal(t, "work: "+subagentReport(name, []string{"First.", "Second."}), test.Log(t)[0])
303 }
304
305 func TestTellToAFinishedSubagentGoesOnFromItsLog(t *testing.T) {
306         server := newModelFake(t)
307         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
308                 text := "Noted."
309                 if subagentTestCall(call) {
310                         text = "Reply " + strconv.Itoa(call.number) + "."
311                 }
312
313                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5))
314         }
315
316         test := newTestTurnLoop(t, server)
317         runs := test.loop.runs
318         effort := "high"
319         _, err := runs.Spawn(t.Context(), []string{"Count."}, &effort)
320         require.NoError(t, err)
321         test.events.WaitFor(t, "idle", 1)
322
323         name := test.events.Last("subagent")
324         output, err := runs.Tell(t.Context(), name, "And the hidden files?")
325         require.NoError(t, err)
326         require.Equal(t, "Sent. "+name+"'s reply will reach you as a message.", output)
327         test.events.WaitFor(t, "idle", 2)
328
329         call := server.Call(2)
330         require.True(t, subagentTestCall(call))
331         require.Equal(t, "high", call.body["reasoning"].(map[string]any)["effort"])
332         require.Equal(t, "0|note: subagent at effort high\n\n1|user: Count.\n\n2|reply: Reply 0.\n\n3|user: And the hidden files?", subagentTestText(subagentTestLastItem(call)))
333
334         // The view of the new run holds the chat of now, with the first report.
335         texts := turnInputTexts(t, call)
336         require.Contains(t, strings.Join(texts[:len(texts)-2], ""), "work: ["+name+"]")
337         require.Equal(t, "work: "+subagentReport(name, []string{"Reply 2."}), test.Log(t)[2])
338
339         _, err = runs.Tell(t.Context(), "BlueFox", "Hello.")
340         require.EqualError(t, err, "no agent BlueFox")
341 }
342
343 func TestStopEndsASubagentAndItsCommand(t *testing.T) {
344         server := newModelFake(t)
345         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
346                 modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "shell", `{"command":"touch started && sleep 30"}`)}, 100, 0, 5))
347         }
348
349         test := newTestTurnLoop(t, server)
350         runs := test.loop.runs
351         _, err := runs.Spawn(t.Context(), []string{"Wait long."}, nil)
352         require.NoError(t, err)
353
354         name := test.events.Last("subagent")
355         subagent, err := runs.subagents.Get(t.Context(), name)
356         require.NoError(t, err)
357
358         require.Eventually(t, func() bool {
359                 _, err := os.Stat(filepath.Join(subagent.WorkDir(), "started"))
360                 return err == nil
361         }, 10*time.Second, time.Millisecond)
362
363         start := time.Now()
364         found, err := test.loop.StopSubagent(name)
365         require.NoError(t, err)
366         require.True(t, found)
367
368         test.events.WaitFor(t, "work", 1)
369         require.Less(t, time.Since(start), 10*time.Second)
370         require.Equal(t, []string{
371                 "note: Stopped by the user: " + name + ".",
372                 "work: " + subagentReport(name, []string{"stopped"}),
373         }, test.Log(t))
374         require.Equal(t, subagentStopped, test.events.LastEvent("work").Status)
375
376         log, err := subagent.Show()
377         require.NoError(t, err)
378         require.Contains(t, log, "echo: \n[interrupted]")
379
380         found, err = test.loop.StopSubagent(name)
381         require.NoError(t, err)
382         require.False(t, found)
383
384         time.Sleep(50 * time.Millisecond)
385         require.Zero(t, test.events.Count("busy"))
386 }
387
388 func TestCloseEndsTheSubagentsWithNoReport(t *testing.T) {
389         server := newModelFake(t)
390         arrived := make(chan struct{})
391         release := make(chan struct{})
392         t.Cleanup(func() {
393                 close(release)
394         })
395
396         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
397                 close(arrived)
398                 <-release
399         }
400
401         test := newTestTurnLoop(t, server)
402         runs := test.loop.runs
403         _, err := runs.Spawn(t.Context(), []string{"Wait."}, nil)
404         require.NoError(t, err)
405
406         <-arrived
407         runs.Close()
408         require.Empty(t, runs.running)
409         require.Empty(t, test.Log(t))
410
411         _, err = runs.Spawn(t.Context(), []string{"Again."}, nil)
412         require.ErrorIs(t, err, errSubagentClosed)
413
414         entries, err := os.ReadDir(runs.subagents.dir)
415         require.NoError(t, err)
416         require.Len(t, entries, 1)
417 }
418
419 func TestRestartResumesTheCutSubagents(t *testing.T) {
420         server := newModelFake(t)
421         arrived := make(chan struct{}, 1)
422         release := make(chan struct{})
423         t.Cleanup(func() {
424                 close(release)
425         })
426
427         var restarted atomic.Bool
428         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
429                 switch {
430                 case subagentTestCall(call) && !restarted.Load():
431                         arrived <- struct{}{}
432                         <-release
433                 case subagentTestCall(call):
434                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Built.")}, 100, 0, 5))
435                 default:
436                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Noted.")}, 100, 0, 5))
437                 }
438         }
439
440         agent, err := openAgent(t.TempDir())
441         require.NoError(t, err)
442
443         first := &TurnEventRecorder{}
444         loop, _, stop := subagentTestStart(t, agent, server, first)
445         _, err = loop.runs.Spawn(t.Context(), []string{"Build the project."}, nil)
446         require.NoError(t, err)
447
448         <-arrived
449         name := first.Last("subagent")
450         stop()
451         require.Zero(t, first.Count("work"))
452
453         data, err := os.ReadFile(filepath.Join(agent.dir, subagentWorkFileName))
454         require.NoError(t, err)
455         require.JSONEq(t, `{"running": ["`+name+`"]}`, string(data))
456
457         restarted.Store(true)
458         second := &TurnEventRecorder{}
459         loop, memory, stop := subagentTestStart(t, agent, server, second)
460         defer stop()
461
462         second.WaitFor(t, "idle", 1)
463         note := "The daemon restarted, cutting " + name + "; nothing was lost: go on."
464         log := subagentTestChat(t, memory)
465         require.Equal(t, []string{
466                 "note: " + note,
467                 "work: " + subagentReport(name, []string{"Built."}),
468                 "reply: Noted.",
469         }, log[len(log)-3:])
470
471         subagent, err := loop.runs.subagents.Get(t.Context(), name)
472         require.NoError(t, err)
473
474         text, err := subagent.Show()
475         require.NoError(t, err)
476         require.Equal(t, "0|note: subagent\n\n1|user: Build the project.\n\n2|user: "+note+"\n\n3|reply: Built.", text)
477
478         data, err = os.ReadFile(filepath.Join(agent.dir, subagentWorkFileName))
479         require.NoError(t, err)
480         require.JSONEq(t, `{"running": []}`, string(data))
481 }
482
483 func TestRestartReportsASubagentThatCannotGoOn(t *testing.T) {
484         server := newModelFake(t)
485         agent, err := openAgent(t.TempDir())
486         require.NoError(t, err)
487         require.NoError(t, os.WriteFile(filepath.Join(agent.dir, subagentWorkFileName), []byte(`{"running": ["BlueFox"]}`), 0o600))
488
489         events := &TurnEventRecorder{}
490         _, memory, stop := subagentTestStart(t, agent, server, events)
491         defer stop()
492
493         events.WaitFor(t, "idle", 1)
494         require.Equal(t, []string{
495                 "note: The daemon restarted, cutting BlueFox; nothing was lost: go on.",
496                 "work: " + subagentReport("BlueFox", []string{"failed: no agent BlueFox"}),
497                 "reply: a line",
498         }, subagentTestChat(t, memory))
499         require.Equal(t, subagentFailed, events.LastEvent("work").Status)
500
501         data, err := os.ReadFile(filepath.Join(agent.dir, subagentWorkFileName))
502         require.NoError(t, err)
503         require.JSONEq(t, `{"running": []}`, string(data))
504 }
505
506 // subagentTestStart runs a loop of agent with its memory and subagents in the
507 // agent directory, as the daemon does. The returned function stops the loop
508 // and closes the files.
509 func subagentTestStart(t *testing.T, agent *Agent, server *ModelFake, events *TurnEventRecorder) (*TurnLoop, *Memory, func()) {
510         t.Helper()
511
512         agent.config.Workdir = t.TempDir()
513         clock := newTestClock()
514         memory, err := openMemory(t.Context(), agent.dir, clock.Now, newFakeSummarizer())
515         require.NoError(t, err)
516
517         subagents := newSubagents(filepath.Join(agent.dir, subagentDirName), clock.Now, subagentNameRandom)
518         client := newModelClient(server.URL, ModelAPIKey(modelTestKey), agent.CacheKey(), filepath.Join(agent.dir, "usage.jsonl"))
519         loop, err := newTurnLoop(agent, memory, subagents, client, clock.Now, events.Add)
520         require.NoError(t, err)
521
522         ctx, cancel := context.WithCancel(t.Context())
523         done := make(chan error)
524         go func() {
525                 done <- loop.Run(ctx)
526         }()
527
528         return loop, memory, func() {
529                 cancel()
530                 require.ErrorIs(t, <-done, context.Canceled)
531                 require.NoError(t, subagents.Close())
532                 require.NoError(t, memory.Close())
533         }
534 }
535
536 // subagentTestChat returns each message of the chat in memory as
537 // "kind: text".
538 func subagentTestChat(t *testing.T, memory *Memory) []string {
539         t.Helper()
540
541         var lines []string
542         for id := range memory.Len() {
543                 message, err := memory.Message(id)
544                 require.NoError(t, err)
545
546                 lines = append(lines, message.Kind+": "+message.Text)
547         }
548
549         return lines
550 }
551
552 // subagentTestCall tells if call is a call of a subagent.
553 func subagentTestCall(call ModelFakeCall) bool {
554         instructions, _ := call.body["instructions"].(string)
555
556         return strings.HasPrefix(instructions, "You are a subagent")
557 }
558
559 // subagentTestLastItem returns the last input item of call.
560 func subagentTestLastItem(call ModelFakeCall) map[string]any {
561         input := call.body["input"].([]any)
562
563         return input[len(input)-1].(map[string]any)
564 }
565
566 // subagentTestText returns the text of the user message item, or "" for
567 // another item.
568 func subagentTestText(item map[string]any) string {
569         content, _ := item["content"].([]any)
570         var text strings.Builder
571         for _, part := range content {
572                 value, _ := part.(map[string]any)["text"].(string)
573                 text.WriteString(value)
574         }
575
576         return text.String()
577 }