diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 03b97c32af6db..7617de634347a 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -243,6 +243,62 @@ it.effect( }), ); +it.effect("a launch steps an inherited mode down to the provider's, and refuses one it lacks", () => + Effect.gen(function* () { + // Pi offers no Auto; a mode a provider lacks would run as Supervised. + const pi = ProviderInstanceId.make("pi"); + const launchPi = Effect.fn("test.launchPi")(function* (runtimeMode?: RuntimeMode) { + const launched: Array = []; + const { projectId, dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "auto", + launched, + providers: [ + { + instanceId: pi, + driver: ProviderDriverKind.make("pi"), + enabled: true, + installed: true, + status: "ready", + version: null, + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00Z", + models: [], + slashCommands: [], + skills: [], + supportedRuntimeModes: ["approval-required", "auto-accept-edits", "full-access"], + }, + ], + }); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe( + Layer.provide(dependencies), + ), + ), + ); + const result = yield* toolkit + .handle("t3_thread_launch", { + projectId, + title: "Fix", + modelSelection: { instanceId: pi, model: "pi-model" }, + ...(runtimeMode === undefined ? {} : { runtimeMode }), + }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + return { result: result.at(-1)?.result, launched }; + }); + + const inherited = yield* launchPi(); + expect(inherited.launched[0]?.runtimeMode).toBe("auto-accept-edits"); + + const requested = yield* launchPi("auto"); + expect(requested.result).toMatchObject({ code: "invalid_request" }); + expect(requested.launched).toHaveLength(0); + + const offered = yield* launchPi("approval-required"); + expect(offered.launched[0]?.runtimeMode).toBe("approval-required"); + }), +); + 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 014b109448f34..e5826433501d2 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -202,12 +202,21 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { message: "Pass modelSelection: the project has no default model. orchestrator_capabilities lists providers and models.", }); - const startedRuntimeMode = + let startedRuntimeMode = projectDefaults !== undefined && input.runtimeMode === undefined ? yield* resolveRuntimeMode(runtimeMode, projectDefaults.defaultRuntimeMode).pipe( Effect.orElseSucceed(() => runtimeMode), ) : runtimeMode; + if (!replaying) { + const providers = yield* (yield* ProviderRegistry.ProviderRegistry).getProviders; + startedRuntimeMode = yield* resolveRuntimeMode(startedRuntimeMode, input.runtimeMode, { + modelSelection, + supportedRuntimeModes: providers.find( + (provider) => provider.instanceId === modelSelection.instanceId, + )?.supportedRuntimeModes, + }); + } const result = yield* ThreadMessageIntake.launchThread({ commandId, threadId, 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( diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 6bd4dea35d7c5..f46803f9d67b6 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -364,7 +364,9 @@ Pass the task in `message`. Project, model, and modes inherit when omitted; workspace does not. `scratch: true` launches without a project, in a folder of its own under the environment's Scratch project. For stacked PRs, use the parent branch as `baseRef` with `startFromOrigin: false`. The new thread may not run with broader runtime or -interaction modes than the caller. Launch has no retry key, so inspect existing threads after a failed or lost response before +interaction modes than the caller. As for `delegate_task`, a runtime mode the +provider does not offer is refused, and an inherited or default one steps down +to the broadest mode it offers. Launch has no retry key, so inspect existing threads after a failed or lost response before launching again. `create_threads` remains the batch option for a shared checkout. ### `t3_thread_list`