diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 9fece2315820..bf44d8af3e67 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -54,6 +54,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"])), runtimeMode: Schema.optional(RuntimeMode), interactionMode: Schema.optional(ProviderInteractionMode), @@ -131,12 +134,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 0d08c2af1073..1d8525e7d656 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 4c962cf5be65..f9b3274679e1 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -318,6 +318,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/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index ec71677013b4..494dbdc34378 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -707,7 +707,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, diff --git a/apps/mobile/src/state/use-thread-outbox-drain.permissions.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.permissions.test.ts index 3bdab5f7a622..72c50d2095bc 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.permissions.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.permissions.test.ts @@ -22,6 +22,7 @@ const state = vi.hoisted(() => ({ interaction: vi.fn(), prepare: vi.fn(), checkout: vi.fn(), + cachedModelUnavailable: false, manager: null as unknown as ReturnType< typeof import("./thread-outbox-manager").createThreadOutboxManager >, @@ -82,7 +83,10 @@ vi.mock("./threads", async () => { }); vi.mock("./use-atom-command", () => ({ useAtomCommand: (command: A) => command })); vi.mock("./git", () => ({ gitEnvironment: { preparePullRequestThread: state.checkout } })); -vi.mock("../lib/modelOptions", () => ({ isModelSelectionUnavailable: () => false })); +vi.mock("../lib/modelOptions", () => ({ + isModelSelectionUnavailable: () => state.cachedModelUnavailable, + describeUnavailableModelSelection: () => ({ title: "Unavailable", detail: "Pick another model" }), +})); vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid", randomHex: () => "abcd" })); vi.mock("../lib/attachmentUpload", () => ({ prepareTurnAttachments: state.prepare })); vi.mock("./use-remote-environment-registry", () => ({ @@ -173,6 +177,7 @@ function runDrain() { beforeEach(() => { state.grantedEnvironments = new Set(["primary"]); state.draftText = "Keep this draft separate"; + state.cachedModelUnavailable = false; appAtomRegistry.set(state.manager.queuedMessagesByThreadKeyAtom, {}); appAtomRegistry.set(dispatchingQueuedMessageIdAtom, null); for (const command of [ @@ -284,6 +289,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 () => { + state.grantedEnvironments.add("secondary"); + 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 () => { + state.grantedEnvironments.add("secondary"); + state.cachedModelUnavailable = 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 () => { + state.grantedEnvironments.add("secondary"); + 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(); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 47c76d72136a..10c97b09dc24 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -743,7 +743,13 @@ export function useThreadOutboxDrain(): void { ); 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, @@ -827,7 +833,10 @@ export function useThreadOutboxDrain(): void { 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, @@ -861,7 +870,7 @@ export function useThreadOutboxDrain(): void { ), attachments: prepared.attachments, }, - modelSelection: sendSettings.modelSelection, + ...(picked ? { modelSelection: sendSettings.modelSelection } : {}), titleSeed: deriveThreadTitleSeed({ text: queuedMessage.text, attachments: queuedMessage.attachments,