diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 84de2bf777cfb..235b1968c8e77 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -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"; @@ -201,6 +202,7 @@ const appServiceDisposers = [ threadShellAtom: environmentThreadShells.threadShellAtom, threadRefsAtom: environmentThreadShells.threadRefsAtom, shellStateAtom: environmentShell.stateAtom, + providersAtom: serverEnvironment.providersValueAtom, pendingThreadIdsAtom: threadEnvironment.pendingThreadIdsAtom, }), installAppearanceRuntime(), diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index adb8eff775bdd..9a2faf6b355df 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -315,6 +315,7 @@ function modelReconciliationHarness( ]), }); const noPendingThreadIdsAtom = Atom.make((): ReadonlySet => new Set()); + const noProvidersAtom = Atom.make(null); const shells = createEnvironmentThreadShellAtoms({ catalogValueAtom, snapshotAtom: () => snapshotAtom, @@ -329,6 +330,7 @@ function modelReconciliationHarness( threadRefsAtom: shells.threadRefsAtom, shellStateAtom: () => shellResultAtom, pendingThreadIdsAtom: () => noPendingThreadIdsAtom, + providersAtom: () => noProvidersAtom, }), ); flush(); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 643d348093208..441c8ec0925f9 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -19,6 +19,7 @@ import { type ProviderOptionSelection, type RuntimeMode, type ScopedThreadRef, + type ServerProvider, type ThreadId, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; @@ -1629,6 +1630,9 @@ export function subscribeComposerDraftModelReconciliation(input: { readonly shellStateAtom: ( environmentId: EnvironmentId, ) => Atom.Atom>; + readonly providersAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom | null>; readonly pendingThreadIdsAtom: (environmentId: EnvironmentId) => Atom.Atom>; }): () => void { const draftsAtom = Atom.make(appAtomRegistry.get(composerDraftsAtom)); @@ -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; @@ -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]); } diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx index faa71137db049..84ae7bdca6629 100644 --- a/apps/web/src/appInit.tsx +++ b/apps/web/src/appInit.tsx @@ -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"; @@ -286,6 +286,7 @@ export function startAppServices(): AppRouter { threadShellAtom: environmentThreadShells.threadShellAtom, threadRefsAtom: environmentThreadShells.threadRefsAtom, shellStateAtom: environmentShell.stateAtom, + providersAtom: providersIgnoringCheckedAtAtom, pendingThreadIdsAtom: threadEnvironment.pendingThreadIdsAtom, }); startMainAppLocationTracking(router); diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 43d61924a8c74..02f0ec097e3b8 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1380,6 +1380,7 @@ function modelReconciliationHarness( ), }; }); + const noProvidersAtom = Atom.make(null); const pendingThreadIdsAtom = Atom.map( inFlightPickAtom, (pick): ReadonlySet => new Set(pick ? [threadId] : []), @@ -1398,6 +1399,7 @@ function modelReconciliationHarness( threadRefsAtom: shells.threadRefsAtom, shellStateAtom: () => shellResultAtom, pendingThreadIdsAtom: () => pendingThreadIdsAtom, + providersAtom: () => noProvidersAtom, }), ); flush(); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 0bf2b690b4696..2b4dc93915c34 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -4454,6 +4454,9 @@ export function subscribeComposerDraftModelReconciliation(input: { readonly shellStateAtom: ( environmentId: EnvironmentId, ) => Atom.Atom>; + readonly providersAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom | null>; readonly pendingThreadIdsAtom: (environmentId: EnvironmentId) => Atom.Atom>; }): () => void { const draftsAtom = Atom.make(composerDraftStore.getState()); @@ -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. @@ -4486,7 +4490,7 @@ export function subscribeComposerDraftModelReconciliation(input: { ); if ( authoritativeThread && - draftModelPickMatchesThread(pick, authoritativeThread.modelSelection) + draftModelPickMatchesThread(pick, authoritativeThread.modelSelection, providers) ) { matches.push([key, draft]); } diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 3bb7bf4a1a885..b8375e5a67f48 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -1,5 +1,10 @@ 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, @@ -7,6 +12,7 @@ import { buildProviderOptionSelectionsFromDescriptors, createModelCapabilities, createModelSelection, + draftModelPickMatchesThread, getProviderOptionCurrentLabel, getProviderOptionDescriptors, readCustomModelEntries, @@ -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 = [ + { + 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", [ diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index babe1347cc056..c86ca60fe672e 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -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"; @@ -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 | 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. */