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_spawn": nil,
156 "update_goal": nil,
157 }
158 spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, parent)
159 if err != nil {
160 t.Fatal(err)
161 }
162 if slices.Contains(spec.Tools, "schedule") || slices.Contains(spec.Tools, "host-only-tool") {
163 t.Fatalf("generic child inherited parent-only tools: %v", spec.Tools)
164 }
165 if !slices.Contains(spec.Tools, "read") {
166 t.Fatalf("generic child lost supported tool: %v", spec.Tools)
167 }
168 for _, name := range []string{"subagent_spawn", "update_goal"} {
169 if slices.Contains(spec.Tools, name) {
170 t.Fatalf("generic child kept forbidden tool %q: %v", name, spec.Tools)
171 }
172 }
173 resolved, err := Resolve(residentChildArgs(runtime.args, runtime.credentialProvider, spec), false)
174 if err != nil {
175 t.Fatal(err)
176 }
177 if _, err := residentChildRegistry(resolved.ToolRegistry, spec.Tools); err != nil {
178 t.Fatalf("generic spec failed child registry validation: %v (tools=%v)", err, spec.Tools)
179 }
180}
181
182func TestResidentChildSpecProfileToolDeclarations(t *testing.T) {
183 t.Setenv("ZUT_HOME", t.TempDir())
184 cwd := t.TempDir()
185 newRuntime := func(policy subagents.SubagentPolicy) *subagentRuntime {
186 rt := newSubagentRuntime(subagentRuntimeConfig{
187 Args: Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true},
188 Root: t.TempDir(),
189 RepoRoot: cwd,
190 Provider: "openai", Model: "gpt-5.6-sol",
191 Policy: policy,
192 })
193 t.Cleanup(func() { _ = rt.Close(context.Background()) })
194 return rt
195 }
196 parent := core.Registry{"read": nil, "bash": nil, "schedule": nil}
197
198 t.Run("omitted inherits like generic", func(t *testing.T) {
199 rt := newRuntime(subagents.SubagentPolicy{})
200 generic, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, parent)
201 if err != nil {
202 t.Fatal(err)
203 }
204 withProfile, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
205 Task: "review",
206 Profile: &subagents.Profile{Name: "generic"},
207 }, parent)
208 if err != nil {
209 t.Fatal(err)
210 }
211 if !slices.Equal(generic.Tools, withProfile.Tools) {
212 t.Fatalf("omitted profile tools = %v, want generic %v", withProfile.Tools, generic.Tools)
213 }
214 })
215
216 t.Run("explicit keeps only supported permitted names", func(t *testing.T) {
217 rt := newRuntime(subagents.SubagentPolicy{})
218 spec, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
219 Task: "review",
220 Profile: &subagents.Profile{
221 Name: "reviewer", ToolsDeclared: true,
222 Tools: []string{"read", "schedule", "host-only-tool", "missing"},
223 },
224 }, parent)
225 if err != nil {
226 t.Fatal(err)
227 }
228 if len(spec.Tools) != 1 || spec.Tools[0] != "read" {
229 t.Fatalf("explicit profile tools = %v, want [read]", spec.Tools)
230 }
231 })
232
233 t.Run("explicit empty stays empty", func(t *testing.T) {
234 rt := newRuntime(subagents.SubagentPolicy{})
235 spec, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
236 Task: "review",
237 Profile: &subagents.Profile{Name: "empty", ToolsDeclared: true, Tools: []string{}},
238 }, parent)
239 if err != nil {
240 t.Fatal(err)
241 }
242 if len(spec.Tools) != 0 {
243 t.Fatalf("explicit empty profile tools = %v, want []", spec.Tools)
244 }
245 })
246
247 t.Run("policy denials hold", func(t *testing.T) {
248 rt := newRuntime(subagents.SubagentPolicy{AllowedTools: []string{"read"}})
249 spec, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, parent)
250 if err != nil {
251 t.Fatal(err)
252 }
253 if len(spec.Tools) != 1 || spec.Tools[0] != "read" {
254 t.Fatalf("policy-restricted tools = %v, want [read]", spec.Tools)
255 }
256 explicit, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
257 Task: "review",
258 Profile: &subagents.Profile{
259 Name: "reviewer", ToolsDeclared: true,
260 Tools: []string{"read", "bash"},
261 },
262 }, parent)
263 if err != nil {
264 t.Fatal(err)
265 }
266 if len(explicit.Tools) != 1 || explicit.Tools[0] != "read" {
267 t.Fatalf("policy-restricted explicit tools = %v, want [read]", explicit.Tools)
268 }
269 })
270}
271
272func TestResidentChildSpecNeverGrantsToolsOutsideParent(t *testing.T) {
273 t.Setenv("ZUT_HOME", t.TempDir())
274 cwd := t.TempDir()
275 runtime := newSubagentRuntime(subagentRuntimeConfig{
276 Args: Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true},
277 Root: t.TempDir(),
278 RepoRoot: cwd,
279 Provider: "openai", Model: "gpt-5.6-sol",
280 })
281 t.Cleanup(func() { _ = runtime.Close(context.Background()) })
282
283 spec, err := runtime.buildResidentChildSpec(context.Background(),
284 tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil})
285 if err != nil {
286 t.Fatal(err)
287 }
288 // bash/write exist in the resolved child runtime but were never offered
289 // by the parent catalogue; the child must not receive them.
290 for _, name := range []string{"bash", "write", "schedule"} {
291 if slices.Contains(spec.Tools, name) {
292 t.Fatalf("child gained tool %q outside parent catalogue: %v", name, spec.Tools)
293 }
294 }
295 explicit, err := runtime.buildResidentChildSpec(context.Background(),
296 tools.ResidentSpawnRequest{
297 Task: "review",
298 Profile: &subagents.Profile{
299 Name: "reviewer", ToolsDeclared: true,
300 Tools: []string{"read", "bash"},
301 },
302 }, core.Registry{"read": nil})
303 if err != nil {
304 t.Fatal(err)
305 }
306 if len(explicit.Tools) != 1 || explicit.Tools[0] != "read" {
307 t.Fatalf("explicit profile gained tool outside parent catalogue: %v", explicit.Tools)
308 }
309}
310
311func TestResidentChildSpecDisabledSkillsDoNotLeak(t *testing.T) {
312 t.Setenv("ZUT_HOME", t.TempDir())
313 cwd := t.TempDir()
314 runtime := newSubagentRuntime(subagentRuntimeConfig{
315 Args: Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true},
316 Root: t.TempDir(),
317 RepoRoot: cwd,
318 Provider: "openai", Model: "gpt-5.6-sol",
319 })
320 t.Cleanup(func() { _ = runtime.Close(context.Background()) })
321
322 inherit := false
323 parent := core.Registry{"read": nil, "skill": nil}
324 spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
325 Task: "review",
326 Profile: &subagents.Profile{Name: "noskill", InheritSkills: &inherit},
327 }, parent)
328 if err != nil {
329 t.Fatal(err)
330 }
331 if slices.Contains(spec.Tools, "skill") {
332 t.Fatalf("skill-disabled child inherited skill tool: %v", spec.Tools)
333 }
334 resolved, err := Resolve(residentChildArgs(runtime.args, runtime.credentialProvider, spec), false)
335 if err != nil {
336 t.Fatal(err)
337 }
338 if _, ok := resolved.ToolRegistry["skill"]; ok {
339 t.Fatalf("skill-disabled child registry contains skill tool")
340 }
341 if _, err := residentChildRegistry(resolved.ToolRegistry, spec.Tools); err != nil {
342 t.Fatalf("skill-disabled spec failed validation: %v", err)
343 }
344}