Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions web-app/src/utils/__tests__/reasoning.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest'
import {
removeReasoningContent,
extractReasoningFromMessage,
} from '../reasoning'

// Built with escapes so the tag characters can never be mangled.
const T_OPEN = '\u003Ct\u0068ink\u003E' // thinking
const T_CLOSE = '\u003C/t\u0068ink\u003E' // response

describe('removeReasoningContent', () => {
it('strips a single complete think block', () => {
const input = `${T_OPEN}reasoning content${T_CLOSE}\nFinal answer is 2.`
expect(removeReasoningContent(input)).toBe('Final answer is 2.')
})

it('strips every complete think block, not just the first', () => {
const input =
`Question\n${T_OPEN}First reasoning pass${T_CLOSE}` +
`Second reasoning begins\n${T_OPEN}Second reasoning pass${T_CLOSE}\nFinal answer is 2.`
expect(removeReasoningContent(input)).toBe(
'Question\nSecond reasoning begins\n\nFinal answer is 2.'
)
})

it('strips an unterminated trailing think block', () => {
const input = `Question\n${T_OPEN}Reasoning with no closing tag.`
expect(removeReasoningContent(input)).toBe('Question')
})

it('strips multiple unterminated think blocks', () => {
const input = `${T_OPEN}first${T_CLOSE}\n${T_OPEN}second without close`
expect(removeReasoningContent(input)).toBe('')
})

it('returns content unchanged when there is no reasoning', () => {
const input = 'Just a normal answer with no think tags.'
expect(removeReasoningContent(input)).toBe(input)
})

it('strips a single complete DeepSeek analysis block', () => {
const input =
'\u003C|channel|>analysis\u003C|message|>reasoning\u003C|start|>assistant\u003C|channel|>final\u003C|message|>\nFinal answer is 2.'
expect(removeReasoningContent(input)).toBe('Final answer is 2.')
})

it('strips multiple DeepSeek analysis blocks, not just the first', () => {
const input =
'Question\n\u003C|channel|>analysis\u003C|message|>first\u003C|start|>assistant\u003C|channel|>final\u003C|message|>' +
'Between\n\u003C|channel|>analysis\u003C|message|>second\u003C|start|>assistant\u003C|channel|>final\u003C|message|>\nFinal answer is 2.'
expect(removeReasoningContent(input)).toBe(
'Question\nBetween\n\nFinal answer is 2.'
)
})

it('strips an unterminated DeepSeek analysis block', () => {
const input =
'Question\n\u003C|channel|>analysis\u003C|message|>no closing tag'
expect(removeReasoningContent(input)).toBe('Question')
})
})

describe('extractReasoningFromMessage', () => {
it('returns reasoning_content when present', () => {
const msg = { role: 'assistant', content: 'hi', reasoning_content: 'think' }
expect(extractReasoningFromMessage(msg as never)).toBe('think')
})

it('falls back to reasoning field', () => {
const msg = { role: 'assistant', content: 'hi', reasoning: 'think' }
expect(extractReasoningFromMessage(msg as never)).toBe('think')
})

it('returns null when no reasoning present', () => {
const msg = { role: 'assistant', content: 'hi' }
expect(extractReasoningFromMessage(msg as never)).toBeNull()
})

it('returns null for null message', () => {
expect(extractReasoningFromMessage(null as never)).toBeNull()
})
})
41 changes: 22 additions & 19 deletions web-app/src/utils/reasoning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,27 @@ function getReasoning(
* @returns
*/
export function removeReasoningContent(content: string): string {
// Reasoning content should not be sent to the model
if (content.includes('<think>')) {
const match = content.match(/<think>([\s\S]*?)<\/think>/)
if (match?.index !== undefined) {
const splitIndex = match.index + match[0].length
content = content.slice(splitIndex).trim()
}
}
if (content.includes('<|channel|>analysis<|message|>')) {
const match = content.match(
/<\|channel\|>analysis<\|message\|>([\s\S]*?)<\|start\|>assistant<\|channel\|>final<\|message\|>/
)
if (match?.index !== undefined) {
const splitIndex = match.index + match[0].length
content = content.slice(splitIndex).trim()
}
}
return content
// Reasoning content should not be sent to the model.
let result = content

// Strip every complete <think>…</think> block. A model can emit several
// reasoning spans across one turn, so this has to be global — the previous
// code only ever removed the first block.
result = result.replace(/<think>([\s\S]*?)<\/think>/g, '')

// Strip an unterminated <think>… block (streaming cut off before the
// closing tag) so partial reasoning still doesn't leak into the prompt.
result = result.replace(/<think>[\s\S]*$/, '')

// Same treatment for the DeepSeek <|channel|>analysis<|message|>…
// reasoning format.
result = result.replace(
/<\|channel\|>analysis<\|message\|>([\s\S]*?)<\|start\|>assistant<\|channel\|>final<\|message\|>/g,
''
)
result = result.replace(/<\|channel\|>analysis<\|message\|>[\s\S]*$/, '')

return result.trim()
}

// Extract reasoning from a message (for completed responses)
Expand All @@ -48,4 +51,4 @@ export function extractReasoningFromMessage(

const extendedMessage = message as chatCompletionRequestMessage
return getReasoning(extendedMessage)
}
}