From e15a26d0bb8f9efde9e09a847dad6d89ab65ea6a Mon Sep 17 00:00:00 2001 From: Mux Date: Tue, 25 Aug 2026 14:32:36 -0500 Subject: [PATCH 1/2] fix: keep compaction request correlated across crash-recovery sentinels A crash mid-compaction leaves an orphaned assistant row; startup recovery appends a synthetic [CONTINUE] user sentinel after it. resolveCompactionRequest previously stopped scanning at that sentinel unless the current stream itself was a compaction send, so the resumed summary stream never correlated with the pending request and the boundary was silently never committed. Skip all synthetic user rows regardless of send options; correlation still stops at the first real user row. Co-authored-by: Mux --- .../agentSession.autoCompaction.test.ts | 54 +++++++++++++++++++ src/node/services/agentSession.ts | 10 ++-- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/node/services/agentSession.autoCompaction.test.ts b/src/node/services/agentSession.autoCompaction.test.ts index 53ceb0d016..260479ed33 100644 --- a/src/node/services/agentSession.autoCompaction.test.ts +++ b/src/node/services/agentSession.autoCompaction.test.ts @@ -176,6 +176,60 @@ 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 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..e62d9ded71 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -4828,8 +4828,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 +4843,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) { + // Synthetic rows can follow a compaction request: prompt snapshots land before + // stream startup, and crash recovery can append a [CONTINUE] sentinel when the + // app restarts mid-compaction. Skip them regardless of what the current stream + // is, so the pending request stays correlated; stop at the first real user row. + if (message.metadata?.synthetic !== true) { return undefined; } } From cbd307de28bbd41975ca210244e0f59506460608 Mon Sep 17 00:00:00 2001 From: Mux Date: Tue, 25 Aug 2026 14:45:10 -0500 Subject: [PATCH 2/2] fix: restrict compaction correlation skip to recognized synthetic rows Codex review follow-up: skipping every synthetic user row let a stale compaction request (left behind when a summary stream failed validation without committing a boundary) be claimed by an unrelated later turn whose history began with some other synthetic row, persisting that turn's response as a compaction boundary. Limit traversal to the three recognized producer shapes: file @-mention prompt snapshots, turn-start notifications, and the crash-recovery [CONTINUE] sentinel. Co-authored-by: Mux --- .../agentSession.autoCompaction.test.ts | 47 ++++++++++++++++++- src/node/services/agentSession.ts | 36 ++++++++++++-- 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/node/services/agentSession.autoCompaction.test.ts b/src/node/services/agentSession.autoCompaction.test.ts index 260479ed33..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: ( @@ -230,6 +231,50 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { 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 e62d9ded71..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"; /** @@ -4843,11 +4869,11 @@ export class AgentSession { }; } - // Synthetic rows can follow a compaction request: prompt snapshots land before - // stream startup, and crash recovery can append a [CONTINUE] sentinel when the - // app restarts mid-compaction. Skip them regardless of what the current stream - // is, so the pending request stays correlated; stop at the first real user row. - if (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; } }