]> git.giacomolli.uk - onechat/blob - subagent_run_test.go
Check spacing between Go statement blocks
[onechat] / subagent_run_test.go
1 package main
2
3 import (
4         "context"
5         "io"
6         "net/http"
7         "os"
8         "path/filepath"
9         "strconv"
10         "strings"
11         "sync/atomic"
12         "testing"
13         "time"
14
15         "github.com/stretchr/testify/require"
16 )
17
18 // subagentTestFullContext is the error body of a call whose input does not fit
19 // in the context of the model.
20 const subagentTestFullContext = `{"error": {"message": "Your input exceeds the context window of this model.", "type": "invalid_request_error", "code": "context_length_exceeded"}}`
21
22 func TestSpawnRunsASubagentInTheBackground(t *testing.T) {
23         server := newModelFake(t)
24
25         var test *TurnTestLoop
26
27         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
28                 last := subagentTestLastItem(call)
29                 switch {
30                 case subagentTestCall(call) && last["type"] == "function_call_output":
31                         // The report comes after the reply of the turn of the spawn.
32                         test.events.WaitFor(t, "reply", 1)
33                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("There are 3 files.")}, 100, 0, 5))
34                 case subagentTestCall(call):
35                         modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "shell", `{"command":"pwd"}`)}, 100, 0, 5))
36                 case last["type"] == "function_call_output":
37                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Started.")}, 100, 0, 5))
38                 case strings.HasPrefix(subagentTestText(last), "["):
39                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Noted.")}, 100, 0, 5))
40                 default:
41                         modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "spawn", `{"tasks":["Count the files."],"effort":null}`)}, 100, 0, 5))
42                 }
43         }
44
45         test = newTestTurnLoop(t, server)
46         _, err := test.loop.Submit(terminalSource, "Count the files in a subagent.")
47         require.NoError(t, err)
48         test.events.WaitFor(t, "idle", 2)
49
50         name := test.events.Last("subagent")
51         require.True(t, subagentNameValid(name), name)
52
53         work := filepath.Join(test.loop.runs.subagents.dir, name, "work")
54         require.DirExists(t, work)
55
56         report := "[" + name + "]\nThere are 3 files.\n\nFull chat: zoom(\"" + name + "\")"
57         require.Equal(t, []string{
58                 "user: Count the files in a subagent.",
59                 `tool: spawn {"tasks":["Count the files."],"effort":null}`,
60                 "echo: Started " + name + ". Each one's report will reach you as a message when it finishes.",
61                 "reply: Started.",
62                 "work: " + report,
63                 "reply: Noted.",
64         }, test.Log(t))
65         require.Equal(t, Event{
66                 Kind:   "work",
67                 ID:     4,
68                 Text:   report,
69                 Source: name,
70                 Status: subagentFinished,
71         }, test.events.LastEvent("work"))
72         require.Equal(t, "subagent "+name+" started\nsubagent "+name+" done after 0s\n", test.logs.String())
73
74         var first, second *ModelFakeCall
75
76         for n := range server.Calls() {
77                 call := server.Call(n)
78                 if !subagentTestCall(call) {
79                         continue
80                 }
81
82                 if first == nil {
83                         first = &call
84                 } else {
85                         second = &call
86                 }
87         }
88
89         require.NotNil(t, second)
90
91         // The first request of the subagent holds the view, the state, and its
92         // whole log, with its own prompt and tools.
93         texts := turnInputTexts(t, *first)
94         view := strings.Join(texts[:len(texts)-2], "")
95         require.True(t, strings.HasPrefix(view, "<chat>\n0+1|user: Count the files in a subagent.\n1+1|tool: spawn"), view)
96         require.Equal(t, "</chat>\n\nNow: Thursday 2026-01-01 12:00 +00:00", texts[len(texts)-2])
97         require.Equal(t, "0|note: subagent\n\n1|user: Count the files.", texts[len(texts)-1])
98
99         instructions, err := test.agent.SubagentPrompt()
100         require.NoError(t, err)
101         require.Equal(t, instructions, first.body["instructions"])
102
103         var tools []string
104
105         for _, tool := range first.body["tools"].([]any) {
106                 tools = append(tools, tool.(map[string]any)["name"].(string))
107         }
108
109         require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit"}, tools)
110         require.Equal(t, work+"\n", subagentTestLastItem(*second)["output"])
111
112         subagent, err := test.loop.runs.subagents.Get(t.Context(), name)
113         require.NoError(t, err)
114
115         log, err := subagent.Show()
116         require.NoError(t, err)
117         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)
118
119         var agents []string
120
121         for _, record := range modelReadUsage(t, test.usagePath) {
122                 agents = append(agents, record.Agent)
123         }
124
125         require.ElementsMatch(t, []string{"", "", "", name, name}, agents)
126 }
127
128 func TestReportJoinsTheNextRequestOfARunningTurn(t *testing.T) {
129         server := newModelFake(t)
130         arrived := make(chan struct{})
131         release := make(chan struct{})
132         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
133                 if call.number == 0 {
134                         close(arrived)
135                         <-release
136                         modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "date", `{"id":0}`)}, 100, 0, 5))
137
138                         return
139                 }
140
141                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("done")}, 100, 0, 5))
142         }
143
144         test := newTestTurnLoop(t, server)
145         _, err := test.loop.Submit(terminalSource, "first")
146         require.NoError(t, err)
147
148         <-arrived
149
150         report := subagentReport("GreenOtter", []string{"It is done."})
151         test.loop.Report("GreenOtter", report, subagentFinished)
152         close(release)
153
154         test.events.WaitFor(t, "idle", 1)
155         require.Equal(t, report, subagentTestText(subagentTestLastItem(server.Call(1))))
156
157         time.Sleep(50 * time.Millisecond)
158         require.Equal(t, 2, server.Calls())
159         require.Equal(t, 1, test.events.Count("busy"))
160 }
161
162 func TestReportOfAStoppedSubagentStartsNoTurn(t *testing.T) {
163         server := newModelFake(t)
164         test := newTestTurnLoop(t, server)
165         report := subagentReport("GreenOtter", []string{"failed: context canceled"})
166         test.loop.Report("GreenOtter", report, subagentStopped)
167
168         test.events.WaitFor(t, "work", 1)
169         time.Sleep(50 * time.Millisecond)
170         require.Equal(t, []string{"work: " + report}, test.Log(t))
171         require.Zero(t, server.Calls())
172         require.Zero(t, test.events.Count("busy"))
173 }
174
175 func TestFullContextGivesAFailedReport(t *testing.T) {
176         server := newModelFake(t)
177         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
178                 if subagentTestCall(call) {
179                         w.WriteHeader(http.StatusBadRequest)
180
181                         _, _ = io.WriteString(w, subagentTestFullContext)
182
183                         return
184                 }
185
186                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("It failed.")}, 100, 0, 5))
187         }
188
189         test := newTestTurnLoop(t, server)
190         _, err := test.loop.runs.Spawn(t.Context(), []string{"Read the big file."}, nil)
191         require.NoError(t, err)
192         test.events.WaitFor(t, "idle", 1)
193
194         name := test.events.Last("subagent")
195         require.Equal(t, []string{
196                 "work: [" + name + "]\nfailed: create model response: model call: HTTP 400: " + subagentTestFullContext + "\n\nFull chat: zoom(\"" + name + "\")",
197                 "reply: It failed.",
198         }, test.Log(t))
199         require.Equal(t, subagentFailed, test.events.LastEvent("work").Status)
200         require.Equal(t, "subagent "+name+" started\nsubagent "+name+" failed after 0s: create model response: model call: HTTP 400: "+subagentTestFullContext+"\n", test.logs.String())
201 }
202
203 func TestSubagentHasItsOwnEffortAndNoSpawn(t *testing.T) {
204         server := newModelFake(t)
205         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
206                 if subagentTestCall(call) && subagentTestLastItem(call)["type"] != "function_call_output" {
207                         modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "spawn", `{"tasks":["More."],"effort":null}`)}, 100, 0, 5))
208
209                         return
210                 }
211
212                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5))
213         }
214
215         test := newTestTurnLoop(t, server)
216         runs := test.loop.runs
217         _, err := runs.Spawn(t.Context(), nil, nil)
218         require.EqualError(t, err, "no tasks")
219
220         effort := "max"
221         _, err = runs.Spawn(t.Context(), []string{"Think."}, &effort)
222         require.EqualError(t, err, `no effort "max": one of low, medium, high, xhigh`)
223
224         effort = "high"
225         _, err = runs.Spawn(t.Context(), []string{"Think hard."}, &effort)
226         require.NoError(t, err)
227         test.events.WaitFor(t, "idle", 1)
228
229         for n := range server.Calls() {
230                 call := server.Call(n)
231                 if subagentTestCall(call) {
232                         require.Equal(t, "high", call.body["reasoning"].(map[string]any)["effort"])
233                 }
234         }
235
236         subagent, err := runs.subagents.Get(t.Context(), test.events.Last("subagent"))
237         require.NoError(t, err)
238
239         log, err := subagent.Show()
240         require.NoError(t, err)
241         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)
242
243         entries, err := os.ReadDir(runs.subagents.dir)
244         require.NoError(t, err)
245         require.Len(t, entries, 1)
246 }
247
248 func TestTellReachesARunningSubagentBetweenItsToolCalls(t *testing.T) {
249         server := newModelFake(t)
250         arrived := make(chan struct{})
251         told := make(chan struct{})
252         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
253                 switch {
254                 case !subagentTestCall(call):
255                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Noted.")}, 100, 0, 5))
256                 case call.number == 0:
257                         close(arrived)
258                         <-told
259                         modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "shell", `{"command":"true"}`)}, 100, 0, 5))
260                 default:
261                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Used the second disk.")}, 100, 0, 5))
262                 }
263         }
264
265         test := newTestTurnLoop(t, server)
266         runs := test.loop.runs
267         _, err := runs.Spawn(t.Context(), []string{"Copy the files."}, nil)
268         require.NoError(t, err)
269
270         <-arrived
271
272         name := test.events.Last("subagent")
273         output, err := runs.Tell(t.Context(), name, "Use the second disk.")
274         require.NoError(t, err)
275         require.Equal(t, "Sent. "+name+" reads it between its tool calls, and its report answers it.", output)
276         close(told)
277
278         test.events.WaitFor(t, "idle", 1)
279         require.Equal(t, "Use the second disk.", subagentTestText(subagentTestLastItem(server.Call(1))))
280         require.Equal(t, "work: "+subagentReport(name, []string{"Used the second disk."}), test.Log(t)[0])
281 }
282
283 func TestTellAfterTheFinalAnswerStartsAnotherChain(t *testing.T) {
284         server := newModelFake(t)
285         arrived := make(chan struct{})
286         told := make(chan struct{})
287         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
288                 switch {
289                 case !subagentTestCall(call):
290                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Noted.")}, 100, 0, 5))
291                 case call.number == 0:
292                         close(arrived)
293                         <-told
294                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("First.")}, 100, 0, 5))
295                 default:
296                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Second.")}, 100, 0, 5))
297                 }
298         }
299
300         test := newTestTurnLoop(t, server)
301         runs := test.loop.runs
302         _, err := runs.Spawn(t.Context(), []string{"Count."}, nil)
303         require.NoError(t, err)
304
305         <-arrived
306
307         name := test.events.Last("subagent")
308         _, err = runs.Tell(t.Context(), name, "Count again.")
309         require.NoError(t, err)
310         close(told)
311
312         test.events.WaitFor(t, "idle", 1)
313         require.Equal(t, "0|note: subagent\n\n1|user: Count.\n\n2|user: Count again.\n\n3|reply: First.", subagentTestText(subagentTestLastItem(server.Call(1))))
314         require.Equal(t, "work: "+subagentReport(name, []string{"First.", "Second."}), test.Log(t)[0])
315 }
316
317 func TestTellToAFinishedSubagentGoesOnFromItsLog(t *testing.T) {
318         server := newModelFake(t)
319         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
320                 text := "Noted."
321                 if subagentTestCall(call) {
322                         text = "Reply " + strconv.Itoa(call.number) + "."
323                 }
324
325                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5))
326         }
327
328         test := newTestTurnLoop(t, server)
329         runs := test.loop.runs
330         effort := "high"
331         _, err := runs.Spawn(t.Context(), []string{"Count."}, &effort)
332         require.NoError(t, err)
333         test.events.WaitFor(t, "idle", 1)
334
335         name := test.events.Last("subagent")
336         output, err := runs.Tell(t.Context(), name, "And the hidden files?")
337         require.NoError(t, err)
338         require.Equal(t, "Sent. "+name+"'s reply will reach you as a message.", output)
339         test.events.WaitFor(t, "idle", 2)
340
341         call := server.Call(2)
342         require.True(t, subagentTestCall(call))
343         require.Equal(t, "high", call.body["reasoning"].(map[string]any)["effort"])
344         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)))
345
346         // The view of the new run holds the chat of now, with the first report.
347         texts := turnInputTexts(t, call)
348         require.Contains(t, strings.Join(texts[:len(texts)-2], ""), "work: ["+name+"]")
349         require.Equal(t, "work: "+subagentReport(name, []string{"Reply 2."}), test.Log(t)[2])
350
351         _, err = runs.Tell(t.Context(), "BlueFox", "Hello.")
352         require.EqualError(t, err, "no agent BlueFox")
353 }
354
355 func TestStopEndsASubagentAndItsCommand(t *testing.T) {
356         server := newModelFake(t)
357         server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
358                 modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "shell", `{"command":"touch started && sleep 30"}`)}, 100, 0, 5))
359         }
360
361         test := newTestTurnLoop(t, server)
362         runs := test.loop.runs
363         _, err := runs.Spawn(t.Context(), []string{"Wait long."}, nil)
364         require.NoError(t, err)
365
366         name := test.events.Last("subagent")
367         subagent, err := runs.subagents.Get(t.Context(), name)
368         require.NoError(t, err)
369
370         require.Eventually(t, func() bool {
371                 _, err := os.Stat(filepath.Join(subagent.WorkDir(), "started"))
372                 return err == nil
373         }, 10*time.Second, time.Millisecond)
374
375         start := time.Now()
376         found, err := test.loop.StopSubagent(name)
377         require.NoError(t, err)
378         require.True(t, found)
379
380         test.events.WaitFor(t, "work", 1)
381         require.Less(t, time.Since(start), 10*time.Second)
382         require.Equal(t, []string{
383                 "note: Stopped by the user: " + name + ".",
384                 "work: " + subagentReport(name, []string{"stopped"}),
385         }, test.Log(t))
386         require.Equal(t, subagentStopped, test.events.LastEvent("work").Status)
387         require.Equal(t, "subagent "+name+" started\nsubagent "+name+" stopped after 0s\n", test.logs.String())
388
389         log, err := subagent.Show()
390         require.NoError(t, err)
391         require.Contains(t, log, "echo: \n[interrupted]")
392
393         found, err = test.loop.StopSubagent(name)
394         require.NoError(t, err)
395         require.False(t, found)
396
397         time.Sleep(50 * time.Millisecond)
398         require.Zero(t, test.events.Count("busy"))
399 }
400
401 func TestCloseEndsTheSubagentsWithNoReport(t *testing.T) {
402         server := newModelFake(t)
403         arrived := make(chan struct{})
404         release := make(chan struct{})
405         t.Cleanup(func() {
406                 close(release)
407         })
408
409         server.reply = func(_ http.ResponseWriter, _ ModelFakeCall) {
410                 close(arrived)
411                 <-release
412         }
413
414         test := newTestTurnLoop(t, server)
415         runs := test.loop.runs
416         _, err := runs.Spawn(t.Context(), []string{"Wait."}, nil)
417         require.NoError(t, err)
418
419         <-arrived
420         runs.Close()
421         require.Empty(t, runs.running)
422         require.Empty(t, test.Log(t))
423
424         _, err = runs.Spawn(t.Context(), []string{"Again."}, nil)
425         require.ErrorIs(t, err, errSubagentClosed)
426
427         entries, err := os.ReadDir(runs.subagents.dir)
428         require.NoError(t, err)
429         require.Len(t, entries, 1)
430 }
431
432 func TestRestartResumesTheCutSubagents(t *testing.T) {
433         server := newModelFake(t)
434         arrived := make(chan struct{}, 1)
435         release := make(chan struct{})
436         t.Cleanup(func() {
437                 close(release)
438         })
439
440         var restarted atomic.Bool
441
442         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
443                 switch {
444                 case subagentTestCall(call) && !restarted.Load():
445                         arrived <- struct{}{}
446
447                         <-release
448                 case subagentTestCall(call):
449                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Built.")}, 100, 0, 5))
450                 default:
451                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Noted.")}, 100, 0, 5))
452                 }
453         }
454
455         agent, err := openAgent(t.TempDir())
456         require.NoError(t, err)
457
458         first := &TurnEventRecorder{}
459         loop, _, stop := subagentTestStart(t, agent, server, first)
460         _, err = loop.runs.Spawn(t.Context(), []string{"Build the project."}, nil)
461         require.NoError(t, err)
462
463         <-arrived
464
465         name := first.Last("subagent")
466
467         stop()
468         require.Zero(t, first.Count("work"))
469
470         data, err := os.ReadFile(filepath.Join(agent.dir, subagentWorkFileName))
471         require.NoError(t, err)
472         require.JSONEq(t, `{"running": ["`+name+`"]}`, string(data))
473
474         restarted.Store(true)
475
476         second := &TurnEventRecorder{}
477         loop, memory, stop := subagentTestStart(t, agent, server, second)
478         defer stop()
479
480         second.WaitFor(t, "idle", 1)
481
482         note := "The daemon restarted, cutting " + name + "; nothing was lost: go on."
483         log := subagentTestChat(t, memory)
484         require.Equal(t, []string{
485                 "note: " + note,
486                 "work: " + subagentReport(name, []string{"Built."}),
487                 "reply: Noted.",
488         }, log[len(log)-3:])
489
490         subagent, err := loop.runs.subagents.Get(t.Context(), name)
491         require.NoError(t, err)
492
493         text, err := subagent.Show()
494         require.NoError(t, err)
495         require.Equal(t, "0|note: subagent\n\n1|user: Build the project.\n\n2|user: "+note+"\n\n3|reply: Built.", text)
496
497         data, err = os.ReadFile(filepath.Join(agent.dir, subagentWorkFileName))
498         require.NoError(t, err)
499         require.JSONEq(t, `{"running": []}`, string(data))
500 }
501
502 func TestRestartReportsASubagentThatCannotGoOn(t *testing.T) {
503         server := newModelFake(t)
504         agent, err := openAgent(t.TempDir())
505         require.NoError(t, err)
506         require.NoError(t, os.WriteFile(filepath.Join(agent.dir, subagentWorkFileName), []byte(`{"running": ["BlueFox"]}`), 0o600))
507
508         events := &TurnEventRecorder{}
509         _, memory, stop := subagentTestStart(t, agent, server, events)
510         defer stop()
511
512         events.WaitFor(t, "idle", 1)
513         require.Equal(t, []string{
514                 "note: The daemon restarted, cutting BlueFox; nothing was lost: go on.",
515                 "work: " + subagentReport("BlueFox", []string{"failed: no agent BlueFox"}),
516                 "reply: a line",
517         }, subagentTestChat(t, memory))
518         require.Equal(t, subagentFailed, events.LastEvent("work").Status)
519
520         data, err := os.ReadFile(filepath.Join(agent.dir, subagentWorkFileName))
521         require.NoError(t, err)
522         require.JSONEq(t, `{"running": []}`, string(data))
523 }
524
525 // subagentTestStart runs a loop of agent with its memory and subagents in the
526 // agent directory, as the daemon does. The returned function stops the loop
527 // and closes the files.
528 func subagentTestStart(t *testing.T, agent *Agent, server *ModelFake, events *TurnEventRecorder) (*TurnLoop, *Memory, func()) {
529         t.Helper()
530
531         agent.config.Workdir = t.TempDir()
532         clock := newTestClock()
533         memory, err := openMemory(t.Context(), agent.dir, clock.Now, newFakeSummarizer(), discardLog())
534         require.NoError(t, err)
535
536         subagents := newSubagents(filepath.Join(agent.dir, subagentDirName), clock.Now, subagentNameRandom)
537         client := newModelClient(server.URL, ModelAPIKey(modelTestKey), agent.CacheKey(), filepath.Join(agent.dir, "usage.jsonl"))
538         whatsapp, _, _ := whatsappStartTest(t, t.TempDir(), clock)
539         loop, err := newTurnLoop(agent, memory, subagents, whatsapp, newTestSchedules(t, clock.Now), client, clock.Now, discardLog(), events.Add)
540         require.NoError(t, err)
541
542         ctx, cancel := context.WithCancel(t.Context())
543         done := make(chan error)
544         go func() {
545                 done <- loop.Run(ctx)
546         }()
547
548         return loop, memory, func() {
549                 cancel()
550                 require.ErrorIs(t, <-done, context.Canceled)
551                 require.NoError(t, subagents.Close())
552                 require.NoError(t, memory.Close())
553         }
554 }
555
556 // subagentTestChat returns each message of the chat in memory as
557 // "kind: text".
558 func subagentTestChat(t *testing.T, memory *Memory) []string {
559         t.Helper()
560
561         var lines []string
562
563         for id := range memory.Len() {
564                 message, err := memory.Message(id)
565                 require.NoError(t, err)
566
567                 lines = append(lines, message.Kind+": "+message.Text)
568         }
569
570         return lines
571 }
572
573 // subagentTestCall tells if call is a call of a subagent.
574 func subagentTestCall(call ModelFakeCall) bool {
575         instructions, _ := call.body["instructions"].(string)
576
577         return strings.HasPrefix(instructions, "You are a subagent")
578 }
579
580 // subagentTestLastItem returns the last input item of call.
581 func subagentTestLastItem(call ModelFakeCall) map[string]any {
582         input := call.body["input"].([]any)
583
584         return input[len(input)-1].(map[string]any)
585 }
586
587 // subagentTestText returns the text of the user message item, or "" for
588 // another item.
589 func subagentTestText(item map[string]any) string {
590         content, _ := item["content"].([]any)
591
592         var text strings.Builder
593
594         for _, part := range content {
595                 value, _ := part.(map[string]any)["text"].(string)
596                 text.WriteString(value)
597         }
598
599         return text.String()
600 }