diff --git a/web-app/src/utils/__tests__/reasoning.test.ts b/web-app/src/utils/__tests__/reasoning.test.ts new file mode 100644 index 000000000..b8ae587fb --- /dev/null +++ b/web-app/src/utils/__tests__/reasoning.test.ts @@ -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() + }) +}) \ No newline at end of file diff --git a/web-app/src/utils/reasoning.ts b/web-app/src/utils/reasoning.ts index b5a29c9f2..8efe4e4b3 100644 --- a/web-app/src/utils/reasoning.ts +++ b/web-app/src/utils/reasoning.ts @@ -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('')) { - const match = content.match(/([\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 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(/([\s\S]*?)<\/think>/g, '') + + // Strip an unterminated … block (streaming cut off before the + // closing tag) so partial reasoning still doesn't leak into the prompt. + result = result.replace(/[\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) @@ -48,4 +51,4 @@ export function extractReasoningFromMessage( const extendedMessage = message as chatCompletionRequestMessage return getReasoning(extendedMessage) -} +} \ No newline at end of file