Skip to content

Commit 61c3794

Browse files
fix(server): imported Claude sessions resume on their first turn (#23219)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 3e1e776 commit 61c3794

2 files changed

Lines changed: 130 additions & 51 deletions

File tree

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts‎

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2715,22 +2715,40 @@ describe("ClaudeAdapterV2 MCP credential channel", () => {
27152715
});
27162716

27172717
describe("ClaudeAdapterV2 native session identity", () => {
2718-
const openTurnWithOrdinal = (providerTurnOrdinal: number, nativeThreadHasTurns?: boolean) =>
2718+
const openTurnWithOrdinal = (
2719+
providerTurnOrdinal: number,
2720+
nativeThreadHasTurns?: boolean,
2721+
importedNativeThreadId?: string,
2722+
) =>
27192723
Effect.scoped(
27202724
Effect.gen(function* () {
27212725
const fileSystem = yield* FileSystem.FileSystem;
27222726
const idAllocator = yield* IdAllocator.IdAllocatorV2;
2727+
const path = yield* Path.Path;
27232728
const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({
27242729
prefix: "t3-claude-v2-session-identity-",
27252730
});
2731+
if (importedNativeThreadId !== undefined) {
2732+
// An unreadable copy in another project must not hide the readable one.
2733+
yield* fileSystem.makeDirectory(
2734+
path.join(attachmentsDir, "projects", "-other", `${importedNativeThreadId}.jsonl`),
2735+
{ recursive: true },
2736+
);
2737+
const projectDir = path.join(attachmentsDir, "projects", "-workspace");
2738+
yield* fileSystem.makeDirectory(projectDir, { recursive: true });
2739+
yield* fileSystem.writeFileString(
2740+
path.join(projectDir, `${importedNativeThreadId}.jsonl`),
2741+
`{"type":"user","message":{"role":"user","content":"hello"},"sessionId":"${importedNativeThreadId}"}\n`,
2742+
);
2743+
}
27262744
const openedQueries: Array<ClaudeAdapterV2.ClaudeAgentSdkQueryOpenInput> = [];
27272745
const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({
27282746
instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
27292747
settings: DEFAULT_CLAUDE_SETTINGS,
2730-
environment: {},
2748+
environment: { CLAUDE_CONFIG_DIR: attachmentsDir },
27312749
attachmentsDir,
27322750
fileSystem,
2733-
path: yield* Path.Path,
2751+
path,
27342752
random: yield* SecureRandom.SecureRandom,
27352753
idAllocator,
27362754
queryRunner: {
@@ -2763,12 +2781,49 @@ describe("ClaudeAdapterV2 native session identity", () => {
27632781
modelSelection: CLAUDE_TEST_MODEL_SELECTION,
27642782
runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
27652783
});
2766-
const providerThread = yield* runtime.ensureThread({
2767-
threadId,
2768-
modelSelection: CLAUDE_TEST_MODEL_SELECTION,
2769-
runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
2770-
});
27712784
const now = yield* DateTime.now;
2785+
// Mirrors the provider thread a session import records: a strong native
2786+
// ref with no conversation head and no provider turns.
2787+
const providerThread =
2788+
importedNativeThreadId === undefined
2789+
? yield* runtime.ensureThread({
2790+
threadId,
2791+
modelSelection: CLAUDE_TEST_MODEL_SELECTION,
2792+
runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
2793+
})
2794+
: yield* runtime.resumeThread({
2795+
threadId,
2796+
modelSelection: CLAUDE_TEST_MODEL_SELECTION,
2797+
runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
2798+
providerThread: {
2799+
id: idAllocator.derive.providerThread({
2800+
driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
2801+
providerInstanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
2802+
nativeThreadId: importedNativeThreadId,
2803+
}),
2804+
driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
2805+
providerInstanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
2806+
providerSessionId: null,
2807+
appThreadId: threadId,
2808+
ownerNodeId: null,
2809+
nativeThreadRef: {
2810+
driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
2811+
nativeId: importedNativeThreadId,
2812+
strength: "strong",
2813+
},
2814+
nativeConversationHeadRef: null,
2815+
status: "not_loaded",
2816+
firstRunOrdinal: null,
2817+
lastRunOrdinal: null,
2818+
handoffIds: [],
2819+
forkedFrom: null,
2820+
contextUsage: null,
2821+
nativeMetadata: null,
2822+
goal: null,
2823+
createdAt: now,
2824+
updatedAt: now,
2825+
},
2826+
});
27722827
yield* runtime.startTurn(
27732828
makeClaudeTestTurnInput({
27742829
threadId,
@@ -2814,6 +2869,15 @@ describe("ClaudeAdapterV2 native session identity", () => {
28142869
assert.equal(openedQueries[0]?.options.title, "Claude attachment test");
28152870
}),
28162871
);
2872+
2873+
it.effect("resumes an imported native session on its first T3 turn", () =>
2874+
Effect.gen(function* () {
2875+
const openedQueries = yield* openTurnWithOrdinal(1, false, "imported-native-session");
2876+
assert.equal(openedQueries.length, 1);
2877+
assert.equal(openedQueries[0]?.options.resume, "imported-native-session");
2878+
assert.equal(openedQueries[0]?.options.sessionId, undefined);
2879+
}),
2880+
);
28172881
});
28182882

28192883
const encodeJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts‎

Lines changed: 58 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -11301,41 +11301,47 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
1130111301
yield* Effect.addFinalizer(() => closeSession());
1130211302

1130311303
// Metadata-only transcripts cannot be resumed or recreated; fail resumeThread to trigger a fresh session and handoff.
11304-
const hasEmptyClaudeTranscript = Effect.fn("ClaudeAdapterV2.hasEmptyClaudeTranscript")(
11305-
function* (nativeThreadId: string) {
11306-
const configuredDir = adapterOptions.environment.CLAUDE_CONFIG_DIR?.trim() ?? "";
11307-
const projectsDir = path.join(
11308-
configuredDir.length > 0
11309-
? path.resolve(session.cwd, configuredDir)
11310-
: path.join(yield* HostProcess.HomeDirectory, ".claude"),
11311-
"projects",
11312-
);
11313-
// Any message in any transcript copy means Claude may still resume it.
11314-
let found = false;
11315-
for (const project of yield* fileSystem.readDirectory(projectsDir)) {
11316-
const transcriptPath = path.join(projectsDir, project, `${nativeThreadId}.jsonl`);
11317-
// Stray files beside the project dirs fail with ENOTDIR.
11318-
if (
11319-
!(yield* fileSystem.exists(transcriptPath).pipe(Effect.orElseSucceed(() => false)))
11320-
) {
11321-
continue;
11322-
}
11323-
const conversationLine = yield* fileSystem
11324-
.stream(transcriptPath)
11325-
.pipe(
11326-
Stream.decodeText,
11327-
Stream.splitLines,
11328-
Stream.filter(isClaudeConversationLine),
11329-
Stream.runHead,
11330-
);
11331-
if (Option.isSome(conversationLine)) {
11332-
return false;
11333-
}
11334-
found = true;
11304+
const claudeTranscriptState = Effect.fn("ClaudeAdapterV2.claudeTranscriptState")(function* (
11305+
nativeThreadId: string,
11306+
) {
11307+
const configuredDir = adapterOptions.environment.CLAUDE_CONFIG_DIR?.trim() ?? "";
11308+
const projectsDir = path.join(
11309+
configuredDir.length > 0
11310+
? path.resolve(session.cwd, configuredDir)
11311+
: path.join(yield* HostProcess.HomeDirectory, ".claude"),
11312+
"projects",
11313+
);
11314+
// Any message in any transcript copy means Claude may still resume it.
11315+
let found = false;
11316+
let unreadable = false;
11317+
for (const project of yield* fileSystem.readDirectory(projectsDir)) {
11318+
const transcriptPath = path.join(projectsDir, project, `${nativeThreadId}.jsonl`);
11319+
// Stray files beside the project dirs fail with ENOTDIR.
11320+
if (
11321+
!(yield* fileSystem.exists(transcriptPath).pipe(Effect.orElseSucceed(() => false)))
11322+
) {
11323+
continue;
1133511324
}
11336-
return found;
11337-
},
11338-
);
11325+
const conversationLine = yield* fileSystem
11326+
.stream(transcriptPath)
11327+
.pipe(
11328+
Stream.decodeText,
11329+
Stream.splitLines,
11330+
Stream.filter(isClaudeConversationLine),
11331+
Stream.runHead,
11332+
Effect.option,
11333+
);
11334+
if (Option.isNone(conversationLine)) {
11335+
unreadable = true;
11336+
continue;
11337+
}
11338+
if (Option.isSome(conversationLine.value)) {
11339+
return "conversation" as const;
11340+
}
11341+
found = true;
11342+
}
11343+
return found && !unreadable ? ("empty" as const) : ("missing" as const);
11344+
});
1133911345

1134011346
const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = {
1134111347
instanceId: adapterOptions.instanceId,
@@ -11427,16 +11433,25 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
1142711433
nativeThreadId !== undefined &&
1142811434
nativeThreadId !== null &&
1142911435
// A live process holds the conversation even before saving it.
11430-
(yield* Ref.get(queryContext))?.nativeThreadId !== nativeThreadId &&
11431-
// An unreadable transcript leaves the resume to the CLI.
11432-
(yield* hasEmptyClaudeTranscript(nativeThreadId).pipe(
11433-
Effect.orElseSucceed(() => false),
11434-
))
11436+
(yield* Ref.get(queryContext))?.nativeThreadId !== nativeThreadId
1143511437
) {
11436-
return yield* new ProviderAdapter.ProviderAdapterProtocolError({
11437-
driver: CLAUDE_PROVIDER,
11438-
detail: `Claude session ${nativeThreadId} has no conversation to resume.`,
11439-
});
11438+
// An unreadable transcript leaves the resume to the CLI.
11439+
const transcript = yield* claudeTranscriptState(nativeThreadId).pipe(
11440+
Effect.orElseSucceed(() => "missing" as const),
11441+
);
11442+
if (transcript === "empty") {
11443+
return yield* new ProviderAdapter.ProviderAdapterProtocolError({
11444+
driver: CLAUDE_PROVIDER,
11445+
detail: `Claude session ${nativeThreadId} has no conversation to resume.`,
11446+
});
11447+
}
11448+
// A session imported from the terminal has history but no T3
11449+
// provider turns; opening it with sessionId fails "already in use".
11450+
if (transcript === "conversation") {
11451+
yield* Ref.update(openedNativeThreads, (current) =>
11452+
current.has(nativeThreadId) ? current : new Set(current).add(nativeThreadId),
11453+
);
11454+
}
1144011455
}
1144111456
const updatedAt = yield* DateTime.now;
1144211457
return {

0 commit comments

Comments
 (0)