v0.12.0 0c5f8d5

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	if r.ContextWindow != 1_000_000 || r.NewAgent().ContextWindow != 1_000_000 {
757		t.Fatalf("resolved/agent context window = %d/%d, want synthetic gateway metadata", r.ContextWindow, r.NewAgent().ContextWindow)
758	}
759}
760
761func TestResolveGatewayPlainUnknownModelFallsBack(t *testing.T) {
762	t.Setenv("ZUT_HOME", t.TempDir())
763	t.Setenv("OPENROUTER_API_KEY", "test-key")
764	stale := "not-a-routed-model"
765	if err := SaveConfig(Config{Provider: "openrouter", Model: stale}); err != nil {
766		t.Fatal(err)
767	}
768
769	r, err := Resolve(Args{}, true)
770	if err != nil {
771		t.Fatalf("Resolve failed: %v", err)
772	}
773	if r.Provider != "openrouter" {
774		t.Fatalf("provider = %q, want openrouter", r.Provider)
775	}
776	if r.Model == stale || r.Model == "" {
777		t.Fatalf("model = %q, want repaired gateway default", r.Model)
778	}
779}
780
781// TestResolveEnvOnlyBedrockDiscoveredWithoutConfig reproduces issue
782// #15: pointing ZUT_HOME at a fresh dir drops the persisted
783// config.json (which pinned provider=amazon-bedrock). Resolve must
784// still discover bedrock from the AWS env vars instead of falling back
785// to anthropic and reporting "not logged in".
786func TestResolveEnvOnlyBedrockDiscoveredWithoutConfig(t *testing.T) {
787	t.Setenv("ZUT_HOME", t.TempDir()) // fresh home: no config.json
788	// Disable the Kimi CLI token fallback so a developer machine with a
789	// real Kimi CLI login doesn't pre-empt bedrock in the scan.
790	if err := SetKimiCLIFallbackDisabled(true); err != nil {
791		t.Fatal(err)
792	}
793	t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "test-bedrock-token")
794	t.Setenv("AWS_REGION", "us-east-1")
795	// Make sure no other provider's env credential pre-empts bedrock.
796	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"} {
797		t.Setenv(k, "")
798	}
799
800	r, err := Resolve(Args{}, true)
801	if err != nil {
802		t.Fatalf("Resolve errored with env-only bedrock: %v", err)
803	}
804	if r.Provider != "amazon-bedrock" {
805		t.Fatalf("provider = %q, want amazon-bedrock", r.Provider)
806	}
807	if !r.HasCredential() {
808		t.Fatalf("bedrock credential not resolved from env")
809	}
810}
811
812func TestResolveOllamaUsesModelBaseURLBeforeDefault(t *testing.T) {
813	t.Setenv("ZUT_HOME", t.TempDir())
814	provider.SetLiveModels(nil)
815	defer provider.SetLiveModels(nil)
816	provider.SetUserModels([]provider.Model{{
817		Provider:      "ollama",
818		ID:            "qwen-local",
819		DisplayName:   "Qwen Local",
820		ContextWindow: 32768,
821		MaxOutput:     8192,
822		BaseURL:       "http://localhost:8000/v1",
823	}})
824
825	r, err := Resolve(Args{Provider: "ollama", Model: "qwen-local"}, false)
826	if err != nil {
827		t.Fatalf("Resolve failed: %v", err)
828	}
829	if r.BaseURL != "http://localhost:8000/v1" {
830		t.Fatalf("BaseURL = %q, want models.json baseUrl", r.BaseURL)
831	}
832}
833
834func TestResolveUsesInheritedSupervisorCredential(t *testing.T) {
835	t.Setenv("ZUT_HOME", t.TempDir())
836	t.Setenv("OPENAI_API_KEY", "")
837
838	r, err := Resolve(Args{
839		Provider:            "openai",
840		Model:               "gpt-5",
841		inheritedCredential: "inherited-key",
842		inheritedAuthMethod: "apikey",
843		inheritedAccountID:  "account-id",
844	}, true)
845	if err != nil {
846		t.Fatal(err)
847	}
848	if r.Credential != "inherited-key" || r.AuthMethod != "apikey" || r.AccountID != "account-id" {
849		t.Fatalf("inherited credential was not preserved: %+v", r)
850	}
851}
852
853func TestResolveLlamaCPPUsesRouterInferenceURL(t *testing.T) {
854	t.Setenv("ZUT_HOME", t.TempDir())
855	t.Setenv("LLAMA_BASE_URL", "http://127.0.0.1:8080/v1/")
856	t.Setenv("LLAMA_API_KEY", "")
857	provider.SetManagedModels(nil)
858	t.Cleanup(func() { provider.SetManagedModels(nil) })
859
860	r, err := Resolve(Args{Provider: "llama.cpp", Model: "local-model"}, true)
861	if err != nil {
862		t.Fatalf("Resolve failed: %v", err)
863	}
864	if r.BaseURL != "http://127.0.0.1:8080/v1" {
865		t.Fatalf("BaseURL = %q", r.BaseURL)
866	}
867	if r.Credential != "local" || !r.HasCredential() {
868		t.Fatalf("credential = %q", r.Credential)
869	}
870	if r.ContextWindow != 128000 || r.NewAgent().ContextWindow != 128000 {
871		t.Fatalf("resolved/agent context window = %d/%d, want synthesized local metadata", r.ContextWindow, r.NewAgent().ContextWindow)
872	}
873	if got := r.NewClient().Name(); got != provider.LlamaCPPProviderID {
874		t.Fatalf("client name = %q, want %q", got, provider.LlamaCPPProviderID)
875	}
876}
877
878func TestResolveLlamaCPPUsesStoredLogin(t *testing.T) {
879	t.Setenv("ZUT_HOME", t.TempDir())
880	t.Setenv("LLAMA_BASE_URL", "")
881	t.Setenv("LLAMA_API_KEY", "")
882	if err := AuthStoreFor().SetEndpointCredential("llama.cpp", "http://localhost:9090", "stored-key"); err != nil {
883		t.Fatal(err)
884	}
885
886	r, err := Resolve(Args{Provider: "llama.cpp", Model: "stored-model"}, true)
887	if err != nil {
888		t.Fatalf("Resolve failed: %v", err)
889	}
890	if r.BaseURL != "http://localhost:9090/v1" || r.Credential != "stored-key" {
891		t.Fatalf("resolved = base %q credential %q", r.BaseURL, r.Credential)
892	}
893}
894
895func TestResolveCustomProviderModelBaseURLBeatsProviderBaseURL(t *testing.T) {
896	t.Setenv("ZUT_HOME", t.TempDir())
897	t.Setenv("MY_COMPANY_API_KEY", "test-key")
898	path := filepath.Join(t.TempDir(), "models.json")
899	if err := os.WriteFile(path, []byte(`{
900		"providers": {
901			"my-company": {
902				"baseUrl": "https://provider.example.com/v1",
903				"api": "openai",
904				"models": [
905					{"id": "fast", "baseUrl": "https://model.example.com/v1", "contextWindow": 65536}
906				]
907			}
908		}
909	}`), 0o644); err != nil {
910		t.Fatal(err)
911	}
912	models, warnings := provider.LoadUserModelsWithWarnings(path)
913	if len(warnings) != 0 {
914		t.Fatalf("warnings = %v", warnings)
915	}
916	provider.SetLiveModels(nil)
917	provider.SetUserModels(models)
918	t.Cleanup(func() { provider.SetLiveModels(nil) })
919
920	r, err := Resolve(Args{Provider: "my-company", Model: "fast"}, true)
921	if err != nil {
922		t.Fatalf("Resolve failed: %v", err)
923	}
924	if r.BaseURL != "https://model.example.com/v1" {
925		t.Fatalf("BaseURL = %q, want model-level baseUrl", r.BaseURL)
926	}
927	if r.ContextWindow != 65536 || r.NewAgent().ContextWindow != 65536 {
928		t.Fatalf("resolved/agent context window = %d/%d, want retained user-model metadata", r.ContextWindow, r.NewAgent().ContextWindow)
929	}
930}
931
932func TestCustomProviderUsesOpenAIResponsesAPI(t *testing.T) {
933	requestPath := make(chan string, 1)
934	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
935		requestPath <- r.URL.Path
936		w.Header().Set("Content-Type", "text/event-stream")
937		w.WriteHeader(http.StatusOK)
938	}))
939	t.Cleanup(srv.Close)
940
941	path := filepath.Join(t.TempDir(), "models.json")
942	if err := os.WriteFile(path, []byte(`{
943		"providers": {
944			"company-responses": {
945				"baseUrl": "`+srv.URL+`/v1",
946				"api": "openai-responses",
947				"models": [{"id": "reasoning-model", "reasoning": true}]
948			}
949		}
950	}`), 0o644); err != nil {
951		t.Fatal(err)
952	}
953	models, warnings := provider.LoadUserModelsWithWarnings(path)
954	if len(warnings) != 0 {
955		t.Fatalf("warnings = %v", warnings)
956	}
957	provider.SetLiveModels(nil)
958	provider.SetUserModels(models)
959	t.Cleanup(func() { provider.SetLiveModels(nil) })
960
961	r := Resolved{
962		Provider:   "company-responses",
963		Credential: "test-key",
964		BaseURL:    srv.URL + "/v1",
965	}
966	events, err := r.NewClient().Stream(context.Background(), provider.Request{
967		Model:    "reasoning-model",
968		Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}},
969	})
970	if err != nil {
971		t.Fatal(err)
972	}
973	for range events {
974	}
975	if got := <-requestPath; got != "/v1/responses" {
976		t.Fatalf("request path = %q, want /v1/responses", got)
977	}
978}
979
980func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) {
981	t.Setenv("ZUT_HOME", t.TempDir())
982	t.Setenv("LOCAL_PROXY_API_KEY", "test-key")
983	path := filepath.Join(t.TempDir(), "models.json")
984	if err := os.WriteFile(path, []byte(`{
985		"providers": {
986			"local-proxy": {
987				"baseUrl": "https://proxy.example.com/v1",
988				"api": "openai",
989				"models": [{"id": "default"}]
990			}
991		}
992	}`), 0o644); err != nil {
993		t.Fatal(err)
994	}
995	models, warnings := provider.LoadUserModelsWithWarnings(path)
996	if len(warnings) != 0 {
997		t.Fatalf("warnings = %v", warnings)
998	}
999	provider.SetLiveModels(nil)
1000	provider.SetUserModels(models)
1001	t.Cleanup(func() { provider.SetLiveModels(nil) })
1002
1003	r, err := Resolve(Args{Provider: "local-proxy", Model: "default", InsecureTLS: true}, true)
1004	if err != nil {
1005		t.Fatalf("Resolve failed: %v", err)
1006	}
1007	if !r.InsecureTLS {
1008		t.Fatal("InsecureTLS must be set for --insecure with models.json custom baseUrl")
1009	}
1010}
1011
1012func TestResolveOllamaFallsBackToDefaultBaseURL(t *testing.T) {
1013	t.Setenv("ZUT_HOME", t.TempDir())
1014	provider.SetLiveModels(nil)
1015	defer provider.SetLiveModels(nil)
1016
1017	r, err := Resolve(Args{Provider: "ollama", Model: "any-local-model"}, false)
1018	if err != nil {
1019		t.Fatalf("Resolve failed: %v", err)
1020	}
1021	if r.BaseURL != "http://localhost:11434" {
1022		t.Fatalf("BaseURL = %q, want ollama default", r.BaseURL)
1023	}
1024}
1025
1026func TestCanonicalProviderResolvesAliases(t *testing.T) {
1027	cases := map[string]string{
1028		"bedrock":         "amazon-bedrock",
1029		"AWS-Bedrock":     "amazon-bedrock",
1030		"  bedrock  ":     "amazon-bedrock",
1031		"vertex":          "google-vertex",
1032		"gemini":          "google",
1033		"azure":           "azure-openai-responses",
1034		"copilot":         "github-copilot",
1035		"codex":           "openai-codex",
1036		"moonshot":        "moonshotai",
1037		"vercel":          "vercel-ai-gateway",
1038		"hf":              "huggingface",
1039		"anthropic":       "anthropic",       // canonical passes through
1040		"amazon-bedrock":  "amazon-bedrock",  // already canonical
1041		"totally-unknown": "totally-unknown", // unknown returned unchanged (lowered)
1042		"Totally-UNKNOWN": "totally-unknown",
1043		"":                "",
1044	}
1045	for in, want := range cases {
1046		if got := canonicalProvider(in); got != want {
1047			t.Errorf("canonicalProvider(%q) = %q, want %q", in, got, want)
1048		}
1049	}
1050}
1051
1052func TestCanonicalProviderAliasesAreKnown(t *testing.T) {
1053	for alias, canon := range providerAliases {
1054		if !isKnownProvider(canon) {
1055			t.Errorf("alias %q maps to %q which is not a known provider", alias, canon)
1056		}
1057	}
1058}
1059
1060func TestResolveInsecureOnlyWithExplicitBaseURL(t *testing.T) {
1061	orig := http.DefaultTransport
1062	t.Cleanup(func() { http.DefaultTransport = orig })
1063
1064	t.Setenv("ZUT_HOME", t.TempDir())
1065	t.Setenv("OPENAI_API_KEY", "test-key")
1066
1067	resolved, err := Resolve(Args{Provider: "moonshotai", InsecureTLS: true}, false)
1068	if err != nil {
1069		t.Fatalf("Resolve: %v", err)
1070	}
1071	if resolved.InsecureTLS {
1072		t.Fatal("InsecureTLS must not be set for built-in provider base URLs")
1073	}
1074	assertDefaultTransportStillSecure(t)
1075
1076	resolved, err = Resolve(Args{Provider: "openai", InsecureTLS: true, 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 with --insecure and explicit --base-url")
1082	}
1083	assertDefaultTransportStillSecure(t)
1084}
1085
1086func TestResolveInsecureFromConfigRequiresExplicitBaseURL(t *testing.T) {
1087	orig := http.DefaultTransport
1088	t.Cleanup(func() { http.DefaultTransport = orig })
1089
1090	t.Setenv("ZUT_HOME", t.TempDir())
1091	t.Setenv("OPENAI_API_KEY", "test-key")
1092	if err := SaveConfig(Config{Provider: "openai", Insecure: true}); err != nil {
1093		t.Fatal(err)
1094	}
1095
1096	resolved, err := Resolve(Args{Provider: "openai"}, false)
1097	if err != nil {
1098		t.Fatalf("Resolve: %v", err)
1099	}
1100	if resolved.InsecureTLS {
1101		t.Fatal("InsecureTLS must not be set without a custom base URL")
1102	}
1103	assertDefaultTransportStillSecure(t)
1104
1105	resolved, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false)
1106	if err != nil {
1107		t.Fatalf("Resolve: %v", err)
1108	}
1109	if !resolved.InsecureTLS {
1110		t.Fatal("InsecureTLS must be set when config insecure=true and --base-url is provided")
1111	}
1112	assertDefaultTransportStillSecure(t)
1113}
1114
1115func TestDefaultXAIModelIsGrok45(t *testing.T) {
1116	if got := defaultModelForProvider("xai"); got != "grok-4.5" {
1117		t.Fatalf("default xAI model = %q, want grok-4.5", got)
1118	}
1119}
1120
1121func assertDefaultTransportStillSecure(t *testing.T) {
1122	t.Helper()
1123	tr, ok := http.DefaultTransport.(*http.Transport)
1124	if !ok {
1125		return
1126	}
1127	if tr.TLSClientConfig != nil && tr.TLSClientConfig.InsecureSkipVerify {
1128		t.Fatal("http.DefaultTransport must not be made insecure")
1129	}
1130}
1131
1132func TestResolveWebSearchRegistryRespectsConfigAndExplicitCLI(t *testing.T) {
1133	t.Setenv("ZUT_HOME", t.TempDir())
1134	disabled := false
1135	if err := SaveConfig(Config{WebSearchEnabled: &disabled}); err != nil {
1136		t.Fatal(err)
1137	}
1138	args := Args{CWD: t.TempDir(), Provider: "ollama", Model: "any-local-model"}
1139	resolved, err := Resolve(args, false)
1140	if err != nil {
1141		t.Fatal(err)
1142	}
1143	if _, ok := resolved.ToolRegistry["web_search"]; ok {
1144		t.Fatal("persisted opt-out left web_search in the registry")
1145	}
1146
1147	args.ToolsSet = true
1148	args.Tools = []string{"web_search"}
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("explicit --tools web_search did not override persisted opt-out")
1155	}
1156
1157	args.PermissionSet = &tools.PermissionSet{}
1158	resolved, err = Resolve(args, false)
1159	if err != nil {
1160		t.Fatal(err)
1161	}
1162	if _, ok := resolved.ToolRegistry["web_search"]; ok {
1163		t.Fatal("packaged-agent PermissionSet received web_search")
1164	}
1165}
1166
1167func TestResolveWebSearchPolicy(t *testing.T) {
1168	disabled := false
1169	cases := []struct {
1170		name    string
1171		args    Args
1172		cfg     Config
1173		cfgErr  error
1174		profile *subagents.Profile
1175		want    subagents.WebSearchPolicy
1176	}{
1177		{name: "legacy config", want: subagents.WebSearchAllow},
1178		{name: "persisted opt out", cfg: Config{WebSearchEnabled: &disabled}, want: subagents.WebSearchDeny},
1179		{name: "explicit CLI allow overrides opt out", args: Args{ToolsSet: true, Tools: []string{"web_search"}}, cfg: Config{WebSearchEnabled: &disabled}, want: subagents.WebSearchAllow},
1180		{name: "explicit empty CLI list denies", args: Args{ToolsSet: true}, want: subagents.WebSearchDeny},
1181		{name: "explicit nonmatching CLI list denies", args: Args{ToolsSet: true, Tools: []string{"read"}}, want: subagents.WebSearchDeny},
1182		{name: "no tools denies", args: Args{NoTools: true}, want: subagents.WebSearchDeny},
1183		{name: "packaged agent denies", args: Args{PermissionSet: &tools.PermissionSet{}}, want: subagents.WebSearchDeny},
1184		{name: "internal policy denies", args: Args{WebSearchPolicy: subagents.WebSearchDeny}, want: subagents.WebSearchDeny},
1185		{name: "internal policy allows", args: Args{WebSearchPolicy: subagents.WebSearchAllow}, cfg: Config{WebSearchEnabled: &disabled}, want: subagents.WebSearchAllow},
1186		{name: "normal explicit empty list narrows internal allow", args: Args{ToolsSet: true, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1187		{name: "worker missing policy denies despite legacy config", args: Args{Mode: ModeSubagentWorker}, want: subagents.WebSearchDeny},
1188		{name: "worker explicit inherit denies", args: Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchInherit}, want: subagents.WebSearchDeny},
1189		{name: "worker invalid policy denies", args: Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchPolicy(99)}, want: subagents.WebSearchDeny},
1190		{name: "worker propagated deny remains denied", args: Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchDeny}, want: subagents.WebSearchDeny},
1191		{name: "worker propagated allow", args: Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchAllow}, cfg: Config{WebSearchEnabled: &disabled}, want: subagents.WebSearchAllow},
1192		{name: "worker explicit empty list caps allow", args: Args{Mode: ModeSubagentWorker, ToolsSet: true, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1193		{name: "worker nonmatching list caps allow", args: Args{Mode: ModeSubagentWorker, ToolsSet: true, Tools: []string{"read"}, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1194		{name: "worker matching list preserves allow", args: Args{Mode: ModeSubagentWorker, ToolsSet: true, Tools: []string{"read", "web_search"}, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchAllow},
1195		{name: "worker no tools caps allow", args: Args{Mode: ModeSubagentWorker, NoTools: true, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1196		{name: "worker permission set caps allow", args: Args{Mode: ModeSubagentWorker, PermissionSet: &tools.PermissionSet{}, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1197		{name: "config load failure denies inherited policy", cfgErr: fmt.Errorf("broken config"), want: subagents.WebSearchDeny},
1198		{name: "named profile requires explicit tool", args: Args{WebSearchPolicy: subagents.WebSearchAllow}, profile: &subagents.Profile{Tools: []string{"read"}}, want: subagents.WebSearchDeny},
1199		{name: "named profile allows explicit tool", args: Args{WebSearchPolicy: subagents.WebSearchAllow}, profile: &subagents.Profile{Tools: []string{"read", "web_search"}}, want: subagents.WebSearchAllow},
1200		{name: "named profile cannot rescue worker inherit", args: Args{Mode: ModeSubagentWorker}, profile: &subagents.Profile{Tools: []string{"web_search"}}, want: subagents.WebSearchDeny},
1201		{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},
1202	}
1203	for _, tc := range cases {
1204		t.Run(tc.name, func(t *testing.T) {
1205			if got := resolveWebSearchPolicy(tc.args, tc.cfg, tc.cfgErr, tc.profile); got != tc.want {
1206				t.Fatalf("policy = %v, want %v", got, tc.want)
1207			}
1208		})
1209	}
1210}

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