detached 01e820c

fix(tui): reset render state after auto-compaction

commit
01e820c8b025e1f38b086f2844d9e564cbdb0add
author
brice · · 2026-08-05 15:04 UTC
parent
e507fbe
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) (
5656 return out, nil
5757 }
5858
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+
59139 func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) {
60140 client := &compactQueueClient{
61141 compactionStarted: make(chan struct{}),
@@ -56,6 +56,86 @@ func (c *compactQueueClient) Stream(ctx context.Context, req provider.Request) (
56 return out, nil56 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) {
64716471 i.toolCalls = map[string]*tui.ToolCallView{}
64726472 i.toolOrder = nil
64736473 i.toolGate = map[string]int{}
6474- i.view.InvalidateRenderCache()
6474+ i.resetTranscriptRenderLocked()
64756475 switch {
64766476 case i.continueAfterCompact:
64776477 continueExisting = true
@@ -6864,6 +6864,13 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
68646864 }
68656865 continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil
68666866 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+ }
68676874 alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)
68686875 i.busy = hasNext || continueQueued || recoverContextOverflow || shouldAutoCompact
68696876 i.mu.Unlock()
@@ -8405,8 +8412,25 @@ func assistantText(m provider.Message) string {
84058412 return sb.String()
84068413 }
84078414
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+
84088432 // 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,
84108434 // queue drain) so the pacer doesn't keep draining stale runes from
84118435 // a prior turn. Must be called with i.mu held.
84128436 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 = nil6472 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 = true6477 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() == nil6865 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 || shouldAutoCompact6875 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 state8432 // 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 from8434 // 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() {

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