diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index 1114c7161ab..27c49b0d4f6 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -150,6 +150,20 @@ describe('createGatewayEventHandler', () => { expect(appended[appended.length - 1]).toMatchObject({ role: 'assistant', text: 'final answer' }) }) + it('filters spinner/status-only reasoning noise from completed thinking', () => { + const appended: Msg[] = [] + const streamed = '(¬_¬) synthesizing...\nactual plan\n( ͡° ͜ʖ ͡°) pondering...\nnext step' + + const onEvent = createGatewayEventHandler(buildCtx(appended)) + + onEvent({ payload: { text: streamed }, type: 'reasoning.delta' } as any) + onEvent({ payload: { text: 'final answer' }, type: 'message.complete' } as any) + + expect(appended[0]?.thinking).toBe(streamed) + expect(appended[0]?.text).toBe('') + 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/__tests__/reasoning.test.ts b/ui-tui/src/__tests__/reasoning.test.ts index c961ea7a0c2..d14a0a2975a 100644 --- a/ui-tui/src/__tests__/reasoning.test.ts +++ b/ui-tui/src/__tests__/reasoning.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js' +import { cleanThinkingText } from '../lib/text.js' describe('splitReasoning', () => { it('extracts and strips it from text', () => { @@ -48,3 +49,13 @@ describe('splitReasoning', () => { expect(hasReasoningTag('no tags at all')).toBe(false) }) }) + +describe('cleanThinkingText', () => { + it('removes face/status ticker fragments while preserving real reasoning', () => { + expect( + cleanThinkingText( + '(¬_¬) synthesizing...**Resolving comments on GitHub**\n( ͡° ͜ʖ ͡°) musing...\nActual step\n٩(๑❛ᴗ❛๑)۶ contemplating...next step' + ) + ).toBe('**Resolving comments on GitHub**\nActual step\nnext step') + }) +}) diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 8541ac3f685..18d5a5a649f 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -70,8 +70,43 @@ export const pasteTokenLabel = (text: string, lineCount: number) => { : `[[ ${preview} [${fmtK(lineCount)} lines] ]]` } +const THINKING_STATUS_WORDS = [ + 'pondering', + 'contemplating', + 'musing', + 'cogitating', + 'ruminating', + 'deliberating', + 'mulling', + 'reflecting', + 'processing', + 'reasoning', + 'analyzing', + 'computing', + 'synthesizing', + 'formulating', + 'brainstorming' +] + +const THINKING_STATUS_RE = new RegExp(`^(?:${THINKING_STATUS_WORDS.join('|')})\\.{0,3}$`, 'i') + +const THINKING_FACE_SOURCE = '[^A-Za-z\n]+' + +const THINKING_STATUS_CHUNK_RE = new RegExp( + `${THINKING_FACE_SOURCE}\\s*(?:${THINKING_STATUS_WORDS.join('|')})\\.{0,3}\\s*`, + 'giu' +) + +export const cleanThinkingText = (reasoning: string) => + reasoning + .split('\n') + .map(line => line.replace(THINKING_STATUS_CHUNK_RE, '').trim()) + .filter(line => line && !THINKING_STATUS_RE.test(line.replace(/\.\.\.$/, '').trim())) + .join('\n') + .trim() + export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number = THINKING_COT_MAX) => { - const raw = reasoning.trim() + const raw = cleanThinkingText(reasoning) return !raw || mode === 'collapsed' ? '' : mode === 'full' ? raw : compactPreview(raw.replace(WS_RE, ' '), max) }