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