diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 8a06dddeeec1..2d2c787f936b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -4546,6 +4546,71 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect.each(["claude-haiku-4-5-20251001", "claude-sonnet-4-6"])( + "renegotiates auto only when switching API models after fallback to default: %s", + (nextModel) => + Effect.gen(function* () { + const harness = yield* makeWakeHarnessWithOptions({ freshQueueOnReopen: true }); + const modelSelection = { + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + model: "claude-haiku-4-5-20251001", + }; + const turnInput = { + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attachments: [], + modelSelection, + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + ...CLAUDE_TEST_RUNTIME_POLICY, + runtimeMode: "auto", + }), + }; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + ...turnInput, + attemptId: RunAttemptId.make("attempt-claude-auto-fallback-1"), + text: "Hello.", + }), + ); + 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({ + ...turnInput, + modelSelection: { ...modelSelection, model: nextModel }, + attemptId: RunAttemptId.make("attempt-claude-auto-fallback-2"), + providerTurnOrdinal: 2, + text: "Continue.", + }), + ); + if (nextModel === modelSelection.model) { + assert.strictEqual(harness.getOpenedOptions(), openedOptions); + assert.lengthOf(harness.processQueues, 1); + } else { + const resumedOptions = harness.getOpenedOptions(); + assert.notStrictEqual(resumedOptions, openedOptions); + assert.equal(resumedOptions?.model, nextModel); + assert.equal(resumedOptions?.resume, WAKE_NATIVE_SESSION); + assert.isUndefined(resumedOptions?.sessionId); + assert.equal(resumedOptions?.permissionMode, "auto"); + assert.lengthOf(harness.processQueues, 2); + } + assert.lengthOf(harness.offeredMessages, 2); + assert.deepEqual(harness.permissionModeChanges, []); + assert.deepEqual(harness.liveSelectionChanges, []); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect("preserves typed Claude plans and todos through generic tool completion", () => Effect.scoped( Effect.gen(function* () { @@ -4627,6 +4692,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { harness.sdkMessages, toolResults("00000000-0000-4000-8000-000000000502", ["tool-todo-1"]), ); + yield* harness.offerAndWait( + claudeSdkFrame({ ...wakeTurnInit, permissionMode: "bypassPermissions" }), + ); yield* Queue.offer( harness.sdkMessages, claudeSdkFrame({ @@ -4638,6 +4706,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 be276af5f0ed..f00bd90b7af6 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3101,7 +3101,9 @@ interface ClaudeLiveQueryContext { // ("early") or only on its result. "acknowledged": the CLI confirmed it took a prompt's 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"; - readonly openedPermissionMode: PermissionMode; + // The CLI may fall back from the requested mode on its first init frame. + openedPermissionMode: PermissionMode; + receivedInit: boolean; permissionMode: PermissionMode; stopping: boolean; readonly subagentsFromEarlierProcesses: ReadonlySet; @@ -8455,7 +8457,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( existing.queryPolicyKey === queryPolicyKey && !existing.stopping && existing.selection.queryIdentity !== compiledSelection.queryIdentity && - canApplyClaudeSelectionLive(existing.selection, compiledSelection) + canApplyClaudeSelectionLive(existing.selection, compiledSelection) && + (existing.selection.apiModelId === compiledSelection.apiModelId || + existing.openedPermissionMode === queryPolicy.permissionMode) ) { const live = existing; yield* live.query.setModel(compiledSelection.apiModelId).pipe( @@ -8478,7 +8482,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ) { // 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; @@ -8585,6 +8589,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( lastTurn: null, promptEchoMode: "unknown", openedPermissionMode: queryOptions.permissionMode, + receivedInit: false, permissionMode: queryOptions.permissionMode, stopping: false, subagentsFromEarlierProcesses: new Set( @@ -8598,6 +8603,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( Stream.runForEach((message) => sdkMessageLock.withPermits(1)( Effect.suspend(() => { + 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") &&