From aa9709c47fe100c8a2b6973d72508eab3ab64b5b Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 21 Jun 2026 00:08:12 +0200 Subject: [PATCH] feat(coding-agent): added snapcompact safety checks and UI integration - Added validation to scan for non-ASCII characters before performing snap-compaction, falling back to LLM-based summarization if the unrenderable ratio is too high. - Updated event handling and status reporting to explicitly support snapcompact actions, including specific error warnings and cancellation states in the UI. - Updated session logic to default to snapcompact strategy when auto-compaction is enabled. --- .../src/modes/controllers/event-controller.ts | 11 +++- .../coding-agent/src/session/agent-session.ts | 58 +++++++++++++------ .../test/acp-lazy-startup.test.ts | 2 +- .../test/agent-session-handoff.test.ts | 4 +- 4 files changed, 52 insertions(+), 23 deletions(-) diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index d5fb1791e..e1ced652e 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -952,7 +952,9 @@ export class EventController { ? "Auto-handoff" : event.action === "shake" ? "Auto-shake" - : "Auto context-full maintenance"; + : event.action === "snapcompact" + ? "Auto-snapcompact" + : "Auto context-full maintenance"; this.ctx.autoCompactionLoader = new Loader( this.ctx.ui, spinner => theme.fg("accent", spinner), @@ -973,13 +975,16 @@ export class EventController { } const isHandoffAction = event.action === "handoff"; const isShakeAction = event.action === "shake"; + const isSnapcompactAction = event.action === "snapcompact"; if (event.aborted) { this.ctx.showStatus( isHandoffAction ? "Auto-handoff cancelled" : isShakeAction ? "Auto-shake cancelled" - : "Auto context-full maintenance cancelled", + : isSnapcompactAction + ? "Auto-snapcompact cancelled" + : "Auto context-full maintenance cancelled", ); } else if (isShakeAction) { // Shake produces no CompactionResult; rebuild on success, suppress benign skips. @@ -1018,6 +1023,8 @@ export class EventController { } else if (event.skipped) { // Benign skip: no model selected, no candidate models available, or nothing // to compact yet. Not a failure — suppress the warning. + } else if (isSnapcompactAction) { + this.ctx.showWarning("Auto-snapcompact maintenance failed; continuing without maintenance"); } else { this.ctx.showWarning("Auto context-full maintenance failed; continuing without maintenance"); } diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 09b01c5ab..3d2221ee5 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -159,7 +159,7 @@ import { import { MODEL_ROLE_IDS, MODEL_ROLES } from "../config/model-roles"; import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates"; import type { Settings, SkillsSettings } from "../config/settings"; -import { onAppendOnlyModeChanged } from "../config/settings"; +import { getDefault, onAppendOnlyModeChanged } from "../config/settings"; import { RawSseDebugBuffer } from "../debug/raw-sse-buffer"; import { loadCapability } from "../discovery"; import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit"; @@ -9654,29 +9654,50 @@ export class AgentSession { // summarizer path. let snapcompactResult: snapcompact.CompactionResult | undefined; if (action === "snapcompact" && compactionPrep.kind !== "fromHook") { - snapcompactResult = await snapcompact.compact(preparation, { - convertToLlm, - model: this.model, - }); - const ctxWindow = this.model?.contextWindow ?? 0; - const budget = - ctxWindow > 0 - ? ctxWindow - effectiveReserveTokens(ctxWindow, compactionSettings) - : Number.POSITIVE_INFINITY; - const projected = this.#projectSnapcompactContextTokens(preparation, snapcompactResult); - if (projected > budget) { - logger.warn("Snapcompact still overflows the window; falling back to an LLM summary", { + const text = snapcompact.serializeConversation( + convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)), + ); + const renderScan = snapcompact.scanRenderability(text); + if (renderScan.isSafe) { + snapcompactResult = await snapcompact.compact(preparation, { + convertToLlm, + model: this.model, + shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")), + }); + } else { + logger.warn("Snapcompact disabled: high non-ASCII rate detected; falling back to an LLM summary", { model: this.model?.id, - projected, - budget, + unrenderableRatio: renderScan.unrenderableRatio, }); this.emitNotice( "warning", - "snapcompact could not bring the context under the limit — using an LLM summary instead", + `snapcompact disabled: high non-ASCII rate detected (${(renderScan.unrenderableRatio * 100).toFixed(1)}%). Falling back to an LLM summary to prevent data loss.`, "compaction", ); action = "context-full"; - snapcompactResult = undefined; + } + + if (snapcompactResult) { + const ctxWindow = this.model?.contextWindow ?? 0; + const budget = + ctxWindow > 0 + ? ctxWindow - effectiveReserveTokens(ctxWindow, compactionSettings) + : Number.POSITIVE_INFINITY; + const projected = this.#projectSnapcompactContextTokens(preparation, snapcompactResult); + if (projected > budget) { + logger.warn("Snapcompact still overflows the window; falling back to an LLM summary", { + model: this.model?.id, + projected, + budget, + }); + this.emitNotice( + "warning", + "snapcompact could not bring the context under the limit — using an LLM summary instead", + "compaction", + ); + action = "context-full"; + snapcompactResult = undefined; + } } } @@ -10081,7 +10102,8 @@ export class AgentSession { result: undefined, aborted: false, willRetry: false, - errorMessage: `Auto-shake failed: ${message}`, + errorMessage: message, + skipped: false, }); // Overflow still needs recovery even if shake threw. return reason === "overflow" ? "fallback" : COMPACTION_CHECK_NONE; diff --git a/packages/coding-agent/test/acp-lazy-startup.test.ts b/packages/coding-agent/test/acp-lazy-startup.test.ts index 9ad225bec..0d4cece67 100644 --- a/packages/coding-agent/test/acp-lazy-startup.test.ts +++ b/packages/coding-agent/test/acp-lazy-startup.test.ts @@ -323,7 +323,7 @@ describe("ACP lazy startup", () => { enabled: false, subagents: false, syncBacklog: "off", - immuneTurns: 1, + immuneTurns: 3, }); } }); diff --git a/packages/coding-agent/test/agent-session-handoff.test.ts b/packages/coding-agent/test/agent-session-handoff.test.ts index 1b9e27733..02c78bef2 100644 --- a/packages/coding-agent/test/agent-session-handoff.test.ts +++ b/packages/coding-agent/test/agent-session-handoff.test.ts @@ -871,13 +871,13 @@ describe("AgentSession handoff", () => { expect(events.filter(event => event.type === "auto_compaction_end")).toHaveLength(0); }); - it("restores context-full strategy when enabling auto-compaction from off strategy", () => { + it("restores default strategy when enabling auto-compaction from off strategy", () => { session.settings.set("compaction.enabled", true); session.settings.set("compaction.strategy", "off"); expect(session.autoCompactionEnabled).toBe(false); session.setAutoCompactionEnabled(true); - expect(session.settings.get("compaction.strategy")).toBe("context-full"); + expect(session.settings.get("compaction.strategy")).toBe("snapcompact"); expect(session.autoCompactionEnabled).toBe(true); });