v0.11.4 020512d

web_search_integration_test.go RawHistoryFolder

1package agent
2
3import (
4	"encoding/json"
5	"os"
6	"path/filepath"
7	"reflect"
8	"strings"
9	"testing"
10
11	"github.com/bnema/zut/packages/agent/subagents"
12	"github.com/bnema/zut/packages/agent/tools"
13	"github.com/bnema/zut/packages/core"
14)
15
16func resolveWebSearchIntegrationCLI(t *testing.T, argv ...string) Resolved {
17	t.Helper()
18	args, err := ParseArgs(argv)
19	if err != nil {
20		t.Fatalf("ParseArgs(%q): %v", argv, err)
21	}
22	args.CWD = t.TempDir()
23	resolved, err := Resolve(args, false)
24	if err != nil {
25		t.Fatalf("Resolve(%q): %v", argv, err)
26	}
27	return resolved
28}
29
30func TestWebSearchConfigAndCLIRegistryPolicy(t *testing.T) {
31	t.Setenv("ZUT_HOME", t.TempDir())
32
33	// A config written before web_search existed has no field for it and must
34	// retain the default availability.
35	if err := SaveConfig(Config{Provider: "ollama", Model: "legacy-test-model"}); err != nil {
36		t.Fatal(err)
37	}
38	legacy, err := LoadConfig()
39	if err != nil {
40		t.Fatal(err)
41	}
42	if legacy.WebSearchEnabled != nil || !legacy.WebSearchEnabledForCLI() {
43		t.Fatalf("legacy web-search setting = %#v, want default enabled", legacy.WebSearchEnabled)
44	}
45	resolved := resolveWebSearchIntegrationCLI(t, "--no-lsp", "--no-skill")
46	if resolved.WebSearchPolicy != subagents.WebSearchAllow {
47		t.Fatalf("legacy web-search policy = %v, want allow", resolved.WebSearchPolicy)
48	}
49	if _, ok := resolved.ToolRegistry["web_search"].(*tools.WebSearchTool); !ok {
50		t.Fatalf("legacy registry web_search = %#v", resolved.ToolRegistry["web_search"])
51	}
52
53	disabled := false
54	if err := SaveConfig(Config{
55		Provider:         "ollama",
56		Model:            "legacy-test-model",
57		WebSearchEnabled: &disabled,
58	}); err != nil {
59		t.Fatal(err)
60	}
61	resolved = resolveWebSearchIntegrationCLI(t, "--no-lsp", "--no-skill")
62	if resolved.WebSearchPolicy != subagents.WebSearchDeny {
63		t.Fatalf("persisted opt-out policy = %v, want deny", resolved.WebSearchPolicy)
64	}
65	if _, ok := resolved.ToolRegistry["web_search"]; ok {
66		t.Fatal("persisted false opt-out retained web_search")
67	}
68
69	// An explicit capability opt-in wins over the persisted default, but a
70	// packaged PermissionSet still denies network-capable web_search.
71	resolved = resolveWebSearchIntegrationCLI(t, "--no-lsp", "--no-skill", "--tools", "web_search")
72	if resolved.WebSearchPolicy != subagents.WebSearchAllow {
73		t.Fatalf("explicit web-search opt-in policy = %v, want allow", resolved.WebSearchPolicy)
74	}
75	if _, ok := resolved.ToolRegistry["web_search"].(*tools.WebSearchTool); !ok {
76		t.Fatalf("explicit opt-in registry web_search = %#v", resolved.ToolRegistry["web_search"])
77	}
78
79	args, err := ParseArgs([]string{"--no-lsp", "--no-skill", "--tools", "web_search"})
80	if err != nil {
81		t.Fatal(err)
82	}
83	args.CWD = t.TempDir()
84	args.PermissionSet = &tools.PermissionSet{}
85	resolved, err = Resolve(args, false)
86	if err != nil {
87		t.Fatal(err)
88	}
89	if resolved.WebSearchPolicy != subagents.WebSearchDeny {
90		t.Fatalf("packaged PermissionSet policy = %v, want deny", resolved.WebSearchPolicy)
91	}
92	if _, ok := resolved.ToolRegistry["web_search"]; ok {
93		t.Fatal("packaged PermissionSet received web_search")
94	}
95
96	// Restore the legacy/default config to isolate explicit --tools behavior
97	// from the persisted setting tested above.
98	if err := SaveConfig(Config{Provider: "ollama", Model: "legacy-test-model"}); err != nil {
99		t.Fatal(err)
100	}
101	resolved = resolveWebSearchIntegrationCLI(t, "--no-lsp", "--no-skill", "--tools", "")
102	if _, ok := resolved.ToolRegistry["web_search"]; ok {
103		t.Fatal("explicit empty --tools list retained web_search")
104	}
105	for _, name := range []string{"read", "write", "edit", "bash"} {
106		if _, ok := resolved.ToolRegistry[name]; !ok {
107			t.Fatalf("explicit empty --tools list removed ordinary tool %q", name)
108		}
109	}
110
111	resolved = resolveWebSearchIntegrationCLI(t, "--no-lsp", "--no-skill", "--tools", "read")
112	if _, ok := resolved.ToolRegistry["web_search"]; ok {
113		t.Fatal("explicit non-matching --tools list retained web_search")
114	}
115	if _, ok := resolved.ToolRegistry["read"]; !ok {
116		t.Fatal("explicit non-matching --tools list removed requested ordinary read tool")
117	}
118	if _, ok := resolved.ToolRegistry["write"]; ok {
119		t.Fatal("explicit non-matching --tools list unexpectedly enabled write")
120	}
121}
122
123func TestWebSearchConfigLoadFailureFailsClosed(t *testing.T) {
124	home := t.TempDir()
125	t.Setenv("ZUT_HOME", home)
126	if err := os.WriteFile(ConfigPath(), []byte("{"), 0o600); err != nil {
127		t.Fatal(err)
128	}
129
130	resolved, err := Resolve(Args{
131		Mode:     ModeInteractive,
132		CWD:      t.TempDir(),
133		Provider: "ollama",
134		Model:    "any-local-model",
135		NoLSP:    true,
136		NoSkill:  true,
137	}, false)
138	if err != nil {
139		t.Fatal(err)
140	}
141	if resolved.WebSearchPolicy != subagents.WebSearchDeny {
142		t.Fatalf("malformed-config policy = %v, want deny", resolved.WebSearchPolicy)
143	}
144	if _, ok := resolved.ToolRegistry["web_search"]; ok {
145		t.Fatal("malformed config defaulted web_search to enabled")
146	}
147}
148
149func TestWebSearchInternalWorkerPolicyCannotBypassNormalToolFilter(t *testing.T) {
150	t.Setenv("ZUT_HOME", t.TempDir())
151	disabled := false
152	if err := SaveConfig(Config{Provider: "ollama", Model: "any-local-model", WebSearchEnabled: &disabled}); err != nil {
153		t.Fatal(err)
154	}
155
156	base := Args{
157		CWD:             t.TempDir(),
158		Provider:        "ollama",
159		Model:           "any-local-model",
160		NoLSP:           true,
161		NoSkill:         true,
162		ToolsSet:        true,
163		WebSearchPolicy: subagents.WebSearchAllow,
164	}
165	resolved, err := Resolve(base, false)
166	if err != nil {
167		t.Fatal(err)
168	}
169	if resolved.WebSearchPolicy != subagents.WebSearchDeny {
170		t.Fatalf("normal explicit-empty policy = %v, want deny", resolved.WebSearchPolicy)
171	}
172	if _, ok := resolved.ToolRegistry["web_search"]; ok {
173		t.Fatal("normal explicit-empty tool list was bypassed by internal allow")
174	}
175
176	base.Mode = ModeSubagentWorker
177	for _, tc := range []struct {
178		name          string
179		tools         []string
180		wantPolicy    subagents.WebSearchPolicy
181		wantWebSearch bool
182	}{
183		{name: "explicit empty list caps propagated allow", wantPolicy: subagents.WebSearchDeny},
184		{name: "nonmatching list caps propagated allow", tools: []string{"read"}, wantPolicy: subagents.WebSearchDeny},
185		{name: "matching list preserves propagated allow", tools: []string{"web_search"}, wantPolicy: subagents.WebSearchAllow, wantWebSearch: true},
186	} {
187		t.Run(tc.name, func(t *testing.T) {
188			base.Tools = tc.tools
189			resolved, err := Resolve(base, false)
190			if err != nil {
191				t.Fatal(err)
192			}
193			if resolved.WebSearchPolicy != tc.wantPolicy {
194				t.Fatalf("worker policy = %v, want %v", resolved.WebSearchPolicy, tc.wantPolicy)
195			}
196			_, hasWebSearch := resolved.ToolRegistry["web_search"]
197			if hasWebSearch != tc.wantWebSearch {
198				t.Fatalf("worker web_search present = %v, want %v", hasWebSearch, tc.wantWebSearch)
199			}
200		})
201	}
202}
203
204func TestRefreshWebSearchPolicyUsesNamedProfileAndRegistryCeiling(t *testing.T) {
205	home := t.TempDir()
206	profiles := filepath.Join(home, "profiles")
207	if err := os.MkdirAll(profiles, 0o755); err != nil {
208		t.Fatal(err)
209	}
210	t.Setenv("HOME", home)
211	t.Setenv("ZUT_HOME", filepath.Join(home, ".zut"))
212	t.Setenv("ZUT_AGENT_PROFILES", profiles)
213	if err := SaveConfig(Config{Provider: "ollama", Model: "any-local-model"}); err != nil {
214		t.Fatal(err)
215	}
216	if err := os.WriteFile(filepath.Join(profiles, "reader.md"), []byte("---\nname: reader\ntools: read\n---\nRead only."), 0o600); err != nil {
217		t.Fatal(err)
218	}
219	if err := os.WriteFile(filepath.Join(profiles, "searcher.md"), []byte("---\nname: searcher\ntools: web_search\n---\nSearch only."), 0o600); err != nil {
220		t.Fatal(err)
221	}
222
223	for _, tc := range []struct {
224		name    string
225		profile string
226		want    subagents.WebSearchPolicy
227	}{
228		{name: "profile without web search", profile: "reader", want: subagents.WebSearchDeny},
229		{name: "profile with web search", profile: "searcher", want: subagents.WebSearchAllow},
230	} {
231		t.Run(tc.name, func(t *testing.T) {
232			ag := core.NewAgent(nil, "", "", nil)
233			policy, err := refreshAgentToolsAndPrompt(Args{
234				Mode:     ModeInteractive,
235				CWD:      t.TempDir(),
236				Provider: "ollama",
237				Model:    "any-local-model",
238				Subagent: tc.profile,
239				NoLSP:    true,
240				NoSkill:  true,
241			}, nil, nil, ag, nil, nil)
242			if err != nil {
243				t.Fatal(err)
244			}
245			if policy != tc.want {
246				t.Fatalf("refresh policy = %v, want %v", policy, tc.want)
247			}
248			_, hasWebSearch := ag.ToolsSnapshot()["web_search"]
249			if hasWebSearch != (tc.want == subagents.WebSearchAllow) {
250				t.Fatalf("refreshed registry web_search present = %v", hasWebSearch)
251			}
252		})
253	}
254
255	ag := core.NewAgent(nil, "", "", nil)
256	policy, err := refreshAgentToolsAndPrompt(Args{
257		Mode:     ModeInteractive,
258		CWD:      t.TempDir(),
259		Provider: "ollama",
260		Model:    "any-local-model",
261		NoLSP:    true,
262		NoSkill:  true,
263	}, nil, nil, ag, func(reg core.Registry) core.Registry {
264		delete(reg, "web_search")
265		return reg
266	}, nil)
267	if err != nil {
268		t.Fatal(err)
269	}
270	if policy != subagents.WebSearchDeny {
271		t.Fatalf("registry-capped refresh policy = %v, want deny", policy)
272	}
273}
274
275func TestWebSearchSummaryAndSpecsHaveStableExposure(t *testing.T) {
276	t.Setenv("ZUT_HOME", t.TempDir())
277	resolved := resolveWebSearchIntegrationCLI(t, "--no-skill", "--tools", "read,write,edit,bash,lsp,web_search")
278	if lsp, ok := resolved.ToolRegistry["lsp"].(*tools.LSPTool); ok && lsp.Manager != nil {
279		t.Cleanup(func() { _ = lsp.Manager.Close() })
280	}
281
282	var summaryNames []string
283	for _, summary := range resolved.ToolSummary {
284		summaryNames = append(summaryNames, summary.Name)
285	}
286	wantSummary := []string{"read", "write", "edit", "bash", "lsp", "web_search"}
287	if !reflect.DeepEqual(summaryNames, wantSummary) {
288		t.Fatalf("tool summary names = %v, want %v", summaryNames, wantSummary)
289	}
290
291	specs := resolved.ToolRegistry.Specs()
292	var specNames []string
293	var webSpecFound bool
294	for _, spec := range specs {
295		specNames = append(specNames, spec.Name)
296		if spec.Name == "web_search" {
297			webSpecFound = true
298			if spec.Description == "" || !json.Valid(spec.Schema) || !strings.Contains(string(spec.Schema), `"query"`) {
299				t.Fatalf("web_search spec = %#v", spec)
300			}
301		}
302	}
303	wantSpecs := []string{"bash", "edit", "lsp", "read", "web_search", "write"}
304	if !reflect.DeepEqual(specNames, wantSpecs) {
305		t.Fatalf("tool spec names = %v, want %v", specNames, wantSpecs)
306	}
307	if !webSpecFound {
308		t.Fatal("tool specs omitted web_search")
309	}
310}
311
312func TestWebSearchExtensionNameIsReserved(t *testing.T) {
313	web := tools.NewWebSearchTool()
314	resolved := Resolved{ToolRegistry: core.NewRegistry(web)}
315	resolved.MergeExtensionTools(webSearchConflictSource{})
316
317	got, ok := resolved.ToolRegistry["web_search"]
318	if !ok {
319		t.Fatal("extension merge removed reserved web_search")
320	}
321	if got != web {
322		t.Fatalf("extension replaced reserved web_search: got %#v, want native tool", got)
323	}
324}

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