]> git.giacomolli.uk - onechat/blob - subagent_run_test.go
Show the subagents in the clients
[onechat] / subagent_run_test.go
1 package main
2
3 import (
4         "io"
5         "net/http"
6         "os"
7         "path/filepath"
8         "strconv"
9         "strings"
10         "testing"
11         "time"
12
13         "github.com/stretchr/testify/require"
14 )
15
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"}}`
19
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)
25                 switch {
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))
36                 default:
37                         modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "spawn", `{"tasks":["Count the files."],"effort":null}`)}, 100, 0, 5))
38                 }
39         }
40
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)
45
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)
50
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.",
56                 "reply: Started.",
57                 "work: " + report,
58                 "reply: Noted.",
59         }, test.Log(t))
60         require.Equal(t, Event{
61                 Kind:   "work",
62                 ID:     4,
63                 Text:   report,
64                 Source: name,
65                 Status: subagentFinished,
66         }, test.events.LastEvent("work"))
67
68         var first, second *ModelFakeCall
69         for n := range server.Calls() {
70                 call := server.Call(n)
71                 if !subagentTestCall(call) {
72                         continue
73                 }
74
75                 if first == nil {
76                         first = &call
77                 } else {
78                         second = &call
79                 }
80         }
81
82         require.NotNil(t, second)
83
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])
91
92         instructions, err := test.agent.SubagentPrompt()
93         require.NoError(t, err)
94         require.Equal(t, instructions, first.body["instructions"])
95
96         var tools []string
97         for _, tool := range first.body["tools"].([]any) {
98                 tools = append(tools, tool.(map[string]any)["name"].(string))
99         }
100
101         require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit"}, tools)
102         require.Equal(t, work+"\n", subagentTestLastItem(*second)["output"])
103
104         subagent, err := test.loop.runs.subagents.Get(t.Context(), name)
105         require.NoError(t, err)
106
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)
110
111         var agents []string
112         for _, record := range modelReadUsage(t, test.usagePath) {
113                 agents = append(agents, record.Agent)
114         }
115
116         require.ElementsMatch(t, []string{"", "", "", name, name}, agents)
117 }
118
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 {
125                         close(arrived)
126                         <-release
127                         modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "date", `{"id":0}`)}, 100, 0, 5))
128
129                         return
130                 }
131
132                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("done")}, 100, 0, 5))
133         }
134
135         test := newTestTurnLoop(t, server)
136         _, err := test.loop.Submit(terminalSource, "first")
137         require.NoError(t, err)
138
139         <-arrived
140         report := subagentReport("GreenOtter", []string{"It is done."})
141         test.loop.Report("GreenOtter", report, subagentFinished)
142         close(release)
143
144         test.events.WaitFor(t, "idle", 1)
145         require.Equal(t, report, subagentTestText(subagentTestLastItem(server.Call(1))))
146
147         time.Sleep(50 * time.Millisecond)
148         require.Equal(t, 2, server.Calls())
149         require.Equal(t, 1, test.events.Count("busy"))
150 }
151
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)
157
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"))
163 }
164
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)
171
172                         return
173                 }
174
175                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("It failed.")}, 100, 0, 5))
176         }
177
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)
182
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 + "\")",
186                 "reply: It failed.",
187         }, test.Log(t))
188         require.Equal(t, subagentFailed, test.events.LastEvent("work").Status)
189 }
190
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))
196
197                         return
198                 }
199
200                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5))
201         }
202
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")
207
208         effort := "max"
209         _, err = runs.Spawn(t.Context(), []string{"Think."}, &effort)
210         require.EqualError(t, err, `no effort "max": one of low, medium, high, xhigh`)
211
212         effort = "high"
213         _, err = runs.Spawn(t.Context(), []string{"Think hard."}, &effort)
214         require.NoError(t, err)
215         test.events.WaitFor(t, "idle", 1)
216
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"])
221                 }
222         }
223
224         subagent, err := runs.subagents.Get(t.Context(), test.events.Last("subagent"))
225         require.NoError(t, err)
226
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)
230
231         entries, err := os.ReadDir(runs.subagents.dir)
232         require.NoError(t, err)
233         require.Len(t, entries, 1)
234 }
235
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) {
241                 switch {
242                 case !subagentTestCall(call):
243                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Noted.")}, 100, 0, 5))
244                 case call.number == 0:
245                         close(arrived)
246                         <-told
247                         modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "shell", `{"command":"true"}`)}, 100, 0, 5))
248                 default:
249                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Used the second disk.")}, 100, 0, 5))
250                 }
251         }
252
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)
257
258         <-arrived
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)
263         close(told)
264
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])
268 }
269
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) {
275                 switch {
276                 case !subagentTestCall(call):
277                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Noted.")}, 100, 0, 5))
278                 case call.number == 0:
279                         close(arrived)
280                         <-told
281                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("First.")}, 100, 0, 5))
282                 default:
283                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Second.")}, 100, 0, 5))
284                 }
285         }
286
287         test := newTestTurnLoop(t, server)
288         runs := test.loop.runs
289         _, err := runs.Spawn(t.Context(), []string{"Count."}, nil)
290         require.NoError(t, err)
291
292         <-arrived
293         name := test.events.Last("subagent")
294         _, err = runs.Tell(t.Context(), name, "Count again.")
295         require.NoError(t, err)
296         close(told)
297
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])
301 }
302
303 func TestTellToAFinishedSubagentGoesOnFromItsLog(t *testing.T) {
304         server := newModelFake(t)
305         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
306                 text := "Noted."
307                 if subagentTestCall(call) {
308                         text = "Reply " + strconv.Itoa(call.number) + "."
309                 }
310
311                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5))
312         }
313
314         test := newTestTurnLoop(t, server)
315         runs := test.loop.runs
316         effort := "high"
317         _, err := runs.Spawn(t.Context(), []string{"Count."}, &effort)
318         require.NoError(t, err)
319         test.events.WaitFor(t, "idle", 1)
320
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)
326
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)))
331
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])
336
337         _, err = runs.Tell(t.Context(), "BlueFox", "Hello.")
338         require.EqualError(t, err, "no agent BlueFox")
339 }
340
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))
345         }
346
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)
351
352         name := test.events.Last("subagent")
353         subagent, err := runs.subagents.Get(t.Context(), name)
354         require.NoError(t, err)
355
356         require.Eventually(t, func() bool {
357                 _, err := os.Stat(filepath.Join(subagent.WorkDir(), "started"))
358                 return err == nil
359         }, 10*time.Second, time.Millisecond)
360
361         start := time.Now()
362         found, err := test.loop.StopSubagent(name)
363         require.NoError(t, err)
364         require.True(t, found)
365
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"}),
371         }, test.Log(t))
372         require.Equal(t, subagentStopped, test.events.LastEvent("work").Status)
373
374         log, err := subagent.Show()
375         require.NoError(t, err)
376         require.Contains(t, log, "echo: \n[interrupted]")
377
378         found, err = test.loop.StopSubagent(name)
379         require.NoError(t, err)
380         require.False(t, found)
381
382         time.Sleep(50 * time.Millisecond)
383         require.Zero(t, test.events.Count("busy"))
384 }
385
386 func TestCloseEndsTheSubagentsWithNoReport(t *testing.T) {
387         server := newModelFake(t)
388         arrived := make(chan struct{})
389         release := make(chan struct{})
390         t.Cleanup(func() {
391                 close(release)
392         })
393
394         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
395                 close(arrived)
396                 <-release
397         }
398
399         test := newTestTurnLoop(t, server)
400         runs := test.loop.runs
401         _, err := runs.Spawn(t.Context(), []string{"Wait."}, nil)
402         require.NoError(t, err)
403
404         <-arrived
405         runs.Close()
406         require.Empty(t, runs.running)
407         require.Empty(t, test.Log(t))
408
409         _, err = runs.Spawn(t.Context(), []string{"Again."}, nil)
410         require.ErrorIs(t, err, errSubagentClosed)
411
412         entries, err := os.ReadDir(runs.subagents.dir)
413         require.NoError(t, err)
414         require.Len(t, entries, 1)
415 }
416
417 // subagentTestCall tells if call is a call of a subagent.
418 func subagentTestCall(call ModelFakeCall) bool {
419         instructions, _ := call.body["instructions"].(string)
420
421         return strings.HasPrefix(instructions, "You are a subagent")
422 }
423
424 // subagentTestLastItem returns the last input item of call.
425 func subagentTestLastItem(call ModelFakeCall) map[string]any {
426         input := call.body["input"].([]any)
427
428         return input[len(input)-1].(map[string]any)
429 }
430
431 // subagentTestText returns the text of the user message item, or "" for
432 // another item.
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)
439         }
440
441         return text.String()
442 }