diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 6450f9acac2cb..08f2640fc6ad1 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -2516,6 +2516,11 @@ describe("OrchestratorMcpService provider resolution", () => { target: { options: {} }, expected: { ...inherited, options: [] }, }, + { + name: "same model by alias", + target: { model: "GPT" }, + expected: { ...inherited, model: "GPT" }, + }, { name: "change model", target: { model: "other-model" }, @@ -2572,6 +2577,7 @@ describe("OrchestratorMcpService provider resolution", () => { name: model, isCustom: false, capabilities: null, + ...(model === inherited.model ? { aliases: ["gpt"] } : {}), })), }, providerSnapshot({ instanceId: peerId, driver, model: inherited.model }), @@ -2602,6 +2608,61 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); + it.effect("validates options against the model a target names by alias", () => + Effect.gen(function* () { + const dependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + hasReceipt: () => Effect.succeed(false), + assertProviderInstanceEnabled: () => Effect.void, + getThreadRecords: (threadId) => + Effect.succeed(threadId === parentThreadId ? parentProjection([]) : childProjection), + dispatch: () => Effect.die("an invalid target must not dispatch"), + }), + ServerSettings.layerTest(), + providerRegistryLayer([ + { + ...providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + }), + models: [ + { + slug: "gpt-5.4", + name: "gpt-5.4", + aliases: ["gpt"], + isCustom: false, + capabilities: { + optionDescriptors: [{ id: "fastMode", label: "Fast", type: "boolean" }], + }, + }, + ], + }, + ]), + adapterRegistryLayer([codexInstanceId]), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + for (const model of ["gpt-5.4", "GPT"]) { + const error = yield* service + .delegateTask(scope, { + task: "Review the change.", + target: yield* decodeTarget({ model, options: { bogus: "x" } }), + mode: "async", + clientRequestId: model, + }) + .pipe(Effect.flip); + assert.equal(error.code, "invalid_request", model); + assert.include(error.message, "Unknown option bogus", model); + } + }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(dependencies)))); + }), + ); + describe("resolveRuntimeMode", () => { const muse = { modelSelection: { instanceId: ProviderInstanceId.make("muse"), model: "muse-model" }, diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 8b32603a7194e..dd474d7e86baf 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -336,6 +336,23 @@ function capabilityModelOption(descriptor: ProviderOptionDescriptor): Orchestrat }; } +/** Yields the model a slug or alias names; a provider that lists no models accepts any slug. */ +export const requireAdvertisedModel = (provider: ServerProvider, model: string) => { + const advertised = provider.models.find( + (candidate) => + candidate.slug === model || + candidate.aliases?.some((alias) => alias.toLowerCase() === model.toLowerCase()), + ); + return provider.models.length > 0 && advertised === undefined + ? Effect.fail( + failure( + "model_unavailable", + `Model ${model} is not advertised by provider ${provider.instanceId}.`, + ), + ) + : Effect.succeed(advertised); +}; + function providerConstraints( provider: ServerProvider | undefined, supportsOrchestrationV2: boolean, @@ -1343,21 +1360,15 @@ const make = Effect.gen(function* () { `Provider ${instanceId} has no model available for inheritance.`, ); } - if ( - requestedModel !== undefined && - provider.models.length > 0 && - !provider.models.some((candidate) => candidate.slug === requestedModel) - ) { - return yield* failure( - "model_unavailable", - `Model ${requestedModel} is not advertised by provider ${instanceId}.`, - ); - } + const slug = + requestedModel === undefined + ? model + : ((yield* requireAdvertisedModel(provider, requestedModel))?.slug ?? model); const requestedOptions = input.target?.options; if (requestedOptions !== undefined) { - const descriptors = provider.models.find((candidate) => candidate.slug === model) - ?.capabilities?.optionDescriptors; + const descriptors = provider.models.find((candidate) => candidate.slug === slug)?.capabilities + ?.optionDescriptors; const invalid = invalidOptionSelections(requestedOptions, descriptors); if (invalid.length > 0) { return yield* failure( @@ -1368,7 +1379,8 @@ const make = Effect.gen(function* () { } const inheritsOptions = - instanceId === inheritedSelection.instanceId && model === inheritedSelection.model; + instanceId === inheritedSelection.instanceId && + (model === inheritedSelection.model || slug === inheritedSelection.model); let options = inheritsOptions ? inheritedSelection.options : undefined; if (requestedOptions !== undefined) { const selections = new Map( diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 4031654a8c826..259a6efc48de8 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -2,11 +2,13 @@ import { expect, it } from "@effect/vitest"; import { EnvironmentId, ProjectId, + ProviderDriverKind, ProviderInstanceId, type Project as ProjectRecord, 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"; @@ -20,6 +22,7 @@ import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import * as DocumentText from "../../../provider/DocumentText.ts"; +import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts"; import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; import * as ServerConfig from "../../../config.ts"; @@ -34,6 +37,30 @@ import * as McpToolAccess from "../../McpToolAccess.ts"; import * as ProjectHandlers from "./handlers.ts"; import { ProjectToolkit } from "./tools.ts"; +const advertisedProvider = ( + instanceId: string, + models: ReadonlyArray, + aliases: Readonly>> = {}, +): ServerProvider => ({ + instanceId: ProviderInstanceId.make(instanceId), + driver: ProviderDriverKind.make(instanceId), + enabled: true, + installed: true, + version: "test", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-10-28T00:00:00.000Z", + models: models.map((slug) => ({ + slug, + name: slug, + ...(aliases[slug] === undefined ? {} : { aliases: aliases[slug] }), + isCustom: false, + capabilities: null, + })), + slashCommands: [], + skills: [], +}); + const clientLaunchHarness = (input: { readonly runtimeModeCeiling: RuntimeMode; readonly defaultRuntimeMode?: RuntimeMode; @@ -126,6 +153,14 @@ const clientLaunchHarness = (input: { : Option.none(), ), }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([ + advertisedProvider("claudeAgent", ["claude-opus", "claude-sonnet"], { + "claude-opus": ["opus"], + }), + advertisedProvider("codex", ["gpt-settings"]), + ]), + }), Layer.mock(PeerForwarding.PeerForwarding)({}), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }), DocumentText.layer.pipe(Layer.provideMerge(NodeServices.layer)), @@ -224,6 +259,58 @@ it.effect( }), ); +it.effect("a launch with a model its provider does not advertise creates no thread", () => + Effect.gen(function* () { + const launched: Array = []; + const { projectId, dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "auto", + launched, + }); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), + ), + ); + const launchWith = (instanceId: string, model: string) => + toolkit + .handle("t3_thread_launch", { + title: "Fix", + projectId, + message: "Fix the bug", + modelSelection: { instanceId: ProviderInstanceId.make(instanceId), model }, + }) + .pipe( + Stream.unwrap, + Stream.runCollect, + Effect.map((result) => result.at(-1)?.result), + Effect.provide(dependencies), + ); + + for (const [instanceId, model] of [ + ["claudeAgent", "claude-issue-26619-unadvertised"], + ["claudeAgent", "gpt-settings"], + ["codex", "claude-opus"], + ["codex", "opus"], + ] as const) { + expect(yield* launchWith(instanceId, model)).toMatchObject({ + code: "model_unavailable", + message: `Model ${model} is not advertised by provider ${instanceId}.`, + }); + } + expect(launched).toHaveLength(0); + + expect(yield* launchWith("claudeAgent", "claude-sonnet")).toMatchObject({ + projectId, + modelSelection: { instanceId: "claudeAgent", model: "claude-sonnet" }, + }); + expect(yield* launchWith("claudeAgent", "Opus")).toMatchObject({ + projectId, + modelSelection: { instanceId: "claudeAgent", model: "Opus" }, + }); + expect(launched).toHaveLength(2); + }), +); + it.effect("a launch retried with the same clientRequestId replays the first one", () => Effect.gen(function* () { const launched: Array = []; diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 950cb38c6c4fd..2391a5f4c4ca3 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -14,6 +14,7 @@ import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts" import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts"; import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; import * as Project from "../../../project/ProjectService.ts"; +import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts"; import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; @@ -23,6 +24,7 @@ import { formatThreadLink } from "@t3tools/shared/threadLinks"; import { resolveRuntimeMode } from "../../delegatedTaskModes.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; +import * as OrchestrationMcp from "../../OrchestratorMcpService.ts"; import { newCommandId, readCaller, resolveProjectId, unavailable } from "../../threadAccess.ts"; import { ProjectToolkit } from "./tools.ts"; @@ -142,6 +144,13 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { }); yield* assertProjectWorktree(project.workspaceRoot, input.workspaceStrategy.worktreePath); } + if (!replaying && input.modelSelection !== undefined) { + const { instanceId, model } = input.modelSelection; + const providers = yield* (yield* ProviderRegistry.ProviderRegistry).getProviders; + const provider = providers.find((candidate) => candidate.instanceId === instanceId); + if (provider !== undefined) + yield* OrchestrationMcp.requireAdvertisedModel(provider, model); + } // Reject unresolved base refs before accepting a launch; worktree provisioning happens later. const strategy = input.workspaceStrategy; if (!replaying && strategy?.type === "worktree") diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 38931596868b4..fbdb4fe74c513 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -33,6 +33,7 @@ import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.t import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; import * as SourceControlRepositoryService from "../../../sourceControl/SourceControlRepositoryService.ts"; import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; +import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; const shared = { @@ -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..b0812d3e42c3b 100644 --- a/apps/server/src/peer/PeerForwarding.test.ts +++ b/apps/server/src/peer/PeerForwarding.test.ts @@ -224,7 +224,9 @@ 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([]) }), + ), 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..cce7093992021 100644 --- a/apps/server/src/peer/RemoteDelegation.test.ts +++ b/apps/server/src/peer/RemoteDelegation.test.ts @@ -481,7 +481,9 @@ const boxToolkitLayer = (thread: BoxThread) => { Layer.provide(NodeServices.layer), ), ), - Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), + Layer.provide( + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + ), Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), Layer.provide(