fix(tui): reset render state after auto-compaction
- commit
- 01e820c8b025e1f38b086f2844d9e564cbdb0add
- author
- brice · · 2026-08-05 15:04 UTC
- parent
- e507fbe
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +106 -2
M packages/agent/modes/compact_queue_test.go +80 -0
| @@ -56,6 +56,86 @@ func (c *compactQueueClient) Stream(ctx context.Context, req provider.Request) ( | |||
| 56 | 56 | return out, nil | |
| 57 | 57 | } | |
| 58 | 58 | ||
| 59 | + | type autoCompactPacerClient struct { | |
| 60 | + | compactionStarted chan struct{} | |
| 61 | + | releaseCompaction chan struct{} | |
| 62 | + | ||
| 63 | + | mu sync.Mutex | |
| 64 | + | calls int | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func (c *autoCompactPacerClient) Name() string { return "auto-compact-pacer-test" } | |
| 68 | + | ||
| 69 | + | func (c *autoCompactPacerClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) { | |
| 70 | + | c.mu.Lock() | |
| 71 | + | c.calls++ | |
| 72 | + | call := c.calls | |
| 73 | + | c.mu.Unlock() | |
| 74 | + | ||
| 75 | + | out := make(chan provider.Event, 3) | |
| 76 | + | go func() { | |
| 77 | + | defer close(out) | |
| 78 | + | switch call { | |
| 79 | + | case 1: | |
| 80 | + | const response = "a final response that is still buffered for typewriter rendering" | |
| 81 | + | out <- provider.EventTextDelta{Delta: response} | |
| 82 | + | out <- provider.EventUsage{Usage: provider.Usage{InputTokens: 150000}} | |
| 83 | + | out <- provider.EventDone{ | |
| 84 | + | Stop: provider.StopEnd, | |
| 85 | + | Message: provider.Message{ | |
| 86 | + | Role: provider.RoleAssistant, | |
| 87 | + | Content: []provider.Content{provider.TextBlock{Text: response}}, | |
| 88 | + | }, | |
| 89 | + | } | |
| 90 | + | case 2: | |
| 91 | + | close(c.compactionStarted) | |
| 92 | + | select { | |
| 93 | + | case <-ctx.Done(): | |
| 94 | + | out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()} | |
| 95 | + | case <-c.releaseCompaction: | |
| 96 | + | out <- provider.EventTextDelta{Delta: "summary"} | |
| 97 | + | out <- provider.EventDone{Stop: provider.StopEnd} | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | }() | |
| 101 | + | return out, nil | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func TestAutoCompactionSettlesFinalStreamingStateBeforeCompacting(t *testing.T) { | |
| 105 | + | client := &autoCompactPacerClient{ | |
| 106 | + | compactionStarted: make(chan struct{}), | |
| 107 | + | releaseCompaction: make(chan struct{}), | |
| 108 | + | } | |
| 109 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 110 | + | threshold := 70 | |
| 111 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 112 | + | Agent: agent, | |
| 113 | + | Provider: "anthropic", | |
| 114 | + | Model: "claude-sonnet-4-5-20250929", | |
| 115 | + | AutoCompactThreshold: &threshold, | |
| 116 | + | }) | |
| 117 | + | interactive.runCtx = context.Background() | |
| 118 | + | ||
| 119 | + | // Do not start the pacer: the test controls the unpainted final delta so | |
| 120 | + | // it can prove compaction never races with a still-live streaming frame. | |
| 121 | + | interactive.startTurn(context.Background(), "initial request") | |
| 122 | + | select { | |
| 123 | + | case <-client.compactionStarted: | |
| 124 | + | case <-time.After(2 * time.Second): | |
| 125 | + | t.Fatal("auto-compaction did not start") | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | interactive.mu.Lock() | |
| 129 | + | streaming, pending := interactive.streaming.Len(), len(interactive.streamPending) | |
| 130 | + | streamOn, flushPending := interactive.streamOn, interactive.streamFlushPending | |
| 131 | + | interactive.mu.Unlock() | |
| 132 | + | if streaming != 0 || pending != 0 || streamOn || flushPending { | |
| 133 | + | t.Fatalf("auto-compaction started with live stream state: rendered=%d pending=%d on=%t flush=%t", streaming, pending, streamOn, flushPending) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | close(client.releaseCompaction) | |
| 137 | + | } | |
| 138 | + | ||
| 59 | 139 | func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) { | |
| 60 | 140 | client := &compactQueueClient{ | |
| 61 | 141 | compactionStarted: make(chan struct{}), | |
| @@ -56,6 +56,86 @@ func (c *compactQueueClient) Stream(ctx context.Context, req provider.Request) ( | |||||
| 56 | return out, nil | 56 | return out, nil | ||
| 57 | } | 57 | } | ||
| 58 | 58 | ||||
| 59 | + | type autoCompactPacerClient struct { | |||
| 60 | + | compactionStarted chan struct{} | |||
| 61 | + | releaseCompaction chan struct{} | |||
| 62 | + | ||||
| 63 | + | mu sync.Mutex | |||
| 64 | + | calls int | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func (c *autoCompactPacerClient) Name() string { return "auto-compact-pacer-test" } | |||
| 68 | + | ||||
| 69 | + | func (c *autoCompactPacerClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) { | |||
| 70 | + | c.mu.Lock() | |||
| 71 | + | c.calls++ | |||
| 72 | + | call := c.calls | |||
| 73 | + | c.mu.Unlock() | |||
| 74 | + | ||||
| 75 | + | out := make(chan provider.Event, 3) | |||
| 76 | + | go func() { | |||
| 77 | + | defer close(out) | |||
| 78 | + | switch call { | |||
| 79 | + | case 1: | |||
| 80 | + | const response = "a final response that is still buffered for typewriter rendering" | |||
| 81 | + | out <- provider.EventTextDelta{Delta: response} | |||
| 82 | + | out <- provider.EventUsage{Usage: provider.Usage{InputTokens: 150000}} | |||
| 83 | + | out <- provider.EventDone{ | |||
| 84 | + | Stop: provider.StopEnd, | |||
| 85 | + | Message: provider.Message{ | |||
| 86 | + | Role: provider.RoleAssistant, | |||
| 87 | + | Content: []provider.Content{provider.TextBlock{Text: response}}, | |||
| 88 | + | }, | |||
| 89 | + | } | |||
| 90 | + | case 2: | |||
| 91 | + | close(c.compactionStarted) | |||
| 92 | + | select { | |||
| 93 | + | case <-ctx.Done(): | |||
| 94 | + | out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()} | |||
| 95 | + | case <-c.releaseCompaction: | |||
| 96 | + | out <- provider.EventTextDelta{Delta: "summary"} | |||
| 97 | + | out <- provider.EventDone{Stop: provider.StopEnd} | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | }() | |||
| 101 | + | return out, nil | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func TestAutoCompactionSettlesFinalStreamingStateBeforeCompacting(t *testing.T) { | |||
| 105 | + | client := &autoCompactPacerClient{ | |||
| 106 | + | compactionStarted: make(chan struct{}), | |||
| 107 | + | releaseCompaction: make(chan struct{}), | |||
| 108 | + | } | |||
| 109 | + | agent := core.NewAgent(client, "test-model", "", nil) | |||
| 110 | + | threshold := 70 | |||
| 111 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 112 | + | Agent: agent, | |||
| 113 | + | Provider: "anthropic", | |||
| 114 | + | Model: "claude-sonnet-4-5-20250929", | |||
| 115 | + | AutoCompactThreshold: &threshold, | |||
| 116 | + | }) | |||
| 117 | + | interactive.runCtx = context.Background() | |||
| 118 | + | ||||
| 119 | + | // Do not start the pacer: the test controls the unpainted final delta so | |||
| 120 | + | // it can prove compaction never races with a still-live streaming frame. | |||
| 121 | + | interactive.startTurn(context.Background(), "initial request") | |||
| 122 | + | select { | |||
| 123 | + | case <-client.compactionStarted: | |||
| 124 | + | case <-time.After(2 * time.Second): | |||
| 125 | + | t.Fatal("auto-compaction did not start") | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | interactive.mu.Lock() | |||
| 129 | + | streaming, pending := interactive.streaming.Len(), len(interactive.streamPending) | |||
| 130 | + | streamOn, flushPending := interactive.streamOn, interactive.streamFlushPending | |||
| 131 | + | interactive.mu.Unlock() | |||
| 132 | + | if streaming != 0 || pending != 0 || streamOn || flushPending { | |||
| 133 | + | t.Fatalf("auto-compaction started with live stream state: rendered=%d pending=%d on=%t flush=%t", streaming, pending, streamOn, flushPending) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | close(client.releaseCompaction) | |||
| 137 | + | } | |||
| 138 | + | ||||
| 59 | func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) { | 139 | func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) { | ||
| 60 | client := &compactQueueClient{ | 140 | client := &compactQueueClient{ | ||
| 61 | compactionStarted: make(chan struct{}), | 141 | compactionStarted: make(chan struct{}), | ||
M packages/agent/modes/interactive.go +26 -2
| @@ -6471,7 +6471,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||
| 6471 | 6471 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 6472 | 6472 | i.toolOrder = nil | |
| 6473 | 6473 | i.toolGate = map[string]int{} | |
| 6474 | - | i.view.InvalidateRenderCache() | |
| 6474 | + | i.resetTranscriptRenderLocked() | |
| 6475 | 6475 | switch { | |
| 6476 | 6476 | case i.continueAfterCompact: | |
| 6477 | 6477 | continueExisting = true | |
| @@ -6864,6 +6864,13 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 6864 | 6864 | } | |
| 6865 | 6865 | continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil | |
| 6866 | 6866 | shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked() | |
| 6867 | + | // The agent run can finish before the paced final text reaches the | |
| 6868 | + | // transcript. A compaction replaces that transcript, so it must never | |
| 6869 | + | // race the still-live stream frame; otherwise stale deltas can repaint | |
| 6870 | + | // after the replacement and corrupt the scrollback renderer's model. | |
| 6871 | + | if recoverContextOverflow || shouldAutoCompact { | |
| 6872 | + | i.resetStreamingStateLocked() | |
| 6873 | + | } | |
| 6867 | 6874 | alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact) | |
| 6868 | 6875 | i.busy = hasNext || continueQueued || recoverContextOverflow || shouldAutoCompact | |
| 6869 | 6876 | i.mu.Unlock() | |
| @@ -8405,8 +8412,25 @@ func assistantText(m provider.Message) string { | |||
| 8405 | 8412 | return sb.String() | |
| 8406 | 8413 | } | |
| 8407 | 8414 | ||
| 8415 | + | // resetTranscriptRenderLocked invalidates every render cache that assumes | |
| 8416 | + | // the previous transcript remains structurally intact. Compaction is a | |
| 8417 | + | // replacement, not an append: the flow renderer must repaint from the new | |
| 8418 | + | // transcript rather than diff it against scrollback rows from the old one. | |
| 8419 | + | // Must be called with i.mu held. | |
| 8420 | + | func (i *Interactive) resetTranscriptRenderLocked() { | |
| 8421 | + | i.view.InvalidateRenderCache() | |
| 8422 | + | i.chatCacheValid = false | |
| 8423 | + | i.prevChatLen = 0 | |
| 8424 | + | i.prevChatCols = 0 | |
| 8425 | + | i.prevChatRows = 0 | |
| 8426 | + | i.prevScrollOffset = 0 | |
| 8427 | + | if i.rend != nil { | |
| 8428 | + | i.rend.Invalidate() | |
| 8429 | + | } | |
| 8430 | + | } | |
| 8431 | + | ||
| 8408 | 8432 | // resetStreamingStateLocked clears every piece of streaming state | |
| 8409 | - | // in one shot. Used by abort paths (turn cancel, compact finish, | |
| 8433 | + | // in one shot. Used by abort paths (turn cancel, compact hand-off, | |
| 8410 | 8434 | // queue drain) so the pacer doesn't keep draining stale runes from | |
| 8411 | 8435 | // a prior turn. Must be called with i.mu held. | |
| 8412 | 8436 | func (i *Interactive) resetStreamingStateLocked() { | |
| @@ -6471,7 +6471,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||||
| 6471 | i.toolCalls = map[string]*tui.ToolCallView{} | 6471 | i.toolCalls = map[string]*tui.ToolCallView{} | ||
| 6472 | i.toolOrder = nil | 6472 | i.toolOrder = nil | ||
| 6473 | i.toolGate = map[string]int{} | 6473 | i.toolGate = map[string]int{} | ||
| 6474 | - | i.view.InvalidateRenderCache() | 6474 | + | i.resetTranscriptRenderLocked() |
| 6475 | switch { | 6475 | switch { | ||
| 6476 | case i.continueAfterCompact: | 6476 | case i.continueAfterCompact: | ||
| 6477 | continueExisting = true | 6477 | continueExisting = true | ||
| @@ -6864,6 +6864,13 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 6864 | } | 6864 | } | ||
| 6865 | continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil | 6865 | continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil | ||
| 6866 | shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked() | 6866 | shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked() | ||
| 6867 | + | // The agent run can finish before the paced final text reaches the | |||
| 6868 | + | // transcript. A compaction replaces that transcript, so it must never | |||
| 6869 | + | // race the still-live stream frame; otherwise stale deltas can repaint | |||
| 6870 | + | // after the replacement and corrupt the scrollback renderer's model. | |||
| 6871 | + | if recoverContextOverflow || shouldAutoCompact { | |||
| 6872 | + | i.resetStreamingStateLocked() | |||
| 6873 | + | } | |||
| 6867 | alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact) | 6874 | alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact) | ||
| 6868 | i.busy = hasNext || continueQueued || recoverContextOverflow || shouldAutoCompact | 6875 | i.busy = hasNext || continueQueued || recoverContextOverflow || shouldAutoCompact | ||
| 6869 | i.mu.Unlock() | 6876 | i.mu.Unlock() | ||
| @@ -8405,8 +8412,25 @@ func assistantText(m provider.Message) string { | |||||
| 8405 | return sb.String() | 8412 | return sb.String() | ||
| 8406 | } | 8413 | } | ||
| 8407 | 8414 | ||||
| 8415 | + | // resetTranscriptRenderLocked invalidates every render cache that assumes | |||
| 8416 | + | // the previous transcript remains structurally intact. Compaction is a | |||
| 8417 | + | // replacement, not an append: the flow renderer must repaint from the new | |||
| 8418 | + | // transcript rather than diff it against scrollback rows from the old one. | |||
| 8419 | + | // Must be called with i.mu held. | |||
| 8420 | + | func (i *Interactive) resetTranscriptRenderLocked() { | |||
| 8421 | + | i.view.InvalidateRenderCache() | |||
| 8422 | + | i.chatCacheValid = false | |||
| 8423 | + | i.prevChatLen = 0 | |||
| 8424 | + | i.prevChatCols = 0 | |||
| 8425 | + | i.prevChatRows = 0 | |||
| 8426 | + | i.prevScrollOffset = 0 | |||
| 8427 | + | if i.rend != nil { | |||
| 8428 | + | i.rend.Invalidate() | |||
| 8429 | + | } | |||
| 8430 | + | } | |||
| 8431 | + | ||||
| 8408 | // resetStreamingStateLocked clears every piece of streaming state | 8432 | // resetStreamingStateLocked clears every piece of streaming state | ||
| 8409 | - | // in one shot. Used by abort paths (turn cancel, compact finish, | 8433 | + | // in one shot. Used by abort paths (turn cancel, compact hand-off, |
| 8410 | // queue drain) so the pacer doesn't keep draining stale runes from | 8434 | // queue drain) so the pacer doesn't keep draining stale runes from | ||
| 8411 | // a prior turn. Must be called with i.mu held. | 8435 | // a prior turn. Must be called with i.mu held. | ||
| 8412 | func (i *Interactive) resetStreamingStateLocked() { | 8436 | func (i *Interactive) resetStreamingStateLocked() { | ||