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}