v0.13.0 befd698

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
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}

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 476

↑↓ moveenter openesc close