1package modes
2
3import (
4 "errors"
5 "strings"
6 "testing"
7
8 "github.com/bnema/zut/packages/agent/modes/telegram"
9 "github.com/bnema/zut/packages/agent/tools"
10 "github.com/bnema/zut/packages/core"
11)
12
13type recordingWebSearchSettingsStore struct {
14 SettingsStore
15 values []bool
16 calls int
17 err error
18 failures map[int]error
19}
20
21func (s *recordingWebSearchSettingsStore) SetWebSearchEnabled(enabled bool) error {
22 s.calls++
23 if s.err != nil {
24 return s.err
25 }
26 if err := s.failures[s.calls]; err != nil {
27 return err
28 }
29 s.values = append(s.values, enabled)
30 return nil
31}
32
33func TestSettingsDialogOffersWebSearchEnabledByDefault(t *testing.T) {
34 store := &recordingWebSearchSettingsStore{}
35 interactive := NewInteractive(InteractiveConfig{
36 SettingsStore: store,
37 RefreshTools: func() error { return nil },
38 })
39 interactive.openSettingsDialog()
40
41 item := findSettingsItem(interactive.settingsDialog.items, "web_search_enabled")
42 if item == nil {
43 t.Fatal("settings dialog is missing web search")
44 }
45 if !item.value {
46 t.Fatal("web search should be enabled when the config value is absent")
47 }
48 if item.disabled {
49 t.Fatal("web search should be available with persistence and refresh capabilities")
50 }
51}
52
53func TestSettingsDialogDisablesWebSearchAtInvocationCapabilityCeiling(t *testing.T) {
54 allowed := false
55 store := &recordingWebSearchSettingsStore{}
56 refreshCalls := 0
57 interactive := NewInteractive(InteractiveConfig{
58 WebSearchToolAllowed: &allowed,
59 SettingsStore: store,
60 RefreshTools: func() error {
61 refreshCalls++
62 return nil
63 },
64 })
65 interactive.rend = nil
66 interactive.openSettingsDialog()
67
68 item := findSettingsItem(interactive.settingsDialog.items, "web_search_enabled")
69 if item == nil {
70 t.Fatal("settings dialog is missing web search")
71 }
72 if item.value {
73 t.Fatal("web search was reported enabled despite the invocation ceiling")
74 }
75 if !item.disabled || !strings.Contains(item.hint, "launch-time tool capability ceiling") {
76 t.Fatalf("ceiling item = disabled %v hint %q", item.disabled, item.hint)
77 }
78
79 interactive.applySettingToggle("web_search_enabled", true)
80 if store.calls != 0 || refreshCalls != 0 {
81 t.Fatalf("ceiling toggle persisted %d times and refreshed %d times", store.calls, refreshCalls)
82 }
83}
84
85func TestSettingsDialogExplainsExplicitWebSearchInvocationOverride(t *testing.T) {
86 persisted := false
87 store := &recordingWebSearchSettingsStore{}
88 refreshCalls := 0
89 interactive := NewInteractive(InteractiveConfig{
90 WebSearchEnabled: &persisted,
91 WebSearchInvocationOverride: true,
92 SettingsStore: store,
93 RefreshTools: func() error {
94 refreshCalls++
95 return nil
96 },
97 })
98 interactive.rend = nil
99 interactive.openSettingsDialog()
100
101 item := findSettingsItem(interactive.settingsDialog.items, "web_search_enabled")
102 if item == nil {
103 t.Fatal("settings dialog is missing web search")
104 }
105 if !item.value || !item.disabled {
106 t.Fatalf("explicit override item = value %v disabled %v, want true and true", item.value, item.disabled)
107 }
108 if !strings.Contains(item.hint, "explicit --tools web_search") || !strings.Contains(item.hint, "without --tools") {
109 t.Fatalf("explicit override hint = %q", item.hint)
110 }
111
112 interactive.applySettingToggle("web_search_enabled", false)
113 if store.calls != 0 || refreshCalls != 0 {
114 t.Fatalf("explicit override toggle persisted %d times and refreshed %d times", store.calls, refreshCalls)
115 }
116 if interactive.statusErr == "" {
117 t.Fatal("explicit override toggle should explain why the row is read-only")
118 }
119}
120
121func TestSettingsDialogDisablesWebSearchWithoutCapabilities(t *testing.T) {
122 cases := []struct {
123 name string
124 cfg InteractiveConfig
125 }{
126 {name: "neither capability", cfg: InteractiveConfig{}},
127 {name: "persistence only", cfg: InteractiveConfig{SettingsStore: &recordingWebSearchSettingsStore{}}},
128 {name: "refresh only", cfg: InteractiveConfig{RefreshTools: func() error { return nil }}},
129 }
130 for _, tc := range cases {
131 t.Run(tc.name, func(t *testing.T) {
132 interactive := NewInteractive(tc.cfg)
133 interactive.openSettingsDialog()
134
135 item := findSettingsItem(interactive.settingsDialog.items, "web_search_enabled")
136 if item == nil {
137 t.Fatal("settings dialog is missing web search")
138 }
139 if !item.disabled {
140 t.Fatal("web search should be disabled without both capabilities")
141 }
142 if item.hint == "" {
143 t.Fatal("disabled web search should explain why it is unavailable")
144 }
145 })
146 }
147}
148
149func TestApplyWebSearchToggleRefreshesAndPersists(t *testing.T) {
150 store := &recordingWebSearchSettingsStore{}
151 refreshCalls := 0
152 interactive := NewInteractive(InteractiveConfig{
153 SettingsStore: store,
154 RefreshTools: func() error {
155 refreshCalls++
156 return nil
157 },
158 })
159 interactive.rend = nil
160
161 interactive.applySettingToggle("web_search_enabled", false)
162 if interactive.webSearchEnabled() {
163 t.Fatal("web search remained enabled after disabling it")
164 }
165 interactive.applySettingToggle("web_search_enabled", true)
166 if !interactive.webSearchEnabled() {
167 t.Fatal("web search remained disabled after enabling it")
168 }
169 if refreshCalls != 2 {
170 t.Fatalf("refresh calls = %d, want 2", refreshCalls)
171 }
172 if len(store.values) != 2 || store.values[0] || !store.values[1] {
173 t.Fatalf("persisted values = %v, want [false true]", store.values)
174 }
175 if interactive.statusErr != "" {
176 t.Fatalf("unexpected status error: %q", interactive.statusErr)
177 }
178}
179
180func TestApplyWebSearchToggleRollsBackWhenRefreshFails(t *testing.T) {
181 store := &recordingWebSearchSettingsStore{}
182 refreshCalls := 0
183 interactive := NewInteractive(InteractiveConfig{
184 SettingsStore: store,
185 RefreshTools: func() error {
186 refreshCalls++
187 if refreshCalls == 1 {
188 return errors.New("refresh failed")
189 }
190 return nil
191 },
192 })
193 interactive.rend = nil
194
195 interactive.applySettingToggle("web_search_enabled", false)
196 if !interactive.webSearchEnabled() {
197 t.Fatal("web search stayed disabled after refresh rollback")
198 }
199 if refreshCalls != 2 {
200 t.Fatalf("refresh calls = %d, want initial refresh and rollback refresh", refreshCalls)
201 }
202 if len(store.values) != 2 || store.values[0] || !store.values[1] {
203 t.Fatalf("persisted rollback values = %v, want [false true]", store.values)
204 }
205 if !strings.Contains(interactive.statusErr, "refresh failed") {
206 t.Fatalf("status error = %q, want refresh failure", interactive.statusErr)
207 }
208}
209
210func TestApplyWebSearchToggleFailsClosedWhenRollbackRefreshFails(t *testing.T) {
211 store := &recordingWebSearchSettingsStore{}
212 agent := core.NewAgent(nil, "model", "", core.Registry{
213 "web_search": tools.NewWebSearchTool(),
214 })
215 var availability []bool
216 interactive := NewInteractive(InteractiveConfig{
217 Agent: agent,
218 SettingsStore: store,
219 RefreshTools: func() error { return errors.New("refresh failed") },
220 SetWebSearchAvailable: func(available bool) {
221 availability = append(availability, available)
222 },
223 })
224 interactive.rend = nil
225
226 interactive.applySettingToggle("web_search_enabled", false)
227 if !interactive.webSearchEnabled() {
228 t.Fatal("persisted setting did not roll back to its prior value")
229 }
230 if _, ok := agent.ToolsSnapshot()["web_search"]; ok {
231 t.Fatal("rollback refresh failure left web_search callable")
232 }
233 if len(availability) == 0 || availability[len(availability)-1] {
234 t.Fatalf("generic-child/live availability = %v, want final deny", availability)
235 }
236 if !strings.Contains(interactive.statusErr, "rollback refresh") {
237 t.Fatalf("status error = %q, want rollback refresh failure", interactive.statusErr)
238 }
239}
240
241func TestApplyWebSearchToggleKeepsDurableStateWhenRollbackPersistenceFails(t *testing.T) {
242 store := &recordingWebSearchSettingsStore{
243 failures: map[int]error{2: errors.New("rollback disk full")},
244 }
245 refreshCalls := 0
246 agent := core.NewAgent(nil, "model", "", core.Registry{
247 "web_search": tools.NewWebSearchTool(),
248 })
249 var availability []bool
250 interactive := NewInteractive(InteractiveConfig{
251 Agent: agent,
252 SettingsStore: store,
253 RefreshTools: func() error {
254 refreshCalls++
255 return errors.New("refresh failed")
256 },
257 SetWebSearchAvailable: func(available bool) {
258 availability = append(availability, available)
259 },
260 })
261 interactive.rend = nil
262
263 interactive.applySettingToggle("web_search_enabled", false)
264 if interactive.webSearchEnabled() {
265 t.Fatal("in-memory setting reverted despite rollback persistence failure")
266 }
267 if len(store.values) != 1 || store.values[0] {
268 t.Fatalf("durable writes = %v, want only the successful disable", store.values)
269 }
270 if refreshCalls != 1 {
271 t.Fatalf("refresh calls = %d, want only the failed refresh", refreshCalls)
272 }
273 if _, ok := agent.ToolsSnapshot()["web_search"]; ok {
274 t.Fatal("failed rollback left the old callable web_search registry in place")
275 }
276 if len(availability) == 0 || availability[len(availability)-1] {
277 t.Fatalf("generic-child/live availability = %v, want final deny", availability)
278 }
279 if !strings.Contains(interactive.statusErr, "rollback disk full") {
280 t.Fatalf("status error = %q, want rollback failure", interactive.statusErr)
281 }
282}
283
284func TestTelegramBridgeRemovesWebSearchAndDeniesGenericChildren(t *testing.T) {
285 agent := core.NewAgent(nil, "model", "", core.Registry{
286 "web_search": tools.NewWebSearchTool(),
287 })
288 var availability []bool
289 interactive := NewInteractive(InteractiveConfig{
290 Agent: agent,
291 SetWebSearchAvailable: func(available bool) {
292 availability = append(availability, available)
293 },
294 })
295 interactive.telegramBridge = &telegram.Bridge{}
296 interactive.applyTelegramTools(true)
297 if _, ok := agent.ToolsSnapshot()["web_search"]; ok {
298 t.Fatal("connected Telegram bridge retained web_search")
299 }
300 if len(availability) == 0 || availability[0] || availability[len(availability)-1] {
301 t.Fatalf("generic-child/live availability = %v, want deny before bridge start", availability)
302 }
303}
304
305func TestStaleWebSearchResolveCannotCommitAfterPolicyChange(t *testing.T) {
306 agent := core.NewAgent(nil, "model", "old system", core.Registry{
307 "web_search": tools.NewWebSearchTool(),
308 })
309 var availability []bool
310 interactive := NewInteractive(InteractiveConfig{
311 Agent: agent,
312 SetWebSearchAvailable: func(available bool) {
313 availability = append(availability, available)
314 },
315 })
316
317 startedAt := interactive.WebSearchPolicyGeneration()
318 // Models a settings/Telegram revocation completing while Resolve is still
319 // discovering config, extensions, and tools.
320 interactive.stripWebSearchTool()
321 _, applied := interactive.ApplyAgentPromptConfigAtWebSearchGeneration(agent, "stale system", core.Registry{
322 "web_search": tools.NewWebSearchTool(),
323 }, startedAt)
324 if applied {
325 t.Fatal("resolve started before the policy change committed its stale registry")
326 }
327 if _, ok := agent.ToolsSnapshot()["web_search"]; ok {
328 t.Fatal("stale resolve reintroduced web_search")
329 }
330 system, _ := agent.PromptConfig()
331 if system != "old system" {
332 t.Fatalf("stale resolve changed system prompt to %q", system)
333 }
334 if len(availability) == 0 || availability[len(availability)-1] {
335 t.Fatalf("stale resolve availability = %v, want final deny", availability)
336 }
337}
338
339func TestTelegramBridgeStripsWebSearchFromPromptRefreshWhileStarting(t *testing.T) {
340 agent := core.NewAgent(nil, "model", "", core.Registry{})
341 var availability []bool
342 interactive := NewInteractive(InteractiveConfig{
343 Agent: agent,
344 SetWebSearchAvailable: func(available bool) {
345 availability = append(availability, available)
346 },
347 })
348 interactive.telegramBridge = &telegram.Bridge{}
349
350 _, applied := interactive.ApplyAgentPromptConfig(agent, "system", core.Registry{
351 "web_search": tools.NewWebSearchTool(),
352 })
353 if !applied {
354 t.Fatal("prompt config was not applied")
355 }
356 if _, ok := agent.ToolsSnapshot()["web_search"]; ok {
357 t.Fatal("starting Telegram bridge accepted web_search from a prompt refresh")
358 }
359 if len(availability) != 1 || availability[0] {
360 t.Fatalf("refresh availability = %v, want deny while bridge is attached", availability)
361 }
362}
363
364func TestTelegramBridgeStripsWebSearchBeforeLiveAgentReplacement(t *testing.T) {
365 for _, tc := range []struct {
366 name string
367 replace func(*Interactive, *core.Agent)
368 }{
369 {
370 name: "session replacement",
371 replace: func(interactive *Interactive, agent *core.Agent) {
372 interactive.ApplySessionAgent(agent, "provider", "model")
373 },
374 },
375 {
376 name: "changed cwd",
377 replace: func(interactive *Interactive, agent *core.Agent) {
378 interactive.ApplyChangedCWD(agent, "provider", "model", t.TempDir())
379 },
380 },
381 } {
382 t.Run(tc.name, func(t *testing.T) {
383 var availability []bool
384 interactive := NewInteractive(InteractiveConfig{
385 Agent: core.NewAgent(nil, "model", "", core.Registry{}),
386 SetWebSearchAvailable: func(available bool) {
387 availability = append(availability, available)
388 },
389 })
390 interactive.telegramBridge = &telegram.Bridge{}
391 replacement := core.NewAgent(nil, "model", "", core.Registry{
392 "web_search": tools.NewWebSearchTool(),
393 })
394
395 tc.replace(interactive, replacement)
396
397 if _, ok := replacement.ToolsSnapshot()["web_search"]; ok {
398 t.Fatal("active Telegram bridge exposed web_search on replacement agent")
399 }
400 if len(availability) == 0 || availability[len(availability)-1] {
401 t.Fatalf("replacement availability = %v, want final deny", availability)
402 }
403 })
404 }
405}
406
407func TestTelegramBridgeRestoresWebSearchOnlyAfterSuccessfulRefresh(t *testing.T) {
408 for _, tc := range []struct {
409 name string
410 refreshErr error
411 want bool
412 }{
413 {name: "successful refresh", want: true},
414 {name: "failed refresh", refreshErr: errors.New("refresh failed"), want: false},
415 } {
416 t.Run(tc.name, func(t *testing.T) {
417 agent := core.NewAgent(nil, "model", "", core.Registry{
418 "web_search": tools.NewWebSearchTool(),
419 })
420 var availability []bool
421 interactive := NewInteractive(InteractiveConfig{
422 Agent: agent,
423 RefreshTools: func() error {
424 if tc.refreshErr == nil {
425 agent.SetTools(core.Registry{"web_search": tools.NewWebSearchTool()})
426 }
427 return tc.refreshErr
428 },
429 SetWebSearchAvailable: func(available bool) {
430 availability = append(availability, available)
431 },
432 })
433 interactive.telegramBridge = &telegram.Bridge{}
434 interactive.applyTelegramTools(true)
435
436 interactive.telegramBridge = nil
437 err := interactive.refreshToolsAfterTelegram()
438 if !errors.Is(err, tc.refreshErr) {
439 t.Fatalf("refresh error = %v, want %v", err, tc.refreshErr)
440 }
441 _, got := agent.ToolsSnapshot()["web_search"]
442 if got != tc.want {
443 t.Fatalf("web_search restored = %v, want %v", got, tc.want)
444 }
445 if len(availability) == 0 || availability[len(availability)-1] != tc.want {
446 t.Fatalf("generic-child/live availability = %v, want final %v", availability, tc.want)
447 }
448 })
449 }
450}
451
452func TestApplyWebSearchToggleKeepsStateWhenPersistenceFails(t *testing.T) {
453 store := &recordingWebSearchSettingsStore{err: errors.New("disk full")}
454 refreshCalls := 0
455 interactive := NewInteractive(InteractiveConfig{
456 SettingsStore: store,
457 RefreshTools: func() error {
458 refreshCalls++
459 return nil
460 },
461 })
462 interactive.rend = nil
463
464 interactive.applySettingToggle("web_search_enabled", false)
465 if !interactive.webSearchEnabled() {
466 t.Fatal("web search changed despite persistence failure")
467 }
468 if refreshCalls != 0 {
469 t.Fatalf("refresh calls = %d, want none", refreshCalls)
470 }
471 if !strings.Contains(interactive.statusErr, "disk full") {
472 t.Fatalf("status error = %q, want persistence failure", interactive.statusErr)
473 }
474}