fix(tui): ignore late thinking deltas after completion (#31055)

* fix(tui): ignore late thinking deltas after completion

Prevent stale reasoning events from repainting the TUI status after a turn has already completed and the UI is idle.

* test(tui): restore timers after thinking delta assertion

Keep fake timer cleanup in a finally block so assertion failures cannot leak timer mode into later tests.
This commit is contained in:
brooklyn! 2026-05-23 13:31:06 -05:00 committed by GitHub
parent e6ca730a22
commit 874c2b1fe6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 44 additions and 8 deletions

View file

@ -139,6 +139,7 @@ describe('createGatewayEventHandler', () => {
const verdict = '✓ Goal achieved: long judge reason goes only in transcript, not merged with cwd label.'
vi.useFakeTimers()
try {
onEvent({
payload: { kind: 'goal', text: verdict },
@ -303,14 +304,40 @@ describe('createGatewayEventHandler', () => {
vi.useFakeTimers()
const appended: Msg[] = []
const streamed = 'short streamed reasoning'
const onEvent = createGatewayEventHandler(buildCtx(appended))
createGatewayEventHandler(buildCtx(appended))({ payload: { text: streamed }, type: 'thinking.delta' } as any)
vi.runOnlyPendingTimers()
try {
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({ payload: { text: streamed }, type: 'thinking.delta' } as any)
vi.runOnlyPendingTimers()
expect(getTurnState().reasoning).toBe(streamed)
expect(getTurnState().reasoningActive).toBe(true)
expect(getTurnState().reasoningTokens).toBe(estimateTokensRough(streamed))
vi.useRealTimers()
expect(getTurnState().reasoning).toBe(streamed)
expect(getTurnState().reasoningActive).toBe(true)
expect(getTurnState().reasoningTokens).toBe(estimateTokensRough(streamed))
} finally {
vi.useRealTimers()
}
})
it('ignores late thinking.delta after the turn has already completed', () => {
vi.useFakeTimers()
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
try {
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({ payload: { text: 'final answer' }, type: 'message.complete' } as any)
expect(getUiState().busy).toBe(false)
expect(getUiState().status).toBe('ready')
onEvent({ payload: { text: 'thinking...' }, type: 'thinking.delta' } as any)
vi.runOnlyPendingTimers()
expect(getUiState().status).toBe('ready')
expect(getTurnState().reasoning).toBe('')
} finally {
vi.useRealTimers()
}
})
it('preserves streamed reasoning as one completed thinking panel after segment flushes', () => {

View file

@ -1,6 +1,6 @@
import { STARTUP_IMAGE, STARTUP_QUERY } from '../config/env.js'
import { STREAM_BATCH_MS } from '../config/timing.js'
import { SETUP_REQUIRED_TITLE, buildSetupRequiredSections } from '../content/setup.js'
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
import type {
CommandsCatalogResponse,
ConfigFullResponse,
@ -313,6 +313,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
}
case 'thinking.delta': {
if (!getUiState().busy) {
return
}
const text = ev.payload?.text
if (text !== undefined) {
@ -340,6 +344,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
if (p.kind === 'goal') {
sys(p.text)
const brief = p.text.startsWith('✓')
? '✓ goal complete'
: p.text.startsWith('↻')
@ -347,8 +352,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
: p.text.startsWith('⏸')
? '⏸ goal paused'
: 'ready'
setStatus(brief)
restoreStatusAfter(6000)
return
}
@ -356,6 +363,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
if (p.kind === 'compressing') {
sys(p.text)
return
}
@ -528,6 +536,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
case 'tool.complete': {
const inlineDiffText =
ev.payload.inline_diff && getUiState().inlineDiffs ? stripAnsi(String(ev.payload.inline_diff)).trim() : ''
const resultText = ev.payload.result_text ? stripAnsi(String(ev.payload.result_text)) : undefined
if (inlineDiffText) {
@ -589,7 +598,6 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
sys(`[bg ${ev.payload.task_id}] ${ev.payload.text}`)
return
case 'review.summary': {
// Self-improvement background review emitted a persistent summary
// of what it saved to memory/skills. Surface it as a system line
@ -597,6 +605,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
// flash. Python-side already formats it as "💾 Self-improvement
// review: …".
const text = String(ev.payload?.text ?? '').trim()
if (text) {
sys(text)
}