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; } }