v0.6.0 01ec125

auto_compact_test.go RawHistoryFolder

1package modes
2
3import (
4	"errors"
5	"testing"
6
7	"github.com/bnema/zut/packages/provider"
8)
9
10func TestNormalizeAutoCompactThreshold(t *testing.T) {
11	tests := []struct {
12		name  string
13		value *int
14		want  int
15	}{
16		{name: "missing", value: nil, want: 85},
17		{name: "off", value: autoCompactIntPtr(0), want: 0},
18		{name: "seventy", value: autoCompactIntPtr(70), want: 70},
19		{name: "eighty", value: autoCompactIntPtr(80), want: 80},
20		{name: "eighty five", value: autoCompactIntPtr(85), want: 85},
21		{name: "ninety", value: autoCompactIntPtr(90), want: 90},
22		{name: "invalid low", value: autoCompactIntPtr(42), want: 85},
23		{name: "invalid high", value: autoCompactIntPtr(100), want: 85},
24	}
25
26	for _, tt := range tests {
27		t.Run(tt.name, func(t *testing.T) {
28			if got := normalizeAutoCompactThreshold(tt.value); got != tt.want {
29				t.Fatalf("normalizeAutoCompactThreshold(%v) = %d, want %d", tt.value, got, tt.want)
30			}
31		})
32	}
33}
34
35func TestClassifyCompactionContinuation(t *testing.T) {
36	tests := []struct {
37		name         string
38		origin       compactContinuationOrigin
39		statusActive bool
40		stop         provider.StopReason
41		turnErr      error
42		msgs         []provider.Message
43		want         compactContinuationReason
44	}{
45		{
46			name:   "completed text answer",
47			origin: compactOriginAfterTurnThreshold,
48			stop:   provider.StopEnd,
49			msgs: []provider.Message{{
50				Role:    provider.RoleAssistant,
51				Content: []provider.Content{provider.TextBlock{Text: "done"}},
52			}},
53			want: compactContinuationNone,
54		},
55		{
56			name:   "reasoning only is structural",
57			origin: compactOriginAfterTurnThreshold,
58			stop:   provider.StopEnd,
59			msgs: []provider.Message{{
60				Role:    provider.RoleAssistant,
61				Content: []provider.Content{provider.ReasoningBlock{Summary: "still working"}},
62			}},
63			want: compactContinuationStructuralTail,
64		},
65		{
66			name:   "tool call is structural",
67			origin: compactOriginAfterTurnThreshold,
68			stop:   provider.StopEnd,
69			msgs: []provider.Message{{
70				Role:    provider.RoleAssistant,
71				Content: []provider.Content{provider.ToolCallBlock{Name: "read"}},
72			}},
73			want: compactContinuationStructuralTail,
74		},
75		{
76			name:   "user tail is structural",
77			origin: compactOriginAfterTurnThreshold,
78			stop:   provider.StopEnd,
79			msgs: []provider.Message{{
80				Role:    provider.RoleUser,
81				Content: []provider.Content{provider.TextBlock{Text: "continue"}},
82			}},
83			want: compactContinuationStructuralTail,
84		},
85		{
86			name:   "next will inspect is status rescue",
87			origin: compactOriginAfterTurnThreshold,
88			stop:   provider.StopEnd,
89			msgs: []provider.Message{{
90				Role:    provider.RoleAssistant,
91				Content: []provider.Content{provider.TextBlock{Text: "Next I will inspect the remaining call sites."}},
92			}},
93			want: compactContinuationStatusRescue,
94		},
95		{
96			name:   "ill now run tests is status rescue",
97			origin: compactOriginAfterTurnThreshold,
98			stop:   provider.StopEnd,
99			msgs: []provider.Message{{
100				Role:    provider.RoleAssistant,
101				Content: []provider.Content{provider.TextBlock{Text: "I’ll now run the targeted tests."}},
102			}},
103			want: compactContinuationStatusRescue,
104		},
105		{
106			name:   "need to continue at sentence end is status rescue",
107			origin: compactOriginAfterTurnThreshold,
108			stop:   provider.StopEnd,
109			msgs: []provider.Message{{
110				Role:    provider.RoleAssistant,
111				Content: []provider.Content{provider.TextBlock{Text: "I need to continue."}},
112			}},
113			want: compactContinuationStatusRescue,
114		},
115		{
116			name:   "embedded phrase is not status rescue",
117			origin: compactOriginAfterTurnThreshold,
118			stop:   provider.StopEnd,
119			msgs: []provider.Message{{
120				Role:    provider.RoleAssistant,
121				Content: []provider.Content{provider.TextBlock{Text: "The mini will now absorb this conceptual example."}},
122			}},
123			want: compactContinuationNone,
124		},
125		{
126			name:   "generic next step prose is not status rescue",
127			origin: compactOriginAfterTurnThreshold,
128			stop:   provider.StopEnd,
129			msgs: []provider.Message{{
130				Role:    provider.RoleAssistant,
131				Content: []provider.Content{provider.TextBlock{Text: "The next step in this algorithm is normalization."}},
132			}},
133			want: compactContinuationNone,
134		},
135		{
136			name:   "empty",
137			origin: compactOriginAfterTurnThreshold,
138			stop:   provider.StopEnd,
139			want:   compactContinuationNone,
140		},
141		{
142			name:   "aborted status is not rescue",
143			origin: compactOriginAfterTurnThreshold,
144			stop:   provider.StopAborted,
145			msgs: []provider.Message{{
146				Role:    provider.RoleAssistant,
147				Content: []provider.Content{provider.TextBlock{Text: "Next I will inspect the remaining call sites."}},
148			}},
149			want: compactContinuationNone,
150		},
151		{
152			name:    "error status is not rescue",
153			origin:  compactOriginAfterTurnThreshold,
154			stop:    provider.StopEnd,
155			turnErr: errors.New("provider failed"),
156			msgs: []provider.Message{{
157				Role:    provider.RoleAssistant,
158				Content: []provider.Content{provider.TextBlock{Text: "Next I will inspect the remaining call sites."}},
159			}},
160			want: compactContinuationNone,
161		},
162		{
163			name:   "manual does not admit initial status rescue",
164			origin: compactOriginManual,
165			stop:   provider.StopEnd,
166			msgs: []provider.Message{{
167				Role:    provider.RoleAssistant,
168				Content: []provider.Content{provider.TextBlock{Text: "Next I will inspect the remaining call sites."}},
169			}},
170			want: compactContinuationNone,
171		},
172		{
173			name:   "pre-turn does not admit initial status rescue",
174			origin: compactOriginPreTurnThreshold,
175			stop:   provider.StopEnd,
176			msgs: []provider.Message{{
177				Role:    provider.RoleAssistant,
178				Content: []provider.Content{provider.TextBlock{Text: "Next I will inspect the remaining call sites."}},
179			}},
180			want: compactContinuationNone,
181		},
182		{
183			name:   "recovery does not admit initial status rescue",
184			origin: compactOriginRecovery,
185			stop:   provider.StopEnd,
186			msgs: []provider.Message{{
187				Role:    provider.RoleAssistant,
188				Content: []provider.Content{provider.TextBlock{Text: "Next I will inspect the remaining call sites."}},
189			}},
190			want: compactContinuationNone,
191		},
192		{
193			name:         "active rescue may classify its own follow-up",
194			origin:       compactOriginManual,
195			statusActive: true,
196			stop:         provider.StopEnd,
197			msgs: []provider.Message{{
198				Role:    provider.RoleAssistant,
199				Content: []provider.Content{provider.TextBlock{Text: "Then I will inspect the remaining call sites."}},
200			}},
201			want: compactContinuationStatusRescue,
202		},
203	}
204
205	for _, tt := range tests {
206		t.Run(tt.name, func(t *testing.T) {
207			if got := classifyCompactionContinuation(tt.origin, tt.statusActive, tt.stop, tt.turnErr, tt.msgs); got != tt.want {
208				t.Fatalf("classifyCompactionContinuation() = %d, want %d", got, tt.want)
209			}
210		})
211	}
212}
213
214func TestAutoCompactContinuationIsHiddenFromTranscriptButPersisted(t *testing.T) {
215	hidden := provider.Message{
216		Role:    provider.RoleUser,
217		Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
218		Meta:    map[string]string{autoCompactContinueMetaKey: "true"},
219	}
220	msgs := []provider.Message{
221		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "visible"}}},
222		hidden,
223	}
224	if !isHiddenTranscriptMessage(hidden) {
225		t.Fatal("auto-compaction continuation should be hidden from transcript views")
226	}
227	filtered := filterHiddenTranscriptMessages(msgs)
228	if len(filtered) != 1 || filtered[0].Content[0].(provider.TextBlock).Text != "visible" {
229		t.Fatalf("filtered transcript = %#v, want only visible message", filtered)
230	}
231	if got := hidden.Meta[autoCompactContinueMetaKey]; got != "true" {
232		t.Fatalf("persisted continuation metadata = %q, want true", got)
233	}
234}
235
236func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) {
237	tests := []struct {
238		name      string
239		used      int
240		window    int
241		threshold int
242		want      bool
243	}{
244		{name: "below threshold", used: 84, window: 100, threshold: 85, want: false},
245		{name: "at threshold", used: 85, window: 100, threshold: 85, want: true},
246		{name: "above threshold", used: 86, window: 100, threshold: 85, want: true},
247		{name: "seventy percent preset", used: 70, window: 100, threshold: 70, want: true},
248		{name: "eighty percent preset", used: 80, window: 100, threshold: 80, want: true},
249		{name: "ninety percent preset", used: 90, window: 100, threshold: 90, want: true},
250		{name: "off", used: 100, window: 100, threshold: 0, want: false},
251		{name: "missing usage", used: 0, window: 100, threshold: 85, want: false},
252		{name: "missing window", used: 85, window: 0, threshold: 85, want: false},
253	}
254
255	for _, tt := range tests {
256		t.Run(tt.name, func(t *testing.T) {
257			if got := shouldAutoCompact(tt.used, tt.window, tt.threshold); got != tt.want {
258				t.Fatalf("shouldAutoCompact(%d, %d, %d) = %t, want %t", tt.used, tt.window, tt.threshold, got, tt.want)
259			}
260		})
261	}
262}
263
264func TestSettingsDialogOffersAutoCompactThresholdPresets(t *testing.T) {
265	interactive := NewInteractive(InteractiveConfig{})
266	interactive.openSettingsDialog()
267
268	var thresholdItem *settingsItem
269	for idx := range interactive.settingsDialog.items {
270		item := &interactive.settingsDialog.items[idx]
271		if item.key == "auto_compact_threshold" {
272			thresholdItem = item
273			break
274		}
275	}
276	if thresholdItem == nil {
277		t.Fatal("settings dialog is missing auto-compact threshold")
278	}
279
280	wantValues := []string{"0", "70", "80", "85", "90"}
281	if len(thresholdItem.options) != len(wantValues) {
282		t.Fatalf("auto-compact options = %d, want %d", len(thresholdItem.options), len(wantValues))
283	}
284	for idx, want := range wantValues {
285		if got := thresholdItem.options[idx].value; got != want {
286			t.Fatalf("auto-compact option %d = %q, want %q", idx, got, want)
287		}
288	}
289	if got := thresholdItem.options[thresholdItem.choice].value; got != "85" {
290		t.Fatalf("default auto-compact choice = %q, want 85", got)
291	}
292}
293
294func TestApplyAutoCompactThresholdSynchronizesLiveUpdate(t *testing.T) {
295	initialThreshold := 85
296	releaseStore := make(chan struct{})
297	store := &blockingAutoCompactSettingsStore{
298		entered: make(chan int, 1),
299		release: releaseStore,
300	}
301	interactive := NewInteractive(InteractiveConfig{
302		AutoCompactThreshold: &initialThreshold,
303		SettingsStore:        store,
304	})
305
306	interactive.mu.Lock()
307	locked := true
308	released := false
309	done := make(chan struct{})
310	go func() {
311		defer close(done)
312		interactive.applyAutoCompactThresholdSetting("70")
313	}()
314	defer func() {
315		if !released {
316			close(releaseStore)
317		}
318		if locked {
319			interactive.mu.Unlock()
320		}
321		<-done
322	}()
323
324	if got := <-store.entered; got != 70 {
325		t.Fatalf("persisted threshold = %d, want 70", got)
326	}
327	if got := *interactive.cfg.AutoCompactThreshold; got != 85 {
328		t.Fatalf("threshold changed without holding Interactive.mu: got %d, want 85", got)
329	}
330
331	close(releaseStore)
332	released = true
333	interactive.mu.Unlock()
334	locked = false
335	<-done
336	if got := *interactive.cfg.AutoCompactThreshold; got != 70 {
337		t.Fatalf("threshold after setting completes = %d, want 70", got)
338	}
339}
340
341type blockingAutoCompactSettingsStore struct {
342	SettingsStore
343	entered chan int
344	release <-chan struct{}
345}
346
347func (s *blockingAutoCompactSettingsStore) SetAutoCompactThreshold(percent int) error {
348	s.entered <- percent
349	<-s.release
350	return nil
351}
352
353func autoCompactIntPtr(value int) *int {
354	return &value
355}

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