Skip to content

Commit 188df6d

Browse files
juliusmarmingecursoragentcodex
authored
Fix Claude session cwd resume drift (#2292)
Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com> Co-authored-by: codex <codex@users.noreply.github.com>
1 parent 0d55a42 commit 188df6d

6 files changed

Lines changed: 327 additions & 4 deletions

File tree

apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,12 @@ describe("ProviderCommandReactor", () => {
138138
(input.runtimeMode === "approval-required" || input.runtimeMode === "full-access")
139139
? input.runtimeMode
140140
: "full-access",
141+
...(typeof input === "object" &&
142+
input !== null &&
143+
"cwd" in input &&
144+
typeof input.cwd === "string"
145+
? { cwd: input.cwd }
146+
: {}),
141147
...(modelSelection.model !== undefined ? { model: modelSelection.model } : {}),
142148
threadId,
143149
resumeCursor: resumeCursor ?? { opaque: `resume-${sessionIndex}` },
@@ -864,6 +870,76 @@ describe("ProviderCommandReactor", () => {
864870
expect(harness.stopSession.mock.calls.length).toBe(0);
865871
});
866872

873+
it("restarts the provider session when the thread workspace changes", async () => {
874+
const harness = await createHarness({
875+
threadModelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" },
876+
});
877+
const now = new Date().toISOString();
878+
879+
await Effect.runPromise(
880+
harness.engine.dispatch({
881+
type: "thread.turn.start",
882+
commandId: CommandId.make("cmd-turn-start-workspace-1"),
883+
threadId: ThreadId.make("thread-1"),
884+
message: {
885+
messageId: asMessageId("user-message-workspace-1"),
886+
role: "user",
887+
text: "first in project root",
888+
attachments: [],
889+
},
890+
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
891+
runtimeMode: "approval-required",
892+
createdAt: now,
893+
}),
894+
);
895+
896+
await waitFor(() => harness.startSession.mock.calls.length === 1);
897+
await waitFor(() => harness.sendTurn.mock.calls.length === 1);
898+
expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
899+
cwd: "/tmp/provider-project",
900+
});
901+
902+
await Effect.runPromise(
903+
harness.engine.dispatch({
904+
type: "thread.meta.update",
905+
commandId: CommandId.make("cmd-thread-worktree-change"),
906+
threadId: ThreadId.make("thread-1"),
907+
worktreePath: "/tmp/provider-project-worktree",
908+
}),
909+
);
910+
911+
await Effect.runPromise(
912+
harness.engine.dispatch({
913+
type: "thread.turn.start",
914+
commandId: CommandId.make("cmd-turn-start-workspace-2"),
915+
threadId: ThreadId.make("thread-1"),
916+
message: {
917+
messageId: asMessageId("user-message-workspace-2"),
918+
role: "user",
919+
text: "second in worktree",
920+
attachments: [],
921+
},
922+
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
923+
runtimeMode: "approval-required",
924+
createdAt: now,
925+
}),
926+
);
927+
928+
await waitFor(() => harness.startSession.mock.calls.length === 2);
929+
await waitFor(() => harness.sendTurn.mock.calls.length === 2);
930+
expect(harness.stopSession.mock.calls.length).toBe(0);
931+
expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({
932+
threadId: ThreadId.make("thread-1"),
933+
cwd: "/tmp/provider-project-worktree",
934+
resumeCursor: { opaque: "resume-1" },
935+
modelSelection: {
936+
provider: "claudeAgent",
937+
model: "claude-sonnet-4-6",
938+
},
939+
runtimeMode: "approval-required",
940+
});
941+
});
942+
867943
it("restarts claude sessions when claude effort changes", async () => {
868944
const harness = await createHarness({
869945
threadModelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" },

apps/server/src/orchestration/Layers/ProviderCommandReactor.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ const make = Effect.gen(function* () {
334334
thread.session && thread.session.status !== "stopped" && activeSession ? thread.id : null;
335335
if (existingSessionThreadId) {
336336
const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode;
337+
const cwdChanged = effectiveCwd !== activeSession?.cwd;
337338
const sessionModelSwitch =
338339
currentProvider === undefined
339340
? "in-session"
@@ -350,6 +351,7 @@ const make = Effect.gen(function* () {
350351

351352
if (
352353
!runtimeModeChanged &&
354+
!cwdChanged &&
353355
!shouldRestartForModelChange &&
354356
!shouldRestartForModelSelectionChange
355357
) {
@@ -367,6 +369,9 @@ const make = Effect.gen(function* () {
367369
currentRuntimeMode: thread.session?.runtimeMode,
368370
desiredRuntimeMode: thread.runtimeMode,
369371
runtimeModeChanged,
372+
previousCwd: activeSession?.cwd,
373+
desiredCwd: effectiveCwd,
374+
cwdChanged,
370375
modelChanged,
371376
shouldRestartForModelChange,
372377
shouldRestartForModelSelectionChange,
@@ -381,6 +386,7 @@ const make = Effect.gen(function* () {
381386
restartedSessionThreadId: restartedSession.threadId,
382387
provider: restartedSession.provider,
383388
runtimeMode: restartedSession.runtimeMode,
389+
cwd: restartedSession.cwd,
384390
});
385391
yield* bindSessionToThread(restartedSession);
386392
return restartedSession.threadId;

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

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2561,6 +2561,96 @@ describe("ClaudeAdapterLive", () => {
25612561
);
25622562
});
25632563

2564+
it.effect("preserves durable resume ids across Claude resume hooks", () => {
2565+
const harness = makeHarness();
2566+
return Effect.gen(function* () {
2567+
const adapter = yield* ClaudeAdapter;
2568+
const durableSessionId = "550e8400-e29b-41d4-a716-446655440000";
2569+
const transientHookSessionId = "7368d0c7-40a3-4d8a-bcc1-ac80c49f2719";
2570+
2571+
const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe(
2572+
Stream.runCollect,
2573+
Effect.forkChild,
2574+
);
2575+
2576+
yield* adapter.startSession({
2577+
threadId: RESUME_THREAD_ID,
2578+
provider: "claudeAgent",
2579+
resumeCursor: {
2580+
threadId: RESUME_THREAD_ID,
2581+
resume: durableSessionId,
2582+
resumeSessionAt: "assistant-99",
2583+
turnCount: 3,
2584+
},
2585+
runtimeMode: "full-access",
2586+
});
2587+
2588+
harness.query.emit({
2589+
type: "system",
2590+
subtype: "hook_started",
2591+
hook_id: "resume-hook-1",
2592+
hook_name: "SessionStart:resume",
2593+
hook_event: "SessionStart",
2594+
session_id: transientHookSessionId,
2595+
uuid: "resume-hook-started",
2596+
} as unknown as SDKMessage);
2597+
2598+
harness.query.emit({
2599+
type: "system",
2600+
subtype: "hook_response",
2601+
hook_id: "resume-hook-1",
2602+
hook_name: "SessionStart:resume",
2603+
hook_event: "SessionStart",
2604+
output: "",
2605+
stdout: "",
2606+
stderr: "",
2607+
outcome: "success",
2608+
session_id: transientHookSessionId,
2609+
uuid: "resume-hook-response",
2610+
} as unknown as SDKMessage);
2611+
2612+
harness.query.emit({
2613+
type: "system",
2614+
subtype: "init",
2615+
apiKeySource: "none",
2616+
claude_code_version: "test",
2617+
cwd: "/tmp/claude-adapter-test",
2618+
tools: [],
2619+
mcp_servers: [],
2620+
model: "claude-sonnet-4-5",
2621+
permissionMode: "bypassPermissions",
2622+
slash_commands: [],
2623+
output_style: "default",
2624+
skills: [],
2625+
plugins: [],
2626+
session_id: durableSessionId,
2627+
uuid: "resume-init",
2628+
} as unknown as SDKMessage);
2629+
2630+
const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
2631+
const threadStartedEvents = runtimeEvents.filter((event) => event.type === "thread.started");
2632+
assert.equal(threadStartedEvents.length, 1);
2633+
const threadStarted = threadStartedEvents[0];
2634+
assert.equal(threadStarted?.type, "thread.started");
2635+
if (threadStarted?.type === "thread.started") {
2636+
assert.deepEqual(threadStarted.payload, {
2637+
providerThreadId: durableSessionId,
2638+
});
2639+
}
2640+
2641+
const activeSessions = yield* adapter.listSessions();
2642+
const resumeCursor = activeSessions[0]?.resumeCursor as
2643+
| {
2644+
readonly resume?: string;
2645+
}
2646+
| undefined;
2647+
assert.equal(resumeCursor?.resume, durableSessionId);
2648+
}).pipe(
2649+
Effect.provideService(Random.Random, makeDeterministicRandomService()),
2650+
Effect.provide(harness.layer),
2651+
);
2652+
});
2653+
25642654
it.effect("uses an app-generated Claude session id for fresh sessions", () => {
25652655
const harness = makeHarness();
25662656
return Effect.gen(function* () {

apps/server/src/provider/Layers/ClaudeAdapter.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,18 @@ function isSyntheticClaudeThreadId(value: string): boolean {
198198
return value.startsWith("claude-thread-");
199199
}
200200

201+
function hasDurableClaudeSessionId(message: SDKMessage): boolean {
202+
if (message.type !== "system") {
203+
return true;
204+
}
205+
206+
return (
207+
message.subtype !== "hook_started" &&
208+
message.subtype !== "hook_progress" &&
209+
message.subtype !== "hook_response"
210+
);
211+
}
212+
201213
function toMessage(cause: unknown, fallback: string): string {
202214
if (cause instanceof Error && cause.message.length > 0) {
203215
return cause.message;
@@ -1249,6 +1261,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
12491261
if (typeof message.session_id !== "string" || message.session_id.length === 0) {
12501262
return;
12511263
}
1264+
if (!hasDurableClaudeSessionId(message)) {
1265+
return;
1266+
}
12521267
const nextThreadId = message.session_id;
12531268
context.resumeSessionId = message.session_id;
12541269
yield* updateResumeCursor(context);
@@ -2875,6 +2890,31 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
28752890
...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}),
28762891
};
28772892

2893+
yield* Effect.annotateCurrentSpan({
2894+
"provider.kind": PROVIDER,
2895+
"provider.thread_id": threadId,
2896+
"provider.runtime_mode": input.runtimeMode,
2897+
"claude.resume.source":
2898+
existingResumeSessionId !== undefined ? "resume-session" : "generated-session",
2899+
"claude.resume.thread_id": resumeState?.threadId ?? "",
2900+
"claude.resume.session_id": existingResumeSessionId ?? "",
2901+
"claude.resume.session_at": resumeState?.resumeSessionAt ?? "",
2902+
"claude.resume.turn_count": resumeState?.turnCount ?? -1,
2903+
"claude.query.cwd": input.cwd ?? "",
2904+
"claude.query.model": apiModelId ?? "",
2905+
"claude.query.effort": effectiveEffort ?? "",
2906+
"claude.query.permission_mode": permissionMode ?? "",
2907+
"claude.query.allow_dangerously_skip_permissions": permissionMode === "bypassPermissions",
2908+
"claude.query.resume": existingResumeSessionId ?? "",
2909+
"claude.query.session_id": newSessionId ?? "",
2910+
"claude.query.include_partial_messages": true,
2911+
"claude.query.additional_directories": input.cwd ? [input.cwd] : [],
2912+
"claude.query.setting_sources": [...CLAUDE_SETTING_SOURCES],
2913+
"claude.query.settings_json": JSON.stringify(settings),
2914+
"claude.query.extra_args_json": JSON.stringify(extraArgs),
2915+
"claude.query.path_to_executable": claudeBinaryPath,
2916+
});
2917+
28782918
const queryRuntime = yield* Effect.try({
28792919
try: () =>
28802920
createQuery({

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

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -929,9 +929,10 @@ routing.layer("ProviderServiceLive routing", (it) => {
929929
const provider = yield* ProviderService;
930930
const runtimeRepository = yield* ProviderSessionRuntimeRepository;
931931

932-
const session = yield* provider.startSession(asThreadId("thread-1"), {
932+
const threadId = asThreadId("thread-runtime-status");
933+
const session = yield* provider.startSession(threadId, {
933934
provider: "codex",
934-
threadId: asThreadId("thread-1"),
935+
threadId,
935936
runtimeMode: "full-access",
936937
});
937938
yield* provider.sendTurn({
@@ -957,7 +958,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
957958
lastError: string | null;
958959
lastRuntimeEvent: string | null;
959960
};
960-
assert.equal(runtimePayload.cwd, process.cwd());
961+
assert.equal(runtimePayload.cwd, session.cwd);
961962
assert.equal(runtimePayload.model, null);
962963
assert.equal(runtimePayload.activeTurnId, `turn-${String(session.threadId)}`);
963964
assert.equal(runtimePayload.lastError, null);
@@ -1058,6 +1059,94 @@ routing.layer("ProviderServiceLive routing", (it) => {
10581059
fs.rmSync(tempDir, { recursive: true, force: true });
10591060
}).pipe(Effect.provide(NodeServices.layer)),
10601061
);
1062+
1063+
it.effect(
1064+
"reuses persisted cwd when startSession resumes a claude session without cwd input",
1065+
() =>
1066+
Effect.gen(function* () {
1067+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-cwd-"));
1068+
const dbPath = path.join(tempDir, "orchestration.sqlite");
1069+
const persistenceLayer = makeSqlitePersistenceLive(dbPath);
1070+
const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe(
1071+
Layer.provide(persistenceLayer),
1072+
);
1073+
1074+
const firstClaude = makeFakeCodexAdapter("claudeAgent");
1075+
const firstRegistry: typeof ProviderAdapterRegistry.Service = {
1076+
getByProvider: (provider) =>
1077+
provider === "claudeAgent"
1078+
? Effect.succeed(firstClaude.adapter)
1079+
: Effect.fail(new ProviderUnsupportedError({ provider })),
1080+
listProviders: () => Effect.succeed(["claudeAgent"]),
1081+
};
1082+
const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe(
1083+
Layer.provide(runtimeRepositoryLayer),
1084+
);
1085+
const firstProviderLayer = makeProviderServiceLive().pipe(
1086+
Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)),
1087+
Layer.provide(firstDirectoryLayer),
1088+
Layer.provide(defaultServerSettingsLayer),
1089+
Layer.provide(AnalyticsService.layerTest),
1090+
);
1091+
1092+
const initial = yield* Effect.gen(function* () {
1093+
const provider = yield* ProviderService;
1094+
return yield* provider.startSession(asThreadId("thread-claude-cwd"), {
1095+
provider: "claudeAgent",
1096+
threadId: asThreadId("thread-claude-cwd"),
1097+
cwd: "/tmp/project-claude-cwd",
1098+
runtimeMode: "full-access",
1099+
});
1100+
}).pipe(Effect.provide(firstProviderLayer));
1101+
1102+
const secondClaude = makeFakeCodexAdapter("claudeAgent");
1103+
const secondRegistry: typeof ProviderAdapterRegistry.Service = {
1104+
getByProvider: (provider) =>
1105+
provider === "claudeAgent"
1106+
? Effect.succeed(secondClaude.adapter)
1107+
: Effect.fail(new ProviderUnsupportedError({ provider })),
1108+
listProviders: () => Effect.succeed(["claudeAgent"]),
1109+
};
1110+
const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe(
1111+
Layer.provide(runtimeRepositoryLayer),
1112+
);
1113+
const secondProviderLayer = makeProviderServiceLive().pipe(
1114+
Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)),
1115+
Layer.provide(secondDirectoryLayer),
1116+
Layer.provide(defaultServerSettingsLayer),
1117+
Layer.provide(AnalyticsService.layerTest),
1118+
);
1119+
1120+
secondClaude.startSession.mockClear();
1121+
1122+
yield* Effect.gen(function* () {
1123+
const provider = yield* ProviderService;
1124+
yield* provider.startSession(initial.threadId, {
1125+
provider: "claudeAgent",
1126+
threadId: initial.threadId,
1127+
runtimeMode: "full-access",
1128+
});
1129+
}).pipe(Effect.provide(secondProviderLayer));
1130+
1131+
assert.equal(secondClaude.startSession.mock.calls.length, 1);
1132+
const resumedStartInput = secondClaude.startSession.mock.calls[0]?.[0];
1133+
assert.equal(typeof resumedStartInput === "object" && resumedStartInput !== null, true);
1134+
if (resumedStartInput && typeof resumedStartInput === "object") {
1135+
const startPayload = resumedStartInput as {
1136+
provider?: string;
1137+
cwd?: string;
1138+
resumeCursor?: unknown;
1139+
threadId?: string;
1140+
};
1141+
assert.equal(startPayload.provider, "claudeAgent");
1142+
assert.equal(startPayload.cwd, "/tmp/project-claude-cwd");
1143+
assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);
1144+
assert.equal(startPayload.threadId, initial.threadId);
1145+
}
1146+
1147+
fs.rmSync(tempDir, { recursive: true, force: true });
1148+
}).pipe(Effect.provide(NodeServices.layer)),
1149+
);
10611150
});
10621151

10631152
const fanout = makeProviderServiceLayer();

0 commit comments

Comments
 (0)