From b7ee9799c5012fea3b60d4b1f0f0f4341fbffc81 Mon Sep 17 00:00:00 2001 From: W ARELIK Date: Wed, 5 Aug 2026 13:07:16 +0300 Subject: [PATCH 1/8] =?UTF-8?q?feat(devin-connect):=20think-text=20reroute?= =?UTF-8?q?=20=E2=80=94=20=D1=80=D0=B0=D0=B7=D1=80=D1=8B=D0=B2=20=D1=8F?= =?UTF-8?q?=D0=B4=D0=BE=D0=B2=D0=B8=D1=82=D0=BE=D0=B9=20=D0=BF=D0=B5=D1=82?= =?UTF-8?q?=D0=BB=D0=B8=20reasoning-as-text=20(Thinking-core=20item=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Деградация: модель иногда эмитит reasoning в CONTENT-канале, обёрнутый в нативные маркеры. Клиент сохраняет это видимым текстом assistant и пересылает в следующем тёрне — он снова подталкивает модель к reasoning-as-text. Самозацикливание. - src/response-classifier.js: ThinkTextClassifier — детектирует ВЕДУЩИЙ think-блок (маркер в самом начале, до него лишь whitespace), обрабатывает маркеры разрезанные по стрим-дельтам, ре-раутит span в thinking-канал. Маркеры в середине ответа (после закоммиченного текста) НЕ ре-раутятся — защита от ложных срабатываний. Незакрытый span на flush отдаётся как текст (видимое лучше потерянного). - messages.js AnthropicStreamTranslator: при гейте content-дельты идут через классификатор; thinking-выход → emitThinkingDelta, text-выход → emitTextDelta; flush в finish() до closeCurrentBlock. - Гейт DEVIN_CONNECT_THINKTEXT_REROUTE (default 0), документирован в .env.example. - Без синтетических подписей, без опоры на роутеры — только собственная классификация. Тесты: +10 юнит (response-classifier) + 2 интеграционных (messages, гейт вкл). Полный сьют 3485 pass / 0 fail. --- .env.example | 5 ++ src/handlers/messages.js | 27 +++++++- src/response-classifier.js | 114 +++++++++++++++++++++++++++++++ test/messages.test.js | 72 ++++++++++++++++++- test/response-classifier.test.js | 82 ++++++++++++++++++++++ 5 files changed, 298 insertions(+), 2 deletions(-) create mode 100644 src/response-classifier.js create mode 100644 test/response-classifier.test.js diff --git a/.env.example b/.env.example index a3243d6c..963147b6 100644 --- a/.env.example +++ b/.env.example @@ -198,6 +198,11 @@ LS_PORT=42100 # regeneration, system-prompt drift, and response truncation; parallel dialogs on # the same key stay independent. In-memory only (no persistence across restarts). # DEVIN_CONNECT_SESSION_REUSE=0 +# --- Think-text reroute (Thinking-core item 1, loop break) --- +# When the model emits a LEADING think-tagged span on the CONTENT channel, reroute it +# to the thinking channel so clients do not store it as visible assistant text and +# resend it into a self-reinforcing loop. Anthropic Messages egress. Default 0 (off). +# DEVIN_CONNECT_THINKTEXT_REROUTE=0 # State TTL before a dormant dialog's session_id is forgotten. Default 1800000 (30 min). # DEVIN_CONNECT_SESSION_TTL_MS=1800000 # Max tracked dialogs (LRU-evicted). Default 500. diff --git a/src/handlers/messages.js b/src/handlers/messages.js index 375664b0..33a2ba99 100644 --- a/src/handlers/messages.js +++ b/src/handlers/messages.js @@ -14,6 +14,7 @@ import { createHash, randomUUID } from 'crypto'; import { handleChatCompletions, connectErrorToHttp } from './chat.js'; import { log } from '../config.js'; +import { ThinkTextClassifier } from '../response-classifier.js'; function genMsgId() { return 'msg_' + randomUUID().replace(/-/g, '').slice(0, 24); @@ -35,6 +36,14 @@ function mapStopReason(finishReason) { return STOP_REASON_MAP[finishReason] || 'end_turn'; } +// Item 1 (loop break): when the model emits a LEADING think-tagged span on the +// CONTENT channel, reroute it to the thinking channel so clients do not store it as +// visible assistant text and resend it into a self-reinforcing loop. Off by default. +function thinkTextRerouteEnabled() { + const v = String(process.env.DEVIN_CONNECT_THINKTEXT_REROUTE ?? '').trim().toLowerCase(); + return v === '1' || v === 'true' || v === 'yes' || v === 'on'; +} + // B5: Anthropic sets stop_reason:'stop_sequence' AND echoes the matched string // in stop_sequence when generation halts on a caller-supplied stop sequence. // The internal OpenAI path reports finish_reason:'stop' for both a natural @@ -1030,6 +1039,8 @@ class AnthropicStreamTranslator { // because strict clients (e.g. Grok Build's messages backend) reject // `signature: ""` as an invalid value. this.pendingThinkingSignature = ''; + // Item 1: reroute leading think-tagged content to the thinking channel (loop break). + this.thinkClassifier = thinkTextRerouteEnabled() ? new ThinkTextClassifier() : null; } send(event, data) { @@ -1248,7 +1259,15 @@ class AnthropicStreamTranslator { // to the reasoning stream, capture it so closeCurrentBlock round-trips the // genuine value instead of the empty-string placeholder. if (delta.reasoning_signature) this.pendingThinkingSignature = delta.reasoning_signature; - if (delta.content) this.emitTextDelta(delta.content); + if (delta.content) { + if (this.thinkClassifier) { + const routed = this.thinkClassifier.feed(delta.content); + if (routed.thinking) this.emitThinkingDelta(routed.thinking); + if (routed.text) this.emitTextDelta(routed.text); + } else { + this.emitTextDelta(delta.content); + } + } if (Array.isArray(delta.tool_calls)) { for (const tc of delta.tool_calls) this.emitToolCallDelta(tc); } @@ -1321,6 +1340,12 @@ class AnthropicStreamTranslator { // content) no longer reaches here: the BUG1 guard above now catches a missing // terminal signal regardless of whether content started. if (!this.messageStarted) this.startMessage(); + // Item 1: flush any content the classifier still holds. An unterminated span is + // delivered as text (visible beats dropped); see response-classifier flush(). + if (this.thinkClassifier) { + const rest = this.thinkClassifier.flush(); + if (rest) this.emitTextDelta(rest); + } this.closeCurrentBlock(); // B1: interleaved-fragment edge case. flushToolArgs only emits arg fragments // while a tool's block is the currently-open one; a tool_use block that got diff --git a/src/response-classifier.js b/src/response-classifier.js new file mode 100644 index 00000000..c02d9395 --- /dev/null +++ b/src/response-classifier.js @@ -0,0 +1,114 @@ +// Response-content classifier (Thinking-core item 2, applied in item 1). +// +// Purpose: decide which parts of a streamed answer are *reasoning* vs *actionable +// text*, so misrouted content can be corrected at the egress and self-reinforcing +// loops can be broken. +// +// Degradation addressed: the model sometimes emits its reasoning through the CONTENT +// channel wrapped in its native markers. The client stores that as visible assistant +// text and resends it next turn, re-priming more reasoning-as-text — the loop. +// +// This module detects the markers in the content stream and lets the egress reroute +// the marked spans to the thinking channel (which clients do not resend), breaking the +// loop. Scope: a LEADING reasoning block only (marker at the very start, optional +// whitespace before it). No synthetic signatures, no router reliance, no guesses about +// unmarked or mid-answer content. Handles markers split across stream deltas. + +const THINK_OPEN = '<' + 'think' + '>'; +const THINK_CLOSE = '<' + '/' + 'think' + '>'; + +const MAX_PENDING = 32000; // hold ceiling for an unterminated think span +const MAX_LEAD = 8192; // undecided-hold ceiling before committing to text + +// Longest suffix of `buf` that is a prefix of `marker` ('' if none). +function suffixPrefixLen(buf, marker) { + const max = Math.min(buf.length, marker.length); + for (let n = max; n > 0; n--) { + if (buf.endsWith(marker.slice(0, n))) return n; + } + return 0; +} + +export class ThinkTextClassifier { + constructor() { + this.pending = ''; + this.mode = 'undecided'; // undecided | text | think + } + + // Feed a content delta; returns { text, thinking } — slices to emit on each channel + // right now ('' when nothing is due). + feed(delta) { + if (!delta) return { text: '', thinking: '' }; + if (this.mode === 'text') return { text: delta, thinking: '' }; + if (this.mode === 'think') return this._feedThink(delta); + return this._feedUndecided(delta); + } + + _feedUndecided(delta) { + this.pending += delta; + + // Enough held without a decision -> it is plain text; commit. + if (this.pending.length > MAX_LEAD) return this._commitText(); + + const oi = this.pending.indexOf(THINK_OPEN); + if (oi >= 0) { + if (this.pending.slice(0, oi).trim() === '') { + // Leading marker -> enter think mode; drop whitespace-only prefix. + this.mode = 'think'; + this.pending = this.pending.slice(oi + THINK_OPEN.length); + return this._feedThink(''); // process the remainder of this same delta + } + // Marker present but real text precedes it -> inline, not a leak. + return this._commitText(); + } + + // No full marker yet. Could the pending still grow into one? + const core = this.pending.replace(/^\s+/, ''); + if (core.length === 0) return { text: '', thinking: '' }; // only whitespace so far + if (THINK_OPEN.startsWith(core)) return { text: '', thinking: '' }; // partial marker + return this._commitText(); // definitively not a leading marker + } + + _commitText() { + const out = this.pending; + this.pending = ''; + this.mode = 'text'; + return { text: out, thinking: '' }; + } + + _feedThink(delta) { + this.pending += delta; + + const ci = this.pending.indexOf(THINK_CLOSE); + if (ci >= 0) { + const span = this.pending.slice(0, ci); + const rest = this.pending.slice(ci + THINK_CLOSE.length); + this.pending = ''; + this.mode = 'undecided'; // may catch a following block; normal text commits + const cont = this._feedUndecided(rest); + return { text: cont.text, thinking: span + cont.thinking }; + } + + if (this.pending.length > MAX_PENDING) { + // Pathological unterminated span -> deliver as text (visible beats dropped, + // and loop-break value is gone at this size anyway). + const dump = this.pending; + this.pending = ''; + this.mode = 'text'; + return { text: dump, thinking: '' }; + } + + return { text: '', thinking: '' }; // buffer until the close marker proves it + } + + // Stream end: flush whatever is held. Undecided content is text; an unterminated + // think span is NOT rerouted (it never proved it was reasoning) — delivered as text. + flush() { + const out = this.pending; + this.pending = ''; + this.mode = 'text'; + return out; + } +} + +export const THINK_MARKERS = { open: THINK_OPEN, close: THINK_CLOSE }; diff --git a/test/messages.test.js b/test/messages.test.js index c5c8a044..90d76685 100644 --- a/test/messages.test.js +++ b/test/messages.test.js @@ -1,4 +1,4 @@ -import { afterEach, describe, it } from 'node:test'; +import { afterEach, beforeEach, describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { annotateRiskyReadToolResult, extractCallerSubKey, handleMessages, handleCountTokens, toAnthropicError } from '../src/handlers/messages.js'; import { applyJsonResponseHint, extractRequestedJsonKeys, isExplicitJsonRequested, stabilizeJsonPayload } from '../src/handlers/chat.js'; @@ -1676,3 +1676,73 @@ describe('Anthropic count_tokens', () => { assert.ok(a.body.input_tokens >= 1); }); }); + +// Item 1 (loop break): with DEVIN_CONNECT_THINKTEXT_REROUTE on, a LEADING think-tagged +// span emitted on the CONTENT channel is rerouted to the thinking channel so clients do +// not store it as visible assistant text and resend it into a self-reinforcing loop. +describe('think-text reroute (DEVIN_CONNECT_THINKTEXT_REROUTE)', () => { + const OPEN = '<' + 'think' + '>'; + const CLOSE = '<' + '/' + 'think' + '>'; + let prev; + beforeEach(() => { prev = process.env.DEVIN_CONNECT_THINKTEXT_REROUTE; process.env.DEVIN_CONNECT_THINKTEXT_REROUTE = '1'; }); + afterEach(() => { if (prev === undefined) delete process.env.DEVIN_CONNECT_THINKTEXT_REROUTE; else process.env.DEVIN_CONNECT_THINKTEXT_REROUTE = prev; }); + + it('reroutes a leading think block from content to the thinking channel', async () => { + const result = await handleMessages({ + model: 'claude-sonnet-4.6', + stream: true, + messages: [{ role: 'user', content: 'hi' }], + }, { + async handleChatCompletions() { + return { + status: 200, + stream: true, + async handler(res) { + res.write(chatChunk({ choices: [{ index: 0, delta: { role: 'assistant', content: OPEN + 'inner reasoning. ' + CLOSE }, finish_reason: null }] })); + res.write(chatChunk({ choices: [{ index: 0, delta: { content: 'The answer.' }, finish_reason: null }] })); + res.write(chatChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] })); + res.write(chatChunk({ choices: [], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } })); + res.end('data: [DONE]\n\n'); + }, + }; + }, + }); + const res = fakeRes(); + await result.handler(res); + const events = parseAnthropicEvents(res.body); + const blocks = events.filter(e => e.event === 'content_block_start').map(e => e.data.content_block.type); + assert.deepEqual(blocks, ['thinking', 'text'], 'think span becomes a thinking block, answer stays text'); + const thinkDeltas = events.filter(e => e.event === 'content_block_delta' && e.data.delta?.type === 'thinking_delta').map(e => e.data.delta.thinking).join(''); + assert.equal(thinkDeltas, 'inner reasoning. '); + const textDeltas = events.filter(e => e.event === 'content_block_delta' && e.data.delta?.type === 'text_delta').map(e => e.data.delta.text).join(''); + assert.equal(textDeltas, 'The answer.'); + }); + + it('with the gate ON, plain content still flows as text (no false reroute)', async () => { + const result = await handleMessages({ + model: 'claude-sonnet-4.6', + stream: true, + messages: [{ role: 'user', content: 'hi' }], + }, { + async handleChatCompletions() { + return { + status: 200, + stream: true, + async handler(res) { + res.write(chatChunk({ choices: [{ index: 0, delta: { role: 'assistant', content: 'Just a normal reply.' }, finish_reason: null }] })); + res.write(chatChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] })); + res.write(chatChunk({ choices: [], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } })); + res.end('data: [DONE]\n\n'); + }, + }; + }, + }); + const res = fakeRes(); + await result.handler(res); + const events = parseAnthropicEvents(res.body); + const blocks = events.filter(e => e.event === 'content_block_start').map(e => e.data.content_block.type); + assert.deepEqual(blocks, ['text']); + const textDeltas = events.filter(e => e.event === 'content_block_delta' && e.data.delta?.type === 'text_delta').map(e => e.data.delta.text).join(''); + assert.equal(textDeltas, 'Just a normal reply.'); + }); +}); diff --git a/test/response-classifier.test.js b/test/response-classifier.test.js new file mode 100644 index 00000000..c548bf43 --- /dev/null +++ b/test/response-classifier.test.js @@ -0,0 +1,82 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { ThinkTextClassifier } from '../src/response-classifier.js'; + +const OPEN = '<' + 'think' + '>'; +const CLOSE = '<' + '/' + 'think' + '>'; + +function runAll(parts) { + const c = new ThinkTextClassifier(); + let text = '', thinking = ''; + for (const p of parts) { + const r = c.feed(p); + text += r.text; + thinking += r.thinking; + } + text += c.flush(); + return { text, thinking }; +} + +describe('ThinkTextClassifier', () => { + it('passes plain text through unchanged', () => { + const { text, thinking } = runAll(['Hello, ', 'world!']); + assert.equal(text, 'Hello, world!'); + assert.equal(thinking, ''); + }); + + it('routes a full think block to thinking', () => { + const { text, thinking } = runAll([OPEN + 'Let me compute. ' + CLOSE, 'The answer is 42.']); + assert.equal(thinking, 'Let me compute. '); + assert.equal(text, 'The answer is 42.'); + }); + + it('routes think block split across many deltas', () => { + const { text, thinking } = runAll(['<' + 'thin', 'k' + '>step ', 'one; step two', '<' + '/thi', 'nk' + '>', 'Final: 7.']); + assert.equal(thinking, 'step one; step two'); + assert.equal(text, 'Final: 7.'); + }); + + it('routes a bare opening marker (loop artifact) to thinking', () => { + const { text, thinking } = runAll([OPEN]); + assert.equal(thinking, ''); + assert.equal(text, ''); + }); + + it('does not reroute text that merely mentions the word think', () => { + const { text, thinking } = runAll(['I think this is correct.']); + assert.equal(text, 'I think this is correct.'); + assert.equal(thinking, ''); + }); + + it('does not reroute when real text precedes the marker in the same delta', () => { + const { text, thinking } = runAll(['Here is a sample: ' + OPEN + 'inner' + CLOSE + ' done.']); + assert.equal(thinking, ''); + assert.equal(text, 'Here is a sample: ' + OPEN + 'inner' + CLOSE + ' done.'); + }); + + it('allows leading whitespace before the marker', () => { + const { text, thinking } = runAll([' \n' + OPEN + 'reasoning' + CLOSE + 'Answer.']); + assert.equal(thinking, 'reasoning'); + assert.equal(text, 'Answer.'); // leading whitespace-only prefix is dropped + }); + + it('does not reroute once real text was committed (mid-answer marker)', () => { + const { text, thinking } = runAll(['Real answer here. ', OPEN + 'late noise' + CLOSE]); + assert.equal(thinking, ''); + assert.equal(text, 'Real answer here. ' + OPEN + 'late noise' + CLOSE); + }); + + it('flush releases an unterminated span as text (visible beats dropped)', () => { + const c = new ThinkTextClassifier(); + const r = c.feed(OPEN + 'dangling reasoning without close'); + assert.equal(r.text, ''); + assert.equal(r.thinking, ''); + assert.equal(c.flush(), 'dangling reasoning without close'); + }); + + it('empty and null deltas are inert', () => { + const c = new ThinkTextClassifier(); + assert.deepEqual(c.feed(''), { text: '', thinking: '' }); + assert.equal(c.flush(), ''); + }); +}); From aaaf212f99d4762237b575a6d8f4195d7b7c79de Mon Sep 17 00:00:00 2001 From: W ARELIK Date: Wed, 5 Aug 2026 15:37:03 +0300 Subject: [PATCH 2/8] =?UTF-8?q?fix(messages):=20non-stream=20=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E4=B9=9F=E5=81=9A=20leading=20think-tag=20=E9=87=8D?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=20(think-text=20reroute=20=E8=A1=A5=E5=85=A8?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 流式翻译器已覆盖流式路径;非流式 openAIToAnthropic 同样可能收到 content 开头的 think 标签泄漏,补上同样的重路由。仅在无 reasoning_content 时生效(典型泄漏是 reasoning 走 content;已有 reasoning 通道时不动 content,避免第二个 thinking 块)。 未闭合的 span 在 flush 时作为文本交付(可见优于丢弃)。 测试: +2 集成(非流式重路由;有 reasoning_content 时 content 不动)。 全套 3487 pass / 0 fail。 --- src/handlers/messages.js | 16 ++++++++++++- test/messages.test.js | 52 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/handlers/messages.js b/src/handlers/messages.js index 33a2ba99..8c5ade35 100644 --- a/src/handlers/messages.js +++ b/src/handlers/messages.js @@ -846,7 +846,21 @@ export function openAIToAnthropic(result, model, msgId, cachePolicy = null, stop }); } } else { - content.push({ type: 'text', text: choice?.message?.content || '' }); + let text = choice?.message?.content || ''; + // Item 1 (non-stream path): same leading think-tag reroute as the stream + // translator. Gated to the no-reasoning_content case — the classic leak is + // reasoning emitted AS content; when a real reasoning channel is present we + // leave content untouched to avoid a second thinking block. + if (thinkTextRerouteEnabled() && text && !choice?.message?.reasoning_content) { + const classifier = new ThinkTextClassifier(); + const routed = classifier.feed(text); + const thinkSpan = routed.thinking; + const rest = routed.text + classifier.flush(); + if (thinkSpan) content.push({ type: 'thinking', thinking: thinkSpan }); + if (rest || !thinkSpan) content.push({ type: 'text', text: rest }); + } else { + content.push({ type: 'text', text }); + } } // B5: resolve stop_reason (incl. content_filter→refusal) and back-fill // stop_sequence when generation halted on a caller-supplied stop sequence. diff --git a/test/messages.test.js b/test/messages.test.js index 90d76685..2476a5c9 100644 --- a/test/messages.test.js +++ b/test/messages.test.js @@ -1718,6 +1718,58 @@ describe('think-text reroute (DEVIN_CONNECT_THINKTEXT_REROUTE)', () => { assert.equal(textDeltas, 'The answer.'); }); + it('non-stream: leading think tag in content (no reasoning_content) becomes a thinking block', async () => { + const OPEN = '<' + 'think' + '>'; + const CLOSE = '<' + '/' + 'think' + '>'; + const result = await handleMessages({ + model: 'claude-sonnet-4.6', + stream: false, + messages: [{ role: 'user', content: 'hi' }], + }, { + async handleChatCompletions() { + return { + status: 200, + body: { + choices: [{ index: 0, message: { role: 'assistant', content: OPEN + 'inner reasoning. ' + CLOSE + 'The answer.' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + }; + }, + }); + const blocks = result.body.content; + assert.equal(blocks[0].type, 'thinking', 'think span rerouted to a thinking block'); + assert.equal(blocks[0].thinking, 'inner reasoning. '); + assert.equal(blocks[1].type, 'text'); + assert.equal(blocks[1].text, 'The answer.'); + }); + + it('non-stream: with reasoning_content present, content is left untouched (no second thinking block)', async () => { + const OPEN = '<' + 'think' + '>'; + const CLOSE = '<' + '/' + 'think' + '>'; + const result = await handleMessages({ + model: 'claude-sonnet-4.6', + stream: false, + messages: [{ role: 'user', content: 'hi' }], + }, { + async handleChatCompletions() { + return { + status: 200, + body: { + choices: [{ index: 0, message: { role: 'assistant', content: OPEN + 'x' + CLOSE, reasoning_content: 'real reasoning' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + }; + }, + }); + const blocks = result.body.content; + // one thinking block (from reasoning_content), content untouched + const thinkingBlocks = blocks.filter(b => b.type === 'thinking'); + assert.equal(thinkingBlocks.length, 1); + assert.equal(thinkingBlocks[0].thinking, 'real reasoning'); + const textBlock = blocks.find(b => b.type === 'text'); + assert.equal(textBlock.text, OPEN + 'x' + CLOSE); + }); + it('with the gate ON, plain content still flows as text (no false reroute)', async () => { const result = await handleMessages({ model: 'claude-sonnet-4.6', From f7fde90eea9fa10460c744d53595b9e1591d72fa Mon Sep 17 00:00:00 2001 From: W ARELIK Date: Wed, 5 Aug 2026 16:38:30 +0300 Subject: [PATCH 3/8] =?UTF-8?q?test(mutations):=20=D1=81=D0=BF=D0=B5=D0=BA?= =?UTF-8?q?=D0=B0=20think-text=20reroute=20(3=20=D0=BC=D1=83=D1=82=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8:=20disable=20reroute=20/=20drop=20unterminat?= =?UTF-8?q?ed=20/=20buffer=20removed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/mutations/think-text-reroute.json | 27 ++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 test/mutations/think-text-reroute.json diff --git a/test/mutations/think-text-reroute.json b/test/mutations/think-text-reroute.json new file mode 100644 index 00000000..faf84b14 --- /dev/null +++ b/test/mutations/think-text-reroute.json @@ -0,0 +1,27 @@ +{ + "tests": [ + "test/response-classifier.test.js", + "test/messages.test.js" + ], + "expectBaselinePass": 89, + "mutations": [ + { + "name": "leading-marker reroute disabled entirely — think-tagged content is never rerouted", + "file": "src/response-classifier.js", + "anchor": " if (this.pending.slice(0, oi).trim() === '') {", + "replacement": " if (false) {" + }, + { + "name": "unterminated think span silently dropped at flush instead of delivered as text", + "file": "src/response-classifier.js", + "anchor": " const out = this.pending;\n this.pending = '';\n this.mode = 'text';\n return out;", + "replacement": " const out = this.pending;\n this.pending = '';\n this.mode = 'text';\n return '';" + }, + { + "name": "think-mode buffering removed — think content leaks to the text channel mid-span", + "file": "src/response-classifier.js", + "anchor": " return { text: '', thinking: '' }; // buffer until the close marker proves it", + "replacement": " return { text: delta, thinking: '' }; // buffer until the close marker proves it" + } + ] +} \ No newline at end of file From 69fec1361a5f7cbc774d96f4362e747ac523b94b Mon Sep 17 00:00:00 2001 From: W ARELIK Date: Thu, 6 Aug 2026 17:56:14 +0300 Subject: [PATCH 4/8] fix(devin-connect): move leading think-tag reroute to the event level, before the #238 rescue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - streamChatWithEmptyRetry now reclassifies leading think-tagged content deltas into the reasoning channel at the stream-event level (gated by DEVIN_CONNECT_THINKTEXT_REROUTE), so a whole-think turn keeps sawText false and the #238 empty-completion rescue fires naturally — the reroute and the rescue combine instead of bypassing each other (dwgx M1 on #243). - messages.js egress translators are passive again: openAIToAnthropic pushes the text block unconditionally (invariant: text block always present), the stream translator no longer holds a classifier. - known limitation: only the ` thinking`…` dialect is recognized; Kimi K2 uses `◁think▷` — extension left for a future PR. - pin tests: whole-think turn -> rescue + answer; think+answer split (toChatCompletion and stream frames); gate off stays passthrough; egress text-block invariant. mutation baseline 89 -> 90. --- src/devin-connect-openai.js | 44 +++++++++- src/handlers/messages.js | 49 +++-------- test/devin-connect-openai.test.js | 110 ++++++++++++++++++++++++- test/messages.test.js | 59 ++++++++----- test/mutations/think-text-reroute.json | 2 +- 5 files changed, 201 insertions(+), 63 deletions(-) diff --git a/src/devin-connect-openai.js b/src/devin-connect-openai.js index 3ea0e56c..32832e77 100644 --- a/src/devin-connect-openai.js +++ b/src/devin-connect-openai.js @@ -24,6 +24,15 @@ import { log } from './config.js'; import { systemFingerprint } from './system-fingerprint.js'; import { applyStop, StopSequenceGate } from './stop-sequences.js'; import { normalizeToolCallArgs, recordArgRepair } from './handlers/cline-compat.js'; +import { ThinkTextClassifier } from './response-classifier.js'; + +// Gate for the leading think-tag reroute (Item 1, loop break). The classifier +// lives HERE, in the connect layer, so the DEVIN_CONNECT_ prefix is accurate: +// only the connect backend's streams are reclassified. Default OFF — the +// reroute is a behavior change and must be opted into per deployment. +function thinkTextRerouteEnabled() { + return String(process.env.DEVIN_CONNECT_THINKTEXT_REROUTE || '') === '1'; +} // Apply the Cline compat tool-arg shim when active: normalize an arguments // string @ai-sdk/openai-compatible would reject (empty / whitespace / non-JSON) @@ -213,8 +222,30 @@ async function* streamChatWithEmptyRetry(params, { env = process.env } = {}) { let sawReasoning = false; let sawReasoningText = ''; let finishEv = null; + // Item 1 (loop break): leading think-tagged CONTENT is reclassified into the + // reasoning channel HERE, at the stream-event level, BEFORE the rescue + // decision below. A whole turn of ` thinking…` therefore stays a + // reasoning-only stream: sawText stays false, so the #238 rescue fires + // naturally — the reroute and the rescue combine, neither bypasses the other. + // (Known limitation: only the ` thinking`…` dialect is recognized; Kimi K2's + // family uses `◁think▷…◁/think▷` — extending the classifier to that dialect + // is left for a future PR.) + const thinkClassifier = thinkTextRerouteEnabled() ? new ThinkTextClassifier() : null; for await (const ev of streamChatImpl(attemptParams)) { - if (ev.type === 'content' || ev.type === 'reasoning') { + if (ev.type === 'content' && thinkClassifier) { + const routed = thinkClassifier.feed(ev.text); + if (routed.thinking) { + sawContent = true; + sawReasoning = true; + sawReasoningText += routed.thinking; + yield { type: 'reasoning', text: routed.thinking }; + } + if (routed.text) { + sawContent = true; + sawText = true; + yield { type: 'content', text: routed.text }; + } + } else if (ev.type === 'content' || ev.type === 'reasoning') { if (ev.text) { sawContent = true; if (ev.type === 'content') sawText = true; @@ -230,6 +261,17 @@ async function* streamChatWithEmptyRetry(params, { env = process.env } = {}) { yield ev; } } + // Flush anything the classifier still holds BEFORE the rescue decision: an + // unterminated/undecided tail is delivered as text (visible beats dropped), + // and it must count as real text for the rescue gate above. + if (thinkClassifier) { + const rest = thinkClassifier.flush(); + if (rest) { + sawContent = true; + sawText = true; + yield { type: 'content', text: rest }; + } + } // swe-1-7 (Kimi K2 fine-tune) intermittently spends the whole turn in reasoning // declaring tool intent without emitting the call; a corrective nudge measurably // (24/24 live probe) forces emission; empty assistant turns poison upstream into diff --git a/src/handlers/messages.js b/src/handlers/messages.js index 8c5ade35..bdc07c49 100644 --- a/src/handlers/messages.js +++ b/src/handlers/messages.js @@ -14,7 +14,6 @@ import { createHash, randomUUID } from 'crypto'; import { handleChatCompletions, connectErrorToHttp } from './chat.js'; import { log } from '../config.js'; -import { ThinkTextClassifier } from '../response-classifier.js'; function genMsgId() { return 'msg_' + randomUUID().replace(/-/g, '').slice(0, 24); @@ -36,13 +35,11 @@ function mapStopReason(finishReason) { return STOP_REASON_MAP[finishReason] || 'end_turn'; } -// Item 1 (loop break): when the model emits a LEADING think-tagged span on the -// CONTENT channel, reroute it to the thinking channel so clients do not store it as -// visible assistant text and resend it into a self-reinforcing loop. Off by default. -function thinkTextRerouteEnabled() { - const v = String(process.env.DEVIN_CONNECT_THINKTEXT_REROUTE ?? '').trim().toLowerCase(); - return v === '1' || v === 'true' || v === 'yes' || v === 'on'; -} +// Item 1 (loop break) classifier moved to the connect layer: leading think-tagged +// CONTENT is now reclassified into the reasoning channel at the stream-event level +// in devin-connect-openai.js (streamChatWithEmptyRetry), BEFORE the #238 rescue +// decision — see docs/GOAL-2026-08-06-PR-REWORK.md. The egress translators here stay +// passive: they render whatever channel the events arrive on. // B5: Anthropic sets stop_reason:'stop_sequence' AND echoes the matched string // in stop_sequence when generation halts on a caller-supplied stop sequence. @@ -846,21 +843,11 @@ export function openAIToAnthropic(result, model, msgId, cachePolicy = null, stop }); } } else { - let text = choice?.message?.content || ''; - // Item 1 (non-stream path): same leading think-tag reroute as the stream - // translator. Gated to the no-reasoning_content case — the classic leak is - // reasoning emitted AS content; when a real reasoning channel is present we - // leave content untouched to avoid a second thinking block. - if (thinkTextRerouteEnabled() && text && !choice?.message?.reasoning_content) { - const classifier = new ThinkTextClassifier(); - const routed = classifier.feed(text); - const thinkSpan = routed.thinking; - const rest = routed.text + classifier.flush(); - if (thinkSpan) content.push({ type: 'thinking', thinking: thinkSpan }); - if (rest || !thinkSpan) content.push({ type: 'text', text: rest }); - } else { - content.push({ type: 'text', text }); - } + // Invariant: the text block is ALWAYS present, even when empty. The think-tag + // reroute lives in the connect layer now (devin-connect-openai.js), so by the + // time a completion reaches this translator the content channel carries no + // think markers — a reasoning-only turn arrives as reasoning_content only. + content.push({ type: 'text', text: choice?.message?.content || '' }); } // B5: resolve stop_reason (incl. content_filter→refusal) and back-fill // stop_sequence when generation halted on a caller-supplied stop sequence. @@ -1053,8 +1040,6 @@ class AnthropicStreamTranslator { // because strict clients (e.g. Grok Build's messages backend) reject // `signature: ""` as an invalid value. this.pendingThinkingSignature = ''; - // Item 1: reroute leading think-tagged content to the thinking channel (loop break). - this.thinkClassifier = thinkTextRerouteEnabled() ? new ThinkTextClassifier() : null; } send(event, data) { @@ -1274,13 +1259,7 @@ class AnthropicStreamTranslator { // genuine value instead of the empty-string placeholder. if (delta.reasoning_signature) this.pendingThinkingSignature = delta.reasoning_signature; if (delta.content) { - if (this.thinkClassifier) { - const routed = this.thinkClassifier.feed(delta.content); - if (routed.thinking) this.emitThinkingDelta(routed.thinking); - if (routed.text) this.emitTextDelta(routed.text); - } else { - this.emitTextDelta(delta.content); - } + this.emitTextDelta(delta.content); } if (Array.isArray(delta.tool_calls)) { for (const tc of delta.tool_calls) this.emitToolCallDelta(tc); @@ -1354,12 +1333,6 @@ class AnthropicStreamTranslator { // content) no longer reaches here: the BUG1 guard above now catches a missing // terminal signal regardless of whether content started. if (!this.messageStarted) this.startMessage(); - // Item 1: flush any content the classifier still holds. An unterminated span is - // delivered as text (visible beats dropped); see response-classifier flush(). - if (this.thinkClassifier) { - const rest = this.thinkClassifier.flush(); - if (rest) this.emitTextDelta(rest); - } this.closeCurrentBlock(); // B1: interleaved-fragment edge case. flushToolArgs only emits arg fragments // while a tool's block is the currently-open one; a tool_use block that got diff --git a/test/devin-connect-openai.test.js b/test/devin-connect-openai.test.js index d190de2d..223d224f 100644 --- a/test/devin-connect-openai.test.js +++ b/test/devin-connect-openai.test.js @@ -1,4 +1,4 @@ -import { afterEach, describe, it } from 'node:test'; +import { afterEach, beforeEach, describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { toChatCompletion, @@ -1295,3 +1295,111 @@ describe('thinking-only rescue & promotion', () => { }); }); +// Pin tests — GOAL-2026-08-06-PR-REWORK item 3. The leading think-tag reroute +// moved from the messages.js egress translators into the connect layer, at the +// stream-event level (streamChatWithEmptyRetry), BEFORE the #238 rescue +// decision. These pin the combined behavior: reclassification + rescue + the +// egress invariant "text block always present". +describe('think-text reroute (connect layer, DEVIN_CONNECT_THINKTEXT_REROUTE)', () => { + const OPEN = '<' + 'think' + '>'; + const CLOSE = '<' + '/' + 'think' + '>'; + const SAMPLE_TOOLS = [{ type: 'function', function: { name: 'read_file' } }]; + let prev; + beforeEach(() => { prev = process.env.DEVIN_CONNECT_THINKTEXT_REROUTE; process.env.DEVIN_CONNECT_THINKTEXT_REROUTE = '1'; }); + afterEach(() => { if (prev === undefined) delete process.env.DEVIN_CONNECT_THINKTEXT_REROUTE; else process.env.DEVIN_CONNECT_THINKTEXT_REROUTE = prev; }); + + it('(a) whole turn = think block: the #238 rescue fires and the client gets an answer', async () => { + // Attempt 1 is an ENTIRE turn on the content channel, think-tagged. With the + // classifier at the event level this decodes to reasoning-only, so sawText + // stays false and the rescue fires naturally — it never did before the move: + // the egress reroute ran AFTER the rescue had already given up, leaving a + // reasoning-only finish (#238 form, APIEmptyResponseError on strict clients). + let callCount = 0; + let lastParams = null; + __setStreamChatForTest(async function* (params) { + callCount++; + lastParams = params; + if (callCount === 1) { + yield { type: 'content', text: OPEN + 'Let me think through the whole request. ' + CLOSE }; + yield { type: 'finish', reason: 'stop', usage: null }; + } else { + yield { type: 'content', text: 'Here is the answer.' }; + yield { type: 'finish', reason: 'stop', usage: null }; + } + }); + const frames = []; + const result = await streamChatCompletion( + { model: 'swe-1-7', messages: [{ role: 'user', content: 'hi' }], tools: SAMPLE_TOOLS }, + (f) => frames.push(f), + { emulateTools: true }, + ); + assert.equal(callCount, 2, 'the #238 rescue must fire for a whole-think turn'); + assert.ok( + lastParams.messages.at(-1).content.includes('Stop reasoning. Emit the tool call markup now.'), + 'the rescue nudge reaches the second attempt', + ); + // The rescued attempt's answer is what reaches the client — the abandoned + // think-only attempt is not concatenated onto it. + assert.equal(result.content, 'Here is the answer.'); + }); + + it('(a) whole-think turn without tools: promotion keeps a visible answer (no empty message)', async () => { + // No tools -> no rescue (plain chat legitimately ends in reasoning/text). The + // think-only content must still reach the client as the visible answer via + // the existing reasoning->content promotion, so the anthropic egress below + // (openAIToAnthropic) always has a text block to push. + __setStreamChatForTest(fakeStream([ + { type: 'content', text: OPEN + 'reasoning only, no tools. ' + CLOSE }, + { type: 'finish', reason: 'stop', usage: null }, + ])); + const { body } = await toChatCompletion({ model: 'swe-1-7', messages: [] }); + const msg = body.choices[0].message; + assert.equal(msg.content, 'reasoning only, no tools. ', 'promoted into the visible content'); + assert.equal(msg.reasoning_content, undefined, 'promotion moves, not copies'); + }); + + it('(b) think + answer still splits into thinking and text', async () => { + __setStreamChatForTest(fakeStream([ + { type: 'content', text: OPEN + 'inner reasoning. ' + CLOSE }, + { type: 'content', text: 'The answer.' }, + { type: 'finish', reason: 'stop', usage: null }, + ])); + const { body } = await toChatCompletion({ model: 'swe-1-7', messages: [] }); + const msg = body.choices[0].message; + assert.equal(msg.reasoning_content, 'inner reasoning. ', 'think span lands on the reasoning channel'); + assert.equal(msg.content, 'The answer.', 'the answer stays on the content channel'); + }); + + it('(c) stream variant: think + answer renders as reasoning_content deltas then content deltas', async () => { + __setStreamChatForTest(fakeStream([ + { type: 'content', text: OPEN + 'inner reasoning. ' + CLOSE }, + { type: 'content', text: 'The answer.' }, + { type: 'finish', reason: 'stop', usage: null }, + ])); + const frames = []; + const result = await streamChatCompletion( + { model: 'swe-1-7', messages: [] }, + (f) => frames.push(f), + {}, + ); + assert.equal(result.reasoning, 'inner reasoning. '); + assert.equal(result.content, 'The answer.'); + const reasoningDeltas = frames.filter((f) => f.choices?.[0]?.delta?.reasoning_content).map((f) => f.choices[0].delta.reasoning_content).join(''); + assert.equal(reasoningDeltas, 'inner reasoning. '); + const contentDeltas = frames.filter((f) => f.choices?.[0]?.delta?.content).map((f) => f.choices[0].delta.content).join(''); + assert.equal(contentDeltas, 'The answer.'); + }); + + it('respects the gate: with DEVIN_CONNECT_THINKTEXT_REROUTE off, think-tagged content stays text', async () => { + process.env.DEVIN_CONNECT_THINKTEXT_REROUTE = '0'; + __setStreamChatForTest(fakeStream([ + { type: 'content', text: OPEN + 'inner reasoning. ' + CLOSE + 'The answer.' }, + { type: 'finish', reason: 'stop', usage: null }, + ])); + const { body } = await toChatCompletion({ model: 'swe-1-7', messages: [] }); + const msg = body.choices[0].message; + assert.equal(msg.content, OPEN + 'inner reasoning. ' + CLOSE + 'The answer.'); + assert.equal(msg.reasoning_content, undefined); + }); +}); + diff --git a/test/messages.test.js b/test/messages.test.js index 2476a5c9..d1bb40ce 100644 --- a/test/messages.test.js +++ b/test/messages.test.js @@ -1677,17 +1677,16 @@ describe('Anthropic count_tokens', () => { }); }); -// Item 1 (loop break): with DEVIN_CONNECT_THINKTEXT_REROUTE on, a LEADING think-tagged -// span emitted on the CONTENT channel is rerouted to the thinking channel so clients do -// not store it as visible assistant text and resend it into a self-reinforcing loop. -describe('think-text reroute (DEVIN_CONNECT_THINKTEXT_REROUTE)', () => { +// Item 1 (loop break) reroute MOVED to the connect layer: leading think-tagged +// content is reclassified at the stream-event level in devin-connect-openai.js +// (streamChatWithEmptyRetry), BEFORE the #238 rescue decision. The egress +// translators here are passive — they render whatever channel the events arrive +// on — and the one invariant they must keep is: the text block is always present. +describe('think-text reroute (egress is passive; text block always present)', () => { const OPEN = '<' + 'think' + '>'; const CLOSE = '<' + '/' + 'think' + '>'; - let prev; - beforeEach(() => { prev = process.env.DEVIN_CONNECT_THINKTEXT_REROUTE; process.env.DEVIN_CONNECT_THINKTEXT_REROUTE = '1'; }); - afterEach(() => { if (prev === undefined) delete process.env.DEVIN_CONNECT_THINKTEXT_REROUTE; else process.env.DEVIN_CONNECT_THINKTEXT_REROUTE = prev; }); - it('reroutes a leading think block from content to the thinking channel', async () => { + it('stream: think-tagged content arrives on the content channel and renders as text (reroute lives below)', async () => { const result = await handleMessages({ model: 'claude-sonnet-4.6', stream: true, @@ -1711,16 +1710,12 @@ describe('think-text reroute (DEVIN_CONNECT_THINKTEXT_REROUTE)', () => { await result.handler(res); const events = parseAnthropicEvents(res.body); const blocks = events.filter(e => e.event === 'content_block_start').map(e => e.data.content_block.type); - assert.deepEqual(blocks, ['thinking', 'text'], 'think span becomes a thinking block, answer stays text'); - const thinkDeltas = events.filter(e => e.event === 'content_block_delta' && e.data.delta?.type === 'thinking_delta').map(e => e.data.delta.thinking).join(''); - assert.equal(thinkDeltas, 'inner reasoning. '); + assert.deepEqual(blocks, ['text'], 'egress translator is passive: no thinking block at this layer'); const textDeltas = events.filter(e => e.event === 'content_block_delta' && e.data.delta?.type === 'text_delta').map(e => e.data.delta.text).join(''); - assert.equal(textDeltas, 'The answer.'); + assert.equal(textDeltas, OPEN + 'inner reasoning. ' + CLOSE + 'The answer.'); }); - it('non-stream: leading think tag in content (no reasoning_content) becomes a thinking block', async () => { - const OPEN = '<' + 'think' + '>'; - const CLOSE = '<' + '/' + 'think' + '>'; + it('non-stream: think-tagged content stays in the text block (invariant: text block always present)', async () => { const result = await handleMessages({ model: 'claude-sonnet-4.6', stream: false, @@ -1737,15 +1732,35 @@ describe('think-text reroute (DEVIN_CONNECT_THINKTEXT_REROUTE)', () => { }, }); const blocks = result.body.content; - assert.equal(blocks[0].type, 'thinking', 'think span rerouted to a thinking block'); - assert.equal(blocks[0].thinking, 'inner reasoning. '); - assert.equal(blocks[1].type, 'text'); - assert.equal(blocks[1].text, 'The answer.'); + assert.equal(blocks.length, 1); + assert.equal(blocks[0].type, 'text'); + assert.equal(blocks[0].text, OPEN + 'inner reasoning. ' + CLOSE + 'The answer.'); + }); + + it('invariant: a reasoning-only completion (reasoning_content, empty content) still yields a text block', async () => { + const result = await handleMessages({ + model: 'claude-sonnet-4.6', + stream: false, + messages: [{ role: 'user', content: 'hi' }], + }, { + async handleChatCompletions() { + return { + status: 200, + body: { + choices: [{ index: 0, message: { role: 'assistant', content: '', reasoning_content: 'deep thoughts' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + }; + }, + }); + const blocks = result.body.content; + assert.equal(blocks[0].type, 'thinking'); + assert.equal(blocks[0].thinking, 'deep thoughts'); + assert.equal(blocks[1].type, 'text', 'the text block is ALWAYS present, even when empty'); + assert.equal(blocks[1].text, ''); }); it('non-stream: with reasoning_content present, content is left untouched (no second thinking block)', async () => { - const OPEN = '<' + 'think' + '>'; - const CLOSE = '<' + '/' + 'think' + '>'; const result = await handleMessages({ model: 'claude-sonnet-4.6', stream: false, @@ -1770,7 +1785,7 @@ describe('think-text reroute (DEVIN_CONNECT_THINKTEXT_REROUTE)', () => { assert.equal(textBlock.text, OPEN + 'x' + CLOSE); }); - it('with the gate ON, plain content still flows as text (no false reroute)', async () => { + it('with plain content, everything still flows as text on the stream path', async () => { const result = await handleMessages({ model: 'claude-sonnet-4.6', stream: true, diff --git a/test/mutations/think-text-reroute.json b/test/mutations/think-text-reroute.json index faf84b14..2c2de66e 100644 --- a/test/mutations/think-text-reroute.json +++ b/test/mutations/think-text-reroute.json @@ -3,7 +3,7 @@ "test/response-classifier.test.js", "test/messages.test.js" ], - "expectBaselinePass": 89, + "expectBaselinePass": 90, "mutations": [ { "name": "leading-marker reroute disabled entirely — think-tagged content is never rerouted", From dd5f5a1ff68187311a8d5d332acece1a533c67ba Mon Sep 17 00:00:00 2001 From: W ARELIK Date: Thu, 6 Aug 2026 18:06:05 +0300 Subject: [PATCH 5/8] test(mutations): retry-rescue-budget-split baseline 81 -> 86 (5 new connect-layer think-text pin tests) --- test/mutations/retry-rescue-budget-split.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/mutations/retry-rescue-budget-split.json b/test/mutations/retry-rescue-budget-split.json index f5d8a64e..d363d9e4 100644 --- a/test/mutations/retry-rescue-budget-split.json +++ b/test/mutations/retry-rescue-budget-split.json @@ -3,7 +3,7 @@ "test/retry-rescue-budget-split.test.js", "test/devin-connect-openai.test.js" ], - "expectBaselinePass": 81, + "expectBaselinePass": 86, "mutations": [ { "name": "the original defect: the empty arm reads the shared loop counter again", From e78fd93c1d6b4cac119c95575c97c15fb8e3434b Mon Sep 17 00:00:00 2001 From: W ARELIK Date: Fri, 7 Aug 2026 06:14:28 +0300 Subject: [PATCH 6/8] =?UTF-8?q?chore(think-reroute):=20review=20nits=20?= =?UTF-8?q?=E2=80=94=20drop=20dead=20suffixPrefixLen,=20one-line=20emit=20?= =?UTF-8?q?guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/handlers/messages.js | 4 +--- src/response-classifier.js | 8 -------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/src/handlers/messages.js b/src/handlers/messages.js index bdc07c49..5bc86f9e 100644 --- a/src/handlers/messages.js +++ b/src/handlers/messages.js @@ -1258,9 +1258,7 @@ class AnthropicStreamTranslator { // to the reasoning stream, capture it so closeCurrentBlock round-trips the // genuine value instead of the empty-string placeholder. if (delta.reasoning_signature) this.pendingThinkingSignature = delta.reasoning_signature; - if (delta.content) { - this.emitTextDelta(delta.content); - } + if (delta.content) this.emitTextDelta(delta.content); if (Array.isArray(delta.tool_calls)) { for (const tc of delta.tool_calls) this.emitToolCallDelta(tc); } diff --git a/src/response-classifier.js b/src/response-classifier.js index c02d9395..b52f1c44 100644 --- a/src/response-classifier.js +++ b/src/response-classifier.js @@ -20,14 +20,6 @@ const THINK_CLOSE = '<' + '/' + 'think' + '>'; const MAX_PENDING = 32000; // hold ceiling for an unterminated think span const MAX_LEAD = 8192; // undecided-hold ceiling before committing to text -// Longest suffix of `buf` that is a prefix of `marker` ('' if none). -function suffixPrefixLen(buf, marker) { - const max = Math.min(buf.length, marker.length); - for (let n = max; n > 0; n--) { - if (buf.endsWith(marker.slice(0, n))) return n; - } - return 0; -} export class ThinkTextClassifier { constructor() { From 61871e122fd9b26f273d8698b934e24e5d3d48a6 Mon Sep 17 00:00:00 2001 From: W ARELIK Date: Sat, 8 Aug 2026 10:33:38 +0300 Subject: [PATCH 7/8] fix(think-reroute): honest rescue-interplay comment; pin history/replay isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the comment claimed the #238 rescue 'fires naturally' on a no-tool all-think turn — it does not: the nudge path requires tools and isEmptyCompletion reads sawContent, which the thinking branch sets. Comment rewritten to state the real outcome (thinking blocks delivered, text empty, no retry) and why that is accepted, plus why isEmptyCompletion must NOT learn to read sawText (would redraw the #238/#241 rescue boundary) - review M3 (caller-pasted content rerouted): verified the classifier is instantiated ONLY in the live upstream stream path — no replay/history path reaches it. New test pins it: an inbound history message with a leading think block is forwarded upstream untouched with the gate ON --- src/devin-connect-openai.js | 12 ++++++++++-- test/devin-connect-openai.test.js | 23 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/devin-connect-openai.js b/src/devin-connect-openai.js index 32832e77..655f3b4b 100644 --- a/src/devin-connect-openai.js +++ b/src/devin-connect-openai.js @@ -225,8 +225,16 @@ async function* streamChatWithEmptyRetry(params, { env = process.env } = {}) { // Item 1 (loop break): leading think-tagged CONTENT is reclassified into the // reasoning channel HERE, at the stream-event level, BEFORE the rescue // decision below. A whole turn of ` thinking…` therefore stays a - // reasoning-only stream: sawText stays false, so the #238 rescue fires - // naturally — the reroute and the rescue combine, neither bypasses the other. + // reasoning-only stream. + // Honest rescue interplay (corrected after review): such a turn does NOT + // fire the #238 rescue — the :284 nudge path requires tools, and + // isEmptyCompletion reads sawContent, which the thinking branch below sets + // at :238. The real outcome: reasoning delivered as thinking blocks, text + // empty, no retry. That is accepted: the reasoning is not lost (the client + // receives thinking blocks, not the #238 whole-turn vanish), and sawContent + // genuinely means "upstream said something", which is true here. + // Deliberately NOT teaching isEmptyCompletion to read sawText — that would + // redraw the #238/#241 rescue boundary for a cosmetic gain. // (Known limitation: only the ` thinking`…` dialect is recognized; Kimi K2's // family uses `◁think▷…◁/think▷` — extending the classifier to that dialect // is left for a future PR.) diff --git a/test/devin-connect-openai.test.js b/test/devin-connect-openai.test.js index 223d224f..29fbe73c 100644 --- a/test/devin-connect-openai.test.js +++ b/test/devin-connect-openai.test.js @@ -1401,5 +1401,28 @@ describe('think-text reroute (connect layer, DEVIN_CONNECT_THINKTEXT_REROUTE)', assert.equal(msg.content, OPEN + 'inner reasoning. ' + CLOSE + 'The answer.'); assert.equal(msg.reasoning_content, undefined); }); + + it('history isolation: a leading think block inside an INBOUND history message is forwarded upstream untouched, even with the gate on', async () => { + // The classifier runs ONLY on live upstream output events. Caller-pasted + // content (a quoted transcript, a literal "what does the tag mean") rides + // the inbound message list and must never be reclassified — that would + // lose attribution, the mirror image of the leak this feature fixes. + process.env.DEVIN_CONNECT_THINKTEXT_REROUTE = '1'; + let captured = null; + __setStreamChatForTest(async function* (params) { + captured = params; + yield { type: 'content', text: 'ok' }; + yield { type: 'finish', reason: 'stop', usage: null }; + }); + const history = [ + { role: 'user', content: 'what does ' + OPEN + CLOSE + ' mean?' }, + { role: 'assistant', content: OPEN + 'quoted from a log' + CLOSE + ' and here is my real answer' }, + { role: 'user', content: 'go on' }, + ]; + const { status, body } = await toChatCompletion({ model: 'swe-1-7', messages: history }); + assert.equal(status, 200); + assert.deepEqual(captured.messages, history); + assert.equal(body.choices[0].message.content, 'ok'); + }); }); From a79c58f4c5f719bdf7404e4dee4f251b51b3e655 Mon Sep 17 00:00:00 2001 From: W ARELIK Date: Sat, 8 Aug 2026 10:57:18 +0300 Subject: [PATCH 8/8] chore(mutations): retry-rescue-budget-split baseline 86 -> 87 (history-isolation test added in this branch) Merge-order note (review M4): this PR and #242 both moved the count from 81. Proposed order: #242 first, this PR second. Verified by an actual merge of pr/modelconfig-reasoning-continuity into this branch: the ONLY textual conflict is this count file, and the combined count is 88 (81 + 1 from #242 + 5 rescue pins + 1 history-isolation test). Whoever merges second sets 88. --- test/mutations/retry-rescue-budget-split.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/mutations/retry-rescue-budget-split.json b/test/mutations/retry-rescue-budget-split.json index d363d9e4..c62410d1 100644 --- a/test/mutations/retry-rescue-budget-split.json +++ b/test/mutations/retry-rescue-budget-split.json @@ -3,7 +3,7 @@ "test/retry-rescue-budget-split.test.js", "test/devin-connect-openai.test.js" ], - "expectBaselinePass": 86, + "expectBaselinePass": 87, "mutations": [ { "name": "the original defect: the empty arm reads the shared loop counter again",