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}