From 1a80d62354ca69ee694ac25b302898dc82d9d8bc Mon Sep 17 00:00:00 2001 From: b <83741810+BelugaCrypto@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:23:12 -0600 Subject: [PATCH 1/6] fix(web): a chat whose account is off or missing no longer moves to another account on send The composer falls back to the next available instance when a started chat's own instance is disabled or missing from the catalog. The next send then carries that fallback selection, and the server switches the chat with a context handoff nobody asked for. Block that send with a reason naming the chat's account until it is back or the user picks a model on purpose. --- .../web/src/components/ChatView.logic.test.ts | 75 ++++++++++++++++++- apps/web/src/components/ChatView.logic.ts | 23 ++++++ apps/web/src/components/chat/ChatComposer.tsx | 15 +++- 3 files changed, 108 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 426818dba7a24..e1dda91cb3587 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -10,7 +10,7 @@ import { } from "./ChatView.logic"; import { recallCheckoutIsRepo, rememberCheckoutIsRepo } from "./ChatView.logic"; import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; -import { deriveProviderInstanceEntries } from "../providerInstances"; +import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; import { ensureInlineContextReferences } from "../lib/composerContextReferences"; import { terminalContextReference } from "../lib/composerContextRecords"; import { @@ -39,6 +39,7 @@ import type { Thread } from "../types"; import { makeThreadFixture, makeThreadProjectionFixture } from "../test-fixtures"; import { getAntigravitySendBlockReason, + getUnchosenProviderSwitchBlockReason, resolveComposerProviderSelection, resolveWorktreeSetupProgress, peekRememberedThreadTimeline, @@ -649,6 +650,78 @@ describe("resolveComposerProviderSelection", () => { expect(selection.unavailableProviderInstanceId).toBe(missingInstanceId); }); + describe("a started chat whose own account is turned off or missing", () => { + const work = entry("claudeAgent", "claude_work", { displayName: "Claude - work" }); + const other = entry("claudeAgent", "claude_other", { displayName: "Claude - other" }); + + function attempt(input: { + entries: ReadonlyArray; + threadStarted?: boolean; + chosenInstanceId?: ProviderInstanceId | null; + }) { + const threadInstanceId = work.instanceId; + const chosenInstanceId = input.chosenInstanceId ?? null; + // The composer passes no driver lock when the server can hand context between providers. + const selection = resolveComposerProviderSelection({ + entries: input.entries, + candidateInstanceIds: [chosenInstanceId, threadInstanceId, other.instanceId], + lockedProvider: null, + lockedInstanceId: threadInstanceId, + }); + return { + selectedInstanceId: selection.selectedProviderEntry?.instanceId, + blockReason: getUnchosenProviderSwitchBlockReason({ + threadStarted: input.threadStarted ?? true, + threadInstanceId, + chosenInstanceId, + selectedInstanceId: selection.selectedProviderEntry?.instanceId, + entries: input.entries, + }), + }; + } + + it("blocks the send instead of moving the chat to another account when its own is turned off", () => { + const result = attempt({ entries: [{ ...work, enabled: false }, other] }); + + expect(result.selectedInstanceId).toBe(other.instanceId); + expect(result.blockReason).toBe( + "Claude - work is turned off, so this chat can't continue on it. Turn it back on in provider settings, or pick a model to move this chat.", + ); + }); + + it("blocks the send when the chat's account is no longer set up", () => { + const result = attempt({ entries: [other] }); + + expect(result.selectedInstanceId).toBe(other.instanceId); + expect(result.blockReason).toBe( + "This chat's account (claude_work) is no longer set up. Add it back in provider settings, or pick a model to move this chat.", + ); + }); + + it("sends on another account the owner picked on purpose", () => { + const result = attempt({ + entries: [{ ...work, enabled: false }, other], + chosenInstanceId: other.instanceId, + }); + + expect(result.selectedInstanceId).toBe(other.instanceId); + expect(result.blockReason).toBeNull(); + }); + + it("keeps sending on the chat's own account while it is on", () => { + const result = attempt({ entries: [work, other] }); + + expect(result.selectedInstanceId).toBe(work.instanceId); + expect(result.blockReason).toBeNull(); + }); + + it("lets a chat that has not started yet use whichever account is available", () => { + const result = attempt({ entries: [other], threadStarted: false }); + + expect(result.blockReason).toBeNull(); + }); + }); + it("keeps the session's continuation group when another instance was selected", () => { const sessionEntry = entry("antigravity", "google_work", { enabled: false, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 0ce51ee5e5534..9ab8669c04536 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -633,6 +633,29 @@ export function resolveComposerInteractionMode(input: { }; } +// The composer falls back to another account when a started chat's own one is turned off or +// missing. Sending that fallback would hand the chat to an account nobody picked. +export function getUnchosenProviderSwitchBlockReason(input: { + threadStarted: boolean; + threadInstanceId: ProviderInstanceId | null | undefined; + chosenInstanceId: ProviderInstanceId | null | undefined; + selectedInstanceId: ProviderInstanceId | null | undefined; + entries: ReadonlyArray>; +}): string | null { + const { threadInstanceId, selectedInstanceId } = input; + if (!input.threadStarted || threadInstanceId == null || selectedInstanceId == null) return null; + if (selectedInstanceId === threadInstanceId || selectedInstanceId === input.chosenInstanceId) { + return null; + } + const own = input.entries.find((entry) => entry.instanceId === threadInstanceId); + if (own === undefined) { + return `This chat's account (${threadInstanceId}) is no longer set up. Add it back in provider settings, or pick a model to move this chat.`; + } + return own.enabled + ? `${own.displayName} is unavailable, so this chat can't continue on it. Check it in provider settings, or pick a model to move this chat.` + : `${own.displayName} is turned off, so this chat can't continue on it. Turn it back on in provider settings, or pick a model to move this chat.`; +} + export function getAntigravitySendBlockReason( provider: | Pick diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index eaf958fa2f5cf..61cb398a7f814 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -76,8 +76,10 @@ import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; import { deriveComposerSendState, getAntigravitySendBlockReason, + getUnchosenProviderSwitchBlockReason, readFileAsDataUrl, resolveComposerInteractionMode, + threadHasStarted, threadShellHasStarted, } from "../ChatView.logic"; import { @@ -987,10 +989,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? runtimeMode : (compatibleRuntimeModeOptions[0]?.mode ?? runtimeMode); - const providerSendBlockReason = getAntigravitySendBlockReason( - selectedProviderEntry?.snapshot, - selectedModel, - ); + const providerSendBlockReason = + getUnchosenProviderSwitchBlockReason({ + threadStarted: threadHasStarted(activeThread), + threadInstanceId: + activeThread?.runtime?.providerInstanceId ?? activeThreadModelSelection?.instanceId, + chosenInstanceId: composerDraft.activeProvider, + selectedInstanceId: selectedProviderEntry?.instanceId, + entries: providerInstanceEntries, + }) ?? getAntigravitySendBlockReason(selectedProviderEntry?.snapshot, selectedModel); const sendDisabledReason = externalSendDisabledReason ?? (multipleModelSelections?.length === 0 ? "Select at least one model." : null) ?? From 898ddf70c3f291e9788f3bc2c3cdd020175cfed1 Mon Sep 17 00:00:00 2001 From: b <83741810+BelugaCrypto@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:30:28 -0600 Subject: [PATCH 2/6] fix(web): only a model-picker pick lifts the account-move block Effort and option edits store the shown instance as the draft's activeProvider, so changing effort on the fallback account counted as choosing it and the send moved the chat anyway. Track a model-picker pick separately (in memory, cleared with the pick) and count only that. --- apps/web/src/components/chat/ChatComposer.tsx | 3 +- apps/web/src/composerDraftStore.test.ts | 88 +++++++++++++++++++ apps/web/src/composerDraftStore.ts | 46 ++++++++-- 3 files changed, 128 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 61cb398a7f814..9b122de050989 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -98,6 +98,7 @@ import { composerFileNeedsReattach, composerDraftHasUserContent, composerTargetKey, + pickedDraftProviderInstanceId, useComposerDraftStore, useComposerThreadDraft, } from "../../composerDraftStore"; @@ -994,7 +995,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) threadStarted: threadHasStarted(activeThread), threadInstanceId: activeThread?.runtime?.providerInstanceId ?? activeThreadModelSelection?.instanceId, - chosenInstanceId: composerDraft.activeProvider, + chosenInstanceId: pickedDraftProviderInstanceId(composerDraft), selectedInstanceId: selectedProviderEntry?.instanceId, entries: providerInstanceEntries, }) ?? getAntigravitySendBlockReason(selectedProviderEntry?.snapshot, selectedModel); diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 44f1d5a92a911..6af1ca09f8976 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -85,9 +85,11 @@ import { type ComposerImageAttachment, composerFileNeedsReattach, partializeComposerDraftStoreState, + pickedDraftProviderInstanceId, useComposerDraftStore, DraftId, } from "./composerDraftStore"; +import { getUnchosenProviderSwitchBlockReason } from "./components/ChatView.logic"; import { insertInlineContextReference } from "./lib/composerContextReferences"; import { terminalContextReference, threadContextRecord } from "./lib/composerContextRecords"; import { @@ -1320,6 +1322,7 @@ describe("composerDraftStore modelSelection", () => { activeProvider: null, modelSelectionByProvider: {}, modelSelectionExplicit: undefined, + providerPicked: undefined, }); expect(draftFor(threadId, OTHER_TEST_ENVIRONMENT_ID)).toEqual(remoteBefore); @@ -1535,6 +1538,91 @@ describe("composerDraftStore modelSelection", () => { }), ); }); + + it("keeps blocking a silent account move after an effort change on the fallback account", () => { + const store = useComposerDraftStore.getState(); + // The chat runs on the secondary account, which is turned off; the composer shows the primary. + const entries = [ + { instanceId: CODEX_SECONDARY_INSTANCE, displayName: "Codex - work", enabled: false }, + { instanceId: CODEX_INSTANCE, displayName: "Codex", enabled: true }, + ]; + const blockReason = () => + getUnchosenProviderSwitchBlockReason({ + threadStarted: true, + threadInstanceId: CODEX_SECONDARY_INSTANCE, + chosenInstanceId: pickedDraftProviderInstanceId(draftFor(threadId, TEST_ENVIRONMENT_ID)), + selectedInstanceId: CODEX_INSTANCE, + entries, + }); + + store.setProviderModelOptions( + threadRef, + CODEX_DRIVER, + toSelections({ reasoningEffort: "low" }), + { instanceId: CODEX_INSTANCE, model: "gpt-5.4" }, + ); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.activeProvider).toBe(CODEX_INSTANCE); + expect(blockReason()).toBe( + "Codex - work is turned off, so this chat can't continue on it. Turn it back on in provider settings, or pick a model to move this chat.", + ); + + store.setModelSelection(threadRef, createModelSelection(CODEX_INSTANCE, "gpt-5.4"), { + explicit: true, + }); + expect(blockReason()).toBeNull(); + }); + + it("counts a model-picker pick even when an effort change already stored that model", () => { + const store = useComposerDraftStore.getState(); + store.setProviderModelOptions( + threadRef, + CODEX_DRIVER, + toSelections({ reasoningEffort: "low" }), + { instanceId: CODEX_SECONDARY_INSTANCE, model: "gpt-5-codex" }, + ); + + store.setModelSelection( + threadRef, + createModelSelection( + CODEX_SECONDARY_INSTANCE, + "gpt-5-codex", + toSelections({ reasoningEffort: "low" }), + ), + { explicit: true, replaceOptions: true }, + ); + + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.providerPicked).toBe(true); + }); + + it("keeps a pick through effort changes on that account and drops it for another account", () => { + const store = useComposerDraftStore.getState(); + store.setModelSelection( + threadRef, + createModelSelection(CODEX_SECONDARY_INSTANCE, "gpt-5-codex"), + { + explicit: true, + replaceOptions: true, + }, + ); + + store.setProviderModelOptions( + threadRef, + CODEX_DRIVER, + toSelections({ reasoningEffort: "high" }), + { instanceId: CODEX_SECONDARY_INSTANCE, model: "gpt-5-codex" }, + ); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.providerPicked).toBe(true); + + store.setProviderModelOptions( + threadRef, + CODEX_DRIVER, + toSelections({ reasoningEffort: "high" }), + { instanceId: CODEX_INSTANCE, model: "gpt-5.4" }, + ); + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); + expect(draft?.activeProvider).toBe(CODEX_INSTANCE); + expect(draft?.providerPicked).toBeUndefined(); + }); }); describe("composerDraftStore per-model sticky options", () => { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 57ffde65949ff..a5ff2891ae43a 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -371,6 +371,9 @@ export interface ComposerThreadDraftState { modelSelectionByProvider: Partial>; activeProvider: ProviderInstanceId | null; modelSelectionExplicit?: boolean; + // Set only when the user picks `activeProvider` in the model picker; option edits also set + // `activeProvider`, and must not count as choosing to move a started thread. Never persisted. + providerPicked?: true; runtimeMode: RuntimeMode | null; interactionMode: ProviderInteractionMode | null; } @@ -2848,11 +2851,16 @@ const composerDraftStore = create()( if ( Equal.equals(base.modelSelectionByProvider, nextMap) && base.activeProvider === stickyActiveProvider && - base.modelSelectionExplicit === undefined + base.modelSelectionExplicit === undefined && + base.providerPicked === undefined ) { return state; } - const { modelSelectionExplicit: _modelSelectionExplicit, ...retained } = base; + const { + modelSelectionExplicit: _modelSelectionExplicit, + providerPicked: _providerPicked, + ...retained + } = base; const nextDraft: ComposerThreadDraftState = { ...retained, modelSelectionByProvider: nextMap, @@ -2951,16 +2959,23 @@ const composerDraftStore = create()( if ( Equal.equals(base.modelSelectionByProvider, nextMap) && base.activeProvider === nextActiveProvider && - (base.modelSelectionExplicit ?? false) === (opts?.explicit === true) + (base.modelSelectionExplicit ?? false) === (opts?.explicit === true) && + (base.providerPicked ?? false) === (opts?.explicit === true) ) { return state; } - const { modelSelectionExplicit: _previousExplicit, ...restBase } = base; + const { + modelSelectionExplicit: _previousExplicit, + providerPicked: _previousPicked, + ...restBase + } = base; const nextDraft: ComposerThreadDraftState = { ...restBase, modelSelectionByProvider: nextMap, activeProvider: nextActiveProvider, - ...(opts?.explicit === true ? { modelSelectionExplicit: true as const } : {}), + ...(opts?.explicit === true + ? { modelSelectionExplicit: true as const, providerPicked: true as const } + : {}), }; const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; if (shouldRemoveDraft(nextDraft)) { @@ -3100,12 +3115,16 @@ const composerDraftStore = create()( return state; } - const { modelSelectionExplicit: _previousExplicit, ...restBase } = base; + const { modelSelectionExplicit: _previousExplicit, providerPicked, ...restBase } = base; + const nextActiveProvider = options?.instanceId ? instanceKey : base.activeProvider; const nextDraft: ComposerThreadDraftState = { ...restBase, - ...(options?.instanceId ? { activeProvider: instanceKey } : {}), + activeProvider: nextActiveProvider, modelSelectionByProvider: nextMap, modelSelectionExplicit: true, + ...(providerPicked && nextActiveProvider === base.activeProvider + ? { providerPicked } + : {}), }; const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; if (shouldRemoveDraft(nextDraft)) { @@ -4202,7 +4221,11 @@ export function subscribeComposerDraftModelReconciliation(input: { const drafts = { ...state.draftsByThreadKey }; for (const [key, matchedDraft] of matches) { if (drafts[key] !== matchedDraft) continue; - const { modelSelectionExplicit: _explicit, ...retained } = matchedDraft; + const { + modelSelectionExplicit: _explicit, + providerPicked: _picked, + ...retained + } = matchedDraft; const draft = { ...retained, activeProvider: null, modelSelectionByProvider: {} }; if (shouldRemoveDraft(draft)) delete drafts[key]; else drafts[key] = draft; @@ -4321,6 +4344,13 @@ export function useThreadHasUnsentDraft(threadRef: ScopedThreadRef): boolean { ); } +// The account the user picked in the model picker, or null when nothing was picked there. +export function pickedDraftProviderInstanceId( + draft: Pick | null | undefined, +): ProviderInstanceId | null { + return draft?.providerPicked ? draft.activeProvider : null; +} + export function useComposerDraftModelState( threadRef: ComposerThreadTarget, ): ComposerDraftModelState { From 16996e205627be23e512df65273df0cee5d78888 Mon Sep 17 00:00:00 2001 From: b <83741810+BelugaCrypto@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:32:32 -0600 Subject: [PATCH 3/6] fix(web): name the right next step when the chat's account is back on An option edit made while the chat's account was off leaves the composer pointing at the fallback. Once the account is back, say to pick it rather than calling it unavailable. --- apps/web/src/components/ChatView.logic.test.ts | 14 ++++++++++++++ apps/web/src/components/ChatView.logic.ts | 14 ++++++++++---- apps/web/src/composerDraftStore.test.ts | 9 +++++++-- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index e1dda91cb3587..d77ce646d83e2 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -715,6 +715,20 @@ describe("resolveComposerProviderSelection", () => { expect(result.blockReason).toBeNull(); }); + it("asks for a pick when the chat's account is back on but the composer still points elsewhere", () => { + const reason = getUnchosenProviderSwitchBlockReason({ + threadStarted: true, + threadInstanceId: work.instanceId, + chosenInstanceId: null, + selectedInstanceId: other.instanceId, + entries: [work, other], + }); + + expect(reason).toBe( + "Pick Claude - work in the model menu to keep this chat on it, or pick another model to move this chat.", + ); + }); + it("lets a chat that has not started yet use whichever account is available", () => { const result = attempt({ entries: [other], threadStarted: false }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 9ab8669c04536..f1f57e97f0c3f 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -640,7 +640,9 @@ export function getUnchosenProviderSwitchBlockReason(input: { threadInstanceId: ProviderInstanceId | null | undefined; chosenInstanceId: ProviderInstanceId | null | undefined; selectedInstanceId: ProviderInstanceId | null | undefined; - entries: ReadonlyArray>; + entries: ReadonlyArray< + Pick + >; }): string | null { const { threadInstanceId, selectedInstanceId } = input; if (!input.threadStarted || threadInstanceId == null || selectedInstanceId == null) return null; @@ -651,9 +653,13 @@ export function getUnchosenProviderSwitchBlockReason(input: { if (own === undefined) { return `This chat's account (${threadInstanceId}) is no longer set up. Add it back in provider settings, or pick a model to move this chat.`; } - return own.enabled - ? `${own.displayName} is unavailable, so this chat can't continue on it. Check it in provider settings, or pick a model to move this chat.` - : `${own.displayName} is turned off, so this chat can't continue on it. Turn it back on in provider settings, or pick a model to move this chat.`; + if (!own.enabled) { + return `${own.displayName} is turned off, so this chat can't continue on it. Turn it back on in provider settings, or pick a model to move this chat.`; + } + // An option edit made while the chat's account was off still points the composer elsewhere. + return own.isAvailable + ? `Pick ${own.displayName} in the model menu to keep this chat on it, or pick another model to move this chat.` + : `${own.displayName} is unavailable, so this chat can't continue on it. Check it in provider settings, or pick a model to move this chat.`; } export function getAntigravitySendBlockReason( diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 6af1ca09f8976..cec58fc3d6b64 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1543,8 +1543,13 @@ describe("composerDraftStore modelSelection", () => { const store = useComposerDraftStore.getState(); // The chat runs on the secondary account, which is turned off; the composer shows the primary. const entries = [ - { instanceId: CODEX_SECONDARY_INSTANCE, displayName: "Codex - work", enabled: false }, - { instanceId: CODEX_INSTANCE, displayName: "Codex", enabled: true }, + { + instanceId: CODEX_SECONDARY_INSTANCE, + displayName: "Codex - work", + enabled: false, + isAvailable: true, + }, + { instanceId: CODEX_INSTANCE, displayName: "Codex", enabled: true, isAvailable: true }, ]; const blockReason = () => getUnchosenProviderSwitchBlockReason({ From 041a3340c7086a4b757753331a4230ddcc054080 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 4/6] 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 4c962cf5be650..f9b3274679e1e 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 ec71677013b44..494dbdc343789 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 3bdab5f7a6223..ed17df004e308 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 a979632029461..d2df2be47e01c 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -860,7 +860,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 388703524183fffed9686fda9cc88fa5c23cf86f 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 5/6] 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 9fece2315820f..bf44d8af3e673 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 0d08c2af1073b..1d8525e7d656e 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 ed17df004e308..72c50d2095bc9 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 d2df2be47e01c..9a3d2c1934bad 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -742,7 +742,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, @@ -826,7 +832,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, @@ -860,9 +869,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, From 0feb924e087d7e9e70176f09e925faf82c3fcdbb Mon Sep 17 00:00:00 2001 From: b <83741810+BelugaCrypto@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:00:28 -0600 Subject: [PATCH 6/6] fix(mobile): a delivered model pick is released once the server applied it A pick stayed in the draft until this client saw the thread on that exact model. A phone that went to sleep right after sending, while another client moved the thread, never saw that state, so its next ordinary message carried the old pick and moved the thread back. The thread shell now says whether any run still waits to start, held runs included. The phone records the event sequence the server returns for a picked message, and releases the pick once the shell has passed that sequence and no run waits: the picked run started (the server applied the pick, then maybe a later choice) or was removed. A retried command returns its original receipt, so the sequence still names that dispatch. A fresh pick drops the record, and a server that does not report queued runs keeps the old behaviour. --- .../src/state/use-composer-drafts.test.ts | 133 ++++++++++++++++++ apps/mobile/src/state/use-composer-drafts.ts | 92 +++++++++++- ...se-thread-outbox-drain.permissions.test.ts | 6 + .../src/state/use-thread-outbox-drain.ts | 14 ++ .../src/orchestration-v2/ProjectionStore.ts | 9 ++ .../src/orchestration-v2/ThreadStop.test.ts | 44 ++++++ packages/contracts/src/orchestrationV2.ts | 2 + 7 files changed, 294 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 94dc617a50d49..789ba98d2d4c3 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -10,6 +10,7 @@ import { v2ShellSnapshot, v2ThreadShell, } from "../../../../packages/client-runtime/src/state/orchestrationV2TestFixtures"; +import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import { afterEach, describe, expect, it } from "@effect/vitest"; @@ -20,6 +21,7 @@ import { MessageId, ProjectId, ProviderInstanceId, + RunId, ThreadId, type OrchestrationV2ThreadShell, type OrchestrationV2ShellSnapshot, @@ -206,6 +208,7 @@ import { setComposerDraftText, insertComposerDraftContext, insertComposerDraftText, + markComposerDraftModelSelectionDelivered, rememberComposerDraftSelection, setComposerDraftAttachmentUpload, waitForComposerDraftsLoaded, @@ -348,6 +351,7 @@ function modelReconciliationHarness( expect(registry.get(selectedShell)?.id).toBe(selectedThreadId); }, model: () => registry.get(shells.threadShellAtom({ environmentId, threadId }))!.modelSelection, + sequence: () => Option.getOrThrow(registry.get(shellStateAtom).snapshot).snapshotSequence, }; } @@ -496,6 +500,135 @@ describe("mobile composer drafts", () => { expect(appAtomRegistry.get(composerDraftsAtom)[key]).toBeUndefined(); }); + it("releases a delivered pick once no run waits to start, even if the thread already moved elsewhere", () => { + const environmentId = EnvironmentId.make("environment-1"); + const threadId = ThreadId.make("thread-1"); + const key = `${environmentId}:${threadId}`; + const pick = { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-5-5" }; + const elsewhere = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6.1-sol" }; + const startedAt = DateTime.makeUnsafe("2026-10-23T23:40:00.000Z"); + const h = modelReconciliationHarness(environmentId, threadId); + updateComposerDraftSettings(key, { modelSelection: pick }); + // Before the shell includes the delivery, nothing it shows is about the picked run. + h.update({ latestRunId: RunId.make("run-active"), status: "running", hasQueuedRuns: false }); + markComposerDraftModelSelectionDelivered(key, pick, h.sequence() + 1); + h.flush(); + expect(getComposerDraftSnapshot(key).modelSelection).toEqual(pick); + + // Queued behind it: the server switches the thread only when the picked run starts. + h.update({ latestRunId: RunId.make("run-picked"), status: "queued", hasQueuedRuns: true }); + expect(getComposerDraftSnapshot(key).modelSelection).toEqual(pick); + + // The phone missed the thread running on the pick; another client moved it since. + h.update({ + modelSelection: elsewhere, + latestRunId: RunId.make("run-later"), + status: "running", + latestRunStartedAt: startedAt, + hasQueuedRuns: false, + }); + expect(getComposerDraftSnapshot(key).modelSelection).toBeUndefined(); + }); + + it("releases a pick whose retried delivery returns a receipt the shell already passed", () => { + const environmentId = EnvironmentId.make("environment-1"); + const threadId = ThreadId.make("thread-1"); + const key = `${environmentId}:${threadId}`; + const pick = { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-5-5" }; + const elsewhere = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6.1-sol" }; + const h = modelReconciliationHarness(environmentId, threadId); + updateComposerDraftSettings(key, { modelSelection: pick }); + // The first delivery's reply was lost; the picked run ran and the thread moved on. + const firstDelivery = h.sequence() + 1; + h.update({ latestRunId: RunId.make("run-picked"), status: "running", hasQueuedRuns: false }); + h.update({ modelSelection: elsewhere, latestRunId: RunId.make("run-later") }); + expect(getComposerDraftSnapshot(key).modelSelection).toEqual(pick); + + markComposerDraftModelSelectionDelivered(key, pick, firstDelivery); + h.flush(); + + expect(getComposerDraftSnapshot(key).modelSelection).toBeUndefined(); + }); + + it.each([ + ["held behind a failed run", { latestRunId: "run-failed", status: "failed" }], + [ + "held behind a run being stopped", + { latestRunId: "run-stopping", latestRunStopRequested: true }, + ], + ["passed by a later run started ahead of it", { latestRunId: "run-reordered" }], + ] as const)("keeps a delivered pick while its run still waits, %s", (_case, latest) => { + const environmentId = EnvironmentId.make("environment-1"); + const threadId = ThreadId.make("thread-1"); + const key = `${environmentId}:${threadId}`; + const pick = { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-5-5" }; + const h = modelReconciliationHarness(environmentId, threadId); + updateComposerDraftSettings(key, { modelSelection: pick }); + markComposerDraftModelSelectionDelivered(key, pick, h.sequence() + 1); + + h.update({ + status: "running", + latestRunStartedAt: DateTime.makeUnsafe("2026-10-23T23:40:00.000Z"), + ...latest, + latestRunId: RunId.make(latest.latestRunId), + hasQueuedRuns: true, + }); + + expect(getComposerDraftSnapshot(key).modelSelection).toEqual(pick); + }); + + it("keeps a delivered pick when the server does not report queued runs", () => { + const environmentId = EnvironmentId.make("environment-1"); + const threadId = ThreadId.make("thread-1"); + const key = `${environmentId}:${threadId}`; + const pick = { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-5-5" }; + const h = modelReconciliationHarness(environmentId, threadId); + updateComposerDraftSettings(key, { modelSelection: pick }); + markComposerDraftModelSelectionDelivered(key, pick, h.sequence() + 1); + + h.update({ latestRunId: RunId.make("run-later"), status: "running" }); + + expect(getComposerDraftSnapshot(key).modelSelection).toEqual(pick); + }); + + it("keeps an undelivered pick while the thread runs on another model", () => { + const environmentId = EnvironmentId.make("environment-1"); + const threadId = ThreadId.make("thread-1"); + const key = `${environmentId}:${threadId}`; + const pick = { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-5-5" }; + const h = modelReconciliationHarness(environmentId, threadId); + updateComposerDraftSettings(key, { modelSelection: pick }); + h.flush(); + + h.update({ + latestRunId: RunId.make("run-later"), + status: "running", + latestRunStartedAt: DateTime.makeUnsafe("2026-10-23T23:40:00.000Z"), + hasQueuedRuns: false, + }); + expect(getComposerDraftSnapshot(key).modelSelection).toEqual(pick); + }); + + it("does not let an earlier delivery release a fresh pick of the same model", () => { + const environmentId = EnvironmentId.make("environment-1"); + const threadId = ThreadId.make("thread-1"); + const key = `${environmentId}:${threadId}`; + const pick = { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-5-5" }; + const h = modelReconciliationHarness(environmentId, threadId); + updateComposerDraftSettings(key, { modelSelection: pick }); + markComposerDraftModelSelectionDelivered(key, pick, h.sequence()); + updateComposerDraftSettings(key, { modelSelection: pick }); + h.flush(); + + h.update({ + latestRunId: RunId.make("run-later"), + status: "running", + latestRunStartedAt: DateTime.makeUnsafe("2026-10-23T23:40:00.000Z"), + hasQueuedRuns: false, + }); + expect(getComposerDraftSnapshot(key).modelSelection).toEqual(pick); + }); + it("releases an inactive thread pick only after its queued model becomes durable", () => { 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 2549cb276bc0f..9299094c8e55c 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -14,6 +14,7 @@ import { RuntimeMode as RuntimeModeSchema, type EnvironmentId, type ModelSelection, + type OrchestrationV2ThreadShell, type ProjectId, type ProviderInteractionMode, type ProviderOptionSelection, @@ -64,6 +65,7 @@ import { encodeQueuedThreadMessage, QueuedThreadMessageSchema, type QueuedThreadMessage, + modelSelectionsEqual, } from "./thread-outbox-model"; import { flushThreadOutbox, threadOutboxManager } from "./thread-outbox"; import { composerDraftEnvironmentId } from "../lib/composerAttachmentUploadQueue"; @@ -337,12 +339,24 @@ export interface ComposerDraft { readonly attachments: ReadonlyArray; readonly importedShareIds?: ReadonlyArray; readonly modelSelection?: ModelSelection; + /** + * Set once a message carrying `modelSelection` was accepted, with the event sequence the server + * returned for it. Once the shell has passed that sequence and its latest run has started, the + * server applied the pick (or a later choice), so the pick is released even when this client + * never saw the thread on it. + */ + readonly modelSelectionDelivery?: ComposerDraftModelSelectionDelivery; readonly runtimeMode?: RuntimeMode; readonly interactionMode?: ProviderInteractionMode; readonly workspaceSelection?: ComposerDraftWorkspaceSelection; readonly project?: ComposerDraftProject; } +export interface ComposerDraftModelSelectionDelivery { + readonly modelSelection: ModelSelection; + readonly sequence: number; +} + export interface ComposerDraftProject { readonly environmentId: EnvironmentId; readonly projectId: ProjectId; @@ -396,6 +410,12 @@ const ComposerDraftSchema = Schema.Struct({ attachments: Schema.Array(DraftComposerAttachmentSchema), importedShareIds: Schema.optional(Schema.Array(Schema.String)), modelSelection: Schema.optional(ModelSelectionSchema), + modelSelectionDelivery: Schema.optional( + Schema.Struct({ + modelSelection: ModelSelectionSchema, + sequence: Schema.Number, + }), + ), runtimeMode: Schema.optional(RuntimeModeSchema), interactionMode: Schema.optional(ProviderInteractionModeSchema), workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema), @@ -1460,14 +1480,59 @@ export function updateComposerDraftSettings( settings: Partial, ): void { updateComposerDrafts((current) => { + const { modelSelectionDelivery, ...existing } = normalizeDraft(current[draftKey]); const draft = { - ...normalizeDraft(current[draftKey]), + ...existing, + ...(settings.modelSelection === undefined && modelSelectionDelivery !== undefined + ? { modelSelectionDelivery } + : {}), ...settings, }; return withComposerDraft(current, draftKey, draft); }); } +export function markComposerDraftModelSelectionDelivered( + draftKey: string, + delivered: ModelSelection, + sequence: number, +): void { + updateComposerDrafts((current) => { + const existing = current[draftKey]; + if (!existing?.modelSelection || !modelSelectionsEqual(existing.modelSelection, delivered)) { + return current; + } + return withComposerDraft(current, draftKey, { + ...existing, + modelSelectionDelivery: { modelSelection: delivered, sequence }, + }); + }); +} + +function isCurrentDelivery(draft: ComposerDraft): boolean { + const delivery = draft.modelSelectionDelivery; + return ( + delivery !== undefined && + draft.modelSelection !== undefined && + modelSelectionsEqual(delivery.modelSelection, draft.modelSelection) + ); +} + +// Once the shell includes the delivery and no run waits to start, the picked run has started (the +// server applied the pick, then maybe a later choice) or was removed. A server that does not report +// queued runs never releases a delivered pick this way. +function deliveredPickWasApplied( + draft: ComposerDraft, + snapshotSequence: number, + thread: Pick, +): boolean { + return ( + isCurrentDelivery(draft) && + snapshotSequence >= draft.modelSelectionDelivery!.sequence && + thread.hasQueuedRuns === false + ); +} + export function subscribeComposerDraftModelReconciliation(input: { readonly registry: AtomRegistry.AtomRegistry; readonly threadRefsAtom: Atom.Atom>; @@ -1492,19 +1557,26 @@ export function subscribeComposerDraftModelReconciliation(input: { const ref = parseScopedThreadKey(key); if (!ref || !threadKeys.has(key)) continue; const thread = get(input.threadShellAtom(ref)); - if (!thread || !draftModelPickMatchesThread(draft.modelSelection, thread.modelSelection)) + if ( + !thread || + (!draftModelPickMatchesThread(draft.modelSelection, thread.modelSelection) && + !isCurrentDelivery(draft)) + ) continue; // Status and selection must come from the same emission; derived atoms can update apart. const shell = Option.getOrNull( AsyncResult.value(get(input.shellStateAtom(ref.environmentId))), ); if (shell?.status !== "live") continue; - const authoritativeThread = Option.getOrNull(shell.snapshot)?.threads.find( + const snapshot = Option.getOrNull(shell.snapshot); + const authoritativeShell = snapshot?.threads.find( (candidate) => candidate.id === ref.threadId, ); if ( - authoritativeThread && - draftModelPickMatchesThread(draft.modelSelection, authoritativeThread.modelSelection) + snapshot && + authoritativeShell && + (draftModelPickMatchesThread(draft.modelSelection, authoritativeShell.modelSelection) || + deliveredPickWasApplied(draft, snapshot.snapshotSequence, authoritativeShell)) ) { matches.push([key, draft]); } @@ -1519,7 +1591,11 @@ export function subscribeComposerDraftModelReconciliation(input: { let next = current; for (const [key, matchedDraft] of matches) { if (next[key] !== matchedDraft) continue; - const { modelSelection: _modelSelection, ...draft } = matchedDraft; + const { + modelSelection: _modelSelection, + modelSelectionDelivery: _modelSelectionDelivery, + ...draft + } = matchedDraft; next = withComposerDraft(next, key, draft); } return next; @@ -1549,6 +1625,7 @@ export function clearComposerDraftContentState( importedShareIds: _importedShareIds, context: _context, modelSelection, + modelSelectionDelivery, workspaceSelection, project: _project, ...retained @@ -1556,6 +1633,9 @@ export function clearComposerDraftContentState( const draft = { ...retained, ...(options?.clearModelSelection || modelSelection === undefined ? {} : { modelSelection }), + ...(options?.clearModelSelection || modelSelectionDelivery === undefined + ? {} + : { modelSelectionDelivery }), ...(options?.clearWorkspaceSelection || workspaceSelection === undefined ? {} : { workspaceSelection }), 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 72c50d2095bc9..7c607eeca2670 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(), + markDelivered: vi.fn(), cachedModelUnavailable: false, manager: null as unknown as ReturnType< typeof import("./thread-outbox-manager").createThreadOutboxManager @@ -138,6 +139,7 @@ vi.mock("./use-composer-drafts", async () => { appendComposerDraftAttachments: vi.fn(), flushComposerDrafts: vi.fn(), getComposerDraftSnapshot: vi.fn(() => ({ text: state.draftText, attachments: [] })), + markComposerDraftModelSelectionDelivered: state.markDelivered, mergeComposerDraftContent: vi.fn(async (_key: string, input: { text: string }) => { state.draftText += `\n${input.text}`; }), @@ -178,6 +180,7 @@ beforeEach(() => { state.grantedEnvironments = new Set(["primary"]); state.draftText = "Keep this draft separate"; state.cachedModelUnavailable = false; + state.markDelivered.mockReset(); appAtomRegistry.set(state.manager.queuedMessagesByThreadKeyAtom, {}); appAtomRegistry.set(dispatchingQueuedMessageIdAtom, null); for (const command of [ @@ -315,11 +318,14 @@ describe("queued follow-up model", () => { state.grantedEnvironments.add("secondary"); const picked = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }; await state.manager.enqueue(message({ modelSelection: picked })); + state.start.mockResolvedValueOnce(AsyncResult.success({ sequence: 42 })); await runDrain(); expect(state.start).toHaveBeenCalledOnce(); expect(state.start.mock.calls[0]?.[0].input.modelSelection).toEqual(picked); + // The draft keeps the pick until a later run shows the server applied it. + expect(state.markDelivered).toHaveBeenCalledExactlyOnceWith("secondary:thread", picked, 42); }); }); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 9a3d2c1934bad..99af0c0cc61d7 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -72,6 +72,7 @@ import { flushComposerDrafts, type ComposerDraft, getComposerDraftSnapshot, + markComposerDraftModelSelectionDelivered, mergeComposerDraftContent, replaceComposerDraftAttachments, removeDeliveredCloudQueuedMessage, @@ -889,6 +890,19 @@ export function useThreadOutboxDrain(): void { return restoreQueuedMessage(persistedMessage, failure.message); } acknowledgedExistingThreadMessageIdsRef.current.add(persistedMessage.messageId); + // A retried command returns its original receipt, so the sequence names this dispatch. + const delivery = AsyncResult.isSuccess(deliveryResult) ? deliveryResult.value : undefined; + const deliveredSequence = + typeof delivery === "object" && delivery !== null && "sequence" in delivery + ? delivery.sequence + : undefined; + if (picked && deliveredSequence !== undefined) { + markComposerDraftModelSelectionDelivered( + scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId), + sendSettings.modelSelection, + deliveredSequence, + ); + } const delivered = (await completeQueuedMessageDelivery(persistedMessage, deliveryRevision)) === "removed"; if (delivered) { diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 00333529dd1fb..d1a25299eb4b6 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1064,6 +1064,7 @@ type ShellThreadRow = { readonly latest_run_started_at: string | null; readonly latest_run_completed_at: string | null; readonly latest_run_stop_requested: number; + readonly has_queued_runs: number; readonly active_run_id: string | null; readonly active_run_status: string | null; readonly activity_run_status: string | null; @@ -1614,6 +1615,7 @@ export function threadShellFromProjection( projection.turnItems.some( (item) => item.runId === latestRun.id && item.type === "run_interrupt_request", ), + hasQueuedRuns: projection.runs.some((run) => run.status === "queued"), activeRunId: activeRun?.id ?? null, blockedOnThreadWait: deriveBlockedOnThreadWait({ activeRun, turnItems: projection.turnItems }), activityRunStatus: activityRun?.status ?? null, @@ -1714,6 +1716,7 @@ type ShellThreadState = { readonly latestRunStartedAt: DateTime.Utc | null; readonly latestRunCompletedAt: DateTime.Utc | null; readonly latestRunStopRequested: boolean; + readonly hasQueuedRuns: boolean; readonly activeRunId: RunId | null; readonly blockedOnThreadWait: boolean; readonly activityRunStatus: ShellActivityRunStatus | null; @@ -1869,6 +1872,7 @@ function shellFromState(input: { latestRunStartedAt: input.state.latestRunStartedAt, latestRunCompletedAt: input.state.latestRunCompletedAt, latestRunStopRequested: input.state.latestRunStopRequested, + hasQueuedRuns: input.state.hasQueuedRuns, activeRunId: input.state.activeRunId, blockedOnThreadWait: input.state.blockedOnThreadWait, activityRunStatus: input.state.activityRunStatus, @@ -5663,6 +5667,10 @@ export const layer: Layer.Layer = WHERE stop_request.thread_id = t.thread_id AND stop_request.run_id = presented.run_id AND stop_request.type = 'run_interrupt_request' ) AS latest_run_stop_requested, + EXISTS ( + SELECT 1 FROM orchestration_v2_projection_runs queued + WHERE queued.thread_id = t.thread_id AND queued.status = 'queued' + ) AS has_queued_runs, active.run_id AS active_run_id, active.status AS active_run_status, ( @@ -6250,6 +6258,7 @@ export const layer: Layer.Layer = latestRunStartedAt, latestRunCompletedAt, latestRunStopRequested, + hasQueuedRuns: row.has_queued_runs === 1, activeRunId: row.active_run_id === null ? null : RunId.make(row.active_run_id), blockedOnThreadWait: deriveBlockedOnThreadWait({ activeRun: diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts index 5952e5c217a3e..ed3cc27bba508 100644 --- a/apps/server/src/orchestration-v2/ThreadStop.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts @@ -876,6 +876,50 @@ it.effect.each([false, true])( }).pipe(Effect.provide(layerTest)), ); +it.effect("the thread shell says whether a run still waits to start, held runs included", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("thread:shell-queued-runs"); + const shells = Effect.gen(function* () { + const snapshot = (yield* projections.getShellSnapshot()).threads.find( + (thread) => thread.id === threadId, + ); + const memoryShell = ProjectionStore.threadShellFromProjection( + yield* orchestrator.getThreadProjection(threadId), + ); + return [yield* projections.getThreadShell(threadId), snapshot, memoryShell].map( + (shell) => shell?.hasQueuedRuns, + ); + }); + yield* createWatchingThread(threadId, 9); + yield* send(threadId, "active", "start_immediately"); + assert.deepEqual(yield* shells, [false, false, false]); + + yield* send(threadId, "queued", "queue_after_active"); + assert.deepEqual(yield* shells, [true, true, true]); + + yield* orchestrator.dispatch({ + type: "thread.stop", + commandId: CommandId.make("stop"), + threadId, + }); + const held = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.status === "queued", + )!; + assert.isTrue(held.queueHeld); + assert.deepEqual(yield* shells, [true, true, true]); + + yield* orchestrator.dispatch({ + type: "queued-run.cancel", + commandId: CommandId.make("cancel-queued"), + threadId, + runId: held.id, + }); + assert.deepEqual(yield* shells, [false, false, false]); + }).pipe(Effect.provide(layerTest)), +); + it.effect("a run Stop reached cannot delegate or start a watch, even after it ends", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index ff7c295b8bf91..74cae0c5bc29f 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1834,6 +1834,8 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ latestRunStartedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), latestRunCompletedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), latestRunStopRequested: Schema.optional(Schema.Boolean), + // Held runs count: a run still waiting to start has not applied its model to the thread. + hasQueuedRuns: Schema.optional(Schema.Boolean), activeRunId: Schema.NullOr(RunId), activityRunStartedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), activityRunStatus: Schema.optional(