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}