Skip to content

Commit f004da3

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

5 files changed

Lines changed: 188 additions & 14 deletions

File tree

apps/server/scripts/acp-mock-agent.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const emitInterleavedAssistantToolCalls =
1919
const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1";
2020
const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1";
2121
const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1";
22+
const supportResumeSession = process.env.T3_ACP_SUPPORT_RESUME_SESSION === "1";
2223
const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";
2324
const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";
2425
const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;
@@ -268,7 +269,10 @@ const program = Effect.gen(function* () {
268269
request.clientCapabilities?._meta?.parameterizedModelPicker === true;
269270
return {
270271
protocolVersion: 1,
271-
agentCapabilities: { loadSession: true },
272+
agentCapabilities: {
273+
loadSession: true,
274+
...(supportResumeSession ? { sessionCapabilities: { resume: {} } } : {}),
275+
},
272276
};
273277
}),
274278
);
@@ -302,6 +306,14 @@ const program = Effect.gen(function* () {
302306
),
303307
);
304308

309+
yield* agent.handleResumeSession(() =>
310+
Effect.succeed({
311+
modes: modeState(),
312+
models: modelState(),
313+
configOptions: configOptions(),
314+
}),
315+
);
316+
305317
yield* agent.handleSetSessionModel((request) =>
306318
Effect.gen(function* () {
307319
if (!grokAcpModels.some((model) => model.modelId === request.modelId)) {

apps/server/src/provider/Layers/CursorAdapter.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,43 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
463463
}),
464464
);
465465

466+
it.effect(
467+
"uses ACP session/resume instead of session/load when the agent advertises resume",
468+
() =>
469+
Effect.gen(function* () {
470+
const adapter = yield* CursorAdapter;
471+
const serverSettings = yield* ServerSettingsService;
472+
const threadId = ThreadId.make("cursor-session-resume-probe");
473+
const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-")));
474+
const requestLogPath = path.join(tempDir, "requests.ndjson");
475+
const argvLogPath = path.join(tempDir, "argv.txt");
476+
yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8"));
477+
const wrapperPath = yield* Effect.promise(() =>
478+
makeProbeWrapper(requestLogPath, argvLogPath, { T3_ACP_SUPPORT_RESUME_SESSION: "1" }),
479+
);
480+
yield* serverSettings.updateSettings({
481+
providers: { cursor: { binaryPath: wrapperPath } },
482+
});
483+
484+
yield* adapter.startSession({
485+
threadId,
486+
provider: ProviderDriverKind.make("cursor"),
487+
cwd: process.cwd(),
488+
runtimeMode: "full-access",
489+
modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" },
490+
resumeCursor: {
491+
schemaVersion: 1,
492+
sessionId: "mock-session-1",
493+
},
494+
});
495+
yield* adapter.stopSession(threadId);
496+
497+
const requests = yield* Effect.promise(() => readJsonLines(requestLogPath));
498+
assert.isTrue(requests.some((entry) => entry.method === "session/resume"));
499+
assert.isFalse(requests.some((entry) => entry.method === "session/load"));
500+
}),
501+
);
502+
466503
it.effect(
467504
"applies initial model and mode configuration during startSession and skips repeating it on first send",
468505
() =>
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: 48 additions & 13 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
);
@@ -407,15 +412,35 @@ const makeAcpSessionRuntime = (
407412
| EffectAcpSchema.NewSessionResponse
408413
| EffectAcpSchema.ResumeSessionResponse;
409414
if (options.resumeSessionId) {
410-
const loadPayload = {
411-
sessionId: options.resumeSessionId,
412-
cwd: options.cwd,
413-
mcpServers: options.mcpServers ?? [],
414-
} satisfies EffectAcpSchema.LoadSessionRequest;
415-
const resumed = yield* runLoggedRequest(
416-
"session/load",
417-
loadPayload,
418-
acp.agent.loadSession(loadPayload),
415+
const supportsResume = initializeResult.agentCapabilities?.sessionCapabilities?.resume;
416+
const resumed = yield* (
417+
supportsResume
418+
? runLoggedRequest(
419+
"session/resume",
420+
{
421+
sessionId: options.resumeSessionId,
422+
cwd: options.cwd,
423+
mcpServers: options.mcpServers ?? [],
424+
} satisfies EffectAcpSchema.ResumeSessionRequest,
425+
acp.agent.resumeSession({
426+
sessionId: options.resumeSessionId,
427+
cwd: options.cwd,
428+
mcpServers: options.mcpServers ?? [],
429+
}),
430+
)
431+
: runLoggedRequest(
432+
"session/load",
433+
{
434+
sessionId: options.resumeSessionId,
435+
cwd: options.cwd,
436+
mcpServers: options.mcpServers ?? [],
437+
} satisfies EffectAcpSchema.LoadSessionRequest,
438+
acp.agent.loadSession({
439+
sessionId: options.resumeSessionId,
440+
cwd: options.cwd,
441+
mcpServers: options.mcpServers ?? [],
442+
}),
443+
)
419444
).pipe(Effect.exit);
420445
if (Exit.isSuccess(resumed)) {
421446
sessionId = options.resumeSessionId;
@@ -518,10 +543,13 @@ const makeAcpSessionRuntime = (
518543
sessionId: started.sessionId,
519544
...payload,
520545
} satisfies EffectAcpSchema.PromptRequest;
521-
return closeActiveAssistantSegment({
522-
queue: eventQueue,
523-
assistantSegmentRef,
524-
}).pipe(
546+
return Ref.set(suppressSessionUpdatesRef, false).pipe(
547+
Effect.andThen(
548+
closeActiveAssistantSegment({
549+
queue: eventQueue,
550+
assistantSegmentRef,
551+
}),
552+
),
525553
Effect.andThen(
526554
runLoggedRequest(
527555
"session/prompt",
@@ -608,12 +636,14 @@ const handleSessionUpdate = ({
608636
modeStateRef,
609637
toolCallsRef,
610638
assistantSegmentRef,
639+
suppressSessionUpdatesRef,
611640
params,
612641
}: {
613642
readonly queue: Queue.Queue<AcpParsedSessionEvent>;
614643
readonly modeStateRef: Ref.Ref<AcpSessionModeState | undefined>;
615644
readonly toolCallsRef: Ref.Ref<Map<string, AcpToolCallState>>;
616645
readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>;
646+
readonly suppressSessionUpdatesRef: Ref.Ref<boolean>;
617647
readonly params: EffectAcpSchema.SessionNotification;
618648
}): Effect.Effect<void> =>
619649
Effect.gen(function* () {
@@ -623,6 +653,9 @@ const handleSessionUpdate = ({
623653
current === undefined ? current : updateModeState(current, parsed.modeId!),
624654
);
625655
}
656+
if (yield* Ref.get(suppressSessionUpdatesRef)) {
657+
return;
658+
}
626659
for (const event of parsed.events) {
627660
if (event._tag === "ToolCallUpdated") {
628661
yield* closeActiveAssistantSegment({
@@ -672,6 +705,8 @@ const handleSessionUpdate = ({
672705
}
673706
});
674707

708+
export const handleSessionUpdateForTest = handleSessionUpdate;
709+
675710
function updateModeState(modeState: AcpSessionModeState, nextModeId: string): AcpSessionModeState {
676711
const normalized = nextModeId.trim();
677712
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)