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
101 changes: 100 additions & 1 deletion src/node/services/agentSession.autoCompaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,9 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => {
muxMetadata: compactionMetadata,
}
);
const snapshot = createMuxMessage("file-change", "user", "<file-change />", {
const snapshot = createMuxMessage("file-snapshot", "user", "@foo.ts contents", {
synthetic: true,
fileAtMentionSnapshot: ["t0"],
});
const internals = session as unknown as {
resolveCompactionRequest: (
Expand All @@ -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";

Expand Down
36 changes: 31 additions & 5 deletions src/node/services/agentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<system-file-update>") || text === "[CONTINUE]";
}

const AUTO_RETRY_PREFERENCE_FILE = "auto-retry-preference.json";

/**
Expand Down Expand Up @@ -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") {
Expand All @@ -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;
}
}
Expand Down
Loading