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