Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 = [];
Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/src/core/compaction/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
107 changes: 101 additions & 6 deletions packages/coding-agent/src/core/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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<any>,
originalProjection: ReturnType<typeof projectModelUsabilityBudget>,
): { 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.
*
Expand Down Expand Up @@ -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<any>, 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();

Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/src/core/session-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
3 changes: 2 additions & 1 deletion packages/coding-agent/src/core/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1267,6 +1267,7 @@ export class SessionManager {
details?: T,
fromHook?: boolean,
usage?: Usage,
preserveTranscript = false,
): string {
const entry: CompactionEntry<T> = {
type: "compaction",
Expand All @@ -1281,7 +1282,7 @@ export class SessionManager {
fromHook,
};
this._appendEntry(entry);
this._trimMirrorAfterCompaction(entry);
if (!preserveTranscript) this._trimMirrorAfterCompaction(entry);
return entry.id;
}

Expand Down
114 changes: 114 additions & 0 deletions packages/coding-agent/test/suite/resume-admission-recovery.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading