From bb59d3bac24888912c34e046573e1e2ccf492ecb Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Apr 2026 13:49:41 -0500 Subject: [PATCH] fix(tui): preserve completed thinking panel --- .../createGatewayEventHandler.test.ts | 34 +++++++++++++------ ui-tui/src/app/turnController.ts | 10 +----- ui-tui/src/components/thinking.tsx | 12 +++---- 3 files changed, 31 insertions(+), 25 deletions(-) diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index 4f7ccdb77e..1114c7161a 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -82,13 +82,12 @@ describe('createGatewayEventHandler', () => { type: 'message.complete' } as any) - expect(appended).toHaveLength(3) + expect(appended).toHaveLength(2) expect(appended[0]).toMatchObject({ kind: 'trail', role: 'system', text: '', thinking: 'mapped the page' }) - expect(appended[1]).toMatchObject({ kind: 'trail', role: 'system', text: '' }) - expect(appended[1]?.tools).toHaveLength(1) - expect(appended[1]?.tools?.[0]).toContain('hero cards') - expect(appended[1]?.toolTokens).toBeGreaterThan(0) - expect(appended[2]).toMatchObject({ role: 'assistant', text: 'final answer' }) + expect(appended[0]?.tools).toHaveLength(1) + expect(appended[0]?.tools?.[0]).toContain('hero cards') + expect(appended[0]?.toolTokens).toBeGreaterThan(0) + expect(appended[1]).toMatchObject({ role: 'assistant', text: 'final answer' }) }) it('keeps tool tokens across handler recreation mid-turn', () => { @@ -116,10 +115,10 @@ describe('createGatewayEventHandler', () => { type: 'message.complete' } as any) - expect(appended).toHaveLength(3) - expect(appended[1]?.tools).toHaveLength(1) - expect(appended[1]?.toolTokens).toBeGreaterThan(0) - expect(appended[2]).toMatchObject({ role: 'assistant', text: 'final answer' }) + expect(appended).toHaveLength(2) + expect(appended[0]?.tools).toHaveLength(1) + expect(appended[0]?.toolTokens).toBeGreaterThan(0) + expect(appended[1]).toMatchObject({ role: 'assistant', text: 'final answer' }) }) it('streams legacy thinking.delta into visible reasoning state', () => { @@ -136,6 +135,21 @@ describe('createGatewayEventHandler', () => { vi.useRealTimers() }) + it('preserves streamed reasoning as one completed thinking panel after segment flushes', () => { + const appended: Msg[] = [] + const streamed = 'first reasoning chunk\nsecond reasoning chunk' + + const onEvent = createGatewayEventHandler(buildCtx(appended)) + + onEvent({ payload: { text: streamed }, type: 'reasoning.delta' } as any) + onEvent({ payload: { text: 'Before edit.' }, type: 'message.delta' } as any) + turnController.flushStreamingSegment() + onEvent({ payload: { text: 'final answer' }, type: 'message.complete' } as any) + + expect(appended.map(msg => msg.thinking).filter(Boolean)).toEqual([streamed]) + expect(appended[appended.length - 1]).toMatchObject({ role: 'assistant', text: 'final answer' }) + }) + it('ignores fallback reasoning.available when streamed reasoning already exists', () => { const appended: Msg[] = [] const streamed = 'short streamed reasoning' diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index 540d3793de..ce6cc60006 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -81,7 +81,6 @@ class TurnController { persistSpawnTree?: (subagents: SubagentProgress[], sessionId: null | string) => Promise protocolWarned = false reasoningText = '' - reasoningSegmentOffset = 0 segmentMessages: Msg[] = [] pendingSegmentTools: string[] = [] statusTimer: Timer = null @@ -107,7 +106,6 @@ class TurnController { clearReasoning() { this.reasoningTimer = clear(this.reasoningTimer) this.reasoningText = '' - this.reasoningSegmentOffset = 0 this.toolTokenAcc = 0 patchTurnState({ reasoning: '', reasoningTokens: 0, toolTokens: 0 }) } @@ -202,15 +200,10 @@ class TurnController { patchTurnState({ reasoning: this.reasoningText, reasoningTokens: estimateTokensRough(this.reasoningText) }) } - const thinking = this.reasoningText.slice(this.reasoningSegmentOffset).trim() const msg: Msg = { role: split.text ? 'assistant' : 'system', text: split.text, ...(!split.text && { kind: 'trail' as const }), - ...(thinking && { - thinking, - thinkingTokens: estimateTokensRough(thinking) - }), ...(this.pendingSegmentTools.length && { tools: this.pendingSegmentTools }) } @@ -220,7 +213,6 @@ class TurnController { this.segmentMessages = [...this.segmentMessages, msg] } - this.reasoningSegmentOffset = this.reasoningText.length this.pendingSegmentTools = [] this.bufRef = '' patchTurnState({ streamPendingTools: [], streamSegments: this.segmentMessages, streaming: '' }) @@ -329,7 +321,7 @@ class TurnController { return body === null || (!finalHasOwnDiffFence && !finalText.includes(body)) }) - const finalThinking = savedReasoning.slice(this.reasoningSegmentOffset).trim() + const finalThinking = savedReasoning.trim() const finalDetails: Msg = { kind: 'trail', role: 'system', diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index dcddd4a914..604b71ebc6 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -646,22 +646,22 @@ export const Thinking = memo(function Thinking({ {preview ? ( mode === 'full' ? ( lines.map((line, index) => ( - + {line || ' '} {index === lines.length - 1 ? ( - + ) : null} )) ) : ( - + {preview} - + ) ) : ( - - + + )}