]> git.giacomolli.uk - onechat/blob - subagent_run_test.go
Run subagents in the background with spawn
[onechat] / subagent_run_test.go
1 package main
2
3 import (
4         "io"
5         "net/http"
6         "os"
7         "path/filepath"
8         "strings"
9         "testing"
10         "time"
11
12         "github.com/stretchr/testify/require"
13 )
14
15 // subagentTestFullContext is the error body of a call whose input does not fit
16 // in the context of the model.
17 const subagentTestFullContext = `{"error": {"message": "Your input exceeds the context window of this model.", "type": "invalid_request_error", "code": "context_length_exceeded"}}`
18
19 func TestSpawnRunsASubagentInTheBackground(t *testing.T) {
20         server := newModelFake(t)
21         var test *TurnTestLoop
22         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
23                 last := subagentTestLastItem(call)
24                 switch {
25                 case subagentTestCall(call) && last["type"] == "function_call_output":
26                         // The report comes after the reply of the turn of the spawn.
27                         test.events.WaitFor(t, "reply", 1)
28                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("There are 3 files.")}, 100, 0, 5))
29                 case subagentTestCall(call):
30                         modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "shell", `{"command":"pwd"}`)}, 100, 0, 5))
31                 case last["type"] == "function_call_output":
32                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Started.")}, 100, 0, 5))
33                 case strings.HasPrefix(subagentTestText(last), "["):
34                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Noted.")}, 100, 0, 5))
35                 default:
36                         modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "spawn", `{"tasks":["Count the files."],"effort":null}`)}, 100, 0, 5))
37                 }
38         }
39
40         test = newTestTurnLoop(t, server)
41         _, err := test.loop.Submit(terminalSource, "Count the files in a subagent.")
42         require.NoError(t, err)
43         test.events.WaitFor(t, "idle", 2)
44
45         name := test.events.Last("subagent")
46         require.True(t, subagentNameValid(name), name)
47         work := filepath.Join(test.loop.runs.subagents.dir, name, "work")
48         require.DirExists(t, work)
49
50         report := "[" + name + "]\nThere are 3 files.\n\nFull chat: zoom(\"" + name + "\")"
51         require.Equal(t, []string{
52                 "user: Count the files in a subagent.",
53                 `tool: spawn {"tasks":["Count the files."],"effort":null}`,
54                 "echo: Started " + name + ". Each one's report will reach you as a message when it finishes.",
55                 "reply: Started.",
56                 "work: " + report,
57                 "reply: Noted.",
58         }, test.Log(t))
59
60         var first, second *ModelFakeCall
61         for n := range server.Calls() {
62                 call := server.Call(n)
63                 if !subagentTestCall(call) {
64                         continue
65                 }
66
67                 if first == nil {
68                         first = &call
69                 } else {
70                         second = &call
71                 }
72         }
73
74         require.NotNil(t, second)
75
76         // The first request of the subagent holds the view, the state, and its
77         // whole log, with its own prompt and tools.
78         texts := turnInputTexts(t, *first)
79         view := strings.Join(texts[:len(texts)-2], "")
80         require.True(t, strings.HasPrefix(view, "<chat>\n0+1|user: Count the files in a subagent.\n1+1|tool: spawn"), view)
81         require.Equal(t, "</chat>\n\nNow: Thursday 2026-01-01 12:00 +00:00", texts[len(texts)-2])
82         require.Equal(t, "0|note: subagent\n\n1|user: Count the files.", texts[len(texts)-1])
83
84         instructions, err := test.agent.SubagentPrompt()
85         require.NoError(t, err)
86         require.Equal(t, instructions, first.body["instructions"])
87
88         var tools []string
89         for _, tool := range first.body["tools"].([]any) {
90                 tools = append(tools, tool.(map[string]any)["name"].(string))
91         }
92
93         require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit"}, tools)
94         require.Equal(t, work+"\n", subagentTestLastItem(*second)["output"])
95
96         subagent, err := test.loop.runs.subagents.Get(t.Context(), name)
97         require.NoError(t, err)
98
99         log, err := subagent.Show()
100         require.NoError(t, err)
101         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)
102
103         var agents []string
104         for _, record := range modelReadUsage(t, test.usagePath) {
105                 agents = append(agents, record.Agent)
106         }
107
108         require.ElementsMatch(t, []string{"", "", "", name, name}, agents)
109 }
110
111 func TestReportJoinsTheNextRequestOfARunningTurn(t *testing.T) {
112         server := newModelFake(t)
113         arrived := make(chan struct{})
114         release := make(chan struct{})
115         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
116                 if call.number == 0 {
117                         close(arrived)
118                         <-release
119                         modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "date", `{"id":0}`)}, 100, 0, 5))
120
121                         return
122                 }
123
124                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("done")}, 100, 0, 5))
125         }
126
127         test := newTestTurnLoop(t, server)
128         _, err := test.loop.Submit(terminalSource, "first")
129         require.NoError(t, err)
130
131         <-arrived
132         report := subagentReport("GreenOtter", []string{"It is done."})
133         test.loop.Report(report, false)
134         close(release)
135
136         test.events.WaitFor(t, "idle", 1)
137         require.Equal(t, report, subagentTestText(subagentTestLastItem(server.Call(1))))
138
139         time.Sleep(50 * time.Millisecond)
140         require.Equal(t, 2, server.Calls())
141         require.Equal(t, 1, test.events.Count("busy"))
142 }
143
144 func TestReportOfAStoppedSubagentStartsNoTurn(t *testing.T) {
145         server := newModelFake(t)
146         test := newTestTurnLoop(t, server)
147         report := subagentReport("GreenOtter", []string{"failed: context canceled"})
148         test.loop.Report(report, true)
149
150         test.events.WaitFor(t, "work", 1)
151         time.Sleep(50 * time.Millisecond)
152         require.Equal(t, []string{"work: " + report}, test.Log(t))
153         require.Zero(t, server.Calls())
154         require.Zero(t, test.events.Count("busy"))
155 }
156
157 func TestFullContextGivesAFailedReport(t *testing.T) {
158         server := newModelFake(t)
159         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
160                 if subagentTestCall(call) {
161                         w.WriteHeader(http.StatusBadRequest)
162                         _, _ = io.WriteString(w, subagentTestFullContext)
163
164                         return
165                 }
166
167                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("It failed.")}, 100, 0, 5))
168         }
169
170         test := newTestTurnLoop(t, server)
171         _, err := test.loop.runs.Spawn(t.Context(), []string{"Read the big file."}, nil)
172         require.NoError(t, err)
173         test.events.WaitFor(t, "idle", 1)
174
175         name := test.events.Last("subagent")
176         require.Equal(t, []string{
177                 "work: [" + name + "]\nfailed: model call: HTTP 400: " + subagentTestFullContext + "\n\nFull chat: zoom(\"" + name + "\")",
178                 "reply: It failed.",
179         }, test.Log(t))
180 }
181
182 func TestSubagentHasItsOwnEffortAndNoSpawn(t *testing.T) {
183         server := newModelFake(t)
184         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
185                 if subagentTestCall(call) && subagentTestLastItem(call)["type"] != "function_call_output" {
186                         modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "spawn", `{"tasks":["More."],"effort":null}`)}, 100, 0, 5))
187
188                         return
189                 }
190
191                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5))
192         }
193
194         test := newTestTurnLoop(t, server)
195         runs := test.loop.runs
196         _, err := runs.Spawn(t.Context(), nil, nil)
197         require.EqualError(t, err, "no tasks")
198
199         effort := "max"
200         _, err = runs.Spawn(t.Context(), []string{"Think."}, &effort)
201         require.EqualError(t, err, `no effort "max": one of low, medium, high, xhigh`)
202
203         effort = "high"
204         _, err = runs.Spawn(t.Context(), []string{"Think hard."}, &effort)
205         require.NoError(t, err)
206         test.events.WaitFor(t, "idle", 1)
207
208         for n := range server.Calls() {
209                 call := server.Call(n)
210                 if subagentTestCall(call) {
211                         require.Equal(t, "high", call.body["reasoning"].(map[string]any)["effort"])
212                 }
213         }
214
215         subagent, err := runs.subagents.Get(t.Context(), test.events.Last("subagent"))
216         require.NoError(t, err)
217
218         log, err := subagent.Show()
219         require.NoError(t, err)
220         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)
221
222         entries, err := os.ReadDir(runs.subagents.dir)
223         require.NoError(t, err)
224         require.Len(t, entries, 1)
225 }
226
227 func TestCloseEndsTheSubagentsWithNoReport(t *testing.T) {
228         server := newModelFake(t)
229         arrived := make(chan struct{})
230         release := make(chan struct{})
231         t.Cleanup(func() {
232                 close(release)
233         })
234
235         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
236                 close(arrived)
237                 <-release
238         }
239
240         test := newTestTurnLoop(t, server)
241         runs := test.loop.runs
242         _, err := runs.Spawn(t.Context(), []string{"Wait."}, nil)
243         require.NoError(t, err)
244
245         <-arrived
246         runs.Close()
247         require.Empty(t, runs.running)
248         require.Empty(t, test.Log(t))
249
250         _, err = runs.Spawn(t.Context(), []string{"Again."}, nil)
251         require.ErrorIs(t, err, errSubagentClosed)
252
253         entries, err := os.ReadDir(runs.subagents.dir)
254         require.NoError(t, err)
255         require.Len(t, entries, 1)
256 }
257
258 // subagentTestCall tells if call is a call of a subagent.
259 func subagentTestCall(call ModelFakeCall) bool {
260         instructions, _ := call.body["instructions"].(string)
261
262         return strings.HasPrefix(instructions, "You are a subagent")
263 }
264
265 // subagentTestLastItem returns the last input item of call.
266 func subagentTestLastItem(call ModelFakeCall) map[string]any {
267         input := call.body["input"].([]any)
268
269         return input[len(input)-1].(map[string]any)
270 }
271
272 // subagentTestText returns the text of the user message item, or "" for
273 // another item.
274 func subagentTestText(item map[string]any) string {
275         content, _ := item["content"].([]any)
276         var text strings.Builder
277         for _, part := range content {
278                 value, _ := part.(map[string]any)["text"].(string)
279                 text.WriteString(value)
280         }
281
282         return text.String()
283 }