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 start the stream presenter: 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.stream.Reset()
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.skillPins.recall(i.pendingCompactPrompt)
329 i.clearPendingCompactTurnLocked()
330 handoff, persistHandoff = i.resetCompactContinuationLocked()
331 case err != nil:
332 i.statusErr = "compaction failed: " + err.Error()
333 i.statusOK = ""
334 i.discardQueuedMessagesLocked(true)
335 i.skillPins.recall(i.pendingCompactPrompt)
336 i.clearPendingCompactTurnLocked()
337 handoff, persistHandoff = i.resetCompactContinuationLocked()
338 default:
339 i.statusErr = ""
340 // Read token count from the compaction message meta.
341 tokens := ""
342 msgs := i.agent.Messages()
343 if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
344 tokens = msgs[0].Meta["tokens_before"]
345 }
346 switch {
347 case i.pendingPostCompactNote != "":
348 i.statusOK = i.pendingPostCompactNote
349 case tokens != "":
350 i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
351 default:
352 i.statusOK = "compacted (ctrl+o to expand)"
353 }
354 i.pendingPostCompactNote = ""
355 i.extNotes = stripAutoCompactNotes(i.extNotes)
356 i.lastCtxInput = estimateCompactedContextInput(msgs)
357 i.toolCalls = map[string]*tui.ToolCallView{}
358 i.toolOrder = nil
359 i.stream.Reset()
360 i.resetTranscriptRenderLocked()
361 switch {
362 case i.continueAfterCompact:
363 continueExisting = true
364 i.continueAfterCompact = false
365 case i.hasPendingCompactPrompt:
366 next = core.QueuedMessage{Text: i.pendingCompactPrompt, Images: i.pendingCompactImages}
367 i.pendingCompactPrompt = ""
368 i.pendingCompactImages = nil
369 i.hasPendingCompactPrompt = false
370 hasNext = true
371 case continuationReason == compactContinuationForcedLength && goalActive:
372 // Preserve the goal as the durable handoff owner so the fresh
373 // turn uses goal accounting and can be resumed after a restart.
374 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: compactContinuationGoal})
375 continueGoal = !i.coordinatorHasPendingWorkers()
376 case continuationReason == compactContinuationForcedLength:
377 // Forced truncated-output continuation keeps its existing
378 // priority over an explicitly queued prompt.
379 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
380 continueAutomatically = true
381 case len(i.queued) > 0:
382 next, i.queued = i.queued[0], i.queued[1:]
383 hasNext = true
384 handoff, persistHandoff = i.resetCompactContinuationLocked()
385 case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue:
386 if continuationReason == compactContinuationStatusRescue {
387 attempts := 1
388 if i.compactContinuation.reason == compactContinuationStatusRescue {
389 attempts = i.compactContinuation.rescueAttempts + 1
390 }
391 if attempts > maxStatusRescueContinuations {
392 handoff, persistHandoff = i.resetCompactContinuationLocked()
393 break
394 }
395 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
396 reason: continuationReason,
397 rescueAttempts: attempts,
398 })
399 } else {
400 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
401 }
402 continueAutomatically = true
403 case auto && goalActive && !i.coordinatorHasPendingWorkers():
404 continueGoal = true
405 handoff, persistHandoff = i.resetCompactContinuationLocked()
406 default:
407 handoff, persistHandoff = i.resetCompactContinuationLocked()
408 }
409 }
410 // Keep the host busy until the hand-off decision is committed under
411 // the mutex. A completion update arriving after the compaction result
412 // but before this assignment is then queued for the selected follow-up
413 // instead of starting a competing turn.
414 if hasNext || continueExisting || continueAutomatically || continueGoal {
415 // The estimate above keeps the status display honest, but the
416 // follow-up turn must not trip the pre-turn compaction guard
417 // before fresh provider usage arrives: it runs against the
418 // just-compacted transcript by construction.
419 i.lastCtxInput = 0
420 }
421 i.busy = hasNext || continueExisting || continueAutomatically || continueGoal
422 i.compacting = false
423 i.autoCompacting = false
424 i.mu.Unlock()
425 if persistHandoff {
426 i.persistCompactHandoff(handoff)
427 }
428 runPendingIdleWork(pendingIdleWork)
429 i.invalidate()
430
431 if hasNext || continueExisting || continueAutomatically || continueGoal {
432 p := i.runCtx
433 if p == nil {
434 p = context.Background()
435 }
436 switch {
437 case continueExisting:
438 i.startTurnRequest(p, "", nil, true, true)
439 case hasNext:
440 i.startQueuedTurn(p, next)
441 case continueAutomatically:
442 i.startAutoCompactContinuation(p)
443 case continueGoal:
444 i.startReservedGoalContinuation(p)
445 }
446 }
447 }()
448}