Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
61 changes: 61 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,67 @@ 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 adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({
instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
settings: DEFAULT_CLAUDE_SETTINGS,
environment: {},
attachmentsDir: yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-claude-empty-session-",
}),
fileSystem,
path: yield* Path.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),
sessionIsEmpty: (input) => Effect.succeed(input.sessionId === "native-thread-empty"),
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");
}),
).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
57 changes: 57 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
type EffortLevel,
type ForkSessionOptions,
type ForkSessionResult,
getSessionInfo,
getSessionMessages,
getSubagentMessages,
query,
type Options as ClaudeQueryOptions,
Expand Down Expand Up @@ -464,6 +466,10 @@ export interface ClaudeAgentSdkQueryRunnerShape {
readonly subagentLaunchToolUseId: (
input: ClaudeAgentSdkSubagentLookupInput,
) => Effect.Effect<string | null, ClaudeAgentSdkQueryRunnerError>;
/** Whether the CLI's session storage holds a transcript with no message to resume. */
readonly sessionIsEmpty?: (
input: ClaudeAgentSdkSessionLookupInput,
) => Effect.Effect<boolean, ClaudeAgentSdkQueryRunnerError>;
readonly assertComplete: Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
}

Expand All @@ -479,6 +485,12 @@ export interface ClaudeAgentSdkSessionForkInput {
readonly providerSessionId: OrchestrationV2ProviderSession["id"];
}

export interface ClaudeAgentSdkSessionLookupInput {
readonly sessionId: string;
/** The instance's `CLAUDE_CONFIG_DIR`, where the CLI keeps its sessions. */
readonly configDir: string | undefined;
}

export interface ClaudeAgentSdkSubagentLookupInput {
readonly sessionId: string;
readonly agentId: string;
Expand Down Expand Up @@ -713,6 +725,7 @@ export const layerQueryRunner: Layer.Layer<
const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers;
const agentScope = yield* AgentScope;
const platform = yield* HostProcess.Platform;
const hostEnvironment = yield* HostProcess.Environment;

return ClaudeAgentSdkQueryRunner.of({
allocateSessionId: crypto
Expand Down Expand Up @@ -1019,6 +1032,26 @@ export const layerQueryRunner: Layer.Layer<
return toolUseId;
},
),
sessionIsEmpty: Effect.fn("ClaudeAgentSdkQueryRunner.sessionIsEmpty")(function* (
input: ClaudeAgentSdkSessionLookupInput,
) {
// The SDK reads sessions from this process's config dir only.
if (input.configDir !== hostEnvironment.CLAUDE_CONFIG_DIR) {
return false;
}
const info = yield* Effect.tryPromise({
try: () => getSessionInfo(input.sessionId),
catch: (cause) => queryRunnerError(cause, "getSessionInfo"),
});
if (info === undefined) {
return false;
}
Comment thread
maria-rcks marked this conversation as resolved.
Outdated
const messages = yield* Effect.tryPromise({
try: () => getSessionMessages(input.sessionId, { limit: 1 }),
catch: (cause) => queryRunnerError(cause, "getSessionMessages"),
});
return messages.length === 0;
}),
assertComplete: Effect.void,
});
}),
Expand Down Expand Up @@ -9182,6 +9215,30 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
),
resumeThread: Effect.fn("ClaudeAdapterV2.resumeThread")(
function* (threadInput: { readonly providerThread: OrchestrationV2ProviderThread }) {
// A CLI stopped before it saved the first prompt can leave a
// transcript with no messages. Claude can neither resume that id
// nor create it again, so fail and let the caller bind a fresh
// native session with a context handoff.
const nativeThreadId = threadInput.providerThread.nativeThreadRef?.nativeId;
if (
nativeThreadId !== undefined &&
nativeThreadId !== null &&
queryRunner.sessionIsEmpty !== undefined &&
(yield* Ref.get(queryContext))?.nativeThreadId !== nativeThreadId &&
!(yield* Ref.get(openedNativeThreads)).has(nativeThreadId) &&
// An unreadable store leaves the resume to the CLI, as before.
(yield* queryRunner
.sessionIsEmpty({
sessionId: nativeThreadId,
configDir: adapterOptions.environment.CLAUDE_CONFIG_DIR,
})
.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