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 for _, required := range []string{
505 "user asks you to delegate",
506 "active skill workflow requires delegation",
507 } {
508 if !strings.Contains(r.SystemPrompt, required) {
509 t.Fatalf("disabled system prompt omits permitted delegation path %q:\n%s", required, r.SystemPrompt)
510 }
511 }
512}
513
514func TestResolveAppliesSelectedSubagentProfile(t *testing.T) {
515 root := t.TempDir()
516 home := filepath.Join(root, "home")
517 zutHome := filepath.Join(root, "zut-home")
518 project := filepath.Join(root, "repo")
519 if err := os.MkdirAll(filepath.Join(home, ".agents", "agents"), 0o755); err != nil {
520 t.Fatal(err)
521 }
522 if err := os.MkdirAll(project, 0o755); err != nil {
523 t.Fatal(err)
524 }
525 t.Setenv("HOME", home)
526 t.Setenv("ZUT_HOME", zutHome)
527 t.Setenv("ZUT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents"))
528 if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", Reasoning: "high"}); err != nil {
529 t.Fatal(err)
530 }
531 if err := os.WriteFile(filepath.Join(zutHome, "AGENTS.md"), []byte("global context"), 0o600); err != nil {
532 t.Fatal(err)
533 }
534 profile := `---
535name: reviewer
536description: Read-only code reviewer
537tools: read
538thinking: off
539systemPromptMode: replace
540inheritProjectContext: false
541inheritSkills: false
542---
543You are a read-only reviewer.
544`
545 if err := os.WriteFile(filepath.Join(home, ".agents", "agents", "reviewer.md"), []byte(profile), 0o600); err != nil {
546 t.Fatal(err)
547 }
548
549 r, err := Resolve(Args{CWD: project, Subagent: "reviewer"}, false)
550 if err != nil {
551 t.Fatal(err)
552 }
553 if r.Reasoning != "" {
554 t.Fatalf("profile thinking=off did not override config reasoning: %q", r.Reasoning)
555 }
556 if _, ok := r.ToolRegistry["read"]; !ok || len(r.ToolRegistry) != 1 {
557 t.Fatalf("profile tools = %#v, want only read", r.ToolRegistry)
558 }
559 if len(r.ContextFiles) != 0 {
560 t.Fatalf("profile inheritProjectContext=false loaded %#v", r.ContextFiles)
561 }
562 if !strings.Contains(r.SystemPrompt, "You are a read-only reviewer.") || strings.Contains(r.SystemPrompt, "global context") {
563 t.Fatalf("profile system prompt inheritance is wrong:\n%s", r.SystemPrompt)
564 }
565 if !strings.Contains(r.SystemPrompt, PonytailSystemAddendum()) {
566 t.Fatalf("replace-mode profile lost global Ponytail addendum:\n%s", r.SystemPrompt)
567 }
568}
569
570func TestResolveSubagentFastModeProfileOverridesHostSetting(t *testing.T) {
571 cases := []struct {
572 name string
573 hostFastMode bool
574 profileFast *bool
575 argFastMode bool
576 argFastSet bool
577 wantFastMode bool
578 }{
579 {name: "unset inherits enabled host", hostFastMode: true, wantFastMode: true},
580 {name: "unset inherits disabled host", hostFastMode: false, wantFastMode: false},
581 {name: "false disables enabled host", hostFastMode: true, profileFast: boolPtr(false), wantFastMode: false},
582 {name: "true enables disabled host", hostFastMode: false, profileFast: boolPtr(true), wantFastMode: true},
583 {name: "false stays disabled with disabled host", hostFastMode: false, profileFast: boolPtr(false), wantFastMode: false},
584 {name: "profile false cannot be bypassed", hostFastMode: true, profileFast: boolPtr(false), argFastMode: true, argFastSet: true, wantFastMode: false},
585 }
586 for _, tc := range cases {
587 t.Run(tc.name, func(t *testing.T) {
588 root := t.TempDir()
589 home := filepath.Join(root, "home")
590 zutHome := filepath.Join(root, "zut-home")
591 project := filepath.Join(root, "repo")
592 profilesDir := filepath.Join(home, ".agents", "agents")
593 if err := os.MkdirAll(profilesDir, 0o755); err != nil {
594 t.Fatal(err)
595 }
596 if err := os.MkdirAll(project, 0o755); err != nil {
597 t.Fatal(err)
598 }
599 t.Setenv("HOME", home)
600 t.Setenv("ZUT_HOME", zutHome)
601 t.Setenv("ZUT_AGENT_PROFILES", profilesDir)
602
603 cfg := Config{Provider: "openai", Model: "gpt-5"}
604 cfg.FastMode = &tc.hostFastMode
605 if err := SaveConfig(cfg); err != nil {
606 t.Fatal(err)
607 }
608 profile := "---\nname: reviewer\n"
609 if tc.profileFast != nil {
610 profile += fmt.Sprintf("fastMode: %t\n", *tc.profileFast)
611 }
612 profile += "---\nReview the requested scope.\n"
613 if err := os.WriteFile(filepath.Join(profilesDir, "reviewer.md"), []byte(profile), 0o600); err != nil {
614 t.Fatal(err)
615 }
616
617 r, err := Resolve(Args{
618 CWD: project,
619 Subagent: "reviewer",
620 FastMode: tc.argFastMode,
621 FastModeSet: tc.argFastSet,
622 }, false)
623 if err != nil {
624 t.Fatal(err)
625 }
626 if r.FastMode != tc.wantFastMode {
627 t.Fatalf("FastMode = %v, want %v", r.FastMode, tc.wantFastMode)
628 }
629 })
630 }
631}
632
633func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) {
634 files := loadAgentsContext(t.TempDir(), t.TempDir())
635 if len(files) != 0 {
636 t.Fatalf("expected no context files, got %#v", files)
637 }
638 if got := formatAgentsContext(files); got != "" {
639 t.Fatalf("expected no context, got %q", got)
640 }
641}
642
643// TestResolveFallsBackWhenConfiguredModelIsGone reproduces the
644// startup failure caught by the user's screenshot: the persisted
645// config.json points at a model id that's no longer in the active
646// catalogue (because they edited models.json or zut's bundled
647// catalogue changed). Resolve must NOT error — strands the user
648// with no way to fix it from the TUI — and should repair the config
649// so the next launch is silent.
650func TestResolveFallsBackWhenConfiguredModelIsGone(t *testing.T) {
651 t.Setenv("ZUT_HOME", t.TempDir())
652 t.Setenv("OPENAI_API_KEY", "test-key")
653 // Persist a stale model id.
654 stale := "gpt-5.5-pro-not-real"
655 if err := SaveConfig(Config{Provider: "openai", Model: stale}); err != nil {
656 t.Fatal(err)
657 }
658
659 r, err := Resolve(Args{}, false)
660 if err != nil {
661 t.Fatalf("Resolve refused to launch with stale model: %v", err)
662 }
663 if r.Model == stale {
664 t.Fatalf("Resolve kept stale model %q", r.Model)
665 }
666 if r.Provider != "openai" {
667 t.Errorf("provider drifted: got %q; want openai", r.Provider)
668 }
669
670 // Config on disk should now hold the fallback so subsequent
671 // launches don't repeat the warning.
672 cfg, _ := LoadConfig()
673 if cfg.Model == stale {
674 t.Errorf("config.json still pins the stale model %q", cfg.Model)
675 }
676 if cfg.Model == "" {
677 t.Errorf("config.json was emptied; expected the fallback model id")
678 }
679}
680
681func TestResolveAppliesJailByDefault(t *testing.T) {
682 for _, tc := range []struct {
683 name string
684 config Config
685 want bool
686 }{
687 {name: "missing defaults to unlocked", config: Config{Provider: "openai", Model: "gpt-5"}},
688 {name: "enabled starts locked", config: Config{Provider: "openai", Model: "gpt-5", JailByDefault: boolPtr(true)}, want: true},
689 } {
690 t.Run(tc.name, func(t *testing.T) {
691 t.Setenv("ZUT_HOME", t.TempDir())
692 t.Setenv("OPENAI_API_KEY", "test-key")
693 if err := SaveConfig(tc.config); err != nil {
694 t.Fatal(err)
695 }
696
697 r, err := Resolve(Args{}, false)
698 if err != nil {
699 t.Fatalf("Resolve failed: %v", err)
700 }
701 if got := r.Sandbox.Locked(); got != tc.want {
702 t.Fatalf("sandbox locked = %v, want %v", got, tc.want)
703 }
704 })
705 }
706}
707
708func boolPtr(v bool) *bool { return &v }
709
710// TestResolveExplicitFlagStaleDoesNotRepairConfig confirms the
711// repair-on-disk happens ONLY when the stale id came from the
712// persisted config. If the user passed --model X explicitly and X is
713// unknown, we still fall back, but we don't touch their config.
714func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) {
715 t.Setenv("ZUT_HOME", t.TempDir())
716 t.Setenv("OPENAI_API_KEY", "test-key")
717 good := "gpt-5"
718 if err := SaveConfig(Config{Provider: "openai", Model: good}); err != nil {
719 t.Fatal(err)
720 }
721
722 r, err := Resolve(Args{Model: "gpt-totally-fake"}, false)
723 if err != nil {
724 t.Fatalf("Resolve errored on unknown --model: %v", err)
725 }
726 if r.Model == "gpt-totally-fake" {
727 t.Errorf("Resolve kept the bogus --model value")
728 }
729 cfg, _ := LoadConfig()
730 if cfg.Model != good {
731 t.Errorf("config.json was clobbered (was %q; now %q)", good, cfg.Model)
732 }
733}
734
735func TestResolveOpenRouterPreservesSavedRoutedModelID(t *testing.T) {
736 t.Setenv("ZUT_HOME", t.TempDir())
737 t.Setenv("OPENROUTER_API_KEY", "test-key")
738 want := "deepseek/deepseek-v4-flash"
739 if err := SaveConfig(Config{Provider: "openrouter", Model: want}); err != nil {
740 t.Fatal(err)
741 }
742
743 r, err := Resolve(Args{}, true)
744 if err != nil {
745 t.Fatalf("Resolve failed: %v", err)
746 }
747 if r.Provider != "openrouter" {
748 t.Fatalf("provider = %q, want openrouter", r.Provider)
749 }
750 if r.Model != want {
751 t.Fatalf("model = %q, want %q", r.Model, want)
752 }
753 if r.MaxOutput != 64000 {
754 t.Fatalf("MaxOutput = %d, want synthetic gateway default 64000", r.MaxOutput)
755 }
756 if r.ContextWindow != 1_000_000 || r.NewAgent().ContextWindow != 1_000_000 {
757 t.Fatalf("resolved/agent context window = %d/%d, want synthetic gateway metadata", r.ContextWindow, r.NewAgent().ContextWindow)
758 }
759}
760
761func TestResolveGatewayPlainUnknownModelFallsBack(t *testing.T) {
762 t.Setenv("ZUT_HOME", t.TempDir())
763 t.Setenv("OPENROUTER_API_KEY", "test-key")
764 stale := "not-a-routed-model"
765 if err := SaveConfig(Config{Provider: "openrouter", Model: stale}); err != nil {
766 t.Fatal(err)
767 }
768
769 r, err := Resolve(Args{}, true)
770 if err != nil {
771 t.Fatalf("Resolve failed: %v", err)
772 }
773 if r.Provider != "openrouter" {
774 t.Fatalf("provider = %q, want openrouter", r.Provider)
775 }
776 if r.Model == stale || r.Model == "" {
777 t.Fatalf("model = %q, want repaired gateway default", r.Model)
778 }
779}
780
781// TestResolveEnvOnlyBedrockDiscoveredWithoutConfig reproduces issue
782// #15: pointing ZUT_HOME at a fresh dir drops the persisted
783// config.json (which pinned provider=amazon-bedrock). Resolve must
784// still discover bedrock from the AWS env vars instead of falling back
785// to anthropic and reporting "not logged in".
786func TestResolveEnvOnlyBedrockDiscoveredWithoutConfig(t *testing.T) {
787 t.Setenv("ZUT_HOME", t.TempDir()) // fresh home: no config.json
788 // Disable the Kimi CLI token fallback so a developer machine with a
789 // real Kimi CLI login doesn't pre-empt bedrock in the scan.
790 if err := SetKimiCLIFallbackDisabled(true); err != nil {
791 t.Fatal(err)
792 }
793 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "test-bedrock-token")
794 t.Setenv("AWS_REGION", "us-east-1")
795 // Make sure no other provider's env credential pre-empts bedrock.
796 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"} {
797 t.Setenv(k, "")
798 }
799
800 r, err := Resolve(Args{}, true)
801 if err != nil {
802 t.Fatalf("Resolve errored with env-only bedrock: %v", err)
803 }
804 if r.Provider != "amazon-bedrock" {
805 t.Fatalf("provider = %q, want amazon-bedrock", r.Provider)
806 }
807 if !r.HasCredential() {
808 t.Fatalf("bedrock credential not resolved from env")
809 }
810}
811
812func TestResolveOllamaUsesModelBaseURLBeforeDefault(t *testing.T) {
813 t.Setenv("ZUT_HOME", t.TempDir())
814 provider.SetLiveModels(nil)
815 defer provider.SetLiveModels(nil)
816 provider.SetUserModels([]provider.Model{{
817 Provider: "ollama",
818 ID: "qwen-local",
819 DisplayName: "Qwen Local",
820 ContextWindow: 32768,
821 MaxOutput: 8192,
822 BaseURL: "http://localhost:8000/v1",
823 }})
824
825 r, err := Resolve(Args{Provider: "ollama", Model: "qwen-local"}, false)
826 if err != nil {
827 t.Fatalf("Resolve failed: %v", err)
828 }
829 if r.BaseURL != "http://localhost:8000/v1" {
830 t.Fatalf("BaseURL = %q, want models.json baseUrl", r.BaseURL)
831 }
832}
833
834func TestResolveUsesInheritedSupervisorCredential(t *testing.T) {
835 t.Setenv("ZUT_HOME", t.TempDir())
836 t.Setenv("OPENAI_API_KEY", "")
837
838 r, err := Resolve(Args{
839 Provider: "openai",
840 Model: "gpt-5",
841 inheritedCredential: "inherited-key",
842 inheritedAuthMethod: "apikey",
843 inheritedAccountID: "account-id",
844 }, true)
845 if err != nil {
846 t.Fatal(err)
847 }
848 if r.Credential != "inherited-key" || r.AuthMethod != "apikey" || r.AccountID != "account-id" {
849 t.Fatalf("inherited credential was not preserved: %+v", r)
850 }
851}
852
853func TestResolveLlamaCPPUsesRouterInferenceURL(t *testing.T) {
854 t.Setenv("ZUT_HOME", t.TempDir())
855 t.Setenv("LLAMA_BASE_URL", "http://127.0.0.1:8080/v1/")
856 t.Setenv("LLAMA_API_KEY", "")
857 provider.SetManagedModels(nil)
858 t.Cleanup(func() { provider.SetManagedModels(nil) })
859
860 r, err := Resolve(Args{Provider: "llama.cpp", Model: "local-model"}, true)
861 if err != nil {
862 t.Fatalf("Resolve failed: %v", err)
863 }
864 if r.BaseURL != "http://127.0.0.1:8080/v1" {
865 t.Fatalf("BaseURL = %q", r.BaseURL)
866 }
867 if r.Credential != "local" || !r.HasCredential() {
868 t.Fatalf("credential = %q", r.Credential)
869 }
870 if r.ContextWindow != 128000 || r.NewAgent().ContextWindow != 128000 {
871 t.Fatalf("resolved/agent context window = %d/%d, want synthesized local metadata", r.ContextWindow, r.NewAgent().ContextWindow)
872 }
873 if got := r.NewClient().Name(); got != provider.LlamaCPPProviderID {
874 t.Fatalf("client name = %q, want %q", got, provider.LlamaCPPProviderID)
875 }
876}
877
878func TestResolveLlamaCPPUsesStoredLogin(t *testing.T) {
879 t.Setenv("ZUT_HOME", t.TempDir())
880 t.Setenv("LLAMA_BASE_URL", "")
881 t.Setenv("LLAMA_API_KEY", "")
882 if err := AuthStoreFor().SetEndpointCredential("llama.cpp", "http://localhost:9090", "stored-key"); err != nil {
883 t.Fatal(err)
884 }
885
886 r, err := Resolve(Args{Provider: "llama.cpp", Model: "stored-model"}, true)
887 if err != nil {
888 t.Fatalf("Resolve failed: %v", err)
889 }
890 if r.BaseURL != "http://localhost:9090/v1" || r.Credential != "stored-key" {
891 t.Fatalf("resolved = base %q credential %q", r.BaseURL, r.Credential)
892 }
893}
894
895func TestResolveCustomProviderModelBaseURLBeatsProviderBaseURL(t *testing.T) {
896 t.Setenv("ZUT_HOME", t.TempDir())
897 t.Setenv("MY_COMPANY_API_KEY", "test-key")
898 path := filepath.Join(t.TempDir(), "models.json")
899 if err := os.WriteFile(path, []byte(`{
900 "providers": {
901 "my-company": {
902 "baseUrl": "https://provider.example.com/v1",
903 "api": "openai",
904 "models": [
905 {"id": "fast", "baseUrl": "https://model.example.com/v1", "contextWindow": 65536}
906 ]
907 }
908 }
909 }`), 0o644); err != nil {
910 t.Fatal(err)
911 }
912 models, warnings := provider.LoadUserModelsWithWarnings(path)
913 if len(warnings) != 0 {
914 t.Fatalf("warnings = %v", warnings)
915 }
916 provider.SetLiveModels(nil)
917 provider.SetUserModels(models)
918 t.Cleanup(func() { provider.SetLiveModels(nil) })
919
920 r, err := Resolve(Args{Provider: "my-company", Model: "fast"}, true)
921 if err != nil {
922 t.Fatalf("Resolve failed: %v", err)
923 }
924 if r.BaseURL != "https://model.example.com/v1" {
925 t.Fatalf("BaseURL = %q, want model-level baseUrl", r.BaseURL)
926 }
927 if r.ContextWindow != 65536 || r.NewAgent().ContextWindow != 65536 {
928 t.Fatalf("resolved/agent context window = %d/%d, want retained user-model metadata", r.ContextWindow, r.NewAgent().ContextWindow)
929 }
930}
931
932func TestCustomProviderUsesOpenAIResponsesAPI(t *testing.T) {
933 requestPath := make(chan string, 1)
934 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
935 requestPath <- r.URL.Path
936 w.Header().Set("Content-Type", "text/event-stream")
937 w.WriteHeader(http.StatusOK)
938 }))
939 t.Cleanup(srv.Close)
940
941 path := filepath.Join(t.TempDir(), "models.json")
942 if err := os.WriteFile(path, []byte(`{
943 "providers": {
944 "company-responses": {
945 "baseUrl": "`+srv.URL+`/v1",
946 "api": "openai-responses",
947 "models": [{"id": "reasoning-model", "reasoning": true}]
948 }
949 }
950 }`), 0o644); err != nil {
951 t.Fatal(err)
952 }
953 models, warnings := provider.LoadUserModelsWithWarnings(path)
954 if len(warnings) != 0 {
955 t.Fatalf("warnings = %v", warnings)
956 }
957 provider.SetLiveModels(nil)
958 provider.SetUserModels(models)
959 t.Cleanup(func() { provider.SetLiveModels(nil) })
960
961 r := Resolved{
962 Provider: "company-responses",
963 Credential: "test-key",
964 BaseURL: srv.URL + "/v1",
965 }
966 events, err := r.NewClient().Stream(context.Background(), provider.Request{
967 Model: "reasoning-model",
968 Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}},
969 })
970 if err != nil {
971 t.Fatal(err)
972 }
973 for range events {
974 }
975 if got := <-requestPath; got != "/v1/responses" {
976 t.Fatalf("request path = %q, want /v1/responses", got)
977 }
978}
979
980func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) {
981 t.Setenv("ZUT_HOME", t.TempDir())
982 t.Setenv("LOCAL_PROXY_API_KEY", "test-key")
983 path := filepath.Join(t.TempDir(), "models.json")
984 if err := os.WriteFile(path, []byte(`{
985 "providers": {
986 "local-proxy": {
987 "baseUrl": "https://proxy.example.com/v1",
988 "api": "openai",
989 "models": [{"id": "default"}]
990 }
991 }
992 }`), 0o644); err != nil {
993 t.Fatal(err)
994 }
995 models, warnings := provider.LoadUserModelsWithWarnings(path)
996 if len(warnings) != 0 {
997 t.Fatalf("warnings = %v", warnings)
998 }
999 provider.SetLiveModels(nil)
1000 provider.SetUserModels(models)
1001 t.Cleanup(func() { provider.SetLiveModels(nil) })
1002
1003 r, err := Resolve(Args{Provider: "local-proxy", Model: "default", InsecureTLS: true}, true)
1004 if err != nil {
1005 t.Fatalf("Resolve failed: %v", err)
1006 }
1007 if !r.InsecureTLS {
1008 t.Fatal("InsecureTLS must be set for --insecure with models.json custom baseUrl")
1009 }
1010}
1011
1012func TestResolveOllamaFallsBackToDefaultBaseURL(t *testing.T) {
1013 t.Setenv("ZUT_HOME", t.TempDir())
1014 provider.SetLiveModels(nil)
1015 defer provider.SetLiveModels(nil)
1016
1017 r, err := Resolve(Args{Provider: "ollama", Model: "any-local-model"}, false)
1018 if err != nil {
1019 t.Fatalf("Resolve failed: %v", err)
1020 }
1021 if r.BaseURL != "http://localhost:11434" {
1022 t.Fatalf("BaseURL = %q, want ollama default", r.BaseURL)
1023 }
1024}
1025
1026func TestCanonicalProviderResolvesAliases(t *testing.T) {
1027 cases := map[string]string{
1028 "bedrock": "amazon-bedrock",
1029 "AWS-Bedrock": "amazon-bedrock",
1030 " bedrock ": "amazon-bedrock",
1031 "vertex": "google-vertex",
1032 "gemini": "google",
1033 "azure": "azure-openai-responses",
1034 "copilot": "github-copilot",
1035 "codex": "openai-codex",
1036 "moonshot": "moonshotai",
1037 "vercel": "vercel-ai-gateway",
1038 "hf": "huggingface",
1039 "anthropic": "anthropic", // canonical passes through
1040 "amazon-bedrock": "amazon-bedrock", // already canonical
1041 "totally-unknown": "totally-unknown", // unknown returned unchanged (lowered)
1042 "Totally-UNKNOWN": "totally-unknown",
1043 "": "",
1044 }
1045 for in, want := range cases {
1046 if got := canonicalProvider(in); got != want {
1047 t.Errorf("canonicalProvider(%q) = %q, want %q", in, got, want)
1048 }
1049 }
1050}
1051
1052func TestCanonicalProviderAliasesAreKnown(t *testing.T) {
1053 for alias, canon := range providerAliases {
1054 if !isKnownProvider(canon) {
1055 t.Errorf("alias %q maps to %q which is not a known provider", alias, canon)
1056 }
1057 }
1058}
1059
1060func TestResolveInsecureOnlyWithExplicitBaseURL(t *testing.T) {
1061 orig := http.DefaultTransport
1062 t.Cleanup(func() { http.DefaultTransport = orig })
1063
1064 t.Setenv("ZUT_HOME", t.TempDir())
1065 t.Setenv("OPENAI_API_KEY", "test-key")
1066
1067 resolved, err := Resolve(Args{Provider: "moonshotai", InsecureTLS: true}, false)
1068 if err != nil {
1069 t.Fatalf("Resolve: %v", err)
1070 }
1071 if resolved.InsecureTLS {
1072 t.Fatal("InsecureTLS must not be set for built-in provider base URLs")
1073 }
1074 assertDefaultTransportStillSecure(t)
1075
1076 resolved, err = Resolve(Args{Provider: "openai", InsecureTLS: true, 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 with --insecure and explicit --base-url")
1082 }
1083 assertDefaultTransportStillSecure(t)
1084}
1085
1086func TestResolveInsecureFromConfigRequiresExplicitBaseURL(t *testing.T) {
1087 orig := http.DefaultTransport
1088 t.Cleanup(func() { http.DefaultTransport = orig })
1089
1090 t.Setenv("ZUT_HOME", t.TempDir())
1091 t.Setenv("OPENAI_API_KEY", "test-key")
1092 if err := SaveConfig(Config{Provider: "openai", Insecure: true}); err != nil {
1093 t.Fatal(err)
1094 }
1095
1096 resolved, err := Resolve(Args{Provider: "openai"}, false)
1097 if err != nil {
1098 t.Fatalf("Resolve: %v", err)
1099 }
1100 if resolved.InsecureTLS {
1101 t.Fatal("InsecureTLS must not be set without a custom base URL")
1102 }
1103 assertDefaultTransportStillSecure(t)
1104
1105 resolved, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false)
1106 if err != nil {
1107 t.Fatalf("Resolve: %v", err)
1108 }
1109 if !resolved.InsecureTLS {
1110 t.Fatal("InsecureTLS must be set when config insecure=true and --base-url is provided")
1111 }
1112 assertDefaultTransportStillSecure(t)
1113}
1114
1115func TestDefaultXAIModelIsGrok45(t *testing.T) {
1116 if got := defaultModelForProvider("xai"); got != "grok-4.5" {
1117 t.Fatalf("default xAI model = %q, want grok-4.5", got)
1118 }
1119}
1120
1121func assertDefaultTransportStillSecure(t *testing.T) {
1122 t.Helper()
1123 tr, ok := http.DefaultTransport.(*http.Transport)
1124 if !ok {
1125 return
1126 }
1127 if tr.TLSClientConfig != nil && tr.TLSClientConfig.InsecureSkipVerify {
1128 t.Fatal("http.DefaultTransport must not be made insecure")
1129 }
1130}
1131
1132func TestResolveWebSearchRegistryRespectsConfigAndExplicitCLI(t *testing.T) {
1133 t.Setenv("ZUT_HOME", t.TempDir())
1134 disabled := false
1135 if err := SaveConfig(Config{WebSearchEnabled: &disabled}); err != nil {
1136 t.Fatal(err)
1137 }
1138 args := Args{CWD: t.TempDir(), Provider: "ollama", Model: "any-local-model"}
1139 resolved, err := Resolve(args, false)
1140 if err != nil {
1141 t.Fatal(err)
1142 }
1143 if _, ok := resolved.ToolRegistry["web_search"]; ok {
1144 t.Fatal("persisted opt-out left web_search in the registry")
1145 }
1146
1147 args.ToolsSet = true
1148 args.Tools = []string{"web_search"}
1149 resolved, err = Resolve(args, false)
1150 if err != nil {
1151 t.Fatal(err)
1152 }
1153 if _, ok := resolved.ToolRegistry["web_search"]; !ok {
1154 t.Fatal("explicit --tools web_search did not override persisted opt-out")
1155 }
1156
1157 args.PermissionSet = &tools.PermissionSet{}
1158 resolved, err = Resolve(args, false)
1159 if err != nil {
1160 t.Fatal(err)
1161 }
1162 if _, ok := resolved.ToolRegistry["web_search"]; ok {
1163 t.Fatal("packaged-agent PermissionSet received web_search")
1164 }
1165}
1166
1167func TestResolveWebSearchPolicy(t *testing.T) {
1168 disabled := false
1169 cases := []struct {
1170 name string
1171 args Args
1172 cfg Config
1173 cfgErr error
1174 profile *subagents.Profile
1175 want subagents.WebSearchPolicy
1176 }{
1177 {name: "legacy config", want: subagents.WebSearchAllow},
1178 {name: "persisted opt out", cfg: Config{WebSearchEnabled: &disabled}, want: subagents.WebSearchDeny},
1179 {name: "explicit CLI allow overrides opt out", args: Args{ToolsSet: true, Tools: []string{"web_search"}}, cfg: Config{WebSearchEnabled: &disabled}, want: subagents.WebSearchAllow},
1180 {name: "explicit empty CLI list denies", args: Args{ToolsSet: true}, want: subagents.WebSearchDeny},
1181 {name: "explicit nonmatching CLI list denies", args: Args{ToolsSet: true, Tools: []string{"read"}}, want: subagents.WebSearchDeny},
1182 {name: "no tools denies", args: Args{NoTools: true}, want: subagents.WebSearchDeny},
1183 {name: "packaged agent denies", args: Args{PermissionSet: &tools.PermissionSet{}}, want: subagents.WebSearchDeny},
1184 {name: "internal policy denies", args: Args{WebSearchPolicy: subagents.WebSearchDeny}, want: subagents.WebSearchDeny},
1185 {name: "internal policy allows", args: Args{WebSearchPolicy: subagents.WebSearchAllow}, cfg: Config{WebSearchEnabled: &disabled}, want: subagents.WebSearchAllow},
1186 {name: "normal explicit empty list narrows internal allow", args: Args{ToolsSet: true, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1187 {name: "worker missing policy denies despite legacy config", args: Args{Mode: ModeSubagentWorker}, want: subagents.WebSearchDeny},
1188 {name: "worker explicit inherit denies", args: Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchInherit}, want: subagents.WebSearchDeny},
1189 {name: "worker invalid policy denies", args: Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchPolicy(99)}, want: subagents.WebSearchDeny},
1190 {name: "worker propagated deny remains denied", args: Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchDeny}, want: subagents.WebSearchDeny},
1191 {name: "worker propagated allow", args: Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchAllow}, cfg: Config{WebSearchEnabled: &disabled}, want: subagents.WebSearchAllow},
1192 {name: "worker explicit empty list caps allow", args: Args{Mode: ModeSubagentWorker, ToolsSet: true, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1193 {name: "worker nonmatching list caps allow", args: Args{Mode: ModeSubagentWorker, ToolsSet: true, Tools: []string{"read"}, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1194 {name: "worker matching list preserves allow", args: Args{Mode: ModeSubagentWorker, ToolsSet: true, Tools: []string{"read", "web_search"}, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchAllow},
1195 {name: "worker no tools caps allow", args: Args{Mode: ModeSubagentWorker, NoTools: true, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1196 {name: "worker permission set caps allow", args: Args{Mode: ModeSubagentWorker, PermissionSet: &tools.PermissionSet{}, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1197 {name: "config load failure denies inherited policy", cfgErr: fmt.Errorf("broken config"), want: subagents.WebSearchDeny},
1198 {name: "named profile requires explicit tool", args: Args{WebSearchPolicy: subagents.WebSearchAllow}, profile: &subagents.Profile{Tools: []string{"read"}}, want: subagents.WebSearchDeny},
1199 {name: "named profile allows explicit tool", args: Args{WebSearchPolicy: subagents.WebSearchAllow}, profile: &subagents.Profile{Tools: []string{"read", "web_search"}}, want: subagents.WebSearchAllow},
1200 {name: "named profile cannot rescue worker inherit", args: Args{Mode: ModeSubagentWorker}, profile: &subagents.Profile{Tools: []string{"web_search"}}, want: subagents.WebSearchDeny},
1201 {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},
1202 }
1203 for _, tc := range cases {
1204 t.Run(tc.name, func(t *testing.T) {
1205 if got := resolveWebSearchPolicy(tc.args, tc.cfg, tc.cfgErr, tc.profile); got != tc.want {
1206 t.Fatalf("policy = %v, want %v", got, tc.want)
1207 }
1208 })
1209 }
1210}