v0.16.0 042538b

build_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close