]> git.giacomolli.uk - onechat/blob - subagent_run_test.go
Render Markdown with plain terminal styles
[onechat] / subagent_run_test.go
1 package main
2
3 import (
4         "context"
5         "io"
6         "log/slog"
7         "net/http"
8         "os"
9         "path/filepath"
10         "strconv"
11         "strings"
12         "sync/atomic"
13         "testing"
14         "time"
15
16         "github.com/stretchr/testify/require"
17 )
18
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"}}`
22
23 func TestSpawnRunsASubagentInTheBackground(t *testing.T) {
24         server := newModelFake(t)
25
26         var test *TurnTestLoop
27
28         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
29                 last := subagentTestLastItem(call)
30                 switch {
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))
41                 default:
42                         modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "spawn", `{"tasks":["Count the files."],"effort":null}`)}, 100, 0, 5))
43                 }
44         }
45
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)
50
51         name := test.events.Last("subagent")
52         require.True(t, subagentNameValid(name), name)
53
54         work := filepath.Join(test.loop.runs.subagents.dir, name, "work")
55         require.DirExists(t, work)
56
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.",
62                 "reply: Started.",
63                 "work: " + report,
64                 "reply: Noted.",
65         }, test.Log(t))
66         require.Equal(t, Event{
67                 Kind:   "work",
68                 ID:     4,
69                 Text:   report,
70                 Source: name,
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())
74
75         var first, second *ModelFakeCall
76
77         for n := range server.Calls() {
78                 call := server.Call(n)
79                 if !subagentTestCall(call) {
80                         continue
81                 }
82
83                 if first == nil {
84                         first = &call
85                 } else {
86                         second = &call
87                 }
88         }
89
90         require.NotNil(t, second)
91
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])
99
100         instructions, err := test.agent.SubagentPrompt()
101         require.NoError(t, err)
102         require.Equal(t, instructions, first.body["instructions"])
103
104         var tools []string
105
106         for _, tool := range first.body["tools"].([]any) {
107                 tools = append(tools, tool.(map[string]any)["name"].(string))
108         }
109
110         require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit"}, tools)
111         require.Equal(t, work+"\n", subagentTestLastItem(*second)["output"])
112
113         subagent, err := test.loop.runs.subagents.Get(t.Context(), name)
114         require.NoError(t, err)
115
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)
119
120         var agents []string
121
122         for _, record := range modelReadUsage(t, test.usagePath) {
123                 agents = append(agents, record.Agent)
124         }
125
126         require.ElementsMatch(t, []string{"", "", "", name, name}, agents)
127 }
128
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 {
135                         close(arrived)
136                         <-release
137                         modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "date", `{"id":0}`)}, 100, 0, 5))
138
139                         return
140                 }
141
142                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("done")}, 100, 0, 5))
143         }
144
145         test := newTestTurnLoop(t, server)
146         _, err := test.loop.Submit(terminalSource, "first")
147         require.NoError(t, err)
148
149         <-arrived
150
151         report := subagentReport("GreenOtter", []string{"It is done."})
152         test.loop.Report("GreenOtter", report, subagentFinished)
153         close(release)
154
155         test.events.WaitFor(t, "idle", 1)
156         require.Equal(t, report, subagentTestText(subagentTestLastItem(server.Call(1))))
157
158         time.Sleep(50 * time.Millisecond)
159         require.Equal(t, 2, server.Calls())
160         require.Equal(t, 1, test.events.Count("busy"))
161 }
162
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)
168
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"))
174 }
175
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)
181
182                         _, _ = io.WriteString(w, subagentTestFullContext)
183
184                         return
185                 }
186
187                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("It failed.")}, 100, 0, 5))
188         }
189
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)
194
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 + "\")",
198                 "reply: It failed.",
199         }, test.Log(t))
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())
202 }
203
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))
209
210                         return
211                 }
212
213                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5))
214         }
215
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")
220
221         effort := "max"
222         _, err = runs.Spawn(t.Context(), []string{"Think."}, &effort)
223         require.EqualError(t, err, `no effort "max": one of low, medium, high, xhigh`)
224
225         effort = "high"
226         _, err = runs.Spawn(t.Context(), []string{"Think hard."}, &effort)
227         require.NoError(t, err)
228         test.events.WaitFor(t, "idle", 1)
229
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"])
234                 }
235         }
236
237         subagent, err := runs.subagents.Get(t.Context(), test.events.Last("subagent"))
238         require.NoError(t, err)
239
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)
243
244         entries, err := os.ReadDir(runs.subagents.dir)
245         require.NoError(t, err)
246         require.Len(t, entries, 1)
247 }
248
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) {
254                 switch {
255                 case !subagentTestCall(call):
256                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Noted.")}, 100, 0, 5))
257                 case call.number == 0:
258                         close(arrived)
259                         <-told
260                         modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "shell", `{"command":"true"}`)}, 100, 0, 5))
261                 default:
262                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Used the second disk.")}, 100, 0, 5))
263                 }
264         }
265
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)
270
271         <-arrived
272
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)
277         close(told)
278
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])
282 }
283
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) {
289                 switch {
290                 case !subagentTestCall(call):
291                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Noted.")}, 100, 0, 5))
292                 case call.number == 0:
293                         close(arrived)
294                         <-told
295                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("First.")}, 100, 0, 5))
296                 default:
297                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Second.")}, 100, 0, 5))
298                 }
299         }
300
301         test := newTestTurnLoop(t, server)
302         runs := test.loop.runs
303         _, err := runs.Spawn(t.Context(), []string{"Count."}, nil)
304         require.NoError(t, err)
305
306         <-arrived
307
308         name := test.events.Last("subagent")
309         _, err = runs.Tell(t.Context(), name, "Count again.")
310         require.NoError(t, err)
311         close(told)
312
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])
316 }
317
318 func TestTellToAFinishedSubagentGoesOnFromItsLog(t *testing.T) {
319         server := newModelFake(t)
320         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
321                 text := "Noted."
322                 if subagentTestCall(call) {
323                         text = "Reply " + strconv.Itoa(call.number) + "."
324                 }
325
326                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5))
327         }
328
329         test := newTestTurnLoop(t, server)
330         runs := test.loop.runs
331         effort := "high"
332         _, err := runs.Spawn(t.Context(), []string{"Count."}, &effort)
333         require.NoError(t, err)
334         test.events.WaitFor(t, "idle", 1)
335
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)
341
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)))
346
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])
351
352         _, err = runs.Tell(t.Context(), "BlueFox", "Hello.")
353         require.EqualError(t, err, "no agent BlueFox")
354 }
355
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))
360         }
361
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)
366
367         name := test.events.Last("subagent")
368         subagent, err := runs.subagents.Get(t.Context(), name)
369         require.NoError(t, err)
370
371         require.Eventually(t, func() bool {
372                 _, err := os.Stat(filepath.Join(subagent.WorkDir(), "started"))
373                 return err == nil
374         }, 10*time.Second, time.Millisecond)
375
376         start := time.Now()
377         found, err := test.loop.StopSubagent(name)
378         require.NoError(t, err)
379         require.True(t, found)
380
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"}),
386         }, test.Log(t))
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())
389
390         log, err := subagent.Show()
391         require.NoError(t, err)
392         require.Contains(t, log, "echo: \n[interrupted]")
393
394         found, err = test.loop.StopSubagent(name)
395         require.NoError(t, err)
396         require.False(t, found)
397
398         time.Sleep(50 * time.Millisecond)
399         require.Zero(t, test.events.Count("busy"))
400 }
401
402 func TestCloseEndsTheSubagentsWithNoReport(t *testing.T) {
403         server := newModelFake(t)
404         arrived := make(chan struct{})
405         release := make(chan struct{})
406         t.Cleanup(func() {
407                 close(release)
408         })
409
410         server.reply = func(_ http.ResponseWriter, _ ModelFakeCall) {
411                 close(arrived)
412                 <-release
413         }
414
415         test := newTestTurnLoop(t, server)
416         runs := test.loop.runs
417         _, err := runs.Spawn(t.Context(), []string{"Wait."}, nil)
418         require.NoError(t, err)
419
420         <-arrived
421         runs.Close()
422         require.Empty(t, runs.running)
423         require.Empty(t, test.Log(t))
424
425         _, err = runs.Spawn(t.Context(), []string{"Again."}, nil)
426         require.ErrorIs(t, err, errSubagentClosed)
427
428         entries, err := os.ReadDir(runs.subagents.dir)
429         require.NoError(t, err)
430         require.Len(t, entries, 1)
431 }
432
433 func TestRestartResumesTheCutSubagents(t *testing.T) {
434         server := newModelFake(t)
435         arrived := make(chan struct{}, 1)
436         release := make(chan struct{})
437         t.Cleanup(func() {
438                 close(release)
439         })
440
441         var restarted atomic.Bool
442
443         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
444                 switch {
445                 case subagentTestCall(call) && !restarted.Load():
446                         arrived <- struct{}{}
447
448                         <-release
449                 case subagentTestCall(call):
450                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Built.")}, 100, 0, 5))
451                 default:
452                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Noted.")}, 100, 0, 5))
453                 }
454         }
455
456         agent, err := openAgent(t.TempDir())
457         require.NoError(t, err)
458
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)
463
464         <-arrived
465
466         name := first.Last("subagent")
467
468         stop()
469         require.Zero(t, first.Count("work"))
470
471         data, err := os.ReadFile(filepath.Join(agent.dir, subagentWorkFileName))
472         require.NoError(t, err)
473         require.JSONEq(t, `{"running": ["`+name+`"]}`, string(data))
474
475         restarted.Store(true)
476
477         second := &TurnEventRecorder{}
478         loop, memory, stop := subagentTestStart(t, agent, server, second, discardLog())
479         defer stop()
480
481         second.WaitFor(t, "idle", 1)
482
483         note := "The daemon restarted, cutting " + name + "; nothing was lost: go on."
484         log := subagentTestChat(t, memory)
485         require.Equal(t, []string{
486                 "note: " + note,
487                 "work: " + subagentReport(name, []string{"Built."}),
488                 "reply: Noted.",
489         }, log[len(log)-3:])
490
491         subagent, err := loop.runs.subagents.Get(t.Context(), name)
492         require.NoError(t, err)
493
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)
497
498         data, err = os.ReadFile(filepath.Join(agent.dir, subagentWorkFileName))
499         require.NoError(t, err)
500         require.JSONEq(t, `{"running": []}`, string(data))
501 }
502
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))
508
509         events := &TurnEventRecorder{}
510         logs := &SyncBuffer{}
511         _, memory, stop := subagentTestStart(t, agent, server, events, daemonTestLogger(logs))
512         defer stop()
513
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"}),
518                 "reply: a line",
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())
522
523         data, err := os.ReadFile(filepath.Join(agent.dir, subagentWorkFileName))
524         require.NoError(t, err)
525         require.JSONEq(t, `{"running": []}`, string(data))
526 }
527
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()) {
532         t.Helper()
533
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)
538
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)
544
545         ctx, cancel := context.WithCancel(t.Context())
546         done := make(chan error)
547         go func() {
548                 done <- loop.Run(ctx)
549         }()
550
551         return loop, memory, func() {
552                 cancel()
553                 require.ErrorIs(t, <-done, context.Canceled)
554                 require.NoError(t, subagents.Close())
555                 require.NoError(t, memory.Close())
556         }
557 }
558
559 // subagentTestChat returns each message of the chat in memory as
560 // "kind: text".
561 func subagentTestChat(t *testing.T, memory *Memory) []string {
562         t.Helper()
563
564         var lines []string
565
566         for id := range memory.Len() {
567                 message, err := memory.Message(id)
568                 require.NoError(t, err)
569
570                 lines = append(lines, message.Kind+": "+message.Text)
571         }
572
573         return lines
574 }
575
576 // subagentTestCall tells if call is a call of a subagent.
577 func subagentTestCall(call ModelFakeCall) bool {
578         instructions, _ := call.body["instructions"].(string)
579
580         return strings.HasPrefix(instructions, "You are a subagent")
581 }
582
583 // subagentTestLastItem returns the last input item of call.
584 func subagentTestLastItem(call ModelFakeCall) map[string]any {
585         input := call.body["input"].([]any)
586
587         return input[len(input)-1].(map[string]any)
588 }
589
590 // subagentTestText returns the text of the user message item, or "" for
591 // another item.
592 func subagentTestText(item map[string]any) string {
593         content, _ := item["content"].([]any)
594
595         var text strings.Builder
596
597         for _, part := range content {
598                 value, _ := part.(map[string]any)["text"].(string)
599                 text.WriteString(value)
600         }
601
602         return text.String()
603 }