1package agent
2
3import (
4 "context"
5 "slices"
6 "testing"
7
8 "github.com/bnema/zut/packages/agent/subagents"
9 "github.com/bnema/zut/packages/agent/tools"
10 "github.com/bnema/zut/packages/core"
11)
12
13func TestExpandWebCapabilityTools(t *testing.T) {
14 catalogue := append([]string{"read"}, tools.WebCapabilityNames...)
15 got := expandWebCapabilityTools([]string{"read", "web_search"}, catalogue, func(string) bool { return true })
16 for _, name := range append([]string{"read"}, tools.WebCapabilityNames...) {
17 found := false
18 for _, candidate := range got {
19 if candidate == name {
20 found = true
21 break
22 }
23 }
24 if !found {
25 t.Fatalf("expanded child tools = %v, missing %s", got, name)
26 }
27 }
28}
29
30func TestExpandWebCapabilityToolsPreservesPolicyAndNonWebSelections(t *testing.T) {
31 catalogue := append([]string{"read"}, tools.WebCapabilityNames...)
32 partial := expandWebCapabilityTools([]string{"web_open"}, catalogue, func(string) bool { return true })
33 for _, name := range tools.WebCapabilityNames {
34 found := false
35 for _, candidate := range partial {
36 if candidate == name {
37 found = true
38 break
39 }
40 }
41 if !found {
42 t.Fatalf("partial web selection did not expand: %v", partial)
43 }
44 }
45 denied := expandWebCapabilityTools([]string{"read", "web_search"}, catalogue, func(name string) bool { return name == "read" })
46 if len(denied) != 1 || denied[0] != "read" {
47 t.Fatalf("denied web capability = %v, want [read]", denied)
48 }
49 nonWeb := expandWebCapabilityTools([]string{"read"}, catalogue, func(string) bool { return true })
50 if len(nonWeb) != 1 || nonWeb[0] != "read" {
51 t.Fatalf("non-web selection changed to %v", nonWeb)
52 }
53}
54
55func TestResidentChildSpecSnapshotsCurrentProviderTransportSettings(t *testing.T) {
56 runtime := newSubagentRuntime(subagentRuntimeConfig{
57 Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),
58 Provider: "openai", Model: "gpt-5.6-sol", BaseURL: "https://old.example/v1", InsecureTLS: false, ContextWindow: 500_000,
59 })
60 t.Cleanup(func() { _ = runtime.Close(context.Background()) })
61 runtime.SetProviderSettings("https://current.example/v1", true)
62 runtime.SetModel("gpt-current")
63 spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil})
64 if err != nil {
65 t.Fatal(err)
66 }
67 if spec.BaseURL != "https://current.example/v1" || !spec.InsecureTLS || spec.Model != "gpt-current" {
68 t.Fatalf("resident spec transport/model = %q insecure=%t model=%q", spec.BaseURL, spec.InsecureTLS, spec.Model)
69 }
70}
71
72func TestResidentChildSpecTracksFastMode(t *testing.T) {
73 runtime := newSubagentRuntime(subagentRuntimeConfig{
74 Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),
75 Provider: "openai", Model: "gpt-5.6-sol", FastMode: false,
76 })
77 t.Cleanup(func() { _ = runtime.Close(context.Background()) })
78
79 spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil})
80 if err != nil {
81 t.Fatal(err)
82 }
83 if spec.FastMode {
84 t.Fatal("initial resident fast mode = true, want false")
85 }
86 runtime.SetFastMode(true)
87 spec, err = runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil})
88 if err != nil {
89 t.Fatal(err)
90 }
91 if !spec.FastMode {
92 t.Fatal("resident fast mode = false after update, want true")
93 }
94}
95
96func TestResidentChildSpecUsesSelectedModelContextForBudget(t *testing.T) {
97 runtime := newSubagentRuntime(subagentRuntimeConfig{
98 Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),
99 Provider: "openai", Model: "gpt-5.6-sol", ContextWindow: 42_000,
100 })
101 t.Cleanup(func() { _ = runtime.Close(context.Background()) })
102
103 spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil})
104 if err != nil {
105 t.Fatal(err)
106 }
107 if spec.BudgetLimit != 500_000 || spec.BudgetSource != "model_context" {
108 t.Fatalf("child model budget = %#v", spec)
109 }
110}
111
112func TestResidentChildSpecDoesNotCarryTransportAcrossProviderOverride(t *testing.T) {
113 runtime := newSubagentRuntime(subagentRuntimeConfig{
114 Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),
115 Provider: "openai", Model: "gpt-5.6-sol", BaseURL: "https://parent.example/v1", InsecureTLS: true, ContextWindow: 500_000,
116 })
117 t.Cleanup(func() { _ = runtime.Close(context.Background()) })
118 spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
119 Task: "review", Provider: "anthropic", Model: "claude-sonnet-4-5",
120 }, core.Registry{"read": nil})
121 if err != nil {
122 t.Fatal(err)
123 }
124 if spec.BaseURL != "" || spec.InsecureTLS {
125 t.Fatalf("cross-provider spec inherited transport: %#v", spec)
126 }
127}
128
129func TestResidentChildSpecOmitsParentOnlyTools(t *testing.T) {
130 t.Setenv("ZUT_HOME", t.TempDir())
131 cwd := t.TempDir()
132 runtime := newSubagentRuntime(subagentRuntimeConfig{
133 Args: Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true},
134 Root: t.TempDir(),
135 RepoRoot: cwd,
136 Provider: "openai", Model: "gpt-5.6-sol",
137 })
138 t.Cleanup(func() { _ = runtime.Close(context.Background()) })
139
140 parent := core.Registry{
141 "read": nil,
142 "schedule": nil,
143 "host-only-tool": nil,
144 "subagent_spawn": nil,
145 "update_goal": nil,
146 }
147 spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, parent)
148 if err != nil {
149 t.Fatal(err)
150 }
151 if slices.Contains(spec.Tools, "schedule") || slices.Contains(spec.Tools, "host-only-tool") {
152 t.Fatalf("generic child inherited parent-only tools: %v", spec.Tools)
153 }
154 if !slices.Contains(spec.Tools, "read") {
155 t.Fatalf("generic child lost supported tool: %v", spec.Tools)
156 }
157 for _, name := range []string{"subagent_spawn", "update_goal"} {
158 if slices.Contains(spec.Tools, name) {
159 t.Fatalf("generic child kept forbidden tool %q: %v", name, spec.Tools)
160 }
161 }
162 resolved, err := Resolve(residentChildArgs(runtime.args, runtime.credentialProvider, spec), false)
163 if err != nil {
164 t.Fatal(err)
165 }
166 if _, err := residentChildRegistry(resolved.ToolRegistry, spec.Tools); err != nil {
167 t.Fatalf("generic spec failed child registry validation: %v (tools=%v)", err, spec.Tools)
168 }
169}
170
171func TestResidentChildSpecProfileToolDeclarations(t *testing.T) {
172 t.Setenv("ZUT_HOME", t.TempDir())
173 cwd := t.TempDir()
174 newRuntime := func(policy subagents.SubagentPolicy) *subagentRuntime {
175 rt := newSubagentRuntime(subagentRuntimeConfig{
176 Args: Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true},
177 Root: t.TempDir(),
178 RepoRoot: cwd,
179 Provider: "openai", Model: "gpt-5.6-sol",
180 Policy: policy,
181 })
182 t.Cleanup(func() { _ = rt.Close(context.Background()) })
183 return rt
184 }
185 parent := core.Registry{"read": nil, "bash": nil, "schedule": nil}
186
187 t.Run("omitted inherits like generic", func(t *testing.T) {
188 rt := newRuntime(subagents.SubagentPolicy{})
189 generic, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, parent)
190 if err != nil {
191 t.Fatal(err)
192 }
193 withProfile, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
194 Task: "review",
195 Profile: &subagents.Profile{Name: "generic"},
196 }, parent)
197 if err != nil {
198 t.Fatal(err)
199 }
200 if !slices.Equal(generic.Tools, withProfile.Tools) {
201 t.Fatalf("omitted profile tools = %v, want generic %v", withProfile.Tools, generic.Tools)
202 }
203 })
204
205 t.Run("explicit keeps only supported permitted names", func(t *testing.T) {
206 rt := newRuntime(subagents.SubagentPolicy{})
207 spec, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
208 Task: "review",
209 Profile: &subagents.Profile{
210 Name: "reviewer", ToolsDeclared: true,
211 Tools: []string{"read", "schedule", "host-only-tool", "missing"},
212 },
213 }, parent)
214 if err != nil {
215 t.Fatal(err)
216 }
217 if len(spec.Tools) != 1 || spec.Tools[0] != "read" {
218 t.Fatalf("explicit profile tools = %v, want [read]", spec.Tools)
219 }
220 })
221
222 t.Run("explicit empty stays empty", func(t *testing.T) {
223 rt := newRuntime(subagents.SubagentPolicy{})
224 spec, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
225 Task: "review",
226 Profile: &subagents.Profile{Name: "empty", ToolsDeclared: true, Tools: []string{}},
227 }, parent)
228 if err != nil {
229 t.Fatal(err)
230 }
231 if len(spec.Tools) != 0 {
232 t.Fatalf("explicit empty profile tools = %v, want []", spec.Tools)
233 }
234 })
235
236 t.Run("policy denials hold", func(t *testing.T) {
237 rt := newRuntime(subagents.SubagentPolicy{AllowedTools: []string{"read"}})
238 spec, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, parent)
239 if err != nil {
240 t.Fatal(err)
241 }
242 if len(spec.Tools) != 1 || spec.Tools[0] != "read" {
243 t.Fatalf("policy-restricted tools = %v, want [read]", spec.Tools)
244 }
245 explicit, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
246 Task: "review",
247 Profile: &subagents.Profile{
248 Name: "reviewer", ToolsDeclared: true,
249 Tools: []string{"read", "bash"},
250 },
251 }, parent)
252 if err != nil {
253 t.Fatal(err)
254 }
255 if len(explicit.Tools) != 1 || explicit.Tools[0] != "read" {
256 t.Fatalf("policy-restricted explicit tools = %v, want [read]", explicit.Tools)
257 }
258 })
259}
260
261func TestResidentChildSpecNeverGrantsToolsOutsideParent(t *testing.T) {
262 t.Setenv("ZUT_HOME", t.TempDir())
263 cwd := t.TempDir()
264 runtime := newSubagentRuntime(subagentRuntimeConfig{
265 Args: Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true},
266 Root: t.TempDir(),
267 RepoRoot: cwd,
268 Provider: "openai", Model: "gpt-5.6-sol",
269 })
270 t.Cleanup(func() { _ = runtime.Close(context.Background()) })
271
272 spec, err := runtime.buildResidentChildSpec(context.Background(),
273 tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil})
274 if err != nil {
275 t.Fatal(err)
276 }
277 // bash/write exist in the resolved child runtime but were never offered
278 // by the parent catalogue; the child must not receive them.
279 for _, name := range []string{"bash", "write", "schedule"} {
280 if slices.Contains(spec.Tools, name) {
281 t.Fatalf("child gained tool %q outside parent catalogue: %v", name, spec.Tools)
282 }
283 }
284 explicit, err := runtime.buildResidentChildSpec(context.Background(),
285 tools.ResidentSpawnRequest{
286 Task: "review",
287 Profile: &subagents.Profile{
288 Name: "reviewer", ToolsDeclared: true,
289 Tools: []string{"read", "bash"},
290 },
291 }, core.Registry{"read": nil})
292 if err != nil {
293 t.Fatal(err)
294 }
295 if len(explicit.Tools) != 1 || explicit.Tools[0] != "read" {
296 t.Fatalf("explicit profile gained tool outside parent catalogue: %v", explicit.Tools)
297 }
298}
299
300func TestResidentChildSpecDisabledSkillsDoNotLeak(t *testing.T) {
301 t.Setenv("ZUT_HOME", t.TempDir())
302 cwd := t.TempDir()
303 runtime := newSubagentRuntime(subagentRuntimeConfig{
304 Args: Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true},
305 Root: t.TempDir(),
306 RepoRoot: cwd,
307 Provider: "openai", Model: "gpt-5.6-sol",
308 })
309 t.Cleanup(func() { _ = runtime.Close(context.Background()) })
310
311 inherit := false
312 parent := core.Registry{"read": nil, "skill": nil}
313 spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
314 Task: "review",
315 Profile: &subagents.Profile{Name: "noskill", InheritSkills: &inherit},
316 }, parent)
317 if err != nil {
318 t.Fatal(err)
319 }
320 if slices.Contains(spec.Tools, "skill") {
321 t.Fatalf("skill-disabled child inherited skill tool: %v", spec.Tools)
322 }
323 resolved, err := Resolve(residentChildArgs(runtime.args, runtime.credentialProvider, spec), false)
324 if err != nil {
325 t.Fatal(err)
326 }
327 if _, ok := resolved.ToolRegistry["skill"]; ok {
328 t.Fatalf("skill-disabled child registry contains skill tool")
329 }
330 if _, err := residentChildRegistry(resolved.ToolRegistry, spec.Tools); err != nil {
331 t.Fatalf("skill-disabled spec failed validation: %v", err)
332 }
333}