From dff94bd552738104b971bd711db2f8f97e7bf781 Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Wed, 19 Aug 2026 15:51:29 +0530 Subject: [PATCH] fix: strip all reasoning blocks in removeReasoningContent, not just the first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit removeReasoningContent only removed the first complete thinking… response block and ignored unterminated blocks. A model can emit several reasoning spans across one turn, and a stream cut off mid-reasoning leaves an unterminated block — both cases leaked reasoning into the token count and, potentially, the prompt sent to the model. Now: - strips every complete thinking… response block (global replace) - strips a trailing unterminated thinking block - applies the same treatment to the DeepSeek <|channel|>analysis…final format Adds a test suite covering single/multiple/unterminated blocks for both formats, plus extractReasoningFromMessage edge cases. --- web-app/src/utils/__tests__/reasoning.test.ts | 82 +++++++++++++++++++ web-app/src/utils/reasoning.ts | 41 +++++----- 2 files changed, 104 insertions(+), 19 deletions(-) create mode 100644 web-app/src/utils/__tests__/reasoning.test.ts 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