From 18273d362cb774ae8979f75bf0a3b5c39222b360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Fri, 8 May 2026 20:03:50 +0800 Subject: [PATCH 01/11] fix(core): deduplicate geminiChat recovery continuation text When a provider hits MAX_TOKENS and the model resumes via the recovery loop, the continuation stream sometimes re-sends characters from the end of the previous response as a context anchor. Without deduplication this causes repeated Markdown tables/prose in the final history even if the live UI suppresses them. Add getRecoveryContinuationSuffix / findContainedRecoveryPrefixReplayLength to strip the replayed prefix before appending the continuation parts. Also include the last 1200 chars of the previous response in the recovery prompt so the model can see where it left off. Two new tests cover: - exact suffix overlap (shared recovery suffix and continuation) - contained tail anchor replay (Markdown table prefix replayed mid-text) Generated with AI Co-authored-by: Qwen-Coder --- packages/core/src/core/geminiChat.test.ts | 103 ++++++++++++ packages/core/src/core/geminiChat.ts | 182 +++++++++++++++++++++- 2 files changed, 280 insertions(+), 5 deletions(-) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 4e5ddb65c5d..34eabe0f869 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -2583,6 +2583,109 @@ describe('GeminiChat', async () => { ); }); + it('should coalesce overlapping recovery continuation text', async () => { + const streams = [ + makeStream([makeChunk([{ text: 'discarded initial' }], 'MAX_TOKENS')]), + makeStream([ + makeChunk([{ text: 'Alpha shared recovery suffix' }], 'MAX_TOKENS'), + ]), + makeStream([ + makeChunk( + [{ text: 'shared recovery suffix and continuation' }], + 'STOP', + ), + ]), + ]; + let callIndex = 0; + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async () => streams[callIndex++]!, + ); + + const stream = await chat.sendMessageStream( + 'gemini-3-pro', + { message: 'write a long essay' }, + 'prompt-recovery-overlap', + ); + + for await (const _event of stream) { + // consume + } + + const history = chat.getHistory(); + const lastEntry = history[history.length - 1]!; + const text = lastEntry.parts + ?.map((part) => ('text' in part ? part.text : '')) + .join(''); + + expect(lastEntry.role).toBe('model'); + expect(text).toBe('Alpha shared recovery suffix and continuation'); + }); + + it('should coalesce recovery text that replays a previous tail anchor', async () => { + const streams = [ + makeStream([makeChunk([{ text: 'discarded initial' }], 'MAX_TOKENS')]), + makeStream([ + makeChunk( + [ + { + text: [ + 'Intro', + '### 常用语法速查', + '| 语法 | 说明 |', + 'tail that was truncated', + ].join('\n'), + }, + ], + 'MAX_TOKENS', + ), + ]), + makeStream([ + makeChunk( + [ + { + text: [ + '### 常用语法速查', + '| 语法 | 说明 |', + 'new suffix', + ].join('\n'), + }, + ], + 'STOP', + ), + ]), + ]; + let callIndex = 0; + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async () => streams[callIndex++]!, + ); + + const stream = await chat.sendMessageStream( + 'gemini-3-pro', + { message: 'write a long mermaid answer' }, + 'prompt-recovery-contained-replay', + ); + + for await (const _event of stream) { + // consume + } + + const history = chat.getHistory(); + const lastEntry = history[history.length - 1]!; + const text = lastEntry.parts + ?.map((part) => ('text' in part ? part.text : '')) + .join(''); + + expect(text).toBe( + [ + 'Intro', + '### 常用语法速查', + '| 语法 | 说明 |', + 'tail that was truncated', + 'new suffix', + ].join('\n'), + ); + }); + it('should skip recovery when truncated turn has a functionCall', async () => { // Initial stream returns a functionCall + MAX_TOKENS. Escalated stream // returns the same (functionCall + MAX_TOKENS). Recovery must NOT run diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 66bc649d99a..666b41fe97f 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -113,6 +113,176 @@ const OUTPUT_RECOVERY_MESSAGE = 'you were doing. Pick up mid-thought if that is where the cut happened. ' + 'Break remaining work into smaller pieces.'; +const OUTPUT_RECOVERY_TAIL_CHARS = 1200; +const RECOVERY_OVERLAP_MAX_SCAN_CHARS = 4000; +const RECOVERY_OVERLAP_MIN_BYTES = 6; +const RECOVERY_STRUCTURAL_OVERLAP_MIN_BYTES = 4; + +function byteLength(text: string): number { + return Buffer.byteLength(text, 'utf8'); +} + +function isSignificantRecoveryOverlap(overlap: string): boolean { + const overlapBytes = byteLength(overlap); + const hasMarkdownStructure = /[#|`\n]/.test(overlap); + return ( + overlapBytes >= RECOVERY_OVERLAP_MIN_BYTES || + (hasMarkdownStructure && + overlapBytes >= RECOVERY_STRUCTURAL_OVERLAP_MIN_BYTES) + ); +} + +function findContainedRecoveryPrefixReplayLength( + previousText: string, + continuationText: string, +): number { + const previousTail = + previousText.length > RECOVERY_OVERLAP_MAX_SCAN_CHARS + ? previousText.slice(-RECOVERY_OVERLAP_MAX_SCAN_CHARS) + : previousText; + const maxPrefix = Math.min( + previousTail.length, + continuationText.length, + RECOVERY_OVERLAP_MAX_SCAN_CHARS, + ); + + for (let length = maxPrefix; length > 0; length -= 1) { + const prefix = continuationText.slice(0, length); + if (isSignificantRecoveryOverlap(prefix) && previousTail.includes(prefix)) { + return length; + } + } + + return 0; +} + +function getRecoveryContinuationSuffix( + previousText: string, + continuationText: string, +): string { + if (previousText.length === 0 || continuationText.length === 0) { + return continuationText; + } + + if ( + previousText.endsWith(continuationText) && + isSignificantRecoveryOverlap(continuationText) + ) { + return ''; + } + + const maxOverlap = Math.min( + previousText.length, + continuationText.length, + RECOVERY_OVERLAP_MAX_SCAN_CHARS, + ); + + for (let length = maxOverlap; length > 0; length -= 1) { + const overlap = continuationText.slice(0, length); + if ( + isSignificantRecoveryOverlap(overlap) && + previousText.endsWith(overlap) + ) { + return continuationText.slice(length); + } + } + + // Providers/models frequently resume a MAX_TOKENS recovery from an anchor + // that appears near the tail of the previous response, rather than from the + // exact last byte. Drop that replayed leading prefix before coalescing the + // recovery model turn into durable history; otherwise later turns inherit + // duplicated Markdown tables/prose even if the live UI suppresses them. + const containedPrefixLength = findContainedRecoveryPrefixReplayLength( + previousText, + continuationText, + ); + if (containedPrefixLength > 0) { + const replayedPrefix = continuationText.slice(0, containedPrefixLength); + let suffix = continuationText.slice(containedPrefixLength); + if ( + suffix.length > 0 && + replayedPrefix.endsWith('\n') && + !previousText.endsWith('\n') && + !suffix.startsWith('\n') + ) { + suffix = `\n${suffix}`; + } + return suffix; + } + + return continuationText; +} + +function isPlainTextPart(part: Part | undefined): part is Part & { + text: string; +} { + return ( + part !== undefined && + typeof part.text === 'string' && + part.thought !== true && + part.functionCall === undefined && + part.functionResponse === undefined + ); +} + +function getPlainTextFromParts(parts: Part[] | undefined): string { + return (parts ?? []) + .filter(isPlainTextPart) + .map((part) => part.text) + .join(''); +} + +function buildOutputRecoveryMessage(previousModelTurn: Content | undefined) { + const previousText = + previousModelTurn?.role === 'model' + ? getPlainTextFromParts(previousModelTurn.parts) + : ''; + if (previousText.trim().length === 0) { + return OUTPUT_RECOVERY_MESSAGE; + } + + const tail = + previousText.length > OUTPUT_RECOVERY_TAIL_CHARS + ? previousText.slice(-OUTPUT_RECOVERY_TAIL_CHARS) + : previousText; + + return ( + `${OUTPUT_RECOVERY_MESSAGE}\n\n` + + 'The previous assistant response ended with this exact suffix. ' + + 'Do not repeat any line, table row, code line, or prose that already ' + + 'appears in it; output only text that comes after this suffix:\n\n' + + '\n' + + tail + + '\n' + ); +} + +function appendRecoveryContinuationParts( + previousParts: Part[] | undefined, + continuationParts: Part[] | undefined, +): Part[] { + const mergedParts = [...(previousParts ?? [])]; + const nextParts = [...(continuationParts ?? [])]; + const previousLastPart = mergedParts[mergedParts.length - 1]; + const continuationFirstPart = nextParts[0]; + + if ( + isPlainTextPart(previousLastPart) && + isPlainTextPart(continuationFirstPart) + ) { + const suffix = getRecoveryContinuationSuffix( + previousLastPart.text, + continuationFirstPart.text, + ); + if (suffix.length > 0) { + previousLastPart.text += suffix; + } + nextParts.shift(); + } + + return [...mergedParts, ...nextParts]; +} + /** * Options for retrying on rate-limit throttling errors returned as stream content. * Starts at 60s to match DashScope's per-minute quota window, then backs off @@ -777,7 +947,9 @@ export class GeminiChat { // (pushed by processStreamResponse). Push a recovery user // message so the model sees its partial output and continues. self.history.push( - createUserContent([{ text: OUTPUT_RECOVERY_MESSAGE }]), + createUserContent([ + { text: buildOutputRecoveryMessage(lastEntry) }, + ]), ); // Signal UI/turn to clear pending (incomplete) tool calls. // isContinuation tells the UI to keep the text buffer so the @@ -1204,10 +1376,10 @@ export class GeminiChat { return; } - precedingModel.parts = [ - ...(precedingModel.parts ?? []), - ...(modelContinuation.parts ?? []), - ]; + precedingModel.parts = appendRecoveryContinuationParts( + precedingModel.parts, + modelContinuation.parts, + ); // Drop the (userRecovery, modelContinuation) pair. this.history.splice(len - 2, 2); } From 549f27a6fcdaa4b0fd00a7fecbf0488a2ca4c1f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sat, 9 May 2026 15:17:14 +0800 Subject: [PATCH 02/11] fix(core): tighten contained-prefix recovery dedup to avoid prose loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on PR #3966: the contained-prefix fallback in geminiChat recovery dedup was too permissive — a 6-byte minimum plus a 4000-char lookahead window allowed common opener phrases ("In summary,", "In conclusion,", "Here is the …") to silently strip legitimate continuation text whenever they happened to coincide with any substring in the previous turn. Silent loss is a worse failure mode than the duplication we were fixing. Constrain the fallback to its real intended use case — replayed Markdown blocks that providers re-emit at the start of a recovery continuation (table headers, headings, fenced code, lists, blockquotes): - Require the continuation to *open* with a Markdown structural anchor before considering any contained-prefix replay; plain prose openers fall through with no dedup attempted. - Restrict the substring search to the immediate truncation tail (last 400 chars) so a coincidental match far above the truncation point cannot win. - Raise the contained-prefix byte floor (12 bytes) above the suffix- overlap floor. Also add coverage for the previously-untested guard branches (empty input, full-overlap drop, empty previous-text path that skips the block) and regression tests for the prose-loss scenarios called out in review. Generated with AI Co-authored-by: Qwen-Coder --- packages/core/src/core/geminiChat.test.ts | 177 +++++++++++++++++++++- packages/core/src/core/geminiChat.ts | 46 +++++- 2 files changed, 219 insertions(+), 4 deletions(-) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 34eabe0f869..82e06968820 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -2521,7 +2521,11 @@ describe('GeminiChat', async () => { describe('output token recovery', () => { function makeChunk( - parts: Array<{ text?: string; functionCall?: unknown }>, + parts: Array<{ + text?: string; + functionCall?: unknown; + thought?: boolean; + }>, finishReason?: string, ): GenerateContentResponse { return { @@ -2686,6 +2690,177 @@ describe('GeminiChat', async () => { ); }); + it('should preserve prose continuation that coincidentally repeats an opener phrase', async () => { + // Regression: an earlier version of the contained-prefix fallback would + // strip leading prose if a substring appeared anywhere in the previous + // response. That silently dropped legitimate continuation text. + // Now the contained-prefix path requires a Markdown structural anchor, + // so common opener phrases like "In summary," / "In conclusion," are + // left intact even when they happen to match the previous tail. + const previous = + 'We covered cats. In summary, this concludes the cat section.'; + const continuation = + 'In summary, the answer is 42 and the dog section follows.'; + const streams = [ + makeStream([makeChunk([{ text: 'discarded initial' }], 'MAX_TOKENS')]), + makeStream([makeChunk([{ text: previous }], 'MAX_TOKENS')]), + makeStream([makeChunk([{ text: continuation }], 'STOP')]), + ]; + let callIndex = 0; + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async () => streams[callIndex++]!, + ); + + const stream = await chat.sendMessageStream( + 'gemini-3-pro', + { message: 'write something' }, + 'prompt-recovery-prose-opener', + ); + for await (const _event of stream) { + // consume + } + + const history = chat.getHistory(); + const lastEntry = history[history.length - 1]!; + const text = lastEntry.parts + ?.map((part) => ('text' in part ? part.text : '')) + .join(''); + // Continuation must be appended verbatim — no silent strip of + // "In summary, " or "In summary, th". + expect(text).toBe(previous + continuation); + }); + + it('should not strip prose that coincides with a far-earlier substring of the previous turn', async () => { + // Even when the continuation accidentally matches a long phrase + // hundreds of characters above the truncation tail, the contained-prefix + // fallback must not replay-strip it: there is no structural anchor and + // the match is not adjacent to the truncation point. + const filler = 'lorem ipsum dolor sit amet '.repeat(20); + const previous = `Here is the rest of the explanation.\n${filler}\nthe model was cut off here`; + const continuation = 'Here is the rest of the explanation continued.'; + const streams = [ + makeStream([makeChunk([{ text: 'discarded initial' }], 'MAX_TOKENS')]), + makeStream([makeChunk([{ text: previous }], 'MAX_TOKENS')]), + makeStream([makeChunk([{ text: continuation }], 'STOP')]), + ]; + let callIndex = 0; + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async () => streams[callIndex++]!, + ); + + const stream = await chat.sendMessageStream( + 'gemini-3-pro', + { message: 'write something' }, + 'prompt-recovery-far-prose', + ); + for await (const _event of stream) { + // consume + } + + const history = chat.getHistory(); + const lastEntry = history[history.length - 1]!; + const text = lastEntry.parts + ?.map((part) => ('text' in part ? part.text : '')) + .join(''); + expect(text).toBe(previous + continuation); + }); + + it('should drop continuation entirely when it exactly replays the previous tail', async () => { + // Covers the full-overlap guard in getRecoveryContinuationSuffix: + // previousText.endsWith(continuationText) AND the overlap is significant. + const streams = [ + makeStream([makeChunk([{ text: 'discarded initial' }], 'MAX_TOKENS')]), + makeStream([ + makeChunk([{ text: 'leading content. tail-fragment' }], 'MAX_TOKENS'), + ]), + // The whole continuation matches the previous tail and is significant + // (>= RECOVERY_OVERLAP_MIN_BYTES). It should be discarded entirely. + makeStream([makeChunk([{ text: 'tail-fragment' }], 'STOP')]), + ]; + let callIndex = 0; + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async () => streams[callIndex++]!, + ); + + const stream = await chat.sendMessageStream( + 'gemini-3-pro', + { message: 'write something' }, + 'prompt-recovery-full-overlap', + ); + for await (const _event of stream) { + // consume + } + + const history = chat.getHistory(); + const lastEntry = history[history.length - 1]!; + const text = lastEntry.parts + ?.map((part) => ('text' in part ? part.text : '')) + .join(''); + expect(text).toBe('leading content. tail-fragment'); + }); + + it('should leave continuation untouched when the previous turn has no plain text', async () => { + // Covers the empty-text branches: getRecoveryContinuationSuffix's + // `previousText.length === 0` guard (continuation passed through + // verbatim) and buildOutputRecoveryMessage's + // `previousText.trim().length === 0` branch (no + // block is appended). The previous turn has + // only a thought part, so there is no plain text to dedupe against. + const streams = [ + makeStream([makeChunk([{ text: 'discarded initial' }], 'MAX_TOKENS')]), + makeStream([ + makeChunk( + [{ text: 'thinking through the problem', thought: true }], + 'MAX_TOKENS', + ), + ]), + makeStream([makeChunk([{ text: 'fresh continuation text' }], 'STOP')]), + ]; + let callIndex = 0; + const recoveryPayloads: string[] = []; + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async (params) => { + const contents = (params as { contents?: Content[] }).contents ?? []; + const lastTurn = contents[contents.length - 1]; + if (lastTurn && lastTurn.role === 'user') { + const lastPart = lastTurn.parts?.[0]; + if ( + lastPart && + typeof (lastPart as { text?: string }).text === 'string' + ) { + recoveryPayloads.push((lastPart as { text: string }).text); + } + } + return streams[callIndex++]!; + }, + ); + + const stream = await chat.sendMessageStream( + 'gemini-3-pro', + { message: 'write something' }, + 'prompt-recovery-thought-only', + ); + for await (const _event of stream) { + // consume + } + + const history = chat.getHistory(); + const lastEntry = history[history.length - 1]!; + const nonThoughtText = lastEntry.parts + ?.filter((part) => !('thought' in part) || !part.thought) + .map((part) => ('text' in part ? part.text : '')) + .join(''); + // Continuation is preserved verbatim (empty-input guard). + expect(nonThoughtText).toBe('fresh continuation text'); + // The recovery user message must NOT include a previous_response_suffix + // block since there was no plain text to anchor on. + const recoveryMessage = recoveryPayloads.find((p) => + p.includes('Output token limit hit'), + ); + expect(recoveryMessage).toBeDefined(); + expect(recoveryMessage).not.toContain(''); + }); + it('should skip recovery when truncated turn has a functionCall', async () => { // Initial stream returns a functionCall + MAX_TOKENS. Escalated stream // returns the same (functionCall + MAX_TOKENS). Recovery must NOT run diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 666b41fe97f..fa07d56ef22 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -117,6 +117,17 @@ const OUTPUT_RECOVERY_TAIL_CHARS = 1200; const RECOVERY_OVERLAP_MAX_SCAN_CHARS = 4000; const RECOVERY_OVERLAP_MIN_BYTES = 6; const RECOVERY_STRUCTURAL_OVERLAP_MIN_BYTES = 4; +// Plain-prose substring matches outside the suffix-anchored path are very +// prone to false positives on common opener phrases ("In summary, …", "Here is +// the …"). The contained-prefix replay path is reserved for replayed Markdown +// blocks (tables, headings, fenced code), so we require both a structural +// anchor at the start of the prefix and a substantially larger byte floor than +// the suffix path uses. This intentionally errs on the side of leaving rare +// duplicates in history rather than silently dropping legitimate continuation. +const RECOVERY_CONTAINED_PREFIX_MIN_BYTES = 12; +// Limit the substring search to the immediate truncation tail so a coincidental +// match thousands of characters earlier in the previous turn cannot win. +const RECOVERY_CONTAINED_TAIL_LOOKBACK_CHARS = 400; function byteLength(text: string): number { return Buffer.byteLength(text, 'utf8'); @@ -132,13 +143,29 @@ function isSignificantRecoveryOverlap(overlap: string): boolean { ); } +/** + * Returns true if `text` opens with a Markdown block-level structural marker + * (table row, fenced code, ATX heading, blockquote, list item). Leading + * blank/newline chars are skipped because providers often prepend them when + * restarting a block. The marker must appear at the start of a line and be + * followed by the syntactic gap the spec requires (e.g. `# ` not `#abc`), so + * incidental `#` or `|` characters in prose do not count. + */ +function startsWithMarkdownStructuralAnchor(text: string): boolean { + const trimmed = text.replace(/^\n+/, ''); + return /^(\|[^\n]*\||#{1,6} |```|>\s|[-*+] |\d+\. )/.test(trimmed); +} + function findContainedRecoveryPrefixReplayLength( previousText: string, continuationText: string, ): number { + // Only consider replaying the *immediate* tail of the previous response. + // Earlier matches would let a coincidental substring far above the + // truncation point silently delete legitimate continuation text. const previousTail = - previousText.length > RECOVERY_OVERLAP_MAX_SCAN_CHARS - ? previousText.slice(-RECOVERY_OVERLAP_MAX_SCAN_CHARS) + previousText.length > RECOVERY_CONTAINED_TAIL_LOOKBACK_CHARS + ? previousText.slice(-RECOVERY_CONTAINED_TAIL_LOOKBACK_CHARS) : previousText; const maxPrefix = Math.min( previousTail.length, @@ -146,9 +173,22 @@ function findContainedRecoveryPrefixReplayLength( RECOVERY_OVERLAP_MAX_SCAN_CHARS, ); + // The contained-prefix path is intended *only* for replayed Markdown blocks + // (tables, headings, fenced code) that providers re-emit when resuming after + // MAX_TOKENS. Prose replays — even ones that briefly coincide with the + // previous tail — are out of scope: dropping them would silently lose user- + // visible content. Require a structural anchor at the very start of the + // continuation before considering any contained-prefix match at all. + if (!startsWithMarkdownStructuralAnchor(continuationText)) { + return 0; + } + for (let length = maxPrefix; length > 0; length -= 1) { const prefix = continuationText.slice(0, length); - if (isSignificantRecoveryOverlap(prefix) && previousTail.includes(prefix)) { + if ( + byteLength(prefix) >= RECOVERY_CONTAINED_PREFIX_MIN_BYTES && + previousTail.includes(prefix) + ) { return length; } } From 7bd70dc495ab64399a5d1e6707d5471b38220930 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 11 May 2026 09:41:54 +0800 Subject: [PATCH 03/11] fix(core): handle leading whitespace in structural anchor + cover tail truncation Address wenshao review on PR #3966: - `startsWithMarkdownStructuralAnchor` now strips all leading whitespace (`/^\s+/`) instead of only newlines (`/^\n+/`). Some providers re-emit a recovered Markdown block with leading spaces or tabs, not just newlines; the old regex caused the structural-anchor gate to fail and the contained-prefix dedup path was silently skipped. - Add a regression test for `buildOutputRecoveryMessage` that exercises the `previousText.slice(-OUTPUT_RECOVERY_TAIL_CHARS)` truncation branch with a 1300-char previous response, asserting the block contains exactly the trailing 1200 chars and that the dropped head does not leak. Generated with AI Co-authored-by: Qwen-Coder --- packages/core/src/core/geminiChat.test.ts | 64 +++++++++++++++++++++++ packages/core/src/core/geminiChat.ts | 12 +++-- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 82e06968820..b5f8f9f189b 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -2861,6 +2861,70 @@ describe('GeminiChat', async () => { expect(recoveryMessage).not.toContain(''); }); + it('should truncate the previous_response_suffix to the trailing 1200 chars when the previous turn is longer', async () => { + // Covers the slice(-OUTPUT_RECOVERY_TAIL_CHARS) branch in + // buildOutputRecoveryMessage. The truncation tail is 1200 chars; we + // build a previous response of 1300 chars so the head (100 chars) is + // dropped and the tail (1200 chars) is what shows up in the + // block sent to the recovery turn. + const head = 'A'.repeat(100); + const tail = 'B'.repeat(1200); + const previous = `${head}${tail}`; + expect(previous.length).toBe(1300); + + const streams = [ + makeStream([makeChunk([{ text: 'discarded initial' }], 'MAX_TOKENS')]), + makeStream([makeChunk([{ text: previous }], 'MAX_TOKENS')]), + makeStream([makeChunk([{ text: ' continuation tail' }], 'STOP')]), + ]; + let callIndex = 0; + const recoveryPayloads: string[] = []; + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async (params) => { + const contents = (params as { contents?: Content[] }).contents ?? []; + const lastTurn = contents[contents.length - 1]; + if (lastTurn && lastTurn.role === 'user') { + const lastPart = lastTurn.parts?.[0]; + if ( + lastPart && + typeof (lastPart as { text?: string }).text === 'string' + ) { + recoveryPayloads.push((lastPart as { text: string }).text); + } + } + return streams[callIndex++]!; + }, + ); + + const stream = await chat.sendMessageStream( + 'gemini-3-pro', + { message: 'write a very long answer' }, + 'prompt-recovery-tail-truncation', + ); + for await (const _event of stream) { + // consume + } + + const recoveryMessage = recoveryPayloads.find((p) => + p.includes('Output token limit hit'), + ); + expect(recoveryMessage).toBeDefined(); + // The recovery prompt must contain the suffix block... + expect(recoveryMessage).toContain(''); + expect(recoveryMessage).toContain(''); + // ...with exactly the trailing 1200 chars of the previous response. + const match = recoveryMessage!.match( + /\n([\s\S]*)\n<\/previous_response_suffix>/, + ); + expect(match).not.toBeNull(); + const suffix = match![1]!; + expect(suffix.length).toBe(1200); + expect(suffix).toBe(tail); + // The 100-char head must NOT leak into the recovery prompt. + expect(suffix.startsWith('A')).toBe(false); + expect(recoveryMessage).not.toContain(head); + }); + it('should skip recovery when truncated turn has a functionCall', async () => { // Initial stream returns a functionCall + MAX_TOKENS. Escalated stream // returns the same (functionCall + MAX_TOKENS). Recovery must NOT run diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index fa07d56ef22..20d195fac74 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -146,13 +146,15 @@ function isSignificantRecoveryOverlap(overlap: string): boolean { /** * Returns true if `text` opens with a Markdown block-level structural marker * (table row, fenced code, ATX heading, blockquote, list item). Leading - * blank/newline chars are skipped because providers often prepend them when - * restarting a block. The marker must appear at the start of a line and be - * followed by the syntactic gap the spec requires (e.g. `# ` not `#abc`), so - * incidental `#` or `|` characters in prose do not count. + * whitespace/newline chars are skipped because providers often prepend them + * when restarting a block — some completion APIs re-emit the suffix with + * leading spaces or tabs, not just newlines. The marker must appear at the + * start of a line and be followed by the syntactic gap the spec requires + * (e.g. `# ` not `#abc`), so incidental `#` or `|` characters in prose do + * not count. */ function startsWithMarkdownStructuralAnchor(text: string): boolean { - const trimmed = text.replace(/^\n+/, ''); + const trimmed = text.replace(/^\s+/, ''); return /^(\|[^\n]*\||#{1,6} |```|>\s|[-*+] |\d+\. )/.test(trimmed); } From 24582d43c07e9c3aee773b2b051fb513c40237ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 12 May 2026 14:04:05 +0800 Subject: [PATCH 04/11] fix(core): unify plain-text predicate and harden recovery delimiter Address two review concerns on geminiChat output-recovery: - `isPlainTextPart` was a near-duplicate of `isValidNonThoughtTextPart` with subtly weaker guards (missing thoughtSignature/inlineData/fileData and using `!== true` vs `!part.thought`). Delegate to the shared predicate so the recovery-merge and consolidated-history paths agree on what counts as plain text. - `buildOutputRecoveryMessage` embedded the previous response inside a `` pseudo-XML block without sanitization. If the model's own truncated output contained the literal closing tag (e.g. while generating XML/HTML examples), the recovery prompt's structure would break. Neutralize literal opening/closing delimiters inside the tail with a zero-width space so the prompt always has exactly one well-formed block; add a regression test that asserts the delimiter pair count stays at 1/1 even when the tail contains a raw ``. Generated with AI Co-authored-by: Qwen-Coder --- packages/core/src/core/geminiChat.test.ts | 66 +++++++++++++++++++++++ packages/core/src/core/geminiChat.ts | 43 ++++++++++++--- 2 files changed, 101 insertions(+), 8 deletions(-) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index b5f8f9f189b..929b7816213 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -2925,6 +2925,72 @@ describe('GeminiChat', async () => { expect(recoveryMessage).not.toContain(head); }); + it('should neutralize a literal previous_response_suffix delimiter inside the tail so the recovery prompt structure stays intact', async () => { + // Guards against a delimiter-collision when the model's own truncated + // output happens to contain the literal closing tag (e.g. while + // generating XML/HTML examples). The recovery prompt must still have + // exactly one well-formed ... block. + const previous = + 'Here is XML: and then more content.'; + const streams = [ + makeStream([makeChunk([{ text: 'discarded initial' }], 'MAX_TOKENS')]), + makeStream([makeChunk([{ text: previous }], 'MAX_TOKENS')]), + makeStream([makeChunk([{ text: ' continuation tail' }], 'STOP')]), + ]; + let callIndex = 0; + const recoveryPayloads: string[] = []; + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async (params) => { + const contents = (params as { contents?: Content[] }).contents ?? []; + const lastTurn = contents[contents.length - 1]; + if (lastTurn && lastTurn.role === 'user') { + const lastPart = lastTurn.parts?.[0]; + if ( + lastPart && + typeof (lastPart as { text?: string }).text === 'string' + ) { + recoveryPayloads.push((lastPart as { text: string }).text); + } + } + return streams[callIndex++]!; + }, + ); + + const stream = await chat.sendMessageStream( + 'gemini-3-pro', + { message: 'write a response that contains my delimiter' }, + 'prompt-recovery-delimiter-collision', + ); + for await (const _event of stream) { + // consume + } + + const recoveryMessage = recoveryPayloads.find((p) => + p.includes('Output token limit hit'), + ); + expect(recoveryMessage).toBeDefined(); + // Exactly one opening and one closing delimiter (the recovery prompt's + // own pair). The model's literal closing tag inside the embedded tail + // must have been neutralized. + const openCount = ( + recoveryMessage!.match(//g) ?? [] + ).length; + const closeCount = ( + recoveryMessage!.match(/<\/previous_response_suffix>/g) ?? [] + ).length; + expect(openCount).toBe(1); + expect(closeCount).toBe(1); + // The block must still parse with a single well-formed match. + const match = recoveryMessage!.match( + /\n([\s\S]*)\n<\/previous_response_suffix>/, + ); + expect(match).not.toBeNull(); + // The block's content should still preserve the model's intent + // (the surrounding prose), just with the literal delimiter neutralized. + expect(match![1]).toContain('Here is XML:'); + expect(match![1]).toContain('and then more content.'); + }); + it('should skip recovery when truncated turn has a functionCall', async () => { // Initial stream returns a functionCall + MAX_TOKENS. Escalated stream // returns the same (functionCall + MAX_TOKENS). Recovery must NOT run diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 20d195fac74..ed8c4b94fe7 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -258,13 +258,13 @@ function getRecoveryContinuationSuffix( function isPlainTextPart(part: Part | undefined): part is Part & { text: string; } { - return ( - part !== undefined && - typeof part.text === 'string' && - part.thought !== true && - part.functionCall === undefined && - part.functionResponse === undefined - ); + // Delegate to the shared predicate used by normal history consolidation + // (see `isValidNonThoughtTextPart` below) so the recovery-merge path and + // the consolidated-history path agree on what counts as "plain text". + // Keeping the type predicate here gives callers `part.text: string` + // narrowing; the underlying checks (thought, thoughtSignature, function*, + // inlineData, fileData) live in one place. + return part !== undefined && isValidNonThoughtTextPart(part); } function getPlainTextFromParts(parts: Part[] | undefined): string { @@ -274,6 +274,32 @@ function getPlainTextFromParts(parts: Part[] | undefined): string { .join(''); } +/** + * Sanitize the previous-response tail before embedding it inside the + * `...` block. + * + * If the model's own truncated output happened to contain the literal + * closing delimiter (e.g. while generating XML/HTML examples), the + * recovery prompt's structure would break — the model would see a + * prematurely closed tag and misinterpret the suffix boundary. We + * neutralize any literal opening/closing delimiter occurrences by + * inserting a zero-width space between the angle bracket and the rest + * of the tag. The text remains visually identical to the model and + * preserves the recovery instruction's intent, but no longer collides + * with our delimiter scan. + */ +function sanitizeRecoverySuffixTail(tail: string): string { + if ( + !tail.includes('') && + !tail.includes('') + ) { + return tail; + } + return tail + .replace(/<\/previous_response_suffix>/g, '<​/previous_response_suffix>') + .replace(//g, '<​previous_response_suffix>'); +} + function buildOutputRecoveryMessage(previousModelTurn: Content | undefined) { const previousText = previousModelTurn?.role === 'model' @@ -283,10 +309,11 @@ function buildOutputRecoveryMessage(previousModelTurn: Content | undefined) { return OUTPUT_RECOVERY_MESSAGE; } - const tail = + const rawTail = previousText.length > OUTPUT_RECOVERY_TAIL_CHARS ? previousText.slice(-OUTPUT_RECOVERY_TAIL_CHARS) : previousText; + const tail = sanitizeRecoverySuffixTail(rawTail); return ( `${OUTPUT_RECOVERY_MESSAGE}\n\n` + From 9eb7f728e937e9f0a647ecb11709890fabeb37ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 12 May 2026 19:18:06 +0800 Subject: [PATCH 05/11] test(core): cover opening-tag branch of sanitizeRecoverySuffixTail The existing prompt-recovery-delimiter-collision test only exercises the closing-tag (``) neutralization path. Add a sibling test that emits a literal opening tag in the previous model turn so the opening-tag replace branch is also covered. Asserts exactly one opening/closing delimiter pair in the recovery message and that the neutralized variant (with zero-width space) appears in the embedded tail. --- packages/core/src/core/geminiChat.test.ts | 69 +++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 929b7816213..f52f91dc719 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -2991,6 +2991,75 @@ describe('GeminiChat', async () => { expect(match![1]).toContain('and then more content.'); }); + it('should neutralize a literal opening previous_response_suffix delimiter inside the tail', async () => { + // Mirrors the closing-tag delimiter-collision test, but verifies the + // opening-tag branch of `sanitizeRecoverySuffixTail`. If the model's own + // output contains a literal `` opening tag, + // the recovery prompt's structural scan must still see exactly one + // well-formed opening/closing pair (its own). + const previous = + 'Tag: was emitted in the output here.'; + const streams = [ + makeStream([makeChunk([{ text: 'discarded initial' }], 'MAX_TOKENS')]), + makeStream([makeChunk([{ text: previous }], 'MAX_TOKENS')]), + makeStream([makeChunk([{ text: ' continuation tail' }], 'STOP')]), + ]; + let callIndex = 0; + const recoveryPayloads: string[] = []; + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async (params) => { + const contents = (params as { contents?: Content[] }).contents ?? []; + const lastTurn = contents[contents.length - 1]; + if (lastTurn && lastTurn.role === 'user') { + const lastPart = lastTurn.parts?.[0]; + if ( + lastPart && + typeof (lastPart as { text?: string }).text === 'string' + ) { + recoveryPayloads.push((lastPart as { text: string }).text); + } + } + return streams[callIndex++]!; + }, + ); + + const stream = await chat.sendMessageStream( + 'gemini-3-pro', + { message: 'write a response that contains my opening delimiter' }, + 'prompt-recovery-delimiter-collision-open', + ); + for await (const _event of stream) { + // consume + } + + const recoveryMessage = recoveryPayloads.find((p) => + p.includes('Output token limit hit'), + ); + expect(recoveryMessage).toBeDefined(); + // Exactly one opening and one closing delimiter — the recovery prompt's + // own pair. The model's literal opening tag inside the embedded tail + // must have been neutralized via a zero-width space. + const openCount = ( + recoveryMessage!.match(//g) ?? [] + ).length; + const closeCount = ( + recoveryMessage!.match(/<\/previous_response_suffix>/g) ?? [] + ).length; + expect(openCount).toBe(1); + expect(closeCount).toBe(1); + // The neutralized variant (with a zero-width space between '<' and the + // tag name) must appear inside the embedded tail. + expect(recoveryMessage).toContain('<​previous_response_suffix>'); + // The block must still parse with a single well-formed match and + // preserve the surrounding prose. + const match = recoveryMessage!.match( + /\n([\s\S]*)\n<\/previous_response_suffix>/, + ); + expect(match).not.toBeNull(); + expect(match![1]).toContain('Tag:'); + expect(match![1]).toContain('was emitted in the output here.'); + }); + it('should skip recovery when truncated turn has a functionCall', async () => { // Initial stream returns a functionCall + MAX_TOKENS. Escalated stream // returns the same (functionCall + MAX_TOKENS). Recovery must NOT run From 3663e8ba196da5a3fc204ad98a3150c3f2617625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 13 May 2026 10:07:28 +0800 Subject: [PATCH 06/11] docs(core): document recovery-dedup constants and tighten contained-prefix anchor Address PR #3966 review polish items from wenshao: - Add JSDoc rationale to each magic constant (OUTPUT_RECOVERY_TAIL_CHARS, RECOVERY_OVERLAP_MAX_SCAN_CHARS, RECOVERY_OVERLAP_MIN_BYTES, RECOVERY_STRUCTURAL_OVERLAP_MIN_BYTES) so future tuning is grounded. - Make the contained-prefix scan symmetric: require the match inside previousTail to begin at index 0 or immediately after a newline, mirroring the structural-anchor check on the continuation side. All occurrences are walked so a benign mid-paragraph hit doesn't shadow a real line-anchored match later in the 400-char tail window. - Document the suffix-anchored overlap loop's O(n^2) bound and the bounded scan cap so the perf characteristic is explicit rather than reverse- engineered. - Explain why appendRecoveryContinuationParts always shifts the first continuation text part even when the dedup suffix is empty (empty suffix means a pure replay that must be discarded). All 68 tests in geminiChat.test.ts still pass; typecheck and lint clean. Generated with AI Co-authored-by: Qwen-Coder --- packages/core/src/core/geminiChat.ts | 76 +++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index ed8c4b94fe7..2e112a9f40e 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -113,9 +113,43 @@ const OUTPUT_RECOVERY_MESSAGE = 'you were doing. Pick up mid-thought if that is where the cut happened. ' + 'Break remaining work into smaller pieces.'; +/** + * Maximum length of the previous-response tail embedded inside the + * `` block of the recovery user-turn. Chosen as a + * pragmatic balance: large enough to give the model enough trailing context to + * resume coherently (covers ~200–400 tokens of prose, or a multi-row Markdown + * table), and small enough to keep the recovery prompt well under any + * provider's input budget even when combined with the rest of history. + */ const OUTPUT_RECOVERY_TAIL_CHARS = 1200; + +/** + * Hard cap on the inner overlap/contained-prefix scan loops. Bounds both the + * suffix-anchored overlap search in {@link getRecoveryContinuationSuffix} and + * the contained-prefix scan in {@link findContainedRecoveryPrefixReplayLength} + * so recovery dedup stays O(min(previous, continuation, 4000)) in iteration + * count instead of unbounded against pathologically large continuations. + */ const RECOVERY_OVERLAP_MAX_SCAN_CHARS = 4000; + +/** + * Minimum byte-length before a plain-text overlap (between previous tail and + * continuation prefix) is considered "significant" enough to dedup. Short + * coincidental matches like `". "`, `"the "`, or `", and "` happen routinely + * across unrelated turns; requiring ≥6 bytes makes accidental matches on + * common short suffixes vanishingly unlikely while still catching meaningful + * replayed phrases. + */ const RECOVERY_OVERLAP_MIN_BYTES = 6; + +/** + * Lower floor for overlaps that contain Markdown structural characters + * (`#`, `|`, backtick, newline). Structural anchors are far less likely to + * collide coincidentally than prose — a 4-byte overlap like `"| a "` or + * `"## "` is almost certainly a replayed block-level marker, so we accept a + * smaller match to catch table/heading replays that the 6-byte prose floor + * would otherwise miss. + */ const RECOVERY_STRUCTURAL_OVERLAP_MIN_BYTES = 4; // Plain-prose substring matches outside the suffix-anchored path are very // prone to false positives on common opener phrases ("In summary, …", "Here is @@ -189,7 +223,7 @@ function findContainedRecoveryPrefixReplayLength( const prefix = continuationText.slice(0, length); if ( byteLength(prefix) >= RECOVERY_CONTAINED_PREFIX_MIN_BYTES && - previousTail.includes(prefix) + previousTailContainsAtLineBoundary(previousTail, prefix) ) { return length; } @@ -198,6 +232,35 @@ function findContainedRecoveryPrefixReplayLength( return 0; } +/** + * Symmetric line-boundary check for the contained-prefix scan: returns true + * iff `prefix` occurs in `previousTail` starting at index 0 or immediately + * after a newline. The structural-anchor check on the continuation side only + * enforces that the *continuation* starts at a Markdown block boundary; + * without this guard, a plain substring match could land mid-paragraph in + * `previousTail` (e.g. inside a code block that contains the literal string + * `"### Heading\nfoo"`) and silently strip legitimate continuation text. All + * occurrences are checked so a benign mid-paragraph hit doesn't shadow a real + * line-anchored replay later in the tail. + */ +function previousTailContainsAtLineBoundary( + previousTail: string, + prefix: string, +): boolean { + let searchFrom = 0; + while (searchFrom <= previousTail.length) { + const matchIndex = previousTail.indexOf(prefix, searchFrom); + if (matchIndex === -1) { + return false; + } + if (matchIndex === 0 || previousTail.charAt(matchIndex - 1) === '\n') { + return true; + } + searchFrom = matchIndex + 1; + } + return false; +} + function getRecoveryContinuationSuffix( previousText: string, continuationText: string, @@ -219,6 +282,13 @@ function getRecoveryContinuationSuffix( RECOVERY_OVERLAP_MAX_SCAN_CHARS, ); + // Worst-case complexity here is O(n²): up to RECOVERY_OVERLAP_MAX_SCAN_CHARS + // iterations, each calling `previousText.endsWith(overlap)` plus + // `byteLength(overlap)` (both O(m)). At the current 4000-char scan cap that + // is ~16M char-ops per recovery event, which is fine because recovery is + // rare and the cap is small. If the cap ever grows materially, this can be + // rewritten with a precomputed Z-array / failure function on + // `continuationText` to scan once instead of repeatedly slicing/comparing. for (let length = maxOverlap; length > 0; length -= 1) { const overlap = continuationText.slice(0, length); if ( @@ -346,6 +416,10 @@ function appendRecoveryContinuationParts( if (suffix.length > 0) { previousLastPart.text += suffix; } + // Always drop the first continuation text part: a non-empty suffix has + // already been appended to previousLastPart above, and an empty suffix + // means the part was a pure replay of the previous tail and should be + // discarded so it does not duplicate into history. nextParts.shift(); } From 08f2abd49e93154935dad78bd1f0959d6816298b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 13 May 2026 14:55:31 +0800 Subject: [PATCH 07/11] fix(core): scan recovery parts for plain-text + CJK-safe overlap floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `appendRecoveryContinuationParts` previously only inspected the boundary parts (last of previous, first of continuation). `processStreamResponse` orders parts as `[thoughtPart?, ...consolidatedHistoryParts]`, so for thinking models the first continuation part is the recovery turn's thought — the plain-text predicate failed on it and the entire dedup block was skipped, leaking the replayed overlap into durable history. Now scan both sides for the plain-text anchor and splice the matched text part rather than shifting the head. Allocate a fresh merged part instead of mutating `mergedParts[i].text` in place so callers caching part references never observe a half-merged turn. Two additional hardening fixes on the overlap path: - `isSignificantRecoveryOverlap` adds a 4-code-point floor on top of the 6-byte floor for prose. CJK characters are 3 UTF-8 bytes each, so the byte-only floor admitted 2-character coincidences like "我们" / "但是" that recur constantly across unrelated Chinese turns. The structural-anchor branch is exempted (those collisions are far rarer and the structural floor already governs them). - `findContainedRecoveryPrefixReplayLength` now strips leading whitespace from the continuation before matching. The structural- anchor check already tolerated leading spaces/tabs (some providers re-emit replayed blocks with extra indentation), but the substring scan still used the un-trimmed prefix and silently failed to match the corresponding `previousTail` occurrence. Adds three regression tests covering: a thinking-model recovery continuation whose first part is a thought, a 2-CJK-character coincidence that must NOT be dedup'd, and a leading-whitespace structural replay that must be dedup'd. Generated with AI Co-authored-by: Qwen-Coder --- packages/core/src/core/geminiChat.test.ts | 129 ++++++++++++++++++++++ packages/core/src/core/geminiChat.ts | 113 +++++++++++++++---- 2 files changed, 218 insertions(+), 24 deletions(-) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index f52f91dc719..aa2c222de50 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -2861,6 +2861,135 @@ describe('GeminiChat', async () => { expect(recoveryMessage).not.toContain(''); }); + it('should dedup recovery continuation when the continuation begins with a thought part', async () => { + // Regression: `processStreamResponse` orders parts as + // `[thoughtPart?, ...consolidatedHistoryParts]`. Before the fix, + // `appendRecoveryContinuationParts` only looked at `nextParts[0]`. For + // thinking models the first part is the recovery turn's thought, the + // plain-text predicate returned false on it, and the entire dedup + // block was skipped — leaking the replayed overlap into history. + const streams = [ + makeStream([makeChunk([{ text: 'discarded initial' }], 'MAX_TOKENS')]), + makeStream([ + makeChunk([{ text: 'Alpha shared recovery suffix' }], 'MAX_TOKENS'), + ]), + makeStream([ + makeChunk( + [ + // Thought first — recovery dedup must scan past it on the + // continuation side instead of giving up. + { text: 'planning the rest', thought: true }, + { text: 'shared recovery suffix and continuation' }, + ], + 'STOP', + ), + ]), + ]; + let callIndex = 0; + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async () => streams[callIndex++]!, + ); + + const stream = await chat.sendMessageStream( + 'gemini-3-pro', + { message: 'write a long essay' }, + 'prompt-recovery-thinking-continuation', + ); + for await (const _event of stream) { + // consume + } + + const history = chat.getHistory(); + const lastEntry = history[history.length - 1]!; + const nonThoughtText = lastEntry.parts + ?.filter((part) => !('thought' in part) || !part.thought) + .map((part) => ('text' in part ? part.text : '')) + .join(''); + expect(nonThoughtText).toBe( + 'Alpha shared recovery suffix and continuation', + ); + }); + + it('should preserve a coincidental 2-character CJK overlap (byte floor insufficient for CJK)', async () => { + // Regression: `RECOVERY_OVERLAP_MIN_BYTES = 6` admits a 2-character + // CJK overlap (each Chinese char is 3 UTF-8 bytes). Two-character + // boundary coincidences such as "我们" / "但是" are extremely common + // across unrelated Chinese sentences. The companion char-floor must + // require ≥4 code points so a 2-char CJK collision does not silently + // strip legitimate continuation. The longer "需要" tail of `previous` + // is meaningful continuation, NOT a replayed suffix of the previous + // turn — the continuation must survive verbatim. + const previous = '在分析数据之前我们'; + const continuation = '我们需要先完成准备工作。'; + const streams = [ + makeStream([makeChunk([{ text: 'discarded initial' }], 'MAX_TOKENS')]), + makeStream([makeChunk([{ text: previous }], 'MAX_TOKENS')]), + makeStream([makeChunk([{ text: continuation }], 'STOP')]), + ]; + let callIndex = 0; + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async () => streams[callIndex++]!, + ); + + const stream = await chat.sendMessageStream( + 'gemini-3-pro', + { message: '帮我分析数据' }, + 'prompt-recovery-cjk-floor', + ); + for await (const _event of stream) { + // consume + } + + const history = chat.getHistory(); + const lastEntry = history[history.length - 1]!; + const text = lastEntry.parts + ?.map((part) => ('text' in part ? part.text : '')) + .join(''); + expect(text).toBe(previous + continuation); + }); + + it('should dedup a replayed structural prefix even when the continuation has leading whitespace', async () => { + // Regression: the structural-anchor check tolerates leading whitespace + // (some providers re-emit the replayed block with extra spaces/tabs), + // but the substring-match loop must also strip that whitespace before + // matching against the previous tail — otherwise the replayed block + // never finds its mirror in `previousTail` and the duplicate leaks + // into history. + const replayedBlock = '### 常用语法速查\n| 语法 | 说明 |'; + const previous = ['Intro', replayedBlock, 'tail that was truncated'].join( + '\n', + ); + // Continuation re-emits the same block prefixed by two spaces. + const continuation = ` ${replayedBlock}\nnew suffix`; + const streams = [ + makeStream([makeChunk([{ text: 'discarded initial' }], 'MAX_TOKENS')]), + makeStream([makeChunk([{ text: previous }], 'MAX_TOKENS')]), + makeStream([makeChunk([{ text: continuation }], 'STOP')]), + ]; + let callIndex = 0; + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async () => streams[callIndex++]!, + ); + + const stream = await chat.sendMessageStream( + 'gemini-3-pro', + { message: 'write a long markdown answer' }, + 'prompt-recovery-leading-whitespace', + ); + for await (const _event of stream) { + // consume + } + + const history = chat.getHistory(); + const lastEntry = history[history.length - 1]!; + const text = lastEntry.parts + ?.map((part) => ('text' in part ? part.text : '')) + .join(''); + // The duplicated `### 常用语法速查\n| 语法 | 说明 |` block must NOT + // appear twice; only the new suffix should follow the previous tail. + expect(text).toBe(`${previous}\nnew suffix`); + }); + it('should truncate the previous_response_suffix to the trailing 1200 chars when the previous turn is longer', async () => { // Covers the slice(-OUTPUT_RECOVERY_TAIL_CHARS) branch in // buildOutputRecoveryMessage. The truncation tail is 1200 chars; we diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 2e112a9f40e..df1bcfaf8bf 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -142,6 +142,21 @@ const RECOVERY_OVERLAP_MAX_SCAN_CHARS = 4000; */ const RECOVERY_OVERLAP_MIN_BYTES = 6; +/** + * Companion floor in *code points* for prose overlaps. The byte floor alone is + * too permissive for CJK: a single Chinese character is 3 UTF-8 bytes, so + * `RECOVERY_OVERLAP_MIN_BYTES = 6` would accept a coincidental 2-character + * overlap like `"我们"` / `"但是"` that is extremely common across unrelated + * Chinese turns. Requiring at least 4 code points in addition to the byte + * floor makes CJK collisions need a 4-character coincidence (~10⁻⁵ when + * each character is independent), without raising the bar for ASCII (4 ASCII + * chars is only 4 bytes — still gated by the 6-byte floor, so ASCII effectively + * needs ≥6 chars). Structural anchors (`#|`\n) are exempted because the + * structural floor already governs them and structural collisions are far + * rarer than prose. + */ +const RECOVERY_OVERLAP_MIN_CHARS = 4; + /** * Lower floor for overlaps that contain Markdown structural characters * (`#`, `|`, backtick, newline). Structural anchors are far less likely to @@ -170,10 +185,19 @@ function byteLength(text: string): number { function isSignificantRecoveryOverlap(overlap: string): boolean { const overlapBytes = byteLength(overlap); const hasMarkdownStructure = /[#|`\n]/.test(overlap); + if ( + hasMarkdownStructure && + overlapBytes >= RECOVERY_STRUCTURAL_OVERLAP_MIN_BYTES + ) { + return true; + } + // Prose overlaps must clear *both* the byte floor (covers ASCII) and the + // code-point floor (covers CJK). Counting code points via the spread + // iterator handles surrogate pairs correctly so emoji do not double-count. + const overlapChars = [...overlap].length; return ( - overlapBytes >= RECOVERY_OVERLAP_MIN_BYTES || - (hasMarkdownStructure && - overlapBytes >= RECOVERY_STRUCTURAL_OVERLAP_MIN_BYTES) + overlapBytes >= RECOVERY_OVERLAP_MIN_BYTES && + overlapChars >= RECOVERY_OVERLAP_MIN_CHARS ); } @@ -203,11 +227,6 @@ function findContainedRecoveryPrefixReplayLength( previousText.length > RECOVERY_CONTAINED_TAIL_LOOKBACK_CHARS ? previousText.slice(-RECOVERY_CONTAINED_TAIL_LOOKBACK_CHARS) : previousText; - const maxPrefix = Math.min( - previousTail.length, - continuationText.length, - RECOVERY_OVERLAP_MAX_SCAN_CHARS, - ); // The contained-prefix path is intended *only* for replayed Markdown blocks // (tables, headings, fenced code) that providers re-emit when resuming after @@ -219,13 +238,30 @@ function findContainedRecoveryPrefixReplayLength( return 0; } + // The anchor check above tolerates leading whitespace because some providers + // re-emit the replayed block with extra leading spaces/tabs. The actual + // substring match must use the *trimmed* continuation, otherwise a + // continuation like `" ### Heading"` would never match a previous tail + // containing `"### Heading"` (no leading whitespace). Track the offset so + // the returned length consumes the leading whitespace too — keeping the + // caller's `continuationText.slice(replayedLength)` invariant intact. + const leadingMatch = continuationText.match(/^\s+/); + const leadingWhitespaceLength = leadingMatch?.[0].length ?? 0; + const trimmedContinuation = continuationText.slice(leadingWhitespaceLength); + + const maxPrefix = Math.min( + previousTail.length, + trimmedContinuation.length, + RECOVERY_OVERLAP_MAX_SCAN_CHARS, + ); + for (let length = maxPrefix; length > 0; length -= 1) { - const prefix = continuationText.slice(0, length); + const prefix = trimmedContinuation.slice(0, length); if ( byteLength(prefix) >= RECOVERY_CONTAINED_PREFIX_MIN_BYTES && previousTailContainsAtLineBoundary(previousTail, prefix) ) { - return length; + return leadingWhitespaceLength + length; } } @@ -402,30 +438,59 @@ function appendRecoveryContinuationParts( ): Part[] { const mergedParts = [...(previousParts ?? [])]; const nextParts = [...(continuationParts ?? [])]; - const previousLastPart = mergedParts[mergedParts.length - 1]; - const continuationFirstPart = nextParts[0]; - if ( - isPlainTextPart(previousLastPart) && - isPlainTextPart(continuationFirstPart) - ) { + // `processStreamResponse` orders parts as + // `[thoughtPart?, ...consolidatedHistoryParts]`, so for thinking models the + // first element of `nextParts` is the recovery turn's thought, not its + // plain-text continuation. Similarly the previous truncated turn may end + // with a non-text part. Scan both sides for the dedup-relevant plain-text + // anchor instead of locking onto the boundary indices, otherwise thinking + // models leak duplicated text into durable history because the dedup block + // gets skipped wholesale. + const previousTextIndex = findLastPlainTextPartIndex(mergedParts); + const continuationTextIndex = nextParts.findIndex(isPlainTextPart); + + if (previousTextIndex >= 0 && continuationTextIndex >= 0) { + const previousTextPart = mergedParts[previousTextIndex] as Part & { + text: string; + }; + const continuationTextPart = nextParts[continuationTextIndex] as Part & { + text: string; + }; const suffix = getRecoveryContinuationSuffix( - previousLastPart.text, - continuationFirstPart.text, + previousTextPart.text, + continuationTextPart.text, ); if (suffix.length > 0) { - previousLastPart.text += suffix; + // Allocate a fresh part rather than mutating in place: `mergedParts` + // shares element references with the caller's history slot, and any + // downstream caller that cached a `part` reference would observe the + // mutation. Cheap allocation; eliminates a fragile invariant. + mergedParts[previousTextIndex] = { + ...previousTextPart, + text: previousTextPart.text + suffix, + }; } - // Always drop the first continuation text part: a non-empty suffix has - // already been appended to previousLastPart above, and an empty suffix - // means the part was a pure replay of the previous tail and should be - // discarded so it does not duplicate into history. - nextParts.shift(); + // Drop the matched continuation text part: a non-empty suffix has already + // been appended above, and an empty suffix means the part was a pure + // replay of the previous tail and should be discarded so it does not + // duplicate into history. Non-text parts in `nextParts` (thoughts, tool + // calls) are preserved. + nextParts.splice(continuationTextIndex, 1); } return [...mergedParts, ...nextParts]; } +function findLastPlainTextPartIndex(parts: Part[]): number { + for (let i = parts.length - 1; i >= 0; i -= 1) { + if (isPlainTextPart(parts[i])) { + return i; + } + } + return -1; +} + /** * Options for retrying on rate-limit throttling errors returned as stream content. * Starts at 60s to match DashScope's per-minute quota window, then backs off From 556b015de7e6716b2e3ea8e6edbdcbc4aecfec18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Thu, 14 May 2026 11:58:52 +0800 Subject: [PATCH 08/11] docs(core): cover recovery-dedup line-boundary + normalization branches Add JSDoc to getRecoveryContinuationSuffix calling out that its empty-input guard is defensive-only (the production caller already filters both sides), and document appendRecoveryContinuationParts' implicit coupling with processStreamResponse's text-part consolidation plus its return-shape convention that coalesceRecoveryPairs relies on for multi-iteration recovery. Add two regression tests: - mid-paragraph match rejection: a structural anchor that appears in the previous tail but is not preceded by a newline must NOT trigger the contained-prefix strip, so legitimate continuation survives verbatim. - newline-normalization branch: when the replayed prefix ends with \n but the previous tail does not and the suffix does not start with \n, the helper must insert a separator so the coalesced text keeps its block boundary. Generated with AI Co-authored-by: Qwen-Coder --- packages/core/src/core/geminiChat.test.ts | 91 +++++++++++++++++++++++ packages/core/src/core/geminiChat.ts | 37 +++++++++ 2 files changed, 128 insertions(+) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index aa2c222de50..82c8ae0d5a3 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -2765,6 +2765,97 @@ describe('GeminiChat', async () => { expect(text).toBe(previous + continuation); }); + it('should preserve continuation when its structural prefix appears mid-paragraph in the previous tail (line-boundary rejection)', async () => { + // Regression: `previousTailContainsAtLineBoundary` must reject matches + // that land mid-paragraph in `previousTail` even when a structural + // anchor at the start of `continuationText` would otherwise pass the + // contained-prefix gate. Without that check, a plain substring match + // (e.g. inside a code block that quotes the literal string + // `"### Heading\n..."` as prose) would silently strip legitimate + // continuation. The only `"### Heading"` occurrence here is preceded + // by `"some text"`, not a newline, so the contained-prefix path MUST + // reject the match and pass the continuation through verbatim. + const previous = + 'some text ### Heading and then more inline prose follows'; + const continuation = + '### Heading\nfresh continuation that should not be stripped'; + const streams = [ + makeStream([makeChunk([{ text: 'discarded initial' }], 'MAX_TOKENS')]), + makeStream([makeChunk([{ text: previous }], 'MAX_TOKENS')]), + makeStream([makeChunk([{ text: continuation }], 'STOP')]), + ]; + let callIndex = 0; + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async () => streams[callIndex++]!, + ); + + const stream = await chat.sendMessageStream( + 'gemini-3-pro', + { message: 'write something with a heading' }, + 'prompt-recovery-line-boundary-reject', + ); + for await (const _event of stream) { + // consume + } + + const history = chat.getHistory(); + const lastEntry = history[history.length - 1]!; + const text = lastEntry.parts + ?.map((part) => ('text' in part ? part.text : '')) + .join(''); + // No silent strip: the full continuation must follow the previous tail + // verbatim because the only `"### Heading"` occurrence in `previous` + // is mid-paragraph (not preceded by `\n`). + expect(text).toBe(previous + continuation); + }); + + it('should insert a newline separator when the replayed prefix ends with newline but previous tail does not', async () => { + // Covers the three-condition normalization branch in + // `getRecoveryContinuationSuffix`: when `replayedPrefix` ends with + // `\n`, `previousText` does NOT, and `suffix` does NOT start with + // `\n`, the helper prepends a `\n` so the coalesced text keeps the + // block-level boundary intact. Without normalization, the suffix + // would butt up against the previous tail with no separator. + // + // Setup: previous tail ends with `### Section` (no trailing newline, + // because the truncation cut the response immediately after the + // heading). Continuation replays `### Section\n` followed by body + // prose. The contained-prefix path strips the replayed heading + + // newline, leaving a suffix that starts with prose. The + // normalization branch must restore a `\n` between `### Section` in + // history and the body prose. + const previous = 'Intro paragraph.\n### Section'; + const replayedBlock = '### Section\n'; + const continuation = `${replayedBlock}body prose continuation`; + const streams = [ + makeStream([makeChunk([{ text: 'discarded initial' }], 'MAX_TOKENS')]), + makeStream([makeChunk([{ text: previous }], 'MAX_TOKENS')]), + makeStream([makeChunk([{ text: continuation }], 'STOP')]), + ]; + let callIndex = 0; + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async () => streams[callIndex++]!, + ); + + const stream = await chat.sendMessageStream( + 'gemini-3-pro', + { message: 'write a structured answer' }, + 'prompt-recovery-newline-normalization', + ); + for await (const _event of stream) { + // consume + } + + const history = chat.getHistory(); + const lastEntry = history[history.length - 1]!; + const text = lastEntry.parts + ?.map((part) => ('text' in part ? part.text : '')) + .join(''); + // No duplicated `### Section`, and the heading is separated from the + // body prose by exactly one newline — the normalization branch fired. + expect(text).toBe(`${previous}\nbody prose continuation`); + }); + it('should drop continuation entirely when it exactly replays the previous tail', async () => { // Covers the full-overlap guard in getRecoveryContinuationSuffix: // previousText.endsWith(continuationText) AND the overlap is significant. diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index df1bcfaf8bf..de8bc880537 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -297,6 +297,20 @@ function previousTailContainsAtLineBoundary( return false; } +/** + * Compute the portion of `continuationText` that should be appended to + * `previousText` after a MAX_TOKENS recovery, stripping any overlap that the + * provider replayed at the boundary. + * + * The empty-input guard (`previousText.length === 0 || + * continuationText.length === 0`) is *defensive only*. The sole production + * caller is {@link appendRecoveryContinuationParts}, which already short- + * circuits when either side has no plain-text part — neither branch of the + * guard can fire from production code. It exists so that anyone reusing this + * helper directly (e.g. a future unit test, a refactor that bypasses the + * caller's filter) cannot crash or read out of bounds. We deliberately leave + * the guard in place rather than rely on the caller's invariant alone. + */ function getRecoveryContinuationSuffix( previousText: string, continuationText: string, @@ -432,6 +446,29 @@ function buildOutputRecoveryMessage(previousModelTurn: Content | undefined) { ); } +/** + * Coalesce a recovery continuation turn into the preceding (truncated) model + * turn, dropping any replayed overlap. + * + * Coupling with `processStreamResponse`. This function assumes the parts + * arrays it receives were produced by {@link GeminiChat.processStreamResponse} + * — i.e. all plain-text streaming chunks from a given turn have been + * consolidated in place into a single text part via `lastPart.text += + * part.text`. The dedup logic only inspects the *last* plain-text part of + * `previousParts` and the *first* plain-text part of `continuationParts`, so + * if a future refactor of `processStreamResponse` ever emits multiple adjacent + * unconsolidated text parts per turn, this function would compare the + * continuation against only the trailing fragment and miss real overlaps with + * earlier fragments. Both functions live in this file precisely so the + * coupling is reviewable in a single window. + * + * Return-value shape. The returned array preserves the *shape convention* of + * `processStreamResponse` output: `[thoughtPart?, ...consolidatedTextParts, + * ...nonTextParts]`. {@link GeminiChat.coalesceRecoveryPairs} relies on this + * by feeding the merged result back as `previousParts` on the next recovery + * iteration; if the shape ever diverges, multi-iteration recovery dedup would + * fail silently against the wrong part. + */ function appendRecoveryContinuationParts( previousParts: Part[] | undefined, continuationParts: Part[] | undefined, From 25ea94e1326415d26417cff3748af1c2a6a8cfed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Thu, 14 May 2026 19:45:03 +0800 Subject: [PATCH 09/11] fix(core): tighten table-row anchor + document structural-class scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tightens `startsWithMarkdownStructuralAnchor`'s table-row alternation so a bare `|expression|` (2 pipes) in technical prose no longer qualifies as a Markdown block anchor — real GFM table rows have ≥3 pipes (≥2 cells) or a separator row like `|---|`. Without this, prose continuation starting with a 2-pipe expression that re-appears at a line boundary mid-tail of the previous response would be silently stripped by the contained-prefix path, contradicting the JSDoc's stated invariant that "incidental `|` characters in prose do not count." Also adds an inline comment to `isSignificantRecoveryOverlap` documenting why the structural-class detection (`[#|`\n]`) is intentionally loose — the 2-byte gap between the 4-byte structural floor and the 6-byte prose floor only matters for 4–5 byte fragments that coincide on both sides of a truncation boundary, which is far rarer than the structural-replay scenarios the lower floor exists to catch. Adds a regression test asserting that a continuation opening with `|expression| ...` is left intact even when it matches at a line boundary in the previous tail. Generated with AI Co-authored-by: Qwen-Coder --- packages/core/src/core/geminiChat.test.ts | 49 +++++++++++++++++++++++ packages/core/src/core/geminiChat.ts | 19 ++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 82c8ae0d5a3..4462c670745 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -2809,6 +2809,55 @@ describe('GeminiChat', async () => { expect(text).toBe(previous + continuation); }); + it('should preserve prose continuation that opens with a single-cell pipe expression matching mid-tail', async () => { + // Regression: `startsWithMarkdownStructuralAnchor` must reject + // single-cell pipe patterns like `|expression|` in technical/math + // prose. A real GFM table row has ≥3 pipes (≥2 cells) or is a + // separator row (`|---|`). Without this tightening, prose continuation + // that coincidentally starts with `|x| more text` and happens to + // re-appear at a line boundary mid-tail of the previous response would + // be silently stripped by the contained-prefix path. + // + // Setup: the suspect prose fragment `|expression| evaluates to a + // scalar value.` appears at a line boundary in the middle of + // `previous`, but `previous` itself ends with a different line — so + // the suffix-anchored scan in `getRecoveryContinuationSuffix` cannot + // match. The only path that could strip the continuation is the + // contained-prefix fallback, which now correctly refuses to anchor on + // a non-GFM single-cell pipe. + const previous = + 'We define the expression as follows:\n|expression| evaluates to a scalar value.\nWe also note other facts here.'; + const continuation = + '|expression| evaluates to a scalar value. Continuing the derivation now.'; + const streams = [ + makeStream([makeChunk([{ text: 'discarded initial' }], 'MAX_TOKENS')]), + makeStream([makeChunk([{ text: previous }], 'MAX_TOKENS')]), + makeStream([makeChunk([{ text: continuation }], 'STOP')]), + ]; + let callIndex = 0; + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async () => streams[callIndex++]!, + ); + + const stream = await chat.sendMessageStream( + 'gemini-3-pro', + { message: 'continue the derivation' }, + 'prompt-recovery-single-cell-pipe-prose', + ); + for await (const _event of stream) { + // consume + } + + const history = chat.getHistory(); + const lastEntry = history[history.length - 1]!; + const text = lastEntry.parts + ?.map((part) => ('text' in part ? part.text : '')) + .join(''); + // No silent strip: the full continuation must follow the previous tail + // verbatim because `|expression|` is prose, not a GFM table row. + expect(text).toBe(previous + continuation); + }); + it('should insert a newline separator when the replayed prefix ends with newline but previous tail does not', async () => { // Covers the three-condition normalization branch in // `getRecoveryContinuationSuffix`: when `replayedPrefix` ends with diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index de8bc880537..425268d17d9 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -184,6 +184,15 @@ function byteLength(text: string): number { function isSignificantRecoveryOverlap(overlap: string): boolean { const overlapBytes = byteLength(overlap); + // This is intentionally a loose "contains any of these chars" check rather + // than a strict Markdown-block-anchor parse: an overlap that picks up `#`, + // `` ` ``, `|`, or `\n` is *probably* a replayed structural marker, and + // the 4-byte structural floor only differs from the 6-byte prose floor by + // a 2-byte window. The worst realistic over-classification (4–5 byte prose + // fragments like `"C#dev"` or `"a|b|c"` slipping through the structural + // path instead of the prose path) still requires that fragment to be + // identical at the truncation boundary on both sides, which is far rarer + // than the structural-replay scenarios this lower floor exists to catch. const hasMarkdownStructure = /[#|`\n]/.test(overlap); if ( hasMarkdownStructure && @@ -210,10 +219,18 @@ function isSignificantRecoveryOverlap(overlap: string): boolean { * start of a line and be followed by the syntactic gap the spec requires * (e.g. `# ` not `#abc`), so incidental `#` or `|` characters in prose do * not count. + * + * The table-row alternation requires either ≥3 pipes (GFM tables need at + * least 2 cells, i.e. 3 separator pipes) *or* a separator row (`|---|`, + * `|:---:|`, etc.). A bare `|expression|` in technical prose has only 2 + * pipes and no separator syntax, so it is intentionally rejected — that + * pattern is not a valid GFM table row anyway. */ function startsWithMarkdownStructuralAnchor(text: string): boolean { const trimmed = text.replace(/^\s+/, ''); - return /^(\|[^\n]*\||#{1,6} |```|>\s|[-*+] |\d+\. )/.test(trimmed); + return /^(\|[^\n]*\|[^\n]*\||\|[\s\-:]+\||#{1,6} |```|>\s|[-*+] |\d+\. )/.test( + trimmed, + ); } function findContainedRecoveryPrefixReplayLength( From 347f6abf993c8f4e27b526a592409a687c589433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Fri, 15 May 2026 17:59:33 +0800 Subject: [PATCH 10/11] test(core): pin recovery thought-before-text ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a regression test for @tanzhenxin's review comment: the existing `prompt-recovery-thinking-continuation` test only asserts joined non- thought text, so a regression where the recovery turn's leading thought ends up *after* the merged text part slips through. The new test explicitly asserts `thoughtIdx < mergedTextIdx` in the final history entry. Thinking-model providers (Gemini 2.5+, Anthropic, OpenAI o-series) validate thought-signature provenance and expect a thought to precede the content it generated; without an ordering assertion the dedup path could silently violate that invariant. The new test fails on the current implementation (`appendRecoveryContinuationParts` appends the leftover leading thought at the end of the part list). Fix follows in a separate commit so the red → green transition is reviewable. Generated with AI Co-authored-by: Qwen-Coder --- packages/core/src/core/geminiChat.test.ts | 56 +++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 4462c670745..4309f718f41 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -3050,6 +3050,62 @@ describe('GeminiChat', async () => { ); }); + it('should keep the recovery thought before the merged text part (thought-signature provenance)', async () => { + // Thinking-model providers (Gemini 2.5+, Anthropic, OpenAI o-series) + // validate thought-signature provenance and expect a thought to + // precede its associated content. The sibling + // `prompt-recovery-thinking-continuation` test only pins the joined + // non-thought text, not structural position, so a regression where + // the recovery turn's leading thought is appended *after* the merged + // text part slips through. Assert the ordering explicitly. + const streams = [ + makeStream([makeChunk([{ text: 'discarded initial' }], 'MAX_TOKENS')]), + makeStream([ + makeChunk([{ text: 'Alpha shared recovery suffix' }], 'MAX_TOKENS'), + ]), + makeStream([ + makeChunk( + [ + { text: 'planning the rest', thought: true }, + { text: 'shared recovery suffix and continuation' }, + ], + 'STOP', + ), + ]), + ]; + let callIndex = 0; + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async () => streams[callIndex++]!, + ); + + const stream = await chat.sendMessageStream( + 'gemini-3-pro', + { message: 'write a long essay' }, + 'prompt-recovery-thinking-continuation-order', + ); + for await (const _event of stream) { + // consume + } + + const history = chat.getHistory(); + const lastEntry = history[history.length - 1]!; + const parts = lastEntry.parts ?? []; + + const thoughtIdx = parts.findIndex( + (part) => 'thought' in part && part.thought === true, + ); + const mergedTextIdx = parts.findIndex( + (part) => + 'text' in part && + typeof part.text === 'string' && + part.text.includes('Alpha shared recovery suffix'), + ); + + expect(thoughtIdx).toBeGreaterThanOrEqual(0); + expect(mergedTextIdx).toBeGreaterThanOrEqual(0); + expect(thoughtIdx).toBeLessThan(mergedTextIdx); + }); + it('should preserve a coincidental 2-character CJK overlap (byte floor insufficient for CJK)', async () => { // Regression: `RECOVERY_OVERLAP_MIN_BYTES = 6` admits a 2-character // CJK overlap (each Chinese char is 3 UTF-8 bytes). Two-character From a916c6886ea0daab8f205e52c52c4a51235169e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Fri, 15 May 2026 17:59:48 +0800 Subject: [PATCH 11/11] fix(core): keep recovery thought before merged text part The recovery dedup path in `appendRecoveryContinuationParts` previously spliced only the matched continuation text part out of `nextParts` and appended the leftover parts (including any leading thought) after the merged text. For thinking-model providers (Gemini 2.5+, Anthropic, OpenAI o-series) that validate thought-signature provenance, this violated the invariant that a thought precedes the content it generated: durable history ended up as `[..., previousText + suffix, recoveryThought]`, with the recovery turn's thought trailing its own text. Hoist any non-text parts that preceded the matched text on the continuation side (typically the recovery turn's thought) into `mergedParts` directly before the merged text part. Trailing non-text parts (tool calls etc.) keep their position via the final concat. Existing `prompt-recovery-thinking-continuation` test still passes because it only asserts joined non-thought text; the new `...-order` test now passes as well. Reported by @tanzhenxin in PR review on commit 556b015. Generated with AI Co-authored-by: Qwen-Coder --- packages/core/src/core/geminiChat.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 425268d17d9..34dfc5e711f 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -528,9 +528,18 @@ function appendRecoveryContinuationParts( // Drop the matched continuation text part: a non-empty suffix has already // been appended above, and an empty suffix means the part was a pure // replay of the previous tail and should be discarded so it does not - // duplicate into history. Non-text parts in `nextParts` (thoughts, tool - // calls) are preserved. - nextParts.splice(continuationTextIndex, 1); + // duplicate into history. Hoist any non-text parts that preceded the + // matched text on the continuation side (typically the recovery turn's + // thought) so they land *before* the merged text part — thinking-model + // providers (Gemini 2.5+, Anthropic, OpenAI o-series) validate + // thought-signature provenance and expect a thought to precede the + // content it generated. Trailing non-text parts (tool calls etc.) keep + // their position via the final `[...mergedParts, ...nextParts]` concat. + const leadingNonTextParts = nextParts.splice(0, continuationTextIndex); + nextParts.shift(); + if (leadingNonTextParts.length > 0) { + mergedParts.splice(previousTextIndex, 0, ...leadingNonTextParts); + } } return [...mergedParts, ...nextParts];