Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
110 changes: 109 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2319,6 +2319,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
readonly interrupt?: Effect.Effect<void>;
readonly stopTask?: (taskId: string) => Effect.Effect<void>;
readonly environment?: NodeJS.ProcessEnv;
readonly setPermissionMode?: ClaudeAdapterV2.ClaudeAgentSdkQuerySession["setPermissionMode"];
readonly subagentLaunchToolUseId?: string;
readonly nativeSessionId?: string;
readonly settings?: ClaudeSettings;
Expand Down Expand Up @@ -2427,7 +2428,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
setPermissionMode: (mode) =>
Effect.sync(() => {
permissionModeChanges.push(mode);
}),
}).pipe(Effect.andThen(options?.setPermissionMode?.(mode) ?? Effect.void)),
stopTask: options?.stopTask ?? (() => Effect.void),
interrupt: options?.interrupt ?? Effect.void,
close: options?.close?.(sdkMessages) ?? Effect.void,
Expand Down Expand Up @@ -2507,6 +2508,113 @@ describe("ClaudeAdapterV2 background wake turns", () => {
});
const makeWakeHarness = makeWakeHarnessWithOptions();

it.effect.each([
{ initialSubtype: "init", initialMode: "default", laterMode: "plan" },
{ initialSubtype: "status", initialMode: "default", laterMode: "plan" },
{ initialSubtype: "init", initialMode: "auto", laterMode: "plan" },
{ initialSubtype: "init", initialMode: "auto", laterMode: "default" },
] as const)(
"preserves initial $initialSubtype mode $initialMode for Auto after $laterMode",
({ initialSubtype, initialMode, laterMode }) =>
Effect.scoped(
Effect.gen(function* () {
const harness = yield* makeWakeHarnessWithOptions({
setPermissionMode: (mode) =>
mode === "auto" && initialMode === "default"
? Effect.fail(
new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({
method: "setPermissionMode",
cause: new Error("Auto permission mode is unsupported."),
}),
)
: Effect.void,
});
const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
runtimeMode: "auto",
interactionMode: "default",
cwd: "/workspace",
});
const modelSelection = {
...CLAUDE_TEST_MODEL_SELECTION,
model: initialMode === "default" ? "claude-haiku-4-5" : "claude-sonnet-4-6",
};
const start = (ordinal: number) =>
harness.runtime.startTurn(
makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now,
attemptId: RunAttemptId.make(`attempt-claude-auto-${ordinal}`),
providerTurnOrdinal: ordinal,
text: "Continue the work.",
attachments: [],
runtimePolicy,
modelSelection,
}),
);
const finish = (ordinal: number) =>
Queue.offer(
harness.sdkMessages,
makeResultFrame({
uuid: `00000000-0000-4000-8000-00000000070${ordinal}`,
result: "Done.",
}),
).pipe(Effect.andThen(Queue.take(harness.terminalReceipts)));
const now = yield* DateTime.now;

yield* start(1);
const openedOptions = harness.getOpenedOptions();
assert.equal(openedOptions?.permissionMode, "auto");
yield* harness.offerAndWait(
claudeSdkFrame({
type: "system",
subtype: initialSubtype,
status: null,
permissionMode: initialMode,
uuid: "00000000-0000-4000-8000-000000000704",
session_id: WAKE_NATIVE_SESSION,
}),
);
assert.equal((yield* finish(1)).status, "completed");

yield* start(2);
assert.strictEqual(harness.getOpenedOptions(), openedOptions);
assert.deepEqual(harness.permissionModeChanges, []);
yield* harness.offerAndWait(
claudeSdkFrame({
type: "system",
subtype: "status",
status: null,
permissionMode: laterMode,
uuid: "00000000-0000-4000-8000-000000000705",
session_id: WAKE_NATIVE_SESSION,
}),
);
// Later init frames must not replace the process's initial fallback.
yield* harness.offerAndWait(
claudeSdkFrame({
type: "system",
subtype: "init",
permissionMode: laterMode,
uuid: "00000000-0000-4000-8000-000000000706",
session_id: WAKE_NATIVE_SESSION,
}),
);
assert.equal((yield* finish(2)).status, "completed");

yield* start(3);
assert.strictEqual(harness.getOpenedOptions(), openedOptions);
assert.deepEqual(harness.permissionModeChanges, [initialMode]);
assert.equal((yield* finish(3)).status, "completed");
assert.lengthOf(harness.offeredMessages, 3);
}).pipe(
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
),
);

it.effect.each([
{ isError: false, title: "Check weather" },
{ isError: true, title: "Check weather" },
Expand Down
29 changes: 21 additions & 8 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2984,10 +2984,11 @@ interface ClaudeLiveQueryContext {
// uuid before any echo, so it echoes, but a resume's own turns can still
// run ahead of that prompt.
promptEchoMode: "unknown" | "acknowledged" | "early" | "result_only";
// The mode this process was opened in, and the mode the CLI last reported
// (init and status frames). Claude changes the latter itself through
// EnterPlanMode.
// The mode requested when this process opened.
readonly openedPermissionMode: PermissionMode;
// The first mode the CLI reports can be Manual when Auto is unsupported.
initialPermissionMode: PermissionMode | null;
// The latest init/status mode, including changes through EnterPlanMode.
permissionMode: PermissionMode;
// Stop, rollback or fork is closing this process; its work is ending.
stopping: boolean;
Expand Down Expand Up @@ -3326,6 +3327,11 @@ export interface ClaudeAdapterV2Options {
};
}

/**
* Creates the Claude provider adapter using the supplied SDK runner and services.
* Sessions reuse live queries across turns and preserve the CLI's initial Manual
* fallback when Auto permission mode is unsupported.
*/
export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
adapterOptions: ClaudeAdapterV2Options,
) {
Expand Down Expand Up @@ -8188,11 +8194,16 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
existing.selectionKey === compiledSelection.queryIdentity
) {
// Claude can switch its own mode mid-session (EnterPlanMode), and
// a denied ExitPlanMode leaves it there. Put the live process back
// in the thread's mode before the next prompt.
if (existing.permissionMode !== existing.openedPermissionMode) {
yield* existing.query.setPermissionMode(existing.openedPermissionMode);
existing.permissionMode = existing.openedPermissionMode;
// a denied ExitPlanMode leaves it there. Restore the thread's mode,
// or Manual if the CLI initially fell back from unsupported Auto.
const permissionMode =
existing.openedPermissionMode === "auto" &&
existing.initialPermissionMode === "default"
? "default"
: existing.openedPermissionMode;
if (existing.permissionMode !== permissionMode) {
yield* existing.query.setPermissionMode(permissionMode);
existing.permissionMode = permissionMode;
}
return existing;
}
Expand Down Expand Up @@ -8317,6 +8328,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
lastTurn: null,
promptEchoMode: "unknown",
openedPermissionMode: queryOptions.permissionMode,
initialPermissionMode: null,
permissionMode: queryOptions.permissionMode,
stopping: false,
subagentsFromEarlierProcesses: new Set(
Expand All @@ -8335,6 +8347,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
(message.subtype === "init" || message.subtype === "status") &&
message.permissionMode !== undefined
) {
context.initialPermissionMode ??= message.permissionMode;
context.permissionMode = message.permissionMode;
}
return handleSdkMessage({ query: querySession, message });
Expand Down
Loading