Skip to content
Merged
80 changes: 72 additions & 8 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2715,22 +2715,40 @@ describe("ClaudeAdapterV2 MCP credential channel", () => {
});

describe("ClaudeAdapterV2 native session identity", () => {
const openTurnWithOrdinal = (providerTurnOrdinal: number, nativeThreadHasTurns?: boolean) =>
const openTurnWithOrdinal = (
providerTurnOrdinal: number,
nativeThreadHasTurns?: boolean,
importedNativeThreadId?: string,
) =>
Effect.scoped(
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const idAllocator = yield* IdAllocator.IdAllocatorV2;
const path = yield* Path.Path;
const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-claude-v2-session-identity-",
});
if (importedNativeThreadId !== undefined) {
// An unreadable copy in another project must not hide the readable one.
yield* fileSystem.makeDirectory(
path.join(attachmentsDir, "projects", "-other", `${importedNativeThreadId}.jsonl`),
{ recursive: true },
);
const projectDir = path.join(attachmentsDir, "projects", "-workspace");
yield* fileSystem.makeDirectory(projectDir, { recursive: true });
yield* fileSystem.writeFileString(
path.join(projectDir, `${importedNativeThreadId}.jsonl`),
`{"type":"user","message":{"role":"user","content":"hello"},"sessionId":"${importedNativeThreadId}"}\n`,
);
}
const openedQueries: Array<ClaudeAdapterV2.ClaudeAgentSdkQueryOpenInput> = [];
const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({
instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
settings: DEFAULT_CLAUDE_SETTINGS,
environment: {},
environment: { CLAUDE_CONFIG_DIR: attachmentsDir },
attachmentsDir,
fileSystem,
path: yield* Path.Path,
path,
random: yield* SecureRandom.SecureRandom,
idAllocator,
queryRunner: {
Expand Down Expand Up @@ -2763,12 +2781,49 @@ describe("ClaudeAdapterV2 native session identity", () => {
modelSelection: CLAUDE_TEST_MODEL_SELECTION,
runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
});
const providerThread = yield* runtime.ensureThread({
threadId,
modelSelection: CLAUDE_TEST_MODEL_SELECTION,
runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
});
const now = yield* DateTime.now;
// Mirrors the provider thread a session import records: a strong native
// ref with no conversation head and no provider turns.
const providerThread =
importedNativeThreadId === undefined
? yield* runtime.ensureThread({
threadId,
modelSelection: CLAUDE_TEST_MODEL_SELECTION,
runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
})
: yield* runtime.resumeThread({
threadId,
modelSelection: CLAUDE_TEST_MODEL_SELECTION,
runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
providerThread: {
id: idAllocator.derive.providerThread({
driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
providerInstanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
nativeThreadId: importedNativeThreadId,
}),
driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
providerInstanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
providerSessionId: null,
appThreadId: threadId,
ownerNodeId: null,
nativeThreadRef: {
driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
nativeId: importedNativeThreadId,
strength: "strong",
},
nativeConversationHeadRef: null,
status: "not_loaded",
firstRunOrdinal: null,
lastRunOrdinal: null,
handoffIds: [],
forkedFrom: null,
contextUsage: null,
nativeMetadata: null,
goal: null,
createdAt: now,
updatedAt: now,
},
});
yield* runtime.startTurn(
makeClaudeTestTurnInput({
threadId,
Expand Down Expand Up @@ -2814,6 +2869,15 @@ describe("ClaudeAdapterV2 native session identity", () => {
assert.equal(openedQueries[0]?.options.title, "Claude attachment test");
}),
);

it.effect("resumes an imported native session on its first T3 turn", () =>
Effect.gen(function* () {
const openedQueries = yield* openTurnWithOrdinal(1, false, "imported-native-session");
assert.equal(openedQueries.length, 1);
assert.equal(openedQueries[0]?.options.resume, "imported-native-session");
assert.equal(openedQueries[0]?.options.sessionId, undefined);
}),
);
});

const encodeJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
Expand Down
101 changes: 58 additions & 43 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11279,41 +11279,47 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
yield* Effect.addFinalizer(() => closeSession());

// Metadata-only transcripts cannot be resumed or recreated; fail resumeThread to trigger a fresh session and handoff.
const hasEmptyClaudeTranscript = Effect.fn("ClaudeAdapterV2.hasEmptyClaudeTranscript")(
function* (nativeThreadId: string) {
const configuredDir = adapterOptions.environment.CLAUDE_CONFIG_DIR?.trim() ?? "";
const projectsDir = path.join(
configuredDir.length > 0
? path.resolve(session.cwd, configuredDir)
: path.join(yield* HostProcess.HomeDirectory, ".claude"),
"projects",
);
// Any message in any transcript copy means Claude may still resume it.
let found = false;
for (const project of yield* fileSystem.readDirectory(projectsDir)) {
const transcriptPath = path.join(projectsDir, project, `${nativeThreadId}.jsonl`);
// Stray files beside the project dirs fail with ENOTDIR.
if (
!(yield* fileSystem.exists(transcriptPath).pipe(Effect.orElseSucceed(() => false)))
) {
continue;
}
const conversationLine = yield* fileSystem
.stream(transcriptPath)
.pipe(
Stream.decodeText,
Stream.splitLines,
Stream.filter(isClaudeConversationLine),
Stream.runHead,
);
if (Option.isSome(conversationLine)) {
return false;
}
found = true;
const claudeTranscriptState = Effect.fn("ClaudeAdapterV2.claudeTranscriptState")(function* (
nativeThreadId: string,
) {
const configuredDir = adapterOptions.environment.CLAUDE_CONFIG_DIR?.trim() ?? "";
const projectsDir = path.join(
configuredDir.length > 0
? path.resolve(session.cwd, configuredDir)
: path.join(yield* HostProcess.HomeDirectory, ".claude"),
"projects",
);
// Any message in any transcript copy means Claude may still resume it.
let found = false;
let unreadable = false;
for (const project of yield* fileSystem.readDirectory(projectsDir)) {
const transcriptPath = path.join(projectsDir, project, `${nativeThreadId}.jsonl`);
// Stray files beside the project dirs fail with ENOTDIR.
if (
!(yield* fileSystem.exists(transcriptPath).pipe(Effect.orElseSucceed(() => false)))
) {
continue;
}
return found;
},
);
const conversationLine = yield* fileSystem
.stream(transcriptPath)
.pipe(
Stream.decodeText,
Stream.splitLines,
Stream.filter(isClaudeConversationLine),
Stream.runHead,
Effect.option,
);
if (Option.isNone(conversationLine)) {
unreadable = true;
continue;
}
if (Option.isSome(conversationLine.value)) {
return "conversation" as const;
}
found = true;
}
return found && !unreadable ? ("empty" as const) : ("missing" as const);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = {
instanceId: adapterOptions.instanceId,
Expand Down Expand Up @@ -11405,16 +11411,25 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
nativeThreadId !== undefined &&
nativeThreadId !== null &&
// A live process holds the conversation even before saving it.
(yield* Ref.get(queryContext))?.nativeThreadId !== nativeThreadId &&
// An unreadable transcript leaves the resume to the CLI.
(yield* hasEmptyClaudeTranscript(nativeThreadId).pipe(
Effect.orElseSucceed(() => false),
))
(yield* Ref.get(queryContext))?.nativeThreadId !== nativeThreadId
) {
return yield* new ProviderAdapter.ProviderAdapterProtocolError({
driver: CLAUDE_PROVIDER,
detail: `Claude session ${nativeThreadId} has no conversation to resume.`,
});
// An unreadable transcript leaves the resume to the CLI.
const transcript = yield* claudeTranscriptState(nativeThreadId).pipe(
Effect.orElseSucceed(() => "missing" as const),
);
if (transcript === "empty") {
return yield* new ProviderAdapter.ProviderAdapterProtocolError({
driver: CLAUDE_PROVIDER,
detail: `Claude session ${nativeThreadId} has no conversation to resume.`,
});
}
// A session imported from the terminal has history but no T3
// provider turns; opening it with sessionId fails "already in use".
if (transcript === "conversation") {
yield* Ref.update(openedNativeThreads, (current) =>
current.has(nativeThreadId) ? current : new Set(current).add(nativeThreadId),
);
}
}
const updatedAt = yield* DateTime.now;
return {
Expand Down
Loading