From 250a05f544ae397bb91af5fc83bf408cfe1c554d Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Wed, 24 Jun 2026 07:53:14 +0200 Subject: [PATCH] fix(ai-chat): strip unclosed tag leaking into compaction summary (#9750) * fix(ai-chat): strip unclosed tag leaking into compaction summary Co-Authored-By: Claude Opus 4.8 (1M context) * fix(ai-chat): strip analysis before matching summary to avoid scratchpad leak Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../copilot/chat/compactionPrompt.test.ts | 31 +++++++++++++++++++ .../copilot/chat/compactionPrompt.ts | 14 +++++++++ 2 files changed, 45 insertions(+) diff --git a/frontend/src/lib/components/copilot/chat/compactionPrompt.test.ts b/frontend/src/lib/components/copilot/chat/compactionPrompt.test.ts index 2161182594..b91aacfa5d 100644 --- a/frontend/src/lib/components/copilot/chat/compactionPrompt.test.ts +++ b/frontend/src/lib/components/copilot/chat/compactionPrompt.test.ts @@ -34,6 +34,37 @@ chronological thinking the model should not keep expect(formatCompactSummary(raw)).toBe('a\n\nb') }) + it('keeps the summary content when has no closing tag', () => { + const raw = '\n1. Primary Request and Intent: build the thing\n2. Pending Tasks: none' + const formatted = formatCompactSummary(raw) + expect(formatted).not.toContain('') + expect(formatted).toContain('Primary Request and Intent: build the thing') + expect(formatted).toContain('Pending Tasks: none') + }) + + it('drops the analysis scratchpad even when is left unclosed', () => { + const raw = + '\nchronological thinking the model should not keep\n\n\nthe real summary' + const formatted = formatCompactSummary(raw) + expect(formatted).not.toContain('chronological thinking') + expect(formatted).not.toContain('') + expect(formatted).not.toContain('') + expect(formatted).toBe('the real summary') + }) + + it('strips an orphaned closing summary tag', () => { + expect(formatCompactSummary('plain summary')).toBe('plain summary') + }) + + it('does not leak analysis scratchpad that mentions a literal tag', () => { + const raw = `scratchpad mentions before output +real summary` + const formatted = formatCompactSummary(raw) + expect(formatted).toBe('real summary') + expect(formatted).not.toContain('scratchpad') + expect(formatted).not.toContain('before output') + }) + it('strips every analysis block, not just the first, when the summary is untagged', () => { const raw = 'first\nkept one\nsecond\nkept two' const formatted = formatCompactSummary(raw) diff --git a/frontend/src/lib/components/copilot/chat/compactionPrompt.ts b/frontend/src/lib/components/copilot/chat/compactionPrompt.ts index 010a8cf8b8..f785682dac 100644 --- a/frontend/src/lib/components/copilot/chat/compactionPrompt.ts +++ b/frontend/src/lib/components/copilot/chat/compactionPrompt.ts @@ -100,13 +100,27 @@ export function getCompactionSummaryPrompt(): string { * well-formed-but-untagged summary is still usable. */ export function formatCompactSummary(raw: string): string { + // Strip the analysis scratchpad first: it precedes the summary and may itself + // mention / tokens that would otherwise be mistaken for the + // real summary boundary. let formatted = raw.replace(/[\s\S]*?<\/analysis>/gi, '') const summaryMatch = formatted.match(/([\s\S]*?)<\/summary>/i) if (summaryMatch) { formatted = (summaryMatch[1] ?? '').trim() + } else { + // A truncated response or a weaker model sometimes opens without + // closing it. The text after the opener is still the summary, so keep it + // rather than leak the bare tag. + const openIdx = formatted.search(//i) + if (openIdx !== -1) { + formatted = formatted.slice(openIdx) + } } + // An orphaned opener or closer left by either branch must never reach the user. + formatted = formatted.replace(/<\/?(?:analysis|summary)>/gi, '') + // Collapse the blank-line runs left behind by stripping the analysis block. return formatted.replace(/\n{3,}/g, '\n\n').trim() }