1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "strings"
8 "sync"
9 "testing"
10 "time"
11
12 "github.com/bnema/zut/packages/agent/subagents"
13 "github.com/bnema/zut/packages/core"
14 "github.com/bnema/zut/packages/provider"
15)
16
17func TestResidentStatusRetrievesBudgetHandoffWithoutExecution(t *testing.T) {
18 manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
19 return func(context.Context, string) error {
20 if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "partial finding"}}}}); err != nil {
21 return err
22 }
23 return subagents.ErrBudgetExceeded
24 }, nil
25 })
26 t.Cleanup(func() { _ = manager.Close(context.Background()) })
27 spec := subagents.ResidentChildSpec{ID: "budget-status", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test", Required: true}
28 completed, cancel := manager.WatchCompletion(spec.ID, spec.InitialTurnID)
29 defer cancel()
30 if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil {
31 t.Fatal(err)
32 }
33 select {
34 case <-completed:
35 case <-time.After(time.Second):
36 t.Fatal("no completion")
37 }
38 status := &SubagentStatusTool{ResidentManager: manager, Enabled: func() bool { return true }}
39 for _, include := range []bool{false, true} {
40 raw := json.RawMessage(`{"agent_id":"budget-status"}`)
41 if include {
42 raw = json.RawMessage(`{"agent_id":"budget-status","include_result":true}`)
43 }
44 result, err := status.Execute(t.Context(), raw, nil)
45 if err != nil || result.IsError {
46 t.Fatalf("status: %#v, %v", result, err)
47 }
48 response := result.Details.(subagentStatusResponse)
49 if response.Agent.State != subagents.ResidentBudgetExhausted || (response.Result != nil) != include {
50 t.Fatalf("response = %#v", response)
51 }
52 if include && (!strings.Contains(response.Result.Handoff, "partial finding") || response.Result.ErrorCode != "budget_exhausted") {
53 t.Fatalf("result = %#v", response.Result)
54 }
55 }
56 result, err := status.Execute(t.Context(), json.RawMessage(`{"include_result":true}`), nil)
57 if err != nil || !result.IsError {
58 t.Fatalf("missing child ID must fail: %#v, %v", result, err)
59 }
60 if len(manager.UnmetRequired()) != 1 {
61 t.Fatal("reading a handoff satisfied required work")
62 }
63}
64
65func TestResidentToolsUseManagerOnly(t *testing.T) {
66 runs := make(chan string, 2)
67 manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
68 return func(_ context.Context, prompt string) error { runs <- prompt; return nil }, nil
69 })
70 t.Cleanup(func() { _ = manager.Close(context.Background()) })
71 spawn := &SubagentSpawnTool{
72 ResidentManager: manager,
73 Enabled: func() bool { return true },
74 DefaultProvider: func() string { return "openai" },
75 DefaultModel: func() string { return "gpt-5" },
76 BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
77 return subagents.ResidentChildSpec{ID: "resident-tool", SessionID: "child-session", Provider: request.Provider, Model: request.Model, Required: request.Required}, nil
78 },
79 }
80 result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"initial"}`), nil)
81 if err != nil {
82 t.Fatal(err)
83 }
84 if got := toolResultText(t, result); !strings.Contains(got, "owns the delegated scope") || !strings.Contains(got, "Do not repeat that work in the parent") {
85 t.Fatalf("spawn result lacks ownership guidance: %q", got)
86 }
87 if got := <-runs; got != "initial" {
88 t.Fatalf("initial prompt = %q", got)
89 }
90 status := &SubagentStatusTool{ResidentManager: manager, Enabled: func() bool { return true }}
91 result, err = status.Execute(context.Background(), json.RawMessage(`{"agent_id":"resident-tool"}`), nil)
92 if err != nil {
93 t.Fatal(err)
94 }
95 if details, ok := result.Details.(subagentStatusResponse); !ok || details.Agent == nil || details.Agent.State == "" {
96 t.Fatalf("status details = %#v", result.Details)
97 }
98 resume := &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }}
99 if _, err := resume.Execute(context.Background(), json.RawMessage(`{"agent_id":"resident-tool","prompt":"follow up"}`), nil); err != nil {
100 t.Fatal(err)
101 }
102 if got := <-runs; got != "follow up" {
103 t.Fatalf("follow-up prompt = %q", got)
104 }
105}
106
107func TestSubagentSpawnGuidanceRequiresIndependentOwnership(t *testing.T) {
108 spawn := &SubagentSpawnTool{}
109 for _, want := range []string{"independent sidecar", "keep immediate blockers local", "never duplicate it in the parent", "end or yield the parent turn"} {
110 if got := spawn.Description(); !strings.Contains(got, want) {
111 t.Fatalf("description missing %q: %s", want, got)
112 }
113 }
114 var schema struct {
115 Properties map[string]struct {
116 Description string `json:"description"`
117 } `json:"properties"`
118 }
119 if err := json.Unmarshal(spawn.Schema(), &schema); err != nil {
120 t.Fatal(err)
121 }
122 task := schema.Properties["task"].Description
123 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"} {
124 if !strings.Contains(task, want) {
125 t.Fatalf("task schema missing %q: %s", want, task)
126 }
127 }
128}
129
130func TestResidentSpawnWaitReturnsInitialCompletion(t *testing.T) {
131 for _, tc := range []struct {
132 name string
133 err error
134 status string
135 }{
136 {"success", nil, "completed"},
137 {"failure", errors.New("worker failed"), "failed"},
138 {"budget", subagents.ErrBudgetExceeded, "budget_exhausted"},
139 {"cancellation", context.Canceled, "interrupted"},
140 {"cancellation and budget", errors.Join(context.Canceled, subagents.ErrBudgetExceeded), "interrupted"},
141 } {
142 t.Run(tc.name, func(t *testing.T) {
143 manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
144 return func(context.Context, string) error { return tc.err }, nil
145 })
146 t.Cleanup(func() { _ = manager.Close(context.Background()) })
147 spawn := &SubagentSpawnTool{
148 ResidentManager: manager,
149 Enabled: func() bool { return true },
150 DefaultProvider: func() string { return "openai" },
151 DefaultModel: func() string { return "gpt-5" },
152 BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
153 return subagents.ResidentChildSpec{ID: "wait-for-completion", SessionID: "child-session", Provider: request.Provider, Model: request.Model, Required: request.Required}, nil
154 },
155 }
156 result, err := spawn.Execute(t.Context(), json.RawMessage(`{"task":"finish now","wait":1,"required":true}`), nil)
157 if err != nil || result.IsError {
158 t.Fatalf("Execute = (%#v, %v)", result, err)
159 }
160 if got := toolResultText(t, result); !strings.Contains(got, "\nstate: "+tc.status+"\n") || !strings.Contains(got, "\nrequired: "+tc.status+"\n") {
161 t.Fatalf("wait result = %q, want %s state", got, tc.status)
162 }
163 if unmet := len(manager.UnmetRequired()); (unmet != 0) != (tc.err != nil) {
164 t.Fatalf("unmet required = %d, error = %v", unmet, tc.err)
165 }
166 })
167 }
168}
169
170func TestResidentSpawnWaitExpiryReportsQueuedChild(t *testing.T) {
171 started := make(chan struct{}, 1)
172 release := make(chan struct{})
173 var releaseOnce sync.Once
174 releaseBlocker := func() { releaseOnce.Do(func() { close(release) }) }
175 finished := make(chan string, 1)
176 manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 1, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
177 return func(ctx context.Context, prompt string) error {
178 switch prompt {
179 case "blocker":
180 started <- struct{}{}
181 select {
182 case <-release:
183 return nil
184 case <-ctx.Done():
185 return ctx.Err()
186 }
187 case "waiter":
188 finished <- prompt
189 }
190 return nil
191 }, nil
192 })
193 t.Cleanup(func() {
194 releaseBlocker()
195 _ = manager.Close(context.Background())
196 })
197 spawn := &SubagentSpawnTool{
198 ResidentManager: manager,
199 Enabled: func() bool { return true },
200 DefaultProvider: func() string { return "openai" },
201 DefaultModel: func() string { return "gpt-5" },
202 BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
203 return subagents.ResidentChildSpec{ID: request.Task, SessionID: request.Task, Provider: request.Provider, Model: request.Model}, nil
204 },
205 }
206 if _, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"blocker"}`), nil); err != nil {
207 t.Fatalf("spawn blocker: %v", err)
208 }
209 select {
210 case <-started:
211 case <-time.After(5 * time.Second):
212 t.Fatal("blocker did not start")
213 }
214 result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"waiter","wait":1}`), nil)
215 if err != nil || result.IsError {
216 t.Fatalf("spawn waiter = (%#v, %v)", result, err)
217 }
218 if got := toolResultText(t, result); !strings.Contains(got, "state: queued") || !strings.Contains(got, "wait: timed out after 1 seconds") {
219 t.Fatalf("wait expiry result = %q, want queued timeout", got)
220 }
221 if state, ok := manager.State("waiter"); !ok || state != subagents.ResidentQueued {
222 t.Fatalf("waiter state = %q (found=%t), want queued", state, ok)
223 }
224 releaseBlocker()
225 select {
226 case got := <-finished:
227 if got != "waiter" {
228 t.Fatalf("finished task = %q, want waiter", got)
229 }
230 case <-time.After(5 * time.Second):
231 t.Fatal("queued waiter did not execute")
232 }
233}
234
235func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) {
236 manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
237 return func(context.Context, string) error { return nil }, nil
238 })
239 t.Cleanup(func() { _ = manager.Close(context.Background()) })
240 built := 0
241 spawn := &SubagentSpawnTool{
242 ResidentManager: manager,
243 Enabled: func() bool { return true },
244 BuildResidentSpec: func(context.Context, ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
245 built++
246 return subagents.ResidentChildSpec{ID: "should-not-exist", SessionID: "child"}, nil
247 },
248 }
249 for _, raw := range []string{
250 `{"task":"x","unexpected":true}`,
251 `{"task":"x"}{}`,
252 `{"task":"x","model":"gpt-5"}`,
253 `{"task":"x","wait":0}`,
254 `{"task":"x","wait":301}`,
255 `{"task":"x","isolation":"outside"}`,
256 `{"task":"x","budget_ratio":0.5}`,
257 `{"task":"x","budget_tokens":1000}`,
258 } {
259 result, err := spawn.Execute(context.Background(), json.RawMessage(raw), nil)
260 if err != nil && !strings.Contains(err.Error(), "invalid args") {
261 t.Fatalf("Execute(%s) error = %v", raw, err)
262 }
263 if err == nil && !result.IsError {
264 t.Fatalf("Execute(%s) = %#v, want protocol error", raw, result)
265 }
266 }
267 if built != 0 || len(manager.Snapshot()) != 0 {
268 t.Fatalf("invalid input reached resident manager: built=%d snapshots=%#v", built, manager.Snapshot())
269 }
270}
271
272func TestResidentSpawnProfileAndExplicitOverridesReachFactory(t *testing.T) {
273 manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
274 return func(context.Context, string) error { return nil }, nil
275 })
276 t.Cleanup(func() { _ = manager.Close(context.Background()) })
277 profileFast := false
278 var got ResidentSpawnRequest
279 spawn := &SubagentSpawnTool{
280 ResidentManager: manager,
281 Enabled: func() bool { return true },
282 DefaultModel: func() string { return "host-model" },
283 DefaultProvider: func() string { return "host-provider" },
284 DefaultReasoning: func() string { return "medium" },
285 ResolveSubagent: func(name string) (*subagents.Profile, error) {
286 return &subagents.Profile{Name: name, Model: "openai/gpt-5.6-sol", Thinking: "low", FastMode: &profileFast}, nil
287 },
288 BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
289 got = request
290 return subagents.ResidentChildSpec{ID: "profiled", SessionID: "child", Provider: request.Provider, Model: request.Model, Profile: request.Profile.Name}, nil
291 },
292 }
293 result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","agent":"reviewer","reasoning":"high","fast_mode":true}`), nil)
294 if err != nil || result.IsError {
295 t.Fatalf("Execute = (%#v, %v)", result, err)
296 }
297 if got.Model != "gpt-5.6-sol" || got.Provider != "openai" || got.Reasoning != "high" || got.FastMode == nil || !*got.FastMode {
298 t.Fatalf("factory request = %#v", got)
299 }
300}
301
302func TestResidentToolsRejectUnknownFields(t *testing.T) {
303 for _, tool := range []interface {
304 Execute(context.Context, json.RawMessage, func(string)) (core.ToolResult, error)
305 }{
306 &SubagentStatusTool{ResidentManager: subagents.NewResidentManager(t.TempDir(), nil), Enabled: func() bool { return true }},
307 &SubagentStopTool{ResidentManager: subagents.NewResidentManager(t.TempDir(), nil), Enabled: func() bool { return true }},
308 &SubagentResumeTool{ResidentManager: subagents.NewResidentManager(t.TempDir(), nil), Enabled: func() bool { return true }},
309 } {
310 _, err := tool.Execute(context.Background(), json.RawMessage(`{"unknown":true}`), nil)
311 if err == nil || !strings.Contains(err.Error(), "unknown field") {
312 t.Fatalf("unknown field error = %v", err)
313 }
314 }
315}
316
317func toolResultText(t *testing.T, result core.ToolResult) string {
318 t.Helper()
319 if len(result.Content) != 1 {
320 t.Fatalf("content = %#v, want one text block", result.Content)
321 }
322 block, ok := result.Content[0].(provider.TextBlock)
323 if !ok {
324 t.Fatalf("content = %#v, want text block", result.Content)
325 }
326 return block.Text
327}
328
329func TestPublicResidentStatusIncludesRolloutBudget(t *testing.T) {
330 entry := publicResidentStatus(subagents.ResidentSnapshot{
331 ID: "budgeted", Budget: subagents.BudgetSnapshot{Used: 70, Limit: 100, Percent: 70, State: subagents.BudgetNormal}, BudgetSource: "model_context",
332 })
333 if entry.Budget == nil || entry.Budget.State != subagents.BudgetNormal || entry.BudgetSource != "model_context" {
334 t.Fatalf("status budget = %#v", entry)
335 }
336}
337
338func TestFindResidentStatusSnapshotRejectsAmbiguousPrefix(t *testing.T) {
339 _, ok := findResidentStatusSnapshot([]subagents.ResidentSnapshot{{ID: "resident-abcd"}, {ID: "resident-abef"}}, "resident-ab")
340 if ok {
341 t.Fatal("ambiguous resident prefix resolved")
342 }
343}