Skip to content

Commit 92a7a6d

Browse files
committed
Fix Cursor ACP session replay on resume
1 parent e408b01 commit 92a7a6d

3 files changed

Lines changed: 109 additions & 4 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { assert, it } from "@effect/vitest";
2+
import * as Effect from "effect/Effect";
3+
import * as Queue from "effect/Queue";
4+
import * as Ref from "effect/Ref";
5+
import type * as EffectAcpSchema from "effect-acp/schema";
6+
7+
import { handleSessionUpdateForTest } from "./AcpSessionRuntime.ts";
8+
import type {
9+
AcpParsedSessionEvent,
10+
AcpSessionModeState,
11+
AcpToolCallState,
12+
} from "./AcpRuntimeModel.ts";
13+
14+
it.effect("suppresses loaded-session replay updates until the first live prompt", () =>
15+
Effect.gen(function* () {
16+
const queue = yield* Queue.unbounded<AcpParsedSessionEvent>();
17+
const modeStateRef = yield* Ref.make<AcpSessionModeState | undefined>({
18+
currentModeId: "ask",
19+
availableModes: [
20+
{ id: "ask", name: "Ask" },
21+
{ id: "code", name: "Code" },
22+
],
23+
});
24+
const toolCallsRef = yield* Ref.make(new Map<string, AcpToolCallState>());
25+
const assistantSegmentRef = yield* Ref.make({ nextSegmentIndex: 0 });
26+
const suppressSessionUpdatesRef = yield* Ref.make(true);
27+
28+
const handle = (params: EffectAcpSchema.SessionNotification) =>
29+
handleSessionUpdateForTest({
30+
queue,
31+
modeStateRef,
32+
toolCallsRef,
33+
assistantSegmentRef,
34+
suppressSessionUpdatesRef,
35+
params,
36+
});
37+
38+
yield* handle({
39+
sessionId: "cursor-session",
40+
update: {
41+
sessionUpdate: "current_mode_update",
42+
currentModeId: "code",
43+
},
44+
});
45+
yield* handle({
46+
sessionId: "cursor-session",
47+
update: {
48+
sessionUpdate: "plan",
49+
entries: [{ content: "Old replayed plan", priority: "high", status: "completed" }],
50+
},
51+
});
52+
yield* handle({
53+
sessionId: "cursor-session",
54+
update: {
55+
sessionUpdate: "user_message_chunk",
56+
content: { type: "text", text: "old replayed user prompt" },
57+
},
58+
});
59+
yield* handle({
60+
sessionId: "cursor-session",
61+
update: {
62+
sessionUpdate: "agent_message_chunk",
63+
content: { type: "text", text: "old replayed assistant text" },
64+
},
65+
});
66+
67+
assert.equal(yield* Queue.size(queue), 0);
68+
assert.equal((yield* Ref.get(modeStateRef))?.currentModeId, "code");
69+
70+
yield* Ref.set(suppressSessionUpdatesRef, false);
71+
yield* handle({
72+
sessionId: "cursor-session",
73+
update: {
74+
sessionUpdate: "agent_message_chunk",
75+
content: { type: "text", text: "new assistant text" },
76+
},
77+
});
78+
79+
const started = yield* Queue.take(queue);
80+
const delta = yield* Queue.take(queue);
81+
assert.equal(started._tag, "AssistantItemStarted");
82+
assert.equal(delta._tag, "ContentDelta");
83+
if (delta._tag === "ContentDelta") {
84+
assert.equal(delta.text, "new assistant text");
85+
}
86+
assert.equal(yield* Queue.size(queue), 0);
87+
}),
88+
);

