diff --git a/apps/mobile/src/state/thread-task-permissions.test.ts b/apps/mobile/src/state/thread-task-permissions.test.ts index d037ac106a025..f243a0b21309e 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -506,6 +506,21 @@ describe("mobile task permissions", () => { }); }); +describe("queued follow-up model", () => { + 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"); + + // 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" } }), + ); + }); +}); + 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 309bb6a2b0a76..d90b8ad66d444 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts @@ -327,6 +327,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(); @@ -343,7 +377,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 c64215ed26cd3..06b22cc0d3766 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.ts @@ -792,7 +792,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, @@ -877,7 +883,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, @@ -942,7 +951,7 @@ export function createThreadOutboxDelivery(): { ), attachments: prepared.attachments, }, - modelSelection: sendSettings.modelSelection, + ...(picked ? { modelSelection: sendSettings.modelSelection } : {}), titleSeed: deriveThreadTitleSeed({ text: queuedMessage.text, attachments: queuedMessage.attachments,