Skip to content

fix(session): Cursor split persists tool result after its continuation in canonical order #4536

Description

@probepark

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:

  1. #emit(message_end, toolResult) - handler runs to line 4612, awaits, returns a pending promise that nobody awaits.
  2. #emit(message_end, continuation) - assistant path has no await, runs straight through and appends the continuation at 4749.
  3. 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).

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions