diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 7652b5636926..025ca5ba32cb 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2319,6 +2319,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly interrupt?: Effect.Effect; readonly stopTask?: (taskId: string) => Effect.Effect; readonly environment?: NodeJS.ProcessEnv; + readonly setPermissionMode?: ClaudeAdapterV2.ClaudeAgentSdkQuerySession["setPermissionMode"]; readonly subagentLaunchToolUseId?: string; readonly nativeSessionId?: string; readonly settings?: ClaudeSettings; @@ -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, @@ -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" }, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 80eb47436534..01a622d70610 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -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; @@ -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, ) { @@ -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; } @@ -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( @@ -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 });