diff --git a/ui-tui/src/__tests__/reasoning.test.ts b/ui-tui/src/__tests__/reasoning.test.ts new file mode 100644 index 0000000000..c961ea7a0c --- /dev/null +++ b/ui-tui/src/__tests__/reasoning.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' + +import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js' + +describe('splitReasoning', () => { + it('extracts and strips it from text', () => { + const { reasoning, text } = splitReasoning('plotting\n\nhere is the answer') + + expect(reasoning).toBe('plotting') + expect(text).toBe('here is the answer') + }) + + it('handles multiple tag shapes', () => { + const input = 'a b c body' + const { reasoning, text } = splitReasoning(input) + + expect(reasoning).toContain('a') + expect(reasoning).toContain('b') + expect(reasoning).toContain('c') + expect(text).toBe('body') + }) + + it('treats unclosed trailing … as reasoning', () => { + const { reasoning, text } = splitReasoning('answer start still deciding') + + expect(reasoning).toBe('still deciding') + expect(text).toBe('answer start') + }) + + it('returns empty reasoning and untouched text when no tags present', () => { + const { reasoning, text } = splitReasoning('plain body with no tags') + + expect(reasoning).toBe('') + expect(text).toBe('plain body with no tags') + }) + + it('preserves text when reasoning block is empty', () => { + const { reasoning, text } = splitReasoning('only body') + + expect(reasoning).toBe('') + expect(text).toBe('only body') + }) + + it('detects presence of any supported tag', () => { + expect(hasReasoningTag('pre x post')).toBe(true) + expect(hasReasoningTag('pre x')).toBe(true) + expect(hasReasoningTag('x')).toBe(true) + expect(hasReasoningTag('no tags at all')).toBe(false) + }) +}) diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index de57b2dd05..236324ffb9 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -1,5 +1,6 @@ import { REASONING_PULSE_MS, STREAM_BATCH_MS } from '../config/timing.js' import type { SessionInterruptResponse, SubagentEventPayload } from '../gatewayTypes.js' +import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js' import { buildToolTrailLine, estimateTokensRough, @@ -121,18 +122,31 @@ class TurnController { } flushStreamingSegment() { - const text = this.bufRef.trimStart() + const raw = this.bufRef.trimStart() - if (!text) { + if (!raw) { return } - const tools = this.pendingSegmentTools + const split = hasReasoningTag(raw) ? splitReasoning(raw) : { reasoning: '', text: raw } + + if (split.reasoning && !this.reasoningText.trim()) { + this.reasoningText = split.reasoning + patchTurnState({ reasoning: this.reasoningText, reasoningTokens: estimateTokensRough(this.reasoningText) }) + } + + const text = split.text this.streamTimer = clear(this.streamTimer) - this.segmentMessages = [...this.segmentMessages, { role: 'assistant', text, ...(tools.length && { tools }) }] + + if (text) { + const tools = this.pendingSegmentTools + + this.segmentMessages = [...this.segmentMessages, { role: 'assistant', text, ...(tools.length && { tools }) }] + this.pendingSegmentTools = [] + } + this.bufRef = '' - this.pendingSegmentTools = [] patchTurnState({ streamPendingTools: [], streamSegments: this.segmentMessages, streaming: '' }) } @@ -187,8 +201,11 @@ class TurnController { } recordMessageComplete(payload: { rendered?: string; reasoning?: string; text?: string }) { - const finalText = (payload.rendered ?? payload.text ?? this.bufRef).trimStart() - const savedReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim() + const rawText = (payload.rendered ?? payload.text ?? this.bufRef).trimStart() + const split = splitReasoning(rawText) + const finalText = split.text + const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim() + const savedReasoning = [existingReasoning, existingReasoning ? '' : split.reasoning].filter(Boolean).join('\n\n') const savedReasoningTokens = savedReasoning ? estimateTokensRough(savedReasoning) : 0 const savedToolTokens = this.toolTokenAcc const tools = this.pendingSegmentTools @@ -355,7 +372,9 @@ class TurnController { this.streamTimer = setTimeout(() => { this.streamTimer = null - patchTurnState({ streaming: this.bufRef.trimStart() }) + const raw = this.bufRef.trimStart() + const visible = hasReasoningTag(raw) ? splitReasoning(raw).text : raw + patchTurnState({ streaming: visible }) }, STREAM_BATCH_MS) } diff --git a/ui-tui/src/lib/reasoning.ts b/ui-tui/src/lib/reasoning.ts new file mode 100644 index 0000000000..eba63918c4 --- /dev/null +++ b/ui-tui/src/lib/reasoning.ts @@ -0,0 +1,50 @@ +const TAGS = ['think', 'reasoning', 'thinking', 'thought', 'REASONING_SCRATCHPAD'] as const + +export interface SplitReasoning { + reasoning: string + text: string +} + +export function splitReasoning(input: string): SplitReasoning { + let text = input + const reasoning: string[] = [] + + for (const tag of TAGS) { + const paired = new RegExp(`<${tag}>([\\s\\S]*?)\\s*`, 'gi') + text = text.replace(paired, (_m, inner: string) => { + const trimmed = inner.trim() + + if (trimmed) { + reasoning.push(trimmed) + } + + return '' + }) + + const unclosed = new RegExp(`<${tag}>([\\s\\S]*)$`, 'i') + text = text.replace(unclosed, (_m, inner: string) => { + const trimmed = inner.trim() + + if (trimmed) { + reasoning.push(trimmed) + } + + return '' + }) + } + + return { + reasoning: reasoning.join('\n\n').trim(), + text: text.trim() + } +} + +export const hasReasoningTag = (input: string) => { + for (const tag of TAGS) { + if (input.includes(`<${tag}>`)) { + return true + } + } + + return false +}