From 5d4936b269de32c9036468387d97845e27a1b957 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 7 Sep 2026 12:48:33 +0900 Subject: [PATCH 1/3] test(coding-agent): cover resume admission recovery --- .../suite/resume-admission-recovery.test.ts | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 packages/coding-agent/test/suite/resume-admission-recovery.test.ts diff --git a/packages/coding-agent/test/suite/resume-admission-recovery.test.ts b/packages/coding-agent/test/suite/resume-admission-recovery.test.ts new file mode 100644 index 000000000..5cfad2c76 --- /dev/null +++ b/packages/coding-agent/test/suite/resume-admission-recovery.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { createAgentSession } from "../../src/core/sdk.ts"; +import type { CompactionEntry } from "../../src/core/session-manager.ts"; +import { createHarness, type Harness } from "./harness.ts"; + +function seedOverBudget(harness: Harness, tokens: number): void { + const timestamp = Date.now(); + const model = harness.getModel(); + harness.sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "! ".repeat(Math.max(100, tokens * 2)) }], + timestamp: timestamp - 3, + }); + harness.sessionManager.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "earlier answer" }], + api: model.api, + provider: model.provider, + model: model.id, + stopReason: "stop", + usage: { + input: 2_000, + output: 1_000, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 3_000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: timestamp - 2, + }); + harness.sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "continue" }], + timestamp: timestamp - 1, + }); + harness.sessionManager.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "still working" }], + api: model.api, + provider: model.provider, + model: model.id, + stopReason: "stop", + usage: { + input: tokens - 1_000, + output: 1_000, + cacheRead: 0, + cacheWrite: 0, + totalTokens: tokens, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp, + }); + harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages; +} + +describe("resume admission recovery", () => { + it("opens an over-budget restored session with a synthetic summary and a pending recovery compaction", async () => { + const harness = await createHarness({ models: [{ id: "tiny", contextWindow: 64_000, maxTokens: 4_000 }] }); + seedOverBudget(harness, 60_000); + // given a restored session whose messages exceed the window; open it + const result = await createAgentSession({ + cwd: harness.tempDir, + sessionManager: harness.sessionManager, + settingsManager: harness.settingsManager, + model: harness.getModel("tiny"), + }); + // the admission must not throw; the session opens + expect(result.session).toBeDefined(); + // the restored context carries a synthetic summary, not the raw oversized prefix + const messages = result.session.agent.state.messages; + const summary = messages.find((m) => m.role === "compactionSummary"); + expect(summary).toBeDefined(); + const admissionCompactions = harness.sessionManager + .getEntries() + .filter( + (entry): entry is CompactionEntry<{ origin?: string }> => + entry.type === "compaction" && + (entry.details as { origin?: string } | undefined)?.origin === "resume-admission", + ); + expect(admissionCompactions).toHaveLength(1); + const admission = admissionCompactions[0]; + if (admission?.type !== "compaction") throw new Error("missing admission compaction"); + expect(harness.sessionManager.getEntry(admission.firstKeptEntryId)).toBeDefined(); + // the verbatim log is untouched: the full prefix is still present + expect( + harness.sessionManager + .getEntries() + .some( + (entry) => + entry.type === "message" && + entry.message.role === "user" && + JSON.stringify(entry.message).includes("! ! !"), + ), + ).toBe(true); + // first turn must run recovery compaction before the prompt + expect(result.session.hasPendingResumeRecovery()).toBe(true); + harness.cleanup(); + }); + + it("a budget-fitting restored session opens unchanged (no synthetic summary, no pending recovery)", async () => { + const harness = await createHarness({ models: [{ id: "roomy", contextWindow: 128_000, maxTokens: 4_000 }] }); + seedOverBudget(harness, 20_000); + const result = await createAgentSession({ + cwd: harness.tempDir, + sessionManager: harness.sessionManager, + settingsManager: harness.settingsManager, + model: harness.getModel("roomy"), + }); + const messages = result.session.agent.state.messages; + expect(messages.find((m) => m.role === "compactionSummary")).toBeUndefined(); + expect(result.session.hasPendingResumeRecovery()).toBe(false); + harness.cleanup(); + }); +}); From c0602c43385047921707cc1778fe7951a4fdb672 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 7 Sep 2026 12:48:33 +0900 Subject: [PATCH 2/3] fix(coding-agent): recover over-budget resumed sessions --- .../coding-agent/src/core/agent-session.ts | 36 +++++- .../src/core/compaction/compaction.ts | 2 +- packages/coding-agent/src/core/sdk.ts | 107 +++++++++++++++++- packages/coding-agent/src/core/session-log.ts | 2 +- .../coding-agent/src/core/session-manager.ts | 3 +- 5 files changed, 140 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index c76b82621..a8cfc23d6 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1072,6 +1072,7 @@ export class AgentSession { private _autoCompactionSessionOverride: boolean | undefined; private _compactionSkippedTooSmall = false; private _requiredCompactionAdmissionError: RequiredCompactionError | undefined; + private _resumeRecoveryPending = false; // Preserve provenance across agent-core's conversion of our admission error // into an assistant error message. Matching provider text alone is not proof // that AgentSession initiated required-compaction recovery. @@ -1287,6 +1288,27 @@ export class AgentSession { }); } + /** Whether resume admission reduced the context and the first prompt still needs recovery compaction. */ + hasPendingResumeRecovery(): boolean { + return this._resumeRecoveryPending; + } + + /** Arm the existing required-compaction route after resume admission reduced context. */ + armResumeRecovery(): void { + this._resumeRecoveryPending = true; + } + + /** Record a structured resume-admission diagnostic in the session log. */ + logResumeAdmissionSlice(data: { + droppedEntries: number; + tokensBefore: number; + tokensAfter: number; + model: string; + shortfall: number; + }): void { + this._sessionLogger.info("resume_admission_slice", data); + } + get modelRuntime(): ModelRuntime { return this._modelRuntime; } @@ -3831,7 +3853,19 @@ export class AgentSession { } // The user's new prompt is sent below, so do not call agent.continue() here. - await this._enforceCompactionBeforeProvider(this._findLastAssistantMessage(), false, "pre_prompt"); + if (this._resumeRecoveryPending) { + const compacted = await this._runPrePromptCompaction( + this._findLastAssistantMessage(), + false, + "pre_prompt", + false, + true, + ); + if (!compacted) throw new RequiredCompactionError(); + this._resumeRecoveryPending = false; + } else { + await this._enforceCompactionBeforeProvider(this._findLastAssistantMessage(), false, "pre_prompt"); + } // Build messages array (custom message if any, then user message) messages = []; diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index 2915145d2..3198af232 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -1019,7 +1019,7 @@ export function prepareCompaction( forceProgress = false, allowSummaryOnly = false, ): CompactionPreparation | undefined { - if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") { + if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction" && !allowSummaryOnly) { return undefined; } diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 91c13eca8..805c0fb7e 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -10,6 +10,10 @@ import { AuthStorage } from "./auth-storage.ts"; import { estimateTokens } from "./compaction/compaction.ts"; import { createSessionCursorExecBridge } from "./cursor-exec-bridge-session.ts"; import { DEFAULT_THINKING_LEVEL } from "./defaults.ts"; +import { + ModelUsabilityBudgetError, + projectModelUsabilityBudget, +} from "./extensions/builtin/compaction/model-usability-budget.ts"; import type { ServiceTier } from "./extensions/builtin/service-tier.ts"; import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.ts"; import { convertToLlmForTransport, TRANSPORT_IMAGE_BUDGET_BYTES } from "./messages.ts"; @@ -25,7 +29,14 @@ import { ModelRuntime } from "./model-runtime.ts"; import { mergeProviderAttributionHeaders } from "./provider-attribution.ts"; import type { ResourceLoader } from "./resource-loader.ts"; import { DefaultResourceLoader } from "./resource-loader.ts"; -import { getDefaultSessionDir, SessionManager } from "./session-manager.ts"; +import { + buildSessionContext, + type CompactionEntry, + getDefaultSessionDir, + type SessionEntry, + SessionManager, + sessionEntryToContextMessages, +} from "./session-manager.ts"; import { SettingsManager } from "./settings-manager.ts"; import { getSupportedThinkingLevels } from "./thinking-levels.ts"; import { time } from "./timings.ts"; @@ -191,6 +202,77 @@ function getDefaultAgentDir(): string { return getAgentDir(); } +const RESUME_ADMISSION_SUMMARY = [ + "[Resume admission recovery checkpoint]", + "The restored transcript exceeded the model admission budget, so older context was reduced without an LLM request.", + "Continue from the retained messages. Treat omitted transcript details as unknown.", +].join("\n"); + +function isResumeAdmissionCutPoint(entry: SessionEntry): boolean { + return sessionEntryToContextMessages(entry).some((message) => + ["user", "assistant", "bashExecution", "custom", "branchSummary", "compactionSummary"].includes(message.role), + ); +} + +function admitResumedSession( + session: AgentSession, + model: Model, + originalProjection: ReturnType, +): { tokensBefore: number; tokensAfter: number; droppedEntries: number } | undefined { + const branch = session.sessionManager.getBranch(); + const cutPoints = branch.flatMap((entry, index) => (isResumeAdmissionCutPoint(entry) ? [index] : [])); + if (cutPoints.length === 0) return undefined; + const previewId = "__senpi_resume_admission_preview__"; + const parentId = branch.at(-1)?.id ?? null; + const settings = session.settingsManager.getCompactionSettings(); + const tokensBefore = session.agent.state.messages.reduce((total, message) => total + estimateTokens(message), 0); + for (const cutIndex of cutPoints) { + const firstKeptEntryId = branch[cutIndex]?.id; + if (!firstKeptEntryId) continue; + const preview: CompactionEntry = { + type: "compaction", + id: previewId, + parentId, + timestamp: new Date(0).toISOString(), + summary: RESUME_ADMISSION_SUMMARY, + firstKeptEntryId, + tokensBefore, + fromHook: false, + }; + const projectedMessages = buildSessionContext([...branch, preview], previewId).messages; + const tokensAfter = projectedMessages.reduce((total, message) => total + estimateTokens(message), 0); + const projection = projectModelUsabilityBudget({ + model, + systemPrompt: session.agent.state.systemPrompt, + tools: session.agent.state.tools, + liveContextTokens: tokensAfter, + compaction: settings, + includeSpeculationLead: false, + admission: "resume", + }); + if (!projection.usable) continue; + session.sessionManager.appendCompaction( + RESUME_ADMISSION_SUMMARY, + firstKeptEntryId, + tokensBefore, + { schema: "senpi.compaction.resume-admission.v1", origin: "resume-admission" }, + false, + undefined, + true, + ); + session.agent.state.messages = session.sessionManager.buildSessionContext().messages; + session.logResumeAdmissionSlice({ + droppedEntries: cutIndex, + tokensBefore: originalProjection.liveContextTokens, + tokensAfter, + model: originalProjection.model, + shortfall: originalProjection.shortfallTokens, + }); + return { tokensBefore, tokensAfter, droppedEntries: cutIndex }; + } + return undefined; +} + /** * Create an AgentSession with the specified options. * @@ -524,11 +606,24 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const liveContextTokens = hasExistingSession ? existingSession.messages.reduce((total, message) => total + estimateTokens(message), 0) : 0; - session.assertModelUsable( - undefined, - liveContextTokens, - hasExistingSession ? { includeSpeculationLead: false, admission: "resume" } : { admission: "start" }, - ); + let resumeRecoveryPending = false; + try { + session.assertModelUsable( + undefined, + liveContextTokens, + hasExistingSession ? { includeSpeculationLead: false, admission: "resume" } : { admission: "start" }, + ); + } catch (error) { + if (!hasExistingSession || !(error instanceof ModelUsabilityBudgetError)) throw error; + const admitted = admitResumedSession(session, model as Model, error.projection); + if (!admitted) throw error; + resumeRecoveryPending = true; + session.assertModelUsable(undefined, admitted.tokensAfter, { + includeSpeculationLead: false, + admission: "resume", + }); + } + if (resumeRecoveryPending) session.armResumeRecovery(); cursorBridgeSessionRef.current = session; const extensionsResult = resourceLoader.getExtensions(); diff --git a/packages/coding-agent/src/core/session-log.ts b/packages/coding-agent/src/core/session-log.ts index 383fd44f0..1fc90a945 100644 --- a/packages/coding-agent/src/core/session-log.ts +++ b/packages/coding-agent/src/core/session-log.ts @@ -9,7 +9,7 @@ const DEBUG_PREFIX = `[${APP_NAME}-session]`; const BLOCKED_KEY = /^(?:__proto__|constructor|prototype|headers?|env(?:ironment)?|authorization|credential(?:s)?|password|secret|token|api_?key|client_?secret)$/i; const ALLOWED_DATA_KEY = - /^(?:action|attemptId|disposition|stage|error|mode|count|willRetry|deferAdmission|delivered|restored|cause|accepted|skipped|rejectionCause|reason|durationMs|kind|retryable|phase|op|bytes|generation|requestId|tokens|tokensBefore|tokensAfter|contextWindow|attempt|aborted)$/; + /^(?:action|attemptId|disposition|stage|error|mode|count|willRetry|deferAdmission|delivered|restored|cause|accepted|skipped|rejectionCause|reason|durationMs|kind|retryable|phase|op|bytes|generation|requestId|tokens|tokensBefore|tokensAfter|contextWindow|attempt|aborted|droppedEntries|model|shortfall)$/; const SENSITIVE_TEXT = /((?:authorization\s*[:=]\s*(?:bearer|basic)\s+)|(?:bearer\s+)|(?:[?&](?:api[_-]?key|token|secret|password|auth(?:orization)?)=))[^\s&,"'}\]]+/gi; diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index e769ec572..707338313 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1267,6 +1267,7 @@ export class SessionManager { details?: T, fromHook?: boolean, usage?: Usage, + preserveTranscript = false, ): string { const entry: CompactionEntry = { type: "compaction", @@ -1281,7 +1282,7 @@ export class SessionManager { fromHook, }; this._appendEntry(entry); - this._trimMirrorAfterCompaction(entry); + if (!preserveTranscript) this._trimMirrorAfterCompaction(entry); return entry.id; } From 962d6313dc2ae55a470dfc7c26e2d5c4a36b2215 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 7 Sep 2026 12:48:33 +0900 Subject: [PATCH 3/3] docs(compaction): record resume admission recovery --- .../extensions/builtin/compaction/changes.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md b/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md index 8ec10e71f..024d02c60 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md @@ -1,5 +1,41 @@ # changes.md — builtin compaction policy +## Slice an over-budget restored session and force first-turn recovery compaction (2026-09-07) + +### What changed + +- `createAgentSession` resume admission now slices a restored transcript that exceeds the + usability budget: a synthetic deterministic `resume-admission` compaction entry replaces the + dropped prefix (the JSONL transcript remains verbatim), the admission passes, and the session opens. +- The summary is an equivalent minimal no-LLM checkpoint rather than the extension fallback: + admission occurs before extensions are wired, while the cut-point selection preserves atomic + tool-call/result pairs. +- `AgentSession` arms the existing required-compaction route, so the first turn after such a + resume runs a real recovery compaction before the user's prompt reaches the provider. +- Admission emits one structured `resume_admission_slice` session-log line with dropped entries, + token counts before/after, model, and shortfall. + + +### Why + +- A session that outgrew its model's window (live transcript > window - output/compaction + reserve - margins) could never open again: `assertModelUsable` threw before any compaction + existed, and the desktop retried the identical doomed `thread.turn.start` (live miss: + thread a9a178a0, 922k window / 963,249-token requirement / 41,249 shortfall, 2026-09-07). +- The fix keeps every transcript byte on disk while only the *context* is reduced, and the + recovery compaction on the first turn restores a usable budget before the real prompt runs. + +### Why an extension could not handle it + +- Admission runs inside `createAgentSession` before the compaction extension is wired, and + the pending-recovery marker must travel through the session constructor - no external hook + can intercept either. + +### Expected merge conflict zones + +- `sdk.ts` resume admission block; `agent-session.ts` constructor options + first-turn + required-compaction reason; `model-usability-budget.test.ts`. + ## Omit speculation lead from resumed-session admission (2026-09-03) ### What changed