Skip to content
2 changes: 2 additions & 0 deletions apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
import { RootStack, rootPathnameFromState, workspaceLocationFromState } from "./Stack";
import { appAtomRegistry } from "./state/atom-registry";
import { subscribeComposerDraftModelReconciliation } from "./state/use-composer-drafts";
import { serverEnvironment } from "./state/server";
import { environmentThreadShells, threadEnvironment } from "./state/threads";
import { environmentShell, shellCachesReadAtom } from "./state/shell";
import { OverlayPortalHost } from "./components/ui/OverlayPortal";
Expand Down Expand Up @@ -201,6 +202,7 @@ const appServiceDisposers = [
threadShellAtom: environmentThreadShells.threadShellAtom,
threadRefsAtom: environmentThreadShells.threadRefsAtom,
shellStateAtom: environmentShell.stateAtom,
providersAtom: serverEnvironment.providersValueAtom,
pendingThreadIdsAtom: threadEnvironment.pendingThreadIdsAtom,
}),
installAppearanceRuntime(),
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/state/use-composer-drafts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ function modelReconciliationHarness(
]),
});
const noPendingThreadIdsAtom = Atom.make((): ReadonlySet<ThreadId> => new Set());
const noProvidersAtom = Atom.make(null);
const shells = createEnvironmentThreadShellAtoms({
catalogValueAtom,
snapshotAtom: () => snapshotAtom,
Expand All @@ -329,6 +330,7 @@ function modelReconciliationHarness(
threadRefsAtom: shells.threadRefsAtom,
shellStateAtom: () => shellResultAtom,
pendingThreadIdsAtom: () => noPendingThreadIdsAtom,
providersAtom: () => noProvidersAtom,
}),
);
flush();
Expand Down
16 changes: 14 additions & 2 deletions apps/mobile/src/state/use-composer-drafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type ProviderOptionSelection,
type RuntimeMode,
type ScopedThreadRef,
type ServerProvider,
type ThreadId,
} from "@t3tools/contracts";
import * as Schema from "effect/Schema";
Expand Down Expand Up @@ -1629,6 +1630,9 @@ export function subscribeComposerDraftModelReconciliation(input: {
readonly shellStateAtom: (
environmentId: EnvironmentId,
) => Atom.Atom<AsyncResult.AsyncResult<EnvironmentShellState, unknown>>;
readonly providersAtom: (
environmentId: EnvironmentId,
) => Atom.Atom<ReadonlyArray<ServerProvider> | null>;
readonly pendingThreadIdsAtom: (environmentId: EnvironmentId) => Atom.Atom<ReadonlySet<ThreadId>>;
}): () => void {
const draftsAtom = Atom.make(appAtomRegistry.get(composerDraftsAtom));
Expand All @@ -1647,7 +1651,11 @@ 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))
const providers = get(input.providersAtom(ref.environmentId));
if (
!thread ||
!draftModelPickMatchesThread(draft.modelSelection, thread.modelSelection, providers)
)
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;
Expand All @@ -1659,7 +1667,11 @@ export function subscribeComposerDraftModelReconciliation(input: {
);
if (
authoritativeThread &&
draftModelPickMatchesThread(draft.modelSelection, authoritativeThread.modelSelection)
draftModelPickMatchesThread(
draft.modelSelection,
authoritativeThread.modelSelection,
providers,
)
) {
matches.push([key, draft]);
}
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/appInit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ import { startProviderAuthDelivery } from "./providerAuthDelivery";
import { getRouter, type AppRouter } from "./router";
import { appAtomRegistry } from "./rpc/atomRegistry";
import { environmentProjects } from "./state/projects";
import { primaryServerConfigAtom } from "./state/server";
import { primaryServerConfigAtom, providersIgnoringCheckedAtAtom } from "./state/server";
import { environmentShell } from "./state/shell";
import { installChatRouteCommands } from "./shortcuts/chatRouteCommands";
import { startWorkspaceFocus } from "./lib/workspaceFocus";
Expand Down Expand Up @@ -286,6 +286,7 @@ export function startAppServices(): AppRouter {
threadShellAtom: environmentThreadShells.threadShellAtom,
threadRefsAtom: environmentThreadShells.threadRefsAtom,
shellStateAtom: environmentShell.stateAtom,
providersAtom: providersIgnoringCheckedAtAtom,
pendingThreadIdsAtom: threadEnvironment.pendingThreadIdsAtom,
});
startMainAppLocationTracking(router);
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/composerDraftStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1380,6 +1380,7 @@ function modelReconciliationHarness(
),
};
});
const noProvidersAtom = Atom.make(null);
const pendingThreadIdsAtom = Atom.map(
inFlightPickAtom,
(pick): ReadonlySet<ThreadId> => new Set(pick ? [threadId] : []),
Expand All @@ -1398,6 +1399,7 @@ function modelReconciliationHarness(
threadRefsAtom: shells.threadRefsAtom,
shellStateAtom: () => shellResultAtom,
pendingThreadIdsAtom: () => pendingThreadIdsAtom,
providersAtom: () => noProvidersAtom,
}),
);
flush();
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/composerDraftStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4454,6 +4454,9 @@ export function subscribeComposerDraftModelReconciliation(input: {
readonly shellStateAtom: (
environmentId: EnvironmentId,
) => Atom.Atom<AsyncResult.AsyncResult<EnvironmentShellState, unknown>>;
readonly providersAtom: (
environmentId: EnvironmentId,
) => Atom.Atom<ReadonlyArray<ServerProvider> | null>;
readonly pendingThreadIdsAtom: (environmentId: EnvironmentId) => Atom.Atom<ReadonlySet<ThreadId>>;
}): () => void {
const draftsAtom = Atom.make(composerDraftStore.getState());
Expand All @@ -4475,7 +4478,8 @@ export function subscribeComposerDraftModelReconciliation(input: {
const pick = draft.modelSelectionByProvider[draft.activeProvider];
if (!ref || !pick || !threadKeys.has(key)) continue;
const thread = get(input.threadShellAtom(ref));
if (!thread || !draftModelPickMatchesThread(pick, thread.modelSelection)) continue;
const providers = get(input.providersAtom(ref.environmentId));
if (!thread || !draftModelPickMatchesThread(pick, thread.modelSelection, providers)) 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.
Expand All @@ -4486,7 +4490,7 @@ export function subscribeComposerDraftModelReconciliation(input: {
);
if (
authoritativeThread &&
draftModelPickMatchesThread(pick, authoritativeThread.modelSelection)
draftModelPickMatchesThread(pick, authoritativeThread.modelSelection, providers)
) {
matches.push([key, draft]);
}
Expand Down
106 changes: 105 additions & 1 deletion packages/shared/src/model.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { describe, expect, it } from "vite-plus/test";
import { ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts";
import {
ProviderDriverKind,
ProviderInstanceId,
type ModelCapabilities,
type ServerProvider,
} from "@t3tools/contracts";

import {
applyClaudePromptEffortPrefix,
buildExplicitProviderOptionSelectionsFromDescriptors,
buildProviderOptionSelectionsFromDescriptors,
createModelCapabilities,
createModelSelection,
draftModelPickMatchesThread,
getProviderOptionCurrentLabel,
getProviderOptionDescriptors,
readCustomModelEntries,
Expand Down Expand Up @@ -101,6 +107,104 @@ describe("descriptor helpers", () => {
expect(modelSelectionsEqual(left, { ...reordered, model: "gpt-5.5" })).toBe(false);
});

it("ignores options the pick's model does not advertise only once the catalog knows it", () => {
const instanceId = ProviderInstanceId.make("codex");
const pick = createModelSelection(instanceId, "gpt-5.4", [
{ id: "reasoningEffort", value: "high" },
{ id: "contextWindow", value: "1m" },
]);
const thread = createModelSelection(instanceId, "gpt-5.4", [
{ id: "reasoningEffort", value: "high" },
]);
const providers: ReadonlyArray<ServerProvider> = [
{
instanceId,
driver: ProviderDriverKind.make("codex"),
enabled: true,
installed: true,
version: null,
status: "ready",
auth: { status: "authenticated" },
checkedAt: "2026-01-01T00:00:00.000Z",
models: [
{ slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, capabilities: codexCaps },
{
slug: "ultracode-model",
name: "Ultracode model",
isCustom: false,
capabilities: createModelCapabilities({
optionDescriptors: [
{
id: "effort",
label: "Reasoning",
type: "select",
options: [
{ id: "high", label: "High", isDefault: true },
{ id: "xhigh", label: "Extra High" },
],
},
{ id: "ultracode", label: "Ultracode", type: "boolean" },
],
}),
},
],
slashCommands: [],
skills: [],
},
];

expect(draftModelPickMatchesThread(pick, thread, providers)).toBe(true);
expect(draftModelPickMatchesThread(pick, thread, [])).toBe(false);
expect(draftModelPickMatchesThread(pick, thread, null)).toBe(false);
const workspaceProviders = providers.map((provider) => ({
...provider,
workspaceSnapshots: [
{
cwd: "/repo",
checkedAt: "2026-01-01T00:00:00.000Z",
slashCommands: [],
skills: [],
models: [
{
slug: "gpt-5.4",
name: "GPT-5.4",
isCustom: false,
capabilities: createModelCapabilities({
optionDescriptors: [
...(codexCaps.optionDescriptors ?? []),
{
id: "contextWindow",
label: "Context",
type: "select" as const,
options: [{ id: "1m", label: "1M" }],
},
],
}),
},
],
},
],
}));
expect(draftModelPickMatchesThread(pick, thread, workspaceProviders)).toBe(false);
const fastPick = createModelSelection(instanceId, "gpt-5.4", [
{ id: "reasoningEffort", value: "high" },
{ id: "fastMode", value: true },
]);
expect(draftModelPickMatchesThread(fastPick, thread, providers)).toBe(false);
const staleValuePick = createModelSelection(instanceId, "gpt-5.4", [
{ id: "reasoningEffort", value: "max" },
]);
expect(draftModelPickMatchesThread(staleValuePick, thread, providers)).toBe(true);
const legacyUltracodePick = createModelSelection(instanceId, "ultracode-model", [
{ id: "effort", value: "ultracode" },
]);
const ultracodeThread = createModelSelection(instanceId, "ultracode-model", [
{ id: "effort", value: "xhigh" },
{ id: "ultracode", value: true },
]);
expect(draftModelPickMatchesThread(legacyUltracodePick, ultracodeThread, providers)).toBe(true);
});

it("keeps the options when the current model is picked again", () => {
const codex = ProviderInstanceId.make("codex");
const current = createModelSelection(codex, "gpt-5.4", [
Expand Down
32 changes: 28 additions & 4 deletions packages/shared/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ProviderInstanceId,
type ProviderOptionDescriptor,
type ProviderOptionSelection,
type ServerProvider,
} from "@t3tools/contracts";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
Expand Down Expand Up @@ -119,15 +120,38 @@ export function modelSelectionsEqual(left: ModelSelection, right: ModelSelection
/**
* Whether a composer's draft model pick is what the thread already runs. Web sends
* `fastMode: false` for models with a fast toggle while mobile omits it, so off counts as unset.
* Once the catalog knows the model, both sides are compared as the composer would send them,
* so unadvertised options and stale values do not count; without it they must match exactly.
*/
export function draftModelPickMatchesThread(pick: ModelSelection, thread: ModelSelection): boolean {
const withoutFastModeOff = (selection: ModelSelection) =>
export function draftModelPickMatchesThread(
pick: ModelSelection,
thread: ModelSelection,
providers: ReadonlyArray<ServerProvider> | null | undefined,
): boolean {
const provider = providers?.find((candidate) => candidate.instanceId === pick.instanceId);
// A workspace can advertise its own models, so the machine catalog may not match the thread's cwd.
const workspaceOverridesModels = provider?.workspaceSnapshots?.some(
(snapshot) => snapshot.models !== undefined || snapshot.optionDescriptors !== undefined,
);
const caps = workspaceOverridesModels
? undefined
: provider?.models.find((model) => model.slug === pick.model)?.capabilities;
const asSent = (options: ModelSelection["options"]) =>
caps?.optionDescriptors
? buildExplicitProviderOptionSelectionsFromDescriptors(
getProviderOptionDescriptors({ caps, selections: options }),
options,
)
: options;
const comparable = (selection: ModelSelection) =>
createModelSelection(
selection.instanceId,
selection.model,
selection.options?.filter((option) => option.id !== "fastMode" || option.value !== false),
asSent(selection.options)?.filter(
(option) => option.id !== "fastMode" || option.value !== false,
),
);
return modelSelectionsEqual(withoutFastModeOff(pick), withoutFastModeOff(thread));
return modelSelectionsEqual(comparable(pick), comparable(thread));
}

/** Cursor ACP can default Fast to true; T3 requires a user selection. */
Expand Down
Loading