From 874c2b1fe6ec185f9d1da17d31d2c7885d58c35c Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Sat, 23 May 2026 13:31:06 -0500 Subject: [PATCH] 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. --- .../createGatewayEventHandler.test.ts | 39 ++++++++++++++++--- ui-tui/src/app/createGatewayEventHandler.ts | 13 ++++++- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index 15ed7f1edd7..0a3e4227396 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -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', () => { diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index deb28a7afc0..26d6cfacd0c 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -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) }