apps/server/src/provider/acp/AcpSessionRuntime.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export interface AcpSessionRuntimeOptions {
5050
};
5151
readonly authMethodId: string;
5252
readonly mcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>;
53+
readonly suppressSessionUpdatesUntilPrompt?: boolean;
5354
readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect<void, never>;
5455
readonly protocolLogging?: {
5556
readonly logIncoming?: boolean;
@@ -171,6 +172,9 @@ const makeAcpSessionRuntime = (
171172
const assistantSegmentRef = yield* Ref.make<AcpAssistantSegmentState>({ nextSegmentIndex: 0 });
172173
const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined));
173174
const startStateRef = yield* Ref.make<AcpStartState>({ _tag: "NotStarted" });
175+
const suppressSessionUpdatesRef = yield* Ref.make(
176+
options.suppressSessionUpdatesUntilPrompt === true,
177+
);
174178

175179
const logRequest = (event: AcpSessionRequestLogEvent) =>
176180
options.requestLogger ? options.requestLogger(event) : Effect.void;
@@ -249,6 +253,7 @@ const makeAcpSessionRuntime = (
249253
modeStateRef,
250254
toolCallsRef,
251255
assistantSegmentRef,
256+
suppressSessionUpdatesRef,
252257
params: notification,
253258
}),
254259
);
@@ -518,10 +523,13 @@ const makeAcpSessionRuntime = (
518523
sessionId: started.sessionId,
519524
...payload,
520525
} satisfies EffectAcpSchema.PromptRequest;
521-
return closeActiveAssistantSegment({
522-
queue: eventQueue,
523-
assistantSegmentRef,
524-
}).pipe(
526+
return Ref.set(suppressSessionUpdatesRef, false).pipe(
527+
Effect.andThen(
528+
closeActiveAssistantSegment({
529+
queue: eventQueue,
530+
assistantSegmentRef,
531+
}),
532+
),
525533
Effect.andThen(
526534
runLoggedRequest(
527535
"session/prompt",
@@ -608,12 +616,14 @@ const handleSessionUpdate = ({
608616
modeStateRef,
609617
toolCallsRef,
610618
assistantSegmentRef,
619+
suppressSessionUpdatesRef,
611620
params,
612621
}: {
613622
readonly queue: Queue.Queue<AcpParsedSessionEvent>;
614623
readonly modeStateRef: Ref.Ref<AcpSessionModeState | undefined>;
615624
readonly toolCallsRef: Ref.Ref<Map<string, AcpToolCallState>>;
616625
readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>;
626+
readonly suppressSessionUpdatesRef: Ref.Ref<boolean>;
617627
readonly params: EffectAcpSchema.SessionNotification;
618628
}): Effect.Effect<void> =>
619629
Effect.gen(function* () {
@@ -623,6 +633,9 @@ const handleSessionUpdate = ({
623633
current === undefined ? current : updateModeState(current, parsed.modeId!),
624634
);
625635
}
636+
if (yield* Ref.get(suppressSessionUpdatesRef)) {
637+
return;
638+
}
626639
for (const event of parsed.events) {
627640
if (event._tag === "ToolCallUpdated") {
628641
yield* closeActiveAssistantSegment({
@@ -672,6 +685,8 @@ const handleSessionUpdate = ({
672685
}
673686
});
674687

688+
export const handleSessionUpdateForTest = handleSessionUpdate;
689+
675690
function updateModeState(modeState: AcpSessionModeState, nextModeId: string): AcpSessionModeState {
676691
const normalized = nextModeId.trim();
677692
if (!normalized) {

apps/server/src/provider/acp/CursorAcpSupport.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ export const makeCursorAcpRuntime = (
6060
spawn: buildCursorAcpSpawnInput(input.cursorSettings, input.cwd, input.environment),
6161
authMethodId: "cursor_login",
6262
clientCapabilities: CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES,
63+
suppressSessionUpdatesUntilPrompt:
64+
input.suppressSessionUpdatesUntilPrompt ?? input.resumeSessionId !== undefined,
6365
}).pipe(
6466
Layer.provide(
6567
Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner),

0 commit comments

Comments
 (0)