diff --git a/src/node/services/agentSession.autoCompaction.test.ts b/src/node/services/agentSession.autoCompaction.test.ts
index 53ceb0d016..5b6082648b 100644
--- a/src/node/services/agentSession.autoCompaction.test.ts
+++ b/src/node/services/agentSession.autoCompaction.test.ts
@@ -151,8 +151,9 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => {
muxMetadata: compactionMetadata,
}
);
- const snapshot = createMuxMessage("file-change", "user", "", {
+ const snapshot = createMuxMessage("file-snapshot", "user", "@foo.ts contents", {
synthetic: true,
+ fileAtMentionSnapshot: ["t0"],
});
const internals = session as unknown as {
resolveCompactionRequest: (
@@ -176,6 +177,104 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => {
session.dispose();
});
+ test("tracks a pending compaction request across a crash-recovery [CONTINUE] sentinel", async () => {
+ const model = "openai:gpt-4o";
+ const { session } = await createSessionHarness({
+ workspaceId: "ws-auto-compaction-request-continue-sentinel",
+ });
+ const compactionMetadata = {
+ type: "compaction-request" as const,
+ rawCommand: "/compact",
+ parsed: {},
+ };
+ const compactionRequest = createMuxMessage(
+ "compaction-request",
+ "user",
+ "Summarize the conversation",
+ {
+ synthetic: true,
+ muxMetadata: compactionMetadata,
+ }
+ );
+ // Crash mid-compaction persists an orphaned assistant row; recovery appends a
+ // synthetic [CONTINUE] sentinel after it. The resumed stream sends without
+ // compaction options but must still correlate with the pending request.
+ const orphanedAssistant = createMuxMessage("orphaned-summary", "assistant", "## Summary", {});
+ const continueSentinel = createMuxMessage("continue-sentinel", "user", "[CONTINUE]", {
+ synthetic: true,
+ });
+ const internals = session as unknown as {
+ resolveCompactionRequest: (
+ history: MuxMessage[],
+ modelString: string,
+ options?: SendMessageOptions
+ ) => { id: string } | undefined;
+ };
+
+ const request = internals.resolveCompactionRequest(
+ [compactionRequest, orphanedAssistant, continueSentinel],
+ model,
+ { model, agentId: "default" }
+ );
+
+ expect(request).toMatchObject({ id: compactionRequest.id });
+
+ // A real user message after the request must stop correlation.
+ const realUser = createMuxMessage("real-user", "user", "thanks", {});
+ const stopped = internals.resolveCompactionRequest(
+ [compactionRequest, orphanedAssistant, continueSentinel, realUser],
+ model,
+ { model, agentId: "default" }
+ );
+ expect(stopped).toBeUndefined();
+
+ session.dispose();
+ });
+
+ test("does not correlate a stale compaction request past unrelated synthetic turns", async () => {
+ const model = "openai:gpt-4o";
+ const { session } = await createSessionHarness({
+ workspaceId: "ws-auto-compaction-stale-request-stop",
+ });
+ const compactionMetadata = {
+ type: "compaction-request" as const,
+ rawCommand: "/compact",
+ parsed: {},
+ };
+ // Failed summary: request stays in history with no boundary committed.
+ const staleRequest = createMuxMessage(
+ "stale-compaction-request",
+ "user",
+ "Summarize the conversation",
+ {
+ synthetic: true,
+ muxMetadata: compactionMetadata,
+ }
+ );
+ const orphanedAssistant = createMuxMessage("orphaned-summary", "assistant", "", {});
+ // An unrecognized synthetic turn (e.g. goal continuation) after the failed
+ // summary must stop correlation instead of claiming the stale request.
+ const goalContinuation = createMuxMessage("goal-wake", "user", "continue the goal", {
+ synthetic: true,
+ });
+ const internals = session as unknown as {
+ resolveCompactionRequest: (
+ history: MuxMessage[],
+ modelString: string,
+ options?: SendMessageOptions
+ ) => { id: string } | undefined;
+ };
+
+ const stopped = internals.resolveCompactionRequest(
+ [staleRequest, orphanedAssistant, goalContinuation],
+ model,
+ { model, agentId: "default" }
+ );
+ expect(stopped).toBeUndefined();
+
+ session.dispose();
+ });
+
test("does not materialize skill snapshots (or run their directives) on deferred on-send compaction turns", async () => {
const workspaceId = "ws-auto-compaction-skill-snapshot-deferral";
diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts
index 49bfc60093..99453138f5 100644
--- a/src/node/services/agentSession.ts
+++ b/src/node/services/agentSession.ts
@@ -408,6 +408,32 @@ function isCompactionRequestMetadata(meta: unknown): meta is CompactionRequestMe
return true;
}
+/**
+ * Synthetic user rows that may legitimately sit between a pending compaction
+ * request and its summary stream: file @-mention prompt snapshots, turn-start
+ * file-change notifications, and the crash-recovery [CONTINUE] sentinel.
+ *
+ * Any other user row stops backward correlation. A summary stream that fails
+ * validation (empty or raw-JSON output) commits no boundary, so its request
+ * stays in history; letting unrelated synthetic turns (goal continuations,
+ * task wakes) traverse past it would persist their responses as compaction
+ * boundaries and collapse valid history.
+ */
+function canFollowPendingCompactionRequest(message: MuxMessage): boolean {
+ if (message.metadata?.synthetic !== true) {
+ return false;
+ }
+ if (message.metadata?.fileAtMentionSnapshot != null) {
+ return true;
+ }
+ const text =
+ message.parts
+ ?.filter((part) => part.type === "text")
+ .map((part) => part.text)
+ .join("\n") ?? "";
+ return text.startsWith("") || text === "[CONTINUE]";
+}
+
const AUTO_RETRY_PREFERENCE_FILE = "auto-retry-preference.json";
/**
@@ -4828,8 +4854,6 @@ export class AgentSession {
source?: "idle-compaction" | "auto-compaction";
}
| undefined {
- const streamIsCompaction = isCompactionRequestMetadata(options?.muxMetadata);
-
for (let index = history.length - 1; index >= 0; index -= 1) {
const message = history[index];
if (message.role !== "user") {
@@ -4845,9 +4869,11 @@ export class AgentSession {
};
}
- // Snapshot rows can follow a synthetic compaction request before stream startup.
- // Skip only those rows when the current send options identify this stream as compaction.
- if (!streamIsCompaction || message.metadata?.synthetic !== true) {
+ // Only recognized synthetic rows may sit between a pending compaction
+ // request and its stream; anything else stops correlation so a stale
+ // request cannot claim an unrelated later turn (see
+ // canFollowPendingCompactionRequest).
+ if (!canFollowPendingCompactionRequest(message)) {
return undefined;
}
}