From 70dd0b4249620503b60df153bd77994fa4cd2939 Mon Sep 17 00:00:00 2001 From: sheehanmunim Date: Wed, 19 Aug 2026 16:17:30 -0400 Subject: [PATCH] fix(orchestration): keep MT Auto selected and routable mid-thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking a real model in a thread whose saved selection is MT Auto failed the turn with "Thread '…' references unknown provider instance 'mt'", and the composer then showed the backend instead of MT Auto. - The router id is virtual: no registry entry answers for `mt`. Resolving the turn's *current* instance handed it straight to the registry whenever the thread's saved selection was MT Auto and this turn picked something real. It now resolves to the routed backend, or to the model the user just picked. - Two session-error paths stamped `providerInstanceId` from the thread's saved selection, which for an MT Auto thread is the router id itself — poisoning every later turn. They now leave it unset rather than write a router id. - The composer treated only `undefined` as "no explicit pick", but an untouched draft carries `null`, so the routed backend took the picker over as soon as any draft existed for the thread. MT Auto stays selected; routing stays a per-turn decision, so it can still switch models mid-thread. - MT Auto is now only offered to an environment that *says* it can route. Absent `modelRouting` means an older server that rejects `mt` outright; the entry stays until the environment's config arrives, so a reconnect no longer yanks a sticky MT selection out of the picker. The reactor test harness resolved every instance id, including `mt`, so none of this could fail in tests. It now rejects the router id the way the real registry does. Orchestration suite: 440 passing. Model: Claude Opus 5, harness: Claude Code --- .../Layers/ProviderCommandReactor.test.ts | 76 +++++++++++++++++++ .../Layers/ProviderCommandReactor.ts | 24 ++++-- apps/web/src/components/chat/ChatComposer.tsx | 15 +++- 3 files changed, 107 insertions(+), 8 deletions(-) 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;