Summary
For a Cursor split turn, the canonical/persisted message order is preamble -> continuation -> server tool result, while the raw event order is the correct preamble -> server result -> continuation. The tool result lands after the assistant continuation that logically follows it.
Found while reviewing #4521, which is titled "prove canonical Cursor compaction" but asserts canonical membership with three independent .find() calls, so it passes with this defect present.
Reproduction
Detached worktree at 15a170623 (#4521 head), real bun install, natives built. I added only a probe to the existing T3 test, projecting the three canonical messages in the order they appear:
const canonicalCursorOrder = canonicalMessages.flatMap(message => {
if (message === canonicalPreamble) return ["preamble"];
if (message === canonicalServerResult) return ["server-result"];
if (message === canonicalContinuation) return ["continuation"];
return [];
});
expect(canonicalCursorOrder).toEqual(["preamble", "server-result", "continuation"]);
$ bun test packages/coding-agent/test/agent-session-midrun-compaction.test.ts -t "Cursor"
@@ -2,5 +2,5 @@
"preamble",
- "server-result",
"continuation",
+ "server-result",
]
(fail) AgentSession mid-run compaction (issue #2035) > T3 exercises the Cursor split path without retaining the original usage anchor
The raw-event assertion in the same test, immediately below, still passes with ["assistant", "toolResult", "assistant"]. So the wire order is right and only the persisted projection is wrong.
Cause
Agent.#emit dispatches listeners synchronously and does not await them:
// packages/agent/src/agent.ts:2071-2075
#emit(e: AgentEvent) {
for (const listener of this.#listeners) {
listener(e);
}
}
#emitCursorSplitAssistantMessage emits the tool results and then the continuation in one synchronous run:
// packages/agent/src/agent.ts:2208-2234
for (const { toolResult } of buffer) {
this.#emit({ type: "message_start", message: toolResult });
this.appendMessage(toolResult);
this.#emit({ type: "message_end", message: toolResult });
}
...
this.#emit({ type: "message_start", message: continuationMessage });
this.appendMessage(continuationMessage);
this.#emit({ type: "message_end", message: continuationMessage });
In AgentSession.#handleAgentEvent, the two roles reach the same canonical append site at agent-session.ts:4749, but only the toolResult branch yields on the way there:
// agent-session.ts:4610-4612
if (event.type === "message_end" && event.message.role === "toolResult") {
// Register synchronously so Agent.transformContext sees the barrier even
// when the event dispatcher does not await this listener.
await this.#queuePreAdmissionArtifactSpill(event.message);
}
The only other await before line 4749 is at 4690, guarded by message_start + role === "custom", so it never applies to an assistant message_end.
Sequence:
#emit(message_end, toolResult) - handler runs to line 4612, awaits, returns a pending promise that nobody awaits.
#emit(message_end, continuation) - assistant path has no await, runs straight through and appends the continuation at 4749.
- Microtask queue drains, the toolResult handler resumes and appends the tool result, now after the continuation.
The comment at 4611 shows the synchronous-dispatch hazard was known for the transformContext barrier; the canonical append ordering has the same exposure and was not covered.
Impact
The persisted branch and anything derived from it (display context, compaction input, replay, provider re-serialization) carry tool_use and its tool_result separated by an assistant message. Compaction preserves the wrong order, so it survives into subsequent turns.
Acceptance
- Canonical/persisted order for a Cursor split equals the raw event order: preamble -> server tool result -> continuation.
- Regression test asserts one ordered projection, not membership, and fails on current
dev.
- Fix serializes canonical admission across the tool-result pre-admission spill boundary rather than removing the
await (the barrier registration it guarantees still has to hold).
#4521 should not land as-is: it deletes expect(loop.session.messages.includes(originalAnchor!)).toBe(false) without replacement and asserts canonical state with unordered .find() calls, which is what let this through. Details in my review there.
Summary
For a Cursor split turn, the canonical/persisted message order is
preamble -> continuation -> server tool result, while the raw event order is the correctpreamble -> server result -> continuation. The tool result lands after the assistant continuation that logically follows it.Found while reviewing #4521, which is titled "prove canonical Cursor compaction" but asserts canonical membership with three independent
.find()calls, so it passes with this defect present.Reproduction
Detached worktree at
15a170623(#4521 head), realbun install, natives built. I added only a probe to the existing T3 test, projecting the three canonical messages in the order they appear:The raw-event assertion in the same test, immediately below, still passes with
["assistant", "toolResult", "assistant"]. So the wire order is right and only the persisted projection is wrong.Cause
Agent.#emitdispatches listeners synchronously and does not await them:#emitCursorSplitAssistantMessageemits the tool results and then the continuation in one synchronous run:In
AgentSession.#handleAgentEvent, the two roles reach the same canonical append site atagent-session.ts:4749, but only the toolResult branch yields on the way there:The only other
awaitbefore line 4749 is at 4690, guarded bymessage_start+role === "custom", so it never applies to an assistantmessage_end.Sequence:
#emit(message_end, toolResult)- handler runs to line 4612, awaits, returns a pending promise that nobody awaits.#emit(message_end, continuation)- assistant path has no await, runs straight through and appends the continuation at 4749.The comment at 4611 shows the synchronous-dispatch hazard was known for the
transformContextbarrier; the canonical append ordering has the same exposure and was not covered.Impact
The persisted branch and anything derived from it (display context, compaction input, replay, provider re-serialization) carry
tool_useand itstool_resultseparated by an assistant message. Compaction preserves the wrong order, so it survives into subsequent turns.Acceptance
dev.await(the barrier registration it guarantees still has to hold).Note on #4521
#4521 should not land as-is: it deletes
expect(loop.session.messages.includes(originalAnchor!)).toBe(false)without replacement and asserts canonical state with unordered.find()calls, which is what let this through. Details in my review there.