1package agent
2
3import (
4 "context"
5 "encoding/json"
6 "slices"
7 "strings"
8 "testing"
9
10 "github.com/bnema/zut/packages/agent/subagents"
11 "github.com/bnema/zut/packages/agent/tools"
12 "github.com/bnema/zut/packages/core"
13)
14
15func TestExpandWebCapabilityTools(t *testing.T) {
16 catalogue := append([]string{"read"}, tools.WebCapabilityNames...)
17 got := expandWebCapabilityTools([]string{"read", "web_search"}, catalogue, func(string) bool { return true })
18 for _, name := range append([]string{"read"}, tools.WebCapabilityNames...) {
19 found := false
20 for _, candidate := range got {
21 if candidate == name {
22 found = true
23 break
24 }
25 }
26 if !found {
27 t.Fatalf("expanded child tools = %v, missing %s", got, name)
28 }
29 }
30}
31
32func TestExpandWebCapabilityToolsPreservesPolicyAndNonWebSelections(t *testing.T) {
33 catalogue := append([]string{"read"}, tools.WebCapabilityNames...)
34 partial := expandWebCapabilityTools([]string{"web_open"}, catalogue, func(string) bool { return true })
35 for _, name := range tools.WebCapabilityNames {
36 found := false
37 for _, candidate := range partial {
38 if candidate == name {
39 found = true
40 break
41 }
42 }
43 if !found {
44 t.Fatalf("partial web selection did not expand: %v", partial)
45 }
46 }
47 denied := expandWebCapabilityTools([]string{"read", "web_search"}, catalogue, func(name string) bool { return name == "read" })
48 if len(denied) != 1 || denied[0] != "read" {
49 t.Fatalf("denied web capability = %v, want [read]", denied)
50 }
51 nonWeb := expandWebCapabilityTools([]string{"read"}, catalogue, func(string) bool { return true })
52 if len(nonWeb) != 1 || nonWeb[0] != "read" {
53 t.Fatalf("non-web selection changed to %v", nonWeb)
54 }
55}
56
57func TestResidentChildSpecSnapshotsCurrentProviderTransportSettings(t *testing.T) {
58 runtime := newSubagentRuntime(subagentRuntimeConfig{
59 Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),
60 Provider: "openai", Model: "gpt-5.6-sol", BaseURL: "https://old.example/v1", InsecureTLS: false, ContextWindow: 500_000,
61 })
62 t.Cleanup(func() { _ = runtime.Close(context.Background()) })
63 runtime.SetProviderSettings("https://current.example/v1", true)
64 runtime.SetModel("gpt-current")
65 spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil})
66 if err != nil {
67 t.Fatal(err)
68 }
69 if spec.BaseURL != "https://current.example/v1" || !spec.InsecureTLS || spec.Model != "gpt-current" {
70 t.Fatalf("resident spec transport/model = %q insecure=%t model=%q", spec.BaseURL, spec.InsecureTLS, spec.Model)
71 }
72}
73
74func TestResidentChildSpecTracksFastMode(t *testing.T) {
75 runtime := newSubagentRuntime(subagentRuntimeConfig{
76 Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),
77 Provider: "openai", Model: "gpt-5.6-sol", FastMode: false,
78 })
79 t.Cleanup(func() { _ = runtime.Close(context.Background()) })
80
81 spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil})
82 if err != nil {
83 t.Fatal(err)
84 }
85 if spec.FastMode {
86 t.Fatal("initial resident fast mode = true, want false")
87 }
88 runtime.SetFastMode(true)
89 spec, err = runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil})
90 if err != nil {
91 t.Fatal(err)
92 }
93 if !spec.FastMode {
94 t.Fatal("resident fast mode = false after update, want true")
95 }
96}
97
98func TestResidentChildSpecCarriesNoCumulativeBudget(t *testing.T) {
99 runtime := newSubagentRuntime(subagentRuntimeConfig{
100 Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),
101 Provider: "openai", Model: "gpt-5.6-sol", ContextWindow: 42_000,
102 })
103 t.Cleanup(func() { _ = runtime.Close(context.Background()) })
104
105 spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil})
106 if err != nil {
107 t.Fatal(err)
108 }
109 if len(spec.Tools) != 1 || spec.Tools[0] != "read" {
110 t.Fatalf("child spec tools = %#v, want resolved tool list", spec)
111 }
112 // The budget fields are gone from the spec struct; the serialized
113 // form must carry no budget bytes either.
114 encoded, err := json.Marshal(spec)
115 if err != nil {
116 t.Fatal(err)
117 }
118 if strings.Contains(string(encoded), "budget") {
119 t.Fatalf("child spec carries budget = %s", encoded)
120 }
121}
122
123func TestResidentChildSpecDoesNotCarryTransportAcrossProviderOverride(t *testing.T) {
124 runtime := newSubagentRuntime(subagentRuntimeConfig{
125 Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),
126 Provider: "openai", Model: "gpt-5.6-sol", BaseURL: "https://parent.example/v1", InsecureTLS: true, ContextWindow: 500_000,
127 })
128 t.Cleanup(func() { _ = runtime.Close(context.Background()) })
129 spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
130 Task: "review", Provider: "anthropic", Model: "claude-sonnet-4-5",
131 }, core.Registry{"read": nil})
132 if err != nil {
133 t.Fatal(err)
134 }
135 if spec.BaseURL != "" || spec.InsecureTLS {
136 t.Fatalf("cross-provider spec inherited transport: %#v", spec)
137 }
138}
139
140func TestResidentChildSpecOmitsParentOnlyTools(t *testing.T) {
141 t.Setenv("ZUT_HOME", t.TempDir())
142 cwd := t.TempDir()
143 runtime := newSubagentRuntime(subagentRuntimeConfig{
144 Args: Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true},
145 Root: t.TempDir(),
146 RepoRoot: cwd,
147 Provider: "openai", Model: "gpt-5.6-sol",
148 })
149 t.Cleanup(func() { _ = runtime.Close(context.Background()) })
150
151 parent := core.Registry{
152 "read": nil,
153 "schedule": nil,
154 "host-only-tool": nil,
155 "subagent": nil,
156 "update_goal": nil,
157 "plan": nil,
158 }
159 spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, parent)
160 if err != nil {
161 t.Fatal(err)
162 }
163 if slices.Contains(spec.Tools, "schedule") || slices.Contains(spec.Tools, "host-only-tool") {
164 t.Fatalf("generic child inherited parent-only tools: %v", spec.Tools)
165 }
166 if !slices.Contains(spec.Tools, "read") {
167 t.Fatalf("generic child lost supported tool: %v", spec.Tools)
168 }
169 for _, name := range []string{"subagent", "update_goal", "plan"} {
170 if slices.Contains(spec.Tools, name) {
171 t.Fatalf("generic child kept forbidden tool %q: %v", name, spec.Tools)
172 }
173 }
174 resolved, err := Resolve(residentChildArgs(runtime.args, runtime.credentialProvider, spec), false)
175 if err != nil {
176 t.Fatal(err)
177 }
178 if _, err := residentChildRegistry(resolved.ToolRegistry, spec.Tools); err != nil {
179 t.Fatalf("generic spec failed child registry validation: %v (tools=%v)", err, spec.Tools)
180 }
181}
182
183func TestResidentChildSpecProfileToolDeclarations(t *testing.T) {
184 t.Setenv("ZUT_HOME", t.TempDir())
185 cwd := t.TempDir()
186 newRuntime := func(policy subagents.SubagentPolicy) *subagentRuntime {
187 rt := newSubagentRuntime(subagentRuntimeConfig{
188 Args: Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true},
189 Root: t.TempDir(),
190 RepoRoot: cwd,
191 Provider: "openai", Model: "gpt-5.6-sol",
192 Policy: policy,
193 })
194 t.Cleanup(func() { _ = rt.Close(context.Background()) })
195 return rt
196 }
197 parent := core.Registry{"read": nil, "bash": nil, "schedule": nil}
198
199 t.Run("omitted inherits like generic", func(t *testing.T) {
200 rt := newRuntime(subagents.SubagentPolicy{})
201 generic, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, parent)
202 if err != nil {
203 t.Fatal(err)
204 }
205 withProfile, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
206 Task: "review",
207 Profile: &subagents.Profile{Name: "generic"},
208 }, parent)
209 if err != nil {
210 t.Fatal(err)
211 }
212 if !slices.Equal(generic.Tools, withProfile.Tools) {
213 t.Fatalf("omitted profile tools = %v, want generic %v", withProfile.Tools, generic.Tools)
214 }
215 })
216
217 t.Run("explicit keeps only supported permitted names", func(t *testing.T) {
218 rt := newRuntime(subagents.SubagentPolicy{})
219 spec, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
220 Task: "review",
221 Profile: &subagents.Profile{
222 Name: "reviewer", ToolsDeclared: true,
223 Tools: []string{"read", "schedule", "host-only-tool", "missing"},
224 },
225 }, parent)
226 if err != nil {
227 t.Fatal(err)
228 }
229 if len(spec.Tools) != 1 || spec.Tools[0] != "read" {
230 t.Fatalf("explicit profile tools = %v, want [read]", spec.Tools)
231 }
232 })
233
234 t.Run("explicit empty stays empty", func(t *testing.T) {
235 rt := newRuntime(subagents.SubagentPolicy{})
236 spec, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
237 Task: "review",
238 Profile: &subagents.Profile{Name: "empty", ToolsDeclared: true, Tools: []string{}},
239 }, parent)
240 if err != nil {
241 t.Fatal(err)
242 }
243 if len(spec.Tools) != 0 {
244 t.Fatalf("explicit empty profile tools = %v, want []", spec.Tools)
245 }
246 })
247
248 t.Run("policy denials hold", func(t *testing.T) {
249 rt := newRuntime(subagents.SubagentPolicy{AllowedTools: []string{"read"}})
250 spec, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, parent)
251 if err != nil {
252 t.Fatal(err)
253 }
254 if len(spec.Tools) != 1 || spec.Tools[0] != "read" {
255 t.Fatalf("policy-restricted tools = %v, want [read]", spec.Tools)
256 }
257 explicit, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
258 Task: "review",
259 Profile: &subagents.Profile{
260 Name: "reviewer", ToolsDeclared: true,
261 Tools: []string{"read", "bash"},
262 },
263 }, parent)
264 if err != nil {
265 t.Fatal(err)
266 }
267 if len(explicit.Tools) != 1 || explicit.Tools[0] != "read" {
268 t.Fatalf("policy-restricted explicit tools = %v, want [read]", explicit.Tools)
269 }
270 })
271}
272
273func TestResidentChildSpecNeverGrantsToolsOutsideParent(t *testing.T) {
274 t.Setenv("ZUT_HOME", t.TempDir())
275 cwd := t.TempDir()
276 runtime := newSubagentRuntime(subagentRuntimeConfig{
277 Args: Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true},
278 Root: t.TempDir(),
279 RepoRoot: cwd,
280 Provider: "openai", Model: "gpt-5.6-sol",
281 })
282 t.Cleanup(func() { _ = runtime.Close(context.Background()) })
283
284 spec, err := runtime.buildResidentChildSpec(context.Background(),
285 tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil})
286 if err != nil {
287 t.Fatal(err)
288 }
289 // bash/write exist in the resolved child runtime but were never offered
290 // by the parent catalogue; the child must not receive them.
291 for _, name := range []string{"bash", "write", "schedule"} {
292 if slices.Contains(spec.Tools, name) {
293 t.Fatalf("child gained tool %q outside parent catalogue: %v", name, spec.Tools)
294 }
295 }
296 explicit, err := runtime.buildResidentChildSpec(context.Background(),
297 tools.ResidentSpawnRequest{
298 Task: "review",
299 Profile: &subagents.Profile{
300 Name: "reviewer", ToolsDeclared: true,
301 Tools: []string{"read", "bash"},
302 },
303 }, core.Registry{"read": nil})
304 if err != nil {
305 t.Fatal(err)
306 }
307 if len(explicit.Tools) != 1 || explicit.Tools[0] != "read" {
308 t.Fatalf("explicit profile gained tool outside parent catalogue: %v", explicit.Tools)
309 }
310}
311
312func TestResidentChildSpecDisabledSkillsDoNotLeak(t *testing.T) {
313 t.Setenv("ZUT_HOME", t.TempDir())
314 cwd := t.TempDir()
315 runtime := newSubagentRuntime(subagentRuntimeConfig{
316 Args: Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true},
317 Root: t.TempDir(),
318 RepoRoot: cwd,
319 Provider: "openai", Model: "gpt-5.6-sol",
320 })
321 t.Cleanup(func() { _ = runtime.Close(context.Background()) })
322
323 inherit := false
324 parent := core.Registry{"read": nil, "skill": nil}
325 spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
326 Task: "review",
327 Profile: &subagents.Profile{Name: "noskill", InheritSkills: &inherit},
328 }, parent)
329 if err != nil {
330 t.Fatal(err)
331 }
332 if slices.Contains(spec.Tools, "skill") {
333 t.Fatalf("skill-disabled child inherited skill tool: %v", spec.Tools)
334 }
335 resolved, err := Resolve(residentChildArgs(runtime.args, runtime.credentialProvider, spec), false)
336 if err != nil {
337 t.Fatal(err)
338 }
339 if _, ok := resolved.ToolRegistry["skill"]; ok {
340 t.Fatalf("skill-disabled child registry contains skill tool")
341 }
342 if _, err := residentChildRegistry(resolved.ToolRegistry, spec.Tools); err != nil {
343 t.Fatalf("skill-disabled spec failed validation: %v", err)
344 }
345}
346
347// TestInjectToolsRegistersOneSubagentFacade pins the registry contract: the four
348// legacy tools collapse into exactly one facade entry whose non-nil fields are
349// the granted actions.
350func TestInjectToolsRegistersOneSubagentFacade(t *testing.T) {
351 t.Setenv("ZUT_HOME", t.TempDir())
352 cwd := t.TempDir()
353 cwdArgs := func() Args {
354 return Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true}
355 }
356 cases := []struct {
357 name string
358 args Args
359 spawn, status, stop, resume, interrupt bool
360 }{
361 {name: "default grants every action", args: cwdArgs(), spawn: true, status: true, stop: true, resume: true, interrupt: true},
362 {name: "resume suffix grants resume only", args: func() Args {
363 args := cwdArgs()
364 args.ToolsSet = true
365 args.Tools = []string{"subagent:resume"}
366 return args
367 }(), resume: true},
368 {name: "interrupt suffix grants interrupt only", args: func() Args {
369 args := cwdArgs()
370 args.ToolsSet = true
371 args.Tools = []string{"subagent:interrupt"}
372 return args
373 }(), interrupt: true},
374 }
375 legacy := []string{"subagent_spawn", "subagent_status", "subagent_stop", "subagent_resume"}
376 for _, tc := range cases {
377 t.Run(tc.name, func(t *testing.T) {
378 runtime := newSubagentRuntime(subagentRuntimeConfig{
379 Args: tc.args,
380 Root: t.TempDir(),
381 RepoRoot: cwd,
382 Provider: "openai", Model: "gpt-5.6-sol",
383 })
384 t.Cleanup(func() { _ = runtime.Close(context.Background()) })
385 registry := runtime.InjectTools(core.Registry{})
386 if len(registry) != 1 {
387 t.Fatalf("injected registry has %d entries, want 1", len(registry))
388 }
389 tool, ok := registry[tools.SubagentToolName]
390 if !ok {
391 t.Fatalf("injected registry has no %q entry", tools.SubagentToolName)
392 }
393 facade, ok := tool.(*tools.SubagentTool)
394 if !ok {
395 t.Fatalf("registry entry %q = %T, want *tools.SubagentTool", tools.SubagentToolName, tool)
396 }
397 got := [5]bool{facade.Spawn != nil, facade.Status != nil, facade.Stop != nil, facade.Resume != nil, facade.Interrupt != nil}
398 want := [5]bool{tc.spawn, tc.status, tc.stop, tc.resume, tc.interrupt}
399 if got != want {
400 t.Fatalf("facade actions = %v, want %v", got, want)
401 }
402 for _, name := range legacy {
403 if _, ok := registry[name]; ok {
404 t.Fatalf("injected registry still exposes legacy tool %q", name)
405 }
406 }
407 })
408 }
409}