Skip to content

Commit acc0178

Browse files
authored
Merge pull request #19 from munimtechnologies/fix/mt-auto-sticky-picker
fix(orchestration): keep MT Auto selected and routable mid-thread
2 parents e421c25 + 70dd0b4 commit acc0178

3 files changed

Lines changed: 107 additions & 8 deletions

File tree

‎apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
ProviderSession,
1010
ProviderDriverKind,
1111
ProviderInstanceId,
12+
isMtModelInstanceId,
1213
MT_MODEL_INSTANCE_ID,
1314
MT_MODEL_SLUG,
1415
} from "@t3tools/contracts";
@@ -333,6 +334,18 @@ describe("ProviderCommandReactor", () => {
333334
}),
334335
getInstanceInfo: (instanceId) => {
335336
const raw = String(instanceId);
337+
// The router is virtual: the real registry has no `mt` instance and
338+
// fails the lookup, which is what surfaces as "references unknown
339+
// provider instance 'mt'" in the app.
340+
if (isMtModelInstanceId(instanceId)) {
341+
return Effect.fail(
342+
new ProviderAdapterRequestError({
343+
provider: "mt",
344+
method: "provider.instance.info",
345+
detail: `Provider instance '${raw}' is not configured in this build.`,
346+
}),
347+
) as ReturnType<ProviderServiceShape["getInstanceInfo"]>;
348+
}
336349
const driverKind = ProviderDriverKind.make(
337350
raw.startsWith("claude") ? "claudeAgent" : raw.startsWith("codex") ? "codex" : raw,
338351
);
@@ -2082,6 +2095,69 @@ describe("ProviderCommandReactor", () => {
20822095
expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex"));
20832096
});
20842097

2098+
it("starts a turn when an MT Auto thread picks a real model", async () => {
2099+
const harness = await createHarness({
2100+
threadModelSelection: { instanceId: MT_MODEL_INSTANCE_ID, model: MT_MODEL_SLUG },
2101+
extraRegistryProviders: [
2102+
{
2103+
instanceId: ProviderInstanceId.make("codex"),
2104+
driver: ProviderDriverKind.make("codex"),
2105+
enabled: true,
2106+
installed: true,
2107+
version: null,
2108+
status: "ready",
2109+
auth: { status: "unknown" },
2110+
checkedAt: "2026-01-01T00:00:00.000Z",
2111+
models: [
2112+
{
2113+
slug: "gpt-5-codex",
2114+
name: "GPT-5 Codex",
2115+
isCustom: false,
2116+
isDefault: true,
2117+
capabilities: null,
2118+
},
2119+
],
2120+
slashCommands: [],
2121+
skills: [],
2122+
},
2123+
],
2124+
});
2125+
const now = "2026-01-01T00:00:00.000Z";
2126+
2127+
// The thread's saved selection is the router; the user picks a real model
2128+
// for this turn. Resolving the *current* instance used to hand `mt` to the
2129+
// registry, which fails with "references unknown provider instance 'mt'".
2130+
await Effect.runPromise(
2131+
harness.engine.dispatch({
2132+
type: "thread.turn.start",
2133+
commandId: CommandId.make("cmd-mt-thread-explicit-model"),
2134+
threadId: ThreadId.make("thread-1"),
2135+
message: {
2136+
messageId: asMessageId("user-message-mt-explicit-model"),
2137+
role: "user",
2138+
text: "what is mv2 doing",
2139+
attachments: [],
2140+
},
2141+
modelSelection: {
2142+
instanceId: ProviderInstanceId.make("codex"),
2143+
model: "gpt-5-codex",
2144+
},
2145+
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
2146+
runtimeMode: "approval-required",
2147+
createdAt: now,
2148+
}),
2149+
);
2150+
2151+
await waitFor(() => harness.sendTurn.mock.calls.length === 1);
2152+
2153+
const readModel = await harness.readModel();
2154+
const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
2155+
expect(
2156+
thread?.activities.filter((activity) => activity.kind === "provider.turn.start.failed"),
2157+
).toHaveLength(0);
2158+
expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex"));
2159+
});
2160+
20852161
it("reuses the same provider session when runtime mode is unchanged", async () => {
20862162
const harness = await createHarness();
20872163
const now = "2026-01-01T00:00:00.000Z";

‎apps/server/src/orchestration/Layers/ProviderCommandReactor.ts‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,11 @@ const make = Effect.gen(function* () {
432432
...(session ?? {
433433
threadId: input.threadId,
434434
providerName: null,
435-
providerInstanceId: thread.modelSelection.instanceId,
435+
// `mt` is a router, not a runnable instance. Stamping it here makes
436+
// every later turn fail with "unknown provider instance 'mt'".
437+
...(isMtModelInstanceId(thread.modelSelection.instanceId)
438+
? {}
439+
: { providerInstanceId: thread.modelSelection.instanceId }),
436440
runtimeMode: thread.runtimeMode,
437441
}),
438442
status: session?.status === "stopped" ? "stopped" : "error",
@@ -567,10 +571,16 @@ const make = Effect.gen(function* () {
567571
: (thread.session?.providerInstanceId ??
568572
mtDecision?.instanceId ??
569573
thread.modelSelection.instanceId);
570-
const currentInstanceId =
571-
isMtModelInstanceId(rawCurrentInstanceId) && mtDecision
572-
? mtDecision.instanceId
573-
: rawCurrentInstanceId;
574+
// A thread can carry the router id from an earlier MT Auto turn. It never
575+
// names a runnable instance, so resolve it to this turn's routed backend,
576+
// or - when the user has since picked a real model - to that pick.
577+
const currentInstanceId = !isMtModelInstanceId(rawCurrentInstanceId)
578+
? rawCurrentInstanceId
579+
: (mtDecision?.instanceId ??
580+
(requestedModelSelection !== undefined &&
581+
!isMtModelInstanceId(requestedModelSelection.instanceId)
582+
? requestedModelSelection.instanceId
583+
: rawCurrentInstanceId));
574584
const desiredModelSelection = mtDecision
575585
? { instanceId: mtDecision.instanceId, model: mtDecision.model }
576586
: (requestedModelSelection ?? thread.modelSelection);
@@ -1347,7 +1357,9 @@ const make = Effect.gen(function* () {
13471357
...(thread.session ?? {
13481358
threadId: thread.id,
13491359
providerName: null,
1350-
providerInstanceId: thread.modelSelection.instanceId,
1360+
...(isMtModelInstanceId(thread.modelSelection.instanceId)
1361+
? {}
1362+
: { providerInstanceId: thread.modelSelection.instanceId }),
13511363
runtimeMode: thread.runtimeMode,
13521364
}),
13531365
status: "error",

‎apps/web/src/components/chat/ChatComposer.tsx‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -811,8 +811,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
811811
const { presentation: composerEnvironmentPresentation } = useEnvironmentPresentation(
812812
_activeThreadEnvironmentId ?? environmentId,
813813
);
814+
// Absent (not just `false`) means an older server that rejects the `mt`
815+
// instance outright, so MT Auto is only offered once an environment has
816+
// answered that it can route. While the config is still loading nothing is
817+
// known yet, and withholding the entry there would yank a sticky MT
818+
// selection out of the picker on every reconnect.
819+
const composerServerConfig = composerEnvironmentPresentation?.serverConfig ?? null;
814820
const serverCanRoute =
815-
composerEnvironmentPresentation?.serverConfig?.environment.capabilities.modelRouting !== false;
821+
composerServerConfig === null
822+
? true
823+
: composerServerConfig.environment.capabilities.modelRouting === true;
816824
const providerInstanceEntries = useMemo<ReadonlyArray<ProviderInstanceEntry>>(
817825
() =>
818826
prependMtModelPickerEntry(
@@ -878,7 +886,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
878886
!lockedProvider &&
879887
activeThreadModelSelection != null &&
880888
isMtModelSelection(activeThreadModelSelection) &&
881-
(composerDraft.activeProvider === undefined ||
889+
// An untouched draft carries `null`, not `undefined` — treating only
890+
// `undefined` as "no explicit pick" let the routed backend take the
891+
// picker over as soon as any draft existed for the thread.
892+
(composerDraft.activeProvider == null ||
882893
composerDraft.activeProvider === activeThreadModelSelection.instanceId)
883894
) {
884895
return activeThreadModelSelection.instanceId;

0 commit comments

Comments
 (0)