From bec335ead8018b6756359e8b3cb370930b6ba256 Mon Sep 17 00:00:00 2001 From: b <83741810+BelugaCrypto@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:46:56 -0600 Subject: [PATCH 1/2] fix(mobile): a queued follow-up carries a model only when the user picked one The send path stored the draft pick or, failing that, this client's copy of the thread's model in the outbox, and delivery always sent it. A message typed while the phone was offline then delivered the model the phone last saw, and the server switched the thread to it with a context handoff. Queue and deliver only an explicit pick; otherwise the server keeps the thread's own model. --- .../src/state/thread-task-permissions.test.ts | 14 +++++++++++ .../src/state/use-thread-composer-state.ts | 4 +++- ...se-thread-outbox-drain.permissions.test.ts | 23 +++++++++++++++++++ .../src/state/use-thread-outbox-drain.ts | 4 +++- 4 files changed, 43 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 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..ed17df004e30 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 @@ -284,6 +284,29 @@ 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 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..dcfd8dc3881f 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -861,7 +861,9 @@ export function useThreadOutboxDrain(): void { ), attachments: prepared.attachments, }, - modelSelection: sendSettings.modelSelection, + ...(queuedMessage.modelSelection === undefined + ? {} + : { modelSelection: sendSettings.modelSelection }), titleSeed: deriveThreadTitleSeed({ text: queuedMessage.text, attachments: queuedMessage.attachments, From 32d7785394805c64145cf6e43385840b8b8da60a Mon Sep 17 00:00:00 2001 From: b <83741810+BelugaCrypto@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:52:14 -0600 Subject: [PATCH 2/2] fix(mobile): stored follow-ups from older builds no longer carry a stale model Older builds stored this client's cached thread model on every queued follow-up. Mark stored models as picks and drop an unmarked one from a follow-up when reading it, so its delivery leaves the model to the server. Delivery also skips the client-side availability check without a pick: the server checks the thread's own model, which a just-reconnected client can misreport. --- apps/mobile/src/state/thread-outbox-model.ts | 11 +++++-- apps/mobile/src/state/thread-outbox.test.ts | 31 +++++++++++++++++++ ...se-thread-outbox-drain.permissions.test.ts | 18 ++++++++++- .../src/state/use-thread-outbox-drain.ts | 17 +++++++--- 4 files changed, 69 insertions(+), 8 deletions(-) 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/use-thread-outbox-drain.permissions.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.permissions.test.ts index ed17df004e30..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 [ @@ -295,6 +300,17 @@ describe("queued follow-up model", () => { 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" }; diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index dcfd8dc3881f..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,9 +870,7 @@ export function useThreadOutboxDrain(): void { ), attachments: prepared.attachments, }, - ...(queuedMessage.modelSelection === undefined - ? {} - : { modelSelection: sendSettings.modelSelection }), + ...(picked ? { modelSelection: sendSettings.modelSelection } : {}), titleSeed: deriveThreadTitleSeed({ text: queuedMessage.text, attachments: queuedMessage.attachments,