v0.10.0 8a8e2b3

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

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