From 21fe6139b9ecec2bc8a2d3e074d1f4ea17171493 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 21:13:23 +0000 Subject: [PATCH 1/3] fix(server): a model change sent with a Claude steer now applies to the running turn Picking a different model and sending a message while a Claude turn ran steered the message into the turn and showed the new model in the picker, but the steer never told Claude to switch, so the old model answered until the next turn. The orchestrator now passes the user's selection on the steer effect, and the Claude adapter switches the live process to it (setModel and flag settings, the same live update a new turn uses) before offering the steer. Fixes pingdotgg/t3code#34549 AI-assisted; verified with the tests and tools listed above. --- .../Adapters/ClaudeAdapterV2.test.ts | 55 ++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 64 +++++++++++++------ .../src/orchestration-v2/EffectOutbox.ts | 2 + .../src/orchestration-v2/EffectWorker.ts | 3 + .../src/orchestration-v2/Orchestrator.ts | 7 +- .../ProviderTurnControlService.ts | 5 ++ .../SteeringCompletion.integration.test.ts | 12 ++-- .../src/server/ProviderAdapter.ts | 2 + 8 files changed, 123 insertions(+), 27 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index a17d4d3d9f3ab..fd198431c8270 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -6213,6 +6213,61 @@ 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("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 d677c5e68c70d..2caf035c46bf7 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -11082,6 +11082,29 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return false; }); + const applySelectionLive = Effect.fnUntraced(function* ( + live: ClaudeLiveQueryContext, + compiledSelection: CompiledClaudeModelSelection, + ) { + // Preserve launcher-selected models unless the model changed or a failed update left it unknown. + yield* ( + live.selection.queryIdentity === "" || + live.selection.apiModelId !== compiledSelection.apiModelId + ? live.query.setModel(compiledSelection.apiModelId) + : Effect.void + ).pipe( + Effect.andThen( + live.query.applyFlagSettings(claudeLiveSelectionFlagSettings(compiledSelection)), + ), + Effect.tapError(() => + Effect.sync(() => { + live.selection = { ...live.selection, queryIdentity: "" }; + }), + ), + ); + live.selection = compiledSelection; + }); + const openQuery = Effect.fnUntraced(function* ( turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, nativeThreadId: string, @@ -11122,24 +11145,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( (existing.selection.apiModelId === compiledSelection.apiModelId || existing.openedPermissionMode === queryPolicy.permissionMode) ) { - const live = existing; - // Preserve launcher-selected models unless the model changed or a failed update left it unknown. - yield* ( - live.selection.queryIdentity === "" || - live.selection.apiModelId !== compiledSelection.apiModelId - ? live.query.setModel(compiledSelection.apiModelId) - : Effect.void - ).pipe( - Effect.andThen( - live.query.applyFlagSettings(claudeLiveSelectionFlagSettings(compiledSelection)), - ), - Effect.tapError(() => - Effect.sync(() => { - live.selection = { ...live.selection, queryIdentity: "" }; - }), - ), - ); - live.selection = compiledSelection; + yield* applySelectionLive(existing, compiledSelection); } if ( existing !== null && @@ -11945,11 +11951,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", @@ -11975,6 +11996,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( detail: `Claude provider turn ${turnInput.providerTurnId} ended before the steer was sent.`, }); } + if (liveSteerSelection !== undefined) { + yield* applySelectionLive(existing, liveSteerSelection); + } yield* Ref.update(steeredTurns, (current) => { const next = new Set(current); next.add(turnInput.providerTurnId); diff --git a/apps/server/src/orchestration-v2/EffectOutbox.ts b/apps/server/src/orchestration-v2/EffectOutbox.ts index 39128063e356e..96f0e2f6c3941 100644 --- a/apps/server/src/orchestration-v2/EffectOutbox.ts +++ b/apps/server/src/orchestration-v2/EffectOutbox.ts @@ -3,6 +3,7 @@ import { CheckpointScopeId, CommandId, MessageId, + ModelSelection, NodeId, ProviderSessionId, RunAttemptId, @@ -57,6 +58,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 2c180231c6ace..fef7472ea1891 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -239,6 +239,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 7af71e48d67ea..8b1c4e1b88c87 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -5561,9 +5561,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)) ) { @@ -5599,6 +5601,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 bd225bb41773b..9ce7a8d9d2011 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts @@ -1,6 +1,7 @@ import { projectComposerContextForProvider } from "@t3tools/shared/composerContextReferences"; import { MessageId, + type ModelSelection, type NodeId, ProviderSessionId, ProviderThreadId, @@ -67,6 +68,7 @@ export interface ProviderTurnControlServiceV2Shape { readonly providerThreadId: ProviderThreadId; readonly providerTurnId: ProviderTurnId; readonly messageId: MessageId; + readonly modelSelection?: ModelSelection; }) => Effect.Effect; readonly interruptAndAwaitTerminal: (input: { readonly threadId: ThreadId; @@ -372,6 +374,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/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 98f71a7473a84..9cee287656f65 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 { From e951daa610edbddca88e8cc4d8846a4f872a6df9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 21:26:09 +0000 Subject: [PATCH 2/3] fix(server): switch the Claude model before the steer's last turn check Applying a steered selection after the final active-turn check let a turn that ended during setModel or applyFlagSettings still receive the steer, starting provider work the orchestrator never tracked. Apply it before the check so such a steer fails and the follow-up turn handles it. AI-assisted; verified with the tests and tools listed above. --- .../Adapters/ClaudeAdapterV2.test.ts | 59 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 12 ++-- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index fd198431c8270..0b92954c12195 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -6268,6 +6268,65 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + 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 2caf035c46bf7..215aff7f4fdb1 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -11984,9 +11984,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 @@ -11996,9 +11999,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( detail: `Claude provider turn ${turnInput.providerTurnId} ended before the steer was sent.`, }); } - if (liveSteerSelection !== undefined) { - yield* applySelectionLive(existing, liveSteerSelection); - } yield* Ref.update(steeredTurns, (current) => { const next = new Set(current); next.add(turnInput.providerTurnId); From fa38be1787ad81d0b29f993ffda34c4dfaffff39 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 21:49:35 +0000 Subject: [PATCH 3/3] test(server): expect the user's selection on a steer effect AI-assisted; verified with the tests and tools listed above. --- apps/server/src/orchestration-v2/runtimeLayer.test.ts | 1 + 1 file changed, 1 insertion(+) 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, }, ], );