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
711// TestResolveFallsBackWhenConfiguredModelIsGone reproduces the
712// startup failure caught by the user's screenshot: the persisted
713// config.json points at a model id that's no longer in the active
714// catalogue (because they edited models.json or zut's bundled
715// catalogue changed). Resolve must NOT error — strands the user
716// with no way to fix it from the TUI — and should repair the config
717// so the next launch is silent.
718func TestResolveFallsBackWhenConfiguredModelIsGone(t *testing.T) {
719 t.Setenv("ZUT_HOME", t.TempDir())
720 t.Setenv("OPENAI_API_KEY", "test-key")
721 // Persist a stale model id.
722 stale := "gpt-5.5-pro-not-real"
723 if err := SaveConfig(Config{Provider: "openai", Model: stale}); err != nil {
724 t.Fatal(err)
725 }
726
727 r, err := Resolve(Args{}, false)
728 if err != nil {
729 t.Fatalf("Resolve refused to launch with stale model: %v", err)
730 }
731 if r.Model == stale {
732 t.Fatalf("Resolve kept stale model %q", r.Model)
733 }
734 if r.Provider != "openai" {
735 t.Errorf("provider drifted: got %q; want openai", r.Provider)
736 }
737
738 // Config on disk should now hold the fallback so subsequent
739 // launches don't repeat the warning.
740 cfg, _ := LoadConfig()
741 if cfg.Model == stale {
742 t.Errorf("config.json still pins the stale model %q", cfg.Model)
743 }
744 if cfg.Model == "" {
745 t.Errorf("config.json was emptied; expected the fallback model id")
746 }
747}
748
749func TestResolveAppliesJailByDefault(t *testing.T) {
750 for _, tc := range []struct {
751 name string
752 config Config
753 want bool
754 }{
755 {name: "missing defaults to unlocked", config: Config{Provider: "openai", Model: "gpt-5"}},
756 {name: "enabled starts locked", config: Config{Provider: "openai", Model: "gpt-5", JailByDefault: boolPtr(true)}, want: true},
757 } {
758 t.Run(tc.name, func(t *testing.T) {
759 t.Setenv("ZUT_HOME", t.TempDir())
760 t.Setenv("OPENAI_API_KEY", "test-key")
761 if err := SaveConfig(tc.config); err != nil {
762 t.Fatal(err)
763 }
764
765 r, err := Resolve(Args{}, false)
766 if err != nil {
767 t.Fatalf("Resolve failed: %v", err)
768 }
769 if got := r.Sandbox.Locked(); got != tc.want {
770 t.Fatalf("sandbox locked = %v, want %v", got, tc.want)
771 }
772 })
773 }
774}
775
776func boolPtr(v bool) *bool { return &v }
777
778// TestResolveExplicitFlagStaleDoesNotRepairConfig confirms the
779// repair-on-disk happens ONLY when the stale id came from the
780// persisted config. If the user passed --model X explicitly and X is
781// unknown, we still fall back, but we don't touch their config.
782func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) {
783 t.Setenv("ZUT_HOME", t.TempDir())
784 t.Setenv("OPENAI_API_KEY", "test-key")
785 good := "gpt-5"
786 if err := SaveConfig(Config{Provider: "openai", Model: good}); err != nil {
787 t.Fatal(err)
788 }
789
790 r, err := Resolve(Args{Model: "gpt-totally-fake"}, false)
791 if err != nil {
792 t.Fatalf("Resolve errored on unknown --model: %v", err)
793 }
794 if r.Model == "gpt-totally-fake" {
795 t.Errorf("Resolve kept the bogus --model value")
796 }
797 cfg, _ := LoadConfig()
798 if cfg.Model != good {
799 t.Errorf("config.json was clobbered (was %q; now %q)", good, cfg.Model)
800 }
801}
802
803func TestResolveOpenRouterPreservesSavedRoutedModelID(t *testing.T) {
804 t.Setenv("ZUT_HOME", t.TempDir())
805 t.Setenv("OPENROUTER_API_KEY", "test-key")
806 want := "deepseek/deepseek-v4-flash"
807 if err := SaveConfig(Config{Provider: "openrouter", Model: want}); err != nil {
808 t.Fatal(err)
809 }
810
811 r, err := Resolve(Args{}, true)
812 if err != nil {
813 t.Fatalf("Resolve failed: %v", err)
814 }
815 if r.Provider != "openrouter" {
816 t.Fatalf("provider = %q, want openrouter", r.Provider)
817 }
818 if r.Model != want {
819 t.Fatalf("model = %q, want %q", r.Model, want)
820 }
821 if r.MaxOutput != 64000 {
822 t.Fatalf("MaxOutput = %d, want synthetic gateway default 64000", r.MaxOutput)
823 }
824 if r.ContextWindow != 1_000_000 || r.NewAgent().ContextWindow != 1_000_000 {
825 t.Fatalf("resolved/agent context window = %d/%d, want synthetic gateway metadata", r.ContextWindow, r.NewAgent().ContextWindow)
826 }
827}
828
829func TestResolveGatewayPlainUnknownModelFallsBack(t *testing.T) {
830 t.Setenv("ZUT_HOME", t.TempDir())
831 t.Setenv("OPENROUTER_API_KEY", "test-key")
832 stale := "not-a-routed-model"
833 if err := SaveConfig(Config{Provider: "openrouter", Model: stale}); err != nil {
834 t.Fatal(err)
835 }
836
837 r, err := Resolve(Args{}, true)
838 if err != nil {
839 t.Fatalf("Resolve failed: %v", err)
840 }
841 if r.Provider != "openrouter" {
842 t.Fatalf("provider = %q, want openrouter", r.Provider)
843 }
844 if r.Model == stale || r.Model == "" {
845 t.Fatalf("model = %q, want repaired gateway default", r.Model)
846 }
847}
848
849// TestResolveEnvOnlyBedrockDiscoveredWithoutConfig reproduces issue
850// #15: pointing ZUT_HOME at a fresh dir drops the persisted
851// config.json (which pinned provider=amazon-bedrock). Resolve must
852// still discover bedrock from the AWS env vars instead of falling back
853// to anthropic and reporting "not logged in".
854func TestResolveEnvOnlyBedrockDiscoveredWithoutConfig(t *testing.T) {
855 t.Setenv("ZUT_HOME", t.TempDir()) // fresh home: no config.json
856 // Disable the Kimi CLI token fallback so a developer machine with a
857 // real Kimi CLI login doesn't pre-empt bedrock in the scan.
858 if err := SetKimiCLIFallbackDisabled(true); err != nil {
859 t.Fatal(err)
860 }
861 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "test-bedrock-token")
862 t.Setenv("AWS_REGION", "us-east-1")
863 // Make sure no other provider's env credential pre-empts bedrock.
864 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"} {
865 t.Setenv(k, "")
866 }
867
868 r, err := Resolve(Args{}, true)
869 if err != nil {
870 t.Fatalf("Resolve errored with env-only bedrock: %v", err)
871 }
872 if r.Provider != "amazon-bedrock" {
873 t.Fatalf("provider = %q, want amazon-bedrock", r.Provider)
874 }
875 if !r.HasCredential() {
876 t.Fatalf("bedrock credential not resolved from env")
877 }
878}
879
880func TestResolveOllamaUsesModelBaseURLBeforeDefault(t *testing.T) {
881 t.Setenv("ZUT_HOME", t.TempDir())
882 provider.SetLiveModels(nil)
883 defer provider.SetLiveModels(nil)
884 provider.SetUserModels([]provider.Model{{
885 Provider: "ollama",
886 ID: "qwen-local",
887 DisplayName: "Qwen Local",
888 ContextWindow: 32768,
889 MaxOutput: 8192,
890 BaseURL: "http://localhost:8000/v1",
891 }})
892
893 r, err := Resolve(Args{Provider: "ollama", Model: "qwen-local"}, false)
894 if err != nil {
895 t.Fatalf("Resolve failed: %v", err)
896 }
897 if r.BaseURL != "http://localhost:8000/v1" {
898 t.Fatalf("BaseURL = %q, want models.json baseUrl", r.BaseURL)
899 }
900}
901
902func TestResolveUsesInheritedSupervisorCredential(t *testing.T) {
903 t.Setenv("ZUT_HOME", t.TempDir())
904 t.Setenv("OPENAI_API_KEY", "")
905
906 r, err := Resolve(Args{
907 Provider: "openai",
908 Model: "gpt-5",
909 inheritedCredential: "inherited-key",
910 inheritedAuthMethod: "apikey",
911 inheritedAccountID: "account-id",
912 }, true)
913 if err != nil {
914 t.Fatal(err)
915 }
916 if r.Credential != "inherited-key" || r.AuthMethod != "apikey" || r.AccountID != "account-id" {
917 t.Fatalf("inherited credential was not preserved: %+v", r)
918 }
919}
920
921func TestResolveLlamaCPPUsesRouterInferenceURL(t *testing.T) {
922 t.Setenv("ZUT_HOME", t.TempDir())
923 t.Setenv("LLAMA_BASE_URL", "http://127.0.0.1:8080/v1/")
924 t.Setenv("LLAMA_API_KEY", "")
925 provider.SetManagedModels(nil)
926 t.Cleanup(func() { provider.SetManagedModels(nil) })
927
928 r, err := Resolve(Args{Provider: "llama.cpp", Model: "local-model"}, true)
929 if err != nil {
930 t.Fatalf("Resolve failed: %v", err)
931 }
932 if r.BaseURL != "http://127.0.0.1:8080/v1" {
933 t.Fatalf("BaseURL = %q", r.BaseURL)
934 }
935 if r.Credential != "local" || !r.HasCredential() {
936 t.Fatalf("credential = %q", r.Credential)
937 }
938 if r.ContextWindow != 128000 || r.NewAgent().ContextWindow != 128000 {
939 t.Fatalf("resolved/agent context window = %d/%d, want synthesized local metadata", r.ContextWindow, r.NewAgent().ContextWindow)
940 }
941 if got := r.NewClient().Name(); got != provider.LlamaCPPProviderID {
942 t.Fatalf("client name = %q, want %q", got, provider.LlamaCPPProviderID)
943 }
944}
945
946func TestResolveLlamaCPPUsesStoredLogin(t *testing.T) {
947 t.Setenv("ZUT_HOME", t.TempDir())
948 t.Setenv("LLAMA_BASE_URL", "")
949 t.Setenv("LLAMA_API_KEY", "")
950 if err := AuthStoreFor().SetEndpointCredential("llama.cpp", "http://localhost:9090", "stored-key"); err != nil {
951 t.Fatal(err)
952 }
953
954 r, err := Resolve(Args{Provider: "llama.cpp", Model: "stored-model"}, true)
955 if err != nil {
956 t.Fatalf("Resolve failed: %v", err)
957 }
958 if r.BaseURL != "http://localhost:9090/v1" || r.Credential != "stored-key" {
959 t.Fatalf("resolved = base %q credential %q", r.BaseURL, r.Credential)
960 }
961}
962
963func TestResolveCustomProviderModelBaseURLBeatsProviderBaseURL(t *testing.T) {
964 t.Setenv("ZUT_HOME", t.TempDir())
965 t.Setenv("MY_COMPANY_API_KEY", "test-key")
966 path := filepath.Join(t.TempDir(), "models.json")
967 if err := os.WriteFile(path, []byte(`{
968 "providers": {
969 "my-company": {
970 "baseUrl": "https://provider.example.com/v1",
971 "api": "openai",
972 "models": [
973 {"id": "fast", "baseUrl": "https://model.example.com/v1", "contextWindow": 65536}
974 ]
975 }
976 }
977 }`), 0o644); err != nil {
978 t.Fatal(err)
979 }
980 models, warnings := provider.LoadUserModelsWithWarnings(path)
981 if len(warnings) != 0 {
982 t.Fatalf("warnings = %v", warnings)
983 }
984 provider.SetLiveModels(nil)
985 provider.SetUserModels(models)
986 t.Cleanup(func() { provider.SetLiveModels(nil) })
987
988 r, err := Resolve(Args{Provider: "my-company", Model: "fast"}, true)
989 if err != nil {
990 t.Fatalf("Resolve failed: %v", err)
991 }
992 if r.BaseURL != "https://model.example.com/v1" {
993 t.Fatalf("BaseURL = %q, want model-level baseUrl", r.BaseURL)
994 }
995 if r.ContextWindow != 65536 || r.NewAgent().ContextWindow != 65536 {
996 t.Fatalf("resolved/agent context window = %d/%d, want retained user-model metadata", r.ContextWindow, r.NewAgent().ContextWindow)
997 }
998}
999
1000func TestCustomProviderUsesOpenAIResponsesAPI(t *testing.T) {
1001 requestPath := make(chan string, 1)
1002 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1003 requestPath <- r.URL.Path
1004 w.Header().Set("Content-Type", "text/event-stream")
1005 w.WriteHeader(http.StatusOK)
1006 }))
1007 t.Cleanup(srv.Close)
1008
1009 path := filepath.Join(t.TempDir(), "models.json")
1010 if err := os.WriteFile(path, []byte(`{
1011 "providers": {
1012 "company-responses": {
1013 "baseUrl": "`+srv.URL+`/v1",
1014 "api": "openai-responses",
1015 "models": [{"id": "reasoning-model", "reasoning": true}]
1016 }
1017 }
1018 }`), 0o644); err != nil {
1019 t.Fatal(err)
1020 }
1021 models, warnings := provider.LoadUserModelsWithWarnings(path)
1022 if len(warnings) != 0 {
1023 t.Fatalf("warnings = %v", warnings)
1024 }
1025 provider.SetLiveModels(nil)
1026 provider.SetUserModels(models)
1027 t.Cleanup(func() { provider.SetLiveModels(nil) })
1028
1029 r := Resolved{
1030 Provider: "company-responses",
1031 Credential: "test-key",
1032 BaseURL: srv.URL + "/v1",
1033 }
1034 events, err := r.NewClient().Stream(context.Background(), provider.Request{
1035 Model: "reasoning-model",
1036 Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}},
1037 })
1038 if err != nil {
1039 t.Fatal(err)
1040 }
1041 for range events {
1042 }
1043 if got := <-requestPath; got != "/v1/responses" {
1044 t.Fatalf("request path = %q, want /v1/responses", got)
1045 }
1046}
1047
1048func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) {
1049 t.Setenv("ZUT_HOME", t.TempDir())
1050 t.Setenv("LOCAL_PROXY_API_KEY", "test-key")
1051 path := filepath.Join(t.TempDir(), "models.json")
1052 if err := os.WriteFile(path, []byte(`{
1053 "providers": {
1054 "local-proxy": {
1055 "baseUrl": "https://proxy.example.com/v1",
1056 "api": "openai",
1057 "models": [{"id": "default"}]
1058 }
1059 }
1060 }`), 0o644); err != nil {
1061 t.Fatal(err)
1062 }
1063 models, warnings := provider.LoadUserModelsWithWarnings(path)
1064 if len(warnings) != 0 {
1065 t.Fatalf("warnings = %v", warnings)
1066 }
1067 provider.SetLiveModels(nil)
1068 provider.SetUserModels(models)
1069 t.Cleanup(func() { provider.SetLiveModels(nil) })
1070
1071 r, err := Resolve(Args{Provider: "local-proxy", Model: "default", InsecureTLS: true}, true)
1072 if err != nil {
1073 t.Fatalf("Resolve failed: %v", err)
1074 }
1075 if !r.InsecureTLS {
1076 t.Fatal("InsecureTLS must be set for --insecure with models.json custom baseUrl")
1077 }
1078}
1079
1080func TestResolveOllamaFallsBackToDefaultBaseURL(t *testing.T) {
1081 t.Setenv("ZUT_HOME", t.TempDir())
1082 provider.SetLiveModels(nil)
1083 defer provider.SetLiveModels(nil)
1084
1085 r, err := Resolve(Args{Provider: "ollama", Model: "any-local-model"}, false)
1086 if err != nil {
1087 t.Fatalf("Resolve failed: %v", err)
1088 }
1089 if r.BaseURL != "http://localhost:11434" {
1090 t.Fatalf("BaseURL = %q, want ollama default", r.BaseURL)
1091 }
1092}
1093
1094func TestCanonicalProviderResolvesAliases(t *testing.T) {
1095 cases := map[string]string{
1096 "bedrock": "amazon-bedrock",
1097 "AWS-Bedrock": "amazon-bedrock",
1098 " bedrock ": "amazon-bedrock",
1099 "vertex": "google-vertex",
1100 "gemini": "google",
1101 "azure": "azure-openai-responses",
1102 "copilot": "github-copilot",
1103 "codex": "openai-codex",
1104 "moonshot": "moonshotai",
1105 "vercel": "vercel-ai-gateway",
1106 "hf": "huggingface",
1107 "anthropic": "anthropic", // canonical passes through
1108 "amazon-bedrock": "amazon-bedrock", // already canonical
1109 "totally-unknown": "totally-unknown", // unknown returned unchanged (lowered)
1110 "Totally-UNKNOWN": "totally-unknown",
1111 "": "",
1112 }
1113 for in, want := range cases {
1114 if got := canonicalProvider(in); got != want {
1115 t.Errorf("canonicalProvider(%q) = %q, want %q", in, got, want)
1116 }
1117 }
1118}
1119
1120func TestCanonicalProviderAliasesAreKnown(t *testing.T) {
1121 for alias, canon := range providerAliases {
1122 if !isKnownProvider(canon) {
1123 t.Errorf("alias %q maps to %q which is not a known provider", alias, canon)
1124 }
1125 }
1126}
1127
1128func TestResolveInsecureOnlyWithExplicitBaseURL(t *testing.T) {
1129 orig := http.DefaultTransport
1130 t.Cleanup(func() { http.DefaultTransport = orig })
1131
1132 t.Setenv("ZUT_HOME", t.TempDir())
1133 t.Setenv("OPENAI_API_KEY", "test-key")
1134
1135 resolved, err := Resolve(Args{Provider: "moonshotai", InsecureTLS: true}, false)
1136 if err != nil {
1137 t.Fatalf("Resolve: %v", err)
1138 }
1139 if resolved.InsecureTLS {
1140 t.Fatal("InsecureTLS must not be set for built-in provider base URLs")
1141 }
1142 assertDefaultTransportStillSecure(t)
1143
1144 resolved, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false)
1145 if err != nil {
1146 t.Fatalf("Resolve: %v", err)
1147 }
1148 if !resolved.InsecureTLS {
1149 t.Fatal("InsecureTLS must be set with --insecure and explicit --base-url")
1150 }
1151 assertDefaultTransportStillSecure(t)
1152}
1153
1154func TestResolveInsecureFromConfigRequiresExplicitBaseURL(t *testing.T) {
1155 orig := http.DefaultTransport
1156 t.Cleanup(func() { http.DefaultTransport = orig })
1157
1158 t.Setenv("ZUT_HOME", t.TempDir())
1159 t.Setenv("OPENAI_API_KEY", "test-key")
1160 if err := SaveConfig(Config{Provider: "openai", Insecure: true}); err != nil {
1161 t.Fatal(err)
1162 }
1163
1164 resolved, err := Resolve(Args{Provider: "openai"}, false)
1165 if err != nil {
1166 t.Fatalf("Resolve: %v", err)
1167 }
1168 if resolved.InsecureTLS {
1169 t.Fatal("InsecureTLS must not be set without a custom base URL")
1170 }
1171 assertDefaultTransportStillSecure(t)
1172
1173 resolved, err = Resolve(Args{Provider: "openai", 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 when config insecure=true and --base-url is provided")
1179 }
1180 assertDefaultTransportStillSecure(t)
1181}
1182
1183func TestDefaultXAIModelIsGrok45(t *testing.T) {
1184 if got := defaultModelForProvider("xai"); got != "grok-4.5" {
1185 t.Fatalf("default xAI model = %q, want grok-4.5", got)
1186 }
1187}
1188
1189func assertDefaultTransportStillSecure(t *testing.T) {
1190 t.Helper()
1191 tr, ok := http.DefaultTransport.(*http.Transport)
1192 if !ok {
1193 return
1194 }
1195 if tr.TLSClientConfig != nil && tr.TLSClientConfig.InsecureSkipVerify {
1196 t.Fatal("http.DefaultTransport must not be made insecure")
1197 }
1198}
1199
1200func TestResolveWebSearchRegistryRespectsConfigAndExplicitCLI(t *testing.T) {
1201 t.Setenv("ZUT_HOME", t.TempDir())
1202 disabled := false
1203 if err := SaveConfig(Config{WebSearchEnabled: &disabled}); err != nil {
1204 t.Fatal(err)
1205 }
1206 args := Args{CWD: t.TempDir(), Provider: "ollama", Model: "any-local-model"}
1207 resolved, err := Resolve(args, false)
1208 if err != nil {
1209 t.Fatal(err)
1210 }
1211 if _, ok := resolved.ToolRegistry["web_search"]; ok {
1212 t.Fatal("persisted opt-out left web_search in the registry")
1213 }
1214
1215 args.ToolsSet = true
1216 args.Tools = []string{"web_search"}
1217 resolved, err = Resolve(args, false)
1218 if err != nil {
1219 t.Fatal(err)
1220 }
1221 if _, ok := resolved.ToolRegistry["web_search"]; !ok {
1222 t.Fatal("explicit --tools web_search did not override persisted opt-out")
1223 }
1224
1225 args.PermissionSet = &tools.PermissionSet{}
1226 resolved, err = Resolve(args, false)
1227 if err != nil {
1228 t.Fatal(err)
1229 }
1230 if _, ok := resolved.ToolRegistry["web_search"]; ok {
1231 t.Fatal("packaged-agent PermissionSet received web_search")
1232 }
1233}
1234
1235func TestResolveWebSearchPolicy(t *testing.T) {
1236 disabled := false
1237 cases := []struct {
1238 name string
1239 args Args
1240 cfg Config
1241 cfgErr error
1242 profile *subagents.Profile
1243 want subagents.WebSearchPolicy
1244 }{
1245 {name: "legacy config", want: subagents.WebSearchAllow},
1246 {name: "persisted opt out", cfg: Config{WebSearchEnabled: &disabled}, want: subagents.WebSearchDeny},
1247 {name: "explicit CLI allow overrides opt out", args: Args{ToolsSet: true, Tools: []string{"web_search"}}, cfg: Config{WebSearchEnabled: &disabled}, want: subagents.WebSearchAllow},
1248 {name: "explicit empty CLI list denies", args: Args{ToolsSet: true}, want: subagents.WebSearchDeny},
1249 {name: "explicit nonmatching CLI list denies", args: Args{ToolsSet: true, Tools: []string{"read"}}, want: subagents.WebSearchDeny},
1250 {name: "no tools denies", args: Args{NoTools: true}, want: subagents.WebSearchDeny},
1251 {name: "packaged agent denies", args: Args{PermissionSet: &tools.PermissionSet{}}, want: subagents.WebSearchDeny},
1252 {name: "internal policy denies", args: Args{WebSearchPolicy: subagents.WebSearchDeny}, want: subagents.WebSearchDeny},
1253 {name: "internal policy allows", args: Args{WebSearchPolicy: subagents.WebSearchAllow}, cfg: Config{WebSearchEnabled: &disabled}, want: subagents.WebSearchAllow},
1254 {name: "normal explicit empty list narrows internal allow", args: Args{ToolsSet: true, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1255 {name: "worker missing policy denies despite legacy config", args: Args{Mode: ModeSubagentWorker}, want: subagents.WebSearchDeny},
1256 {name: "worker explicit inherit denies", args: Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchInherit}, want: subagents.WebSearchDeny},
1257 {name: "worker invalid policy denies", args: Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchPolicy(99)}, want: subagents.WebSearchDeny},
1258 {name: "worker propagated deny remains denied", args: Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchDeny}, want: subagents.WebSearchDeny},
1259 {name: "worker propagated allow", args: Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchAllow}, cfg: Config{WebSearchEnabled: &disabled}, want: subagents.WebSearchAllow},
1260 {name: "worker explicit empty list caps allow", args: Args{Mode: ModeSubagentWorker, ToolsSet: true, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1261 {name: "worker nonmatching list caps allow", args: Args{Mode: ModeSubagentWorker, ToolsSet: true, Tools: []string{"read"}, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1262 {name: "worker matching list preserves allow", args: Args{Mode: ModeSubagentWorker, ToolsSet: true, Tools: []string{"read", "web_search"}, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchAllow},
1263 {name: "worker no tools caps allow", args: Args{Mode: ModeSubagentWorker, NoTools: true, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1264 {name: "worker permission set caps allow", args: Args{Mode: ModeSubagentWorker, PermissionSet: &tools.PermissionSet{}, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1265 {name: "config load failure denies inherited policy", cfgErr: fmt.Errorf("broken config"), want: subagents.WebSearchDeny},
1266 {name: "named profile requires explicit tool", args: Args{WebSearchPolicy: subagents.WebSearchAllow}, profile: &subagents.Profile{Tools: []string{"read"}}, want: subagents.WebSearchDeny},
1267 {name: "named profile allows explicit tool", args: Args{WebSearchPolicy: subagents.WebSearchAllow}, profile: &subagents.Profile{Tools: []string{"read", "web_search"}}, want: subagents.WebSearchAllow},
1268 {name: "named profile cannot rescue worker inherit", args: Args{Mode: ModeSubagentWorker}, profile: &subagents.Profile{Tools: []string{"web_search"}}, want: subagents.WebSearchDeny},
1269 {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},
1270 }
1271 for _, tc := range cases {
1272 t.Run(tc.name, func(t *testing.T) {
1273 if got := resolveWebSearchPolicy(tc.args, tc.cfg, tc.cfgErr, tc.profile); got != tc.want {
1274 t.Fatalf("policy = %v, want %v", got, tc.want)
1275 }
1276 })
1277 }
1278}