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 var resume compactHandoffResume
117 var handoff json.RawMessage
118 var persistHandoff bool
119 switch {
120 case state.reason != compactContinuationForcedLength && len(i.queued) > 0:
121 next, i.queued = i.queued[0], i.queued[1:]
122 hasNext = true
123 handoff, persistHandoff = i.resetCompactContinuationLocked()
124 case state.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0:
125 continueQueued = true
126 handoff, persistHandoff = i.resetCompactContinuationLocked()
127 default:
128 resume = classifyCompactHandoffResume(ag.Messages())
129 if resume == compactHandoffDiscard {
130 handoff, persistHandoff = i.resetCompactContinuationLocked()
131 }
132 }
133 // Reserve the turn slot before persisting or dispatching so concurrent input
134 // can only queue behind the restored handoff, never start a competing turn.
135 starting := hasNext || continueQueued || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt
136 i.busy = starting
137 i.mu.Unlock()
138 if persistHandoff {
139 i.persistCompactHandoff(handoff)
140 }
141 if !starting {
142 i.invalidate()
143 return
144 }
145 switch {
146 case hasNext:
147 i.startTurnWithImages(parent, next.Text, next.Images)
148 case continueQueued:
149 i.startTurnRequest(parent, "", nil, true, false)
150 case resume == compactHandoffContinueExisting:
151 i.startTurnRequest(parent, "", nil, true, false)
152 case resume == compactHandoffAppendPrompt:
153 i.startAutoCompactContinuation(parent)
154 }
155}
156func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
157 i.mu.Lock()
158 ag := i.agent
159 if ag == nil {
160 i.busy = false
161 pendingIdleWork := i.takePendingIdleWorkLocked()
162 i.mu.Unlock()
163 runPendingIdleWork(pendingIdleWork)
164 i.invalidate()
165 return
166 }
167 // The caller selected the continuation while holding i.mu. Keep that
168 // ordering through the hidden append so an explicitly queued user prompt
169 // cannot be overtaken by an old-task handoff. Forced truncation is the
170 // existing exception: it keeps priority and the queued prompt runs after it.
171 if i.compactContinuation.reason != compactContinuationForcedLength && len(i.queued) > 0 {
172 next := i.queued[0]
173 i.queued = i.queued[1:]
174 handoff, persistHandoff := i.resetCompactContinuationLocked()
175 i.mu.Unlock()
176 if persistHandoff {
177 i.persistCompactHandoff(handoff)
178 }
179 i.startTurnWithImages(parent, next.Text, next.Images)
180 return
181 }
182 if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 {
183 handoff, persistHandoff := i.resetCompactContinuationLocked()
184 i.mu.Unlock()
185 if persistHandoff {
186 i.persistCompactHandoff(handoff)
187 }
188 i.startTurnRequest(parent, "", nil, true, false)
189 return
190 }
191 ag.AppendUserContext(autoCompactContinuationPrompt, map[string]string{
192 autoCompactContinueMetaKey: "true",
193 })
194 i.mu.Unlock()
195 i.startTurnRequest(parent, "", nil, true, false)
196}
197func (i *Interactive) runCompact(parent context.Context, request compactContinuationRequest) {
198 auto := request.origin != compactOriginManual
199 if i.agent == nil {
200 i.mu.Lock()
201 i.statusErr = "not logged in. type /login first."
202 i.mu.Unlock()
203 return
204 }
205 ctx, cancel := context.WithCancel(parent)
206 i.mu.Lock()
207 i.busy = true
208 i.compacting = true
209 i.spin.Start()
210 i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
211 if auto {
212 i.activity.kind = activityCondensingHistory
213 i.autoCompacting = true
214 } else {
215 i.activity.kind = activityCompactingHistory
216 }
217 i.cancelTurn = cancel
218 var initialHandoff json.RawMessage
219 persistInitialHandoff := false
220 if request.origin == compactOriginManual || request.origin == compactOriginRecovery {
221 initialHandoff, persistInitialHandoff = i.resetCompactContinuationLocked()
222 }
223 i.statusErr = ""
224 i.statusOK = ""
225 // Do NOT set streamOn: the summary text should not be visible
226 // in the chat while compacting. The user just sees the spinner
227 // and can keep typing / queue prompts.
228 i.scrollOffset = 0
229 i.helpBlock = nil
230 i.mu.Unlock()
231 if persistInitialHandoff {
232 i.persistCompactHandoff(initialHandoff)
233 }
234 i.invalidate()
235
236 go func() {
237 // Sink discards deltas — we don't stream the summary to the UI.
238 sink := func(delta string) {}
239 msgsBefore := i.agent.Messages()
240 i.mu.Lock()
241 statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue
242 i.mu.Unlock()
243 continuationReason := classifyCompactionContinuation(request.origin, statusRescueActive, request.lastStop, request.turnError, msgsBefore)
244 if request.force {
245 continuationReason = compactContinuationForcedLength
246 }
247 // Keep the usual recent tail when possible, but never let it cover
248 // the whole transcript. A short session can still be at 70–90% of
249 // a model's window because one prompt or tool result is large; the
250 // automatic path must summarize that session instead of failing
251 // before it reaches the compaction provider.
252 keepTail := 4
253 if n := len(msgsBefore); n > 0 && keepTail >= n {
254 keepTail = n - 1
255 }
256 summary, err := i.agent.Compact(ctx, keepTail, sink)
257 _ = summary
258 goalMessage, goalActive := i.goalContinuationMessage()
259 i.mu.Lock()
260 // Keep busy/compacting asserted while cleanup and queue selection run.
261 // Completion updates can arrive in this window; clearing busy before
262 // inspecting the queues lets them race a new turn or be stranded.
263 i.resetStreamingStateLocked()
264 i.cancelTurn = nil
265 i.autoCompacting = false
266 pendingIdleWork := i.takePendingIdleWorkLocked()
267
268 // Drain pending work after the transcript is clean. Overflow recovery
269 // continues the user message already in the transcript. Pre-turn
270 // compaction preserves its complete text-and-images request. Regular
271 // prompts typed during compaction remain in the host queue.
272 var next string
273 var nextImages []provider.ImageBlock
274 var hasNext bool
275 var continueExisting bool
276 var continueAutomatically bool
277 var continueGoal bool
278 var handoff json.RawMessage
279 var persistHandoff bool
280
281 switch {
282 case err != nil && ctx.Err() != nil:
283 i.statusErr = ""
284 if auto {
285 i.statusOK = "auto-condense cancelled"
286 } else {
287 i.statusOK = "compaction cancelled"
288 }
289 i.queued = nil // drop queue on cancel
290 i.clearPendingCompactTurnLocked()
291 handoff, persistHandoff = i.resetCompactContinuationLocked()
292 if i.agent != nil {
293 i.agent.DrainQueuedMessages()
294 }
295 case err != nil:
296 i.statusErr = "compaction failed: " + err.Error()
297 i.statusOK = ""
298 i.queued = nil // drop queue on error
299 i.clearPendingCompactTurnLocked()
300 handoff, persistHandoff = i.resetCompactContinuationLocked()
301 if i.agent != nil {
302 i.agent.DrainQueuedMessages()
303 }
304 default:
305 i.statusErr = ""
306 // Read token count from the compaction message meta.
307 tokens := ""
308 msgs := i.agent.Messages()
309 if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
310 tokens = msgs[0].Meta["tokens_before"]
311 }
312 switch {
313 case i.pendingPostCompactNote != "":
314 i.statusOK = i.pendingPostCompactNote
315 case tokens != "":
316 i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
317 default:
318 i.statusOK = "compacted (ctrl+o to expand)"
319 }
320 i.pendingPostCompactNote = ""
321 i.extNotes = stripAutoCompactNotes(i.extNotes)
322 i.lastCtxInput = 0
323 i.toolCalls = map[string]*tui.ToolCallView{}
324 i.toolOrder = nil
325 i.toolGate = map[string]int{}
326 i.resetTranscriptRenderLocked()
327 switch {
328 case i.continueAfterCompact:
329 continueExisting = true
330 i.continueAfterCompact = false
331 case i.hasPendingCompactPrompt:
332 next = i.pendingCompactPrompt
333 nextImages = i.pendingCompactImages
334 i.pendingCompactPrompt = ""
335 i.pendingCompactImages = nil
336 i.hasPendingCompactPrompt = false
337 hasNext = true
338 case continuationReason == compactContinuationForcedLength:
339 // Forced truncated-output continuation keeps its existing
340 // priority over an explicitly queued prompt.
341 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
342 continueAutomatically = true
343 case len(i.queued) > 0:
344 queued := i.queued[0]
345 i.queued = i.queued[1:]
346 next = queued.Text
347 nextImages = queued.Images
348 hasNext = true
349 handoff, persistHandoff = i.resetCompactContinuationLocked()
350 case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue:
351 if continuationReason == compactContinuationStatusRescue {
352 attempts := 1
353 if i.compactContinuation.reason == compactContinuationStatusRescue {
354 attempts = i.compactContinuation.rescueAttempts + 1
355 }
356 if attempts > maxStatusRescueContinuations {
357 handoff, persistHandoff = i.resetCompactContinuationLocked()
358 break
359 }
360 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
361 reason: continuationReason,
362 rescueAttempts: attempts,
363 })
364 } else {
365 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
366 }
367 continueAutomatically = true
368 case auto && goalActive && !i.coordinatorHasPendingWorkers():
369 continueGoal = true
370 handoff, persistHandoff = i.resetCompactContinuationLocked()
371 default:
372 handoff, persistHandoff = i.resetCompactContinuationLocked()
373 }
374 }
375 // Keep the host busy until the hand-off decision is committed under
376 // the mutex. A completion update arriving after the compaction result
377 // but before this assignment is then queued for the selected follow-up
378 // instead of starting a competing turn.
379 i.busy = hasNext || continueExisting || continueAutomatically || continueGoal
380 i.compacting = false
381 i.autoCompacting = false
382 i.mu.Unlock()
383 if persistHandoff {
384 i.persistCompactHandoff(handoff)
385 }
386 runPendingIdleWork(pendingIdleWork)
387 i.invalidate()
388
389 if hasNext || continueExisting || continueAutomatically || continueGoal {
390 p := i.runCtx
391 if p == nil {
392 p = context.Background()
393 }
394 switch {
395 case continueExisting:
396 i.startTurnRequest(p, "", nil, true, true)
397 case hasNext:
398 i.startTurnWithImages(p, next, nextImages)
399 case continueAutomatically:
400 i.startAutoCompactContinuation(p)
401 case continueGoal:
402 var run *goalContinuationRun
403 if i.cfg.CurrentGoal != nil {
404 goal := copySessionGoal(i.cfg.CurrentGoal())
405 if !i.limitGoalBeforeRun(goal) {
406 run, err = i.startGoalRun(goal)
407 }
408 }
409 if err != nil || run == nil {
410 i.mu.Lock()
411 i.busy = false
412 i.mu.Unlock()
413 if err != nil {
414 i.ReportError(err)
415 }
416 return
417 }
418 i.startGoalContinuation(p, goalMessage, run)
419 }
420 }
421 }()
422}