fix(ai-chat): strip unclosed <summary> tag leaking into compaction summary (#9750)

* fix(ai-chat): strip unclosed <summary> tag leaking into compaction summary

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ai-chat): strip analysis before matching summary to avoid scratchpad leak

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
centdix
2026-06-24 07:53:14 +02:00
committed by GitHub
parent 043c2c05b7
commit 250a05f544
2 changed files with 45 additions and 0 deletions
@@ -34,6 +34,37 @@ chronological thinking the model should not keep
expect(formatCompactSummary(raw)).toBe('a\n\nb')
})
it('keeps the summary content when <summary> has no closing tag', () => {
const raw = '<summary>\n1. Primary Request and Intent: build the thing\n2. Pending Tasks: none'
const formatted = formatCompactSummary(raw)
expect(formatted).not.toContain('<summary>')
expect(formatted).toContain('Primary Request and Intent: build the thing')
expect(formatted).toContain('Pending Tasks: none')
})
it('drops the analysis scratchpad even when <summary> is left unclosed', () => {
const raw =
'<analysis>\nchronological thinking the model should not keep\n</analysis>\n<summary>\nthe real summary'
const formatted = formatCompactSummary(raw)
expect(formatted).not.toContain('chronological thinking')
expect(formatted).not.toContain('<analysis>')
expect(formatted).not.toContain('<summary>')
expect(formatted).toBe('the real summary')
})
it('strips an orphaned closing summary tag', () => {
expect(formatCompactSummary('plain summary</summary>')).toBe('plain summary')
})
it('does not leak analysis scratchpad that mentions a literal <summary> tag', () => {
const raw = `<analysis>scratchpad mentions <summary> before output</analysis>
<summary>real summary</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 = '<analysis>first</analysis>\nkept one\n<analysis>second</analysis>\nkept two'
const formatted = formatCompactSummary(raw)
@@ -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 <summary>/<analysis> tokens that would otherwise be mistaken for the
// real summary boundary.
let formatted = raw.replace(/<analysis>[\s\S]*?<\/analysis>/gi, '')
const summaryMatch = formatted.match(/<summary>([\s\S]*?)<\/summary>/i)
if (summaryMatch) {
formatted = (summaryMatch[1] ?? '').trim()
} else {
// A truncated response or a weaker model sometimes opens <summary> 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(/<summary>/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()
}