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", () => {...@@ -386,7 +386,7 @@ describe("ClaudeCodeAdapterLive", () => {
386 provider: "claudeCode",386 provider: "claudeCode",
387 resumeCursor: {387 resumeCursor: {
388 threadId: "resume-thread-1",388 threadId: "resume-thread-1",
389 resume: "resume-session-1",389 resume: "550e8400-e29b-41d4-a716-446655440000",
390 resumeSessionAt: "assistant-99",390 resumeSessionAt: "assistant-99",
391 turnCount: 3,391 turnCount: 3,
392 },392 },
...@@ -395,13 +395,13 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -395,13 +395,13 @@ describe("ClaudeCodeAdapterLive", () => {
395 assert.equal(session.threadId, "resume-thread-1");395 assert.equal(session.threadId, "resume-thread-1");
396 assert.deepEqual(session.resumeCursor, {396 assert.deepEqual(session.resumeCursor, {
397 threadId: "resume-thread-1",397 threadId: "resume-thread-1",
398 resume: "resume-session-1",398 resume: "550e8400-e29b-41d4-a716-446655440000",
399 resumeSessionAt: "assistant-99",399 resumeSessionAt: "assistant-99",
400 turnCount: 3,400 turnCount: 3,
401 });401 });
402402
403 const createInput = harness.getLastCreateQueryInput();403 const createInput = harness.getLastCreateQueryInput();
404 assert.equal(createInput?.options.resume, "resume-session-1");404 assert.equal(createInput?.options.resume, "550e8400-e29b-41d4-a716-446655440000");
405 assert.equal(createInput?.options.resumeSessionAt, "assistant-99");405 assert.equal(createInput?.options.resumeSessionAt, "assistant-99");
406 }).pipe(406 }).pipe(
407 Effect.provideService(Random.Random, makeDeterministicRandomService()),407 Effect.provideService(Random.Random, makeDeterministicRandomService()),
...@@ -409,6 +409,28 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -409,6 +409,28 @@ describe("ClaudeCodeAdapterLive", () => {
409 );409 );
410 });410 });
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
412 it.effect("supports rollbackThread by trimming in-memory turns and preserving earlier turns", () => {434 it.effect("supports rollbackThread by trimming in-memory turns and preserving earlier turns", () => {
413 const harness = makeHarness();435 const harness = makeHarness();
414 return Effect.gen(function* () {436 return Effect.gen(function* () {
apps/server/src/provider/Layers/ClaudeCodeAdapter.ts+13-3
...@@ -88,6 +88,7 @@ interface ClaudeSessionContext {...@@ -88,6 +88,7 @@ interface ClaudeSessionContext {
88 readonly promptQueue: Queue.Queue<PromptQueueItem>;88 readonly promptQueue: Queue.Queue<PromptQueueItem>;
89 readonly query: ClaudeQueryRuntime;89 readonly query: ClaudeQueryRuntime;
90 readonly startedAt: string;90 readonly startedAt: string;
91 resumeSessionId: string | undefined;
91 readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>;92 readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>;
92 readonly turns: Array<{93 readonly turns: Array<{
93 id: ReturnType<typeof ProviderTurnId.makeUnsafe>;94 id: ReturnType<typeof ProviderTurnId.makeUnsafe>;
...@@ -115,6 +116,12 @@ export interface ClaudeCodeAdapterLiveOptions {...@@ -115,6 +116,12 @@ export interface ClaudeCodeAdapterLiveOptions {
115 }) => ClaudeQueryRuntime;116 }) => ClaudeQueryRuntime;
116}117}
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
118function toMessage(cause: unknown, fallback: string): string {125function toMessage(cause: unknown, fallback: string): string {
119 if (cause instanceof Error && cause.message.length > 0) {126 if (cause instanceof Error && cause.message.length > 0) {
120 return cause.message;127 return cause.message;
...@@ -148,12 +155,13 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef...@@ -148,12 +155,13 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef
148 };155 };
149156
150 const threadId = typeof cursor.threadId === "string" ? cursor.threadId : undefined;157 const threadId = typeof cursor.threadId === "string" ? cursor.threadId : undefined;
151 const resume =158 const resumeCandidate =
152 typeof cursor.resume === "string"159 typeof cursor.resume === "string"
153 ? cursor.resume160 ? cursor.resume
154 : typeof cursor.sessionId === "string"161 : typeof cursor.sessionId === "string"
155 ? cursor.sessionId162 ? cursor.sessionId
156 : undefined;163 : undefined;
164 const resume = resumeCandidate && isUuid(resumeCandidate) ? resumeCandidate : undefined;
157 const resumeSessionAt =165 const resumeSessionAt =
158 typeof cursor.resumeSessionAt === "string" ? cursor.resumeSessionAt : undefined;166 typeof cursor.resumeSessionAt === "string" ? cursor.resumeSessionAt : undefined;
159 const turnCountValue = typeof cursor.turnCount === "number" ? cursor.turnCount : undefined;167 const turnCountValue = typeof cursor.turnCount === "number" ? cursor.turnCount : undefined;
...@@ -351,7 +359,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -351,7 +359,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
351359
352 const resumeCursor = {360 const resumeCursor = {
353 threadId,361 threadId,
354 resume: threadId,362 ...(context.resumeSessionId ? { resume: context.resumeSessionId } : {}),
355 ...(context.lastAssistantUuid ? { resumeSessionAt: context.lastAssistantUuid } : {}),363 ...(context.lastAssistantUuid ? { resumeSessionAt: context.lastAssistantUuid } : {}),
356 turnCount: context.turns.length,364 turnCount: context.turns.length,
357 };365 };
...@@ -369,6 +377,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -369,6 +377,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
369 ): Effect.Effect<void> =>377 ): Effect.Effect<void> =>
370 Effect.gen(function* () {378 Effect.gen(function* () {
371 const nextThreadId = ProviderThreadId.makeUnsafe(message.session_id);379 const nextThreadId = ProviderThreadId.makeUnsafe(message.session_id);
380 context.resumeSessionId = message.session_id;
372 const changed = context.session.threadId !== nextThreadId;381 const changed = context.session.threadId !== nextThreadId;
373382
374 if (changed) {383 if (changed) {
...@@ -935,7 +944,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -935,7 +944,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
935 threadId,944 threadId,
936 resumeCursor: {945 resumeCursor: {
937 threadId,946 threadId,
938 resume: resumeState?.resume ?? threadId,947 ...(resumeState?.resume ? { resume: resumeState.resume } : {}),
939 ...(resumeState?.resumeSessionAt948 ...(resumeState?.resumeSessionAt
940 ? { resumeSessionAt: resumeState.resumeSessionAt }949 ? { resumeSessionAt: resumeState.resumeSessionAt }
941 : {}),950 : {}),
...@@ -950,6 +959,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -950,6 +959,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
950 promptQueue,959 promptQueue,
951 query: queryRuntime,960 query: queryRuntime,
952 startedAt,961 startedAt,
962 resumeSessionId: resumeState?.resume,
953 pendingApprovals,963 pendingApprovals,
954 turns: [],964 turns: [],
955 inFlightTools,965 inFlightTools,