Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1287,6 +1287,96 @@ describe("ClaudeAdapterV2 executable path", () => {
);
});

describe("ClaudeAdapterV2 resumeThread", () => {
it.effect("fails for a transcript without messages so a fresh session replaces it", () =>
Effect.scoped(
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const configDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-claude-empty-session-",
});
const projectDir = path.join(configDir, "projects", "-workspace");
yield* fileSystem.makeDirectory(projectDir, { recursive: true });
// What a CLI stopped before it saved the first prompt leaves behind.
yield* fileSystem.writeFileString(
path.join(projectDir, "native-thread-empty.jsonl"),
[
`{"type":"last-prompt","lastPrompt":"hello","sessionId":"native-thread-empty"}`,
`{"type":"atis-latch","atis":"","sessionId":"native-thread-empty"}`,
`{"type":"cost-state","sessionId":"native-thread-empty","totalCostUSD":0}`,
"",
].join("\n"),
);
yield* fileSystem.writeFileString(
path.join(projectDir, "native-thread-saved.jsonl"),
`{"type":"user","message":{"role":"user","content":"hello"},"sessionId":"native-thread-saved"}\n`,
);
const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({
instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
settings: DEFAULT_CLAUDE_SETTINGS,
environment: { CLAUDE_CONFIG_DIR: configDir },
attachmentsDir: configDir,
fileSystem,
path,
crypto: yield* Crypto.Crypto,
idAllocator: yield* IdAllocator.IdAllocatorV2,
queryRunner: {
allocateSessionId: Effect.succeed("native-thread-empty"),
open: () => Effect.die("unused"),
forkSession: () => Effect.die("unused"),
subagentLaunchToolUseId: () => Effect.succeed(null),
assertComplete: Effect.void,
},
});
const threadId = ThreadId.make("thread-claude-empty-session");
const runtime = yield* adapter.openSession({
threadId,
providerSessionId: ProviderSessionId.make("provider-session-claude-empty-session"),
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 error = yield* Effect.flip(runtime.resumeThread({ providerThread }));
assert.equal(error._tag, "ProviderAdapterResumeThreadError");

const saved = yield* runtime.resumeThread({
providerThread: {
...providerThread,
nativeThreadRef: {
driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
nativeId: "native-thread-saved",
strength: "strong",
},
},
});
assert.equal(saved.nativeThreadRef?.nativeId, "native-thread-saved");

const missing = yield* runtime.resumeThread({
providerThread: {
...providerThread,
nativeThreadRef: {
driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
nativeId: "native-thread-missing",
strength: "strong",
},
},
});
assert.equal(missing.nativeThreadRef?.nativeId, "native-thread-missing");
}),
).pipe(
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
);
});

describe("ClaudeAdapterV2 resume compaction", () => {
it.effect("resolves and cancels the SDK resume dialog through structured runtime input", () =>
Effect.scoped(
Expand Down
58 changes: 58 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1409,6 +1409,16 @@ function makeProviderThread(input: {
};
}

const decodeClaudeTranscriptLine = Schema.decodeUnknownOption(
Schema.fromJsonString(Schema.Struct({ type: Schema.String })),
);

const isClaudeConversationLine = (line: string): boolean =>
Option.match(decodeClaudeTranscriptLine(line), {
onNone: () => false,
onSome: (entry) => entry.type === "user" || entry.type === "assistant",
});

const getNativeThreadId = Effect.fnUntraced(function* (
providerThread: OrchestrationV2ProviderThread,
) {
Expand Down Expand Up @@ -9103,6 +9113,38 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
});
yield* Effect.addFinalizer(() => closeSession());

// A CLI stopped before it saved the first prompt can leave a transcript
// with only metadata lines. Claude can neither resume that id nor
// create it again, so resumeThread fails and the caller binds a fresh
// native session with a context 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(configuredDir)
: path.join(yield* HostProcess.HomeDirectory, ".claude"),
"projects",
);
for (const project of yield* fileSystem.readDirectory(projectsDir)) {
const transcriptPath = path.join(projectsDir, project, `${nativeThreadId}.jsonl`);
if (!(yield* fileSystem.exists(transcriptPath))) {
continue;
}
const conversationLine = yield* fileSystem
.stream(transcriptPath)
.pipe(
Stream.decodeText,
Stream.splitLines,
Stream.filter(isClaudeConversationLine),
Stream.runHead,
);
return Option.isNone(conversationLine);
}
return false;
},
);

const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = {
instanceId: adapterOptions.instanceId,
driver: CLAUDE_PROVIDER,
Expand Down Expand Up @@ -9182,6 +9224,22 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
),
resumeThread: Effect.fn("ClaudeAdapterV2.resumeThread")(
function* (threadInput: { readonly providerThread: OrchestrationV2ProviderThread }) {
const nativeThreadId = threadInput.providerThread.nativeThreadRef?.nativeId;
if (
nativeThreadId !== undefined &&
nativeThreadId !== null &&
(yield* Ref.get(queryContext))?.nativeThreadId !== nativeThreadId &&
!(yield* Ref.get(openedNativeThreads)).has(nativeThreadId) &&
// An unreadable transcript leaves the resume to the CLI, as before.
(yield* hasEmptyClaudeTranscript(nativeThreadId).pipe(
Effect.orElseSucceed(() => false),
))
) {
return yield* new ProviderAdapter.ProviderAdapterProtocolError({
driver: CLAUDE_PROVIDER,
detail: `Claude session ${nativeThreadId} has no conversation to resume.`,
});
}
const updatedAt = yield* DateTime.now;
return {
...threadInput.providerThread,
Expand Down
Loading