v0.24.0 3dbf466

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

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