1package modes
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7
8 "github.com/bnema/zut/packages/core"
9 "github.com/bnema/zut/packages/provider"
10 "github.com/bnema/zut/packages/tui"
11)
12
13func (i *Interactive) clearPendingCompactTurnLocked() {
14 i.pendingCompactPrompt = ""
15 i.pendingCompactImages = nil
16 i.hasPendingCompactPrompt = false
17 i.continueAfterCompact = false
18 i.pendingPostCompactNote = ""
19}
20
21// estimateCompactedContextInput approximates the input tokens of the
22// just-compacted transcript for the status display. It uses the same rough
23// 1-token-per-4-chars rule as compaction's own estimate, summed over the
24// textual timeline content. Empty transcripts yield zero; image bytes are
25// not counted. This is display-only: the next provider usage replaces it,
26// and immediate continuations bypass the pre-turn guard until then.
27func estimateCompactedContextInput(msgs []provider.Message) int {
28 total := 0
29 for _, msg := range msgs {
30 for _, c := range msg.Content {
31 switch v := c.(type) {
32 case provider.TextBlock:
33 total += len(v.Text)
34 case provider.ToolCallBlock:
35 total += len(v.Name) + len(v.Arguments)
36 case provider.ToolResultBlock:
37 for _, inner := range v.Content {
38 if text, ok := inner.(provider.TextBlock); ok {
39 total += len(text.Text)
40 }
41 }
42 case provider.ReasoningBlock:
43 total += len(v.Summary)
44 }
45 }
46 }
47 return total / 4
48}
49func (i *Interactive) compactHandoffLocked() json.RawMessage {
50 return encodeCompactHandoff(i.compactContinuation)
51}
52func (i *Interactive) setCompactContinuationLocked(state compactContinuationState) (json.RawMessage, bool) {
53 if i.compactContinuation == state {
54 return i.compactHandoffLocked(), false
55 }
56 i.compactContinuation = state
57 return i.compactHandoffLocked(), true
58}
59func (i *Interactive) resetCompactContinuationLocked() (json.RawMessage, bool) {
60 return i.setCompactContinuationLocked(compactContinuationState{})
61}
62func (i *Interactive) persistCompactHandoff(state json.RawMessage) {
63 if persist := i.cfg.PersistCompactHandoff; persist != nil {
64 if err := persist(state); err != nil {
65 i.ReportError(fmt.Errorf("persist compact handoff: %w", err))
66 }
67 }
68}
69func (i *Interactive) resetCompactHandoff() {
70 i.mu.Lock()
71 state, persist := i.resetCompactContinuationLocked()
72 i.mu.Unlock()
73 if persist {
74 i.persistCompactHandoff(state)
75 }
76}
77func (i *Interactive) currentCompactHandoff() json.RawMessage {
78 if current := i.cfg.CurrentCompactHandoff; current != nil {
79 return current()
80 }
81 return nil
82}
83func (i *Interactive) restoreCompactHandoff(state compactContinuationState) {
84 i.mu.Lock()
85 i.compactContinuation = state
86 i.mu.Unlock()
87}
88func (i *Interactive) restoreCurrentCompactHandoff() compactContinuationState {
89 state := decodeCompactHandoff(i.currentCompactHandoff())
90 i.restoreCompactHandoff(state)
91 return state
92}
93func classifyCompactHandoffResume(messages []provider.Message) compactHandoffResume {
94 lastHandoff := -1
95 for idx := len(messages) - 1; idx >= 0; idx-- {
96 message := messages[idx]
97 if message.Role == provider.RoleUser && message.Meta[autoCompactContinueMetaKey] == "true" {
98 lastHandoff = idx
99 break
100 }
101 }
102 if lastHandoff < 0 {
103 return compactHandoffAppendPrompt
104 }
105 for _, message := range messages[lastHandoff+1:] {
106 switch message.Role {
107 case provider.RoleTool:
108 continue
109 case provider.RoleAssistant:
110 hasToolCall := false
111 for _, content := range message.Content {
112 if _, ok := content.(provider.ToolCallBlock); ok {
113 hasToolCall = true
114 break
115 }
116 }
117 if !hasToolCall {
118 return compactHandoffDiscard
119 }
120 default:
121 return compactHandoffDiscard
122 }
123 }
124 return compactHandoffContinueExisting
125}
126func (i *Interactive) startRestoredCompactHandoff(parent context.Context) {
127 if parent == nil {
128 parent = i.runCtx
129 }
130 if parent == nil {
131 parent = context.Background()
132 }
133 i.mu.Lock()
134 state := i.compactContinuation
135 ag := i.agent
136 busy := i.busy || i.compacting || i.autoCompacting || i.shellRunning
137 if state.reason == compactContinuationNone || ag == nil || busy {
138 i.mu.Unlock()
139 return
140 }
141
142 var next core.QueuedMessage
143 var hasNext bool
144 continueQueued := false
145 resumeGoalCompaction := false
146 resumeGoal := false
147 var resume compactHandoffResume
148 var handoff json.RawMessage
149 var persistHandoff bool
150 switch {
151 case state.reason != compactContinuationForcedLength && state.reason != compactContinuationGoalCompactionPending && len(i.queued) > 0:
152 next, i.queued = i.queued[0], i.queued[1:]
153 hasNext = true
154 handoff, persistHandoff = i.resetCompactContinuationLocked()
155 case state.reason != compactContinuationForcedLength && state.reason != compactContinuationGoalCompactionPending && ag.QueuedMessageCount() > 0:
156 continueQueued = true
157 handoff, persistHandoff = i.resetCompactContinuationLocked()
158 case state.reason == compactContinuationGoalCompactionPending:
159 resumeGoalCompaction = true
160 case state.reason == compactContinuationGoal:
161 resumeGoal = true
162 default:
163 resume = classifyCompactHandoffResume(ag.Messages())
164 if resume == compactHandoffDiscard {
165 handoff, persistHandoff = i.resetCompactContinuationLocked()
166 }
167 }
168 // Reserve the turn slot before persisting or dispatching so concurrent input
169 // can only queue behind the restored handoff, never start a competing turn.
170 starting := hasNext || continueQueued || resumeGoalCompaction || resumeGoal || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt
171 i.busy = starting
172 i.mu.Unlock()
173 if persistHandoff {
174 i.persistCompactHandoff(handoff)
175 }
176 if !starting {
177 i.invalidate()
178 return
179 }
180 switch {
181 case hasNext:
182 i.startTurnWithImages(parent, next.Text, next.Images)
183 case continueQueued:
184 i.startTurnRequest(parent, "", nil, true, false)
185 case resumeGoalCompaction:
186 i.runCompact(parent, compactContinuationRequest{origin: compactOriginRecovery, force: true})
187 case resumeGoal:
188 i.startReservedGoalContinuation(parent)
189 case resume == compactHandoffContinueExisting:
190 i.startTurnRequest(parent, "", nil, true, false)
191 case resume == compactHandoffAppendPrompt:
192 i.startAutoCompactContinuation(parent)
193 }
194}
195func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
196 i.mu.Lock()
197 ag := i.agent
198 if ag == nil {
199 i.busy = false
200 pendingIdleWork := i.takePendingIdleWorkLocked()
201 i.mu.Unlock()
202 runPendingIdleWork(pendingIdleWork)
203 i.invalidate()
204 return
205 }
206 // The caller selected the continuation while holding i.mu. Keep that
207 // ordering through the hidden append so an explicitly queued user prompt
208 // cannot be overtaken by an old-task handoff. Forced truncation is the
209 // existing exception: it keeps priority and the queued prompt runs after it.
210 if i.compactContinuation.reason != compactContinuationForcedLength && len(i.queued) > 0 {
211 next := i.queued[0]
212 i.queued = i.queued[1:]
213 handoff, persistHandoff := i.resetCompactContinuationLocked()
214 i.mu.Unlock()
215 if persistHandoff {
216 i.persistCompactHandoff(handoff)
217 }
218 i.startTurnWithImages(parent, next.Text, next.Images)
219 return
220 }
221 if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 {
222 handoff, persistHandoff := i.resetCompactContinuationLocked()
223 i.mu.Unlock()
224 if persistHandoff {
225 i.persistCompactHandoff(handoff)
226 }
227 i.startTurnRequest(parent, "", nil, true, false)
228 return
229 }
230 ag.AppendUserContext(autoCompactContinuationPrompt, map[string]string{
231 autoCompactContinueMetaKey: "true",
232 })
233 i.mu.Unlock()
234 i.startTurnRequest(parent, "", nil, true, false)
235}
236func (i *Interactive) runCompact(parent context.Context, request compactContinuationRequest) {
237 auto := request.origin != compactOriginManual
238 if i.agent == nil {
239 i.mu.Lock()
240 i.statusErr = "not logged in. type /login first."
241 i.mu.Unlock()
242 return
243 }
244 ctx, cancel := context.WithCancel(parent)
245 i.mu.Lock()
246 i.busy = true
247 i.compacting = true
248 i.spin.Start()
249 i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
250 if auto {
251 i.activity.kind = activityCondensingHistory
252 i.autoCompacting = true
253 } else {
254 i.activity.kind = activityCompactingHistory
255 }
256 i.cancelTurn = cancel
257 var initialHandoff json.RawMessage
258 persistInitialHandoff := false
259 if request.origin == compactOriginManual || (request.origin == compactOriginRecovery && i.compactContinuation.reason != compactContinuationGoalCompactionPending) {
260 initialHandoff, persistInitialHandoff = i.resetCompactContinuationLocked()
261 }
262 i.statusErr = ""
263 i.statusOK = ""
264 // Do NOT set streamOn: the summary text should not be visible
265 // in the chat while compacting. The user just sees the spinner
266 // and can keep typing / queue prompts.
267 i.scrollOffset = 0
268 i.helpBlock = nil
269 i.mu.Unlock()
270 if persistInitialHandoff {
271 i.persistCompactHandoff(initialHandoff)
272 }
273 i.invalidate()
274
275 go func() {
276 // Sink discards deltas — we don't stream the summary to the UI.
277 sink := func(delta string) {}
278 msgsBefore := i.agent.Messages()
279 i.mu.Lock()
280 statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue
281 i.mu.Unlock()
282 continuationReason := classifyCompactionContinuation(request.origin, statusRescueActive, request.lastStop, request.turnError, msgsBefore)
283 if request.force {
284 continuationReason = compactContinuationForcedLength
285 }
286 // Keep the usual recent tail when possible, but never let it cover
287 // the whole transcript. A short session can still be at 70–90% of
288 // a model's window because one prompt or tool result is large; the
289 // automatic path must summarize that session instead of failing
290 // before it reaches the compaction provider.
291 keepTail := 4
292 if n := len(msgsBefore); n > 0 && keepTail >= n {
293 keepTail = n - 1
294 }
295 summary, err := i.agent.Compact(ctx, keepTail, sink)
296 _ = summary
297 _, goalActive := i.goalContinuationMessage()
298 i.mu.Lock()
299 // Keep busy/compacting asserted while cleanup and queue selection run.
300 // Completion updates can arrive in this window; clearing busy before
301 // inspecting the queues lets them race a new turn or be stranded.
302 i.resetStreamingStateLocked()
303 i.cancelTurn = nil
304 i.autoCompacting = false
305 pendingIdleWork := i.takePendingIdleWorkLocked()
306
307 // Drain pending work after the transcript is clean. Overflow recovery
308 // continues the user message already in the transcript. Pre-turn
309 // compaction preserves its complete text-and-images request. Regular
310 // prompts typed during compaction remain in the host queue.
311 var next core.QueuedMessage
312 var hasNext bool
313 var continueExisting bool
314 var continueAutomatically bool
315 var continueGoal bool
316 var handoff json.RawMessage
317 var persistHandoff bool
318
319 switch {
320 case err != nil && ctx.Err() != nil:
321 i.statusErr = ""
322 if auto {
323 i.statusOK = "auto-condense cancelled"
324 } else {
325 i.statusOK = "compaction cancelled"
326 }
327 i.discardQueuedMessagesLocked(false)
328 i.clearPendingCompactTurnLocked()
329 handoff, persistHandoff = i.resetCompactContinuationLocked()
330 case err != nil:
331 i.statusErr = "compaction failed: " + err.Error()
332 i.statusOK = ""
333 i.discardQueuedMessagesLocked(true)
334 i.clearPendingCompactTurnLocked()
335 handoff, persistHandoff = i.resetCompactContinuationLocked()
336 default:
337 i.statusErr = ""
338 // Read token count from the compaction message meta.
339 tokens := ""
340 msgs := i.agent.Messages()
341 if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
342 tokens = msgs[0].Meta["tokens_before"]
343 }
344 switch {
345 case i.pendingPostCompactNote != "":
346 i.statusOK = i.pendingPostCompactNote
347 case tokens != "":
348 i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
349 default:
350 i.statusOK = "compacted (ctrl+o to expand)"
351 }
352 i.pendingPostCompactNote = ""
353 i.extNotes = stripAutoCompactNotes(i.extNotes)
354 i.lastCtxInput = estimateCompactedContextInput(msgs)
355 i.toolCalls = map[string]*tui.ToolCallView{}
356 i.toolOrder = nil
357 i.toolGate = map[string]int{}
358 i.resetTranscriptRenderLocked()
359 switch {
360 case i.continueAfterCompact:
361 continueExisting = true
362 i.continueAfterCompact = false
363 case i.hasPendingCompactPrompt:
364 next = core.QueuedMessage{Text: i.pendingCompactPrompt, Images: i.pendingCompactImages}
365 i.pendingCompactPrompt = ""
366 i.pendingCompactImages = nil
367 i.hasPendingCompactPrompt = false
368 hasNext = true
369 case continuationReason == compactContinuationForcedLength && goalActive:
370 // Preserve the goal as the durable handoff owner so the fresh
371 // turn uses goal accounting and can be resumed after a restart.
372 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: compactContinuationGoal})
373 continueGoal = !i.coordinatorHasPendingWorkers()
374 case continuationReason == compactContinuationForcedLength:
375 // Forced truncated-output continuation keeps its existing
376 // priority over an explicitly queued prompt.
377 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
378 continueAutomatically = true
379 case len(i.queued) > 0:
380 next, i.queued = i.queued[0], i.queued[1:]
381 hasNext = true
382 handoff, persistHandoff = i.resetCompactContinuationLocked()
383 case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue:
384 if continuationReason == compactContinuationStatusRescue {
385 attempts := 1
386 if i.compactContinuation.reason == compactContinuationStatusRescue {
387 attempts = i.compactContinuation.rescueAttempts + 1
388 }
389 if attempts > maxStatusRescueContinuations {
390 handoff, persistHandoff = i.resetCompactContinuationLocked()
391 break
392 }
393 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
394 reason: continuationReason,
395 rescueAttempts: attempts,
396 })
397 } else {
398 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
399 }
400 continueAutomatically = true
401 case auto && goalActive && !i.coordinatorHasPendingWorkers():
402 continueGoal = true
403 handoff, persistHandoff = i.resetCompactContinuationLocked()
404 default:
405 handoff, persistHandoff = i.resetCompactContinuationLocked()
406 }
407 }
408 // Keep the host busy until the hand-off decision is committed under
409 // the mutex. A completion update arriving after the compaction result
410 // but before this assignment is then queued for the selected follow-up
411 // instead of starting a competing turn.
412 if hasNext || continueExisting || continueAutomatically || continueGoal {
413 // The estimate above keeps the status display honest, but the
414 // follow-up turn must not trip the pre-turn compaction guard
415 // before fresh provider usage arrives: it runs against the
416 // just-compacted transcript by construction.
417 i.lastCtxInput = 0
418 }
419 i.busy = hasNext || continueExisting || continueAutomatically || continueGoal
420 i.compacting = false
421 i.autoCompacting = false
422 i.mu.Unlock()
423 if persistHandoff {
424 i.persistCompactHandoff(handoff)
425 }
426 runPendingIdleWork(pendingIdleWork)
427 i.invalidate()
428
429 if hasNext || continueExisting || continueAutomatically || continueGoal {
430 p := i.runCtx
431 if p == nil {
432 p = context.Background()
433 }
434 switch {
435 case continueExisting:
436 i.startTurnRequest(p, "", nil, true, true)
437 case hasNext:
438 i.startQueuedTurn(p, next)
439 case continueAutomatically:
440 i.startAutoCompactContinuation(p)
441 case continueGoal:
442 i.startReservedGoalContinuation(p)
443 }
444 }
445 }()
446}