detached 8293ec4

fix(interactive): harden compact handoff recovery

commit
8293ec4ed3f94168678208478cc4160b28699ec2
author
brice · · 2026-08-07 08:05 UTC
parent
f73cf18
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +201 -20
M README.md +1 -1
@@ -424,7 +424,7 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro
424424
425425 Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Manual `/compact` only summarizes; it does not silently start work afterward. Use it when the context meter creeps past ~80%.
426426
427-zut also auto-compacts in the background after an interactive turn reaches the configured context threshold. Structural unfinished tails and output stopped at the model's token limit continue as before. After a successful after-turn threshold compaction, a visible assistant status that appears to promise future work may receive at most two hidden, bounded recovery continuations; clear completed text settles normally, and a newer queued user request supersedes that recovery. An interrupted interactive session resumes an outstanding hidden handoff without displaying it as a chat message. Manual `/compact` does not silently start work, and truncated-output continuation remains a separate priority rule. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response.
427+zut also auto-compacts in the background after an interactive turn reaches the configured context threshold. Structural unfinished tails and output stopped at the model's token limit continue as before. After a successful after-turn threshold compaction, a visible assistant status that appears to promise future work may receive at most two hidden, bounded recovery continuations; clear completed text settles normally, and a newer queued user request supersedes that recovery. An interrupted interactive session resumes an outstanding hidden handoff without displaying it as a chat message. A new branch keeps that handoff only when it copies the complete effective transcript; `/fork` or `/session tree` selection at an earlier turn, and every branch from pre-compaction history, drops it and starts no hidden continuation. Manual `/compact` does not silently start work, and truncated-output continuation remains a separate priority rule. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response.
428428
429429 ### `/jail`
430430
@@ -424,7 +424,7 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro
424 424
425 Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Manual `/compact` only summarizes; it does not silently start work afterward. Use it when the context meter creeps past ~80%.425 Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Manual `/compact` only summarizes; it does not silently start work afterward. Use it when the context meter creeps past ~80%.
426 426
427-zut also auto-compacts in the background after an interactive turn reaches the configured context threshold. Structural unfinished tails and output stopped at the model's token limit continue as before. After a successful after-turn threshold compaction, a visible assistant status that appears to promise future work may receive at most two hidden, bounded recovery continuations; clear completed text settles normally, and a newer queued user request supersedes that recovery. An interrupted interactive session resumes an outstanding hidden handoff without displaying it as a chat message. Manual `/compact` does not silently start work, and truncated-output continuation remains a separate priority rule. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response.427+zut also auto-compacts in the background after an interactive turn reaches the configured context threshold. Structural unfinished tails and output stopped at the model's token limit continue as before. After a successful after-turn threshold compaction, a visible assistant status that appears to promise future work may receive at most two hidden, bounded recovery continuations; clear completed text settles normally, and a newer queued user request supersedes that recovery. An interrupted interactive session resumes an outstanding hidden handoff without displaying it as a chat message. A new branch keeps that handoff only when it copies the complete effective transcript; `/fork` or `/session tree` selection at an earlier turn, and every branch from pre-compaction history, drops it and starts no hidden continuation. Manual `/compact` does not silently start work, and truncated-output continuation remains a separate priority rule. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response.
428 428
429 ### `/jail`429 ### `/jail`
430 430
M packages/agent/modes/auto_compact_test.go +34 -6
@@ -1,7 +1,6 @@
11 package modes
22
33 import (
4- "context"
54 "encoding/json"
65 "errors"
76 "testing"
@@ -245,29 +244,58 @@ func TestNewInteractiveRestoresValidCompactHandoff(t *testing.T) {
245244 }
246245 }
247246
248-func TestCancelTurnClearsPersistedCompactHandoff(t *testing.T) {
247+func TestCancelTurnCancelsBeforePersistingCompactHandoff(t *testing.T) {
249248 persisted := make(chan string, 1)
249+ cancelled := make(chan struct{})
250+ persistenceStarted := make(chan struct{})
251+ releasePersistence := make(chan struct{})
252+ released := false
253+ defer func() {
254+ if !released {
255+ close(releasePersistence)
256+ }
257+ }()
258+
250259 interactive := NewInteractive(InteractiveConfig{
251260 InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
252261 PersistCompactHandoff: func(state json.RawMessage) error {
262+ close(persistenceStarted)
263+ <-releasePersistence
253264 persisted <- string(state)
254265 return nil
255266 },
256267 })
257- _, cancel := context.WithCancel(context.Background())
258268 interactive.mu.Lock()
259269 interactive.busy = true
260- interactive.cancelTurn = cancel
270+ interactive.cancelTurn = func() { close(cancelled) }
261271 interactive.mu.Unlock()
262272
263- interactive.CancelTurn()
273+ done := make(chan struct{})
274+ go func() {
275+ interactive.CancelTurn()
276+ close(done)
277+ }()
278+
279+ select {
280+ case <-cancelled:
281+ case <-time.After(2 * time.Second):
282+ t.Fatal("cancellation waited for compact handoff persistence")
283+ }
284+ select {
285+ case <-persistenceStarted:
286+ case <-time.After(2 * time.Second):
287+ t.Fatal("cancellation did not persist the cleared compact handoff")
288+ }
289+ close(releasePersistence)
290+ released = true
291+ <-done
264292 select {
265293 case state := <-persisted:
266294 if state != "" {
267295 t.Fatalf("cleared compact handoff = %q, want empty", state)
268296 }
269297 case <-time.After(2 * time.Second):
270- t.Fatal("cancellation did not clear the persisted compact handoff")
298+ t.Fatal("cancellation did not finish compact handoff persistence")
271299 }
272300 }
273301
@@ -1,7 +1,6 @@
1 package modes1 package modes
2 2
3 import (3 import (
4- "context"
5 "encoding/json"4 "encoding/json"
6 "errors"5 "errors"
7 "testing"6 "testing"
@@ -245,29 +244,58 @@ func TestNewInteractiveRestoresValidCompactHandoff(t *testing.T) {
245 }244 }
246 }245 }
247 246
248-func TestCancelTurnClearsPersistedCompactHandoff(t *testing.T) {247+func TestCancelTurnCancelsBeforePersistingCompactHandoff(t *testing.T) {
249 persisted := make(chan string, 1)248 persisted := make(chan string, 1)
249+ cancelled := make(chan struct{})
250+ persistenceStarted := make(chan struct{})
251+ releasePersistence := make(chan struct{})
252+ released := false
253+ defer func() {
254+ if !released {
255+ close(releasePersistence)
256+ }
257+ }()
258+
250 interactive := NewInteractive(InteractiveConfig{259 interactive := NewInteractive(InteractiveConfig{
251 InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),260 InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
252 PersistCompactHandoff: func(state json.RawMessage) error {261 PersistCompactHandoff: func(state json.RawMessage) error {
262+ close(persistenceStarted)
263+ <-releasePersistence
253 persisted <- string(state)264 persisted <- string(state)
254 return nil265 return nil
255 },266 },
256 })267 })
257- _, cancel := context.WithCancel(context.Background())
258 interactive.mu.Lock()268 interactive.mu.Lock()
259 interactive.busy = true269 interactive.busy = true
260- interactive.cancelTurn = cancel270+ interactive.cancelTurn = func() { close(cancelled) }
261 interactive.mu.Unlock()271 interactive.mu.Unlock()
262 272
263- interactive.CancelTurn()273+ done := make(chan struct{})
274+ go func() {
275+ interactive.CancelTurn()
276+ close(done)
277+ }()
278+
279+ select {
280+ case <-cancelled:
281+ case <-time.After(2 * time.Second):
282+ t.Fatal("cancellation waited for compact handoff persistence")
283+ }
284+ select {
285+ case <-persistenceStarted:
286+ case <-time.After(2 * time.Second):
287+ t.Fatal("cancellation did not persist the cleared compact handoff")
288+ }
289+ close(releasePersistence)
290+ released = true
291+ <-done
264 select {292 select {
265 case state := <-persisted:293 case state := <-persisted:
266 if state != "" {294 if state != "" {
267 t.Fatalf("cleared compact handoff = %q, want empty", state)295 t.Fatalf("cleared compact handoff = %q, want empty", state)
268 }296 }
269 case <-time.After(2 * time.Second):297 case <-time.After(2 * time.Second):
270- t.Fatal("cancellation did not clear the persisted compact handoff")298+ t.Fatal("cancellation did not finish compact handoff persistence")
271 }299 }
272 }300 }
273 301
M packages/agent/modes/compact_queue_test.go +146 -2
@@ -293,6 +293,34 @@ func (c *restoredHandoffClient) Stream(_ context.Context, req provider.Request)
293293 return out, nil
294294 }
295295
296+type heldCompletionClient struct {
297+ started chan struct{}
298+ release chan struct{}
299+}
300+
301+func (c *heldCompletionClient) Name() string { return "held-completion-test" }
302+
303+func (c *heldCompletionClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) {
304+ out := make(chan provider.Event, 1)
305+ go func() {
306+ defer close(out)
307+ close(c.started)
308+ select {
309+ case <-ctx.Done():
310+ out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
311+ case <-c.release:
312+ out <- provider.EventDone{
313+ Stop: provider.StopEnd,
314+ Message: provider.Message{
315+ Role: provider.RoleAssistant,
316+ Content: []provider.Content{provider.TextBlock{Text: "Next I will inspect the remaining call sites."}},
317+ },
318+ }
319+ }
320+ }()
321+ return out, nil
322+}
323+
296324 func TestRestoredStatusHandoffContinuesExistingPromptWithoutDuplication(t *testing.T) {
297325 client := &restoredHandoffClient{
298326 requests: make(chan provider.Request, 2),
@@ -364,12 +392,21 @@ func TestRestoredHandoffAfterFinalReplyIsDiscarded(t *testing.T) {
364392 Agent: agent,
365393 InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
366394 PersistCompactHandoff: func(state json.RawMessage) error {
367- persisted <- string(state)
395+ select {
396+ case persisted <- string(state):
397+ default:
398+ }
368399 return nil
369400 },
370401 })
371402
372403 interactive.startRestoredCompactHandoff(context.Background())
404+ interactive.mu.Lock()
405+ busy := interactive.busy
406+ interactive.mu.Unlock()
407+ if busy {
408+ t.Fatal("discarded restored handoff left the turn slot reserved")
409+ }
373410 select {
374411 case state := <-persisted:
375412 if state != "" {
@@ -385,6 +422,110 @@ func TestRestoredHandoffAfterFinalReplyIsDiscarded(t *testing.T) {
385422 }
386423 }
387424
425+func TestRestoredHandoffReservesTurnBeforePersistence(t *testing.T) {
426+ client := &restoredHandoffClient{
427+ requests: make(chan provider.Request, 1),
428+ replies: []string{"All required work is complete."},
429+ }
430+ agent := core.NewAgent(client, "test-model", "", nil)
431+ agent.SetMessages([]provider.Message{{
432+ Role: provider.RoleUser,
433+ Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
434+ Meta: map[string]string{autoCompactContinueMetaKey: "true"},
435+ }})
436+ persistenceStarted := make(chan struct{})
437+ releasePersistence := make(chan struct{})
438+ released := false
439+ defer func() {
440+ if !released {
441+ close(releasePersistence)
442+ }
443+ }()
444+ interactive := NewInteractive(InteractiveConfig{
445+ Agent: agent,
446+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
447+ PersistCompactHandoff: func(json.RawMessage) error {
448+ close(persistenceStarted)
449+ <-releasePersistence
450+ return nil
451+ },
452+ })
453+ interactive.runCtx = context.Background()
454+ interactive.mu.Lock()
455+ interactive.queued = []string{"newer user request"}
456+ interactive.mu.Unlock()
457+
458+ done := make(chan struct{})
459+ go func() {
460+ interactive.startRestoredCompactHandoff(context.Background())
461+ close(done)
462+ }()
463+ select {
464+ case <-persistenceStarted:
465+ case <-time.After(2 * time.Second):
466+ t.Fatal("restored handoff did not reach persistence")
467+ }
468+ interactive.mu.Lock()
469+ busy := interactive.busy
470+ interactive.mu.Unlock()
471+ if !busy {
472+ t.Fatal("restored handoff did not reserve the turn slot before persistence")
473+ }
474+ close(releasePersistence)
475+ released = true
476+ <-done
477+ select {
478+ case <-client.requests:
479+ case <-time.After(2 * time.Second):
480+ t.Fatal("restored handoff did not continue after persistence")
481+ }
482+ waitInteractiveIdle(t, interactive)
483+}
484+
485+func TestStatusRescueClearsHandoffWhenAgentIsRemovedDuringTurn(t *testing.T) {
486+ client := &heldCompletionClient{started: make(chan struct{}), release: make(chan struct{})}
487+ agent := core.NewAgent(client, "test-model", "", nil)
488+ agent.SetMessages([]provider.Message{{
489+ Role: provider.RoleUser,
490+ Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
491+ Meta: map[string]string{autoCompactContinueMetaKey: "true"},
492+ }})
493+ persisted := make(chan string, 1)
494+ interactive := NewInteractive(InteractiveConfig{
495+ Agent: agent,
496+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
497+ PersistCompactHandoff: func(state json.RawMessage) error {
498+ select {
499+ case persisted <- string(state):
500+ default:
501+ }
502+ return nil
503+ },
504+ })
505+ interactive.runCtx = context.Background()
506+
507+ interactive.startTurnRequest(context.Background(), "", nil, true, false)
508+ select {
509+ case <-client.started:
510+ case <-time.After(2 * time.Second):
511+ t.Fatal("continuation did not start")
512+ }
513+ interactive.mu.Lock()
514+ interactive.agent = nil
515+ interactive.mu.Unlock()
516+ close(client.release)
517+
518+ select {
519+ case state := <-persisted:
520+ if state != "" {
521+ t.Fatalf("cleared handoff = %q, want empty", state)
522+ }
523+ case <-time.After(2 * time.Second):
524+ t.Fatal("agent removal did not clear the persisted handoff")
525+ }
526+ waitInteractiveIdle(t, interactive)
527+}
528+
388529 func TestRestoredHandoffDefersToQueuedUserPrompt(t *testing.T) {
389530 client := &restoredHandoffClient{requests: make(chan provider.Request, 1)}
390531 agent := core.NewAgent(client, "test-model", "", nil)
@@ -503,7 +644,10 @@ func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) {
503644 Model: "claude-sonnet-4-5-20250929",
504645 AutoCompactThreshold: &threshold,
505646 PersistCompactHandoff: func(state json.RawMessage) error {
506- persistedHandoff <- string(state)
647+ select {
648+ case persistedHandoff <- string(state):
649+ default:
650+ }
507651 return nil
508652 },
509653 })
@@ -293,6 +293,34 @@ func (c *restoredHandoffClient) Stream(_ context.Context, req provider.Request)
293 return out, nil293 return out, nil
294 }294 }
295 295
296+type heldCompletionClient struct {
297+ started chan struct{}
298+ release chan struct{}
299+}
300+
301+func (c *heldCompletionClient) Name() string { return "held-completion-test" }
302+
303+func (c *heldCompletionClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) {
304+ out := make(chan provider.Event, 1)
305+ go func() {
306+ defer close(out)
307+ close(c.started)
308+ select {
309+ case <-ctx.Done():
310+ out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
311+ case <-c.release:
312+ out <- provider.EventDone{
313+ Stop: provider.StopEnd,
314+ Message: provider.Message{
315+ Role: provider.RoleAssistant,
316+ Content: []provider.Content{provider.TextBlock{Text: "Next I will inspect the remaining call sites."}},
317+ },
318+ }
319+ }
320+ }()
321+ return out, nil
322+}
323+
296 func TestRestoredStatusHandoffContinuesExistingPromptWithoutDuplication(t *testing.T) {324 func TestRestoredStatusHandoffContinuesExistingPromptWithoutDuplication(t *testing.T) {
297 client := &restoredHandoffClient{325 client := &restoredHandoffClient{
298 requests: make(chan provider.Request, 2),326 requests: make(chan provider.Request, 2),
@@ -364,12 +392,21 @@ func TestRestoredHandoffAfterFinalReplyIsDiscarded(t *testing.T) {
364 Agent: agent,392 Agent: agent,
365 InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),393 InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
366 PersistCompactHandoff: func(state json.RawMessage) error {394 PersistCompactHandoff: func(state json.RawMessage) error {
367- persisted <- string(state)395+ select {
396+ case persisted <- string(state):
397+ default:
398+ }
368 return nil399 return nil
369 },400 },
370 })401 })
371 402
372 interactive.startRestoredCompactHandoff(context.Background())403 interactive.startRestoredCompactHandoff(context.Background())
404+ interactive.mu.Lock()
405+ busy := interactive.busy
406+ interactive.mu.Unlock()
407+ if busy {
408+ t.Fatal("discarded restored handoff left the turn slot reserved")
409+ }
373 select {410 select {
374 case state := <-persisted:411 case state := <-persisted:
375 if state != "" {412 if state != "" {
@@ -385,6 +422,110 @@ func TestRestoredHandoffAfterFinalReplyIsDiscarded(t *testing.T) {
385 }422 }
386 }423 }
387 424
425+func TestRestoredHandoffReservesTurnBeforePersistence(t *testing.T) {
426+ client := &restoredHandoffClient{
427+ requests: make(chan provider.Request, 1),
428+ replies: []string{"All required work is complete."},
429+ }
430+ agent := core.NewAgent(client, "test-model", "", nil)
431+ agent.SetMessages([]provider.Message{{
432+ Role: provider.RoleUser,
433+ Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
434+ Meta: map[string]string{autoCompactContinueMetaKey: "true"},
435+ }})
436+ persistenceStarted := make(chan struct{})
437+ releasePersistence := make(chan struct{})
438+ released := false
439+ defer func() {
440+ if !released {
441+ close(releasePersistence)
442+ }
443+ }()
444+ interactive := NewInteractive(InteractiveConfig{
445+ Agent: agent,
446+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
447+ PersistCompactHandoff: func(json.RawMessage) error {
448+ close(persistenceStarted)
449+ <-releasePersistence
450+ return nil
451+ },
452+ })
453+ interactive.runCtx = context.Background()
454+ interactive.mu.Lock()
455+ interactive.queued = []string{"newer user request"}
456+ interactive.mu.Unlock()
457+
458+ done := make(chan struct{})
459+ go func() {
460+ interactive.startRestoredCompactHandoff(context.Background())
461+ close(done)
462+ }()
463+ select {
464+ case <-persistenceStarted:
465+ case <-time.After(2 * time.Second):
466+ t.Fatal("restored handoff did not reach persistence")
467+ }
468+ interactive.mu.Lock()
469+ busy := interactive.busy
470+ interactive.mu.Unlock()
471+ if !busy {
472+ t.Fatal("restored handoff did not reserve the turn slot before persistence")
473+ }
474+ close(releasePersistence)
475+ released = true
476+ <-done
477+ select {
478+ case <-client.requests:
479+ case <-time.After(2 * time.Second):
480+ t.Fatal("restored handoff did not continue after persistence")
481+ }
482+ waitInteractiveIdle(t, interactive)
483+}
484+
485+func TestStatusRescueClearsHandoffWhenAgentIsRemovedDuringTurn(t *testing.T) {
486+ client := &heldCompletionClient{started: make(chan struct{}), release: make(chan struct{})}
487+ agent := core.NewAgent(client, "test-model", "", nil)
488+ agent.SetMessages([]provider.Message{{
489+ Role: provider.RoleUser,
490+ Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
491+ Meta: map[string]string{autoCompactContinueMetaKey: "true"},
492+ }})
493+ persisted := make(chan string, 1)
494+ interactive := NewInteractive(InteractiveConfig{
495+ Agent: agent,
496+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
497+ PersistCompactHandoff: func(state json.RawMessage) error {
498+ select {
499+ case persisted <- string(state):
500+ default:
501+ }
502+ return nil
503+ },
504+ })
505+ interactive.runCtx = context.Background()
506+
507+ interactive.startTurnRequest(context.Background(), "", nil, true, false)
508+ select {
509+ case <-client.started:
510+ case <-time.After(2 * time.Second):
511+ t.Fatal("continuation did not start")
512+ }
513+ interactive.mu.Lock()
514+ interactive.agent = nil
515+ interactive.mu.Unlock()
516+ close(client.release)
517+
518+ select {
519+ case state := <-persisted:
520+ if state != "" {
521+ t.Fatalf("cleared handoff = %q, want empty", state)
522+ }
523+ case <-time.After(2 * time.Second):
524+ t.Fatal("agent removal did not clear the persisted handoff")
525+ }
526+ waitInteractiveIdle(t, interactive)
527+}
528+
388 func TestRestoredHandoffDefersToQueuedUserPrompt(t *testing.T) {529 func TestRestoredHandoffDefersToQueuedUserPrompt(t *testing.T) {
389 client := &restoredHandoffClient{requests: make(chan provider.Request, 1)}530 client := &restoredHandoffClient{requests: make(chan provider.Request, 1)}
390 agent := core.NewAgent(client, "test-model", "", nil)531 agent := core.NewAgent(client, "test-model", "", nil)
@@ -503,7 +644,10 @@ func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) {
503 Model: "claude-sonnet-4-5-20250929",644 Model: "claude-sonnet-4-5-20250929",
504 AutoCompactThreshold: &threshold,645 AutoCompactThreshold: &threshold,
505 PersistCompactHandoff: func(state json.RawMessage) error {646 PersistCompactHandoff: func(state json.RawMessage) error {
506- persistedHandoff <- string(state)647+ select {
648+ case persistedHandoff <- string(state):
649+ default:
650+ }
507 return nil651 return nil
508 },652 },
509 })653 })
M packages/agent/modes/interactive.go +20 -11
@@ -3159,15 +3159,15 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
31593159 handoff, persistHandoff = i.resetCompactContinuationLocked()
31603160 }
31613161 i.mu.Unlock()
3162- if persistHandoff {
3163- i.persistCompactHandoff(handoff)
3164- }
31653162 if busyCancel {
31663163 cancelTurn()
31673164 // If a confirm dialog is pending, refuse it so the agent
31683165 // goroutine unblocks and the context cancellation can
31693166 // actually take effect.
31703167 i.confirmDialog.CancelAll("turn cancelled")
3168+ if persistHandoff {
3169+ i.persistCompactHandoff(handoff)
3170+ }
31713171 return false
31723172 }
31733173 case tui.KeyCtrlD:
@@ -4036,11 +4036,11 @@ func (i *Interactive) CancelTurn() {
40364036 i.mu.Lock()
40374037 handoff, persistHandoff := i.resetCompactContinuationLocked()
40384038 i.mu.Unlock()
4039+ cancel()
4040+ i.confirmDialog.CancelAll("turn cancelled")
40394041 if persistHandoff {
40404042 i.persistCompactHandoff(handoff)
40414043 }
4042- cancel()
4043- i.confirmDialog.CancelAll("turn cancelled")
40444044 }
40454045 }
40464046
@@ -6413,10 +6413,10 @@ func (i *Interactive) cancelAndWaitForIdle() {
64136413 i.mu.Lock()
64146414 handoff, persistHandoff := i.resetCompactContinuationLocked()
64156415 i.mu.Unlock()
6416+ cancel()
64166417 if persistHandoff {
64176418 i.persistCompactHandoff(handoff)
64186419 }
6419- cancel()
64206420 }
64216421 // ConfirmToolCall waits on its response channel rather than the turn
64226422 // context, so cancellation must also resolve every pending prompt.
@@ -7026,10 +7026,18 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) {
70267026 handoff, persistHandoff = i.resetCompactContinuationLocked()
70277027 }
70287028 }
7029+ // Reserve the turn slot before persisting or dispatching so concurrent input
7030+ // can only queue behind the restored handoff, never start a competing turn.
7031+ starting := next != "" || continueQueued || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt
7032+ i.busy = starting
70297033 i.mu.Unlock()
70307034 if persistHandoff {
70317035 i.persistCompactHandoff(handoff)
70327036 }
7037+ if !starting {
7038+ i.invalidate()
7039+ return
7040+ }
70337041 switch {
70347042 case next != "":
70357043 i.startTurn(parent, next)
@@ -7661,7 +7669,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
76617669 continueStatusRescue := false
76627670 var handoff json.RawMessage
76637671 var persistHandoff bool
7664- if statusRescueActive && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil {
7672+ if statusRescueActive && i.agent != nil && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil {
76657673 followUpMessages := i.agent.Messages()
76667674 reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages)
76677675 if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations {
@@ -7674,7 +7682,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
76747682 handoff, persistHandoff = i.resetCompactContinuationLocked()
76757683 }
76767684 }
7677- if !continueStatusRescue && (ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {
7685+ if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {
76787686 handoff, persistHandoff = i.resetCompactContinuationLocked()
76797687 }
76807688 // The agent run can finish before the paced final text reaches the
@@ -9362,9 +9370,10 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in
93629370 i.statusErr = ""
93639371 i.mu.Unlock()
93649372 i.scrollToBottom()
9365- // Selection only changes local/session state. In particular, do not call
9366- // Submit, startTurn, or queue a message here; the restored user draft is
9367- // submitted only by a later explicit Enter.
9373+ // Selection does not submit, start, or queue the restored user draft; only
9374+ // a later explicit Enter does that. An inherited compact handoff may resume
9375+ // its hidden continuation below when this branch has the complete effective
9376+ // transcript, without submitting the restored draft.
93689377 i.invalidate()
93699378 if state.reason != compactContinuationNone {
93709379 i.startRestoredCompactHandoff(i.runCtx)
@@ -3159,15 +3159,15 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
3159 handoff, persistHandoff = i.resetCompactContinuationLocked()3159 handoff, persistHandoff = i.resetCompactContinuationLocked()
3160 }3160 }
3161 i.mu.Unlock()3161 i.mu.Unlock()
3162- if persistHandoff {
3163- i.persistCompactHandoff(handoff)
3164- }
3165 if busyCancel {3162 if busyCancel {
3166 cancelTurn()3163 cancelTurn()
3167 // If a confirm dialog is pending, refuse it so the agent3164 // If a confirm dialog is pending, refuse it so the agent
3168 // goroutine unblocks and the context cancellation can3165 // goroutine unblocks and the context cancellation can
3169 // actually take effect.3166 // actually take effect.
3170 i.confirmDialog.CancelAll("turn cancelled")3167 i.confirmDialog.CancelAll("turn cancelled")
3168+ if persistHandoff {
3169+ i.persistCompactHandoff(handoff)
3170+ }
3171 return false3171 return false
3172 }3172 }
3173 case tui.KeyCtrlD:3173 case tui.KeyCtrlD:
@@ -4036,11 +4036,11 @@ func (i *Interactive) CancelTurn() {
4036 i.mu.Lock()4036 i.mu.Lock()
4037 handoff, persistHandoff := i.resetCompactContinuationLocked()4037 handoff, persistHandoff := i.resetCompactContinuationLocked()
4038 i.mu.Unlock()4038 i.mu.Unlock()
4039+ cancel()
4040+ i.confirmDialog.CancelAll("turn cancelled")
4039 if persistHandoff {4041 if persistHandoff {
4040 i.persistCompactHandoff(handoff)4042 i.persistCompactHandoff(handoff)
4041 }4043 }
4042- cancel()
4043- i.confirmDialog.CancelAll("turn cancelled")
4044 }4044 }
4045 }4045 }
4046 4046
@@ -6413,10 +6413,10 @@ func (i *Interactive) cancelAndWaitForIdle() {
6413 i.mu.Lock()6413 i.mu.Lock()
6414 handoff, persistHandoff := i.resetCompactContinuationLocked()6414 handoff, persistHandoff := i.resetCompactContinuationLocked()
6415 i.mu.Unlock()6415 i.mu.Unlock()
6416+ cancel()
6416 if persistHandoff {6417 if persistHandoff {
6417 i.persistCompactHandoff(handoff)6418 i.persistCompactHandoff(handoff)
6418 }6419 }
6419- cancel()
6420 }6420 }
6421 // ConfirmToolCall waits on its response channel rather than the turn6421 // ConfirmToolCall waits on its response channel rather than the turn
6422 // context, so cancellation must also resolve every pending prompt.6422 // context, so cancellation must also resolve every pending prompt.
@@ -7026,10 +7026,18 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) {
7026 handoff, persistHandoff = i.resetCompactContinuationLocked()7026 handoff, persistHandoff = i.resetCompactContinuationLocked()
7027 }7027 }
7028 }7028 }
7029+ // Reserve the turn slot before persisting or dispatching so concurrent input
7030+ // can only queue behind the restored handoff, never start a competing turn.
7031+ starting := next != "" || continueQueued || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt
7032+ i.busy = starting
7029 i.mu.Unlock()7033 i.mu.Unlock()
7030 if persistHandoff {7034 if persistHandoff {
7031 i.persistCompactHandoff(handoff)7035 i.persistCompactHandoff(handoff)
7032 }7036 }
7037+ if !starting {
7038+ i.invalidate()
7039+ return
7040+ }
7033 switch {7041 switch {
7034 case next != "":7042 case next != "":
7035 i.startTurn(parent, next)7043 i.startTurn(parent, next)
@@ -7661,7 +7669,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
7661 continueStatusRescue := false7669 continueStatusRescue := false
7662 var handoff json.RawMessage7670 var handoff json.RawMessage
7663 var persistHandoff bool7671 var persistHandoff bool
7664- if statusRescueActive && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil {7672+ if statusRescueActive && i.agent != nil && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil {
7665 followUpMessages := i.agent.Messages()7673 followUpMessages := i.agent.Messages()
7666 reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages)7674 reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages)
7667 if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations {7675 if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations {
@@ -7674,7 +7682,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
7674 handoff, persistHandoff = i.resetCompactContinuationLocked()7682 handoff, persistHandoff = i.resetCompactContinuationLocked()
7675 }7683 }
7676 }7684 }
7677- if !continueStatusRescue && (ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {7685+ if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {
7678 handoff, persistHandoff = i.resetCompactContinuationLocked()7686 handoff, persistHandoff = i.resetCompactContinuationLocked()
7679 }7687 }
7680 // The agent run can finish before the paced final text reaches the7688 // The agent run can finish before the paced final text reaches the
@@ -9362,9 +9370,10 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in
9362 i.statusErr = ""9370 i.statusErr = ""
9363 i.mu.Unlock()9371 i.mu.Unlock()
9364 i.scrollToBottom()9372 i.scrollToBottom()
9365- // Selection only changes local/session state. In particular, do not call9373+ // Selection does not submit, start, or queue the restored user draft; only
9366- // Submit, startTurn, or queue a message here; the restored user draft is9374+ // a later explicit Enter does that. An inherited compact handoff may resume
9367- // submitted only by a later explicit Enter.9375+ // its hidden continuation below when this branch has the complete effective
9376+ // transcript, without submitting the restored draft.
9368 i.invalidate()9377 i.invalidate()
9369 if state.reason != compactContinuationNone {9378 if state.reason != compactContinuationNone {
9370 i.startRestoredCompactHandoff(i.runCtx)9379 i.startRestoredCompactHandoff(i.runCtx)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close