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}