v0.38.0 58133ea

web_search_setting_test.go RawHistoryFolder

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}

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