diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 80d95752cda1..06e1322f9c4b 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -9,6 +9,7 @@ import { ProviderSession, ProviderDriverKind, ProviderInstanceId, + isMtModelInstanceId, MT_MODEL_INSTANCE_ID, MT_MODEL_SLUG, } from "@t3tools/contracts"; @@ -333,6 +334,18 @@ describe("ProviderCommandReactor", () => { }), getInstanceInfo: (instanceId) => { const raw = String(instanceId); + // The router is virtual: the real registry has no `mt` instance and + // fails the lookup, which is what surfaces as "references unknown + // provider instance 'mt'" in the app. + if (isMtModelInstanceId(instanceId)) { + return Effect.fail( + new ProviderAdapterRequestError({ + provider: "mt", + method: "provider.instance.info", + detail: `Provider instance '${raw}' is not configured in this build.`, + }), + ) as ReturnType; + } const driverKind = ProviderDriverKind.make( raw.startsWith("claude") ? "claudeAgent" : raw.startsWith("codex") ? "codex" : raw, ); @@ -2082,6 +2095,69 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex")); }); + it("starts a turn when an MT Auto thread picks a real model", async () => { + const harness = await createHarness({ + threadModelSelection: { instanceId: MT_MODEL_INSTANCE_ID, model: MT_MODEL_SLUG }, + extraRegistryProviders: [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "unknown" }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [ + { + slug: "gpt-5-codex", + name: "GPT-5 Codex", + isCustom: false, + isDefault: true, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + }, + ], + }); + const now = "2026-01-01T00:00:00.000Z"; + + // The thread's saved selection is the router; the user picks a real model + // for this turn. Resolving the *current* instance used to hand `mt` to the + // registry, which fails with "references unknown provider instance 'mt'". + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-mt-thread-explicit-model"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-mt-explicit-model"), + role: "user", + text: "what is mv2 doing", + attachments: [], + }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect( + thread?.activities.filter((activity) => activity.kind === "provider.turn.start.failed"), + ).toHaveLength(0); + expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex")); + }); + it("reuses the same provider session when runtime mode is unchanged", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index cc1001c7bb6c..75ffeffaafbb 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -432,7 +432,11 @@ const make = Effect.gen(function* () { ...(session ?? { threadId: input.threadId, providerName: null, - providerInstanceId: thread.modelSelection.instanceId, + // `mt` is a router, not a runnable instance. Stamping it here makes + // every later turn fail with "unknown provider instance 'mt'". + ...(isMtModelInstanceId(thread.modelSelection.instanceId) + ? {} + : { providerInstanceId: thread.modelSelection.instanceId }), runtimeMode: thread.runtimeMode, }), status: session?.status === "stopped" ? "stopped" : "error", @@ -567,10 +571,16 @@ const make = Effect.gen(function* () { : (thread.session?.providerInstanceId ?? mtDecision?.instanceId ?? thread.modelSelection.instanceId); - const currentInstanceId = - isMtModelInstanceId(rawCurrentInstanceId) && mtDecision - ? mtDecision.instanceId - : rawCurrentInstanceId; + // A thread can carry the router id from an earlier MT Auto turn. It never + // names a runnable instance, so resolve it to this turn's routed backend, + // or - when the user has since picked a real model - to that pick. + const currentInstanceId = !isMtModelInstanceId(rawCurrentInstanceId) + ? rawCurrentInstanceId + : (mtDecision?.instanceId ?? + (requestedModelSelection !== undefined && + !isMtModelInstanceId(requestedModelSelection.instanceId) + ? requestedModelSelection.instanceId + : rawCurrentInstanceId)); const desiredModelSelection = mtDecision ? { instanceId: mtDecision.instanceId, model: mtDecision.model } : (requestedModelSelection ?? thread.modelSelection); @@ -1347,7 +1357,9 @@ const make = Effect.gen(function* () { ...(thread.session ?? { threadId: thread.id, providerName: null, - providerInstanceId: thread.modelSelection.instanceId, + ...(isMtModelInstanceId(thread.modelSelection.instanceId) + ? {} + : { providerInstanceId: thread.modelSelection.instanceId }), runtimeMode: thread.runtimeMode, }), status: "error", diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e3c02d356395..9289fe99611c 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -811,8 +811,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const { presentation: composerEnvironmentPresentation } = useEnvironmentPresentation( _activeThreadEnvironmentId ?? environmentId, ); + // Absent (not just `false`) means an older server that rejects the `mt` + // instance outright, so MT Auto is only offered once an environment has + // answered that it can route. While the config is still loading nothing is + // known yet, and withholding the entry there would yank a sticky MT + // selection out of the picker on every reconnect. + const composerServerConfig = composerEnvironmentPresentation?.serverConfig ?? null; const serverCanRoute = - composerEnvironmentPresentation?.serverConfig?.environment.capabilities.modelRouting !== false; + composerServerConfig === null + ? true + : composerServerConfig.environment.capabilities.modelRouting === true; const providerInstanceEntries = useMemo>( () => prependMtModelPickerEntry( @@ -878,7 +886,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) !lockedProvider && activeThreadModelSelection != null && isMtModelSelection(activeThreadModelSelection) && - (composerDraft.activeProvider === undefined || + // An untouched draft carries `null`, not `undefined` — treating only + // `undefined` as "no explicit pick" let the routed backend take the + // picker over as soon as any draft existed for the thread. + (composerDraft.activeProvider == null || composerDraft.activeProvider === activeThreadModelSelection.instanceId) ) { return activeThreadModelSelection.instanceId;