From 2410f9b6d4717ef721aebaec5d9317d2aa9c8e57 Mon Sep 17 00:00:00 2001 From: Guillermo Casanova Date: Wed, 28 Oct 2026 16:59:50 -0300 Subject: [PATCH] fix(server): t3_thread_launch refuses models the provider does not advertise --- apps/server/src/mcp/linkOrigin.test.ts | 11 ++- .../src/mcp/toolkits/project/handlers.test.ts | 87 +++++++++++++++++-- .../src/mcp/toolkits/project/handlers.ts | 20 +++++ apps/server/src/mcp/toolkits/project/tools.ts | 2 + apps/server/src/peer/PeerForwarding.test.ts | 13 ++- apps/server/src/peer/RemoteDelegation.test.ts | 13 ++- 6 files changed, 136 insertions(+), 10 deletions(-) diff --git a/apps/server/src/mcp/linkOrigin.test.ts b/apps/server/src/mcp/linkOrigin.test.ts index c17f4ba579dd6..d3a03695ddf8e 100644 --- a/apps/server/src/mcp/linkOrigin.test.ts +++ b/apps/server/src/mcp/linkOrigin.test.ts @@ -122,7 +122,16 @@ const layerTools = Layer.mergeAll( Layer.provide(layerLaunches), Layer.provide(NodeCrypto.layer), Layer.provide( - Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([ + { + instanceId, + enabled: true, + status: "ready", + models: [{ slug: "gpt-5", name: "GPT-5" }], + } as never, + ]), + }), ), Layer.provide( Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 4031654a8c826..0c1b65596ee8b 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -7,6 +7,7 @@ import { type ModelSelection, type RuntimeMode, type OrchestrationV2ThreadShell, + type ServerProvider, } from "@t3tools/contracts"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -27,6 +28,7 @@ import * as ServerSettings from "../../../serverSettings.ts"; import * as Project from "../../../project/ProjectService.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; import * as PeerForwarding from "../../../peer/PeerForwarding.ts"; +import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../../../vcs/VcsProcess.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -52,6 +54,28 @@ const clientLaunchHarness = (input: { const projectDefaults: { model: ModelSelection | null } = { model: input.overrideModel === undefined ? modelSelection : null, }; + // The registry's providers, which a test can change between launches. + const providers: Array = [ + { + instanceId: ProviderInstanceId.make("claudeAgent"), + enabled: true, + status: "ready", + models: [{ slug: "claude-opus", name: "Claude Opus" }], + } as never, + { + instanceId: ProviderInstanceId.make("codex"), + enabled: true, + status: "ready", + models: [{ slug: "gpt-settings", name: "GPT Settings" }], + } as never, + // A provider that has not listed its models yet accepts any slug. + { + instanceId: ProviderInstanceId.make("opencode"), + enabled: true, + status: "ready", + models: [], + } as never, + ]; const layerDependencies = Layer.mergeAll( ServerSettings.layerTest({ ...(input.defaultRuntimeMode === undefined @@ -127,6 +151,9 @@ const clientLaunchHarness = (input: { ), }), Layer.mock(PeerForwarding.PeerForwarding)({}), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.sync(() => providers), + }), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }), DocumentText.layer.pipe(Layer.provideMerge(NodeServices.layer)), ).pipe( @@ -139,11 +166,11 @@ const clientLaunchHarness = (input: { ), Layer.provideMerge(NodeServices.layer), ); - return { projectId, modelSelection, projectDefaults, dependencies: layerDependencies }; + return { projectId, modelSelection, projectDefaults, providers, dependencies: layerDependencies }; }; it.effect( - "a client launches with the project's default model and mode, capped at its ceiling", + "a client launches with the project's default model and mode, capped at its ceiling, or a model its provider advertises", () => Effect.gen(function* () { const launchWith = Effect.fn("test.launchWith")(function* ( @@ -155,6 +182,7 @@ it.effect( readonly runtimeMode?: RuntimeMode; readonly targeted?: false; readonly overrideModel?: ModelSelection; + readonly modelSelection?: ModelSelection; }, ) { const { targeted, overrideModel, ...input } = params; @@ -221,6 +249,36 @@ it.effect( overrideModel, }); expect(fromSettings.launched[0]?.modelSelection).toEqual(overrideModel); + + const advertised = yield* launchWith("full-access", "full-access", { + title: "Fix", + modelSelection: overrideModel, + }); + expect(advertised.launched[0]?.modelSelection).toEqual(overrideModel); + + const unadvertised = yield* launchWith("full-access", "full-access", { + title: "Fix", + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "gpt-6-astra", + }, + }); + expect(unadvertised.result).toMatchObject({ code: "model_unavailable" }); + expect(unadvertised.launched).toHaveLength(0); + + const unregistered = yield* launchWith("full-access", "full-access", { + title: "Fix", + modelSelection: { instanceId: ProviderInstanceId.make("missing"), model: "claude-opus" }, + }); + expect(unregistered.result).toMatchObject({ code: "provider_unavailable" }); + expect(unregistered.launched).toHaveLength(0); + + const unlisted = { instanceId: ProviderInstanceId.make("opencode"), model: "any-model" }; + const fromUnlisted = yield* launchWith("full-access", "full-access", { + title: "Fix", + modelSelection: unlisted, + }); + expect(fromUnlisted.launched[0]?.modelSelection).toEqual(unlisted); }), ); @@ -291,13 +349,14 @@ it.effect("a launch key of any length or content gives a short id and never a de }), ); -it.effect("a retried launch replays after the project loses its default model", () => +it.effect("a retried launch replays after its project or provider drops the model", () => Effect.gen(function* () { const launched: Array = []; - const { projectId, modelSelection, projectDefaults, dependencies } = clientLaunchHarness({ - runtimeModeCeiling: "auto", - launched, - }); + const { projectId, modelSelection, projectDefaults, providers, dependencies } = + clientLaunchHarness({ + runtimeModeCeiling: "auto", + launched, + }); const toolkit = yield* ProjectToolkit.pipe( Effect.provide( McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), @@ -315,6 +374,20 @@ it.effect("a retried launch replays after the project loses its default model", launched[0]?.threadId, launched[0]?.threadId, ]); + + // An accepted launch replays even once its provider no longer lists the model. + const requested = toolkit + .handle("t3_thread_launch", { + title: "Fix", + projectId, + clientRequestId: "fix-2", + modelSelection, + }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + yield* requested; + providers.length = 0; + expect((yield* requested).at(-1)?.result).toMatchObject({ projectId, modelSelection }); + expect(launched[3]?.threadId).toBe(launched[2]?.threadId); }), ); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 950cb38c6c4fd..1de435fe993c0 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -15,6 +15,7 @@ import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageInt import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; import * as Project from "../../../project/ProjectService.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; +import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts"; import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; import * as ServerSettings from "../../../serverSettings.ts"; @@ -133,6 +134,25 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { const launchedModel = launched === null || launched.deletedAt !== null ? undefined : launched.modelSelection; const replaying = launchedModel !== undefined; + if (!replaying && input.modelSelection !== undefined) { + const { instanceId, model } = input.modelSelection; + const registry = yield* ProviderRegistry.ProviderRegistry; + const providers = yield* registry.getProviders; + const provider = providers.find((candidate) => candidate.instanceId === instanceId); + if (provider === undefined) + return yield* new OrchestratorMcpFailure({ + code: "provider_unavailable", + message: `Provider instance ${instanceId} is not registered. No thread was created.`, + }); + if ( + provider.models.length > 0 && + !provider.models.some((candidate) => candidate.slug === model) + ) + return yield* new OrchestratorMcpFailure({ + code: "model_unavailable", + message: `Model ${model} is not advertised by provider ${instanceId}. No thread was created. orchestrator_capabilities lists each provider's models.`, + }); + } if (!replaying && input.workspaceStrategy?.type === "existing_worktree") { const project = yield* readProject; if (project === undefined) diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 38931596868b4..0e2dbebf0f6d0 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -22,6 +22,7 @@ import { } from "@t3tools/contracts"; import * as FileSystem from "effect/FileSystem"; import * as DocumentText from "../../../provider/DocumentText.ts"; +import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import * as ServerConfig from "../../../config.ts"; import * as ServerSettings from "../../../serverSettings.ts"; import * as ThreadLaunchService from "../../../orchestration-v2/ThreadLaunchService.ts"; @@ -167,6 +168,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { ServerConfig.ServerConfig, ServerSettings.ServerSettingsService, DocumentText.DocumentText, + ProviderRegistry.ProviderRegistry, ], }) .annotate(Tool.Destructive, true) diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts index 50be40682066b..b51be1df8ed2e 100644 --- a/apps/server/src/peer/PeerForwarding.test.ts +++ b/apps/server/src/peer/PeerForwarding.test.ts @@ -224,7 +224,18 @@ const serveBox = (seen: Ref.Ref, linkSession: Ref.Ref) => ), ), Layer.provide(NodeServices.layer), - Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), + Layer.provide( + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([ + { + instanceId: ProviderInstanceId.make("codex"), + enabled: true, + status: "ready", + models: [{ slug: "gpt-5", name: "GPT-5" }], + } as never, + ]), + }), + ), Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), diff --git a/apps/server/src/peer/RemoteDelegation.test.ts b/apps/server/src/peer/RemoteDelegation.test.ts index a344f63c523ab..0cef4a785ed1f 100644 --- a/apps/server/src/peer/RemoteDelegation.test.ts +++ b/apps/server/src/peer/RemoteDelegation.test.ts @@ -481,7 +481,18 @@ const boxToolkitLayer = (thread: BoxThread) => { Layer.provide(NodeServices.layer), ), ), - Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), + Layer.provide( + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([ + { + instanceId, + enabled: true, + status: "ready", + models: [{ slug: modelSelection.model, name: "GPT-5.4" }], + } as never, + ]), + }), + ), Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), Layer.provide(