diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 95df58c2bff32..ee1b475bd30d5 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -6361,6 +6361,120 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + // regression: #34549 a model change sent with a steer was shown but never applied to the turn. + it.effect("applies a model change sent with a steer to the running turn", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const opus = { + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + model: "claude-opus-5-5", + } satisfies ModelSelection; + const input = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-steer-model-change"), + text: "Run the slow build.", + attachments: [], + modelSelection: opus, + }); + yield* harness.runtime.startTurn(input); + yield* harness.runtime.steerTurn({ + threadId: harness.threadId, + runId: input.runId, + providerThread: harness.providerThread, + providerTurnId: idAllocator.derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: `turn:${input.attemptId}`, + }), + modelSelection: { ...opus, model: "claude-sonnet-4-6" }, + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make("steer-model-change"), + text: "Which model are you?", + attachments: [], + }, + }); + assert.deepEqual(harness.liveSelectionChanges, [ + { model: "claude-sonnet-4-6" }, + { effortLevel: "high", fastMode: null, ultracode: null }, + ]); + assert.lengthOf(harness.offeredMessages, 2); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); + + it.effect("fails a model-changing steer when the turn ends during the selection change", () => + Effect.scoped( + Effect.gen(function* () { + let duringFlags: Effect.Effect = Effect.void; + const harness = yield* makeWakeHarnessWithOptions({ + applyFlagSettings: () => Effect.suspend(() => duringFlags), + }); + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const opus = { + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + model: "claude-opus-5-5", + } satisfies ModelSelection; + const input = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-steer-model-change-race"), + text: "Run the slow build.", + attachments: [], + modelSelection: opus, + }); + yield* harness.runtime.startTurn(input); + duringFlags = harness + .offerAndWait(turnOneResult) + .pipe(Effect.andThen(Queue.take(harness.terminalReceipts)), Effect.asVoid); + const steer = yield* harness.runtime + .steerTurn({ + threadId: harness.threadId, + runId: input.runId, + providerThread: harness.providerThread, + providerTurnId: idAllocator.derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: `turn:${input.attemptId}`, + }), + modelSelection: { ...opus, model: "claude-sonnet-4-6" }, + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make("steer-model-change-race"), + text: "Which model are you?", + attachments: [], + }, + }) + .pipe(Effect.result); + assert.equal(steer._tag, "Failure"); + assert.lengthOf(harness.offeredMessages, 1); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); + it.effect("re-sends the model on the turn after a live selection change fails", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 7c63bdb6024a6..b8e6e3d90b760 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -12018,11 +12018,26 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }), persistAttachmentBudget(existing.nativeThreadId), ); + // A selection sent with the steer applies to the running turn when the live process can switch to it. + const steerSelection = + turnInput.modelSelection === undefined + ? undefined + : compileClaudeModelSelection(turnInput.modelSelection, modelCatalog); + const liveSteerSelection = + steerSelection !== undefined && + existing.selection.queryIdentity !== steerSelection.queryIdentity && + canApplyClaudeSelectionLive(existing.selection, steerSelection) && + (existing.selection.apiModelId === steerSelection.apiModelId || + existing.openedPermissionMode === currentTurn.queryPolicy.permissionMode) + ? steerSelection + : undefined; const userMessage = yield* makeClaudeUserMessageWithAttachments({ text: applyClaudePromptEffortPrefix( turnInput.message.text, - compileClaudeModelSelection(currentTurn.input.modelSelection, modelCatalog) - .promptEffort, + ( + liveSteerSelection ?? + compileClaudeModelSelection(currentTurn.input.modelSelection, modelCatalog) + ).promptEffort, ), attachments: turnInput.message.attachments, priority: "now", @@ -12036,9 +12051,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( Effect.provideService(Path.Path, path), ); yield* backgroundForegroundSubagents(existing, backgroundDeadline); - // The turn can end while the moves are answered. A steer offered - // then would start a new turn the orchestrator never asked for, so - // the steer fails and the orchestrator starts the follow-up turn. + if (liveSteerSelection !== undefined) { + yield* applySelectionLive(existing, liveSteerSelection); + } + // The turn can end while the moves or the selection change are answered. + // A steer offered then would start a new turn the orchestrator never + // asked for, so the steer fails and the orchestrator starts the follow-up turn. if ( (yield* Ref.get(activeTurn))?.providerTurnId !== turnInput.providerTurnId || (yield* Ref.get(queryContext)) !== existing || diff --git a/apps/server/src/orchestration-v2/EffectOutbox.ts b/apps/server/src/orchestration-v2/EffectOutbox.ts index 2e94254c8a805..58aa3ea97489c 100644 --- a/apps/server/src/orchestration-v2/EffectOutbox.ts +++ b/apps/server/src/orchestration-v2/EffectOutbox.ts @@ -64,6 +64,7 @@ export const OrchestrationEffectRequestV2 = Schema.Union([ providerThreadId: ProviderThreadId, providerTurnId: ProviderTurnId, messageId: MessageId, + modelSelection: Schema.optional(ModelSelection), }), Schema.Struct({ type: Schema.Literal("provider-turn.restart"), diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index 0fdfcc3a34f91..3ec8193d3ef7b 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -257,6 +257,9 @@ export const layerExecutor: Layer.Layer< providerThreadId: effect.request.providerThreadId, providerTurnId: effect.request.providerTurnId, messageId: effect.request.messageId, + ...(effect.request.modelSelection === undefined + ? {} + : { modelSelection: effect.request.modelSelection }), }) .pipe( Effect.tap(() => diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 4393d3692a641..63b7773287584 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -5601,9 +5601,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (steeringPolicy === "active_steering") { const instanceChanged = input.projection.thread.providerInstanceId !== input.modelSelection.instanceId; + // Only a user's steer carries the picker's selection; the adapter applies it to the live turn. + const steerCarriesSelection = + input.delegatedCompletion === undefined && input.notification === undefined; if ( - input.delegatedCompletion === undefined && - input.notification === undefined && + steerCarriesSelection && (instanceChanged || !modelSelectionsEqual(input.projection.thread.modelSelection, input.modelSelection)) ) { @@ -5639,6 +5641,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio providerThreadId: providerThread.id, providerTurnId: providerTurn.id, messageId: input.messageId, + ...(steerCarriesSelection ? { modelSelection: input.modelSelection } : {}), }, } satisfies PendingOrchestrationEffectV2, ]); diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts index 2537018b57e31..00f299cc0313f 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts @@ -68,6 +68,7 @@ export interface ProviderTurnControlServiceV2Shape { readonly providerThreadId: ProviderThreadId; readonly providerTurnId: ProviderTurnId; readonly messageId: MessageId; + readonly modelSelection?: ModelSelection; }) => Effect.Effect; /** Best effort: a turn that ended or moved sessions picks the selection up when it next starts. */ readonly applyModelSelection: (input: { @@ -405,6 +406,9 @@ export const layer: Layer.Layer< runId: run.id, providerThread: loaded.providerThread, providerTurnId: loaded.providerTurn.id, + ...(input.modelSelection === undefined + ? {} + : { modelSelection: input.modelSelection }), message: { messageId: message.id, text: projectComposerContextForProvider({ diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index 7d66d8d101996..63b47dfe9779d 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -499,6 +499,7 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* const events = yield* Queue.unbounded(); const started: ProviderAdapter.ProviderAdapterV2TurnInput[] = []; const steered: string[] = []; + const steeredSelections: Array = []; const interrupted: ProviderTurnId[] = []; const capabilities = { ...CodexProviderCapabilitiesV2, @@ -587,6 +588,7 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* steerTurn: (turn) => Effect.sync(() => { steered.push(turn.message.text); + steeredSelections.push(turn.modelSelection); }), interruptTurn: ({ providerTurnId }) => Effect.sync(() => { @@ -658,7 +660,7 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* yield* Fiber.join(running); return threadId; }); - return { cwd, events, started, steered, interrupted, layer, startFirstTurn }; + return { cwd, events, started, steered, steeredSelections, interrupted, layer, startFirstTurn }; }); it.effect.each([ @@ -965,10 +967,8 @@ it.effect( () => Effect.scoped( Effect.gen(function* () { - const { started, steered, layer, startFirstTurn } = yield* nextTurnSelectionHarness( - "steering-selection-change", - true, - ); + const { started, steered, steeredSelections, layer, startFirstTurn } = + yield* nextTurnSelectionHarness("steering-selection-change", true); yield* Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; @@ -1028,6 +1028,8 @@ it.effect( yield* steer("steer-back", runSelection); const back = yield* orchestrator.getThreadProjection(threadId); assert.deepEqual(steered, ["steer-changed", "steer-reverted", "steer-back"]); + // The adapter gets each steer's selection so it can switch the live turn, and back. + assert.deepEqual(steeredSelections, [composerSelection, runSelection, runSelection]); assert.equal(back.thread.providerInstanceId, instanceId); assert.deepEqual(back.thread.modelSelection, runSelection); }).pipe(Effect.provide(layer)); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 9cce2d21a85ff..a56616340050c 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -1520,6 +1520,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive", (it) => { providerThreadId: providerThread.id, providerTurnId: providerTurn.id, messageId: steerMessageId, + modelSelection: run.modelSelection, }, ], ); diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 6f09312a3cbea..ff62582274192 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -521,6 +521,8 @@ export interface ProviderAdapterV2SteerInput { readonly providerThread: OrchestrationV2ProviderThread; readonly providerTurnId: ProviderTurnId; readonly message: ProviderAdapterV2TurnMessage; + /** The selection sent with the message when it differs from the running turn's. */ + readonly modelSelection?: ModelSelection; } export interface ProviderAdapterV2InterruptInput {