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 TestResidentStatusRetrievesFailedResultWithoutExecution(t *testing.T) {
18 failure := errors.New("worker failed")
19 manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
20 return func(context.Context, string) error {
21 if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "partial finding"}}}}); err != nil {
22 return err
23 }
24 return failure
25 }, nil
26 })
27 t.Cleanup(func() { _ = manager.Close(context.Background()) })
28 spec := subagents.ResidentChildSpec{ID: "failed-status", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test", Required: true}
29 completed, cancel := manager.WatchCompletion(spec.ID, spec.InitialTurnID)
30 defer cancel()
31 if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil {
32 t.Fatal(err)
33 }
34 select {
35 case <-completed:
36 case <-time.After(time.Second):
37 t.Fatal("no completion")
38 }
39 status := &SubagentStatusTool{ResidentManager: manager, Enabled: func() bool { return true }}
40 for _, include := range []bool{false, true} {
41 raw := json.RawMessage(`{"agent_id":"failed-status"}`)
42 if include {
43 raw = json.RawMessage(`{"agent_id":"failed-status","include_result":true}`)
44 }
45 result, err := status.Execute(t.Context(), raw, nil)
46 if err != nil || result.IsError {
47 t.Fatalf("status: %#v, %v", result, err)
48 }
49 response := result.Details.(subagentStatusResponse)
50 if response.Agent.State != subagents.ResidentFailed || (response.Result != nil) != include {
51 t.Fatalf("response = %#v", response)
52 }
53 if include && (response.Result.Handoff != "" || response.Result.ErrorCode != "turn_failed" || response.Result.Summary != "partial finding") {
54 t.Fatalf("result = %#v", response.Result)
55 }
56 }
57 result, err := status.Execute(t.Context(), json.RawMessage(`{"include_result":true}`), nil)
58 if err != nil || !result.IsError {
59 t.Fatalf("missing child ID must fail: %#v, %v", result, err)
60 }
61 if len(manager.UnmetRequired()) != 1 {
62 t.Fatal("reading a result satisfied required work")
63 }
64}
65
66func TestResidentToolsUseManagerOnly(t *testing.T) {
67 runs := make(chan string, 2)
68 manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
69 return func(_ context.Context, prompt string) error { runs <- prompt; return nil }, nil
70 })
71 t.Cleanup(func() { _ = manager.Close(context.Background()) })
72 spawn := &SubagentSpawnTool{
73 ResidentManager: manager,
74 Enabled: func() bool { return true },
75 DefaultProvider: func() string { return "openai" },
76 DefaultModel: func() string { return "gpt-5" },
77 BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
78 return subagents.ResidentChildSpec{ID: "resident-tool", SessionID: "child-session", Provider: request.Provider, Model: request.Model, Required: request.Required}, nil
79 },
80 }
81 result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"initial"}`), nil)
82 if err != nil {
83 t.Fatal(err)
84 }
85 if got := toolResultText(t, result); !strings.Contains(got, "owns the delegated scope") || !strings.Contains(got, "Do not repeat that work in the parent") {
86 t.Fatalf("spawn result lacks ownership guidance: %q", got)
87 }
88 if got := <-runs; got != "initial" {
89 t.Fatalf("initial prompt = %q", got)
90 }
91 status := &SubagentStatusTool{ResidentManager: manager, Enabled: func() bool { return true }}
92 result, err = status.Execute(context.Background(), json.RawMessage(`{"agent_id":"resident-tool"}`), nil)
93 if err != nil {
94 t.Fatal(err)
95 }
96 if details, ok := result.Details.(subagentStatusResponse); !ok || details.Agent == nil || details.Agent.State == "" {
97 t.Fatalf("status details = %#v", result.Details)
98 }
99 resume := &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }}
100 if _, err := resume.Execute(context.Background(), json.RawMessage(`{"agent_id":"resident-tool","prompt":"follow up"}`), nil); err != nil {
101 t.Fatal(err)
102 }
103 if got := <-runs; got != "follow up" {
104 t.Fatalf("follow-up prompt = %q", got)
105 }
106}
107
108func TestSubagentSpawnGuidanceRequiresIndependentOwnership(t *testing.T) {
109 spawn := &SubagentSpawnTool{}
110 for _, want := range []string{"independent sidecar", "keep immediate blockers local", "never duplicate it in the parent", "end or yield the parent turn"} {
111 if got := spawn.Description(); !strings.Contains(got, want) {
112 t.Fatalf("description missing %q: %s", want, got)
113 }
114 }
115 var schema struct {
116 Properties map[string]struct {
117 Description string `json:"description"`
118 } `json:"properties"`
119 }
120 if err := json.Unmarshal(spawn.Schema(), &schema); err != nil {
121 t.Fatal(err)
122 }
123 task := schema.Properties["task"].Description
124 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"} {
125 if !strings.Contains(task, want) {
126 t.Fatalf("task schema missing %q: %s", want, task)
127 }
128 }
129}
130
131func TestResidentSpawnWaitReturnsInitialCompletion(t *testing.T) {
132 for _, tc := range []struct {
133 name string
134 err error
135 status string
136 }{
137 {"success", nil, "completed"},
138 {"failure", errors.New("worker failed"), "failed"},
139 {"cancellation", context.Canceled, "interrupted"},
140 {"cancellation with failure", errors.Join(context.Canceled, errors.New("worker failed")), "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 TestPublicResidentStatusOmitsBudget(t *testing.T) {
330 entry := publicResidentStatus(subagents.ResidentSnapshot{
331 ID: "resident", State: subagents.ResidentIdle, Provider: "openai", Model: "test",
332 })
333 encoded, err := json.Marshal(entry)
334 if err != nil {
335 t.Fatal(err)
336 }
337 if strings.Contains(string(encoded), "budget") {
338 t.Fatalf("status entry carries budget = %s", encoded)
339 }
340}
341
342func TestFindResidentStatusSnapshotRejectsAmbiguousPrefix(t *testing.T) {
343 _, ok := findResidentStatusSnapshot([]subagents.ResidentSnapshot{{ID: "resident-abcd"}, {ID: "resident-abef"}}, "resident-ab")
344 if ok {
345 t.Fatal("ambiguous resident prefix resolved")
346 }
347}