v0.7.0 3110ba7

auto_compact_test.go RawHistoryFolder

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

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 477

↑↓ moveenter openesc close