13 "github.com/stretchr/testify/require"
16 // subagentTestFullContext is the error body of a call whose input does not fit
17 // in the context of the model.
18 const subagentTestFullContext = `{"error": {"message": "Your input exceeds the context window of this model.", "type": "invalid_request_error", "code": "context_length_exceeded"}}`
20 func TestSpawnRunsASubagentInTheBackground(t *testing.T) {
21 server := newModelFake(t)
22 var test *TurnTestLoop
23 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
24 last := subagentTestLastItem(call)
26 case subagentTestCall(call) && last["type"] == "function_call_output":
27 // The report comes after the reply of the turn of the spawn.
28 test.events.WaitFor(t, "reply", 1)
29 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("There are 3 files.")}, 100, 0, 5))
30 case subagentTestCall(call):
31 modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "shell", `{"command":"pwd"}`)}, 100, 0, 5))
32 case last["type"] == "function_call_output":
33 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Started.")}, 100, 0, 5))
34 case strings.HasPrefix(subagentTestText(last), "["):
35 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Noted.")}, 100, 0, 5))
37 modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "spawn", `{"tasks":["Count the files."],"effort":null}`)}, 100, 0, 5))
41 test = newTestTurnLoop(t, server)
42 _, err := test.loop.Submit(terminalSource, "Count the files in a subagent.")
43 require.NoError(t, err)
44 test.events.WaitFor(t, "idle", 2)
46 name := test.events.Last("subagent")
47 require.True(t, subagentNameValid(name), name)
48 work := filepath.Join(test.loop.runs.subagents.dir, name, "work")
49 require.DirExists(t, work)
51 report := "[" + name + "]\nThere are 3 files.\n\nFull chat: zoom(\"" + name + "\")"
52 require.Equal(t, []string{
53 "user: Count the files in a subagent.",
54 `tool: spawn {"tasks":["Count the files."],"effort":null}`,
55 "echo: Started " + name + ". Each one's report will reach you as a message when it finishes.",
60 require.Equal(t, Event{
65 Status: subagentFinished,
66 }, test.events.LastEvent("work"))
68 var first, second *ModelFakeCall
69 for n := range server.Calls() {
70 call := server.Call(n)
71 if !subagentTestCall(call) {
82 require.NotNil(t, second)
84 // The first request of the subagent holds the view, the state, and its
85 // whole log, with its own prompt and tools.
86 texts := turnInputTexts(t, *first)
87 view := strings.Join(texts[:len(texts)-2], "")
88 require.True(t, strings.HasPrefix(view, "<chat>\n0+1|user: Count the files in a subagent.\n1+1|tool: spawn"), view)
89 require.Equal(t, "</chat>\n\nNow: Thursday 2026-01-01 12:00 +00:00", texts[len(texts)-2])
90 require.Equal(t, "0|note: subagent\n\n1|user: Count the files.", texts[len(texts)-1])
92 instructions, err := test.agent.SubagentPrompt()
93 require.NoError(t, err)
94 require.Equal(t, instructions, first.body["instructions"])
97 for _, tool := range first.body["tools"].([]any) {
98 tools = append(tools, tool.(map[string]any)["name"].(string))
101 require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit"}, tools)
102 require.Equal(t, work+"\n", subagentTestLastItem(*second)["output"])
104 subagent, err := test.loop.runs.subagents.Get(t.Context(), name)
105 require.NoError(t, err)
107 log, err := subagent.Show()
108 require.NoError(t, err)
109 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 for _, record := range modelReadUsage(t, test.usagePath) {
113 agents = append(agents, record.Agent)
116 require.ElementsMatch(t, []string{"", "", "", name, name}, agents)
119 func TestReportJoinsTheNextRequestOfARunningTurn(t *testing.T) {
120 server := newModelFake(t)
121 arrived := make(chan struct{})
122 release := make(chan struct{})
123 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
124 if call.number == 0 {
127 modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "date", `{"id":0}`)}, 100, 0, 5))
132 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("done")}, 100, 0, 5))
135 test := newTestTurnLoop(t, server)
136 _, err := test.loop.Submit(terminalSource, "first")
137 require.NoError(t, err)
140 report := subagentReport("GreenOtter", []string{"It is done."})
141 test.loop.Report("GreenOtter", report, subagentFinished)
144 test.events.WaitFor(t, "idle", 1)
145 require.Equal(t, report, subagentTestText(subagentTestLastItem(server.Call(1))))
147 time.Sleep(50 * time.Millisecond)
148 require.Equal(t, 2, server.Calls())
149 require.Equal(t, 1, test.events.Count("busy"))
152 func TestReportOfAStoppedSubagentStartsNoTurn(t *testing.T) {
153 server := newModelFake(t)
154 test := newTestTurnLoop(t, server)
155 report := subagentReport("GreenOtter", []string{"failed: context canceled"})
156 test.loop.Report("GreenOtter", report, subagentStopped)
158 test.events.WaitFor(t, "work", 1)
159 time.Sleep(50 * time.Millisecond)
160 require.Equal(t, []string{"work: " + report}, test.Log(t))
161 require.Zero(t, server.Calls())
162 require.Zero(t, test.events.Count("busy"))
165 func TestFullContextGivesAFailedReport(t *testing.T) {
166 server := newModelFake(t)
167 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
168 if subagentTestCall(call) {
169 w.WriteHeader(http.StatusBadRequest)
170 _, _ = io.WriteString(w, subagentTestFullContext)
175 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("It failed.")}, 100, 0, 5))
178 test := newTestTurnLoop(t, server)
179 _, err := test.loop.runs.Spawn(t.Context(), []string{"Read the big file."}, nil)
180 require.NoError(t, err)
181 test.events.WaitFor(t, "idle", 1)
183 name := test.events.Last("subagent")
184 require.Equal(t, []string{
185 "work: [" + name + "]\nfailed: model call: HTTP 400: " + subagentTestFullContext + "\n\nFull chat: zoom(\"" + name + "\")",
188 require.Equal(t, subagentFailed, test.events.LastEvent("work").Status)
191 func TestSubagentHasItsOwnEffortAndNoSpawn(t *testing.T) {
192 server := newModelFake(t)
193 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
194 if subagentTestCall(call) && subagentTestLastItem(call)["type"] != "function_call_output" {
195 modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "spawn", `{"tasks":["More."],"effort":null}`)}, 100, 0, 5))
200 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5))
203 test := newTestTurnLoop(t, server)
204 runs := test.loop.runs
205 _, err := runs.Spawn(t.Context(), nil, nil)
206 require.EqualError(t, err, "no tasks")
209 _, err = runs.Spawn(t.Context(), []string{"Think."}, &effort)
210 require.EqualError(t, err, `no effort "max": one of low, medium, high, xhigh`)
213 _, err = runs.Spawn(t.Context(), []string{"Think hard."}, &effort)
214 require.NoError(t, err)
215 test.events.WaitFor(t, "idle", 1)
217 for n := range server.Calls() {
218 call := server.Call(n)
219 if subagentTestCall(call) {
220 require.Equal(t, "high", call.body["reasoning"].(map[string]any)["effort"])
224 subagent, err := runs.subagents.Get(t.Context(), test.events.Last("subagent"))
225 require.NoError(t, err)
227 log, err := subagent.Show()
228 require.NoError(t, err)
229 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)
231 entries, err := os.ReadDir(runs.subagents.dir)
232 require.NoError(t, err)
233 require.Len(t, entries, 1)
236 func TestTellReachesARunningSubagentBetweenItsToolCalls(t *testing.T) {
237 server := newModelFake(t)
238 arrived := make(chan struct{})
239 told := make(chan struct{})
240 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
242 case !subagentTestCall(call):
243 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Noted.")}, 100, 0, 5))
244 case call.number == 0:
247 modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "shell", `{"command":"true"}`)}, 100, 0, 5))
249 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Used the second disk.")}, 100, 0, 5))
253 test := newTestTurnLoop(t, server)
254 runs := test.loop.runs
255 _, err := runs.Spawn(t.Context(), []string{"Copy the files."}, nil)
256 require.NoError(t, err)
259 name := test.events.Last("subagent")
260 output, err := runs.Tell(t.Context(), name, "Use the second disk.")
261 require.NoError(t, err)
262 require.Equal(t, "Sent. "+name+" reads it between its tool calls, and its report answers it.", output)
265 test.events.WaitFor(t, "idle", 1)
266 require.Equal(t, "Use the second disk.", subagentTestText(subagentTestLastItem(server.Call(1))))
267 require.Equal(t, "work: "+subagentReport(name, []string{"Used the second disk."}), test.Log(t)[0])
270 func TestTellAfterTheFinalAnswerStartsAnotherChain(t *testing.T) {
271 server := newModelFake(t)
272 arrived := make(chan struct{})
273 told := make(chan struct{})
274 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
276 case !subagentTestCall(call):
277 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Noted.")}, 100, 0, 5))
278 case call.number == 0:
281 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("First.")}, 100, 0, 5))
283 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Second.")}, 100, 0, 5))
287 test := newTestTurnLoop(t, server)
288 runs := test.loop.runs
289 _, err := runs.Spawn(t.Context(), []string{"Count."}, nil)
290 require.NoError(t, err)
293 name := test.events.Last("subagent")
294 _, err = runs.Tell(t.Context(), name, "Count again.")
295 require.NoError(t, err)
298 test.events.WaitFor(t, "idle", 1)
299 require.Equal(t, "0|note: subagent\n\n1|user: Count.\n\n2|user: Count again.\n\n3|reply: First.", subagentTestText(subagentTestLastItem(server.Call(1))))
300 require.Equal(t, "work: "+subagentReport(name, []string{"First.", "Second."}), test.Log(t)[0])
303 func TestTellToAFinishedSubagentGoesOnFromItsLog(t *testing.T) {
304 server := newModelFake(t)
305 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
307 if subagentTestCall(call) {
308 text = "Reply " + strconv.Itoa(call.number) + "."
311 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5))
314 test := newTestTurnLoop(t, server)
315 runs := test.loop.runs
317 _, err := runs.Spawn(t.Context(), []string{"Count."}, &effort)
318 require.NoError(t, err)
319 test.events.WaitFor(t, "idle", 1)
321 name := test.events.Last("subagent")
322 output, err := runs.Tell(t.Context(), name, "And the hidden files?")
323 require.NoError(t, err)
324 require.Equal(t, "Sent. "+name+"'s reply will reach you as a message.", output)
325 test.events.WaitFor(t, "idle", 2)
327 call := server.Call(2)
328 require.True(t, subagentTestCall(call))
329 require.Equal(t, "high", call.body["reasoning"].(map[string]any)["effort"])
330 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)))
332 // The view of the new run holds the chat of now, with the first report.
333 texts := turnInputTexts(t, call)
334 require.Contains(t, strings.Join(texts[:len(texts)-2], ""), "work: ["+name+"]")
335 require.Equal(t, "work: "+subagentReport(name, []string{"Reply 2."}), test.Log(t)[2])
337 _, err = runs.Tell(t.Context(), "BlueFox", "Hello.")
338 require.EqualError(t, err, "no agent BlueFox")
341 func TestStopEndsASubagentAndItsCommand(t *testing.T) {
342 server := newModelFake(t)
343 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
344 modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "shell", `{"command":"touch started && sleep 30"}`)}, 100, 0, 5))
347 test := newTestTurnLoop(t, server)
348 runs := test.loop.runs
349 _, err := runs.Spawn(t.Context(), []string{"Wait long."}, nil)
350 require.NoError(t, err)
352 name := test.events.Last("subagent")
353 subagent, err := runs.subagents.Get(t.Context(), name)
354 require.NoError(t, err)
356 require.Eventually(t, func() bool {
357 _, err := os.Stat(filepath.Join(subagent.WorkDir(), "started"))
359 }, 10*time.Second, time.Millisecond)
362 found, err := test.loop.StopSubagent(name)
363 require.NoError(t, err)
364 require.True(t, found)
366 test.events.WaitFor(t, "work", 1)
367 require.Less(t, time.Since(start), 10*time.Second)
368 require.Equal(t, []string{
369 "note: Stopped by the user: " + name + ".",
370 "work: " + subagentReport(name, []string{"stopped"}),
372 require.Equal(t, subagentStopped, test.events.LastEvent("work").Status)
374 log, err := subagent.Show()
375 require.NoError(t, err)
376 require.Contains(t, log, "echo: \n[interrupted]")
378 found, err = test.loop.StopSubagent(name)
379 require.NoError(t, err)
380 require.False(t, found)
382 time.Sleep(50 * time.Millisecond)
383 require.Zero(t, test.events.Count("busy"))
386 func TestCloseEndsTheSubagentsWithNoReport(t *testing.T) {
387 server := newModelFake(t)
388 arrived := make(chan struct{})
389 release := make(chan struct{})
394 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
399 test := newTestTurnLoop(t, server)
400 runs := test.loop.runs
401 _, err := runs.Spawn(t.Context(), []string{"Wait."}, nil)
402 require.NoError(t, err)
406 require.Empty(t, runs.running)
407 require.Empty(t, test.Log(t))
409 _, err = runs.Spawn(t.Context(), []string{"Again."}, nil)
410 require.ErrorIs(t, err, errSubagentClosed)
412 entries, err := os.ReadDir(runs.subagents.dir)
413 require.NoError(t, err)
414 require.Len(t, entries, 1)
417 // subagentTestCall tells if call is a call of a subagent.
418 func subagentTestCall(call ModelFakeCall) bool {
419 instructions, _ := call.body["instructions"].(string)
421 return strings.HasPrefix(instructions, "You are a subagent")
424 // subagentTestLastItem returns the last input item of call.
425 func subagentTestLastItem(call ModelFakeCall) map[string]any {
426 input := call.body["input"].([]any)
428 return input[len(input)-1].(map[string]any)
431 // subagentTestText returns the text of the user message item, or "" for
433 func subagentTestText(item map[string]any) string {
434 content, _ := item["content"].([]any)
435 var text strings.Builder
436 for _, part := range content {
437 value, _ := part.(map[string]any)["text"].(string)
438 text.WriteString(value)