From 5ff5f110af833e4a1985a2c1ecccf8a00ccdaff9 Mon Sep 17 00:00:00 2001 From: cagedbird043 Date: Fri, 19 Jun 2026 21:50:16 +0800 Subject: [PATCH] fix(agent): dynamically fallback from snapcompact to text summary on high CJK/non-ASCII rates --- .../coding-agent/src/session/agent-session.ts | 13 +++++- packages/snapcompact/src/snapcompact.ts | 41 +++++++++++++++++++ packages/snapcompact/test/snapcompact.test.ts | 30 ++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index ed1f23319..4352ec573 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -7645,13 +7645,24 @@ export class AgentSession { // both take the summarizer path (the latter loudly). const wantsSnapcompact = compactionPrep.kind !== "fromHook" && effectiveSettings.strategy === "snapcompact" && !customInstructions; - const snapcompactReady = wantsSnapcompact && this.model.input.includes("image"); + let snapcompactReady = wantsSnapcompact && this.model.input.includes("image"); if (wantsSnapcompact && !snapcompactReady) { this.emitNotice( "warning", `snapcompact needs a vision-capable model (${this.model.id} is text-only) — using an LLM summary instead`, "compaction", ); + } else if (snapcompactReady) { + const text = snapcompact.serializeConversation(convertToLlm(preparation.messagesToSummarize)); + const renderScan = snapcompact.scanRenderability(text); + if (!renderScan.isSafe) { + this.emitNotice( + "warning", + `snapcompact disabled: high non-ASCII rate detected (${(renderScan.unrenderableRatio * 100).toFixed(1)}%). Falling back to an LLM summary to prevent data loss.`, + "compaction", + ); + snapcompactReady = false; + } } let summary: string; diff --git a/packages/snapcompact/src/snapcompact.ts b/packages/snapcompact/src/snapcompact.ts index 5f46e9912..228ecc3ca 100644 --- a/packages/snapcompact/src/snapcompact.ts +++ b/packages/snapcompact/src/snapcompact.ts @@ -989,6 +989,47 @@ export function normalize(text: string): string { return out; } +/** + * Scan text to determine the proportion of graphic characters that will hit the + * `?` fallback during {@link normalize}. Used as a preflight check to abort + * snapcompact and fall back to the text summarizer when the input is heavily + * non-renderable (e.g., CJK). + */ +export function scanRenderability(text: string): { isSafe: boolean; unrenderableRatio: number } { + const stripped = text.includes("\u001b") ? Bun.stripANSI(text) : text; + const collapsed = stripped + .replace(COLLAPSIBLE, run => (LINE_BREAK.test(run) ? NEWLINE_GLYPH : /[^\p{Cf}]/u.test(run) ? " " : "")) + .replace(EDGE_RUNS, ""); + let totalGraphics = 0; + let fallbackCount = 0; + for (const ch of collapsed) { + const cp = ch.codePointAt(0) as number; + if ((cp >= 0x20 && cp < 0x7f) || (cp >= 0xa0 && cp <= 0xff)) { + totalGraphics++; + continue; + } + if (ch === DIM_ON || ch === DIM_OFF || ch === NEWLINE_GLYPH) { + continue; + } + const fold = CHAR_FOLD[ch]; + if (fold !== undefined) { + totalGraphics++; + } else if (cp >= 0x2500 && cp <= 0x257f) { + totalGraphics++; + } else { + const folded = foldToAscii(ch); + if (folded !== undefined) { + totalGraphics++; + } else if (!UNRENDERABLE.test(ch)) { + totalGraphics++; + fallbackCount++; + } + } + } + const unrenderableRatio = totalGraphics > 0 ? fallbackCount / totalGraphics : 0; + return { isSafe: unrenderableRatio <= 0.05, unrenderableRatio }; +} + // ============================================================================ // Stopword dimming // ============================================================================ diff --git a/packages/snapcompact/test/snapcompact.test.ts b/packages/snapcompact/test/snapcompact.test.ts index b7dbe8ffa..12686aa10 100644 --- a/packages/snapcompact/test/snapcompact.test.ts +++ b/packages/snapcompact/test/snapcompact.test.ts @@ -67,6 +67,36 @@ function makePreparation( }; } +describe("scanRenderability", () => { + it("considers pure ASCII text safe", () => { + const res = snapcompact.scanRenderability("function hello() { return 'world'; }"); + expect(res.isSafe).toBe(true); + expect(res.unrenderableRatio).toBe(0); + }); + + it("considers Latin-1 text safe", () => { + const res = snapcompact.scanRenderability("café résumé naïve"); + expect(res.isSafe).toBe(true); + expect(res.unrenderableRatio).toBe(0); + }); + + it("detects high unrenderable rates in CJK text and marks it unsafe", () => { + // Mix of ASCII and CJK: "const a = '你好世界';" + // Total graphics: ~15. Unrenderable: 4. Ratio > 5% (0.05). + const res = snapcompact.scanRenderability("const a = '你好世界';"); + expect(res.isSafe).toBe(false); + expect(res.unrenderableRatio).toBeGreaterThan(0.05); + }); + + it("ignores whitespace, ANSI, and zero-width markers in ratio calculations", () => { + // \u001b[31m is ANSI. + // \u000e \u000f are DIM markers. + const res = snapcompact.scanRenderability("\u001b[31mhello \u000e \u000f \n\t world\u001b[0m"); + expect(res.isSafe).toBe(true); + expect(res.unrenderableRatio).toBe(0); + }); +}); + describe("computeFileLists", () => { it("drops scheme:// URLs from legacy fileOps before rendering ", () => { const fileOps = snapcompact.createFileOps();