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