package subagent
import (
"context"
"encoding/json"
"strings"
"sync"
"testing"
"time"
"harness/internal/agent"
"harness/internal/tools"
)
func toolNamed(t *testing.T, s *Supervisor, name string) tools.Tool {
t.Helper()
for _, tool := range s.Tools() {
if tool.Name() == name {
return tool
}
}
t.Fatalf("no %s tool", name)
return nil
}
func TestTheParentGetsSpawnAndWaitAndNothingElse(t *testing.T) {
s := newSupervisor(t, newFakeAPI(t))
var names []string
for _, tool := range s.Tools() {
names = append(names, tool.Name())
}
if strings.Join(names, " ") != "spawn_agent agent_wait" {
t.Errorf("tools = %v", names)
}
}
// The description is what the model chooses a preset from, so every preset has
// to be in it — and so does the rule that subagents cannot spawn subagents,
// which it would otherwise discover by trying.
func TestTheSpawnDescriptionExplainsThePresetsAndTheRules(t *testing.T) {
s := newSupervisor(t, newFakeAPI(t))
desc := toolNamed(t, s, "spawn_agent").Description()
for _, p := range Presets() {
if !strings.Contains(desc, p.Name) {
t.Errorf("description never mentions the %q preset", p.Name)
}
}
for _, phrase := range []string{"background", "approve", "cannot spawn"} {
if !strings.Contains(strings.ToLower(desc), phrase) {
t.Errorf("description never says %q:\n%s", phrase, desc)
}
}
}
func TestSpawnToolReturnsTheIDToReferToItBy(t *testing.T) {
f := newFakeAPI(t)
f.hold()
s := newSupervisor(t, f)
out, err := toolNamed(t, s, "spawn_agent").Execute(context.Background(),
json.RawMessage(`{"preset":"explore","task":"find the policy"}`))
if err != nil {
t.Fatalf("spawn_agent: %v", err)
}
if !strings.Contains(out, "a1") {
t.Errorf("spawn_agent returned %q, which does not name the agent", out)
}
f.release()
}
func TestSpawnToolReportsAnUnknownPresetToTheModel(t *testing.T) {
s := newSupervisor(t, newFakeAPI(t))
_, err := toolNamed(t, s, "spawn_agent").Execute(context.Background(),
json.RawMessage(`{"preset":"researcher","task":"go"}`))
if err == nil {
t.Fatal("an unknown preset was accepted")
}
if !strings.Contains(err.Error(), "explore") {
t.Errorf("error %q does not offer a preset that exists", err)
}
}
// The spawning tool call belongs to one turn; the agent it starts does not.
// Interrupting that turn — or simply the turn ending — must not take the
// subagent with it.
func TestASpawnedAgentOutlivesTheTurnThatSpawnedIt(t *testing.T) {
f := newFakeAPI(t)
f.hold()
s := newSupervisor(t, f)
ctx, cancel := context.WithCancel(context.Background())
if _, err := toolNamed(t, s, "spawn_agent").Execute(ctx,
json.RawMessage(`{"preset":"general","task":"keep going"}`)); err != nil {
t.Fatalf("spawn_agent: %v", err)
}
cancel()
time.Sleep(settle)
if got := statusOf(t, s, "a1"); !got.Live() {
t.Fatalf("the agent is %s after the spawning turn ended", got)
}
f.release()
waitFor(t, "the agent to finish anyway", func() bool { return statusOf(t, s, "a1") == Done })
}
func TestAgentWaitHandsBackTheReport(t *testing.T) {
s := newSupervisor(t, newFakeAPI(t))
id, _ := s.Spawn(Spec{Preset: "explore", Task: "find it"})
out, err := toolNamed(t, s, "agent_wait").Execute(context.Background(),
json.RawMessage(`{"id":"`+id+`"}`))
if err != nil {
t.Fatalf("agent_wait: %v", err)
}
if !strings.Contains(out, "handled: find it") {
t.Errorf("agent_wait returned %q, want the agent's report", out)
}
}
func TestAgentWaitOnAnUnknownIDIsAnError(t *testing.T) {
s := newSupervisor(t, newFakeAPI(t))
_, err := toolNamed(t, s, "agent_wait").Execute(context.Background(), json.RawMessage(`{"id":"a9"}`))
if err == nil {
t.Fatal("waiting on an unknown agent succeeded")
}
}
// Nesting is the way a spawn loop becomes a fork bomb with a credit card. A
// subagent's tool set simply does not contain the spawn tools, and calling one
// comes back as an ordinary unknown-tool error it can read.
func TestASubagentCannotSpawnSubagents(t *testing.T) {
f := newFakeAPI(t)
f.callTool = "spawn_agent"
var mu sync.Mutex
var errors []string
s := newSupervisor(t, f, func(o *Options) {
o.Observe = func(ev Event) {
if ev.Agent.Kind == agent.EventToolResult && ev.Agent.IsError {
mu.Lock()
errors = append(errors, ev.Agent.Result)
mu.Unlock()
}
}
})
id, _ := s.Spawn(Spec{Preset: "general", Task: "go"})
waitFor(t, "the agent to finish", func() bool { return statusOf(t, s, id) == Done })
mu.Lock()
defer mu.Unlock()
if len(errors) != 1 || !strings.Contains(errors[0], "unknown tool") {
t.Fatalf("a subagent's spawn attempt produced %v", errors)
}
if n := len(s.Snapshot()); n != 1 {
t.Errorf("%d agents exist; the subagent managed to spawn one", n)
}
}
// The model is the main thing that decides what a subagent costs, and nothing
// else picks it any more — so the description has to say how to choose. It
// names no ids of its own: what this endpoint serves is in the system prompt,
// and a list in two places is a list that goes out of step.
func TestTheSpawnDescriptionExplainsHowToChooseAModel(t *testing.T) {
s := newSupervisor(t, newFakeAPI(t))
desc := toolNamed(t, s, "spawn_agent").Description()
for _, phrase := range []string{"you choose the model", "newest model", "system prompt"} {
if !strings.Contains(strings.ToLower(desc), phrase) {
t.Errorf("the description never says %q:\n%s", phrase, desc)
}
}
if strings.Contains(desc, "claude-") {
t.Errorf("the description hardcodes a model id, which will drift from what the endpoint serves:\n%s", desc)
}
// And it must not promise a model per preset, which is what it used to do.
for _, p := range Presets() {
line := p.Name + " — " + p.Description
if !strings.Contains(desc, line) {
t.Errorf("preset line missing or changed shape: %q", line)
}
}
}
// Waiting on an agent whose report the front end has already put into the
// conversation returns a pointer to it, not the text again: the parent is
// paying for context, and it already has this.
func TestWaitingOnAnAlreadyDeliveredReportDoesNotRepeatIt(t *testing.T) {
f := newFakeAPI(t)
s := newSupervisor(t, f)
id, _ := s.Spawn(Spec{Preset: "explore", Task: "find it"})
waitFor(t, "the agent to finish", func() bool { return statusOf(t, s, id) == Done })
// The front end delivered it.
if !s.ClaimReport(id) {
t.Fatal("could not claim a fresh report")
}
out, err := toolNamed(t, s, "agent_wait").Execute(context.Background(), json.RawMessage(`{"id":"`+id+`"}`))
if err != nil {
t.Fatalf("agent_wait: %v", err)
}
if strings.Contains(out, "handled: find it") {
t.Errorf("agent_wait repeated a report the conversation already has:\n%s", out)
}
for _, want := range []string{id, "already"} {
if !strings.Contains(out, want) {
t.Errorf("agent_wait's answer does not say %q:\n%s", want, out)
}
}
}
// And when nobody has delivered it, agent_wait is the delivery.
func TestWaitingFirstHandsOverTheWholeReport(t *testing.T) {
f := newFakeAPI(t)
s := newSupervisor(t, f)
id, _ := s.Spawn(Spec{Preset: "explore", Task: "find it"})
out, err := toolNamed(t, s, "agent_wait").Execute(context.Background(), json.RawMessage(`{"id":"`+id+`"}`))
if err != nil {
t.Fatalf("agent_wait: %v", err)
}
if !strings.Contains(out, "handled: find it") {
t.Errorf("agent_wait did not hand over the report:\n%s", out)
}
if s.ClaimReport(id) {
t.Error("the front end would send it again after agent_wait delivered it")
}
}
// The effort argument is the answer to "spawn these on low", which the tool
// could not express before.
func TestSpawnToolTakesAnEffortLevel(t *testing.T) {
f := newFakeAPI(t)
f.hold()
s := newSupervisor(t, f)
out, err := toolNamed(t, s, "spawn_agent").Execute(context.Background(),
json.RawMessage(`{"preset":"explore","task":"find it","model":"claude-opus-5","effort":"low"}`))
if err != nil {
t.Fatalf("spawn_agent: %v", err)
}
if !strings.Contains(out, "low effort") {
t.Errorf("the answer does not say what it is running at:\n%s", out)
}
if info, _ := s.Info("a1"); info.Effort != "low" {
t.Errorf("Info.Effort = %q", info.Effort)
}
f.release()
}
// Asking for an effort level on a model that has none must not read as though
// it worked.
func TestSpawnToolSaysWhenTheEffortCouldNotBeApplied(t *testing.T) {
f := newFakeAPI(t)
f.hold()
s := newSupervisor(t, f)
out, err := toolNamed(t, s, "spawn_agent").Execute(context.Background(),
json.RawMessage(`{"preset":"explore","task":"find it","model":"claude-haiku-4-5","effort":"low"}`))
if err != nil {
t.Fatalf("spawn_agent: %v", err)
}
for _, want := range []string{"takes no effort level", "not applied"} {
if !strings.Contains(out, want) {
t.Errorf("the answer does not say the level was dropped (%q):\n%s", want, out)
}
}
f.release()
}
// And the description has to say the knob exists, or nothing will reach for it.
func TestTheSpawnDescriptionMentionsEffort(t *testing.T) {
s := newSupervisor(t, newFakeAPI(t))
desc := strings.ToLower(toolNamed(t, s, "spawn_agent").Description())
for _, want := range []string{"effort", "low"} {
if !strings.Contains(desc, want) {
t.Errorf("the description never mentions %q", want)
}
}
}