fix(tui): strip <think>…</think> tags from assistant content and route to reasoning panel

Models that emit reasoning inline as <think>/<reasoning>/<thinking>/<thought>/
<REASONING_SCRATCHPAD> tags in the content field (rather than a separate API
reasoning channel) had the raw tags + inner content shown twice: once as body
text with literal <think> markers, and again in the thinking panel when the
reasoning field was populated.

Port v1's tag set to lib/reasoning.ts with a splitReasoning(text) helper that
returns { reasoning, text }. Applied in three spots:

  - scheduleStreaming: strips tags from the live streaming view so the user
    never sees <think> mid-turn.
  - flushStreamingSegment: when a tool interrupts assistant output mid-turn,
    the saved segment is the stripped text; extracted reasoning promotes to
    reasoningText if the API channel hasn't already populated it.
  - recordMessageComplete: final message text is split, extracted reasoning
    merges with any existing reasoning (API channel wins on conflicts so we
    don't double-count when both are present).
This commit is contained in:
Brooklyn Nicholson 2026-04-18 14:46:38 -05:00
parent 37cba82bfc
commit 4caf6c23dd
3 changed files with 127 additions and 8 deletions

View file

@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js'
describe('splitReasoning', () => {
it('extracts <think>…</think> and strips it from text', () => {
const { reasoning, text } = splitReasoning('<think>plotting</think>\n\nhere is the answer')
expect(reasoning).toBe('plotting')
expect(text).toBe('here is the answer')
})
it('handles multiple tag shapes', () => {
const input = '<reasoning>a</reasoning> <THINKING>b</THINKING> <thought>c</thought> 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 <think>… as reasoning', () => {
const { reasoning, text } = splitReasoning('answer start <think>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('<think></think>only body')
expect(reasoning).toBe('')
expect(text).toBe('only body')
})
it('detects presence of any supported tag', () => {
expect(hasReasoningTag('pre <think>x</think> post')).toBe(true)
expect(hasReasoningTag('pre <reasoning>x</reasoning>')).toBe(true)
expect(hasReasoningTag('<REASONING_SCRATCHPAD>x</REASONING_SCRATCHPAD>')).toBe(true)
expect(hasReasoningTag('no tags at all')).toBe(false)
})
})

View file

@ -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)
}

View file

@ -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]*?)</${tag}>\\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
}