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