v0.12.2 1adbe2f

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

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