v0.11.4 020512d

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 TestResolveOmitsAutoSubagentsOrchestratorContractWhenDisabled(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	if err := os.MkdirAll(project, 0o755); err != nil {
482		t.Fatal(err)
483	}
484	t.Setenv("HOME", home)
485	t.Setenv("ZUT_HOME", zutHome)
486	disabled := false
487	if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", AutoSubagentsEnabled: &disabled}); err != nil {
488		t.Fatal(err)
489	}
490
491	r, err := Resolve(Args{CWD: project}, false)
492	if err != nil {
493		t.Fatal(err)
494	}
495	for _, unwanted := range []string{
496		AutoSubagentsSystemAddendum,
497		"primary-agent orchestrator",
498		"[subagents_list]",
499	} {
500		if strings.Contains(r.SystemPrompt, unwanted) {
501			t.Fatalf("disabled system prompt contains auto-subagents contract %q:\n%s", unwanted, r.SystemPrompt)
502		}
503	}
504	for _, required := range []string{
505		"user asks you to delegate",
506		"active skill workflow requires delegation",
507	} {
508		if !strings.Contains(r.SystemPrompt, required) {
509			t.Fatalf("disabled system prompt omits permitted delegation path %q:\n%s", required, r.SystemPrompt)
510		}
511	}
512}
513
514func TestResolveAppliesSelectedSubagentProfile(t *testing.T) {
515	root := t.TempDir()
516	home := filepath.Join(root, "home")
517	zutHome := filepath.Join(root, "zut-home")
518	project := filepath.Join(root, "repo")
519	if err := os.MkdirAll(filepath.Join(home, ".agents", "agents"), 0o755); err != nil {
520		t.Fatal(err)
521	}
522	if err := os.MkdirAll(project, 0o755); err != nil {
523		t.Fatal(err)
524	}
525	t.Setenv("HOME", home)
526	t.Setenv("ZUT_HOME", zutHome)
527	t.Setenv("ZUT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents"))
528	if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", Reasoning: "high"}); err != nil {
529		t.Fatal(err)
530	}
531	if err := os.WriteFile(filepath.Join(zutHome, "AGENTS.md"), []byte("global context"), 0o600); err != nil {
532		t.Fatal(err)
533	}
534	profile := `---
535name: reviewer
536description: Read-only code reviewer
537tools: read
538thinking: off
539systemPromptMode: replace
540inheritProjectContext: false
541inheritSkills: false
542---
543You are a read-only reviewer.
544`
545	if err := os.WriteFile(filepath.Join(home, ".agents", "agents", "reviewer.md"), []byte(profile), 0o600); err != nil {
546		t.Fatal(err)
547	}
548
549	r, err := Resolve(Args{CWD: project, Subagent: "reviewer"}, false)
550	if err != nil {
551		t.Fatal(err)
552	}
553	if r.Reasoning != "" {
554		t.Fatalf("profile thinking=off did not override config reasoning: %q", r.Reasoning)
555	}
556	if _, ok := r.ToolRegistry["read"]; !ok || len(r.ToolRegistry) != 1 {
557		t.Fatalf("profile tools = %#v, want only read", r.ToolRegistry)
558	}
559	if len(r.ContextFiles) != 0 {
560		t.Fatalf("profile inheritProjectContext=false loaded %#v", r.ContextFiles)
561	}
562	if !strings.Contains(r.SystemPrompt, "You are a read-only reviewer.") || strings.Contains(r.SystemPrompt, "global context") {
563		t.Fatalf("profile system prompt inheritance is wrong:\n%s", r.SystemPrompt)
564	}
565	if !strings.Contains(r.SystemPrompt, PonytailSystemAddendum()) {
566		t.Fatalf("replace-mode profile lost global Ponytail addendum:\n%s", r.SystemPrompt)
567	}
568}
569
570func TestResolveSubagentFastModeProfileOverridesHostSetting(t *testing.T) {
571	cases := []struct {
572		name         string
573		hostFastMode bool
574		profileFast  *bool
575		argFastMode  bool
576		argFastSet   bool
577		wantFastMode bool
578	}{
579		{name: "unset inherits enabled host", hostFastMode: true, wantFastMode: true},
580		{name: "unset inherits disabled host", hostFastMode: false, wantFastMode: false},
581		{name: "false disables enabled host", hostFastMode: true, profileFast: boolPtr(false), wantFastMode: false},
582		{name: "true enables disabled host", hostFastMode: false, profileFast: boolPtr(true), wantFastMode: true},
583		{name: "false stays disabled with disabled host", hostFastMode: false, profileFast: boolPtr(false), wantFastMode: false},
584		{name: "profile false cannot be bypassed", hostFastMode: true, profileFast: boolPtr(false), argFastMode: true, argFastSet: true, wantFastMode: false},
585	}
586	for _, tc := range cases {
587		t.Run(tc.name, func(t *testing.T) {
588			root := t.TempDir()
589			home := filepath.Join(root, "home")
590			zutHome := filepath.Join(root, "zut-home")
591			project := filepath.Join(root, "repo")
592			profilesDir := filepath.Join(home, ".agents", "agents")
593			if err := os.MkdirAll(profilesDir, 0o755); err != nil {
594				t.Fatal(err)
595			}
596			if err := os.MkdirAll(project, 0o755); err != nil {
597				t.Fatal(err)
598			}
599			t.Setenv("HOME", home)
600			t.Setenv("ZUT_HOME", zutHome)
601			t.Setenv("ZUT_AGENT_PROFILES", profilesDir)
602
603			cfg := Config{Provider: "openai", Model: "gpt-5"}
604			cfg.FastMode = &tc.hostFastMode
605			if err := SaveConfig(cfg); err != nil {
606				t.Fatal(err)
607			}
608			profile := "---\nname: reviewer\n"
609			if tc.profileFast != nil {
610				profile += fmt.Sprintf("fastMode: %t\n", *tc.profileFast)
611			}
612			profile += "---\nReview the requested scope.\n"
613			if err := os.WriteFile(filepath.Join(profilesDir, "reviewer.md"), []byte(profile), 0o600); err != nil {
614				t.Fatal(err)
615			}
616
617			r, err := Resolve(Args{
618				CWD:         project,
619				Subagent:    "reviewer",
620				FastMode:    tc.argFastMode,
621				FastModeSet: tc.argFastSet,
622			}, false)
623			if err != nil {
624				t.Fatal(err)
625			}
626			if r.FastMode != tc.wantFastMode {
627				t.Fatalf("FastMode = %v, want %v", r.FastMode, tc.wantFastMode)
628			}
629		})
630	}
631}
632
633func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) {
634	files := loadAgentsContext(t.TempDir(), t.TempDir())
635	if len(files) != 0 {
636		t.Fatalf("expected no context files, got %#v", files)
637	}
638	if got := formatAgentsContext(files); got != "" {
639		t.Fatalf("expected no context, got %q", got)
640	}
641}
642
643// TestResolveFallsBackWhenConfiguredModelIsGone reproduces the
644// startup failure caught by the user's screenshot: the persisted
645// config.json points at a model id that's no longer in the active
646// catalogue (because they edited models.json or zut's bundled
647// catalogue changed). Resolve must NOT error — strands the user
648// with no way to fix it from the TUI — and should repair the config
649// so the next launch is silent.
650func TestResolveFallsBackWhenConfiguredModelIsGone(t *testing.T) {
651	t.Setenv("ZUT_HOME", t.TempDir())
652	t.Setenv("OPENAI_API_KEY", "test-key")
653	// Persist a stale model id.
654	stale := "gpt-5.5-pro-not-real"
655	if err := SaveConfig(Config{Provider: "openai", Model: stale}); err != nil {
656		t.Fatal(err)
657	}
658
659	r, err := Resolve(Args{}, false)
660	if err != nil {
661		t.Fatalf("Resolve refused to launch with stale model: %v", err)
662	}
663	if r.Model == stale {
664		t.Fatalf("Resolve kept stale model %q", r.Model)
665	}
666	if r.Provider != "openai" {
667		t.Errorf("provider drifted: got %q; want openai", r.Provider)
668	}
669
670	// Config on disk should now hold the fallback so subsequent
671	// launches don't repeat the warning.
672	cfg, _ := LoadConfig()
673	if cfg.Model == stale {
674		t.Errorf("config.json still pins the stale model %q", cfg.Model)
675	}
676	if cfg.Model == "" {
677		t.Errorf("config.json was emptied; expected the fallback model id")
678	}
679}
680
681func TestResolveAppliesJailByDefault(t *testing.T) {
682	for _, tc := range []struct {
683		name   string
684		config Config
685		want   bool
686	}{
687		{name: "missing defaults to unlocked", config: Config{Provider: "openai", Model: "gpt-5"}},
688		{name: "enabled starts locked", config: Config{Provider: "openai", Model: "gpt-5", JailByDefault: boolPtr(true)}, want: true},
689	} {
690		t.Run(tc.name, func(t *testing.T) {
691			t.Setenv("ZUT_HOME", t.TempDir())
692			t.Setenv("OPENAI_API_KEY", "test-key")
693			if err := SaveConfig(tc.config); err != nil {
694				t.Fatal(err)
695			}
696
697			r, err := Resolve(Args{}, false)
698			if err != nil {
699				t.Fatalf("Resolve failed: %v", err)
700			}
701			if got := r.Sandbox.Locked(); got != tc.want {
702				t.Fatalf("sandbox locked = %v, want %v", got, tc.want)
703			}
704		})
705	}
706}
707
708func boolPtr(v bool) *bool { return &v }
709
710// TestResolveExplicitFlagStaleDoesNotRepairConfig confirms the
711// repair-on-disk happens ONLY when the stale id came from the
712// persisted config. If the user passed --model X explicitly and X is
713// unknown, we still fall back, but we don't touch their config.
714func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) {
715	t.Setenv("ZUT_HOME", t.TempDir())
716	t.Setenv("OPENAI_API_KEY", "test-key")
717	good := "gpt-5"
718	if err := SaveConfig(Config{Provider: "openai", Model: good}); err != nil {
719		t.Fatal(err)
720	}
721
722	r, err := Resolve(Args{Model: "gpt-totally-fake"}, false)
723	if err != nil {
724		t.Fatalf("Resolve errored on unknown --model: %v", err)
725	}
726	if r.Model == "gpt-totally-fake" {
727		t.Errorf("Resolve kept the bogus --model value")
728	}
729	cfg, _ := LoadConfig()
730	if cfg.Model != good {
731		t.Errorf("config.json was clobbered (was %q; now %q)", good, cfg.Model)
732	}
733}
734
735func TestResolveOpenRouterPreservesSavedRoutedModelID(t *testing.T) {
736	t.Setenv("ZUT_HOME", t.TempDir())
737	t.Setenv("OPENROUTER_API_KEY", "test-key")
738	want := "deepseek/deepseek-v4-flash"
739	if err := SaveConfig(Config{Provider: "openrouter", Model: want}); err != nil {
740		t.Fatal(err)
741	}
742
743	r, err := Resolve(Args{}, true)
744	if err != nil {
745		t.Fatalf("Resolve failed: %v", err)
746	}
747	if r.Provider != "openrouter" {
748		t.Fatalf("provider = %q, want openrouter", r.Provider)
749	}
750	if r.Model != want {
751		t.Fatalf("model = %q, want %q", r.Model, want)
752	}
753	if r.MaxOutput != 64000 {
754		t.Fatalf("MaxOutput = %d, want synthetic gateway default 64000", r.MaxOutput)
755	}
756}
757
758func TestResolveGatewayPlainUnknownModelFallsBack(t *testing.T) {
759	t.Setenv("ZUT_HOME", t.TempDir())
760	t.Setenv("OPENROUTER_API_KEY", "test-key")
761	stale := "not-a-routed-model"
762	if err := SaveConfig(Config{Provider: "openrouter", Model: stale}); err != nil {
763		t.Fatal(err)
764	}
765
766	r, err := Resolve(Args{}, true)
767	if err != nil {
768		t.Fatalf("Resolve failed: %v", err)
769	}
770	if r.Provider != "openrouter" {
771		t.Fatalf("provider = %q, want openrouter", r.Provider)
772	}
773	if r.Model == stale || r.Model == "" {
774		t.Fatalf("model = %q, want repaired gateway default", r.Model)
775	}
776}
777
778// TestResolveEnvOnlyBedrockDiscoveredWithoutConfig reproduces issue
779// #15: pointing ZUT_HOME at a fresh dir drops the persisted
780// config.json (which pinned provider=amazon-bedrock). Resolve must
781// still discover bedrock from the AWS env vars instead of falling back
782// to anthropic and reporting "not logged in".
783func TestResolveEnvOnlyBedrockDiscoveredWithoutConfig(t *testing.T) {
784	t.Setenv("ZUT_HOME", t.TempDir()) // fresh home: no config.json
785	// Disable the Kimi CLI token fallback so a developer machine with a
786	// real Kimi CLI login doesn't pre-empt bedrock in the scan.
787	if err := SetKimiCLIFallbackDisabled(true); err != nil {
788		t.Fatal(err)
789	}
790	t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "test-bedrock-token")
791	t.Setenv("AWS_REGION", "us-east-1")
792	// Make sure no other provider's env credential pre-empts bedrock.
793	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"} {
794		t.Setenv(k, "")
795	}
796
797	r, err := Resolve(Args{}, true)
798	if err != nil {
799		t.Fatalf("Resolve errored with env-only bedrock: %v", err)
800	}
801	if r.Provider != "amazon-bedrock" {
802		t.Fatalf("provider = %q, want amazon-bedrock", r.Provider)
803	}
804	if !r.HasCredential() {
805		t.Fatalf("bedrock credential not resolved from env")
806	}
807}
808
809func TestResolveOllamaUsesModelBaseURLBeforeDefault(t *testing.T) {
810	t.Setenv("ZUT_HOME", t.TempDir())
811	provider.SetLiveModels(nil)
812	defer provider.SetLiveModels(nil)
813	provider.SetUserModels([]provider.Model{{
814		Provider:      "ollama",
815		ID:            "qwen-local",
816		DisplayName:   "Qwen Local",
817		ContextWindow: 32768,
818		MaxOutput:     8192,
819		BaseURL:       "http://localhost:8000/v1",
820	}})
821
822	r, err := Resolve(Args{Provider: "ollama", Model: "qwen-local"}, false)
823	if err != nil {
824		t.Fatalf("Resolve failed: %v", err)
825	}
826	if r.BaseURL != "http://localhost:8000/v1" {
827		t.Fatalf("BaseURL = %q, want models.json baseUrl", r.BaseURL)
828	}
829}
830
831func TestResolveUsesInheritedSupervisorCredential(t *testing.T) {
832	t.Setenv("ZUT_HOME", t.TempDir())
833	t.Setenv("OPENAI_API_KEY", "")
834
835	r, err := Resolve(Args{
836		Provider:            "openai",
837		Model:               "gpt-5",
838		inheritedCredential: "inherited-key",
839		inheritedAuthMethod: "apikey",
840		inheritedAccountID:  "account-id",
841	}, true)
842	if err != nil {
843		t.Fatal(err)
844	}
845	if r.Credential != "inherited-key" || r.AuthMethod != "apikey" || r.AccountID != "account-id" {
846		t.Fatalf("inherited credential was not preserved: %+v", r)
847	}
848}
849
850func TestResolveLlamaCPPUsesRouterInferenceURL(t *testing.T) {
851	t.Setenv("ZUT_HOME", t.TempDir())
852	t.Setenv("LLAMA_BASE_URL", "http://127.0.0.1:8080/v1/")
853	t.Setenv("LLAMA_API_KEY", "")
854	provider.SetManagedModels(nil)
855	t.Cleanup(func() { provider.SetManagedModels(nil) })
856
857	r, err := Resolve(Args{Provider: "llama.cpp", Model: "local-model"}, true)
858	if err != nil {
859		t.Fatalf("Resolve failed: %v", err)
860	}
861	if r.BaseURL != "http://127.0.0.1:8080/v1" {
862		t.Fatalf("BaseURL = %q", r.BaseURL)
863	}
864	if r.Credential != "local" || !r.HasCredential() {
865		t.Fatalf("credential = %q", r.Credential)
866	}
867	if got := r.NewClient().Name(); got != provider.LlamaCPPProviderID {
868		t.Fatalf("client name = %q, want %q", got, provider.LlamaCPPProviderID)
869	}
870}
871
872func TestResolveLlamaCPPUsesStoredLogin(t *testing.T) {
873	t.Setenv("ZUT_HOME", t.TempDir())
874	t.Setenv("LLAMA_BASE_URL", "")
875	t.Setenv("LLAMA_API_KEY", "")
876	if err := AuthStoreFor().SetEndpointCredential("llama.cpp", "http://localhost:9090", "stored-key"); err != nil {
877		t.Fatal(err)
878	}
879
880	r, err := Resolve(Args{Provider: "llama.cpp", Model: "stored-model"}, true)
881	if err != nil {
882		t.Fatalf("Resolve failed: %v", err)
883	}
884	if r.BaseURL != "http://localhost:9090/v1" || r.Credential != "stored-key" {
885		t.Fatalf("resolved = base %q credential %q", r.BaseURL, r.Credential)
886	}
887}
888
889func TestResolveCustomProviderModelBaseURLBeatsProviderBaseURL(t *testing.T) {
890	t.Setenv("ZUT_HOME", t.TempDir())
891	t.Setenv("MY_COMPANY_API_KEY", "test-key")
892	path := filepath.Join(t.TempDir(), "models.json")
893	if err := os.WriteFile(path, []byte(`{
894		"providers": {
895			"my-company": {
896				"baseUrl": "https://provider.example.com/v1",
897				"api": "openai",
898				"models": [
899					{"id": "fast", "baseUrl": "https://model.example.com/v1"}
900				]
901			}
902		}
903	}`), 0o644); err != nil {
904		t.Fatal(err)
905	}
906	models, warnings := provider.LoadUserModelsWithWarnings(path)
907	if len(warnings) != 0 {
908		t.Fatalf("warnings = %v", warnings)
909	}
910	provider.SetLiveModels(nil)
911	provider.SetUserModels(models)
912	t.Cleanup(func() { provider.SetLiveModels(nil) })
913
914	r, err := Resolve(Args{Provider: "my-company", Model: "fast"}, true)
915	if err != nil {
916		t.Fatalf("Resolve failed: %v", err)
917	}
918	if r.BaseURL != "https://model.example.com/v1" {
919		t.Fatalf("BaseURL = %q, want model-level baseUrl", r.BaseURL)
920	}
921}
922
923func TestCustomProviderUsesOpenAIResponsesAPI(t *testing.T) {
924	requestPath := make(chan string, 1)
925	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
926		requestPath <- r.URL.Path
927		w.Header().Set("Content-Type", "text/event-stream")
928		w.WriteHeader(http.StatusOK)
929	}))
930	t.Cleanup(srv.Close)
931
932	path := filepath.Join(t.TempDir(), "models.json")
933	if err := os.WriteFile(path, []byte(`{
934		"providers": {
935			"company-responses": {
936				"baseUrl": "`+srv.URL+`/v1",
937				"api": "openai-responses",
938				"models": [{"id": "reasoning-model", "reasoning": true}]
939			}
940		}
941	}`), 0o644); err != nil {
942		t.Fatal(err)
943	}
944	models, warnings := provider.LoadUserModelsWithWarnings(path)
945	if len(warnings) != 0 {
946		t.Fatalf("warnings = %v", warnings)
947	}
948	provider.SetLiveModels(nil)
949	provider.SetUserModels(models)
950	t.Cleanup(func() { provider.SetLiveModels(nil) })
951
952	r := Resolved{
953		Provider:   "company-responses",
954		Credential: "test-key",
955		BaseURL:    srv.URL + "/v1",
956	}
957	events, err := r.NewClient().Stream(context.Background(), provider.Request{
958		Model:    "reasoning-model",
959		Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}},
960	})
961	if err != nil {
962		t.Fatal(err)
963	}
964	for range events {
965	}
966	if got := <-requestPath; got != "/v1/responses" {
967		t.Fatalf("request path = %q, want /v1/responses", got)
968	}
969}
970
971func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) {
972	t.Setenv("ZUT_HOME", t.TempDir())
973	t.Setenv("LOCAL_PROXY_API_KEY", "test-key")
974	path := filepath.Join(t.TempDir(), "models.json")
975	if err := os.WriteFile(path, []byte(`{
976		"providers": {
977			"local-proxy": {
978				"baseUrl": "https://proxy.example.com/v1",
979				"api": "openai",
980				"models": [{"id": "default"}]
981			}
982		}
983	}`), 0o644); err != nil {
984		t.Fatal(err)
985	}
986	models, warnings := provider.LoadUserModelsWithWarnings(path)
987	if len(warnings) != 0 {
988		t.Fatalf("warnings = %v", warnings)
989	}
990	provider.SetLiveModels(nil)
991	provider.SetUserModels(models)
992	t.Cleanup(func() { provider.SetLiveModels(nil) })
993
994	r, err := Resolve(Args{Provider: "local-proxy", Model: "default", InsecureTLS: true}, true)
995	if err != nil {
996		t.Fatalf("Resolve failed: %v", err)
997	}
998	if !r.InsecureTLS {
999		t.Fatal("InsecureTLS must be set for --insecure with models.json custom baseUrl")
1000	}
1001}
1002
1003func TestResolveOllamaFallsBackToDefaultBaseURL(t *testing.T) {
1004	t.Setenv("ZUT_HOME", t.TempDir())
1005	provider.SetLiveModels(nil)
1006	defer provider.SetLiveModels(nil)
1007
1008	r, err := Resolve(Args{Provider: "ollama", Model: "any-local-model"}, false)
1009	if err != nil {
1010		t.Fatalf("Resolve failed: %v", err)
1011	}
1012	if r.BaseURL != "http://localhost:11434" {
1013		t.Fatalf("BaseURL = %q, want ollama default", r.BaseURL)
1014	}
1015}
1016
1017func TestCanonicalProviderResolvesAliases(t *testing.T) {
1018	cases := map[string]string{
1019		"bedrock":         "amazon-bedrock",
1020		"AWS-Bedrock":     "amazon-bedrock",
1021		"  bedrock  ":     "amazon-bedrock",
1022		"vertex":          "google-vertex",
1023		"gemini":          "google",
1024		"azure":           "azure-openai-responses",
1025		"copilot":         "github-copilot",
1026		"codex":           "openai-codex",
1027		"moonshot":        "moonshotai",
1028		"vercel":          "vercel-ai-gateway",
1029		"hf":              "huggingface",
1030		"anthropic":       "anthropic",       // canonical passes through
1031		"amazon-bedrock":  "amazon-bedrock",  // already canonical
1032		"totally-unknown": "totally-unknown", // unknown returned unchanged (lowered)
1033		"Totally-UNKNOWN": "totally-unknown",
1034		"":                "",
1035	}
1036	for in, want := range cases {
1037		if got := canonicalProvider(in); got != want {
1038			t.Errorf("canonicalProvider(%q) = %q, want %q", in, got, want)
1039		}
1040	}
1041}
1042
1043func TestCanonicalProviderAliasesAreKnown(t *testing.T) {
1044	for alias, canon := range providerAliases {
1045		if !isKnownProvider(canon) {
1046			t.Errorf("alias %q maps to %q which is not a known provider", alias, canon)
1047		}
1048	}
1049}
1050
1051func TestResolveInsecureOnlyWithExplicitBaseURL(t *testing.T) {
1052	orig := http.DefaultTransport
1053	t.Cleanup(func() { http.DefaultTransport = orig })
1054
1055	t.Setenv("ZUT_HOME", t.TempDir())
1056	t.Setenv("OPENAI_API_KEY", "test-key")
1057
1058	resolved, err := Resolve(Args{Provider: "moonshotai", InsecureTLS: true}, false)
1059	if err != nil {
1060		t.Fatalf("Resolve: %v", err)
1061	}
1062	if resolved.InsecureTLS {
1063		t.Fatal("InsecureTLS must not be set for built-in provider base URLs")
1064	}
1065	assertDefaultTransportStillSecure(t)
1066
1067	resolved, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false)
1068	if err != nil {
1069		t.Fatalf("Resolve: %v", err)
1070	}
1071	if !resolved.InsecureTLS {
1072		t.Fatal("InsecureTLS must be set with --insecure and explicit --base-url")
1073	}
1074	assertDefaultTransportStillSecure(t)
1075}
1076
1077func TestResolveInsecureFromConfigRequiresExplicitBaseURL(t *testing.T) {
1078	orig := http.DefaultTransport
1079	t.Cleanup(func() { http.DefaultTransport = orig })
1080
1081	t.Setenv("ZUT_HOME", t.TempDir())
1082	t.Setenv("OPENAI_API_KEY", "test-key")
1083	if err := SaveConfig(Config{Provider: "openai", Insecure: true}); err != nil {
1084		t.Fatal(err)
1085	}
1086
1087	resolved, err := Resolve(Args{Provider: "openai"}, false)
1088	if err != nil {
1089		t.Fatalf("Resolve: %v", err)
1090	}
1091	if resolved.InsecureTLS {
1092		t.Fatal("InsecureTLS must not be set without a custom base URL")
1093	}
1094	assertDefaultTransportStillSecure(t)
1095
1096	resolved, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false)
1097	if err != nil {
1098		t.Fatalf("Resolve: %v", err)
1099	}
1100	if !resolved.InsecureTLS {
1101		t.Fatal("InsecureTLS must be set when config insecure=true and --base-url is provided")
1102	}
1103	assertDefaultTransportStillSecure(t)
1104}
1105
1106func TestDefaultXAIModelIsGrok45(t *testing.T) {
1107	if got := defaultModelForProvider("xai"); got != "grok-4.5" {
1108		t.Fatalf("default xAI model = %q, want grok-4.5", got)
1109	}
1110}
1111
1112func assertDefaultTransportStillSecure(t *testing.T) {
1113	t.Helper()
1114	tr, ok := http.DefaultTransport.(*http.Transport)
1115	if !ok {
1116		return
1117	}
1118	if tr.TLSClientConfig != nil && tr.TLSClientConfig.InsecureSkipVerify {
1119		t.Fatal("http.DefaultTransport must not be made insecure")
1120	}
1121}
1122
1123func TestResolveWebSearchRegistryRespectsConfigAndExplicitCLI(t *testing.T) {
1124	t.Setenv("ZUT_HOME", t.TempDir())
1125	disabled := false
1126	if err := SaveConfig(Config{WebSearchEnabled: &disabled}); err != nil {
1127		t.Fatal(err)
1128	}
1129	args := Args{CWD: t.TempDir(), Provider: "ollama", Model: "any-local-model"}
1130	resolved, err := Resolve(args, false)
1131	if err != nil {
1132		t.Fatal(err)
1133	}
1134	if _, ok := resolved.ToolRegistry["web_search"]; ok {
1135		t.Fatal("persisted opt-out left web_search in the registry")
1136	}
1137
1138	args.ToolsSet = true
1139	args.Tools = []string{"web_search"}
1140	resolved, err = Resolve(args, false)
1141	if err != nil {
1142		t.Fatal(err)
1143	}
1144	if _, ok := resolved.ToolRegistry["web_search"]; !ok {
1145		t.Fatal("explicit --tools web_search did not override persisted opt-out")
1146	}
1147
1148	args.PermissionSet = &tools.PermissionSet{}
1149	resolved, err = Resolve(args, false)
1150	if err != nil {
1151		t.Fatal(err)
1152	}
1153	if _, ok := resolved.ToolRegistry["web_search"]; ok {
1154		t.Fatal("packaged-agent PermissionSet received web_search")
1155	}
1156}
1157
1158func TestResolveWebSearchPolicy(t *testing.T) {
1159	disabled := false
1160	cases := []struct {
1161		name    string
1162		args    Args
1163		cfg     Config
1164		cfgErr  error
1165		profile *subagents.Profile
1166		want    subagents.WebSearchPolicy
1167	}{
1168		{name: "legacy config", want: subagents.WebSearchAllow},
1169		{name: "persisted opt out", cfg: Config{WebSearchEnabled: &disabled}, want: subagents.WebSearchDeny},
1170		{name: "explicit CLI allow overrides opt out", args: Args{ToolsSet: true, Tools: []string{"web_search"}}, cfg: Config{WebSearchEnabled: &disabled}, want: subagents.WebSearchAllow},
1171		{name: "explicit empty CLI list denies", args: Args{ToolsSet: true}, want: subagents.WebSearchDeny},
1172		{name: "explicit nonmatching CLI list denies", args: Args{ToolsSet: true, Tools: []string{"read"}}, want: subagents.WebSearchDeny},
1173		{name: "no tools denies", args: Args{NoTools: true}, want: subagents.WebSearchDeny},
1174		{name: "packaged agent denies", args: Args{PermissionSet: &tools.PermissionSet{}}, want: subagents.WebSearchDeny},
1175		{name: "internal policy denies", args: Args{WebSearchPolicy: subagents.WebSearchDeny}, want: subagents.WebSearchDeny},
1176		{name: "internal policy allows", args: Args{WebSearchPolicy: subagents.WebSearchAllow}, cfg: Config{WebSearchEnabled: &disabled}, want: subagents.WebSearchAllow},
1177		{name: "normal explicit empty list narrows internal allow", args: Args{ToolsSet: true, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1178		{name: "worker missing policy denies despite legacy config", args: Args{Mode: ModeSubagentWorker}, want: subagents.WebSearchDeny},
1179		{name: "worker explicit inherit denies", args: Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchInherit}, want: subagents.WebSearchDeny},
1180		{name: "worker invalid policy denies", args: Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchPolicy(99)}, want: subagents.WebSearchDeny},
1181		{name: "worker propagated deny remains denied", args: Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchDeny}, want: subagents.WebSearchDeny},
1182		{name: "worker propagated allow", args: Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchAllow}, cfg: Config{WebSearchEnabled: &disabled}, want: subagents.WebSearchAllow},
1183		{name: "worker explicit empty list caps allow", args: Args{Mode: ModeSubagentWorker, ToolsSet: true, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1184		{name: "worker nonmatching list caps allow", args: Args{Mode: ModeSubagentWorker, ToolsSet: true, Tools: []string{"read"}, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1185		{name: "worker matching list preserves allow", args: Args{Mode: ModeSubagentWorker, ToolsSet: true, Tools: []string{"read", "web_search"}, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchAllow},
1186		{name: "worker no tools caps allow", args: Args{Mode: ModeSubagentWorker, NoTools: true, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1187		{name: "worker permission set caps allow", args: Args{Mode: ModeSubagentWorker, PermissionSet: &tools.PermissionSet{}, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1188		{name: "config load failure denies inherited policy", cfgErr: fmt.Errorf("broken config"), want: subagents.WebSearchDeny},
1189		{name: "named profile requires explicit tool", args: Args{WebSearchPolicy: subagents.WebSearchAllow}, profile: &subagents.Profile{Tools: []string{"read"}}, want: subagents.WebSearchDeny},
1190		{name: "named profile allows explicit tool", args: Args{WebSearchPolicy: subagents.WebSearchAllow}, profile: &subagents.Profile{Tools: []string{"read", "web_search"}}, want: subagents.WebSearchAllow},
1191		{name: "named profile cannot rescue worker inherit", args: Args{Mode: ModeSubagentWorker}, profile: &subagents.Profile{Tools: []string{"web_search"}}, want: subagents.WebSearchDeny},
1192		{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},
1193	}
1194	for _, tc := range cases {
1195		t.Run(tc.name, func(t *testing.T) {
1196			if got := resolveWebSearchPolicy(tc.args, tc.cfg, tc.cfgErr, tc.profile); got != tc.want {
1197				t.Fatalf("policy = %v, want %v", got, tc.want)
1198			}
1199		})
1200	}
1201}

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