mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-10-17 05:14:19 +02:00
ThinkTagSplitter was wired into both streaming paths but neither non-streaming one, and every ancillary call goes through non-streaming chat(). So a reasoning tasks model put its reasoning transcript into the sidebar title, into the suggestion chips, and — worst — into the string that gets embedded and sent to Qdrant, silently corrupting retrieval for the rest of the conversation. splitThinkTags() already existed and was already unit tested; it just had no production callers. Fixed in three layers: 1. Strip at the transport. New normalizeNonStreamed() in think_stream.ts merges structured reasoning (native `thinking`, /v1 `reasoning`) with anything the splitter pulls out of inline tags, and both _chatNative and _chatCompat now return through it. That covers every caller at once, including the legacy controller path and the eval harness. 2. Suppress at the source. _chatNative and _chatStreamNative forwarded `think` only when truthy, so `think: false` was never sent and Ollama left reasoning on for capable models. Guard is now definedness. This also fixes the ai.autoThinking=off setting, which had no effect on the native streaming transport. 3. Guard the call sites. Title, suggestions, and query rewrite now pass think:false plus thinkingCapable (from the memoized capability check, so no extra round-trip), and handle an empty-after-strip result. The rewrite matters most: QUERY_REWRITE_MAX_TOKENS caps it at 120, which a reasoning model burns entirely on thinking, so stripping alone would have turned a corrupt query into an empty one. It now falls back to the raw user message instead of embedding "". Verified: think_stream.spec.ts 17/17 (6 new cases, including the truncated-mid-thought case that drives the fallback), typecheck clean, full test:unit failures byte-identical to the pre-change baseline.
155 lines
5.4 KiB
TypeScript
155 lines
5.4 KiB
TypeScript
import { test } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import {
|
|
ThinkTagSplitter,
|
|
normalizeNonStreamed,
|
|
partialTagSuffix,
|
|
splitThinkTags,
|
|
} from '../../app/utils/think_stream.js'
|
|
|
|
/**
|
|
* Characterization tests for the <think> tag splitter extracted out of
|
|
* OllamaService.chatStream. The behaviour under test is the chunk-boundary
|
|
* handling: a tag can arrive split across any number of stream chunks, and text
|
|
* that might still turn out to be a tag must not be emitted early.
|
|
*/
|
|
|
|
/** Feed a whole string through the splitter one chunk at a time. */
|
|
function stream(chunks: string[]) {
|
|
const splitter = new ThinkTagSplitter()
|
|
let content = ''
|
|
let thinking = ''
|
|
for (const chunk of chunks) {
|
|
const out = splitter.push(chunk)
|
|
content += out.content
|
|
thinking += out.thinking
|
|
}
|
|
const tail = splitter.flush()
|
|
return { content: content + tail.content, thinking: thinking + tail.thinking }
|
|
}
|
|
|
|
test('partialTagSuffix finds how much trailing text could start a tag', () => {
|
|
assert.equal(partialTagSuffix('<think>', 'hello<thi'), 4)
|
|
assert.equal(partialTagSuffix('<think>', 'hello<'), 1)
|
|
assert.equal(partialTagSuffix('<think>', 'hello'), 0)
|
|
// A complete tag is not a *partial* tag — the caller handles that case via indexOf.
|
|
assert.equal(partialTagSuffix('<think>', '<think>'), 0)
|
|
})
|
|
|
|
test('passes through text with no tags', () => {
|
|
assert.deepEqual(stream(['Hello ', 'world']), { content: 'Hello world', thinking: '' })
|
|
})
|
|
|
|
test('separates a complete think block arriving in one chunk', () => {
|
|
assert.deepEqual(stream(['<think>reasoning</think>answer']), {
|
|
content: 'answer',
|
|
thinking: 'reasoning',
|
|
})
|
|
})
|
|
|
|
test('handles an opening tag split across chunks', () => {
|
|
assert.deepEqual(stream(['<thi', 'nk>reasoning</think>answer']), {
|
|
content: 'answer',
|
|
thinking: 'reasoning',
|
|
})
|
|
})
|
|
|
|
test('handles a closing tag split across chunks', () => {
|
|
assert.deepEqual(stream(['<think>reasoning</thi', 'nk>answer']), {
|
|
content: 'answer',
|
|
thinking: 'reasoning',
|
|
})
|
|
})
|
|
|
|
test('handles tags split one character at a time', () => {
|
|
const chunks = '<think>abc</think>xyz'.split('')
|
|
assert.deepEqual(stream(chunks), { content: 'xyz', thinking: 'abc' })
|
|
})
|
|
|
|
test('never emits a partial tag as content before it resolves', () => {
|
|
const splitter = new ThinkTagSplitter()
|
|
// "<thi" could still become "<think>", so it must be held back, not emitted.
|
|
assert.deepEqual(splitter.push('answer<thi'), { content: 'answer', thinking: '' })
|
|
assert.deepEqual(splitter.push('nk>hidden</think>'), { content: '', thinking: 'hidden' })
|
|
})
|
|
|
|
test('flushes a dangling partial tag rather than swallowing it', () => {
|
|
// "<thi" that never completes was never a tag. Dropping it would silently
|
|
// truncate the answer, which is what the pre-extraction code did.
|
|
assert.deepEqual(stream(['answer<thi']), { content: 'answer<thi', thinking: '' })
|
|
})
|
|
|
|
test('flushes an unterminated think block to the thinking channel', () => {
|
|
assert.deepEqual(stream(['<think>cut off mid-thought']), {
|
|
content: '',
|
|
thinking: 'cut off mid-thought',
|
|
})
|
|
})
|
|
|
|
test('handles multiple think blocks in one stream', () => {
|
|
assert.deepEqual(stream(['<think>one</think>a<think>two</think>b']), {
|
|
content: 'ab',
|
|
thinking: 'onetwo',
|
|
})
|
|
})
|
|
|
|
test('splitThinkTags handles the non-streaming case', () => {
|
|
assert.deepEqual(splitThinkTags('<think>why</think>because'), {
|
|
content: 'because',
|
|
thinking: 'why',
|
|
})
|
|
assert.deepEqual(splitThinkTags('no tags here'), { content: 'no tags here', thinking: '' })
|
|
})
|
|
|
|
// --- normalizeNonStreamed ----------------------------------------------------
|
|
//
|
|
// The non-streaming path feeds every ancillary call: chat titles, suggestion
|
|
// chips, and — the one that matters — the query that gets embedded and sent to
|
|
// Qdrant. Reasoning reaching any of those is the bug this exists to close.
|
|
|
|
test('normalizeNonStreamed: inline tags are split out of content', () => {
|
|
assert.deepEqual(normalizeNonStreamed('<think>weighing it up</think>Boiling Water'), {
|
|
content: 'Boiling Water',
|
|
thinking: 'weighing it up',
|
|
})
|
|
})
|
|
|
|
test('normalizeNonStreamed: a structured thinking field passes through untouched', () => {
|
|
assert.deepEqual(normalizeNonStreamed('Boiling Water', 'weighing it up'), {
|
|
content: 'Boiling Water',
|
|
thinking: 'weighing it up',
|
|
})
|
|
})
|
|
|
|
test('normalizeNonStreamed: structured and inline reasoning merge into one channel', () => {
|
|
// Ollama reports reasoning natively AND the model emits literal tags in content.
|
|
assert.deepEqual(normalizeNonStreamed('<think>then this</think>Answer', 'first this '), {
|
|
content: 'Answer',
|
|
thinking: 'first this then this',
|
|
})
|
|
})
|
|
|
|
test('normalizeNonStreamed: reasoning truncated mid-thought leaves no content', () => {
|
|
// What a reasoning model does under QUERY_REWRITE_MAX_TOKENS: it spends the whole
|
|
// budget thinking and never closes the tag. Callers must treat this as "no answer"
|
|
// rather than embedding the empty string.
|
|
assert.deepEqual(normalizeNonStreamed('<think>still working on it'), {
|
|
content: '',
|
|
thinking: 'still working on it',
|
|
})
|
|
})
|
|
|
|
test('normalizeNonStreamed: text with no tags is passed through exactly', () => {
|
|
assert.deepEqual(normalizeNonStreamed(' How do I purify water? '), {
|
|
content: ' How do I purify water? ',
|
|
thinking: '',
|
|
})
|
|
})
|
|
|
|
test('normalizeNonStreamed: tolerates a missing content field', () => {
|
|
assert.deepEqual(normalizeNonStreamed(undefined as unknown as string), {
|
|
content: '',
|
|
thinking: '',
|
|
})
|
|
})
|