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