package subagent import ( "bytes" "context" "encoding/json" "io" "net/http" "net/http/httptest" "sort" "strings" "sync" "testing" "time" "harness/internal/agent" "harness/internal/llm" "harness/internal/metrics" "harness/internal/tools" ) // settle is how long a test waits before concluding nothing more is coming. const settle = 150 * time.Millisecond // fakeAPI answers every child's request. What it answers is derived from the // task the child was given, so a test can tell two agents' traffic apart. type fakeAPI struct { mu sync.Mutex requests int models []string // bodies is every request body, for asking what a child actually sent. bodies [][]byte // gate, when non-nil, holds every request until it is closed: the way a // test keeps an agent running while it inspects the supervisor. gate chan struct{} // status, when non-zero, is returned instead of a response. status int // twoTurns makes the first answer a tool call, so the agent has a second // turn — which is where a pause lands. twoTurns bool // callTool makes the first answer a call to a named tool, for asking what // happens when a child reaches for one it does not have. callTool string srv *httptest.Server } func newFakeAPI(t *testing.T) *fakeAPI { t.Helper() f := &fakeAPI{} f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) var req llm.Request json.Unmarshal(body, &req) f.mu.Lock() f.requests++ f.models = append(f.models, req.Model) f.bodies = append(f.bodies, body) gate, status := f.gate, f.status f.mu.Unlock() if gate != nil { <-gate } if status != 0 { w.WriteHeader(status) io.WriteString(w, `{"type":"error","error":{"type":"overloaded_error","message":"nope"}}`) return } f.mu.Lock() two := f.twoTurns f.mu.Unlock() f.mu.Lock() named := f.callTool f.mu.Unlock() if len(req.Messages) == 1 { switch { case named != "": io.WriteString(w, toolUse("tu_1", named)) return case two: io.WriteString(w, toolUse("tu_1", "noop")) return } } io.WriteString(w, endTurn("handled: "+task(req))) })) t.Cleanup(f.srv.Close) return f } func (f *fakeAPI) hold() { f.mu.Lock(); f.gate = make(chan struct{}); f.mu.Unlock() } func (f *fakeAPI) release() { f.mu.Lock() gate := f.gate f.gate = nil f.mu.Unlock() if gate != nil { close(gate) } } func (f *fakeAPI) fail(status int) { f.mu.Lock(); f.status = status; f.mu.Unlock() } func (f *fakeAPI) count() int { f.mu.Lock(); defer f.mu.Unlock(); return f.requests } // requestsSeen decodes every request the fake was sent. func (f *fakeAPI) requestsSeen(t *testing.T) []llm.Request { t.Helper() f.mu.Lock() defer f.mu.Unlock() out := make([]llm.Request, 0, len(f.bodies)) for _, b := range f.bodies { var req llm.Request if err := json.Unmarshal(b, &req); err != nil { t.Fatalf("request body does not decode: %v", err) } out = append(out, req) } return out } // effortSent is the effort level the one request carried, or "" for none. func (f *fakeAPI) effortSent(t *testing.T) string { t.Helper() reqs := f.requestsSeen(t) if len(reqs) != 1 { t.Fatalf("want exactly one request, got %d", len(reqs)) } if reqs[0].OutputConfig == nil { return "" } return reqs[0].OutputConfig.Effort } func (f *fakeAPI) modelsSeen() []string { f.mu.Lock() defer f.mu.Unlock() return append([]string(nil), f.models...) } // task pulls the first user message out of a request: the brief the child was // spawned on. func task(req llm.Request) string { for _, m := range req.Messages { if m.Role == llm.RoleUser && len(m.Content) > 0 { return m.Content[0].Text } } return "" } func toolUse(id, name string) string { return `{"id":"msg","type":"message","role":"assistant","model":"m","content":[` + `{"type":"tool_use","id":"` + id + `","name":"` + name + `","input":{}}],` + `"stop_reason":"tool_use","usage":{"input_tokens":100,"output_tokens":20}}` } func endTurn(text string) string { b, _ := json.Marshal(text) return `{"id":"msg","type":"message","role":"assistant","model":"m","content":[{"type":"text","text":` + string(b) + `}],"stop_reason":"end_turn","usage":{"input_tokens":100,"output_tokens":20}}` } func newSupervisor(t *testing.T, f *fakeAPI, opts ...func(*Options)) *Supervisor { t.Helper() log := metrics.NewWriter(&bytes.Buffer{}, "test") root := t.TempDir() o := Options{ Client: llm.New(llm.WithBaseURL(f.srv.URL), llm.WithAPIKey("k"), llm.WithModel("claude-opus-5")), Log: log, Tools: func(state *agent.State, log *metrics.Logger) *tools.Registry { return tools.NewRegistry(log).Register( tools.NewRead(root, state), tools.New("noop", "does nothing", func(context.Context, struct{}) (string, error) { return "ok", nil }), ) }, } for _, fn := range opts { fn(&o) } s := New(context.Background(), o) t.Cleanup(s.Shutdown) return s } // waitFor polls until cond holds, so tests do not race the goroutines they are // watching. It fails rather than hanging. func waitFor(t *testing.T, what string, cond func() bool) { t.Helper() deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { if cond() { return } time.Sleep(5 * time.Millisecond) } t.Fatalf("timed out waiting for %s", what) } func statusOf(t *testing.T, s *Supervisor, id string) Status { t.Helper() info, ok := s.Info(id) if !ok { t.Fatalf("no agent %q", id) } return info.Status } func TestSpawnRunsTheAgentAndKeepsItsReport(t *testing.T) { f := newFakeAPI(t) s := newSupervisor(t, f) id, err := s.Spawn(Spec{Preset: "explore", Task: "find the approval policy"}) if err != nil { t.Fatalf("Spawn: %v", err) } waitFor(t, "the agent to finish", func() bool { return statusOf(t, s, id) == Done }) info, _ := s.Info(id) if info.Report != "handled: find the approval policy" { t.Errorf("report = %q, want the agent's final text", info.Report) } if info.Preset != "explore" || info.Task != "find the approval policy" { t.Errorf("info = %+v, want the preset and task it was spawned with", info) } if info.Turns != 1 { t.Errorf("turns = %d, want 1", info.Turns) } if info.Usage.OutputTokens != 20 { t.Errorf("usage = %+v, want the child's token counts", info.Usage) } if info.Ended.IsZero() || info.Ended.Before(info.Started) { t.Errorf("started %v, ended %v", info.Started, info.Ended) } } // Ids are what the sidebar shows and what the model says back to you, so they // are short and in spawn order rather than random. func TestIDsAreShortAndInSpawnOrder(t *testing.T) { f := newFakeAPI(t) s := newSupervisor(t, f) first, _ := s.Spawn(Spec{Preset: "explore", Task: "one"}) second, _ := s.Spawn(Spec{Preset: "plan", Task: "two"}) if first != "a1" || second != "a2" { t.Errorf("ids = %q, %q; want a1, a2", first, second) } got := []string{} for _, info := range s.Snapshot() { got = append(got, info.ID) } if strings.Join(got, " ") != "a1 a2" { t.Errorf("snapshot order = %v, want spawn order", got) } } // A preset the model made up must come back as something it can act on, not as // a silent no-op or a crash. func TestUnknownPresetNamesTheOnesThatExist(t *testing.T) { f := newFakeAPI(t) s := newSupervisor(t, f) _, err := s.Spawn(Spec{Preset: "researcher", Task: "go"}) if err == nil { t.Fatal("spawning an unknown preset succeeded") } for _, p := range Presets() { if !strings.Contains(err.Error(), p.Name) { t.Errorf("error %q does not offer the %q preset", err, p.Name) } } if len(s.Snapshot()) != 0 { t.Error("a rejected spawn still created an agent") } } // No preset pins a model: the agent doing the spawning picks one for the job, // and a spawn that names none runs on the session's own model rather than on // something the harness chose for it. func TestTheSpawningAgentChoosesTheModel(t *testing.T) { f := newFakeAPI(t) s := newSupervisor(t, f) unnamed, _ := s.Spawn(Spec{Preset: "explore", Task: "one"}) chosen, _ := s.Spawn(Spec{Preset: "explore", Task: "two", Model: "claude-haiku-4-5"}) waitFor(t, "both agents to finish", func() bool { return statusOf(t, s, unnamed) == Done && statusOf(t, s, chosen) == Done }) first, _ := s.Info(unnamed) second, _ := s.Info(chosen) if first.Model != "claude-opus-5" { t.Errorf("a spawn naming no model ran on %q, want the session's", first.Model) } if second.Model != "claude-haiku-4-5" { t.Errorf("the chosen model was %q", second.Model) } // Both agents run at once, so which request arrives first is not // something to assert on — only that each asked for its own model. models := f.modelsSeen() sort.Strings(models) want := []string{"claude-haiku-4-5", "claude-opus-5"} if strings.Join(models, " ") != strings.Join(want, " ") { t.Errorf("requests asked for %v, want %v", models, want) } } // An agent whose model call fails is finished, not stuck: the status says so // and the reason is kept for the parent to be told. func TestAFailedAgentIsRecordedAsFailed(t *testing.T) { f := newFakeAPI(t) f.fail(http.StatusBadRequest) s := newSupervisor(t, f) id, _ := s.Spawn(Spec{Preset: "explore", Task: "go"}) waitFor(t, "the agent to fail", func() bool { return statusOf(t, s, id) == Failed }) info, _ := s.Info(id) if info.Err == "" { t.Error("a failed agent kept no reason") } if info.Ended.IsZero() { t.Error("a failed agent never ended") } } // Every event a child produces reaches the front end tagged with the agent it // came from, because they all land in one UI. func TestEventsAreTaggedWithTheAgent(t *testing.T) { f := newFakeAPI(t) var mu sync.Mutex var seen []Event s := newSupervisor(t, f, func(o *Options) { o.Observe = func(ev Event) { mu.Lock() defer mu.Unlock() seen = append(seen, ev) } }) id, _ := s.Spawn(Spec{Preset: "explore", Task: "go"}) waitFor(t, "the agent to finish", func() bool { return statusOf(t, s, id) == Done }) mu.Lock() defer mu.Unlock() if len(seen) == 0 { t.Fatal("no events were observed") } for _, ev := range seen { if ev.ID != id { t.Fatalf("event %+v is not tagged with %q", ev, id) } if ev.Preset != "explore" { t.Fatalf("event %+v does not name the preset", ev) } } } // How hard a subagent thinks is the other half of what it costs, so the agent // spawning it chooses that too. func TestTheChosenEffortReachesTheChild(t *testing.T) { f := newFakeAPI(t) s := newSupervisor(t, f, func(o *Options) { o.Base.Effort = "high" }) id, err := s.Spawn(Spec{Preset: "explore", Task: "go", Model: "claude-opus-5", Effort: "low"}) if err != nil { t.Fatalf("Spawn: %v", err) } waitFor(t, "the agent to finish", func() bool { return statusOf(t, s, id) == Done }) if got := f.effortSent(t); got != "low" { t.Errorf("the child asked for effort %q, want the level it was spawned with", got) } if info, _ := s.Info(id); info.Effort != "low" { t.Errorf("Info.Effort = %q", info.Effort) } } // Choosing nothing inherits the session's level, the way it always did. func TestASpawnWithNoEffortInheritsTheSessions(t *testing.T) { f := newFakeAPI(t) s := newSupervisor(t, f, func(o *Options) { o.Base.Effort = "xhigh" }) id, _ := s.Spawn(Spec{Preset: "explore", Task: "go", Model: "claude-opus-5"}) waitFor(t, "the agent to finish", func() bool { return statusOf(t, s, id) == Done }) if got := f.effortSent(t); got != "xhigh" { t.Errorf("the child asked for effort %q, want the session's", got) } } // A level that is not a level is a typo, and the model needs to be told which // words exist rather than watching an agent run at something else. func TestAnUnknownEffortLevelIsRefused(t *testing.T) { s := newSupervisor(t, newFakeAPI(t)) _, err := s.Spawn(Spec{Preset: "explore", Task: "go", Effort: "lowest"}) if err == nil { t.Fatal("an unknown effort level was accepted") } for _, level := range llm.EffortLevels { if !strings.Contains(err.Error(), level) { t.Errorf("error %q does not offer %q", err, level) } } if len(s.Snapshot()) != 0 { t.Error("a rejected spawn still created an agent") } } // Haiku takes no effort level at all. The spawn is still a good spawn — the // model picked a cheap model for a cheap job — so it runs, and the answer says // plainly that the level was not applied rather than implying it was. func TestEffortOnAModelThatCannotTakeItIsRunAndReported(t *testing.T) { f := newFakeAPI(t) s := newSupervisor(t, f) id, err := s.Spawn(Spec{Preset: "explore", Task: "go", Model: "claude-haiku-4-5", Effort: "low"}) if err != nil { t.Fatalf("Spawn: %v", err) } waitFor(t, "the agent to finish", func() bool { return statusOf(t, s, id) == Done }) if got := f.effortSent(t); got != "" { t.Errorf("the child sent effort %q to a model that rejects it", got) } info, _ := s.Info(id) if info.Effort != "" { t.Errorf("Info.Effort = %q, want empty — the level is not in force", info.Effort) } } // A borrowed Claude Code credential is only accepted alongside its identity // block: a request without it comes back 429, which reads like a quota problem // and is not one. Subagents authenticate with the same credential, so they // carry the same block — and they get their own system prompt, which is // exactly where it could have been dropped. func TestASubagentCarriesTheIdentityBlockOfItsSession(t *testing.T) { f := newFakeAPI(t) s := newSupervisor(t, f, func(o *Options) { o.Base.SystemPrefix = agent.ClaudeCodeIdentity }) id, _ := s.Spawn(Spec{Preset: "explore", Task: "go"}) waitFor(t, "the agent to finish", func() bool { return statusOf(t, s, id) == Done }) reqs := f.requestsSeen(t) if len(reqs) != 1 { t.Fatalf("saw %d requests", len(reqs)) } blocks := reqs[0].System if len(blocks) < 2 { t.Fatalf("the child sent %d system blocks, want the identity block and its own prompt", len(blocks)) } if blocks[0].Text != agent.ClaudeCodeIdentity { t.Errorf("the first system block is %q, want the identity string", blocks[0].Text) } if !strings.Contains(blocks[1].Text, "You are a subagent") { t.Errorf("the child's own brief is missing:\n%s", blocks[1].Text) } } // Children authenticate exactly as the session does, including a credential // that is resolved per request and may need refreshing — one source, shared, // rather than a copy per agent that refreshes on its own. func TestChildrenAuthenticateThroughTheSessionsCredential(t *testing.T) { var mu sync.Mutex var calls int var bearers []string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() bearers = append(bearers, r.Header.Get("authorization")) mu.Unlock() io.WriteString(w, endTurn("ok")) })) t.Cleanup(srv.Close) client := llm.New(llm.WithBaseURL(srv.URL), llm.WithTokenSource(func(context.Context) (string, error) { mu.Lock() defer mu.Unlock() calls++ return "borrowed-token", nil })) log := metrics.NewWriter(&bytes.Buffer{}, "test") root := t.TempDir() s := New(context.Background(), Options{ Client: client, Log: log, Tools: func(state *agent.State, log *metrics.Logger) *tools.Registry { return tools.NewRegistry(log).Register(tools.NewRead(root, state)) }, }) t.Cleanup(s.Shutdown) id, _ := s.Spawn(Spec{Preset: "explore", Task: "go"}) waitFor(t, "the agent to finish", func() bool { return statusOf(t, s, id) == Done }) mu.Lock() defer mu.Unlock() if calls == 0 { t.Fatal("the child never asked the session's token source for a credential") } for _, b := range bearers { if b != "Bearer borrowed-token" { t.Errorf("the child sent authorization %q", b) } } }