1package agent
2
3import (
4 "context"
5 "fmt"
6 "net/http"
7 "net/http/httptest"
8 "os"
9 "path/filepath"
10 "strings"
11 "testing"
12
13 "github.com/bnema/zut/packages/agent/skills"
14 "github.com/bnema/zut/packages/agent/subagents"
15 "github.com/bnema/zut/packages/agent/tools"
16 "github.com/bnema/zut/packages/core"
17 "github.com/bnema/zut/packages/provider"
18)
19
20type bundledSkillSource struct {
21 bundled []*skills.Skill
22}
23
24func (s bundledSkillSource) Tools() []ExtensionToolInfo { return nil }
25func (s bundledSkillSource) NewExtensionTool(ExtensionToolInfo) core.Tool {
26 return nil
27}
28func (s bundledSkillSource) Skills() []*skills.Skill { return s.bundled }
29
30type webSearchConflictSource struct{}
31
32func (webSearchConflictSource) Tools() []ExtensionToolInfo {
33 return []ExtensionToolInfo{{Extension: "test", Name: "web_search"}}
34}
35func (webSearchConflictSource) NewExtensionTool(ExtensionToolInfo) core.Tool {
36 return &tools.ReadTool{}
37}
38
39type unorderedExtensionSource struct{}
40
41func (unorderedExtensionSource) Tools() []ExtensionToolInfo {
42 return []ExtensionToolInfo{
43 {Extension: "test", Name: "zeta"},
44 {Extension: "test", Name: "alpha"},
45 }
46}
47func (unorderedExtensionSource) NewExtensionTool(ExtensionToolInfo) core.Tool {
48 return &tools.ReadTool{}
49}
50
51func TestMergeExtensionToolsCreatesSkillToolWhenDiscoveryFoundNoSkills(t *testing.T) {
52 r := Resolved{
53 CWD: t.TempDir(),
54 ToolRegistry: make(core.Registry),
55 skillsEnabled: true,
56 }
57 bundled := &skills.Skill{Name: "bundled", Description: "bundled skill", Source: "extension tasked-phases"}
58
59 r.MergeExtensionTools(bundledSkillSource{bundled: []*skills.Skill{bundled}})
60
61 if r.SkillTool == nil {
62 t.Fatal("MergeExtensionTools did not create a skill tool for bundled skills")
63 }
64 if _, ok := r.ToolRegistry[r.SkillTool.Name()]; !ok {
65 t.Fatalf("skill tool %q was not registered", r.SkillTool.Name())
66 }
67 if got := r.SkillTool.Skills(); len(got) != 1 || got[0].Name != bundled.Name {
68 t.Fatalf("merged bundled skills = %#v", got)
69 }
70}
71
72func TestMergeExtensionToolsRespectsDisabledSkillDiscovery(t *testing.T) {
73 r := Resolved{
74 CWD: t.TempDir(),
75 ToolRegistry: make(core.Registry),
76 skillsEnabled: false,
77 }
78 bundled := &skills.Skill{Name: "bundled", Description: "bundled skill", Source: "extension tasked-phases"}
79
80 r.MergeExtensionTools(bundledSkillSource{bundled: []*skills.Skill{bundled}})
81
82 if r.SkillTool != nil {
83 t.Fatal("disabled skill discovery created a skill tool")
84 }
85}
86
87func TestMergeExtensionToolsReservesWebSearchName(t *testing.T) {
88 r := Resolved{ToolRegistry: make(core.Registry)}
89 r.MergeExtensionTools(webSearchConflictSource{})
90 if _, ok := r.ToolRegistry["web_search"]; ok {
91 t.Fatal("extension replaced reserved native web_search name")
92 }
93}
94
95func TestMergeExtensionToolsKeepsDeterministicNativeSummaryOrder(t *testing.T) {
96 r := Resolved{
97 CWD: t.TempDir(),
98 ToolRegistry: buildToolRegistry(Args{}, t.TempDir(), nil, false, false, false),
99 }
100 r.MergeExtensionTools(unorderedExtensionSource{})
101
102 var names []string
103 for _, summary := range r.ToolSummary {
104 names = append(names, summary.Name)
105 }
106 want := "read,write,edit,bash,create_worktree,web_search,alpha,zeta"
107 if got := strings.Join(names, ","); got != want {
108 t.Fatalf("merged tool summary order = %q, want %q", got, want)
109 }
110
111 r.MergeExtensionTools(unorderedExtensionSource{})
112 names = names[:0]
113 for _, summary := range r.ToolSummary {
114 names = append(names, summary.Name)
115 }
116 if got := strings.Join(names, ","); got != want {
117 t.Fatalf("reloaded tool summary order = %q, want %q", got, want)
118 }
119}
120
121func TestMergeExtensionToolsRetainsPonytailPromptAddendum(t *testing.T) {
122 r := Resolved{
123 CWD: t.TempDir(),
124 ToolRegistry: make(core.Registry),
125 skillsEnabled: true,
126 systemAppend: []string{PonytailSystemAddendum()},
127 }
128 bundled := &skills.Skill{Name: "bundled", Description: "bundled skill", Source: "extension tasked-phases"}
129
130 r.MergeExtensionTools(bundledSkillSource{bundled: []*skills.Skill{bundled}})
131
132 if !strings.Contains(r.SystemPrompt, PonytailSystemAddendum()) {
133 t.Fatalf("extension prompt rebuild lost Ponytail addendum:\n%s", r.SystemPrompt)
134 }
135}
136
137func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
138 root := t.TempDir()
139 sandbox := tools.NewSandbox(root)
140 registry := buildToolRegistry(Args{}, root, sandbox, true, true, false)
141 lspTool, ok := registry["lsp"].(*tools.LSPTool)
142 if !ok || lspTool.Manager == nil {
143 t.Fatalf("lsp tool = %#v", registry["lsp"])
144 }
145 t.Cleanup(func() { _ = lspTool.Manager.Close() })
146 if _, ok := registry["lsp"]; !ok {
147 t.Fatal("default registry does not include lsp")
148 }
149 worktree, ok := registry["create_worktree"].(*tools.CreateWorktreeTool)
150 if !ok || worktree.CWD != root || worktree.Sandbox != sandbox {
151 t.Fatalf("default registry worktree tool = %#v", registry["create_worktree"])
152 }
153 if _, ok := registry["web_search"].(*tools.WebSearchTool); !ok {
154 t.Fatalf("default registry web_search = %#v", registry["web_search"])
155 }
156 write, ok := registry["write"].(*tools.WriteTool)
157 if !ok || write.LSP == nil || !write.LSPDiagnostics {
158 t.Fatalf("write tool LSP wiring = %#v", write)
159 }
160 edit, ok := registry["edit"].(*tools.EditTool)
161 if !ok || edit.LSP == nil || edit.LSPDiagnostics {
162 t.Fatalf("edit tool LSP wiring = %#v", edit)
163 }
164 if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 6 {
165 t.Fatalf("LSP-disabled registry has %d tools, want 6", len(disabled))
166 }
167 readOnly := buildToolRegistry(Args{Tools: []string{"read"}}, root, tools.NewSandbox(root), true, true, true)
168 if read, ok := readOnly["read"].(*tools.ReadTool); !ok || read == nil {
169 t.Fatalf("read-only registry = %#v", readOnly)
170 }
171 if _, ok := readOnly["web_search"]; ok {
172 t.Fatalf("explicit non-matching tools list retained web search: %#v", readOnly)
173 }
174 webOnly := buildToolRegistry(Args{Tools: []string{"web_search"}}, root, tools.NewSandbox(root), true, true, true)
175 if web, ok := webOnly["web_search"].(*tools.WebSearchTool); !ok || web == nil || len(webOnly) != 1 {
176 t.Fatalf("web-search-only registry = %#v", webOnly)
177 }
178 workerMissingPolicy := buildToolRegistry(Args{Mode: ModeSubagentWorker}, root, tools.NewSandbox(root), false, false, false)
179 if _, ok := workerMissingPolicy["web_search"]; ok {
180 t.Fatal("worker registry enabled web_search without an explicit propagated allow")
181 }
182 workerAllowed := buildToolRegistry(Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchAllow}, root, tools.NewSandbox(root), false, false, false)
183 if _, ok := workerAllowed["web_search"].(*tools.WebSearchTool); !ok {
184 t.Fatalf("worker propagated allow registry = %#v", workerAllowed)
185 }
186 workerCapped := buildToolRegistry(Args{Mode: ModeSubagentWorker, ToolsSet: true, Tools: []string{"read"}, WebSearchPolicy: subagents.WebSearchAllow}, root, tools.NewSandbox(root), false, false, false)
187 if _, ok := workerCapped["web_search"]; ok {
188 t.Fatalf("worker tool list ceiling was bypassed: %#v", workerCapped)
189 }
190 worktreeOnly := buildToolRegistry(Args{Tools: []string{"create_worktree"}}, root, tools.NewSandbox(root), true, true, true)
191 if worktree, ok := worktreeOnly["create_worktree"].(*tools.CreateWorktreeTool); !ok || worktree == nil || len(worktreeOnly) != 1 {
192 t.Fatalf("worktree-only registry = %#v", worktreeOnly)
193 }
194}
195
196func TestToolSummariesIncludeCreateWorktreeAndWebSearch(t *testing.T) {
197 root := t.TempDir()
198 registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, false, false)
199
200 summaries := toolSummaries(registry, Args{})
201 want := []string{"read", "write", "edit", "bash", "create_worktree", "web_search"}
202 if len(summaries) != len(want) {
203 t.Fatalf("tool summaries = %#v, want %v", summaries, want)
204 }
205 for index, summary := range summaries {
206 if summary.Name != want[index] {
207 t.Fatalf("tool summaries[%d] = %q, want %q", index, summary.Name, want[index])
208 }
209 }
210}
211
212func TestResolvedUseSandboxUpdatesCreateWorktreeTool(t *testing.T) {
213 root := t.TempDir()
214 previous := tools.NewSandbox(root)
215 shared := tools.NewSandbox(root)
216 worktree := &tools.CreateWorktreeTool{CWD: root, Sandbox: previous}
217 resolved := &Resolved{
218 Sandbox: previous,
219 ToolRegistry: core.NewRegistry(worktree),
220 }
221
222 resolved.UseSandbox(shared)
223
224 if resolved.Sandbox != shared {
225 t.Fatal("resolved sandbox was not replaced")
226 }
227 if worktree.Sandbox != shared {
228 t.Fatal("worktree tool sandbox was not replaced")
229 }
230}
231
232func TestAutoSubagentsToolPoliciesTrackEachTool(t *testing.T) {
233 for _, tc := range []struct {
234 name string
235 tools []string
236 wantSpawn bool
237 wantStatus bool
238 wantAny bool
239 }{
240 {name: "default", wantSpawn: true, wantStatus: true, wantAny: true},
241 {name: "spawn", tools: []string{"subagent_spawn"}, wantSpawn: true, wantAny: true},
242 {name: "status", tools: []string{"subagent_status"}, wantStatus: true, wantAny: true},
243 {name: "other", tools: []string{"read"}},
244 {name: "no tools", wantAny: false, tools: nil},
245 } {
246 t.Run(tc.name, func(t *testing.T) {
247 args := Args{Tools: tc.tools}
248 if tc.name == "no tools" {
249 args.NoTools = true
250 }
251 if got := autoSubagentsToolAllowed(args); got != tc.wantSpawn {
252 t.Fatalf("spawn allowed = %v, want %v", got, tc.wantSpawn)
253 }
254 if got := autoSubagentsStatusToolAllowed(args); got != tc.wantStatus {
255 t.Fatalf("status allowed = %v, want %v", got, tc.wantStatus)
256 }
257 if got := autoSubagentsAnyToolAllowed(args); got != tc.wantAny {
258 t.Fatalf("any allowed = %v, want %v", got, tc.wantAny)
259 }
260 })
261 }
262}
263
264func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) {
265 root := t.TempDir()
266 zutHome := filepath.Join(root, "zut-home")
267 project := filepath.Join(root, "repo")
268 nested := filepath.Join(project, "packages", "app")
269 if err := os.MkdirAll(zutHome, 0o755); err != nil {
270 t.Fatal(err)
271 }
272 if err := os.MkdirAll(nested, 0o755); err != nil {
273 t.Fatal(err)
274 }
275 if err := os.WriteFile(filepath.Join(zutHome, "AGENTS.md"), []byte("global rule"), 0o644); err != nil {
276 t.Fatal(err)
277 }
278 if err := os.WriteFile(filepath.Join(project, "AGENTS.md"), []byte("repo rule"), 0o644); err != nil {
279 t.Fatal(err)
280 }
281 if err := os.WriteFile(filepath.Join(nested, "AGENTS.md"), []byte("app rule"), 0o644); err != nil {
282 t.Fatal(err)
283 }
284
285 files := loadAgentsContext(nested, zutHome)
286 if len(files) != 3 {
287 t.Fatalf("loaded %d context files, want 3: %#v", len(files), files)
288 }
289 wantPaths := []string{
290 filepath.Join(zutHome, "AGENTS.md"),
291 filepath.Join(project, "AGENTS.md"),
292 filepath.Join(nested, "AGENTS.md"),
293 }
294 for idx, want := range wantPaths {
295 if files[idx].Path != want {
296 t.Fatalf("context file %d path = %q, want %q", idx, files[idx].Path, want)
297 }
298 }
299
300 got := formatAgentsContext(files)
301 for _, want := range []string{"global rule", "repo rule", "app rule"} {
302 if !strings.Contains(got, want) {
303 t.Fatalf("formatAgentsContext missing %q in:\n%s", want, got)
304 }
305 }
306 if strings.Index(got, "global rule") > strings.Index(got, "repo rule") || strings.Index(got, "repo rule") > strings.Index(got, "app rule") {
307 t.Fatalf("AGENTS.md files loaded in wrong order:\n%s", got)
308 }
309}
310
311func TestFindSubagentProfileReportsDiscoveryFailure(t *testing.T) {
312 profileSource := filepath.Join(t.TempDir(), "profiles.md")
313 if err := os.WriteFile(profileSource, []byte("not a directory"), 0o600); err != nil {
314 t.Fatal(err)
315 }
316 t.Setenv("ZUT_AGENT_PROFILES", profileSource)
317
318 profile, err := findSubagentProfile("", "reviewer")
319 if profile != nil {
320 t.Fatalf("profile = %#v, want nil", profile)
321 }
322 if err == nil || !strings.Contains(err.Error(), "discover subagent profiles") {
323 t.Fatalf("error = %v, want discovery failure", err)
324 }
325 if strings.Contains(err.Error(), profileSource) {
326 t.Fatalf("error leaked profile path: %v", err)
327 }
328}
329
330func TestResolveIncludesNamedSubagentsListWhenAutoSubagentsIsEnabled(t *testing.T) {
331 root := t.TempDir()
332 home := filepath.Join(root, "home")
333 zutHome := filepath.Join(root, "zut-home")
334 project := filepath.Join(root, "repo")
335 if err := os.MkdirAll(filepath.Join(home, ".agents", "agents"), 0o755); err != nil {
336 t.Fatal(err)
337 }
338 if err := os.MkdirAll(project, 0o755); err != nil {
339 t.Fatal(err)
340 }
341 t.Setenv("HOME", home)
342 t.Setenv("ZUT_HOME", zutHome)
343 t.Setenv("ZUT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents"))
344 enabled := true
345 if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", AutoSubagentsEnabled: &enabled}); err != nil {
346 t.Fatal(err)
347 }
348 profile := `---
349name: reviewer
350description: Read-only code reviewer
351thinking: high
352---
353Review without editing.
354`
355 if err := os.WriteFile(filepath.Join(home, ".agents", "agents", "reviewer.md"), []byte(profile), 0o600); err != nil {
356 t.Fatal(err)
357 }
358
359 r, err := Resolve(Args{CWD: project}, false)
360 if err != nil {
361 t.Fatal(err)
362 }
363 for _, want := range []string{
364 "[subagents_list]",
365 "reviewer",
366 "Read-only code reviewer",
367 "[/subagents_list]",
368 "primary-agent orchestrator",
369 "Delegate all implementation, debugging/testing, and code-review work",
370 "appropriately named subagent profile",
371 "general worker",
372 "Do not write or edit code yourself",
373 "direct implementation tool calls",
374 "inspect or review code",
375 "apply worker patches",
376 "Shared-worktree",
377 "isolation:\"worktree\"",
378 "Child workers cannot recursively spawn more sub-agents in v1",
379 } {
380 if !strings.Contains(r.SystemPrompt, want) {
381 t.Fatalf("system prompt missing %q:\n%s", want, r.SystemPrompt)
382 }
383 }
384}
385
386func TestResolveKeepsStrictContractButOmitsProfilesWhenSpawnIsUnavailable(t *testing.T) {
387 root := t.TempDir()
388 home := filepath.Join(root, "home")
389 zutHome := filepath.Join(root, "zut-home")
390 project := filepath.Join(root, "repo")
391 if err := os.MkdirAll(filepath.Join(home, ".agents", "agents"), 0o755); err != nil {
392 t.Fatal(err)
393 }
394 if err := os.MkdirAll(project, 0o755); err != nil {
395 t.Fatal(err)
396 }
397 t.Setenv("HOME", home)
398 t.Setenv("ZUT_HOME", zutHome)
399 t.Setenv("ZUT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents"))
400 enabled := true
401 if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", AutoSubagentsEnabled: &enabled}); err != nil {
402 t.Fatal(err)
403 }
404 profile := "---\nname: reviewer\ndescription: Read-only code reviewer\n---\nReview without editing.\n"
405 if err := os.WriteFile(filepath.Join(home, ".agents", "agents", "reviewer.md"), []byte(profile), 0o600); err != nil {
406 t.Fatal(err)
407 }
408
409 cases := []struct {
410 name string
411 args Args
412 }{
413 {name: "no tools", args: Args{CWD: project, NoTools: true}},
414 {name: "read allowlist", args: Args{CWD: project, Tools: []string{"read"}, ToolsSet: true}},
415 {name: "status allowlist", args: Args{CWD: project, Tools: []string{"subagent_status"}, ToolsSet: true}},
416 {name: "permission set", args: Args{CWD: project, PermissionSet: &tools.PermissionSet{}}},
417 }
418 for _, tc := range cases {
419 t.Run(tc.name, func(t *testing.T) {
420 r, err := Resolve(tc.args, false)
421 if err != nil {
422 t.Fatal(err)
423 }
424 for _, want := range []string{
425 "primary-agent orchestrator",
426 "Do not write or edit code yourself",
427 "subagent_spawn",
428 "report this limitation to the user",
429 "Completion is host-event-driven",
430 } {
431 if !strings.Contains(r.SystemPrompt, want) {
432 t.Fatalf("system prompt missing %q:\n%s", want, r.SystemPrompt)
433 }
434 }
435 for _, unwanted := range []string{"[subagents_list]", "reviewer", "Read-only code reviewer"} {
436 if strings.Contains(r.SystemPrompt, unwanted) {
437 t.Fatalf("system prompt advertises unusable profile %q:\n%s", unwanted, r.SystemPrompt)
438 }
439 }
440 })
441 }
442}
443
444func TestAutoSubagentsSystemAddendumRequiresHostCompletionUpdate(t *testing.T) {
445 for _, want := range []string{
446 "bash sleep",
447 "watch",
448 "tail -f",
449 "polling loops",
450 "repeated \"subagent_status\"",
451 "dashboard, metadata, event-log, or file checks",
452 "unrelated independent tasks",
453 "end or yield your turn",
454 "[auto-subagents update]",
455 "Completion updates are the only completion signal",
456 "user-requested commands, provider flows, extensions, or tests",
457 } {
458 if !strings.Contains(AutoSubagentsSystemAddendum, want) {
459 t.Fatalf("auto-subagents contract missing %q:\n%s", want, AutoSubagentsSystemAddendum)
460 }
461 }
462}
463
464func TestResolveOmitsAutoSubagentsOrchestratorContractWhenDisabled(t *testing.T) {
465 root := t.TempDir()
466 home := filepath.Join(root, "home")
467 zutHome := filepath.Join(root, "zut-home")
468 project := filepath.Join(root, "repo")
469 if err := os.MkdirAll(project, 0o755); err != nil {
470 t.Fatal(err)
471 }
472 t.Setenv("HOME", home)
473 t.Setenv("ZUT_HOME", zutHome)
474 disabled := false
475 if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", AutoSubagentsEnabled: &disabled}); err != nil {
476 t.Fatal(err)
477 }
478
479 r, err := Resolve(Args{CWD: project}, false)
480 if err != nil {
481 t.Fatal(err)
482 }
483 for _, unwanted := range []string{
484 AutoSubagentsSystemAddendum,
485 "primary-agent orchestrator",
486 "[subagents_list]",
487 } {
488 if strings.Contains(r.SystemPrompt, unwanted) {
489 t.Fatalf("disabled system prompt contains auto-subagents contract %q:\n%s", unwanted, r.SystemPrompt)
490 }
491 }
492}
493
494func TestResolveAppliesSelectedSubagentProfile(t *testing.T) {
495 root := t.TempDir()
496 home := filepath.Join(root, "home")
497 zutHome := filepath.Join(root, "zut-home")
498 project := filepath.Join(root, "repo")
499 if err := os.MkdirAll(filepath.Join(home, ".agents", "agents"), 0o755); err != nil {
500 t.Fatal(err)
501 }
502 if err := os.MkdirAll(project, 0o755); err != nil {
503 t.Fatal(err)
504 }
505 t.Setenv("HOME", home)
506 t.Setenv("ZUT_HOME", zutHome)
507 t.Setenv("ZUT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents"))
508 if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", Reasoning: "high"}); err != nil {
509 t.Fatal(err)
510 }
511 if err := os.WriteFile(filepath.Join(zutHome, "AGENTS.md"), []byte("global context"), 0o600); err != nil {
512 t.Fatal(err)
513 }
514 profile := `---
515name: reviewer
516description: Read-only code reviewer
517tools: read
518thinking: off
519systemPromptMode: replace
520inheritProjectContext: false
521inheritSkills: false
522---
523You are a read-only reviewer.
524`
525 if err := os.WriteFile(filepath.Join(home, ".agents", "agents", "reviewer.md"), []byte(profile), 0o600); err != nil {
526 t.Fatal(err)
527 }
528
529 r, err := Resolve(Args{CWD: project, Subagent: "reviewer"}, false)
530 if err != nil {
531 t.Fatal(err)
532 }
533 if r.Reasoning != "" {
534 t.Fatalf("profile thinking=off did not override config reasoning: %q", r.Reasoning)
535 }
536 if _, ok := r.ToolRegistry["read"]; !ok || len(r.ToolRegistry) != 1 {
537 t.Fatalf("profile tools = %#v, want only read", r.ToolRegistry)
538 }
539 if len(r.ContextFiles) != 0 {
540 t.Fatalf("profile inheritProjectContext=false loaded %#v", r.ContextFiles)
541 }
542 if !strings.Contains(r.SystemPrompt, "You are a read-only reviewer.") || strings.Contains(r.SystemPrompt, "global context") {
543 t.Fatalf("profile system prompt inheritance is wrong:\n%s", r.SystemPrompt)
544 }
545 if !strings.Contains(r.SystemPrompt, PonytailSystemAddendum()) {
546 t.Fatalf("replace-mode profile lost global Ponytail addendum:\n%s", r.SystemPrompt)
547 }
548}
549
550func TestResolveSubagentFastModeProfileOverridesHostSetting(t *testing.T) {
551 cases := []struct {
552 name string
553 hostFastMode bool
554 profileFast *bool
555 argFastMode bool
556 argFastSet bool
557 wantFastMode bool
558 }{
559 {name: "unset inherits enabled host", hostFastMode: true, wantFastMode: true},
560 {name: "unset inherits disabled host", hostFastMode: false, wantFastMode: false},
561 {name: "false disables enabled host", hostFastMode: true, profileFast: boolPtr(false), wantFastMode: false},
562 {name: "true enables disabled host", hostFastMode: false, profileFast: boolPtr(true), wantFastMode: true},
563 {name: "false stays disabled with disabled host", hostFastMode: false, profileFast: boolPtr(false), wantFastMode: false},
564 {name: "profile false cannot be bypassed", hostFastMode: true, profileFast: boolPtr(false), argFastMode: true, argFastSet: true, wantFastMode: false},
565 }
566 for _, tc := range cases {
567 t.Run(tc.name, func(t *testing.T) {
568 root := t.TempDir()
569 home := filepath.Join(root, "home")
570 zutHome := filepath.Join(root, "zut-home")
571 project := filepath.Join(root, "repo")
572 profilesDir := filepath.Join(home, ".agents", "agents")
573 if err := os.MkdirAll(profilesDir, 0o755); err != nil {
574 t.Fatal(err)
575 }
576 if err := os.MkdirAll(project, 0o755); err != nil {
577 t.Fatal(err)
578 }
579 t.Setenv("HOME", home)
580 t.Setenv("ZUT_HOME", zutHome)
581 t.Setenv("ZUT_AGENT_PROFILES", profilesDir)
582
583 cfg := Config{Provider: "openai", Model: "gpt-5"}
584 cfg.FastMode = &tc.hostFastMode
585 if err := SaveConfig(cfg); err != nil {
586 t.Fatal(err)
587 }
588 profile := "---\nname: reviewer\n"
589 if tc.profileFast != nil {
590 profile += fmt.Sprintf("fastMode: %t\n", *tc.profileFast)
591 }
592 profile += "---\nReview the requested scope.\n"
593 if err := os.WriteFile(filepath.Join(profilesDir, "reviewer.md"), []byte(profile), 0o600); err != nil {
594 t.Fatal(err)
595 }
596
597 r, err := Resolve(Args{
598 CWD: project,
599 Subagent: "reviewer",
600 FastMode: tc.argFastMode,
601 FastModeSet: tc.argFastSet,
602 }, false)
603 if err != nil {
604 t.Fatal(err)
605 }
606 if r.FastMode != tc.wantFastMode {
607 t.Fatalf("FastMode = %v, want %v", r.FastMode, tc.wantFastMode)
608 }
609 })
610 }
611}
612
613func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) {
614 files := loadAgentsContext(t.TempDir(), t.TempDir())
615 if len(files) != 0 {
616 t.Fatalf("expected no context files, got %#v", files)
617 }
618 if got := formatAgentsContext(files); got != "" {
619 t.Fatalf("expected no context, got %q", got)
620 }
621}
622
623// TestResolveFallsBackWhenConfiguredModelIsGone reproduces the
624// startup failure caught by the user's screenshot: the persisted
625// config.json points at a model id that's no longer in the active
626// catalogue (because they edited models.json or zut's bundled
627// catalogue changed). Resolve must NOT error — strands the user
628// with no way to fix it from the TUI — and should repair the config
629// so the next launch is silent.
630func TestResolveFallsBackWhenConfiguredModelIsGone(t *testing.T) {
631 t.Setenv("ZUT_HOME", t.TempDir())
632 t.Setenv("OPENAI_API_KEY", "test-key")
633 // Persist a stale model id.
634 stale := "gpt-5.5-pro-not-real"
635 if err := SaveConfig(Config{Provider: "openai", Model: stale}); err != nil {
636 t.Fatal(err)
637 }
638
639 r, err := Resolve(Args{}, false)
640 if err != nil {
641 t.Fatalf("Resolve refused to launch with stale model: %v", err)
642 }
643 if r.Model == stale {
644 t.Fatalf("Resolve kept stale model %q", r.Model)
645 }
646 if r.Provider != "openai" {
647 t.Errorf("provider drifted: got %q; want openai", r.Provider)
648 }
649
650 // Config on disk should now hold the fallback so subsequent
651 // launches don't repeat the warning.
652 cfg, _ := LoadConfig()
653 if cfg.Model == stale {
654 t.Errorf("config.json still pins the stale model %q", cfg.Model)
655 }
656 if cfg.Model == "" {
657 t.Errorf("config.json was emptied; expected the fallback model id")
658 }
659}
660
661func TestResolveAppliesJailByDefault(t *testing.T) {
662 for _, tc := range []struct {
663 name string
664 config Config
665 want bool
666 }{
667 {name: "missing defaults to unlocked", config: Config{Provider: "openai", Model: "gpt-5"}},
668 {name: "enabled starts locked", config: Config{Provider: "openai", Model: "gpt-5", JailByDefault: boolPtr(true)}, want: true},
669 } {
670 t.Run(tc.name, func(t *testing.T) {
671 t.Setenv("ZUT_HOME", t.TempDir())
672 t.Setenv("OPENAI_API_KEY", "test-key")
673 if err := SaveConfig(tc.config); err != nil {
674 t.Fatal(err)
675 }
676
677 r, err := Resolve(Args{}, false)
678 if err != nil {
679 t.Fatalf("Resolve failed: %v", err)
680 }
681 if got := r.Sandbox.Locked(); got != tc.want {
682 t.Fatalf("sandbox locked = %v, want %v", got, tc.want)
683 }
684 })
685 }
686}
687
688func boolPtr(v bool) *bool { return &v }
689
690// TestResolveExplicitFlagStaleDoesNotRepairConfig confirms the
691// repair-on-disk happens ONLY when the stale id came from the
692// persisted config. If the user passed --model X explicitly and X is
693// unknown, we still fall back, but we don't touch their config.
694func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) {
695 t.Setenv("ZUT_HOME", t.TempDir())
696 t.Setenv("OPENAI_API_KEY", "test-key")
697 good := "gpt-5"
698 if err := SaveConfig(Config{Provider: "openai", Model: good}); err != nil {
699 t.Fatal(err)
700 }
701
702 r, err := Resolve(Args{Model: "gpt-totally-fake"}, false)
703 if err != nil {
704 t.Fatalf("Resolve errored on unknown --model: %v", err)
705 }
706 if r.Model == "gpt-totally-fake" {
707 t.Errorf("Resolve kept the bogus --model value")
708 }
709 cfg, _ := LoadConfig()
710 if cfg.Model != good {
711 t.Errorf("config.json was clobbered (was %q; now %q)", good, cfg.Model)
712 }
713}
714
715func TestResolveOpenRouterPreservesSavedRoutedModelID(t *testing.T) {
716 t.Setenv("ZUT_HOME", t.TempDir())
717 t.Setenv("OPENROUTER_API_KEY", "test-key")
718 want := "deepseek/deepseek-v4-flash"
719 if err := SaveConfig(Config{Provider: "openrouter", Model: want}); err != nil {
720 t.Fatal(err)
721 }
722
723 r, err := Resolve(Args{}, true)
724 if err != nil {
725 t.Fatalf("Resolve failed: %v", err)
726 }
727 if r.Provider != "openrouter" {
728 t.Fatalf("provider = %q, want openrouter", r.Provider)
729 }
730 if r.Model != want {
731 t.Fatalf("model = %q, want %q", r.Model, want)
732 }
733 if r.MaxOutput != 64000 {
734 t.Fatalf("MaxOutput = %d, want synthetic gateway default 64000", r.MaxOutput)
735 }
736}
737
738func TestResolveGatewayPlainUnknownModelFallsBack(t *testing.T) {
739 t.Setenv("ZUT_HOME", t.TempDir())
740 t.Setenv("OPENROUTER_API_KEY", "test-key")
741 stale := "not-a-routed-model"
742 if err := SaveConfig(Config{Provider: "openrouter", Model: stale}); err != nil {
743 t.Fatal(err)
744 }
745
746 r, err := Resolve(Args{}, true)
747 if err != nil {
748 t.Fatalf("Resolve failed: %v", err)
749 }
750 if r.Provider != "openrouter" {
751 t.Fatalf("provider = %q, want openrouter", r.Provider)
752 }
753 if r.Model == stale || r.Model == "" {
754 t.Fatalf("model = %q, want repaired gateway default", r.Model)
755 }
756}
757
758// TestResolveEnvOnlyBedrockDiscoveredWithoutConfig reproduces issue
759// #15: pointing ZUT_HOME at a fresh dir drops the persisted
760// config.json (which pinned provider=amazon-bedrock). Resolve must
761// still discover bedrock from the AWS env vars instead of falling back
762// to anthropic and reporting "not logged in".
763func TestResolveEnvOnlyBedrockDiscoveredWithoutConfig(t *testing.T) {
764 t.Setenv("ZUT_HOME", t.TempDir()) // fresh home: no config.json
765 // Disable the Kimi CLI token fallback so a developer machine with a
766 // real Kimi CLI login doesn't pre-empt bedrock in the scan.
767 if err := SetKimiCLIFallbackDisabled(true); err != nil {
768 t.Fatal(err)
769 }
770 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "test-bedrock-token")
771 t.Setenv("AWS_REGION", "us-east-1")
772 // Make sure no other provider's env credential pre-empts bedrock.
773 for _, k := range []string{"ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "DEEPSEEK_API_KEY", "KIMI_API_KEY", "MOONSHOT_API_KEY", "XIAOMI_API_KEY", "OPENCODE_API_KEY"} {
774 t.Setenv(k, "")
775 }
776
777 r, err := Resolve(Args{}, true)
778 if err != nil {
779 t.Fatalf("Resolve errored with env-only bedrock: %v", err)
780 }
781 if r.Provider != "amazon-bedrock" {
782 t.Fatalf("provider = %q, want amazon-bedrock", r.Provider)
783 }
784 if !r.HasCredential() {
785 t.Fatalf("bedrock credential not resolved from env")
786 }
787}
788
789func TestResolveOllamaUsesModelBaseURLBeforeDefault(t *testing.T) {
790 t.Setenv("ZUT_HOME", t.TempDir())
791 provider.SetLiveModels(nil)
792 defer provider.SetLiveModels(nil)
793 provider.SetUserModels([]provider.Model{{
794 Provider: "ollama",
795 ID: "qwen-local",
796 DisplayName: "Qwen Local",
797 ContextWindow: 32768,
798 MaxOutput: 8192,
799 BaseURL: "http://localhost:8000/v1",
800 }})
801
802 r, err := Resolve(Args{Provider: "ollama", Model: "qwen-local"}, false)
803 if err != nil {
804 t.Fatalf("Resolve failed: %v", err)
805 }
806 if r.BaseURL != "http://localhost:8000/v1" {
807 t.Fatalf("BaseURL = %q, want models.json baseUrl", r.BaseURL)
808 }
809}
810
811func TestResolveUsesInheritedSupervisorCredential(t *testing.T) {
812 t.Setenv("ZUT_HOME", t.TempDir())
813 t.Setenv("OPENAI_API_KEY", "")
814
815 r, err := Resolve(Args{
816 Provider: "openai",
817 Model: "gpt-5",
818 inheritedCredential: "inherited-key",
819 inheritedAuthMethod: "apikey",
820 inheritedAccountID: "account-id",
821 }, true)
822 if err != nil {
823 t.Fatal(err)
824 }
825 if r.Credential != "inherited-key" || r.AuthMethod != "apikey" || r.AccountID != "account-id" {
826 t.Fatalf("inherited credential was not preserved: %+v", r)
827 }
828}
829
830func TestResolveLlamaCPPUsesRouterInferenceURL(t *testing.T) {
831 t.Setenv("ZUT_HOME", t.TempDir())
832 t.Setenv("LLAMA_BASE_URL", "http://127.0.0.1:8080/v1/")
833 t.Setenv("LLAMA_API_KEY", "")
834 provider.SetManagedModels(nil)
835 t.Cleanup(func() { provider.SetManagedModels(nil) })
836
837 r, err := Resolve(Args{Provider: "llama.cpp", Model: "local-model"}, true)
838 if err != nil {
839 t.Fatalf("Resolve failed: %v", err)
840 }
841 if r.BaseURL != "http://127.0.0.1:8080/v1" {
842 t.Fatalf("BaseURL = %q", r.BaseURL)
843 }
844 if r.Credential != "local" || !r.HasCredential() {
845 t.Fatalf("credential = %q", r.Credential)
846 }
847 if got := r.NewClient().Name(); got != provider.LlamaCPPProviderID {
848 t.Fatalf("client name = %q, want %q", got, provider.LlamaCPPProviderID)
849 }
850}
851
852func TestResolveLlamaCPPUsesStoredLogin(t *testing.T) {
853 t.Setenv("ZUT_HOME", t.TempDir())
854 t.Setenv("LLAMA_BASE_URL", "")
855 t.Setenv("LLAMA_API_KEY", "")
856 if err := AuthStoreFor().SetEndpointCredential("llama.cpp", "http://localhost:9090", "stored-key"); err != nil {
857 t.Fatal(err)
858 }
859
860 r, err := Resolve(Args{Provider: "llama.cpp", Model: "stored-model"}, true)
861 if err != nil {
862 t.Fatalf("Resolve failed: %v", err)
863 }
864 if r.BaseURL != "http://localhost:9090/v1" || r.Credential != "stored-key" {
865 t.Fatalf("resolved = base %q credential %q", r.BaseURL, r.Credential)
866 }
867}
868
869func TestResolveCustomProviderModelBaseURLBeatsProviderBaseURL(t *testing.T) {
870 t.Setenv("ZUT_HOME", t.TempDir())
871 t.Setenv("MY_COMPANY_API_KEY", "test-key")
872 path := filepath.Join(t.TempDir(), "models.json")
873 if err := os.WriteFile(path, []byte(`{
874 "providers": {
875 "my-company": {
876 "baseUrl": "https://provider.example.com/v1",
877 "api": "openai",
878 "models": [
879 {"id": "fast", "baseUrl": "https://model.example.com/v1"}
880 ]
881 }
882 }
883 }`), 0o644); err != nil {
884 t.Fatal(err)
885 }
886 models, warnings := provider.LoadUserModelsWithWarnings(path)
887 if len(warnings) != 0 {
888 t.Fatalf("warnings = %v", warnings)
889 }
890 provider.SetLiveModels(nil)
891 provider.SetUserModels(models)
892 t.Cleanup(func() { provider.SetLiveModels(nil) })
893
894 r, err := Resolve(Args{Provider: "my-company", Model: "fast"}, true)
895 if err != nil {
896 t.Fatalf("Resolve failed: %v", err)
897 }
898 if r.BaseURL != "https://model.example.com/v1" {
899 t.Fatalf("BaseURL = %q, want model-level baseUrl", r.BaseURL)
900 }
901}
902
903func TestCustomProviderUsesOpenAIResponsesAPI(t *testing.T) {
904 requestPath := make(chan string, 1)
905 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
906 requestPath <- r.URL.Path
907 w.Header().Set("Content-Type", "text/event-stream")
908 w.WriteHeader(http.StatusOK)
909 }))
910 t.Cleanup(srv.Close)
911
912 path := filepath.Join(t.TempDir(), "models.json")
913 if err := os.WriteFile(path, []byte(`{
914 "providers": {
915 "company-responses": {
916 "baseUrl": "`+srv.URL+`/v1",
917 "api": "openai-responses",
918 "models": [{"id": "reasoning-model", "reasoning": true}]
919 }
920 }
921 }`), 0o644); err != nil {
922 t.Fatal(err)
923 }
924 models, warnings := provider.LoadUserModelsWithWarnings(path)
925 if len(warnings) != 0 {
926 t.Fatalf("warnings = %v", warnings)
927 }
928 provider.SetLiveModels(nil)
929 provider.SetUserModels(models)
930 t.Cleanup(func() { provider.SetLiveModels(nil) })
931
932 r := Resolved{
933 Provider: "company-responses",
934 Credential: "test-key",
935 BaseURL: srv.URL + "/v1",
936 }
937 events, err := r.NewClient().Stream(context.Background(), provider.Request{
938 Model: "reasoning-model",
939 Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}},
940 })
941 if err != nil {
942 t.Fatal(err)
943 }
944 for range events {
945 }
946 if got := <-requestPath; got != "/v1/responses" {
947 t.Fatalf("request path = %q, want /v1/responses", got)
948 }
949}
950
951func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) {
952 t.Setenv("ZUT_HOME", t.TempDir())
953 t.Setenv("LOCAL_PROXY_API_KEY", "test-key")
954 path := filepath.Join(t.TempDir(), "models.json")
955 if err := os.WriteFile(path, []byte(`{
956 "providers": {
957 "local-proxy": {
958 "baseUrl": "https://proxy.example.com/v1",
959 "api": "openai",
960 "models": [{"id": "default"}]
961 }
962 }
963 }`), 0o644); err != nil {
964 t.Fatal(err)
965 }
966 models, warnings := provider.LoadUserModelsWithWarnings(path)
967 if len(warnings) != 0 {
968 t.Fatalf("warnings = %v", warnings)
969 }
970 provider.SetLiveModels(nil)
971 provider.SetUserModels(models)
972 t.Cleanup(func() { provider.SetLiveModels(nil) })
973
974 r, err := Resolve(Args{Provider: "local-proxy", Model: "default", InsecureTLS: true}, true)
975 if err != nil {
976 t.Fatalf("Resolve failed: %v", err)
977 }
978 if !r.InsecureTLS {
979 t.Fatal("InsecureTLS must be set for --insecure with models.json custom baseUrl")
980 }
981}
982
983func TestResolveOllamaFallsBackToDefaultBaseURL(t *testing.T) {
984 t.Setenv("ZUT_HOME", t.TempDir())
985 provider.SetLiveModels(nil)
986 defer provider.SetLiveModels(nil)
987
988 r, err := Resolve(Args{Provider: "ollama", Model: "any-local-model"}, false)
989 if err != nil {
990 t.Fatalf("Resolve failed: %v", err)
991 }
992 if r.BaseURL != "http://localhost:11434" {
993 t.Fatalf("BaseURL = %q, want ollama default", r.BaseURL)
994 }
995}
996
997func TestCanonicalProviderResolvesAliases(t *testing.T) {
998 cases := map[string]string{
999 "bedrock": "amazon-bedrock",
1000 "AWS-Bedrock": "amazon-bedrock",
1001 " bedrock ": "amazon-bedrock",
1002 "vertex": "google-vertex",
1003 "gemini": "google",
1004 "azure": "azure-openai-responses",
1005 "copilot": "github-copilot",
1006 "codex": "openai-codex",
1007 "moonshot": "moonshotai",
1008 "vercel": "vercel-ai-gateway",
1009 "hf": "huggingface",
1010 "anthropic": "anthropic", // canonical passes through
1011 "amazon-bedrock": "amazon-bedrock", // already canonical
1012 "totally-unknown": "totally-unknown", // unknown returned unchanged (lowered)
1013 "Totally-UNKNOWN": "totally-unknown",
1014 "": "",
1015 }
1016 for in, want := range cases {
1017 if got := canonicalProvider(in); got != want {
1018 t.Errorf("canonicalProvider(%q) = %q, want %q", in, got, want)
1019 }
1020 }
1021}
1022
1023func TestCanonicalProviderAliasesAreKnown(t *testing.T) {
1024 for alias, canon := range providerAliases {
1025 if !isKnownProvider(canon) {
1026 t.Errorf("alias %q maps to %q which is not a known provider", alias, canon)
1027 }
1028 }
1029}
1030
1031func TestResolveInsecureOnlyWithExplicitBaseURL(t *testing.T) {
1032 orig := http.DefaultTransport
1033 t.Cleanup(func() { http.DefaultTransport = orig })
1034
1035 t.Setenv("ZUT_HOME", t.TempDir())
1036 t.Setenv("OPENAI_API_KEY", "test-key")
1037
1038 resolved, err := Resolve(Args{Provider: "moonshotai", InsecureTLS: true}, false)
1039 if err != nil {
1040 t.Fatalf("Resolve: %v", err)
1041 }
1042 if resolved.InsecureTLS {
1043 t.Fatal("InsecureTLS must not be set for built-in provider base URLs")
1044 }
1045 assertDefaultTransportStillSecure(t)
1046
1047 resolved, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false)
1048 if err != nil {
1049 t.Fatalf("Resolve: %v", err)
1050 }
1051 if !resolved.InsecureTLS {
1052 t.Fatal("InsecureTLS must be set with --insecure and explicit --base-url")
1053 }
1054 assertDefaultTransportStillSecure(t)
1055}
1056
1057func TestResolveInsecureFromConfigRequiresExplicitBaseURL(t *testing.T) {
1058 orig := http.DefaultTransport
1059 t.Cleanup(func() { http.DefaultTransport = orig })
1060
1061 t.Setenv("ZUT_HOME", t.TempDir())
1062 t.Setenv("OPENAI_API_KEY", "test-key")
1063 if err := SaveConfig(Config{Provider: "openai", Insecure: true}); err != nil {
1064 t.Fatal(err)
1065 }
1066
1067 resolved, err := Resolve(Args{Provider: "openai"}, false)
1068 if err != nil {
1069 t.Fatalf("Resolve: %v", err)
1070 }
1071 if resolved.InsecureTLS {
1072 t.Fatal("InsecureTLS must not be set without a custom base URL")
1073 }
1074 assertDefaultTransportStillSecure(t)
1075
1076 resolved, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false)
1077 if err != nil {
1078 t.Fatalf("Resolve: %v", err)
1079 }
1080 if !resolved.InsecureTLS {
1081 t.Fatal("InsecureTLS must be set when config insecure=true and --base-url is provided")
1082 }
1083 assertDefaultTransportStillSecure(t)
1084}
1085
1086func TestDefaultXAIModelIsGrok45(t *testing.T) {
1087 if got := defaultModelForProvider("xai"); got != "grok-4.5" {
1088 t.Fatalf("default xAI model = %q, want grok-4.5", got)
1089 }
1090}
1091
1092func assertDefaultTransportStillSecure(t *testing.T) {
1093 t.Helper()
1094 tr, ok := http.DefaultTransport.(*http.Transport)
1095 if !ok {
1096 return
1097 }
1098 if tr.TLSClientConfig != nil && tr.TLSClientConfig.InsecureSkipVerify {
1099 t.Fatal("http.DefaultTransport must not be made insecure")
1100 }
1101}
1102
1103func TestResolveWebSearchRegistryRespectsConfigAndExplicitCLI(t *testing.T) {
1104 t.Setenv("ZUT_HOME", t.TempDir())
1105 disabled := false
1106 if err := SaveConfig(Config{WebSearchEnabled: &disabled}); err != nil {
1107 t.Fatal(err)
1108 }
1109 args := Args{CWD: t.TempDir(), Provider: "ollama", Model: "any-local-model"}
1110 resolved, err := Resolve(args, false)
1111 if err != nil {
1112 t.Fatal(err)
1113 }
1114 if _, ok := resolved.ToolRegistry["web_search"]; ok {
1115 t.Fatal("persisted opt-out left web_search in the registry")
1116 }
1117
1118 args.ToolsSet = true
1119 args.Tools = []string{"web_search"}
1120 resolved, err = Resolve(args, false)
1121 if err != nil {
1122 t.Fatal(err)
1123 }
1124 if _, ok := resolved.ToolRegistry["web_search"]; !ok {
1125 t.Fatal("explicit --tools web_search did not override persisted opt-out")
1126 }
1127
1128 args.PermissionSet = &tools.PermissionSet{}
1129 resolved, err = Resolve(args, false)
1130 if err != nil {
1131 t.Fatal(err)
1132 }
1133 if _, ok := resolved.ToolRegistry["web_search"]; ok {
1134 t.Fatal("packaged-agent PermissionSet received web_search")
1135 }
1136}
1137
1138func TestResolveWebSearchPolicy(t *testing.T) {
1139 disabled := false
1140 cases := []struct {
1141 name string
1142 args Args
1143 cfg Config
1144 cfgErr error
1145 profile *subagents.Profile
1146 want subagents.WebSearchPolicy
1147 }{
1148 {name: "legacy config", want: subagents.WebSearchAllow},
1149 {name: "persisted opt out", cfg: Config{WebSearchEnabled: &disabled}, want: subagents.WebSearchDeny},
1150 {name: "explicit CLI allow overrides opt out", args: Args{ToolsSet: true, Tools: []string{"web_search"}}, cfg: Config{WebSearchEnabled: &disabled}, want: subagents.WebSearchAllow},
1151 {name: "explicit empty CLI list denies", args: Args{ToolsSet: true}, want: subagents.WebSearchDeny},
1152 {name: "explicit nonmatching CLI list denies", args: Args{ToolsSet: true, Tools: []string{"read"}}, want: subagents.WebSearchDeny},
1153 {name: "no tools denies", args: Args{NoTools: true}, want: subagents.WebSearchDeny},
1154 {name: "packaged agent denies", args: Args{PermissionSet: &tools.PermissionSet{}}, want: subagents.WebSearchDeny},
1155 {name: "internal policy denies", args: Args{WebSearchPolicy: subagents.WebSearchDeny}, want: subagents.WebSearchDeny},
1156 {name: "internal policy allows", args: Args{WebSearchPolicy: subagents.WebSearchAllow}, cfg: Config{WebSearchEnabled: &disabled}, want: subagents.WebSearchAllow},
1157 {name: "normal explicit empty list narrows internal allow", args: Args{ToolsSet: true, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1158 {name: "worker missing policy denies despite legacy config", args: Args{Mode: ModeSubagentWorker}, want: subagents.WebSearchDeny},
1159 {name: "worker explicit inherit denies", args: Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchInherit}, want: subagents.WebSearchDeny},
1160 {name: "worker invalid policy denies", args: Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchPolicy(99)}, want: subagents.WebSearchDeny},
1161 {name: "worker propagated deny remains denied", args: Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchDeny}, want: subagents.WebSearchDeny},
1162 {name: "worker propagated allow", args: Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchAllow}, cfg: Config{WebSearchEnabled: &disabled}, want: subagents.WebSearchAllow},
1163 {name: "worker explicit empty list caps allow", args: Args{Mode: ModeSubagentWorker, ToolsSet: true, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1164 {name: "worker nonmatching list caps allow", args: Args{Mode: ModeSubagentWorker, ToolsSet: true, Tools: []string{"read"}, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1165 {name: "worker matching list preserves allow", args: Args{Mode: ModeSubagentWorker, ToolsSet: true, Tools: []string{"read", "web_search"}, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchAllow},
1166 {name: "worker no tools caps allow", args: Args{Mode: ModeSubagentWorker, NoTools: true, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1167 {name: "worker permission set caps allow", args: Args{Mode: ModeSubagentWorker, PermissionSet: &tools.PermissionSet{}, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1168 {name: "config load failure denies inherited policy", cfgErr: fmt.Errorf("broken config"), want: subagents.WebSearchDeny},
1169 {name: "named profile requires explicit tool", args: Args{WebSearchPolicy: subagents.WebSearchAllow}, profile: &subagents.Profile{Tools: []string{"read"}}, want: subagents.WebSearchDeny},
1170 {name: "named profile allows explicit tool", args: Args{WebSearchPolicy: subagents.WebSearchAllow}, profile: &subagents.Profile{Tools: []string{"read", "web_search"}}, want: subagents.WebSearchAllow},
1171 {name: "named profile cannot rescue worker inherit", args: Args{Mode: ModeSubagentWorker}, profile: &subagents.Profile{Tools: []string{"web_search"}}, want: subagents.WebSearchDeny},
1172 {name: "named profile cannot bypass worker tool list", args: Args{Mode: ModeSubagentWorker, ToolsSet: true, Tools: []string{"read"}, WebSearchPolicy: subagents.WebSearchAllow}, profile: &subagents.Profile{Tools: []string{"web_search"}}, want: subagents.WebSearchDeny},
1173 }
1174 for _, tc := range cases {
1175 t.Run(tc.name, func(t *testing.T) {
1176 if got := resolveWebSearchPolicy(tc.args, tc.cfg, tc.cfgErr, tc.profile); got != tc.want {
1177 t.Fatalf("policy = %v, want %v", got, tc.want)
1178 }
1179 })
1180 }
1181}