From 593b9776670d51f1b0616cfef1bbbe6fbdedbc65 Mon Sep 17 00:00:00 2001 From: Alex Pan <53453555+HelloAlexPan@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:33:47 +1100 Subject: [PATCH 1/6] fix(mobile): permission mode picks follow changes made on other clients A permission mode picked on the phone stayed in the thread's draft for good, so every later phone send carried it. After another client changed the thread's mode, the next phone send switched it back; for Claude that restarts the process and ends running Workflows and background agents. The reconciliation that drops a draft model pick once the live server thread has it (#20886) now releases the runtime mode the same way, each field on its own. A mode pick stays while the outbox still holds a message for the thread sent in a different mode. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/App.tsx | 6 +- .../src/state/use-composer-drafts.test.ts | 33 ++++++++++- apps/mobile/src/state/use-composer-drafts.ts | 57 ++++++++++++++----- 3 files changed, 78 insertions(+), 18 deletions(-) diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 6287a05956519..dba97666e50e6 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -48,7 +48,8 @@ import { } from "./features/settings/appearance/AppearancePreferencesProvider"; import { RootStack, rootPathnameFromState, workspaceLocationFromState } from "./Stack"; import { appAtomRegistry } from "./state/atom-registry"; -import { subscribeComposerDraftModelReconciliation } from "./state/use-composer-drafts"; +import { subscribeComposerDraftSettingsReconciliation } from "./state/use-composer-drafts"; +import { threadOutboxManager } from "./state/thread-outbox"; import { environmentThreadShells, threadEnvironment } from "./state/threads"; import { environmentShell } from "./state/shell"; import { OverlayPortalHost } from "./components/ui/OverlayPortal"; @@ -182,12 +183,13 @@ declare const module: { readonly hot?: FoundationHotModule } | undefined; const appServiceDisposers = [ installUiRuntimeMemoryWarningGc(), - subscribeComposerDraftModelReconciliation({ + subscribeComposerDraftSettingsReconciliation({ registry: appAtomRegistry, threadShellAtom: environmentThreadShells.threadShellAtom, threadRefsAtom: environmentThreadShells.threadRefsAtom, shellStateAtom: environmentShell.stateAtom, pendingThreadIdsAtom: threadEnvironment.pendingThreadIdsAtom, + queuedMessagesByThreadKeyAtom: threadOutboxManager.queuedMessagesByThreadKeyAtom, }), installAppearanceRuntime(), installSubscriptionUsageRefresh(), diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index f8a14977acca9..a6fa695553fff 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -25,6 +25,7 @@ import { type OrchestrationV2ThreadShell, type OrchestrationV2ShellSnapshot, type ModelSelection, + type RuntimeMode, } from "@t3tools/contracts"; import { onTestFinished, vi } from "vite-plus/test"; @@ -181,7 +182,7 @@ import { archiveCloudComposerDrafts, clearComposerDraftContent, clearComposerDraftContentState, - subscribeComposerDraftModelReconciliation, + subscribeComposerDraftSettingsReconciliation, clearComposerDraftsEnvironment, ComposerDraftPersistenceError, composerDraftsAtom, @@ -311,6 +312,11 @@ function modelReconciliationHarness( ]), }); const noPendingThreadIdsAtom = Atom.make((): ReadonlySet => new Set()); + const queuedModeAtom = Atom.make(null); + const queuedMessagesByThreadKeyAtom = Atom.make((get) => { + const runtimeMode = get(queuedModeAtom); + return runtimeMode === null ? {} : { [`${environmentId}:${threadId}`]: [{ runtimeMode }] }; + }); const shells = createEnvironmentThreadShellAtoms({ catalogValueAtom, snapshotAtom: () => snapshotAtom, @@ -319,12 +325,13 @@ function modelReconciliationHarness( const selectedShell = shells.threadShellAtom({ environmentId, threadId: selectedThreadId }); onTestFinished(registry.mount(selectedShell)); onTestFinished( - subscribeComposerDraftModelReconciliation({ + subscribeComposerDraftSettingsReconciliation({ registry, threadShellAtom: shells.threadShellAtom, threadRefsAtom: shells.threadRefsAtom, shellStateAtom: () => shellResultAtom, pendingThreadIdsAtom: () => noPendingThreadIdsAtom, + queuedMessagesByThreadKeyAtom, }), ); flush(); @@ -334,6 +341,10 @@ function modelReconciliationHarness( registry.set(shellStateAtom, { ...registry.get(shellStateAtom), status }); flush(); }, + setQueuedMode(runtimeMode: RuntimeMode | null) { + registry.set(queuedModeAtom, runtimeMode); + flush(); + }, update(changes: Partial, status?: EnvironmentShellStatus) { const state = registry.get(shellStateAtom); const snapshot = Option.getOrThrow(state.snapshot); @@ -551,6 +562,24 @@ describe("mobile composer drafts", () => { expect(getComposerDraftSnapshot(key)).toEqual({ ...before, modelSelection: undefined }); }); + it("releases a permission mode pick once the live thread runs in it", () => { + const environmentId = EnvironmentId.make("environment-1"); + const threadId = ThreadId.make("thread-1"); + const key = `${environmentId}:${threadId}`; + const modelPick = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.5" }; + const h = modelReconciliationHarness(environmentId, threadId); + updateComposerDraftSettings(key, { runtimeMode: "auto", modelSelection: modelPick }); + h.flush(); + const before = getComposerDraftSnapshot(key); + expect(before.runtimeMode).toBe("auto"); + + h.setQueuedMode("full-access"); + h.update({ runtimeMode: "auto" }); + expect(getComposerDraftSnapshot(key)).toEqual(before); + h.setQueuedMode("auto"); + expect(getComposerDraftSnapshot(key)).toEqual({ ...before, runtimeMode: 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 b233034c5cf84..18693f115f563 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1565,7 +1565,7 @@ export function isFreshSessionPending(draftKey: string): boolean { return getComposerDraftSnapshot(draftKey).freshContext === true; } -export function subscribeComposerDraftModelReconciliation(input: { +export function subscribeComposerDraftSettingsReconciliation(input: { readonly registry: AtomRegistry.AtomRegistry; readonly threadRefsAtom: Atom.Atom>; readonly threadShellAtom: (ref: ScopedThreadRef) => Atom.Atom; @@ -1573,6 +1573,9 @@ export function subscribeComposerDraftModelReconciliation(input: { environmentId: EnvironmentId, ) => Atom.Atom>; readonly pendingThreadIdsAtom: (environmentId: EnvironmentId) => Atom.Atom>; + readonly queuedMessagesByThreadKeyAtom: Atom.Atom< + Readonly>> + >; }): () => void { const draftsAtom = Atom.make(appAtomRegistry.get(composerDraftsAtom)); const unsubscribeDrafts = appAtomRegistry.subscribe(composerDraftsAtom, (drafts) => @@ -1584,14 +1587,35 @@ export function subscribeComposerDraftModelReconciliation(input: { ); const matchesAtom = Atom.make((get) => { const threadKeys = get(threadKeysAtom); - const matches: Array = []; + const queuedMessages = get(input.queuedMessagesByThreadKeyAtom); + const matches: Array< + readonly [string, ComposerDraft, { readonly model: boolean; readonly runtimeMode: boolean }] + > = []; for (const [key, draft] of Object.entries(get(draftsAtom))) { - if (!draft.modelSelection || isNewTaskDraftKey(key) || isQueuedEditDraftKey(key)) continue; + if ( + (!draft.modelSelection && !draft.runtimeMode) || + isNewTaskDraftKey(key) || + isQueuedEditDraftKey(key) + ) + continue; const ref = parseScopedThreadKey(key); if (!ref || !threadKeys.has(key)) continue; const thread = get(input.threadShellAtom(ref)); - if (!thread || !draftModelPickMatchesThread(draft.modelSelection, thread.modelSelection)) - continue; + if (!thread) continue; + // A queued message sent in another mode can still switch the thread away from this pick. + const queuedModeChange = (queuedMessages[key] ?? []).some( + (message) => message.runtimeMode !== undefined && message.runtimeMode !== draft.runtimeMode, + ); + const pickMatches = ( + candidate: Pick, + ) => ({ + model: + draft.modelSelection !== undefined && + draftModelPickMatchesThread(draft.modelSelection, candidate.modelSelection), + runtimeMode: draft.runtimeMode === candidate.runtimeMode && !queuedModeChange, + }); + const cached = pickMatches(thread); + if (!cached.model && !cached.runtimeMode) continue; // The pick can match an old model while an earlier pick in flight still replaces it. if (get(input.pendingThreadIdsAtom(ref.environmentId)).has(ref.threadId)) continue; // Status and selection must come from the same emission; derived atoms can update apart. @@ -1600,12 +1624,13 @@ export function subscribeComposerDraftModelReconciliation(input: { const authoritativeThread = Option.getOrNull(shell.snapshot)?.threads.find( (candidate) => candidate.id === ref.threadId, ); - if ( - authoritativeThread && - draftModelPickMatchesThread(draft.modelSelection, authoritativeThread.modelSelection) - ) { - matches.push([key, draft]); - } + if (!authoritativeThread) continue; + const live = pickMatches(authoritativeThread); + const release = { + model: cached.model && live.model, + runtimeMode: cached.runtimeMode && live.runtimeMode, + }; + if (release.model || release.runtimeMode) matches.push([key, draft, release]); } return matches; }); @@ -1615,10 +1640,14 @@ export function subscribeComposerDraftModelReconciliation(input: { if (matches.length === 0) return; updateComposerDrafts((current) => { let next = current; - for (const [key, matchedDraft] of matches) { + for (const [key, matchedDraft, release] of matches) { if (next[key] !== matchedDraft) continue; - const { modelSelection: _modelSelection, ...draft } = matchedDraft; - next = withComposerDraft(next, key, draft); + const { modelSelection, runtimeMode, ...draft } = matchedDraft; + next = withComposerDraft(next, key, { + ...draft, + ...(release.model || modelSelection === undefined ? {} : { modelSelection }), + ...(release.runtimeMode || runtimeMode === undefined ? {} : { runtimeMode }), + }); } return next; }); From 9182999ba677e6be6d3136258be5505fb405ce62 Mon Sep 17 00:00:00 2001 From: Alex Pan <53453555+HelloAlexPan@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:40:59 +1100 Subject: [PATCH 2/6] fix(mobile): hold permission mode picks until the outbox has loaded On a cold start the outbox reads persisted messages asynchronously, so the reconciliation could see an empty queue and release a mode pick that a persisted message in another mode would later override. Mode picks now stay until every persisted message has been read. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/App.tsx | 1 + apps/mobile/src/state/thread-outbox-manager.ts | 7 +++++++ apps/mobile/src/state/thread-outbox.test.ts | 2 ++ apps/mobile/src/state/use-composer-drafts.test.ts | 11 ++++++++++- apps/mobile/src/state/use-composer-drafts.ts | 14 ++++++++++---- 5 files changed, 30 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index dba97666e50e6..b4857cce6fc1e 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -190,6 +190,7 @@ const appServiceDisposers = [ shellStateAtom: environmentShell.stateAtom, pendingThreadIdsAtom: threadEnvironment.pendingThreadIdsAtom, queuedMessagesByThreadKeyAtom: threadOutboxManager.queuedMessagesByThreadKeyAtom, + outboxLoadedAtom: threadOutboxManager.loadedAtom, }), installAppearanceRuntime(), installSubscriptionUsageRefresh(), diff --git a/apps/mobile/src/state/thread-outbox-manager.ts b/apps/mobile/src/state/thread-outbox-manager.ts index 732db0159b7f7..ab8c002f6fa37 100644 --- a/apps/mobile/src/state/thread-outbox-manager.ts +++ b/apps/mobile/src/state/thread-outbox-manager.ts @@ -41,6 +41,10 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { const queuedMessagesByThreadKeyAtom = Atom.make< Record> >({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-outbox:queued-messages")); + const loadedAtom = Atom.make(false).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:thread-outbox:loaded"), + ); const warn = options.warn ?? ((message: string, error: unknown) => { @@ -85,6 +89,7 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { if (result.errors.length > 0) { throw new AggregateError(result.errors, "Some queued messages could not be read."); } + options.registry.set(loadedAtom, true); return true; }).catch((cause) => { loadPromise = null; @@ -341,6 +346,8 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { return { queuedMessagesByThreadKeyAtom, + /** True once every persisted message has been read into the queue. */ + loadedAtom, serialize, load, enqueue, diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index dbd621004b0d6..c4839bed763ab 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -283,11 +283,13 @@ describe("thread outbox", () => { expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ "environment-1:thread-1": [edited], }); + expect(registry.get(manager.loadedAtom)).toBe(false); await expect(manager.confirmQueued(edited)).resolves.toBe(true); await manager.remove(edited); await expect(manager.load()).resolves.toBe(true); expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({}); + expect(registry.get(manager.loadedAtom)).toBe(true); expect(load).toHaveBeenCalledTimes(2); }); diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index a6fa695553fff..cc3b4b9711300 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -312,6 +312,7 @@ function modelReconciliationHarness( ]), }); const noPendingThreadIdsAtom = Atom.make((): ReadonlySet => new Set()); + const outboxLoadedAtom = Atom.make(true); const queuedModeAtom = Atom.make(null); const queuedMessagesByThreadKeyAtom = Atom.make((get) => { const runtimeMode = get(queuedModeAtom); @@ -332,6 +333,7 @@ function modelReconciliationHarness( shellStateAtom: () => shellResultAtom, pendingThreadIdsAtom: () => noPendingThreadIdsAtom, queuedMessagesByThreadKeyAtom, + outboxLoadedAtom, }), ); flush(); @@ -345,6 +347,10 @@ function modelReconciliationHarness( registry.set(queuedModeAtom, runtimeMode); flush(); }, + setOutboxLoaded(loaded: boolean) { + registry.set(outboxLoadedAtom, loaded); + flush(); + }, update(changes: Partial, status?: EnvironmentShellStatus) { const state = registry.get(shellStateAtom); const snapshot = Option.getOrThrow(state.snapshot); @@ -573,9 +579,12 @@ describe("mobile composer drafts", () => { const before = getComposerDraftSnapshot(key); expect(before.runtimeMode).toBe("auto"); - h.setQueuedMode("full-access"); + h.setOutboxLoaded(false); h.update({ runtimeMode: "auto" }); expect(getComposerDraftSnapshot(key)).toEqual(before); + h.setQueuedMode("full-access"); + h.setOutboxLoaded(true); + expect(getComposerDraftSnapshot(key)).toEqual(before); h.setQueuedMode("auto"); expect(getComposerDraftSnapshot(key)).toEqual({ ...before, runtimeMode: undefined }); }); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 18693f115f563..8ed95971e71d5 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1576,6 +1576,7 @@ export function subscribeComposerDraftSettingsReconciliation(input: { readonly queuedMessagesByThreadKeyAtom: Atom.Atom< Readonly>> >; + readonly outboxLoadedAtom: Atom.Atom; }): () => void { const draftsAtom = Atom.make(appAtomRegistry.get(composerDraftsAtom)); const unsubscribeDrafts = appAtomRegistry.subscribe(composerDraftsAtom, (drafts) => @@ -1588,6 +1589,7 @@ export function subscribeComposerDraftSettingsReconciliation(input: { const matchesAtom = Atom.make((get) => { const threadKeys = get(threadKeysAtom); const queuedMessages = get(input.queuedMessagesByThreadKeyAtom); + const outboxLoaded = get(input.outboxLoadedAtom); const matches: Array< readonly [string, ComposerDraft, { readonly model: boolean; readonly runtimeMode: boolean }] > = []; @@ -1602,10 +1604,14 @@ export function subscribeComposerDraftSettingsReconciliation(input: { if (!ref || !threadKeys.has(key)) continue; const thread = get(input.threadShellAtom(ref)); if (!thread) continue; - // A queued message sent in another mode can still switch the thread away from this pick. - const queuedModeChange = (queuedMessages[key] ?? []).some( - (message) => message.runtimeMode !== undefined && message.runtimeMode !== draft.runtimeMode, - ); + // A queued message sent in another mode, including one not loaded yet, can still switch + // the thread away from this pick. + const queuedModeChange = + !outboxLoaded || + (queuedMessages[key] ?? []).some( + (message) => + message.runtimeMode !== undefined && message.runtimeMode !== draft.runtimeMode, + ); const pickMatches = ( candidate: Pick, ) => ({ From 8ccbde6c7a3004a67157e6d49c07729e1b625dc3 Mon Sep 17 00:00:00 2001 From: Alex Pan <53453555+HelloAlexPan@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:47:18 +1100 Subject: [PATCH 3/6] fix(mobile): release a permission mode pick once its send applies it Replaces matching the pick against the live thread, which needed guards for queued messages and outbox loading and still raced the shell update after a delivery. The outbox now releases the composer's mode pick right after a send has set that mode on the thread (or found it already set), the way model picks are released on the server's reply. A different pick made after the message was queued stays for its own send. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/App.tsx | 7 +- .../mobile/src/state/thread-outbox-manager.ts | 7 -- apps/mobile/src/state/thread-outbox.test.ts | 2 - .../threadOutboxDelivery.permissions.test.ts | 25 +++++++ apps/mobile/src/state/threadOutboxDelivery.ts | 5 ++ .../src/state/use-composer-drafts.test.ts | 53 ++++--------- apps/mobile/src/state/use-composer-drafts.ts | 74 +++++++------------ 7 files changed, 70 insertions(+), 103 deletions(-) diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index b4857cce6fc1e..6287a05956519 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -48,8 +48,7 @@ import { } from "./features/settings/appearance/AppearancePreferencesProvider"; import { RootStack, rootPathnameFromState, workspaceLocationFromState } from "./Stack"; import { appAtomRegistry } from "./state/atom-registry"; -import { subscribeComposerDraftSettingsReconciliation } from "./state/use-composer-drafts"; -import { threadOutboxManager } from "./state/thread-outbox"; +import { subscribeComposerDraftModelReconciliation } from "./state/use-composer-drafts"; import { environmentThreadShells, threadEnvironment } from "./state/threads"; import { environmentShell } from "./state/shell"; import { OverlayPortalHost } from "./components/ui/OverlayPortal"; @@ -183,14 +182,12 @@ declare const module: { readonly hot?: FoundationHotModule } | undefined; const appServiceDisposers = [ installUiRuntimeMemoryWarningGc(), - subscribeComposerDraftSettingsReconciliation({ + subscribeComposerDraftModelReconciliation({ registry: appAtomRegistry, threadShellAtom: environmentThreadShells.threadShellAtom, threadRefsAtom: environmentThreadShells.threadRefsAtom, shellStateAtom: environmentShell.stateAtom, pendingThreadIdsAtom: threadEnvironment.pendingThreadIdsAtom, - queuedMessagesByThreadKeyAtom: threadOutboxManager.queuedMessagesByThreadKeyAtom, - outboxLoadedAtom: threadOutboxManager.loadedAtom, }), installAppearanceRuntime(), installSubscriptionUsageRefresh(), diff --git a/apps/mobile/src/state/thread-outbox-manager.ts b/apps/mobile/src/state/thread-outbox-manager.ts index ab8c002f6fa37..732db0159b7f7 100644 --- a/apps/mobile/src/state/thread-outbox-manager.ts +++ b/apps/mobile/src/state/thread-outbox-manager.ts @@ -41,10 +41,6 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { const queuedMessagesByThreadKeyAtom = Atom.make< Record> >({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-outbox:queued-messages")); - const loadedAtom = Atom.make(false).pipe( - Atom.keepAlive, - Atom.withLabel("mobile:thread-outbox:loaded"), - ); const warn = options.warn ?? ((message: string, error: unknown) => { @@ -89,7 +85,6 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { if (result.errors.length > 0) { throw new AggregateError(result.errors, "Some queued messages could not be read."); } - options.registry.set(loadedAtom, true); return true; }).catch((cause) => { loadPromise = null; @@ -346,8 +341,6 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { return { queuedMessagesByThreadKeyAtom, - /** True once every persisted message has been read into the queue. */ - loadedAtom, serialize, load, enqueue, diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index c4839bed763ab..dbd621004b0d6 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -283,13 +283,11 @@ describe("thread outbox", () => { expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ "environment-1:thread-1": [edited], }); - expect(registry.get(manager.loadedAtom)).toBe(false); await expect(manager.confirmQueued(edited)).resolves.toBe(true); await manager.remove(edited); await expect(manager.load()).resolves.toBe(true); expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({}); - expect(registry.get(manager.loadedAtom)).toBe(true); expect(load).toHaveBeenCalledTimes(2); }); diff --git a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts index 3222101bf927a..bd64b10e34fbe 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts @@ -23,6 +23,7 @@ const state = vi.hoisted(() => ({ metadata: vi.fn(), runtime: vi.fn(), interaction: vi.fn(), + releaseRuntimeMode: vi.fn(), prepare: vi.fn(), checkout: vi.fn(), modelUnavailable: vi.fn(() => false), @@ -150,6 +151,7 @@ vi.mock("./use-composer-drafts", async () => { mergeComposerDraftContent: vi.fn(async (_key: string, input: { text: string }) => { state.draftText += `\n${input.text}`; }), + releaseComposerDraftRuntimeMode: state.releaseRuntimeMode, replaceComposerDraftAttachments: vi.fn(), setFreshSessionPending: vi.fn(), undoComposerDraftMerge: vi.fn(), @@ -212,6 +214,7 @@ beforeEach(() => { state.config.shellThreadFamilies = false; state.modelUnavailable.mockReset().mockReturnValue(false); state.familyMount.mockReset().mockReturnValue(() => {}); + state.releaseRuntimeMode.mockReset(); appAtomRegistry.set(state.threadShells, [state.thread]); appAtomRegistry.set(state.manager.queuedMessagesByThreadKeyAtom, {}); appAtomRegistry.set(dispatchingQueuedMessageIdAtom, null); @@ -405,6 +408,28 @@ describe("queued task operation access", () => { expect(remaining()).toEqual([queued]); }); + it.each([ + { runtimeMode: "approval-required", refused: false, released: true }, + { runtimeMode: "full-access", refused: false, released: true }, + { runtimeMode: "approval-required", refused: true, released: false }, + ] as const)( + "releases the composer's mode pick only once delivery applied it ($runtimeMode, refused: $refused)", + async ({ runtimeMode, refused, released }) => { + setOperateGrant("secondary", true); + await state.manager.enqueue(message({ runtimeMode })); + if (refused) { + state.runtime.mockResolvedValueOnce( + AsyncResult.failure(Cause.fail(new Error("Mode change refused."))), + ); + } + await runDrain(); + expect(state.runtime).toHaveBeenCalledTimes(runtimeMode === state.thread.runtimeMode ? 0 : 1); + expect(state.releaseRuntimeMode.mock.calls).toEqual( + released ? [["secondary:thread", runtimeMode]] : [], + ); + }, + ); + it.each([ { isCreation: false, accepted: false }, { isCreation: true, accepted: true }, diff --git a/apps/mobile/src/state/threadOutboxDelivery.ts b/apps/mobile/src/state/threadOutboxDelivery.ts index 9b2da676eee44..1c7dbbfca904f 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.ts @@ -78,6 +78,7 @@ import { type ComposerDraft, getComposerDraftSnapshot, mergeComposerDraftContent, + releaseComposerDraftRuntimeMode, replaceComposerDraftAttachments, removeDeliveredCloudQueuedMessage, undoComposerDraftMerge, @@ -809,6 +810,10 @@ export function createThreadOutboxDelivery(): { return false; } } + releaseComposerDraftRuntimeMode( + scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId), + settings.runtimeMode, + ); if (settings.interactionMode !== thread.interactionMode) { if (!hasAccess()) return true; diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index cc3b4b9711300..667d94f50864d 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -25,7 +25,6 @@ import { type OrchestrationV2ThreadShell, type OrchestrationV2ShellSnapshot, type ModelSelection, - type RuntimeMode, } from "@t3tools/contracts"; import { onTestFinished, vi } from "vite-plus/test"; @@ -182,7 +181,7 @@ import { archiveCloudComposerDrafts, clearComposerDraftContent, clearComposerDraftContentState, - subscribeComposerDraftSettingsReconciliation, + subscribeComposerDraftModelReconciliation, clearComposerDraftsEnvironment, ComposerDraftPersistenceError, composerDraftsAtom, @@ -204,6 +203,7 @@ import { replaceComposerDraftAttachments, resetComposerDraftsLoadState, restoreComposerDraftSnapshotState, + releaseComposerDraftRuntimeMode, restoreCloudComposerDrafts, retargetNewTaskDraft, setComposerDraftText, @@ -312,12 +312,6 @@ function modelReconciliationHarness( ]), }); const noPendingThreadIdsAtom = Atom.make((): ReadonlySet => new Set()); - const outboxLoadedAtom = Atom.make(true); - const queuedModeAtom = Atom.make(null); - const queuedMessagesByThreadKeyAtom = Atom.make((get) => { - const runtimeMode = get(queuedModeAtom); - return runtimeMode === null ? {} : { [`${environmentId}:${threadId}`]: [{ runtimeMode }] }; - }); const shells = createEnvironmentThreadShellAtoms({ catalogValueAtom, snapshotAtom: () => snapshotAtom, @@ -326,14 +320,12 @@ function modelReconciliationHarness( const selectedShell = shells.threadShellAtom({ environmentId, threadId: selectedThreadId }); onTestFinished(registry.mount(selectedShell)); onTestFinished( - subscribeComposerDraftSettingsReconciliation({ + subscribeComposerDraftModelReconciliation({ registry, threadShellAtom: shells.threadShellAtom, threadRefsAtom: shells.threadRefsAtom, shellStateAtom: () => shellResultAtom, pendingThreadIdsAtom: () => noPendingThreadIdsAtom, - queuedMessagesByThreadKeyAtom, - outboxLoadedAtom, }), ); flush(); @@ -343,14 +335,6 @@ function modelReconciliationHarness( registry.set(shellStateAtom, { ...registry.get(shellStateAtom), status }); flush(); }, - setQueuedMode(runtimeMode: RuntimeMode | null) { - registry.set(queuedModeAtom, runtimeMode); - flush(); - }, - setOutboxLoaded(loaded: boolean) { - registry.set(outboxLoadedAtom, loaded); - flush(); - }, update(changes: Partial, status?: EnvironmentShellStatus) { const state = registry.get(shellStateAtom); const snapshot = Option.getOrThrow(state.snapshot); @@ -495,6 +479,16 @@ describe("mobile composer drafts", () => { }, ); + it("releases a delivered mode pick but keeps a newer one and the unsent text", () => { + const key = "environment-1:thread-1"; + setComposerDraftText(key, "next message"); + updateComposerDraftSettings(key, { runtimeMode: "approval-required" }); + releaseComposerDraftRuntimeMode(key, "full-access"); + expect(getComposerDraftSnapshot(key).runtimeMode).toBe("approval-required"); + releaseComposerDraftRuntimeMode(key, "approval-required"); + expect(getComposerDraftSnapshot(key)).toEqual({ text: "next message", attachments: [] }); + }); + it("retains a pick matching cached state until the live shell confirms it", () => { const environmentId = EnvironmentId.make("environment-1"); const threadId = ThreadId.make("thread-1"); @@ -568,27 +562,6 @@ describe("mobile composer drafts", () => { expect(getComposerDraftSnapshot(key)).toEqual({ ...before, modelSelection: undefined }); }); - it("releases a permission mode pick once the live thread runs in it", () => { - const environmentId = EnvironmentId.make("environment-1"); - const threadId = ThreadId.make("thread-1"); - const key = `${environmentId}:${threadId}`; - const modelPick = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.5" }; - const h = modelReconciliationHarness(environmentId, threadId); - updateComposerDraftSettings(key, { runtimeMode: "auto", modelSelection: modelPick }); - h.flush(); - const before = getComposerDraftSnapshot(key); - expect(before.runtimeMode).toBe("auto"); - - h.setOutboxLoaded(false); - h.update({ runtimeMode: "auto" }); - expect(getComposerDraftSnapshot(key)).toEqual(before); - h.setQueuedMode("full-access"); - h.setOutboxLoaded(true); - expect(getComposerDraftSnapshot(key)).toEqual(before); - h.setQueuedMode("auto"); - expect(getComposerDraftSnapshot(key)).toEqual({ ...before, runtimeMode: 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 8ed95971e71d5..4109af2eb472c 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1556,6 +1556,17 @@ export function releaseComposerDraftModelSelection( }); } +/** Lets the thread's mode show again once a send applied this pick, so later changes elsewhere hold. */ +export function releaseComposerDraftRuntimeMode(draftKey: string, runtimeMode: RuntimeMode): void { + updateComposerDrafts((current) => { + const existing = current[draftKey]; + // A different pick made after this message was queued still waits for its own send. + if (existing?.runtimeMode !== runtimeMode) return current; + const { runtimeMode: _runtimeMode, ...draft } = existing; + return withComposerDraft(current, draftKey, draft); + }); +} + export function setFreshSessionPending(draftKey: string, pending: boolean): void { if ((getComposerDraftSnapshot(draftKey).freshContext === true) === pending) return; updateComposerDraftSettings(draftKey, { freshContext: pending ? true : undefined }); @@ -1565,7 +1576,7 @@ export function isFreshSessionPending(draftKey: string): boolean { return getComposerDraftSnapshot(draftKey).freshContext === true; } -export function subscribeComposerDraftSettingsReconciliation(input: { +export function subscribeComposerDraftModelReconciliation(input: { readonly registry: AtomRegistry.AtomRegistry; readonly threadRefsAtom: Atom.Atom>; readonly threadShellAtom: (ref: ScopedThreadRef) => Atom.Atom; @@ -1573,10 +1584,6 @@ export function subscribeComposerDraftSettingsReconciliation(input: { environmentId: EnvironmentId, ) => Atom.Atom>; readonly pendingThreadIdsAtom: (environmentId: EnvironmentId) => Atom.Atom>; - readonly queuedMessagesByThreadKeyAtom: Atom.Atom< - Readonly>> - >; - readonly outboxLoadedAtom: Atom.Atom; }): () => void { const draftsAtom = Atom.make(appAtomRegistry.get(composerDraftsAtom)); const unsubscribeDrafts = appAtomRegistry.subscribe(composerDraftsAtom, (drafts) => @@ -1588,40 +1595,14 @@ export function subscribeComposerDraftSettingsReconciliation(input: { ); const matchesAtom = Atom.make((get) => { const threadKeys = get(threadKeysAtom); - const queuedMessages = get(input.queuedMessagesByThreadKeyAtom); - const outboxLoaded = get(input.outboxLoadedAtom); - const matches: Array< - readonly [string, ComposerDraft, { readonly model: boolean; readonly runtimeMode: boolean }] - > = []; + const matches: Array = []; for (const [key, draft] of Object.entries(get(draftsAtom))) { - if ( - (!draft.modelSelection && !draft.runtimeMode) || - isNewTaskDraftKey(key) || - isQueuedEditDraftKey(key) - ) - continue; + if (!draft.modelSelection || isNewTaskDraftKey(key) || isQueuedEditDraftKey(key)) continue; const ref = parseScopedThreadKey(key); if (!ref || !threadKeys.has(key)) continue; const thread = get(input.threadShellAtom(ref)); - if (!thread) continue; - // A queued message sent in another mode, including one not loaded yet, can still switch - // the thread away from this pick. - const queuedModeChange = - !outboxLoaded || - (queuedMessages[key] ?? []).some( - (message) => - message.runtimeMode !== undefined && message.runtimeMode !== draft.runtimeMode, - ); - const pickMatches = ( - candidate: Pick, - ) => ({ - model: - draft.modelSelection !== undefined && - draftModelPickMatchesThread(draft.modelSelection, candidate.modelSelection), - runtimeMode: draft.runtimeMode === candidate.runtimeMode && !queuedModeChange, - }); - const cached = pickMatches(thread); - if (!cached.model && !cached.runtimeMode) continue; + if (!thread || !draftModelPickMatchesThread(draft.modelSelection, thread.modelSelection)) + continue; // The pick can match an old model while an earlier pick in flight still replaces it. if (get(input.pendingThreadIdsAtom(ref.environmentId)).has(ref.threadId)) continue; // Status and selection must come from the same emission; derived atoms can update apart. @@ -1630,13 +1611,12 @@ export function subscribeComposerDraftSettingsReconciliation(input: { const authoritativeThread = Option.getOrNull(shell.snapshot)?.threads.find( (candidate) => candidate.id === ref.threadId, ); - if (!authoritativeThread) continue; - const live = pickMatches(authoritativeThread); - const release = { - model: cached.model && live.model, - runtimeMode: cached.runtimeMode && live.runtimeMode, - }; - if (release.model || release.runtimeMode) matches.push([key, draft, release]); + if ( + authoritativeThread && + draftModelPickMatchesThread(draft.modelSelection, authoritativeThread.modelSelection) + ) { + matches.push([key, draft]); + } } return matches; }); @@ -1646,14 +1626,10 @@ export function subscribeComposerDraftSettingsReconciliation(input: { if (matches.length === 0) return; updateComposerDrafts((current) => { let next = current; - for (const [key, matchedDraft, release] of matches) { + for (const [key, matchedDraft] of matches) { if (next[key] !== matchedDraft) continue; - const { modelSelection, runtimeMode, ...draft } = matchedDraft; - next = withComposerDraft(next, key, { - ...draft, - ...(release.model || modelSelection === undefined ? {} : { modelSelection }), - ...(release.runtimeMode || runtimeMode === undefined ? {} : { runtimeMode }), - }); + const { modelSelection: _modelSelection, ...draft } = matchedDraft; + next = withComposerDraft(next, key, draft); } return next; }); From 2ce4f23b00637424e027e845c4bb6e88e3b02342 Mon Sep 17 00:00:00 2001 From: Alex Pan <53453555+HelloAlexPan@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:25:59 +1100 Subject: [PATCH 4/6] fix(mobile): keep a newer permission pick while a queued message can still change the mode A delivered send released any pick with its own mode, including a pick made after a later queued message in another mode. That message then moved the thread, and the next send used its mode instead of the pick. Delivery now releases a pick only when no other queued message for the thread carries a different mode. setRuntimeMode is optimistic in client-runtime, as setModelSelection is, so a released pick never falls back to the old mode before the shell update arrives. The delivery test now checks the real draft store instead of mock calls, and the duplicate drafts-level test is gone. Co-Authored-By: Claude Opus 5.5 --- .../threadOutboxDelivery.permissions.test.ts | 107 +++++++++++++----- apps/mobile/src/state/threadOutboxDelivery.ts | 13 ++- .../src/state/use-composer-drafts.test.ts | 11 -- .../src/state/threadCommands.test.ts | 2 + .../src/state/threadCommands.ts | 4 + 5 files changed, 92 insertions(+), 45 deletions(-) diff --git a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts index bd64b10e34fbe..ee58d5cae383c 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts @@ -23,7 +23,6 @@ const state = vi.hoisted(() => ({ metadata: vi.fn(), runtime: vi.fn(), interaction: vi.fn(), - releaseRuntimeMode: vi.fn(), prepare: vi.fn(), checkout: vi.fn(), modelUnavailable: vi.fn(() => false), @@ -140,30 +139,40 @@ vi.mock("./use-thread-outbox", async () => { threadOutboxShellStatusesAtom: Atom.make(new Map([["secondary", "live"]])), }; }); -vi.mock("./use-composer-drafts", async () => { - const { Atom } = await import("effect/reactivity"); - return { - composerDraftsAtom: Atom.make({}), - removeDeliveredCloudQueuedMessage: async () => {}, - appendComposerDraftAttachments: vi.fn(), - flushComposerDrafts: vi.fn(), - getComposerDraftSnapshot: vi.fn(() => ({ text: state.draftText, attachments: [] })), - mergeComposerDraftContent: vi.fn(async (_key: string, input: { text: string }) => { - state.draftText += `\n${input.text}`; - }), - releaseComposerDraftRuntimeMode: state.releaseRuntimeMode, - replaceComposerDraftAttachments: vi.fn(), - setFreshSessionPending: vi.fn(), - undoComposerDraftMerge: vi.fn(), - updateComposerDraftSettings: vi.fn(), - waitForComposerDraftsLoaded: vi.fn(), - }; -}); +vi.mock("expo-file-system", () => ({ + Directory: class { + create() {} + }, + File: class { + exists = false; + create() {} + write() {} + moveSync() {} + }, + Paths: { document: { uri: "file:///documents" } }, +})); +// The real draft store keeps mode picks; draft text stays a fake the restore tests read. +vi.mock("./use-composer-drafts", async (importOriginal) => ({ + ...(await importOriginal()), + removeDeliveredCloudQueuedMessage: async () => {}, + appendComposerDraftAttachments: vi.fn(), + flushComposerDrafts: vi.fn(), + getComposerDraftSnapshot: vi.fn(() => ({ text: state.draftText, attachments: [] })), + mergeComposerDraftContent: vi.fn(async (_key: string, input: { text: string }) => { + state.draftText += `\n${input.text}`; + }), + replaceComposerDraftAttachments: vi.fn(), + setFreshSessionPending: vi.fn(), + undoComposerDraftMerge: vi.fn(), + updateComposerDraftSettings: vi.fn(), + waitForComposerDraftsLoaded: vi.fn(), +})); import { appAtomRegistry } from "./atom-registry"; import { environmentSession } from "./session"; import { dispatchingQueuedMessageIdAtom, editingQueuedMessageIdsAtom } from "./use-thread-outbox"; import { createThreadOutboxDelivery } from "./threadOutboxDelivery"; +import { composerDraftsAtom } from "./use-composer-drafts"; const message = (overrides: Partial = {}): QueuedThreadMessage => ({ environmentId: EnvironmentId.make("secondary"), @@ -214,7 +223,7 @@ beforeEach(() => { state.config.shellThreadFamilies = false; state.modelUnavailable.mockReset().mockReturnValue(false); state.familyMount.mockReset().mockReturnValue(() => {}); - state.releaseRuntimeMode.mockReset(); + appAtomRegistry.set(composerDraftsAtom, {}); appAtomRegistry.set(state.threadShells, [state.thread]); appAtomRegistry.set(state.manager.queuedMessagesByThreadKeyAtom, {}); appAtomRegistry.set(dispatchingQueuedMessageIdAtom, null); @@ -409,23 +418,59 @@ describe("queued task operation access", () => { }); it.each([ - { runtimeMode: "approval-required", refused: false, released: true }, - { runtimeMode: "full-access", refused: false, released: true }, - { runtimeMode: "approval-required", refused: true, released: false }, + { queued: ["approval-required"], pick: "approval-required", refused: false, kept: false }, + { queued: ["full-access"], pick: "full-access", refused: false, kept: false }, + { queued: ["approval-required"], pick: "approval-required", refused: true, kept: true }, + // Picked again after the Full access message was queued; that message still moves the thread. + { + queued: ["approval-required", "full-access"], + pick: "approval-required", + refused: false, + kept: true, + }, + // A message without a mode, as an MCP app sends, follows the thread and moves nothing. + { + queued: ["approval-required", undefined], + pick: "approval-required", + refused: false, + kept: false, + }, ] as const)( - "releases the composer's mode pick only once delivery applied it ($runtimeMode, refused: $refused)", - async ({ runtimeMode, refused, released }) => { + "keeps the composer's mode pick until a delivered send applied it last ($queued, refused: $refused)", + async ({ queued, pick, refused, kept }) => { setOperateGrant("secondary", true); - await state.manager.enqueue(message({ runtimeMode })); + const draft = { text: "unsent follow-up", attachments: [] }; + appAtomRegistry.set(composerDraftsAtom, { + "secondary:thread": { ...draft, runtimeMode: pick }, + }); + for (const [index, runtimeMode] of queued.entries()) { + await state.manager.enqueue( + message({ + messageId: MessageId.make(`message-${index}`), + commandId: CommandId.make(`command-${index}`), + createdAt: `2026-09-05T00:00:0${index}.000Z`, + ...(runtimeMode === undefined ? {} : { runtimeMode }), + }), + ); + } if (refused) { state.runtime.mockResolvedValueOnce( AsyncResult.failure(Cause.fail(new Error("Mode change refused."))), ); + await runDrain(); + } else { + const delivered = Promise.withResolvers(); + const release = appAtomRegistry.subscribe( + state.manager.queuedMessagesByThreadKeyAtom, + () => { + if (remaining().length === 0) delivered.resolve(); + }, + ); + startDelivery(); + await delivered.promise.finally(release); } - await runDrain(); - expect(state.runtime).toHaveBeenCalledTimes(runtimeMode === state.thread.runtimeMode ? 0 : 1); - expect(state.releaseRuntimeMode.mock.calls).toEqual( - released ? [["secondary:thread", runtimeMode]] : [], + expect(appAtomRegistry.get(composerDraftsAtom)["secondary:thread"]).toEqual( + kept ? { ...draft, runtimeMode: pick } : draft, ); }, ); diff --git a/apps/mobile/src/state/threadOutboxDelivery.ts b/apps/mobile/src/state/threadOutboxDelivery.ts index 1c7dbbfca904f..e4c2ea43375da 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.ts @@ -810,10 +810,17 @@ export function createThreadOutboxDelivery(): { return false; } } - releaseComposerDraftRuntimeMode( - scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId), - settings.runtimeMode, + const threadKey = scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId); + // A newer pick can share this mode while another queued message still moves the thread off it. + const modeChangeQueued = ( + appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom)[threadKey] ?? [] + ).some( + (message) => + message.messageId !== queuedMessage.messageId && + message.runtimeMode !== undefined && + message.runtimeMode !== settings.runtimeMode, ); + if (!modeChangeQueued) releaseComposerDraftRuntimeMode(threadKey, settings.runtimeMode); if (settings.interactionMode !== thread.interactionMode) { if (!hasAccess()) return true; diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 667d94f50864d..f8a14977acca9 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -203,7 +203,6 @@ import { replaceComposerDraftAttachments, resetComposerDraftsLoadState, restoreComposerDraftSnapshotState, - releaseComposerDraftRuntimeMode, restoreCloudComposerDrafts, retargetNewTaskDraft, setComposerDraftText, @@ -479,16 +478,6 @@ describe("mobile composer drafts", () => { }, ); - it("releases a delivered mode pick but keeps a newer one and the unsent text", () => { - const key = "environment-1:thread-1"; - setComposerDraftText(key, "next message"); - updateComposerDraftSettings(key, { runtimeMode: "approval-required" }); - releaseComposerDraftRuntimeMode(key, "full-access"); - expect(getComposerDraftSnapshot(key).runtimeMode).toBe("approval-required"); - releaseComposerDraftRuntimeMode(key, "approval-required"); - expect(getComposerDraftSnapshot(key)).toEqual({ text: "next message", attachments: [] }); - }); - it("retains a pick matching cached state until the live shell confirms it", () => { const environmentId = EnvironmentId.make("environment-1"); const threadId = ThreadId.make("thread-1"); diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index 9a723f0f6e05e..79e2c4266a614 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -266,6 +266,7 @@ describe("remote thread lifecycle commands", () => { ["setAutoSettle", { enabled: false }, { autoSettleDisabledAt: expect.any(Object) }], ["reorderPin", { orderKey: "b" }, { pinOrderKey: "b" }], ["reorderActive", { orderKey: "b" }, { activeOrderKey: "b" }], + ["setRuntimeMode", {}, { runtimeMode: "approval-required" }], ] as const; it.effect.each(actions)( @@ -301,6 +302,7 @@ describe("remote thread lifecycle commands", () => { enabled: false, orderKey: "a", snoozedUntil: "2099-01-01T00:00:00.000Z", + runtimeMode: "approval-required", ...input, }, }); diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 3761d0fa6e715..315653481913f 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -465,5 +465,9 @@ export function createThreadEnvironmentAtoms( ...thread, modelSelection: input.modelSelection, })), + setRuntimeMode: optimistic.wrap(commands.setRuntimeMode, (thread, input) => ({ + ...thread, + runtimeMode: input.runtimeMode, + })), }; } From 685446d3a8dd8852bc19a1d5f49d863fa1b8ed16 Mon Sep 17 00:00:00 2001 From: Alex Pan <53453555+HelloAlexPan@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:43:47 +1100 Subject: [PATCH 5/6] fix(mobile): wait for saved drafts before releasing a permission pick A pick made before saved drafts finished loading could be released into an empty draft, which deletes it; the load then brought back the older saved draft with its old mode and text. Delivery now waits for drafts to load before releasing, and keeps the pick if loading fails. Co-Authored-By: Claude Opus 5.5 --- .../threadOutboxDelivery.permissions.test.ts | 25 +++++++++++-------- apps/mobile/src/state/threadOutboxDelivery.ts | 9 ++++++- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts index ee58d5cae383c..29c4c3dc711c2 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts @@ -165,14 +165,14 @@ vi.mock("./use-composer-drafts", async (importOriginal) => ({ setFreshSessionPending: vi.fn(), undoComposerDraftMerge: vi.fn(), updateComposerDraftSettings: vi.fn(), - waitForComposerDraftsLoaded: vi.fn(), + waitForComposerDraftsLoaded: vi.fn(async () => {}), })); import { appAtomRegistry } from "./atom-registry"; import { environmentSession } from "./session"; import { dispatchingQueuedMessageIdAtom, editingQueuedMessageIdsAtom } from "./use-thread-outbox"; import { createThreadOutboxDelivery } from "./threadOutboxDelivery"; -import { composerDraftsAtom } from "./use-composer-drafts"; +import { composerDraftsAtom, waitForComposerDraftsLoaded } from "./use-composer-drafts"; const message = (overrides: Partial = {}): QueuedThreadMessage => ({ environmentId: EnvironmentId.make("secondary"), @@ -418,26 +418,28 @@ describe("queued task operation access", () => { }); it.each([ - { queued: ["approval-required"], pick: "approval-required", refused: false, kept: false }, - { queued: ["full-access"], pick: "full-access", refused: false, kept: false }, - { queued: ["approval-required"], pick: "approval-required", refused: true, kept: true }, + { queued: ["approval-required"], pick: "approval-required", failed: null, kept: false }, + { queued: ["full-access"], pick: "full-access", failed: null, kept: false }, + { queued: ["approval-required"], pick: "approval-required", failed: "mode", kept: true }, + // Saved drafts could still load afterwards and bring back the draft the release emptied. + { queued: ["approval-required"], pick: "approval-required", failed: "drafts", kept: true }, // Picked again after the Full access message was queued; that message still moves the thread. { queued: ["approval-required", "full-access"], pick: "approval-required", - refused: false, + failed: null, kept: true, }, // A message without a mode, as an MCP app sends, follows the thread and moves nothing. { queued: ["approval-required", undefined], pick: "approval-required", - refused: false, + failed: null, kept: false, }, ] as const)( - "keeps the composer's mode pick until a delivered send applied it last ($queued, refused: $refused)", - async ({ queued, pick, refused, kept }) => { + "keeps the composer's mode pick until a delivered send applied it last ($queued, failed: $failed)", + async ({ queued, pick, failed, kept }) => { setOperateGrant("secondary", true); const draft = { text: "unsent follow-up", attachments: [] }; appAtomRegistry.set(composerDraftsAtom, { @@ -453,7 +455,10 @@ describe("queued task operation access", () => { }), ); } - if (refused) { + if (failed === "drafts") { + vi.mocked(waitForComposerDraftsLoaded).mockRejectedValueOnce(new Error("Read failed.")); + } + if (failed === "mode") { state.runtime.mockResolvedValueOnce( AsyncResult.failure(Cause.fail(new Error("Mode change refused."))), ); diff --git a/apps/mobile/src/state/threadOutboxDelivery.ts b/apps/mobile/src/state/threadOutboxDelivery.ts index e4c2ea43375da..fcb61916fe39e 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.ts @@ -810,6 +810,11 @@ export function createThreadOutboxDelivery(): { return false; } } + // Loading restores the saved draft for a key this release empties, so it must finish first. + const draftsLoaded = await waitForComposerDraftsLoaded().then( + () => true, + () => false, + ); const threadKey = scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId); // A newer pick can share this mode while another queued message still moves the thread off it. const modeChangeQueued = ( @@ -820,7 +825,9 @@ export function createThreadOutboxDelivery(): { message.runtimeMode !== undefined && message.runtimeMode !== settings.runtimeMode, ); - if (!modeChangeQueued) releaseComposerDraftRuntimeMode(threadKey, settings.runtimeMode); + if (draftsLoaded && !modeChangeQueued) { + releaseComposerDraftRuntimeMode(threadKey, settings.runtimeMode); + } if (settings.interactionMode !== thread.interactionMode) { if (!hasAccess()) return true; From 7616b6d4ebbc53c8383e909d2653d21e0ae0591b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 22:27:05 +0000 Subject: [PATCH 6/6] fix(mobile): wait for pending thread changes before delivering a queued send The optimistic runtime mode could let delivery skip its own mode change while Resume's change was still in flight. The server refuses a mode change that would end background work, so the queued turn then ran in the old mode. Delivery now holds a send while its thread has an unconfirmed optimistic update, then compares against the shell the server confirmed. Co-Authored-By: Claude Opus 5.5 --- .../threadOutboxDelivery.permissions.test.ts | 30 +++++++++++++++++++ apps/mobile/src/state/threadOutboxDelivery.ts | 14 +++++++++ 2 files changed, 44 insertions(+) diff --git a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts index 8bad2b80ccd7b..a92b4023b72be 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts @@ -28,6 +28,7 @@ const state = vi.hoisted(() => ({ modelUnavailable: vi.fn(() => false), familyMount: vi.fn(() => () => {}), threadShells: null as unknown as Atom.Writable>, + pendingThreadIds: null as unknown as Atom.Writable>, manager: null as unknown as ReturnType< typeof import("./thread-outbox-manager").createThreadOutboxManager >, @@ -87,8 +88,11 @@ vi.mock("./server", async () => { vi.mock("./threads", async () => { const { Atom, AsyncResult } = await import("effect/reactivity"); state.threadShells = Atom.make>([state.thread]).pipe(Atom.keepAlive); + state.pendingThreadIds = Atom.make>([]).pipe(Atom.keepAlive); + const pendingThreadIdsAtom = Atom.map(state.pendingThreadIds, (ids) => new Set(ids)); return { threadEnvironment: { + pendingThreadIdsAtom: () => pendingThreadIdsAtom, startTurn: state.start, updateMetadata: state.metadata, setRuntimeMode: state.runtime, @@ -226,6 +230,7 @@ beforeEach(() => { state.familyMount.mockReset().mockReturnValue(() => {}); appAtomRegistry.set(composerDraftsAtom, {}); appAtomRegistry.set(state.threadShells, [state.thread]); + appAtomRegistry.set(state.pendingThreadIds, []); appAtomRegistry.set(state.manager.queuedMessagesByThreadKeyAtom, {}); appAtomRegistry.set(dispatchingQueuedMessageIdAtom, null); appAtomRegistry.set(editingQueuedMessageIdsAtom, {}); @@ -418,6 +423,31 @@ describe("queued task operation access", () => { expect(remaining()).toEqual([queued]); }); + it("waits for a pending mode change to settle before trusting the thread's mode", async () => { + setOperateGrant("secondary", true); + // Resume's mode change is in flight: the shell still holds Full access, as the server does. + appAtomRegistry.set(state.pendingThreadIds, ["thread"]); + const queued = message({ runtimeMode: "approval-required" }); + await state.manager.enqueue(queued); + startDelivery(); + expect(appAtomRegistry.get(dispatchingQueuedMessageIdAtom)).toBeNull(); + expect(state.runtime).not.toHaveBeenCalled(); + + // The server refused it, so delivery applies the mode itself and stops when that is refused too. + state.runtime.mockResolvedValueOnce( + AsyncResult.failure(Cause.fail(new Error("Background work would end."))), + ); + const settled = Promise.withResolvers(); + const release = appAtomRegistry.subscribe(dispatchingQueuedMessageIdAtom, (id) => { + if (id === null) settled.resolve(); + }); + appAtomRegistry.set(state.pendingThreadIds, []); + await settled.promise.finally(release); + expect(state.runtime).toHaveBeenCalledOnce(); + expect(state.start).not.toHaveBeenCalled(); + expect(remaining()).toEqual([queued]); + }); + it.each([ { queued: ["approval-required"], pick: "approval-required", failed: null, kept: false }, { queued: ["full-access"], pick: "full-access", failed: null, kept: false }, diff --git a/apps/mobile/src/state/threadOutboxDelivery.ts b/apps/mobile/src/state/threadOutboxDelivery.ts index aebe0988cbf80..46b95dccc2f56 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.ts @@ -20,6 +20,7 @@ import { managedRuntimeModeDenial, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, type EnvironmentId, + type ThreadId, } from "@t3tools/contracts"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import * as Cause from "effect/Cause"; @@ -593,7 +594,9 @@ async function preserveUploadedAttachmentsForEditor( const deliveryInputsAtom = Atom.make((get) => { const connectedEnvironments = get(workspaceConnections.environmentsAtom); const operableEnvironments = new Set(); + const pendingThreadIds = new Map>(); for (const { environmentId } of connectedEnvironments) { + pendingThreadIds.set(environmentId, get(threadEnvironment.pendingThreadIdsAtom(environmentId))); if ( sessionHasScope( get(environmentSession.sessionStateAtom(environmentId)), @@ -613,6 +616,7 @@ const deliveryInputsAtom = Atom.make((get) => { serverConfigs: get(environmentServerConfigsAtom), connectedEnvironments, operableEnvironments, + pendingThreadIds, }; }).pipe(Atom.withLabel("mobile:thread-outbox:delivery-inputs")); @@ -1210,6 +1214,7 @@ export function createThreadOutboxDelivery(): { serverConfigs, connectedEnvironments, operableEnvironments, + pendingThreadIds, } = appAtomRegistry.get(deliveryInputsAtom); if (dispatchingQueuedMessageId !== null) { return; @@ -1348,6 +1353,15 @@ export function createThreadOutboxDelivery(): { if (deliveryAction === "send" && !operableEnvironments.has(nextQueuedMessage.environmentId)) { continue; } + // An unconfirmed optimistic change, such as Resume's mode, can still be refused; the send + // must compare against what the server holds, or it skips a mode change it still needs. + if ( + deliveryAction === "send" && + creation === undefined && + pendingThreadIds.get(nextQueuedMessage.environmentId)?.has(nextQueuedMessage.threadId) + ) { + continue; + } const dispatchStep = resolveThreadOutboxDispatchStep({ deliveryAction, fileAttachments: nextQueuedMessage.attachments.filter(