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