mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
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:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user