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