1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "strings"
7 "sync"
8 "testing"
9 "time"
10
11 "github.com/bnema/zut/packages/agent/subagents"
12 "github.com/bnema/zut/packages/core"
13 "github.com/bnema/zut/packages/provider"
14)
15
16func TestResidentToolsUseManagerOnly(t *testing.T) {
17 runs := make(chan string, 2)
18 manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
19 return func(_ context.Context, prompt string) error { runs <- prompt; return nil }, nil
20 })
21 t.Cleanup(func() { _ = manager.Close(context.Background()) })
22 spawn := &SubagentSpawnTool{
23 ResidentManager: manager,
24 Enabled: func() bool { return true },
25 DefaultProvider: func() string { return "openai" },
26 DefaultModel: func() string { return "gpt-5" },
27 BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
28 return subagents.ResidentChildSpec{ID: "resident-tool", SessionID: "child-session", Provider: request.Provider, Model: request.Model, Required: request.Required}, nil
29 },
30 }
31 result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"initial"}`), nil)
32 if err != nil {
33 t.Fatal(err)
34 }
35 if got := toolResultText(t, result); !strings.Contains(got, "owns the delegated scope") || !strings.Contains(got, "Do not repeat that work in the parent") {
36 t.Fatalf("spawn result lacks ownership guidance: %q", got)
37 }
38 if got := <-runs; got != "initial" {
39 t.Fatalf("initial prompt = %q", got)
40 }
41 status := &SubagentStatusTool{ResidentManager: manager, Enabled: func() bool { return true }}
42 result, err = status.Execute(context.Background(), json.RawMessage(`{"agent_id":"resident-tool"}`), nil)
43 if err != nil {
44 t.Fatal(err)
45 }
46 if details, ok := result.Details.(subagentStatusResponse); !ok || details.Agent == nil || details.Agent.State == "" {
47 t.Fatalf("status details = %#v", result.Details)
48 }
49 resume := &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }}
50 if _, err := resume.Execute(context.Background(), json.RawMessage(`{"agent_id":"resident-tool","prompt":"follow up"}`), nil); err != nil {
51 t.Fatal(err)
52 }
53 if got := <-runs; got != "follow up" {
54 t.Fatalf("follow-up prompt = %q", got)
55 }
56}
57
58func TestSubagentSpawnGuidanceRequiresIndependentOwnership(t *testing.T) {
59 spawn := &SubagentSpawnTool{}
60 for _, want := range []string{"independent sidecar", "keep immediate blockers local", "never duplicate it in the parent", "end or yield the parent turn"} {
61 if got := spawn.Description(); !strings.Contains(got, want) {
62 t.Fatalf("description missing %q: %s", want, got)
63 }
64 }
65 var schema struct {
66 Properties map[string]struct {
67 Description string `json:"description"`
68 } `json:"properties"`
69 }
70 if err := json.Unmarshal(spawn.Schema(), &schema); err != nil {
71 t.Fatal(err)
72 }
73 task := schema.Properties["task"].Description
74 for _, want := range []string{"concrete, bounded scope", "explicit ownership", "does not overlap", "Shared isolation uses this working directory", "worktree isolation captures a patch without merging it"} {
75 if !strings.Contains(task, want) {
76 t.Fatalf("task schema missing %q: %s", want, task)
77 }
78 }
79}
80
81func TestResidentSpawnWaitReturnsInitialCompletion(t *testing.T) {
82 manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
83 return func(context.Context, string) error { return nil }, nil
84 })
85 t.Cleanup(func() { _ = manager.Close(context.Background()) })
86 spawn := &SubagentSpawnTool{
87 ResidentManager: manager,
88 Enabled: func() bool { return true },
89 DefaultProvider: func() string { return "openai" },
90 DefaultModel: func() string { return "gpt-5" },
91 BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
92 return subagents.ResidentChildSpec{ID: "wait-for-completion", SessionID: "child-session", Provider: request.Provider, Model: request.Model}, nil
93 },
94 }
95 result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"finish now","wait":1}`), nil)
96 if err != nil || result.IsError {
97 t.Fatalf("Execute = (%#v, %v)", result, err)
98 }
99 if got := toolResultText(t, result); !strings.Contains(got, "state: completed") {
100 t.Fatalf("wait result = %q, want completed state", got)
101 }
102}
103
104func TestResidentSpawnWaitExpiryReportsQueuedChild(t *testing.T) {
105 started := make(chan struct{}, 1)
106 release := make(chan struct{})
107 var releaseOnce sync.Once
108 releaseBlocker := func() { releaseOnce.Do(func() { close(release) }) }
109 finished := make(chan string, 1)
110 manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 1, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
111 return func(ctx context.Context, prompt string) error {
112 switch prompt {
113 case "blocker":
114 started <- struct{}{}
115 select {
116 case <-release:
117 return nil
118 case <-ctx.Done():
119 return ctx.Err()
120 }
121 case "waiter":
122 finished <- prompt
123 }
124 return nil
125 }, nil
126 })
127 t.Cleanup(func() {
128 releaseBlocker()
129 _ = manager.Close(context.Background())
130 })
131 spawn := &SubagentSpawnTool{
132 ResidentManager: manager,
133 Enabled: func() bool { return true },
134 DefaultProvider: func() string { return "openai" },
135 DefaultModel: func() string { return "gpt-5" },
136 BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
137 return subagents.ResidentChildSpec{ID: request.Task, SessionID: request.Task, Provider: request.Provider, Model: request.Model}, nil
138 },
139 }
140 if _, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"blocker"}`), nil); err != nil {
141 t.Fatalf("spawn blocker: %v", err)
142 }
143 select {
144 case <-started:
145 case <-time.After(5 * time.Second):
146 t.Fatal("blocker did not start")
147 }
148 result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"waiter","wait":1}`), nil)
149 if err != nil || result.IsError {
150 t.Fatalf("spawn waiter = (%#v, %v)", result, err)
151 }
152 if got := toolResultText(t, result); !strings.Contains(got, "state: queued") || !strings.Contains(got, "wait: timed out after 1 seconds") {
153 t.Fatalf("wait expiry result = %q, want queued timeout", got)
154 }
155 if state, ok := manager.State("waiter"); !ok || state != subagents.ResidentQueued {
156 t.Fatalf("waiter state = %q (found=%t), want queued", state, ok)
157 }
158 releaseBlocker()
159 select {
160 case got := <-finished:
161 if got != "waiter" {
162 t.Fatalf("finished task = %q, want waiter", got)
163 }
164 case <-time.After(5 * time.Second):
165 t.Fatal("queued waiter did not execute")
166 }
167}
168
169func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) {
170 manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
171 return func(context.Context, string) error { return nil }, nil
172 })
173 t.Cleanup(func() { _ = manager.Close(context.Background()) })
174 built := 0
175 spawn := &SubagentSpawnTool{
176 ResidentManager: manager,
177 Enabled: func() bool { return true },
178 BuildResidentSpec: func(context.Context, ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
179 built++
180 return subagents.ResidentChildSpec{ID: "should-not-exist", SessionID: "child"}, nil
181 },
182 }
183 for _, raw := range []string{
184 `{"task":"x","unexpected":true}`,
185 `{"task":"x"}{}`,
186 `{"task":"x","model":"gpt-5"}`,
187 `{"task":"x","wait":0}`,
188 `{"task":"x","wait":301}`,
189 `{"task":"x","isolation":"outside"}`,
190 `{"task":"x","budget_ratio":0}`,
191 `{"task":"x","budget_tokens":-1}`,
192 `{"task":"x","budget_ratio":0.5,"budget_tokens":1000}`,
193 } {
194 result, err := spawn.Execute(context.Background(), json.RawMessage(raw), nil)
195 if err != nil && !strings.Contains(err.Error(), "invalid args") {
196 t.Fatalf("Execute(%s) error = %v", raw, err)
197 }
198 if err == nil && !result.IsError {
199 t.Fatalf("Execute(%s) = %#v, want protocol error", raw, result)
200 }
201 }
202 if built != 0 || len(manager.Snapshot()) != 0 {
203 t.Fatalf("invalid input reached resident manager: built=%d snapshots=%#v", built, manager.Snapshot())
204 }
205}
206
207func TestResidentSpawnProfileAndExplicitOverridesReachFactory(t *testing.T) {
208 manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
209 return func(context.Context, string) error { return nil }, nil
210 })
211 t.Cleanup(func() { _ = manager.Close(context.Background()) })
212 profileFast := false
213 profileBudget := 0.6
214 var got ResidentSpawnRequest
215 spawn := &SubagentSpawnTool{
216 ResidentManager: manager,
217 Enabled: func() bool { return true },
218 DefaultModel: func() string { return "host-model" },
219 DefaultProvider: func() string { return "host-provider" },
220 DefaultReasoning: func() string { return "medium" },
221 ResolveSubagent: func(name string) (*subagents.Profile, error) {
222 return &subagents.Profile{Name: name, Model: "openai/gpt-5.6-sol", Thinking: "low", FastMode: &profileFast, BudgetRatio: &profileBudget}, nil
223 },
224 BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
225 got = request
226 return subagents.ResidentChildSpec{ID: "profiled", SessionID: "child", Provider: request.Provider, Model: request.Model, Profile: request.Profile.Name}, nil
227 },
228 }
229 result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","agent":"reviewer","reasoning":"high","fast_mode":true,"budget_tokens":42000}`), nil)
230 if err != nil || result.IsError {
231 t.Fatalf("Execute = (%#v, %v)", result, err)
232 }
233 if got.Model != "gpt-5.6-sol" || got.Provider != "openai" || got.Reasoning != "high" || got.FastMode == nil || !*got.FastMode || got.BudgetTokens == nil || *got.BudgetTokens != 42_000 {
234 t.Fatalf("factory request = %#v", got)
235 }
236}
237
238func TestResidentToolsRejectUnknownFields(t *testing.T) {
239 for _, tool := range []interface {
240 Execute(context.Context, json.RawMessage, func(string)) (core.ToolResult, error)
241 }{
242 &SubagentStatusTool{ResidentManager: subagents.NewResidentManager(t.TempDir(), nil), Enabled: func() bool { return true }},
243 &SubagentStopTool{ResidentManager: subagents.NewResidentManager(t.TempDir(), nil), Enabled: func() bool { return true }},
244 &SubagentResumeTool{ResidentManager: subagents.NewResidentManager(t.TempDir(), nil), Enabled: func() bool { return true }},
245 } {
246 _, err := tool.Execute(context.Background(), json.RawMessage(`{"unknown":true}`), nil)
247 if err == nil || !strings.Contains(err.Error(), "unknown field") {
248 t.Fatalf("unknown field error = %v", err)
249 }
250 }
251}
252
253func toolResultText(t *testing.T, result core.ToolResult) string {
254 t.Helper()
255 if len(result.Content) != 1 {
256 t.Fatalf("content = %#v, want one text block", result.Content)
257 }
258 block, ok := result.Content[0].(provider.TextBlock)
259 if !ok {
260 t.Fatalf("content = %#v, want text block", result.Content)
261 }
262 return block.Text
263}
264
265func TestPublicResidentStatusIncludesRolloutBudget(t *testing.T) {
266 entry := publicResidentStatus(subagents.ResidentSnapshot{
267 ID: "budgeted", Budget: subagents.BudgetSnapshot{Used: 70, Limit: 100, Percent: 70, State: subagents.BudgetFocused}, BudgetSource: "default_ratio",
268 })
269 if entry.Budget == nil || entry.Budget.State != subagents.BudgetFocused || entry.BudgetSource != "default_ratio" {
270 t.Fatalf("status budget = %#v", entry)
271 }
272}
273
274func TestFindResidentStatusSnapshotRejectsAmbiguousPrefix(t *testing.T) {
275 _, ok := findResidentStatusSnapshot([]subagents.ResidentSnapshot{{ID: "resident-abcd"}, {ID: "resident-abef"}}, "resident-ab")
276 if ok {
277 t.Fatal("ambiguous resident prefix resolved")
278 }
279}