16 "github.com/stretchr/testify/require"
19 // subagentTestFullContext is the error body of a call whose input does not fit
20 // in the context of the model.
21 const subagentTestFullContext = `{"error": {"message": "Your input exceeds the context window of this model.", "type": "invalid_request_error", "code": "context_length_exceeded"}}`
23 func TestSpawnRunsASubagentInTheBackground(t *testing.T) {
24 server := newModelFake(t)
26 var test *TurnTestLoop
28 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
29 last := subagentTestLastItem(call)
31 case subagentTestCall(call) && last["type"] == "function_call_output":
32 // The report comes after the reply of the turn of the spawn.
33 test.events.WaitFor(t, "reply", 1)
34 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("There are 3 files.")}, 100, 0, 5))
35 case subagentTestCall(call):
36 modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "shell", `{"command":"pwd"}`)}, 100, 0, 5))
37 case last["type"] == "function_call_output":
38 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Started.")}, 100, 0, 5))
39 case strings.HasPrefix(subagentTestText(last), "["):
40 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Noted.")}, 100, 0, 5))
42 modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "spawn", `{"tasks":["Count the files."],"effort":null}`)}, 100, 0, 5))
46 test = newTestTurnLoop(t, server)
47 _, err := test.loop.Submit(terminalSource, "Count the files in a subagent.")
48 require.NoError(t, err)
49 test.events.WaitFor(t, "idle", 2)
51 name := test.events.Last("subagent")
52 require.True(t, subagentNameValid(name), name)
54 work := filepath.Join(test.loop.runs.subagents.dir, name, "work")
55 require.DirExists(t, work)
57 report := "[" + name + "]\nThere are 3 files.\n\nFull chat: zoom(\"" + name + "\")"
58 require.Equal(t, []string{
59 "user: Count the files in a subagent.",
60 `tool: spawn {"tasks":["Count the files."],"effort":null}`,
61 "echo: Started " + name + ". Each one's report will reach you as a message when it finishes.",
66 require.Equal(t, Event{
71 Status: subagentFinished,
72 }, test.events.LastEvent("work"))
73 require.Equal(t, "<6>subagent started name="+name+"\n<6>subagent done name="+name+" after=0s\n", test.logs.String())
75 var first, second *ModelFakeCall
77 for n := range server.Calls() {
78 call := server.Call(n)
79 if !subagentTestCall(call) {
90 require.NotNil(t, second)
92 // The first request of the subagent holds the view, the state, and its
93 // whole log, with its own prompt and tools.
94 texts := turnInputTexts(t, *first)
95 view := strings.Join(texts[:len(texts)-2], "")
96 require.True(t, strings.HasPrefix(view, "<chat>\n0+1|user: Count the files in a subagent.\n1+1|tool: spawn"), view)
97 require.Equal(t, "</chat>\n\nNow: Thursday 2026-01-01 12:00 +00:00", texts[len(texts)-2])
98 require.Equal(t, "0|note: subagent\n\n1|user: Count the files.", texts[len(texts)-1])
100 instructions, err := test.agent.SubagentPrompt()
101 require.NoError(t, err)
102 require.Equal(t, instructions, first.body["instructions"])
106 for _, tool := range first.body["tools"].([]any) {
107 tools = append(tools, tool.(map[string]any)["name"].(string))
110 require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit"}, tools)
111 require.Equal(t, work+"\n", subagentTestLastItem(*second)["output"])
113 subagent, err := test.loop.runs.subagents.Get(t.Context(), name)
114 require.NoError(t, err)
116 log, err := subagent.Show()
117 require.NoError(t, err)
118 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)
122 for _, record := range modelReadUsage(t, test.usagePath) {
123 agents = append(agents, record.Agent)
126 require.ElementsMatch(t, []string{"", "", "", name, name}, agents)
129 func TestReportJoinsTheNextRequestOfARunningTurn(t *testing.T) {
130 server := newModelFake(t)
131 arrived := make(chan struct{})
132 release := make(chan struct{})
133 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
134 if call.number == 0 {
137 modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "date", `{"id":0}`)}, 100, 0, 5))
142 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("done")}, 100, 0, 5))
145 test := newTestTurnLoop(t, server)
146 _, err := test.loop.Submit(terminalSource, "first")
147 require.NoError(t, err)
151 report := subagentReport("GreenOtter", []string{"It is done."})
152 test.loop.Report("GreenOtter", report, subagentFinished)
155 test.events.WaitFor(t, "idle", 1)
156 require.Equal(t, report, subagentTestText(subagentTestLastItem(server.Call(1))))
158 time.Sleep(50 * time.Millisecond)
159 require.Equal(t, 2, server.Calls())
160 require.Equal(t, 1, test.events.Count("busy"))
163 func TestReportOfAStoppedSubagentStartsNoTurn(t *testing.T) {
164 server := newModelFake(t)
165 test := newTestTurnLoop(t, server)
166 report := subagentReport("GreenOtter", []string{"failed: context canceled"})
167 test.loop.Report("GreenOtter", report, subagentStopped)
169 test.events.WaitFor(t, "work", 1)
170 time.Sleep(50 * time.Millisecond)
171 require.Equal(t, []string{"work: " + report}, test.Log(t))
172 require.Zero(t, server.Calls())
173 require.Zero(t, test.events.Count("busy"))
176 func TestFullContextGivesAFailedReport(t *testing.T) {
177 server := newModelFake(t)
178 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
179 if subagentTestCall(call) {
180 w.WriteHeader(http.StatusBadRequest)
182 _, _ = io.WriteString(w, subagentTestFullContext)
187 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("It failed.")}, 100, 0, 5))
190 test := newTestTurnLoop(t, server)
191 _, err := test.loop.runs.Spawn(t.Context(), []string{"Read the big file."}, nil)
192 require.NoError(t, err)
193 test.events.WaitFor(t, "idle", 1)
195 name := test.events.Last("subagent")
196 require.Equal(t, []string{
197 "work: [" + name + "]\nfailed: create model response: model call: HTTP 400: " + subagentTestFullContext + "\n\nFull chat: zoom(\"" + name + "\")",
200 require.Equal(t, subagentFailed, test.events.LastEvent("work").Status)
201 require.Equal(t, "<6>subagent started name="+name+"\n<3>subagent failed name="+name+" after=0s: create model response: model call: HTTP 400: "+subagentTestFullContext+"\n", test.logs.String())
204 func TestSubagentHasItsOwnEffortAndNoSpawn(t *testing.T) {
205 server := newModelFake(t)
206 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
207 if subagentTestCall(call) && subagentTestLastItem(call)["type"] != "function_call_output" {
208 modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "spawn", `{"tasks":["More."],"effort":null}`)}, 100, 0, 5))
213 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5))
216 test := newTestTurnLoop(t, server)
217 runs := test.loop.runs
218 _, err := runs.Spawn(t.Context(), nil, nil)
219 require.EqualError(t, err, "no tasks")
222 _, err = runs.Spawn(t.Context(), []string{"Think."}, &effort)
223 require.EqualError(t, err, `no effort "max": one of low, medium, high, xhigh`)
226 _, err = runs.Spawn(t.Context(), []string{"Think hard."}, &effort)
227 require.NoError(t, err)
228 test.events.WaitFor(t, "idle", 1)
230 for n := range server.Calls() {
231 call := server.Call(n)
232 if subagentTestCall(call) {
233 require.Equal(t, "high", call.body["reasoning"].(map[string]any)["effort"])
237 subagent, err := runs.subagents.Get(t.Context(), test.events.Last("subagent"))
238 require.NoError(t, err)
240 log, err := subagent.Show()
241 require.NoError(t, err)
242 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)
244 entries, err := os.ReadDir(runs.subagents.dir)
245 require.NoError(t, err)
246 require.Len(t, entries, 1)
249 func TestTellReachesARunningSubagentBetweenItsToolCalls(t *testing.T) {
250 server := newModelFake(t)
251 arrived := make(chan struct{})
252 told := make(chan struct{})
253 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
255 case !subagentTestCall(call):
256 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Noted.")}, 100, 0, 5))
257 case call.number == 0:
260 modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "shell", `{"command":"true"}`)}, 100, 0, 5))
262 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Used the second disk.")}, 100, 0, 5))
266 test := newTestTurnLoop(t, server)
267 runs := test.loop.runs
268 _, err := runs.Spawn(t.Context(), []string{"Copy the files."}, nil)
269 require.NoError(t, err)
273 name := test.events.Last("subagent")
274 output, err := runs.Tell(t.Context(), name, "Use the second disk.")
275 require.NoError(t, err)
276 require.Equal(t, "Sent. "+name+" reads it between its tool calls, and its report answers it.", output)
279 test.events.WaitFor(t, "idle", 1)
280 require.Equal(t, "Use the second disk.", subagentTestText(subagentTestLastItem(server.Call(1))))
281 require.Equal(t, "work: "+subagentReport(name, []string{"Used the second disk."}), test.Log(t)[0])
284 func TestTellAfterTheFinalAnswerStartsAnotherChain(t *testing.T) {
285 server := newModelFake(t)
286 arrived := make(chan struct{})
287 told := make(chan struct{})
288 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
290 case !subagentTestCall(call):
291 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Noted.")}, 100, 0, 5))
292 case call.number == 0:
295 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("First.")}, 100, 0, 5))
297 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Second.")}, 100, 0, 5))
301 test := newTestTurnLoop(t, server)
302 runs := test.loop.runs
303 _, err := runs.Spawn(t.Context(), []string{"Count."}, nil)
304 require.NoError(t, err)
308 name := test.events.Last("subagent")
309 _, err = runs.Tell(t.Context(), name, "Count again.")
310 require.NoError(t, err)
313 test.events.WaitFor(t, "idle", 1)
314 require.Equal(t, "0|note: subagent\n\n1|user: Count.\n\n2|user: Count again.\n\n3|reply: First.", subagentTestText(subagentTestLastItem(server.Call(1))))
315 require.Equal(t, "work: "+subagentReport(name, []string{"First.", "Second."}), test.Log(t)[0])
318 func TestTellToAFinishedSubagentGoesOnFromItsLog(t *testing.T) {
319 server := newModelFake(t)
320 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
322 if subagentTestCall(call) {
323 text = "Reply " + strconv.Itoa(call.number) + "."
326 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5))
329 test := newTestTurnLoop(t, server)
330 runs := test.loop.runs
332 _, err := runs.Spawn(t.Context(), []string{"Count."}, &effort)
333 require.NoError(t, err)
334 test.events.WaitFor(t, "idle", 1)
336 name := test.events.Last("subagent")
337 output, err := runs.Tell(t.Context(), name, "And the hidden files?")
338 require.NoError(t, err)
339 require.Equal(t, "Sent. "+name+"'s reply will reach you as a message.", output)
340 test.events.WaitFor(t, "idle", 2)
342 call := server.Call(2)
343 require.True(t, subagentTestCall(call))
344 require.Equal(t, "high", call.body["reasoning"].(map[string]any)["effort"])
345 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)))
347 // The view of the new run holds the chat of now, with the first report.
348 texts := turnInputTexts(t, call)
349 require.Contains(t, strings.Join(texts[:len(texts)-2], ""), "work: ["+name+"]")
350 require.Equal(t, "work: "+subagentReport(name, []string{"Reply 2."}), test.Log(t)[2])
352 _, err = runs.Tell(t.Context(), "BlueFox", "Hello.")
353 require.EqualError(t, err, "no agent BlueFox")
356 func TestStopEndsASubagentAndItsCommand(t *testing.T) {
357 server := newModelFake(t)
358 server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
359 modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "shell", `{"command":"touch started && sleep 30"}`)}, 100, 0, 5))
362 test := newTestTurnLoop(t, server)
363 runs := test.loop.runs
364 _, err := runs.Spawn(t.Context(), []string{"Wait long."}, nil)
365 require.NoError(t, err)
367 name := test.events.Last("subagent")
368 subagent, err := runs.subagents.Get(t.Context(), name)
369 require.NoError(t, err)
371 require.Eventually(t, func() bool {
372 _, err := os.Stat(filepath.Join(subagent.WorkDir(), "started"))
374 }, 10*time.Second, time.Millisecond)
377 found, err := test.loop.StopSubagent(name)
378 require.NoError(t, err)
379 require.True(t, found)
381 test.events.WaitFor(t, "work", 1)
382 require.Less(t, time.Since(start), 10*time.Second)
383 require.Equal(t, []string{
384 "note: Stopped by the user: " + name + ".",
385 "work: " + subagentReport(name, []string{"stopped"}),
387 require.Equal(t, subagentStopped, test.events.LastEvent("work").Status)
388 require.Equal(t, "<6>subagent started name="+name+"\n<6>subagent stopped name="+name+" after=0s\n", test.logs.String())
390 log, err := subagent.Show()
391 require.NoError(t, err)
392 require.Contains(t, log, "echo: \n[interrupted]")
394 found, err = test.loop.StopSubagent(name)
395 require.NoError(t, err)
396 require.False(t, found)
398 time.Sleep(50 * time.Millisecond)
399 require.Zero(t, test.events.Count("busy"))
402 func TestCloseEndsTheSubagentsWithNoReport(t *testing.T) {
403 server := newModelFake(t)
404 arrived := make(chan struct{})
405 release := make(chan struct{})
410 server.reply = func(_ http.ResponseWriter, _ ModelFakeCall) {
415 test := newTestTurnLoop(t, server)
416 runs := test.loop.runs
417 _, err := runs.Spawn(t.Context(), []string{"Wait."}, nil)
418 require.NoError(t, err)
422 require.Empty(t, runs.running)
423 require.Empty(t, test.Log(t))
425 _, err = runs.Spawn(t.Context(), []string{"Again."}, nil)
426 require.ErrorIs(t, err, errSubagentClosed)
428 entries, err := os.ReadDir(runs.subagents.dir)
429 require.NoError(t, err)
430 require.Len(t, entries, 1)
433 func TestRestartResumesTheCutSubagents(t *testing.T) {
434 server := newModelFake(t)
435 arrived := make(chan struct{}, 1)
436 release := make(chan struct{})
441 var restarted atomic.Bool
443 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
445 case subagentTestCall(call) && !restarted.Load():
446 arrived <- struct{}{}
449 case subagentTestCall(call):
450 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Built.")}, 100, 0, 5))
452 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Noted.")}, 100, 0, 5))
456 agent, err := openAgent(t.TempDir())
457 require.NoError(t, err)
459 first := &TurnEventRecorder{}
460 loop, _, stop := subagentTestStart(t, agent, server, first, discardLog())
461 _, err = loop.runs.Spawn(t.Context(), []string{"Build the project."}, nil)
462 require.NoError(t, err)
466 name := first.Last("subagent")
469 require.Zero(t, first.Count("work"))
471 data, err := os.ReadFile(filepath.Join(agent.dir, subagentWorkFileName))
472 require.NoError(t, err)
473 require.JSONEq(t, `{"running": ["`+name+`"]}`, string(data))
475 restarted.Store(true)
477 second := &TurnEventRecorder{}
478 loop, memory, stop := subagentTestStart(t, agent, server, second, discardLog())
481 second.WaitFor(t, "idle", 1)
483 note := "The daemon restarted, cutting " + name + "; nothing was lost: go on."
484 log := subagentTestChat(t, memory)
485 require.Equal(t, []string{
487 "work: " + subagentReport(name, []string{"Built."}),
491 subagent, err := loop.runs.subagents.Get(t.Context(), name)
492 require.NoError(t, err)
494 text, err := subagent.Show()
495 require.NoError(t, err)
496 require.Equal(t, "0|note: subagent\n\n1|user: Build the project.\n\n2|user: "+note+"\n\n3|reply: Built.", text)
498 data, err = os.ReadFile(filepath.Join(agent.dir, subagentWorkFileName))
499 require.NoError(t, err)
500 require.JSONEq(t, `{"running": []}`, string(data))
503 func TestRestartReportsASubagentThatCannotGoOn(t *testing.T) {
504 server := newModelFake(t)
505 agent, err := openAgent(t.TempDir())
506 require.NoError(t, err)
507 require.NoError(t, os.WriteFile(filepath.Join(agent.dir, subagentWorkFileName), []byte(`{"running": ["BlueFox"]}`), 0o600))
509 events := &TurnEventRecorder{}
510 logs := &SyncBuffer{}
511 _, memory, stop := subagentTestStart(t, agent, server, events, daemonTestLogger(logs))
514 events.WaitFor(t, "idle", 1)
515 require.Equal(t, []string{
516 "note: The daemon restarted, cutting BlueFox; nothing was lost: go on.",
517 "work: " + subagentReport("BlueFox", []string{"failed: no agent BlueFox"}),
519 }, subagentTestChat(t, memory))
520 require.Equal(t, subagentFailed, events.LastEvent("work").Status)
521 require.Equal(t, "<3>subagent lost at restart name=BlueFox: no agent BlueFox\n", logs.String())
523 data, err := os.ReadFile(filepath.Join(agent.dir, subagentWorkFileName))
524 require.NoError(t, err)
525 require.JSONEq(t, `{"running": []}`, string(data))
528 // subagentTestStart runs a loop of agent with its memory and subagents in the
529 // agent directory, as the daemon does. The returned function stops the loop
530 // and closes the files.
531 func subagentTestStart(t *testing.T, agent *Agent, server *ModelFake, events *TurnEventRecorder, logger *slog.Logger) (*TurnLoop, *Memory, func()) {
534 agent.config.Workdir = t.TempDir()
535 clock := newTestClock()
536 memory, err := openMemory(t.Context(), agent.dir, clock.Now, newFakeSummarizer(), discardLog())
537 require.NoError(t, err)
539 subagents := newSubagents(filepath.Join(agent.dir, subagentDirName), clock.Now, subagentNameRandom)
540 client := newModelClient(server.URL, ModelAPIKey(modelTestKey), agent.CacheKey(), filepath.Join(agent.dir, "usage.jsonl"))
541 whatsapp, _, _ := whatsappStartTest(t, t.TempDir(), clock)
542 loop, err := newTurnLoop(agent, memory, subagents, whatsapp, newTestSchedules(t, clock.Now), client, clock.Now, logger, events.Add)
543 require.NoError(t, err)
545 ctx, cancel := context.WithCancel(t.Context())
546 done := make(chan error)
548 done <- loop.Run(ctx)
551 return loop, memory, func() {
553 require.ErrorIs(t, <-done, context.Canceled)
554 require.NoError(t, subagents.Close())
555 require.NoError(t, memory.Close())
559 // subagentTestChat returns each message of the chat in memory as
561 func subagentTestChat(t *testing.T, memory *Memory) []string {
566 for id := range memory.Len() {
567 message, err := memory.Message(id)
568 require.NoError(t, err)
570 lines = append(lines, message.Kind+": "+message.Text)
576 // subagentTestCall tells if call is a call of a subagent.
577 func subagentTestCall(call ModelFakeCall) bool {
578 instructions, _ := call.body["instructions"].(string)
580 return strings.HasPrefix(instructions, "You are a subagent")
583 // subagentTestLastItem returns the last input item of call.
584 func subagentTestLastItem(call ModelFakeCall) map[string]any {
585 input := call.body["input"].([]any)
587 return input[len(input)-1].(map[string]any)
590 // subagentTestText returns the text of the user message item, or "" for
592 func subagentTestText(item map[string]any) string {
593 content, _ := item["content"].([]any)
595 var text strings.Builder
597 for _, part := range content {
598 value, _ := part.(map[string]any)["text"].(string)
599 text.WriteString(value)