From d0062f214a80c089e633a3dc37117f7687bc2780 Mon Sep 17 00:00:00 2001 From: Justin Gray Date: Sun, 31 May 2026 19:17:39 +0000 Subject: [PATCH] Fix ACP assistant item IDs across session reloads. Mint a per-runtime instance id so assistant segment itemIds stay unique when Cursor reuses the same ACP sessionId after session/load. Without this, the orchestrator collapses new assistant output onto the prior turn's message. Co-authored-by: Cursor --- .../src/provider/Layers/CursorAdapter.test.ts | 10 ++++- .../provider/acp/AcpJsonRpcConnection.test.ts | 42 +++++++++++++++++++ .../src/provider/acp/AcpSessionRuntime.ts | 24 +++++++++-- 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 0c54c92b6dd..74ad725527c 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -209,7 +209,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { assert.isDefined(delta); if (delta?.type === "content.delta") { assert.equal(delta.payload.delta, "hello from mock"); - assert.match(String(delta.itemId), /^assistant:mock-session-1:segment:0$/); + assert.match( + String(delta.itemId), + /^assistant:mock-session-1:run:[0-9a-f-]{36}:segment:0$/, + ); } const assistantCompleted = runtimeEvents.find( @@ -588,7 +591,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { if (contentDelta?.type === "content.delta") { assert.equal(String(contentDelta.turnId), String(turn.turnId)); assert.equal(contentDelta.payload.delta, "hello from mock"); - assert.equal(String(contentDelta.itemId), "assistant:mock-session-1:segment:0"); + assert.match( + String(contentDelta.itemId), + /^assistant:mock-session-1:run:[0-9a-f-]{36}:segment:0$/, + ); } }); diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 1b8f7be5d7d..25f884e7363 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -173,6 +173,48 @@ describe("AcpSessionRuntime", () => { ), ); + it.effect( + "namespaces assistant itemIds per runtime instance so reloaded sessions don't collide", + () => + Effect.gen(function* () { + const runOnce = Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime; + yield* runtime.start(); + yield* runtime.prompt({ prompt: [{ type: "text", text: "hi" }] }); + const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 4))); + const delta = notes.find((note) => note._tag === "ContentDelta"); + if (delta?._tag !== "ContentDelta") { + throw new Error("expected ContentDelta event"); + } + return delta.itemId; + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { command: bunExe, args: [mockAgentPath] }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }), + ), + Effect.scoped, + ); + + const firstItemId = yield* runOnce; + const secondItemId = yield* runOnce; + + // Both runtimes loaded the same mock ACP `sessionId`; without per-runtime + // namespacing the synthesized itemIds collide and the orchestrator + // collapses today's assistant message onto yesterday's. + expect(firstItemId).not.toBe(secondItemId); + expect(firstItemId).toMatch( + /^assistant:mock-session-1:run:[0-9a-f-]{36}:segment:0$/, + ); + expect(secondItemId).toMatch( + /^assistant:mock-session-1:run:[0-9a-f-]{36}:segment:0$/, + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("suppresses generic placeholder tool updates until completion", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime; diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 8652b2cfeaf..ce16588e614 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -1,3 +1,5 @@ +import { randomUUID } from "node:crypto"; + import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -165,6 +167,7 @@ const makeAcpSessionRuntime = ( const assistantSegmentRef = yield* Ref.make({ nextSegmentIndex: 0 }); const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); const startStateRef = yield* Ref.make({ _tag: "NotStarted" }); + const runtimeInstanceId = randomUUID(); const logRequest = (event: AcpSessionRequestLogEvent) => options.requestLogger ? options.requestLogger(event) : Effect.void; @@ -237,6 +240,7 @@ const makeAcpSessionRuntime = ( toolCallsRef, assistantSegmentRef, params: notification, + runtimeInstanceId, }), ); @@ -582,12 +586,14 @@ const handleSessionUpdate = ({ toolCallsRef, assistantSegmentRef, params, + runtimeInstanceId, }: { readonly queue: Queue.Queue; readonly modeStateRef: Ref.Ref; readonly toolCallsRef: Ref.Ref>; readonly assistantSegmentRef: Ref.Ref; readonly params: EffectAcpSchema.SessionNotification; + readonly runtimeInstanceId: string; }): Effect.Effect => Effect.gen(function* () { const parsed = parseSessionUpdateEvent(params); @@ -634,6 +640,7 @@ const handleSessionUpdate = ({ queue, assistantSegmentRef, sessionId: params.sessionId, + runtimeInstanceId, }); yield* Queue.offer(queue, { ...event, @@ -671,17 +678,28 @@ function shouldEmitToolCallUpdate( return previous === undefined || previous.title !== next.title || previous.detail !== next.detail; } -const assistantItemId = (sessionId: string, segmentIndex: number) => - `assistant:${sessionId}:segment:${segmentIndex}`; +// `runtimeInstanceId` is minted per AcpSessionRuntime construction so that +// itemIds remain unique across runtime restarts that reuse the same ACP +// `sessionId` (e.g. Cursor's `session/load`). Without it, segment counters +// reset to 0 on reload while the cursor session id stays the same, and the +// orchestrator collapses the new assistant message onto the prior turn's +// message of the same id — making the agent's response invisible to clients. +const assistantItemId = ( + sessionId: string, + runtimeInstanceId: string, + segmentIndex: number, +) => `assistant:${sessionId}:run:${runtimeInstanceId}:segment:${segmentIndex}`; const ensureActiveAssistantSegment = ({ queue, assistantSegmentRef, sessionId, + runtimeInstanceId, }: { readonly queue: Queue.Queue; readonly assistantSegmentRef: Ref.Ref; readonly sessionId: string; + readonly runtimeInstanceId: string; }) => Ref.modify( assistantSegmentRef, @@ -689,7 +707,7 @@ const ensureActiveAssistantSegment = ({ if (current.activeItemId) { return [{ itemId: current.activeItemId }, current] as const; } - const itemId = assistantItemId(sessionId, current.nextSegmentIndex); + const itemId = assistantItemId(sessionId, runtimeInstanceId, current.nextSegmentIndex); return [ { itemId,