From fcd3ad6986056af2293bb13b186c70e4d20fadd2 Mon Sep 17 00:00:00 2001 From: Guillermo Casanova Date: Sun, 4 Oct 2026 12:54:23 -0300 Subject: [PATCH] fix(server): Claude follow-ups no longer fail in Auto on models without Auto Restore the permission mode the CLI reported on its first init frame instead of the requested mode. Claude Code falls back from Auto on models that lack it (Haiku 4.5), so restoring the requested mode called setPermissionMode("auto") and the CLI rejected it. Fixes #15650 Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 52 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 22 ++++++-- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 2e237bf3bb4c..45b78aad13ca 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2917,6 +2917,48 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, IdAllocator.layer))), ); + it.effect("reuses the CLI permission mode when auto falls back to default", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + ...CLAUDE_TEST_RUNTIME_POLICY, + runtimeMode: "auto", + }); + const turnInput = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-auto-fallback-1"), + text: "Hello.", + attachments: [], + runtimePolicy, + }); + yield* harness.runtime.startTurn(turnInput); + const openedOptions = harness.getOpenedOptions(); + assert.equal(openedOptions?.permissionMode, "auto"); + yield* harness.offerAndWait(claudeSdkFrame({ ...wakeTurnInit, permissionMode: "default" })); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* Queue.take(harness.terminalReceipts); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-auto-fallback-2"), + providerTurnOrdinal: 2, + text: "Continue.", + attachments: [], + runtimePolicy, + }), + ); + assert.strictEqual(harness.getOpenedOptions(), openedOptions); + assert.lengthOf(harness.offeredMessages, 2); + assert.deepEqual(harness.permissionModeChanges, []); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); + it.effect("preserves typed Claude plans and todos through generic tool completion", () => Effect.scoped( Effect.gen(function* () { @@ -2998,6 +3040,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { harness.sdkMessages, toolResults("00000000-0000-4000-8000-000000000502", ["tool-todo-1"]), ); + yield* harness.offerAndWait( + claudeSdkFrame({ ...wakeTurnInit, permissionMode: "bypassPermissions" }), + ); // Claude entered plan mode on its own (EnterPlanMode). yield* Queue.offer( harness.sdkMessages, @@ -3010,6 +3055,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { session_id: WAKE_NATIVE_SESSION, }), ); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...wakeTurnInit, + uuid: "00000000-0000-4000-8000-000000000509", + permissionMode: "plan", + }), + ); yield* Queue.offer( harness.sdkMessages, makeResultFrame({ diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index ba0cfbd8fbd5..d663b4031342 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2739,10 +2739,12 @@ 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. - readonly openedPermissionMode: PermissionMode; + // The mode this process opened in (first init frame; the CLI falls back + // from modes the model lacks, such as Auto on Haiku), and the mode the CLI + // last reported (init and status frames). Claude changes the latter itself + // through EnterPlanMode. + openedPermissionMode: PermissionMode; + receivedInit: boolean; permissionMode: PermissionMode; // Stop, rollback or fork is closing this process; its work is ending. stopping: boolean; @@ -6929,7 +6931,7 @@ export function makeClaudeAdapterV2( ) { // 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. + // in its initial mode before the next prompt. if (existing.permissionMode !== existing.openedPermissionMode) { yield* existing.query.setPermissionMode(existing.openedPermissionMode); existing.permissionMode = existing.openedPermissionMode; @@ -7049,6 +7051,7 @@ export function makeClaudeAdapterV2( closed, promptEchoMode: "unknown", openedPermissionMode: queryOptions.permissionMode, + receivedInit: false, permissionMode: queryOptions.permissionMode, stopping: false, subagentsFromEarlierProcesses: new Set( @@ -7060,6 +7063,15 @@ export function makeClaudeAdapterV2( yield* Ref.set(queryContext, context); yield* querySession.messages.pipe( Stream.runForEach((message) => { + if ( + message.type === "system" && + message.subtype === "init" && + !context.receivedInit + ) { + context.openedPermissionMode = + message.permissionMode ?? context.openedPermissionMode; + context.receivedInit = true; + } if ( message.type === "system" && (message.subtype === "init" || message.subtype === "status") &&