From 57c6a769a960ec8365416bfda61e42093e3b4123 Mon Sep 17 00:00:00 2001 From: taham8875 Date: Fri, 23 Oct 2026 16:08:45 +0300 Subject: [PATCH 1/8] fix(claude): follow the session model after a refusal fallback - Report Claude's session-scoped model_refusal_fallback as a new app_thread.model_selection.updated provider event - Move the live query's selection to the fallback model, so picking the original model again calls setModel - Write the thread's model selection under its lock, sharing the subagent model sync, and leave threads moved to another provider alone --- .../Adapters/ClaudeAdapterV2.test.ts | 77 +++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 15 ++++ .../ProviderEventIngestor.test.ts | 46 ++++++++++ .../orchestration-v2/ProviderEventIngestor.ts | 83 +++++++++++++------ .../orchestration-v2/RunExecutionService.ts | 2 + .../src/server/ProviderAdapter.ts | 7 ++ 6 files changed, 204 insertions(+), 26 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 0c8b2ad588acc..693f2aaf2906a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -3323,6 +3323,83 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("moves the thread to the model a session refusal fallback switched to", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const original = { + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + model: "claude-opus-5-5", + } satisfies ModelSelection; + const fallback = { ...original, model: "claude-opus-4-8" }; + const turn = (ordinal: number, modelSelection: ModelSelection) => + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make(`attempt-refusal-fallback:${ordinal}`), + text: `Request ${ordinal}`, + attachments: [], + providerTurnOrdinal: ordinal, + modelSelection, + }); + const settle = Effect.gen(function* () { + yield* harness.offerAndWait(turnOneResult); + yield* Queue.take(harness.terminalReceipts); + }); + const refusalFallback = (scope: "session" | "local", uuid: string) => + claudeSdkFrame({ + type: "system", + subtype: "model_refusal_fallback", + trigger: "refusal", + direction: "retry", + scope, + original_model: original.model, + fallback_model: fallback.model, + request_id: null, + content: "Switched to Opus 4.8", + uuid, + session_id: WAKE_NATIVE_SESSION, + }); + + yield* harness.runtime.startTurn(turn(1, original)); + yield* Queue.offer( + harness.sdkMessages, + refusalFallback("local", "00000000-0000-4000-8000-000000000401"), + ); + yield* Queue.offer( + harness.sdkMessages, + refusalFallback("session", "00000000-0000-4000-8000-000000000402"), + ); + yield* settle; + assert.deepEqual( + harness.events.flatMap((event) => + event.type === "app_thread.model_selection.updated" ? [event] : [], + ), + [ + { + type: "app_thread.model_selection.updated", + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + threadId: harness.threadId, + modelSelection: fallback, + }, + ], + ); + + yield* harness.runtime.startTurn(turn(2, fallback)); + yield* settle; + assert.deepEqual(harness.liveSelectionChanges, []); + yield* harness.runtime.startTurn(turn(3, original)); + yield* settle; + assert.deepEqual(harness.liveSelectionChanges[0], { model: original.model }); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect.each(["completed", "interrupted"] as const)( "projects Claude thinking blocks when %s", (status) => diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 0e0868918eaa4..f7556ffcd7ce2 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -7887,6 +7887,21 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ) { const now = yield* DateTime.now; const nativeItemId = message.uuid; + // "local" covers a subagent or side question; absent (older CLIs) means "session". + if (message.subtype === "model_refusal_fallback" && message.scope !== "local") { + const modelSelection = { + ...context.input.modelSelection, + model: resolveClaudeModelSlug(subagentModelCatalog, message.fallback_model), + }; + const live = yield* Ref.get(queryContext); + if (live !== null) live.selection = compileClaudeModelSelection(modelSelection); + yield* emitProviderEvent({ + type: "app_thread.model_selection.updated", + driver: CLAUDE_PROVIDER, + threadId: context.input.threadId, + modelSelection, + }); + } context.assistant.noticeTexts.add(message.content); yield* emitProviderEvent({ type: "turn_item.updated", diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index 849cedded9292..2ddb5ce03c51c 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -2116,4 +2116,50 @@ layer("ProviderEventIngestorV2", (it) => { ); }), ); + + it.effect("moves a thread to the model its provider switched the session to", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const eventSink = yield* EventSink.EventSinkV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const rootEvent = yield* threadCreatedEvent(now); + const threadId = rootEvent.threadId; + if (threadId === undefined) throw new Error("Expected a thread fixture event"); + yield* eventSink.write({ events: [rootEvent] }); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + const reportModel = (reported: ModelSelection) => + ingestor.ingestNormalized({ + providerSessionId, + providerInstanceId: modelSelection.instanceId, + threadId, + event: { + type: "app_thread.model_selection.updated", + driver: CODEX_DRIVER, + threadId, + modelSelection: reported, + }, + }); + const switched = { ...modelSelection, model: "gpt-fallback" }; + + const first = yield* reportModel(switched); + const repeated = yield* reportModel(switched); + const otherInstance = yield* reportModel({ + ...switched, + instanceId: ProviderInstanceId.make("codex-work"), + }); + + assert.deepEqual( + first.map((stored) => stored.event.type), + ["thread.model-selection-updated"], + ); + assert.lengthOf(repeated, 0); + assert.lengthOf(otherInstance, 0); + assert.deepEqual((yield* projectionStore.getThread(threadId)).modelSelection, switched); + }), + ); }); diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 8e916ab79319b..2541670b76f4f 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -391,27 +391,16 @@ export const layer: Layer.Layer< ); /** - * A native subagent's thread starts on the parent's model when the provider - * names the real one later (a Claude agent file's model arrives with the - * subagent's first reply). Thread commands rewrite the whole thread row - * under the thread's lock, so this read and write take it too. + * Thread commands rewrite the whole thread row under the thread's lock, so + * this read and write take it too. */ - const syncSubagentThreadModel = Effect.fn("ProviderEventIngestor.syncSubagentThreadModel")( - function* (input: ProviderEventIngestInput, subagent: OrchestrationV2Subagent) { - const { childThreadId, model } = subagent; - if ( - subagent.origin !== "provider_native" || - childThreadId === null || - (model === null && subagent.modelSelection === undefined) - ) { - return []; - } - const reportedSelection = ({ modelSelection }: { modelSelection: ModelSelection }) => - subagent.modelSelection ?? - (model === null || model === modelSelection.model - ? modelSelection - : { instanceId: modelSelection.instanceId, model }); - const staleThread = projections.getThread(childThreadId).pipe( + const syncThreadModelSelection = Effect.fn("ProviderEventIngestor.syncThreadModelSelection")( + function* ( + input: ProviderEventIngestInput, + threadId: ThreadId, + reportedSelection: (thread: { modelSelection: ModelSelection }) => ModelSelection, + ) { + const staleThread = projections.getThread(threadId).pipe( Effect.map((thread) => modelSelectionsEqual(thread.modelSelection, reportedSelection(thread)) ? null : thread, ), @@ -419,7 +408,7 @@ export const layer: Layer.Layer< ); if ((yield* staleThread) === null) return []; return yield* threadCommands.withLock( - childThreadId, + threadId, Effect.gen(function* () { const thread = yield* staleThread; if (thread === null) return []; @@ -440,6 +429,33 @@ export const layer: Layer.Layer< }, ); + /** + * A native subagent's thread starts on the parent's model when the provider + * names the real one later (a Claude agent file's model arrives with the + * subagent's first reply). + */ + const syncSubagentThreadModel = Effect.fn("ProviderEventIngestor.syncSubagentThreadModel")( + function* (input: ProviderEventIngestInput, subagent: OrchestrationV2Subagent) { + const { childThreadId, model } = subagent; + if ( + subagent.origin !== "provider_native" || + childThreadId === null || + (model === null && subagent.modelSelection === undefined) + ) { + return []; + } + return yield* syncThreadModelSelection( + input, + childThreadId, + ({ modelSelection }) => + subagent.modelSelection ?? + (model === null || model === modelSelection.model + ? modelSelection + : { instanceId: modelSelection.instanceId, model }), + ); + }, + ); + /** * Retire the runs that fell off its active branch with the same run, * root-node, and checkpoint updates CheckpointRollbackService writes for a @@ -555,6 +571,9 @@ export const layer: Layer.Layer< }), ]; } + case "app_thread.model_selection.updated": + // Written under the thread's lock after ingestion. + return []; case "provider_session.updated": return [ yield* makeDomainEvent(input, { @@ -815,10 +834,22 @@ export const layer: Layer.Layer< ), ), ), - Effect.flatMap((storedEvents) => - storedEvents.length === 0 || input.event.type !== "subagent.updated" + Effect.flatMap((storedEvents) => { + const event = input.event; + const sync = + event.type === "app_thread.model_selection.updated" + ? // A thread the user has since moved to another provider keeps its choice. + syncThreadModelSelection(input, event.threadId, ({ modelSelection }) => + modelSelection.instanceId === event.modelSelection.instanceId + ? event.modelSelection + : modelSelection, + ) + : storedEvents.length > 0 && event.type === "subagent.updated" + ? syncSubagentThreadModel(input, event.subagent) + : null; + return sync === null ? Effect.succeed(storedEvents) - : syncSubagentThreadModel(input, input.event.subagent).pipe( + : sync.pipe( Effect.map((synced) => [...storedEvents, ...synced]), Effect.mapError( (cause) => @@ -828,8 +859,8 @@ export const layer: Layer.Layer< cause, }), ), - ), - ), + ); + }), Effect.tap((storedEvents) => Effect.gen(function* () { if (storedEvents.length === 0 || input.event.type !== "provider_turn.updated") diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index a2b4f1de5bcf2..1ef01d7651399 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -398,6 +398,8 @@ export function routeProviderEvent( }, ]; } + case "app_thread.model_selection.updated": + return [ownsThread(event.threadId), state]; case "provider_thread.updated": { const belongs = state.ownedProviderThreadIds.has(event.providerThread.id) || diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 314705eca3afc..cb35511add11a 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -83,6 +83,13 @@ export const ProviderAdapterV2Event = Schema.Union([ appThread: OrchestrationV2AppThread, createOnly: Schema.optional(Schema.Boolean), }), + /** The provider moved the thread's session to another model on its own, such as Claude's refusal fallback. */ + Schema.Struct({ + type: Schema.Literal("app_thread.model_selection.updated"), + driver: ProviderDriverKind, + threadId: ThreadId, + modelSelection: ModelSelection, + }), Schema.Struct({ type: Schema.Literal("provider_session.updated"), driver: ProviderDriverKind, From a43e78f738c423764a27e1effd9a7c80d221ac5a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 1 Nov 2026 07:06:38 +0000 Subject: [PATCH 2/8] test(claude): preserve newer model choices during fallback --- .../ProviderEventIngestor.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index 28f27a0a25d28..0bd6db1d72e40 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -2327,6 +2327,27 @@ layer("ProviderEventIngestorV2", (it) => { assert.lengthOf(repeated, 0); assert.lengthOf(otherInstance, 0); assert.deepEqual((yield* projectionStore.getThread(threadId)).modelSelection, switched); + for (const newer of [ + { ...modelSelection, model: "user-selected-model" }, + { ...modelSelection, options: [{ id: "reasoningEffort", value: "low" }] }, + ]) { + yield* eventSink.write({ + events: [ + { + ...rootEvent, + id: yield* idAllocator.allocate.event({ threadId }), + type: "thread.model-selection-updated", + payload: { + ...(yield* projectionStore.getThread(threadId)), + modelSelection: newer, + updatedAt: now, + }, + }, + ], + }); + assert.lengthOf(yield* reportModel(switched), 0); + assert.deepEqual((yield* projectionStore.getThread(threadId)).modelSelection, newer); + } }), ); }); From 3d9d7b172960ffb7f697c7d0d725c405af4c286b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 11:22:42 +0000 Subject: [PATCH 3/8] test(claude): provide crypto to the refusal fallback test after the main merge --- .../src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index f1f4b11b38faa..a629782696425 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -6066,7 +6066,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); From 502f1c7704f7055e7bb1c35ece07e23569b8f041 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 11:26:26 +0000 Subject: [PATCH 4/8] fix(claude): use the merged model catalog for refusal fallbacks --- apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 1e3dda00bf217..aecff89583f3b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -9378,9 +9378,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (message.subtype === "model_refusal_fallback" && message.scope !== "local") { const modelSelection = { ...context.input.modelSelection, - model: resolveClaudeModelSlug(subagentModelCatalog, message.fallback_model), + model: resolveClaudeModelSlug(modelCatalog, message.fallback_model), }; - liveQuery.selection = compileClaudeModelSelection(modelSelection); + liveQuery.selection = compileClaudeModelSelection(modelSelection, modelCatalog); yield* emitProviderEvent({ type: "app_thread.model_selection.updated", driver: CLAUDE_PROVIDER, From 48528e8496c14e942ba5931f8421d51e90c27bee Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 23:34:20 +0000 Subject: [PATCH 5/8] fix(claude): reject fallback updates from superseded runs --- .../Adapters/ClaudeAdapterV2.test.ts | 2 + .../Adapters/ClaudeAdapterV2.ts | 2 + .../ProviderEventIngestor.test.ts | 61 ++++++++++++++++++- .../orchestration-v2/ProviderEventIngestor.ts | 20 ++++++ .../src/server/ProviderAdapter.ts | 2 + 5 files changed, 85 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 0eb2f9ac2f437..a4497c0fb999a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -6517,6 +6517,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { [ { type: "app_thread.model_selection.updated", + runId: turn(1, original).runId, + attemptId: turn(1, original).attemptId, driver: ClaudeAdapterV2.CLAUDE_PROVIDER, threadId: harness.threadId, previousModelSelection: original, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 52e5cdbdbb2f2..ced8cb7bc23ac 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -9636,6 +9636,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( liveQuery.selection = compileClaudeModelSelection(modelSelection, modelCatalog); yield* emitProviderEvent({ type: "app_thread.model_selection.updated", + runId: context.input.runId, + attemptId: context.input.attemptId, driver: CLAUDE_PROVIDER, threadId: context.input.threadId, previousModelSelection: context.input.modelSelection, diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index e852b586c4d14..8a507615ce396 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -2359,6 +2359,35 @@ layer("ProviderEventIngestorV2", (it) => { providerInstanceId: modelSelection.instanceId, threadId, }); + const runId = RunId.make("run:fallback"); + const attemptId = RunAttemptId.make("attempt:fallback"); + const run = { + id: runId, + threadId, + ordinal: 1, + providerInstanceId: modelSelection.instanceId, + modelSelection, + providerThreadId: null, + userMessageId: MessageId.make("message:fallback"), + rootNodeId: NodeId.make("node:fallback"), + activeAttemptId: attemptId, + status: "running", + requestedAt: now, + startedAt: now, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + } satisfies OrchestrationV2Run; + yield* eventSink.write({ + events: [ + { + ...rootEvent, + id: yield* idAllocator.allocate.event({ threadId }), + type: "run.created", + payload: run, + }, + ], + }); const reportModel = (reported: ModelSelection) => ingestor.ingestNormalized({ providerSessionId, @@ -2366,6 +2395,8 @@ layer("ProviderEventIngestorV2", (it) => { threadId, event: { type: "app_thread.model_selection.updated", + runId, + attemptId, driver: CODEX_DRIVER, threadId, previousModelSelection: modelSelection, @@ -2374,13 +2405,15 @@ layer("ProviderEventIngestorV2", (it) => { }); const switched = { ...modelSelection, model: "gpt-fallback" }; - const first = yield* reportModel(switched); - const repeated = yield* reportModel(switched); const otherInstance = yield* reportModel({ ...switched, instanceId: ProviderInstanceId.make("codex-work"), }); + assert.lengthOf(otherInstance, 0); + const first = yield* reportModel(switched); + const repeated = yield* reportModel(switched); + assert.deepEqual( first.map((stored) => stored.event.type), ["thread.model-selection-updated"], @@ -2409,6 +2442,30 @@ layer("ProviderEventIngestorV2", (it) => { assert.lengthOf(yield* reportModel(switched), 0); assert.deepEqual((yield* projectionStore.getThread(threadId)).modelSelection, newer); } + yield* eventSink.write({ + events: [ + { + ...rootEvent, + id: yield* idAllocator.allocate.event({ threadId }), + type: "thread.model-selection-updated", + payload: { ...(yield* projectionStore.getThread(threadId)), modelSelection }, + }, + { + ...rootEvent, + id: yield* idAllocator.allocate.event({ threadId }), + type: "run.updated", + payload: { ...run, status: "interrupted", completedAt: now }, + }, + { + ...rootEvent, + id: yield* idAllocator.allocate.event({ threadId }), + type: "run.created", + payload: { ...run, id: RunId.make("run:newer"), ordinal: 2 }, + }, + ], + }); + assert.lengthOf(yield* reportModel(switched), 0); + assert.deepEqual((yield* projectionStore.getThread(threadId)).modelSelection, modelSelection); }), ); }); diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 48d4a4c898ed9..d86ba4831731b 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -368,6 +368,26 @@ export const layer: Layer.Layer< }, occurredAt: now, }); + if (input.event.type === "app_thread.model_selection.updated") { + const source = input.event; + const run = yield* projections.getRun(threadId, source.runId); + if ( + run === undefined || + run.activeAttemptId !== source.attemptId || + (run.status !== "running" && run.status !== "waiting") + ) + return []; + const result = yield* eventSink + .writeIfRunCurrent({ + threadId, + runId: source.runId, + activeAttemptId: source.attemptId, + expectedStatus: run.status, + events: [event], + }) + .pipe(retryLockTimeout); + return result.storedEvents; + } return yield* eventSink.write({ events: [event] }).pipe(retryLockTimeout); }), ); diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 8c474e20c258a..9fabc0127cffb 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -90,6 +90,8 @@ export const ProviderAdapterV2Event = Schema.Union([ /** The provider moved the thread's session to another model on its own, such as Claude's refusal fallback. */ Schema.Struct({ type: Schema.Literal("app_thread.model_selection.updated"), + runId: RunId, + attemptId: RunAttemptId, driver: ProviderDriverKind, threadId: ThreadId, previousModelSelection: ModelSelection, From da93dd9f0523a2d07bda20b10741b59376fd394e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 23:42:07 +0000 Subject: [PATCH 6/8] fix(claude): follow fallback after a model-changing steer --- .../Adapters/ClaudeAdapterV2.test.ts | 31 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 18 +++++++---- 2 files changed, 43 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 a4497c0fb999a..9938bbb807e35 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -6533,6 +6533,37 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* harness.runtime.startTurn(turn(3, original)); yield* settle; assert.deepEqual(harness.liveSelectionChanges[0], { model: original.model }); + const steered = { ...original, model: "claude-sonnet-4-6" }; + const input = turn(4, original); + yield* harness.runtime.startTurn(input); + const idAllocator = yield* IdAllocator.IdAllocatorV2; + 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: steered, + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make("steer-fallback"), + text: "Try this model", + attachments: [], + }, + }); + yield* Queue.offer( + harness.sdkMessages, + refusalFallback("session", "00000000-0000-4000-8000-000000000403"), + ); + yield* settle; + const updates = harness.events.filter( + (event) => event.type === "app_thread.model_selection.updated", + ); + assert.deepEqual(updates.at(-1)?.previousModelSelection, steered); + assert.deepEqual(updates.at(-1)?.modelSelection, fallback); }).pipe( Effect.scoped, Effect.provide( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index ced8cb7bc23ac..93501c29cfdaa 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -4526,6 +4526,7 @@ interface ClaudeLiveQueryContext { readonly query: ClaudeAgentSdkQuerySession; readonly queryPolicyKey: string; selection: CompiledClaudeModelSelection; + modelSelection: ModelSelection; readonly closed: Deferred.Deferred; lastTurn: ActiveClaudeTurnContext | null; // "early" echoes on the first frame; "acknowledged" confirms uuid acceptance, but resume turns can still precede the prompt. @@ -9629,18 +9630,20 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const nativeItemId = message.uuid; // "local" covers a subagent or side question; absent (older CLIs) means "session". if (message.subtype === "model_refusal_fallback" && message.scope !== "local") { + const previousModelSelection = liveQuery.modelSelection; const modelSelection = { - ...context.input.modelSelection, + ...previousModelSelection, model: resolveClaudeModelSlug(modelCatalog, message.fallback_model), }; liveQuery.selection = compileClaudeModelSelection(modelSelection, modelCatalog); + liveQuery.modelSelection = modelSelection; yield* emitProviderEvent({ type: "app_thread.model_selection.updated", runId: context.input.runId, attemptId: context.input.attemptId, driver: CLAUDE_PROVIDER, threadId: context.input.threadId, - previousModelSelection: context.input.modelSelection, + previousModelSelection, modelSelection, }); } @@ -11212,6 +11215,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const applySelectionLive = Effect.fnUntraced(function* ( live: ClaudeLiveQueryContext, selection: CompiledClaudeModelSelection, + modelSelection: ModelSelection, ) { // Preserve launcher-selected models unless the model changed or a failed update left it unknown. yield* ( @@ -11230,6 +11234,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ), ); live.selection = selection; + live.modelSelection = modelSelection; }); const openQuery = Effect.fnUntraced(function* ( @@ -11272,7 +11277,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( (existing.selection.apiModelId === compiledSelection.apiModelId || existing.openedPermissionMode === queryPolicy.permissionMode) ) { - yield* applySelectionLive(existing, compiledSelection); + yield* applySelectionLive(existing, compiledSelection, turnInput.modelSelection); } if ( existing !== null && @@ -11436,6 +11441,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( query: querySession, queryPolicyKey, selection: compiledSelection, + modelSelection: turnInput.modelSelection, closed, lastTurn: null, promptEchoMode: "unknown", @@ -12117,8 +12123,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( Effect.provideService(Path.Path, path), ); yield* backgroundForegroundSubagents(existing, backgroundDeadline); - if (liveSteerSelection !== undefined) { - yield* applySelectionLive(existing, liveSteerSelection); + if (liveSteerSelection !== undefined && turnInput.modelSelection !== undefined) { + yield* applySelectionLive(existing, liveSteerSelection, turnInput.modelSelection); } // 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 @@ -12511,7 +12517,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return; } // A failed update clears the live identity, so the next turn applies the selection again. - yield* applySelectionLive(live, selection).pipe( + yield* applySelectionLive(live, selection, modelSelection).pipe( Effect.catch((cause) => Effect.logWarning("orchestration-v2.claude-live-selection-failed", { cause }), ), From 15004213d736f1e577fc0555103ac6df3925f8b7 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 23:44:55 +0000 Subject: [PATCH 7/8] fix(claude): refresh fallback selection when reusing a query --- .../Adapters/ClaudeAdapterV2.test.ts | 17 ++++++++++++++++- .../Adapters/ClaudeAdapterV2.ts | 1 + 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 9938bbb807e35..aac716f107d13 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -6533,8 +6533,23 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* harness.runtime.startTurn(turn(3, original)); yield* settle; assert.deepEqual(harness.liveSelectionChanges[0], { model: original.model }); + const explicitDefault = { ...original, options: [{ id: "fastMode", value: false }] }; + yield* harness.runtime.startTurn(turn(4, explicitDefault)); + yield* Queue.offer( + harness.sdkMessages, + refusalFallback("session", "00000000-0000-4000-8000-000000000404"), + ); + yield* settle; + const defaultUpdate = harness.events + .filter((event) => event.type === "app_thread.model_selection.updated") + .at(-1); + assert.deepEqual(defaultUpdate?.previousModelSelection, explicitDefault); + assert.deepEqual(defaultUpdate?.modelSelection, { + ...fallback, + options: explicitDefault.options, + }); const steered = { ...original, model: "claude-sonnet-4-6" }; - const input = turn(4, original); + const input = turn(5, original); yield* harness.runtime.startTurn(input); const idAllocator = yield* IdAllocator.IdAllocatorV2; yield* harness.runtime.steerTurn({ diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 93501c29cfdaa..695892d965190 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -11303,6 +11303,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }), ); } + existing.modelSelection = turnInput.modelSelection; return existing; } From a235cfe10099bb08c7e67f5bd5ae97192067f1a2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 21:56:07 +0000 Subject: [PATCH 8/8] test(server): drop a duplicate fallback assertion AI-assisted; verified with the tests and tools listed above. --- apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index 8a507615ce396..775e6ad235971 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -2419,7 +2419,6 @@ layer("ProviderEventIngestorV2", (it) => { ["thread.model-selection-updated"], ); assert.lengthOf(repeated, 0); - assert.lengthOf(otherInstance, 0); assert.deepEqual((yield* projectionStore.getThread(threadId)).modelSelection, switched); for (const newer of [ { ...modelSelection, model: "user-selected-model" },