From e9045850605f4c90d1738fdc5df943c6100e0aad Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 15 Jun 2026 21:05:08 +0000 Subject: [PATCH] fix(ai): preserved sibling thinking envelopes Parsed literal thinking envelopes independently before transcript rendering so interleaved thinking blocks do not collapse into one malformed wrapper. Added advisor raw dump regression coverage for sibling literal thinking blocks.\n\nFixes #2700 --- packages/ai/src/dialect/rendering.ts | 61 +++++++++++++++---- .../src/advisor/__tests__/advisor.test.ts | 21 +++++++ 2 files changed, 69 insertions(+), 13 deletions(-) diff --git a/packages/ai/src/dialect/rendering.ts b/packages/ai/src/dialect/rendering.ts index 882961f65..94b69b403 100644 --- a/packages/ai/src/dialect/rendering.ts +++ b/packages/ai/src/dialect/rendering.ts @@ -161,20 +161,55 @@ function isAsciiWhitespace(code: number): boolean { return code === 9 || code === 10 || code === 11 || code === 12 || code === 13 || code === 32; } -function unwrapDelimitedThinking(open: string, close: string, text: string): string { - let start = 0; - let end = text.length; - let changed = false; - while (true) { - while (start < end && isAsciiWhitespace(text.charCodeAt(start))) start++; - while (end > start && isAsciiWhitespace(text.charCodeAt(end - 1))) end--; - if (end - start < open.length + close.length) break; - if (!text.startsWith(open, start) || !text.startsWith(close, end - close.length)) break; - start += open.length; - end -= close.length; - changed = true; +function trimAsciiStart(text: string, start: number, end: number): number { + let cursor = start; + while (cursor < end && isAsciiWhitespace(text.charCodeAt(cursor))) cursor++; + return cursor; +} + +function trimAsciiEnd(text: string, start: number, end: number): number { + let cursor = end; + while (cursor > start && isAsciiWhitespace(text.charCodeAt(cursor - 1))) cursor--; + return cursor; +} + +function findDelimitedThinkingClose(open: string, close: string, text: string, start: number, end: number): number { + let depth = 1; + let cursor = start; + while (cursor < end) { + const nextClose = text.indexOf(close, cursor); + if (nextClose < 0 || nextClose >= end) return -1; + const nextOpen = text.indexOf(open, cursor); + if (nextOpen >= 0 && nextOpen < nextClose) { + depth++; + cursor = nextOpen + open.length; + continue; + } + depth--; + if (depth === 0) return nextClose; + cursor = nextClose + close.length; } - return changed ? text.slice(start, end) : text; + return -1; +} + +function unwrapDelimitedThinking(open: string, close: string, text: string): string { + const end = trimAsciiEnd(text, 0, text.length); + let cursor = trimAsciiStart(text, 0, end); + if (cursor >= end || !text.startsWith(open, cursor)) return text; + + const segments: string[] = []; + while (cursor < end) { + if (!text.startsWith(open, cursor)) return text; + const innerStart = cursor + open.length; + const innerEnd = findDelimitedThinkingClose(open, close, text, innerStart, end); + if (innerEnd < 0) return text; + + const trimmedInnerEnd = trimAsciiEnd(text, innerStart, innerEnd); + const trimmedInnerStart = trimAsciiStart(text, innerStart, trimmedInnerEnd); + segments.push(unwrapDelimitedThinking(open, close, text.slice(trimmedInnerStart, trimmedInnerEnd))); + cursor = trimAsciiStart(text, innerEnd + close.length, end); + } + return segments.join("\n"); } export function renderDelimitedThinking(open: string, close: string, text: string): string { diff --git a/packages/coding-agent/src/advisor/__tests__/advisor.test.ts b/packages/coding-agent/src/advisor/__tests__/advisor.test.ts index e707079e8..a1dbf51b0 100644 --- a/packages/coding-agent/src/advisor/__tests__/advisor.test.ts +++ b/packages/coding-agent/src/advisor/__tests__/advisor.test.ts @@ -605,5 +605,26 @@ describe("advisor", () => { expect(md).toContain("Assistant: \nCheck logs before accepting container health.\n"); expect(md).not.toContain("\n"); }); + + it("unwraps sibling literal thinking envelopes independently", () => { + const md = formatSessionDumpText({ + messages: [ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "\nfirst\n" }, + { type: "toolCall", id: "tc-1", name: "read", arguments: { path: "file.ts" } }, + { type: "thinking", thinking: "\nsecond\n" }, + ], + timestamp: Date.now(), + } as AgentMessage, + ], + tools: [{ name: "read", description: "Read a file", parameters: { type: "object" } }], + thinkingLevel: "high", + }); + + expect(md).toContain("Assistant: \nfirst\nsecond\n"); + expect(md).not.toContain("first\n\n\nsecond"); + }); }); });