authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-27 12:27:09-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:00:41-08:00
log061633891162bdc03d7d9be809b6a9a211c776ba
treefc015531c7e48c81b9fa4bd7322d8e0ca2d62b81
parent58427d565d91c27f1f37b3e0d1a396348c8afe6a

Fix Claude resume cursor to only persist valid session UUIDs

- Stop synthesizing `resume` from generated thread IDs - Persist resume session ID from query messages when available - Validate resume/sessionId values as UUIDs before reuse - Add tests for valid UUID resume passthrough and no synthesized resume

2 files changed, 38 insertions(+), 6 deletions(-)

apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts+25-3
......@@ -386,7 +386,7 @@ describe("ClaudeCodeAdapterLive", () => {
386386 provider: "claudeCode",
387387 resumeCursor: {
388388 threadId: "resume-thread-1",
389 resume: "resume-session-1",
389 resume: "550e8400-e29b-41d4-a716-446655440000",
390390 resumeSessionAt: "assistant-99",
391391 turnCount: 3,
392392 },
......@@ -395,13 +395,13 @@ describe("ClaudeCodeAdapterLive", () => {
395395 assert.equal(session.threadId, "resume-thread-1");
396396 assert.deepEqual(session.resumeCursor, {
397397 threadId: "resume-thread-1",
398 resume: "resume-session-1",
398 resume: "550e8400-e29b-41d4-a716-446655440000",
399399 resumeSessionAt: "assistant-99",
400400 turnCount: 3,
401401 });
402402
403403 const createInput = harness.getLastCreateQueryInput();
404 assert.equal(createInput?.options.resume, "resume-session-1");
404 assert.equal(createInput?.options.resume, "550e8400-e29b-41d4-a716-446655440000");
405405 assert.equal(createInput?.options.resumeSessionAt, "assistant-99");
406406 }).pipe(
407407 Effect.provideService(Random.Random, makeDeterministicRandomService()),
......@@ -409,6 +409,28 @@ describe("ClaudeCodeAdapterLive", () => {
409409 );
410410 });
411411
412 it.effect("does not synthesize resume session id from generated thread ids", () => {
413 const harness = makeHarness();
414 return Effect.gen(function* () {
415 const adapter = yield* ClaudeCodeAdapter;
416
417 const session = yield* adapter.startSession({
418 provider: "claudeCode",
419 });
420
421 assert.equal(
422 "resume" in (session.resumeCursor as Record<string, unknown>),
423 false,
424 );
425
426 const createInput = harness.getLastCreateQueryInput();
427 assert.equal(createInput?.options.resume, undefined);
428 }).pipe(
429 Effect.provideService(Random.Random, makeDeterministicRandomService()),
430 Effect.provide(harness.layer),
431 );
432 });
433
412434 it.effect("supports rollbackThread by trimming in-memory turns and preserving earlier turns", () => {
413435 const harness = makeHarness();
414436 return Effect.gen(function* () {
apps/server/src/provider/Layers/ClaudeCodeAdapter.ts+13-3
......@@ -88,6 +88,7 @@ interface ClaudeSessionContext {
8888 readonly promptQueue: Queue.Queue<PromptQueueItem>;
8989 readonly query: ClaudeQueryRuntime;
9090 readonly startedAt: string;
91 resumeSessionId: string | undefined;
9192 readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>;
9293 readonly turns: Array<{
9394 id: ReturnType<typeof ProviderTurnId.makeUnsafe>;
......@@ -115,6 +116,12 @@ export interface ClaudeCodeAdapterLiveOptions {
115116 }) => ClaudeQueryRuntime;
116117}
117118
119function isUuid(value: string): boolean {
120 return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
121 value,
122 );
123}
124
118125function toMessage(cause: unknown, fallback: string): string {
119126 if (cause instanceof Error && cause.message.length > 0) {
120127 return cause.message;
......@@ -148,12 +155,13 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef
148155 };
149156
150157 const threadId = typeof cursor.threadId === "string" ? cursor.threadId : undefined;
151 const resume =
158 const resumeCandidate =
152159 typeof cursor.resume === "string"
153160 ? cursor.resume
154161 : typeof cursor.sessionId === "string"
155162 ? cursor.sessionId
156163 : undefined;
164 const resume = resumeCandidate && isUuid(resumeCandidate) ? resumeCandidate : undefined;
157165 const resumeSessionAt =
158166 typeof cursor.resumeSessionAt === "string" ? cursor.resumeSessionAt : undefined;
159167 const turnCountValue = typeof cursor.turnCount === "number" ? cursor.turnCount : undefined;
......@@ -351,7 +359,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
351359
352360 const resumeCursor = {
353361 threadId,
354 resume: threadId,
362 ...(context.resumeSessionId ? { resume: context.resumeSessionId } : {}),
355363 ...(context.lastAssistantUuid ? { resumeSessionAt: context.lastAssistantUuid } : {}),
356364 turnCount: context.turns.length,
357365 };
......@@ -369,6 +377,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
369377 ): Effect.Effect<void> =>
370378 Effect.gen(function* () {
371379 const nextThreadId = ProviderThreadId.makeUnsafe(message.session_id);
380 context.resumeSessionId = message.session_id;
372381 const changed = context.session.threadId !== nextThreadId;
373382
374383 if (changed) {
......@@ -935,7 +944,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
935944 threadId,
936945 resumeCursor: {
937946 threadId,
938 resume: resumeState?.resume ?? threadId,
947 ...(resumeState?.resume ? { resume: resumeState.resume } : {}),
939948 ...(resumeState?.resumeSessionAt
940949 ? { resumeSessionAt: resumeState.resumeSessionAt }
941950 : {}),
......@@ -950,6 +959,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
950959 promptQueue,
951960 query: queryRuntime,
952961 startedAt,
962 resumeSessionId: resumeState?.resume,
953963 pendingApprovals,
954964 turns: [],
955965 inFlightTools,