+package main
+
+import (
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+// subagentTestFullContext is the error body of a call whose input does not fit
+// in the context of the model.
+const subagentTestFullContext = `{"error": {"message": "Your input exceeds the context window of this model.", "type": "invalid_request_error", "code": "context_length_exceeded"}}`
+
+func TestSpawnRunsASubagentInTheBackground(t *testing.T) {
+ server := newModelFake(t)
+ var test *TurnTestLoop
+ server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+ last := subagentTestLastItem(call)
+ switch {
+ case subagentTestCall(call) && last["type"] == "function_call_output":
+ // The report comes after the reply of the turn of the spawn.
+ test.events.WaitFor(t, "reply", 1)
+ modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("There are 3 files.")}, 100, 0, 5))
+ case subagentTestCall(call):
+ modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "shell", `{"command":"pwd"}`)}, 100, 0, 5))
+ case last["type"] == "function_call_output":
+ modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Started.")}, 100, 0, 5))
+ case strings.HasPrefix(subagentTestText(last), "["):
+ modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Noted.")}, 100, 0, 5))
+ default:
+ modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "spawn", `{"tasks":["Count the files."],"effort":null}`)}, 100, 0, 5))
+ }
+ }
+
+ test = newTestTurnLoop(t, server)
+ _, err := test.loop.Submit(terminalSource, "Count the files in a subagent.")
+ require.NoError(t, err)
+ test.events.WaitFor(t, "idle", 2)
+
+ name := test.events.Last("subagent")
+ require.True(t, subagentNameValid(name), name)
+ work := filepath.Join(test.loop.runs.subagents.dir, name, "work")
+ require.DirExists(t, work)
+
+ report := "[" + name + "]\nThere are 3 files.\n\nFull chat: zoom(\"" + name + "\")"
+ require.Equal(t, []string{
+ "user: Count the files in a subagent.",
+ `tool: spawn {"tasks":["Count the files."],"effort":null}`,
+ "echo: Started " + name + ". Each one's report will reach you as a message when it finishes.",
+ "reply: Started.",
+ "work: " + report,
+ "reply: Noted.",
+ }, test.Log(t))
+
+ var first, second *ModelFakeCall
+ for n := range server.Calls() {
+ call := server.Call(n)
+ if !subagentTestCall(call) {
+ continue
+ }
+
+ if first == nil {
+ first = &call
+ } else {
+ second = &call
+ }
+ }
+
+ require.NotNil(t, second)
+
+ // The first request of the subagent holds the view, the state, and its
+ // whole log, with its own prompt and tools.
+ texts := turnInputTexts(t, *first)
+ view := strings.Join(texts[:len(texts)-2], "")
+ require.True(t, strings.HasPrefix(view, "<chat>\n0+1|user: Count the files in a subagent.\n1+1|tool: spawn"), view)
+ require.Equal(t, "</chat>\n\nNow: Thursday 2026-01-01 12:00 +00:00", texts[len(texts)-2])
+ require.Equal(t, "0|note: subagent\n\n1|user: Count the files.", texts[len(texts)-1])
+
+ instructions, err := test.agent.SubagentPrompt()
+ require.NoError(t, err)
+ require.Equal(t, instructions, first.body["instructions"])
+
+ var tools []string
+ for _, tool := range first.body["tools"].([]any) {
+ tools = append(tools, tool.(map[string]any)["name"].(string))
+ }
+
+ require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit"}, tools)
+ require.Equal(t, work+"\n", subagentTestLastItem(*second)["output"])
+
+ subagent, err := test.loop.runs.subagents.Get(t.Context(), name)
+ require.NoError(t, err)
+
+ log, err := subagent.Show()
+ require.NoError(t, err)
+ 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)
+
+ var agents []string
+ for _, record := range modelReadUsage(t, test.usagePath) {
+ agents = append(agents, record.Agent)
+ }
+
+ require.ElementsMatch(t, []string{"", "", "", name, name}, agents)
+}
+
+func TestReportJoinsTheNextRequestOfARunningTurn(t *testing.T) {
+ server := newModelFake(t)
+ arrived := make(chan struct{})
+ release := make(chan struct{})
+ server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+ if call.number == 0 {
+ close(arrived)
+ <-release
+ modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "date", `{"id":0}`)}, 100, 0, 5))
+
+ return
+ }
+
+ modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("done")}, 100, 0, 5))
+ }
+
+ test := newTestTurnLoop(t, server)
+ _, err := test.loop.Submit(terminalSource, "first")
+ require.NoError(t, err)
+
+ <-arrived
+ report := subagentReport("GreenOtter", []string{"It is done."})
+ test.loop.Report(report, false)
+ close(release)
+
+ test.events.WaitFor(t, "idle", 1)
+ require.Equal(t, report, subagentTestText(subagentTestLastItem(server.Call(1))))
+
+ time.Sleep(50 * time.Millisecond)
+ require.Equal(t, 2, server.Calls())
+ require.Equal(t, 1, test.events.Count("busy"))
+}
+
+func TestReportOfAStoppedSubagentStartsNoTurn(t *testing.T) {
+ server := newModelFake(t)
+ test := newTestTurnLoop(t, server)
+ report := subagentReport("GreenOtter", []string{"failed: context canceled"})
+ test.loop.Report(report, true)
+
+ test.events.WaitFor(t, "work", 1)
+ time.Sleep(50 * time.Millisecond)
+ require.Equal(t, []string{"work: " + report}, test.Log(t))
+ require.Zero(t, server.Calls())
+ require.Zero(t, test.events.Count("busy"))
+}
+
+func TestFullContextGivesAFailedReport(t *testing.T) {
+ server := newModelFake(t)
+ server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+ if subagentTestCall(call) {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = io.WriteString(w, subagentTestFullContext)
+
+ return
+ }
+
+ modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("It failed.")}, 100, 0, 5))
+ }
+
+ test := newTestTurnLoop(t, server)
+ _, err := test.loop.runs.Spawn(t.Context(), []string{"Read the big file."}, nil)
+ require.NoError(t, err)
+ test.events.WaitFor(t, "idle", 1)
+
+ name := test.events.Last("subagent")
+ require.Equal(t, []string{
+ "work: [" + name + "]\nfailed: model call: HTTP 400: " + subagentTestFullContext + "\n\nFull chat: zoom(\"" + name + "\")",
+ "reply: It failed.",
+ }, test.Log(t))
+}
+
+func TestSubagentHasItsOwnEffortAndNoSpawn(t *testing.T) {
+ server := newModelFake(t)
+ server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+ if subagentTestCall(call) && subagentTestLastItem(call)["type"] != "function_call_output" {
+ modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "spawn", `{"tasks":["More."],"effort":null}`)}, 100, 0, 5))
+
+ return
+ }
+
+ modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5))
+ }
+
+ test := newTestTurnLoop(t, server)
+ runs := test.loop.runs
+ _, err := runs.Spawn(t.Context(), nil, nil)
+ require.EqualError(t, err, "no tasks")
+
+ effort := "max"
+ _, err = runs.Spawn(t.Context(), []string{"Think."}, &effort)
+ require.EqualError(t, err, `no effort "max": one of low, medium, high, xhigh`)
+
+ effort = "high"
+ _, err = runs.Spawn(t.Context(), []string{"Think hard."}, &effort)
+ require.NoError(t, err)
+ test.events.WaitFor(t, "idle", 1)
+
+ for n := range server.Calls() {
+ call := server.Call(n)
+ if subagentTestCall(call) {
+ require.Equal(t, "high", call.body["reasoning"].(map[string]any)["effort"])
+ }
+ }
+
+ subagent, err := runs.subagents.Get(t.Context(), test.events.Last("subagent"))
+ require.NoError(t, err)
+
+ log, err := subagent.Show()
+ require.NoError(t, err)
+ 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)
+
+ entries, err := os.ReadDir(runs.subagents.dir)
+ require.NoError(t, err)
+ require.Len(t, entries, 1)
+}
+
+func TestCloseEndsTheSubagentsWithNoReport(t *testing.T) {
+ server := newModelFake(t)
+ arrived := make(chan struct{})
+ release := make(chan struct{})
+ t.Cleanup(func() {
+ close(release)
+ })
+
+ server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+ close(arrived)
+ <-release
+ }
+
+ test := newTestTurnLoop(t, server)
+ runs := test.loop.runs
+ _, err := runs.Spawn(t.Context(), []string{"Wait."}, nil)
+ require.NoError(t, err)
+
+ <-arrived
+ runs.Close()
+ require.Empty(t, runs.running)
+ require.Empty(t, test.Log(t))
+
+ _, err = runs.Spawn(t.Context(), []string{"Again."}, nil)
+ require.ErrorIs(t, err, errSubagentClosed)
+
+ entries, err := os.ReadDir(runs.subagents.dir)
+ require.NoError(t, err)
+ require.Len(t, entries, 1)
+}
+
+// subagentTestCall tells if call is a call of a subagent.
+func subagentTestCall(call ModelFakeCall) bool {
+ instructions, _ := call.body["instructions"].(string)
+
+ return strings.HasPrefix(instructions, "You are a subagent")
+}
+
+// subagentTestLastItem returns the last input item of call.
+func subagentTestLastItem(call ModelFakeCall) map[string]any {
+ input := call.body["input"].([]any)
+
+ return input[len(input)-1].(map[string]any)
+}
+
+// subagentTestText returns the text of the user message item, or "" for
+// another item.
+func subagentTestText(item map[string]any) string {
+ content, _ := item["content"].([]any)
+ var text strings.Builder
+ for _, part := range content {
+ value, _ := part.(map[string]any)["text"].(string)
+ text.WriteString(value)
+ }
+
+ return text.String()
+}