From 20a2c3de8b39afdaff27156d88216af57ad80701 Mon Sep 17 00:00:00 2001 From: b <83741810+BelugaCrypto@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:09:07 +0100 Subject: [PATCH 1/6] fix(mobile): a queued follow-up carries a model only when the user picked one From #24562, ported onto the outbox delivery service. Stored follow-ups from older builds no longer carry a stale model either. Co-Authored-By: Vitalii Yehorov Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/state/thread-outbox-model.ts | 11 +++++- apps/mobile/src/state/thread-outbox.test.ts | 31 +++++++++++++++ .../src/state/thread-task-permissions.test.ts | 14 +++++++ .../threadOutboxDelivery.permissions.test.ts | 38 ++++++++++++++++++- apps/mobile/src/state/threadOutboxDelivery.ts | 15 ++++++-- .../src/state/use-thread-composer-state.ts | 4 +- 6 files changed, 106 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 703d9f8450fdb..6c2046fe0445d 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -55,6 +55,9 @@ export const QueuedThreadMessageSchema = Schema.Struct({ context: Schema.optional(OrchestrationMessageContext), attachments: Schema.Array(DraftComposerAttachmentSchema), modelSelection: Schema.optional(ModelSelection), + // Written with every stored model since follow-ups carry only the user's pick. Entries without it + // come from builds that stored this client's cached thread model on every follow-up. + modelPicked: Schema.optional(Schema.Literal(true)), dispatchMode: Schema.optional(Schema.Literals(["auto", "queue", "steer", "restart"])), compactBeforeSend: Schema.optional(Schema.Boolean), runtimeMode: Schema.optional(RuntimeMode), @@ -133,12 +136,16 @@ export function encodeQueuedThreadMessage(message: QueuedThreadMessage): unknown return encodeStoredQueuedThreadMessage({ schemaVersion: THREAD_OUTBOX_SCHEMA_VERSION, ...message, + ...(message.modelSelection === undefined ? {} : { modelPicked: true as const }), }); } export function decodeQueuedThreadMessage(value: unknown): QueuedThreadMessage { - const { schemaVersion: _, ...message } = decodeStoredQueuedThreadMessage(value); - return message; + const { schemaVersion: _, modelPicked, ...message } = decodeStoredQueuedThreadMessage(value); + if (message.creation !== undefined || modelPicked === true) return message; + // Possibly a stale cached model rather than a pick: let the server use the thread's own. + const { modelSelection: _unconfirmed, ...followUp } = message; + return followUp; } export function groupQueuedThreadMessages( diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index dbd621004b0d6..b784d40f4c291 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -137,6 +137,37 @@ describe("thread outbox", () => { decodeQueuedThreadMessage(JSON.parse(JSON.stringify(encodeQueuedThreadMessage(message)))), ).toEqual(message); }); + it("keeps a picked follow-up model through a persisted round trip", () => { + const message: QueuedThreadMessage = { + ...queuedMessage({ messageId: "picked-model", createdAt: "2026-10-23T23:00:00.000Z" }), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, + }; + + expect( + decodeQueuedThreadMessage(JSON.parse(JSON.stringify(encodeQueuedThreadMessage(message)))), + ).toEqual(message); + }); + + it("drops the model an older build stored on a follow-up, so the server keeps the thread's own", () => { + const stale = { instanceId: "claudeAgent", model: "claude-opus-5-5" }; + const base = queuedMessage({ messageId: "older-entry", createdAt: "2026-10-23T23:00:00.000Z" }); + const stored = { schemaVersion: 3, ...base, modelSelection: stale }; + + expect(decodeQueuedThreadMessage(stored)).toEqual(base); + // A new thread needs its model, so an older creation entry keeps it. + const creation = { + projectId: ProjectId.make("project-1"), + workspaceMode: "local" as const, + branch: null, + worktreePath: null, + }; + expect(decodeQueuedThreadMessage({ ...stored, creation })).toEqual({ + ...base, + modelSelection: stale, + creation, + }); + }); + it.each(["read", "json", "schema"] as const)( "recovers usable messages without permitting cleanup after a record %s failure", async (failure) => { diff --git a/apps/mobile/src/state/thread-task-permissions.test.ts b/apps/mobile/src/state/thread-task-permissions.test.ts index adf72cc6527b1..2436ad67f6051 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -319,6 +319,20 @@ describe("mobile task permissions", () => { }); }); +describe("queued follow-up model", () => { + it("queues a follow-up with no model when the user picked none", async () => { + state.grantedEnvironments.add("secondary"); + const composer = useThreadComposerState(); + + expect(await composer.onSendMessage()).toBe("message"); + + // The outbox can deliver after the thread moved elsewhere; only the server knows its model then. + expect(state.enqueue).toHaveBeenCalledOnce(); + const [queued] = state.enqueue.mock.calls[0] as unknown as [object]; + expect(queued).not.toHaveProperty("modelSelection"); + }); +}); + describe("mobile handoff preview and submission", () => { it("follows project enablement and provider availability for the selected destination", async () => { state.grantedEnvironments.add("secondary"); diff --git a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts index fdd5c71094161..4e2eff4006a0b 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts @@ -325,6 +325,40 @@ afterEach(() => { state.cleanups.splice(0).forEach((cleanup) => cleanup()); }); +describe("queued follow-up model", () => { + it("delivers a follow-up queued without a pick with no model, so the server keeps the thread's own", async () => { + setOperateGrant("secondary", true); + await state.manager.enqueue(message()); + + await runDrain(); + + expect(state.start).toHaveBeenCalledOnce(); + expect(state.start.mock.calls[0]?.[0].input).not.toHaveProperty("modelSelection"); + }); + + it("delivers a follow-up without a pick even when this client's copy of the thread model looks unavailable", async () => { + setOperateGrant("secondary", true); + state.modelUnavailable.mockReturnValue(true); + await state.manager.enqueue(message()); + + await runDrain(); + + expect(state.start).toHaveBeenCalledOnce(); + expect(remaining()).toEqual([]); + }); + + it("delivers the model the user picked with the follow-up", async () => { + setOperateGrant("secondary", true); + const picked = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }; + await state.manager.enqueue(message({ modelSelection: picked })); + + await runDrain(); + + expect(state.start).toHaveBeenCalledOnce(); + expect(state.start.mock.calls[0]?.[0].input.modelSelection).toEqual(picked); + }); +}); + describe("queued task operation access", () => { it("parks a connected read-only target, then delivers its unchanged queue after a grant", async () => { const queued = message(); @@ -341,7 +375,9 @@ describe("queued task operation access", () => { it("backs off instead of redelivering at once when a delivery hits a defect", async () => { setOperateGrant("secondary", true); - const queued = message(); + const queued = message({ + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, + }); await state.manager.enqueue(queued); // Awaited steps catch their own failures, so a defect in the delivery's own code is what // rejects a delivery. diff --git a/apps/mobile/src/state/threadOutboxDelivery.ts b/apps/mobile/src/state/threadOutboxDelivery.ts index 44bd564ef8969..d4875bc65af48 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.ts @@ -775,7 +775,13 @@ export function createThreadOutboxDelivery(): { ); if (!serverConfig) return false; const settings = resolveQueuedThreadSettings(queuedMessage, thread, serverConfig.providers); - if (isModelSelectionUnavailable(serverConfig, settings.modelSelection, thread.projectId)) { + // Without a pick the server delivers on the thread's own model, which this client's copy + // of the thread can misreport right after reconnecting; the server checks that one itself. + const picked = queuedMessage.modelSelection !== undefined; + if ( + picked && + isModelSelectionUnavailable(serverConfig, settings.modelSelection, thread.projectId) + ) { const { title, detail } = describeUnavailableModelSelection( serverConfig, settings.modelSelection, @@ -860,7 +866,10 @@ export function createThreadOutboxDelivery(): { serverEnvironment.configValueAtom(queuedMessage.environmentId), ); if (!currentConfig) return false; - if (isModelSelectionUnavailable(currentConfig, settings.modelSelection, thread.projectId)) { + if ( + picked && + isModelSelectionUnavailable(currentConfig, settings.modelSelection, thread.projectId) + ) { const { title, detail } = describeUnavailableModelSelection( currentConfig, settings.modelSelection, @@ -924,7 +933,7 @@ export function createThreadOutboxDelivery(): { ), attachments: prepared.attachments, }, - modelSelection: sendSettings.modelSelection, + ...(picked ? { modelSelection: sendSettings.modelSelection } : {}), titleSeed: deriveThreadTitleSeed({ text: queuedMessage.text, attachments: queuedMessage.attachments, diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 336226cce8bff..6525e35b8c82f 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -692,7 +692,9 @@ export function useThreadComposerState() { text, attachments, context: draft.context, - modelSelection, + // Only a pick travels with the message. The thread's model is read by the server when the + // outbox delivers, because this client's copy of it can be stale by then. + ...(draft.modelSelection === undefined ? {} : { modelSelection: draft.modelSelection }), runtimeMode: draft.runtimeMode ?? thread.runtimeMode, interactionMode: resolveProviderInteractionMode( provider, From b715abb8657bef55f080c0a1bc21ea3b5ea0aef5 Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Thu, 29 Oct 2026 10:10:49 +0100 Subject: [PATCH 2/6] fix(server,mobile): a model pick applies to the message it was sent with Unpicked client follow-ups queue on the newest queued pick, skipping monitor updates; goal commands and server-created messages keep the thread's model. Mobile clears a delivered pick from the draft once drafts have loaded. Co-Authored-By: Claude Opus 5.5 --- .../threadOutboxDelivery.permissions.test.ts | 1 + apps/mobile/src/state/threadOutboxDelivery.ts | 7 + .../src/state/use-composer-drafts.test.ts | 18 ++ apps/mobile/src/state/use-composer-drafts.ts | 15 ++ .../src/orchestration-v2/Orchestrator.ts | 17 +- .../ProviderSwitch.integration.test.ts | 182 ++++++++++++++++++ 6 files changed, 239 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts index 4e2eff4006a0b..fc47167831507 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts @@ -152,6 +152,7 @@ vi.mock("./use-composer-drafts", async () => { }), replaceComposerDraftAttachments: vi.fn(), undoComposerDraftMerge: vi.fn(), + releaseDeliveredComposerModelPick: vi.fn(async () => {}), updateComposerDraftSettings: vi.fn(), waitForComposerDraftsLoaded: vi.fn(), }; diff --git a/apps/mobile/src/state/threadOutboxDelivery.ts b/apps/mobile/src/state/threadOutboxDelivery.ts index d4875bc65af48..c33a782c9fb90 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.ts @@ -83,6 +83,7 @@ import { undoComposerDraftMerge, updateComposerDraftSettings, waitForComposerDraftsLoaded, + releaseDeliveredComposerModelPick, } from "./use-composer-drafts"; import { dispatchingQueuedMessageIdAtom, @@ -953,6 +954,12 @@ export function createThreadOutboxDelivery(): { return restoreQueuedMessage(persistedMessage, failure.message); } acknowledgedExistingThreadMessageIds.add(persistedMessage.messageId); + if (queuedMessage.modelSelection) { + await releaseDeliveredComposerModelPick( + scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId), + queuedMessage.modelSelection, + ); + } const delivered = (await completeQueuedMessageDelivery( persistedMessage, diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 1f9345da8284e..aa6eae3ac25d7 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -181,6 +181,7 @@ import { archiveCloudComposerDrafts, clearComposerDraftContent, clearComposerDraftContentState, + releaseDeliveredComposerModelPick, subscribeComposerDraftModelReconciliation, clearComposerDraftsEnvironment, ComposerDraftPersistenceError, @@ -549,6 +550,23 @@ describe("mobile composer drafts", () => { expect(getComposerDraftSnapshot(key)).toEqual({ ...before, modelSelection: undefined }); }); + it("releases a pick once a message carrying it is delivered", async () => { + const key = "environment-1:thread-1"; + const pick = { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-5-5" }; + setComposerDraftText(key, "next follow-up"); + updateComposerDraftSettings(key, { modelSelection: pick, interactionMode: "plan" }); + const before = getComposerDraftSnapshot(key); + + await releaseDeliveredComposerModelPick(key, { ...pick, model: "claude-sonnet-5-5" }); + expect(getComposerDraftSnapshot(key)).toEqual(before); + + await releaseDeliveredComposerModelPick(key, { + ...pick, + options: [{ id: "fastMode", value: false }], + }); + expect(getComposerDraftSnapshot(key)).toEqual({ ...before, modelSelection: undefined }); + }); + it("retains an unavailable provider pick through differing durable model updates", () => { const environmentId = EnvironmentId.make("environment-1"); const threadId = ThreadId.make("thread-1"); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 2a19a7a4f7b72..7b967287fb8ee 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1521,6 +1521,21 @@ export function updateComposerDraftSettings( }); } +export async function releaseDeliveredComposerModelPick( + draftKey: string, + delivered: ModelSelection, +): Promise { + await waitForComposerDraftsLoaded(); + updateComposerDrafts((current) => { + const draft = current[draftKey]; + if (!draft?.modelSelection || !draftModelPickMatchesThread(draft.modelSelection, delivered)) { + return current; + } + const { modelSelection: _modelSelection, ...rest } = draft; + return withComposerDraft(current, draftKey, rest); + }); +} + export function subscribeComposerDraftModelReconciliation(input: { readonly registry: AtomRegistry.AtomRegistry; readonly threadRefsAtom: Atom.Atom>; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index a6b876db1de96..9000e9ba6850f 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -5710,7 +5710,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ]); projection = applyToProjection(projection, event); } - const modelSelection = command.modelSelection ?? projection.thread.modelSelection; + let modelSelection = command.modelSelection ?? projection.thread.modelSelection; let dispatchMode = resolveMessageDispatchIntent( projection, command.dispatchMode, @@ -5995,6 +5995,21 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio command.createdBy === "user" && (command.creationSource === "web" || command.creationSource === "mobile") && command.scheduledTaskId === undefined; + // A queued pick reaches the thread only when its run starts; unpicked messages behind it follow it. + // Monitor updates follow whatever runs before them; goal commands stay on the provider validated above. + const newestQueuedPick = + shouldQueue && + sentByUserFromClient && + command.modelSelection === undefined && + parsedGoalCommand === null + ? queuedRunsInDeliveryOrder(projection).findLast( + (run) => + !followsCurrentConversation( + projection.messages.find((message) => message.id === run.userMessageId), + ), + ) + : undefined; + if (newestQueuedPick) modelSelection = newestQueuedPick.modelSelection; const heldRunsToRelease = sentByUserFromClient && startsOrQueuesRun && !worktreeSetupUnresolved ? projection.runs.filter((run) => run.status === "queued" && run.queueHeld === true) diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index 629b3902e763d..e45759c797f59 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -1891,6 +1891,188 @@ describe("orchestration v2 provider switching", () => { ), ); + it.live("queues an unpicked follow-up on the newest queued pick", () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace("queued-unpicked-follow-up"); + const capturedTurns = yield* Ref.make>([]); + const started = yield* Deferred.make(); + const threadId = ThreadId.make("thread:queued-unpicked-follow-up"); + const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([ + makeTestAdapter({ + instanceId: CODEX_MODEL_SELECTION.instanceId, + driver: CODEX_DRIVER, + capabilities: CodexProviderCapabilitiesV2, + modelSelection: CODEX_MODEL_SELECTION, + responseByRunOrdinal: {}, + capturedTurns, + holdFirstTurn: started, + }), + makeTestAdapter({ + instanceId: CLAUDE_MODEL_SELECTION.instanceId, + driver: CLAUDE_DRIVER, + capabilities: ClaudeProviderCapabilitiesV2, + modelSelection: CLAUDE_MODEL_SELECTION, + responseByRunOrdinal: {}, + capturedTurns, + }), + ]); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:queued-unpicked-follow-up:create"), + threadId, + projectId: ProjectId.make("project:queued-unpicked-follow-up"), + title: "Queued unpicked follow-up", + modelSelection: CODEX_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:queued-unpicked-follow-up:first"), + threadId, + messageId: MessageId.make("message:queued-unpicked-follow-up:first"), + text: "Current Codex turn", + attachments: [], + modelSelection: CODEX_MODEL_SELECTION, + dispatchMode: { type: "start_immediately" }, + }); + yield* Deferred.await(started); + yield* worker.drain(); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "mobile", + commandId: CommandId.make("command:queued-unpicked-follow-up:pick"), + threadId, + messageId: MessageId.make("message:queued-unpicked-follow-up:pick"), + text: "Picked Claude", + attachments: [], + modelSelection: CLAUDE_MODEL_SELECTION, + dispatchMode: { type: "queue_after_active" }, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "mobile", + commandId: CommandId.make("command:queued-unpicked-follow-up:follow-up"), + threadId, + messageId: MessageId.make("message:queued-unpicked-follow-up:follow-up"), + text: "Follow-up without a pick", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + }); + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual( + projection.runs.map((run) => [run.status, run.modelSelection.instanceId]), + [ + ["running", CODEX_MODEL_SELECTION.instanceId], + ["queued", CLAUDE_MODEL_SELECTION.instanceId], + ["queued", CLAUDE_MODEL_SELECTION.instanceId], + ], + ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "agent", + creationSource: "server", + commandId: CommandId.make("command:queued-unpicked-follow-up:monitor"), + threadId, + messageId: MessageId.make("message:queued-unpicked-follow-up:monitor"), + text: "Monitor update", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + notification: { source: { kind: "monitor" }, outcome: "updated", summary: "Update" }, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "mobile", + commandId: CommandId.make("command:queued-unpicked-follow-up:after-monitor"), + threadId, + messageId: MessageId.make("message:queued-unpicked-follow-up:after-monitor"), + text: "Follow-up after a monitor update", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + }); + const afterMonitor = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + afterMonitor.runs.find( + (run) => run.userMessageId === "message:queued-unpicked-follow-up:after-monitor", + )?.modelSelection.instanceId, + CLAUDE_MODEL_SELECTION.instanceId, + ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "server", + commandId: CommandId.make("command:queued-unpicked-follow-up:server"), + threadId, + messageId: MessageId.make("message:queued-unpicked-follow-up:server"), + text: "Server-created answer", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + }); + const withServerMessage = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + withServerMessage.runs.find( + (run) => run.userMessageId === "message:queued-unpicked-follow-up:server", + )?.modelSelection.instanceId, + CODEX_MODEL_SELECTION.instanceId, + ); + yield* orchestrator.dispatch({ + type: "thread.stop", + commandId: CommandId.make("command:queued-unpicked-follow-up:stop"), + threadId, + }); + yield* worker.drain(); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "mobile", + commandId: CommandId.make("command:queued-unpicked-follow-up:continue"), + threadId, + messageId: MessageId.make("message:queued-unpicked-follow-up:continue"), + text: "Continue without a pick", + attachments: [], + dispatchMode: { type: "start_immediately" }, + }); + const continued = yield* orchestrator.getThreadProjection(threadId); + const continuation = continued.runs.find( + (run) => run.userMessageId === "message:queued-unpicked-follow-up:continue", + ); + assert.equal(continuation?.modelSelection.instanceId, CODEX_MODEL_SELECTION.instanceId); + }).pipe( + Effect.provide( + ProviderReplayHarness.layerWithRegistry( + { + name: "queued-unpicked-follow-up", + runtimePolicyOverride: { + cwd, + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }, + layerRegistry, + ), + ), + ); + }), + ), + ); + it.live("hands completed Grok steering context to earlier queued Codex and later Claude", () => Effect.scoped( Effect.gen(function* () { From f62236a80d8ef13c0ecc52c1311e8f84bd031f76 Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Mon, 2 Nov 2026 07:54:00 +0100 Subject: [PATCH 3/6] fix(mobile): let unpicked follow-ups pass model preflight --- .../src/state/thread-task-permissions.test.ts | 34 +++++++++++++++++++ .../src/state/use-thread-composer-state.ts | 5 +-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/state/thread-task-permissions.test.ts b/apps/mobile/src/state/thread-task-permissions.test.ts index 2436ad67f6051..5f19feefdea3a 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -320,6 +320,40 @@ describe("mobile task permissions", () => { }); describe("queued follow-up model", () => { + it("queues an unpicked follow-up despite an unavailable cached thread model", async () => { + state.grantedEnvironments.add("secondary"); + state.serverConfig = { + ...state.serverConfig, + providers: [{ instanceId: "codex", driver: "codex", enabled: false }], + } as unknown as ServerConfig; + + expect(await useThreadComposerState().onSendMessage()).toBe("message"); + + expect(state.enqueue).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ text: "Keep my draft", threadId: "thread" }), + ); + const [queued] = state.enqueue.mock.calls[0] as unknown as [object]; + expect(queued).not.toHaveProperty("modelSelection"); + expect(state.clearDraft).toHaveBeenCalledOnce(); + }); + + it("preserves an explicit unavailable pick instead of queueing it", async () => { + state.grantedEnvironments.add("secondary"); + state.serverConfig = { + ...state.serverConfig, + providers: [{ instanceId: "codex", driver: "codex", enabled: false }], + } as unknown as ServerConfig; + state.draft.modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }; + + expect(await useThreadComposerState().onSendMessage()).toBeNull(); + + expect(state.enqueue).not.toHaveBeenCalled(); + expect(state.clearDraft).not.toHaveBeenCalled(); + }); + it("queues a follow-up with no model when the user picked none", async () => { state.grantedEnvironments.add("secondary"); const composer = useThreadComposerState(); diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 6525e35b8c82f..476cda383bd73 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -562,11 +562,12 @@ export function useThreadComposerState() { const serverConfig = selectedEnvironmentRuntime?.serverConfig; if ( selectedEnvironmentRuntime?.connectionState === "connected" && - isModelSelectionUnavailable(serverConfig, modelSelection, thread.projectId) + draft.modelSelection !== undefined && + isModelSelectionUnavailable(serverConfig, draft.modelSelection, thread.projectId) ) { const { title, detail } = describeUnavailableModelSelection( serverConfig, - modelSelection, + draft.modelSelection, thread.projectId, ); Dialog.alert(title, detail); From 75d90347e5e80edc355c326c8ee73380db13ee79 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 21:22:21 +0000 Subject: [PATCH 4/6] refactor(mobile): drop server queue pick now covered on main, trim tests Main's queue-tail model selection (#33591) already gives an unpicked queued message the model picked ahead of it, for every sender, so the orchestrator hunk and its integration test are redundant. The composer keeps main's unavailable-model check, since the send button already gates on the shown model; only the enqueued payload changes. Tests fold the picked round trip into the existing one and assert that a delivered pick releases the draft's pick. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/state/thread-outbox.test.ts | 13 +- .../src/state/thread-task-permissions.test.ts | 36 +--- .../threadOutboxDelivery.permissions.test.ts | 5 + .../src/orchestration-v2/Orchestrator.ts | 15 -- .../ProviderSwitch.integration.test.ts | 182 ------------------ 5 files changed, 8 insertions(+), 243 deletions(-) diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index e1b6eed7de4f0..39d2404d1bebc 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -112,9 +112,10 @@ function queuedMessage(input: { } describe("thread outbox", () => { - it("retains structured context through a persisted offline queue round trip", () => { + it("retains structured context and a model pick through a persisted offline queue round trip", () => { const message: QueuedThreadMessage = { ...queuedMessage({ messageId: "context-message", createdAt: "2026-09-06T12:00:00.000Z" }), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, text: "[Build](t3-context://v1/terminal/build-output)", context: { version: 1, @@ -137,16 +138,6 @@ describe("thread outbox", () => { decodeQueuedThreadMessage(JSON.parse(JSON.stringify(encodeQueuedThreadMessage(message)))), ).toEqual(message); }); - it("keeps a picked follow-up model through a persisted round trip", () => { - const message: QueuedThreadMessage = { - ...queuedMessage({ messageId: "picked-model", createdAt: "2026-10-23T23:00:00.000Z" }), - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, - }; - - expect( - decodeQueuedThreadMessage(JSON.parse(JSON.stringify(encodeQueuedThreadMessage(message)))), - ).toEqual(message); - }); it("drops the model an older build stored on a follow-up, so the server keeps the thread's own", () => { const stale = { instanceId: "claudeAgent", model: "claude-opus-5-5" }; diff --git a/apps/mobile/src/state/thread-task-permissions.test.ts b/apps/mobile/src/state/thread-task-permissions.test.ts index e6cf6ca1af5ee..3eead9619cfcc 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -507,43 +507,9 @@ describe("mobile task permissions", () => { }); describe("queued follow-up model", () => { - it("queues an unpicked follow-up despite an unavailable cached thread model", async () => { - state.grantedEnvironments.add("secondary"); - state.serverConfig = { - ...state.serverConfig, - providers: [{ instanceId: "codex", driver: "codex", enabled: false }], - } as unknown as ServerConfig; - - expect(await useThreadComposerState().onSendMessage()).toBe("message"); - - expect(state.enqueue).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ text: "Keep my draft", threadId: "thread" }), - ); - const [queued] = state.enqueue.mock.calls[0] as unknown as [object]; - expect(queued).not.toHaveProperty("modelSelection"); - expect(state.clearDraft).toHaveBeenCalledOnce(); - }); - - it("preserves an explicit unavailable pick instead of queueing it", async () => { - state.grantedEnvironments.add("secondary"); - state.serverConfig = { - ...state.serverConfig, - providers: [{ instanceId: "codex", driver: "codex", enabled: false }], - } as unknown as ServerConfig; - state.draft.modelSelection = { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", - }; - - expect(await useThreadComposerState().onSendMessage()).toBeNull(); - - expect(state.enqueue).not.toHaveBeenCalled(); - expect(state.clearDraft).not.toHaveBeenCalled(); - }); - it("queues a follow-up with no model when the user picked none", async () => { state.grantedEnvironments.add("secondary"); - const composer = useThreadComposerState(); + const composer = useThreadComposerState(NO_SIDE_CHATS); expect(await composer.onSendMessage()).toBe("message"); diff --git a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts index 284a935899eab..825b093da1783 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts @@ -164,6 +164,7 @@ import { appAtomRegistry } from "./atom-registry"; import { environmentSession } from "./session"; import { dispatchingQueuedMessageIdAtom, editingQueuedMessageIdsAtom } from "./use-thread-outbox"; import { createThreadOutboxDelivery } from "./threadOutboxDelivery"; +import { releaseDeliveredComposerModelPick } from "./use-composer-drafts"; const message = (overrides: Partial = {}): QueuedThreadMessage => ({ environmentId: EnvironmentId.make("secondary"), @@ -359,6 +360,10 @@ describe("queued follow-up model", () => { expect(state.start).toHaveBeenCalledOnce(); expect(state.start.mock.calls[0]?.[0].input.modelSelection).toEqual(picked); + expect(releaseDeliveredComposerModelPick).toHaveBeenCalledExactlyOnceWith( + "secondary:thread", + picked, + ); }); }); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 1b723a48cd87a..c7cd7a9ff288e 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -7039,21 +7039,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio command.createdBy === "user" && (command.creationSource === "web" || command.creationSource === "mobile") && command.scheduledTaskId === undefined; - // A queued pick reaches the thread only when its run starts; unpicked messages behind it follow it. - // Monitor updates follow whatever runs before them; goal commands stay on the provider validated above. - const newestQueuedPick = - shouldQueue && - sentByUserFromClient && - command.modelSelection === undefined && - parsedGoalCommand === null - ? queuedRunsInDeliveryOrder(projection).findLast( - (run) => - !followsCurrentConversation( - projection.messages.find((message) => message.id === run.userMessageId), - ), - ) - : undefined; - if (newestQueuedPick) modelSelection = newestQueuedPick.modelSelection; const heldRunsToRelease = sentByUserFromClient && startsOrQueuesRun && !worktreeSetupUnresolved ? projection.runs.filter((run) => run.status === "queued" && run.queueHeld === true) diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index d446a5fca3e27..4ca516284a3b9 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -2020,188 +2020,6 @@ describe("orchestration v2 provider switching", () => { ), ); - it.live("queues an unpicked follow-up on the newest queued pick", () => - Effect.scoped( - Effect.gen(function* () { - const cwd = yield* checkpointWorkspace("queued-unpicked-follow-up"); - const capturedTurns = yield* Ref.make>([]); - const started = yield* Deferred.make(); - const threadId = ThreadId.make("thread:queued-unpicked-follow-up"); - const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([ - makeTestAdapter({ - instanceId: CODEX_MODEL_SELECTION.instanceId, - driver: CODEX_DRIVER, - capabilities: CodexProviderCapabilitiesV2, - modelSelection: CODEX_MODEL_SELECTION, - responseByRunOrdinal: {}, - capturedTurns, - holdFirstTurn: started, - }), - makeTestAdapter({ - instanceId: CLAUDE_MODEL_SELECTION.instanceId, - driver: CLAUDE_DRIVER, - capabilities: ClaudeProviderCapabilitiesV2, - modelSelection: CLAUDE_MODEL_SELECTION, - responseByRunOrdinal: {}, - capturedTurns, - }), - ]); - yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:queued-unpicked-follow-up:create"), - threadId, - projectId: ProjectId.make("project:queued-unpicked-follow-up"), - title: "Queued unpicked follow-up", - modelSelection: CODEX_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:queued-unpicked-follow-up:first"), - threadId, - messageId: MessageId.make("message:queued-unpicked-follow-up:first"), - text: "Current Codex turn", - attachments: [], - modelSelection: CODEX_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }); - yield* Deferred.await(started); - yield* worker.drain(); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "mobile", - commandId: CommandId.make("command:queued-unpicked-follow-up:pick"), - threadId, - messageId: MessageId.make("message:queued-unpicked-follow-up:pick"), - text: "Picked Claude", - attachments: [], - modelSelection: CLAUDE_MODEL_SELECTION, - dispatchMode: { type: "queue_after_active" }, - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "mobile", - commandId: CommandId.make("command:queued-unpicked-follow-up:follow-up"), - threadId, - messageId: MessageId.make("message:queued-unpicked-follow-up:follow-up"), - text: "Follow-up without a pick", - attachments: [], - dispatchMode: { type: "queue_after_active" }, - }); - const projection = yield* orchestrator.getThreadProjection(threadId); - assert.deepEqual( - projection.runs.map((run) => [run.status, run.modelSelection.instanceId]), - [ - ["running", CODEX_MODEL_SELECTION.instanceId], - ["queued", CLAUDE_MODEL_SELECTION.instanceId], - ["queued", CLAUDE_MODEL_SELECTION.instanceId], - ], - ); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "agent", - creationSource: "server", - commandId: CommandId.make("command:queued-unpicked-follow-up:monitor"), - threadId, - messageId: MessageId.make("message:queued-unpicked-follow-up:monitor"), - text: "Monitor update", - attachments: [], - dispatchMode: { type: "queue_after_active" }, - notification: { source: { kind: "monitor" }, outcome: "updated", summary: "Update" }, - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "mobile", - commandId: CommandId.make("command:queued-unpicked-follow-up:after-monitor"), - threadId, - messageId: MessageId.make("message:queued-unpicked-follow-up:after-monitor"), - text: "Follow-up after a monitor update", - attachments: [], - dispatchMode: { type: "queue_after_active" }, - }); - const afterMonitor = yield* orchestrator.getThreadProjection(threadId); - assert.equal( - afterMonitor.runs.find( - (run) => run.userMessageId === "message:queued-unpicked-follow-up:after-monitor", - )?.modelSelection.instanceId, - CLAUDE_MODEL_SELECTION.instanceId, - ); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "server", - commandId: CommandId.make("command:queued-unpicked-follow-up:server"), - threadId, - messageId: MessageId.make("message:queued-unpicked-follow-up:server"), - text: "Server-created answer", - attachments: [], - dispatchMode: { type: "queue_after_active" }, - }); - const withServerMessage = yield* orchestrator.getThreadProjection(threadId); - assert.equal( - withServerMessage.runs.find( - (run) => run.userMessageId === "message:queued-unpicked-follow-up:server", - )?.modelSelection.instanceId, - CODEX_MODEL_SELECTION.instanceId, - ); - yield* orchestrator.dispatch({ - type: "thread.stop", - commandId: CommandId.make("command:queued-unpicked-follow-up:stop"), - threadId, - }); - yield* worker.drain(); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "mobile", - commandId: CommandId.make("command:queued-unpicked-follow-up:continue"), - threadId, - messageId: MessageId.make("message:queued-unpicked-follow-up:continue"), - text: "Continue without a pick", - attachments: [], - dispatchMode: { type: "start_immediately" }, - }); - const continued = yield* orchestrator.getThreadProjection(threadId); - const continuation = continued.runs.find( - (run) => run.userMessageId === "message:queued-unpicked-follow-up:continue", - ); - assert.equal(continuation?.modelSelection.instanceId, CODEX_MODEL_SELECTION.instanceId); - }).pipe( - Effect.provide( - ProviderReplayHarness.layerWithRegistry( - { - name: "queued-unpicked-follow-up", - runtimePolicyOverride: { - cwd, - approvalPolicy: "never", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, - }, - }, - }, - layerRegistry, - ), - ), - ); - }), - ), - ); - it.live("hands completed Grok steering context to earlier queued Codex and later Claude", () => Effect.scoped( Effect.gen(function* () { From bff132f7ec23746c9331c1faff4201bd74d0c60e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 21:39:58 +0000 Subject: [PATCH 5/6] fix(mobile): keep follow-up and legacy outbox models as main sends them Older builds stored explicit picks and cached thread models the same way, so dropping the model from a legacy follow-up could discard a real pick. The composer also needs to send the thread's model when the draft has no pick: a same-instance pick leaves the draft once the thread accepts it, and an unpicked queued message would run on the model queued ahead of it. Delivery still sends no model for messages that name none and releases a delivered pick from the draft. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/state/thread-outbox-model.ts | 11 ++------- apps/mobile/src/state/thread-outbox.test.ts | 24 +------------------ .../src/state/thread-task-permissions.test.ts | 11 +++++---- .../src/state/use-thread-composer-state.ts | 4 +--- 4 files changed, 10 insertions(+), 40 deletions(-) diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 6b492f6ff67ef..64fe62d8a15f7 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -57,9 +57,6 @@ export const QueuedThreadMessageSchema = Schema.Struct({ context: Schema.optional(OrchestrationMessageContext), attachments: Schema.Array(DraftComposerAttachmentSchema), modelSelection: Schema.optional(ModelSelection), - // Written with every stored model since follow-ups carry only the user's pick. Entries without it - // come from builds that stored this client's cached thread model on every follow-up. - modelPicked: Schema.optional(Schema.Literal(true)), dispatchMode: Schema.optional(Schema.Literals(["auto", "queue", "steer", "restart"])), compactBeforeSend: Schema.optional(Schema.Boolean), freshContext: Schema.optional(Schema.Boolean), @@ -158,16 +155,12 @@ export function encodeQueuedThreadMessage(message: QueuedThreadMessage): unknown return encodeStoredQueuedThreadMessage({ schemaVersion: THREAD_OUTBOX_SCHEMA_VERSION, ...message, - ...(message.modelSelection === undefined ? {} : { modelPicked: true as const }), }); } export function decodeQueuedThreadMessage(value: unknown): QueuedThreadMessage { - const { schemaVersion: _, modelPicked, ...message } = decodeStoredQueuedThreadMessage(value); - if (message.creation !== undefined || modelPicked === true) return message; - // Possibly a stale cached model rather than a pick: let the server use the thread's own. - const { modelSelection: _unconfirmed, ...followUp } = message; - return followUp; + const { schemaVersion: _, ...message } = decodeStoredQueuedThreadMessage(value); + return message; } export function groupQueuedThreadMessages( diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index 39d2404d1bebc..95333fc5925d2 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -112,10 +112,9 @@ function queuedMessage(input: { } describe("thread outbox", () => { - it("retains structured context and a model pick through a persisted offline queue round trip", () => { + it("retains structured context through a persisted offline queue round trip", () => { const message: QueuedThreadMessage = { ...queuedMessage({ messageId: "context-message", createdAt: "2026-09-06T12:00:00.000Z" }), - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, text: "[Build](t3-context://v1/terminal/build-output)", context: { version: 1, @@ -138,27 +137,6 @@ describe("thread outbox", () => { decodeQueuedThreadMessage(JSON.parse(JSON.stringify(encodeQueuedThreadMessage(message)))), ).toEqual(message); }); - - it("drops the model an older build stored on a follow-up, so the server keeps the thread's own", () => { - const stale = { instanceId: "claudeAgent", model: "claude-opus-5-5" }; - const base = queuedMessage({ messageId: "older-entry", createdAt: "2026-10-23T23:00:00.000Z" }); - const stored = { schemaVersion: 3, ...base, modelSelection: stale }; - - expect(decodeQueuedThreadMessage(stored)).toEqual(base); - // A new thread needs its model, so an older creation entry keeps it. - const creation = { - projectId: ProjectId.make("project-1"), - workspaceMode: "local" as const, - branch: null, - worktreePath: null, - }; - expect(decodeQueuedThreadMessage({ ...stored, creation })).toEqual({ - ...base, - modelSelection: stale, - creation, - }); - }); - it.each(["read", "json", "schema"] as const)( "recovers usable messages without permitting cleanup after a record %s failure", async (failure) => { diff --git a/apps/mobile/src/state/thread-task-permissions.test.ts b/apps/mobile/src/state/thread-task-permissions.test.ts index 3eead9619cfcc..f243a0b21309e 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -507,16 +507,17 @@ describe("mobile task permissions", () => { }); describe("queued follow-up model", () => { - it("queues a follow-up with no model when the user picked none", async () => { + it("queues an unpicked follow-up on the thread's current model", async () => { state.grantedEnvironments.add("secondary"); const composer = useThreadComposerState(NO_SIDE_CHATS); expect(await composer.onSendMessage()).toBe("message"); - // The outbox can deliver after the thread moved elsewhere; only the server knows its model then. - expect(state.enqueue).toHaveBeenCalledOnce(); - const [queued] = state.enqueue.mock.calls[0] as unknown as [object]; - expect(queued).not.toHaveProperty("modelSelection"); + // A same-instance pick leaves the draft once the thread accepts it; without a model the + // server would run this message on the model queued ahead of it instead. + expect(state.enqueue).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ modelSelection: { instanceId: "codex", model: "gpt-5.4" } }), + ); }); }); diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index e7ed00d8c851f..11b38251cec77 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -803,9 +803,7 @@ export function useThreadComposerState(input: { text, attachments, context: draft.context, - // Only a pick travels with the message. The thread's model is read by the server when the - // outbox delivers, because this client's copy of it can be stale by then. - ...(draft.modelSelection === undefined ? {} : { modelSelection: draft.modelSelection }), + modelSelection, runtimeMode: draft.runtimeMode ?? thread.runtimeMode, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, ...(compactBeforeSend From 77eac40e88ae8298b716ce5789663c499eaf829a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 23:51:30 +0000 Subject: [PATCH 6/6] fix(mobile): keep a model pick in the draft until the thread runs on it Delivery released the draft pick as soon as the server accepted the queued message, so a second follow-up sent before that run started went out on the old thread model. Leave the pick to the existing reconciliation, which keeps it until the authoritative thread matches. Co-Authored-By: Claude Opus 5.5 --- .../threadOutboxDelivery.permissions.test.ts | 6 ------ apps/mobile/src/state/threadOutboxDelivery.ts | 7 ------- .../src/state/use-composer-drafts.test.ts | 18 ------------------ apps/mobile/src/state/use-composer-drafts.ts | 15 --------------- 4 files changed, 46 deletions(-) diff --git a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts index 825b093da1783..d90b8ad66d444 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts @@ -154,7 +154,6 @@ vi.mock("./use-composer-drafts", async () => { replaceComposerDraftAttachments: vi.fn(), setFreshSessionPending: vi.fn(), undoComposerDraftMerge: vi.fn(), - releaseDeliveredComposerModelPick: vi.fn(async () => {}), updateComposerDraftSettings: vi.fn(), waitForComposerDraftsLoaded: vi.fn(), }; @@ -164,7 +163,6 @@ import { appAtomRegistry } from "./atom-registry"; import { environmentSession } from "./session"; import { dispatchingQueuedMessageIdAtom, editingQueuedMessageIdsAtom } from "./use-thread-outbox"; import { createThreadOutboxDelivery } from "./threadOutboxDelivery"; -import { releaseDeliveredComposerModelPick } from "./use-composer-drafts"; const message = (overrides: Partial = {}): QueuedThreadMessage => ({ environmentId: EnvironmentId.make("secondary"), @@ -360,10 +358,6 @@ describe("queued follow-up model", () => { expect(state.start).toHaveBeenCalledOnce(); expect(state.start.mock.calls[0]?.[0].input.modelSelection).toEqual(picked); - expect(releaseDeliveredComposerModelPick).toHaveBeenCalledExactlyOnceWith( - "secondary:thread", - picked, - ); }); }); diff --git a/apps/mobile/src/state/threadOutboxDelivery.ts b/apps/mobile/src/state/threadOutboxDelivery.ts index b9b0d4431930a..06b22cc0d3766 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.ts @@ -83,7 +83,6 @@ import { undoComposerDraftMerge, updateComposerDraftSettings, waitForComposerDraftsLoaded, - releaseDeliveredComposerModelPick, } from "./use-composer-drafts"; import { dispatchingQueuedMessageIdAtom, @@ -974,12 +973,6 @@ export function createThreadOutboxDelivery(): { return restoreQueuedMessage(persistedMessage, failure.message); } acknowledgedExistingThreadMessageIds.add(persistedMessage.messageId); - if (queuedMessage.modelSelection) { - await releaseDeliveredComposerModelPick( - scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId), - queuedMessage.modelSelection, - ); - } const delivered = (await completeQueuedMessageDelivery( persistedMessage, diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index f506d57f28fb8..f7fe9ebbcf57c 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -181,7 +181,6 @@ import { archiveCloudComposerDrafts, clearComposerDraftContent, clearComposerDraftContentState, - releaseDeliveredComposerModelPick, subscribeComposerDraftModelReconciliation, clearComposerDraftsEnvironment, ComposerDraftPersistenceError, @@ -553,23 +552,6 @@ describe("mobile composer drafts", () => { expect(getComposerDraftSnapshot(key)).toEqual({ ...before, modelSelection: undefined }); }); - it("releases a pick once a message carrying it is delivered", async () => { - const key = "environment-1:thread-1"; - const pick = { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-5-5" }; - setComposerDraftText(key, "next follow-up"); - updateComposerDraftSettings(key, { modelSelection: pick, interactionMode: "plan" }); - const before = getComposerDraftSnapshot(key); - - await releaseDeliveredComposerModelPick(key, { ...pick, model: "claude-sonnet-5-5" }); - expect(getComposerDraftSnapshot(key)).toEqual(before); - - await releaseDeliveredComposerModelPick(key, { - ...pick, - options: [{ id: "fastMode", value: false }], - }); - expect(getComposerDraftSnapshot(key)).toEqual({ ...before, modelSelection: undefined }); - }); - it("retains an unavailable provider pick through differing durable model updates", () => { const environmentId = EnvironmentId.make("environment-1"); const threadId = ThreadId.make("thread-1"); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 3c9ddb587fb02..2e4e53ef6dd45 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1542,21 +1542,6 @@ export function updateComposerDraftSettings( }); } -export async function releaseDeliveredComposerModelPick( - draftKey: string, - delivered: ModelSelection, -): Promise { - await waitForComposerDraftsLoaded(); - updateComposerDrafts((current) => { - const draft = current[draftKey]; - if (!draft?.modelSelection || !draftModelPickMatchesThread(draft.modelSelection, delivered)) { - return current; - } - const { modelSelection: _modelSelection, ...rest } = draft; - return withComposerDraft(current, draftKey, rest); - }); -} - /** Lets the thread's model show again once the server accepted this exact pick. */ export function releaseComposerDraftModelSelection( draftKey: string,