From 7046579f5262468b7970322a399d8a0a2ac90a20 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 21:05:54 +0000 Subject: [PATCH 1/5] test(server): reproduce #27819 agent-chosen plan mode on child threads These tests reproduce #27819: a default-mode caller that passes interactionMode "plan" to t3_thread_launch, delegate_task, or create_threads gets a child stored in plan mode. They expect the child to come out in default mode instead. Co-Authored-By: Claude Opus 5.5 --- .../src/mcp/OrchestratorMcpService.test.ts | 108 ++++++++++++++++++ .../src/mcp/toolkits/project/handlers.test.ts | 26 +++++ 2 files changed, 134 insertions(+) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 6450f9acac2cb..2b491412cbd14 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -1629,6 +1629,114 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); + it.effect("children of a default-mode thread start in default mode even when plan is asked", () => + Effect.gen(function* () { + const dispatched = yield* Ref.make>([]); + const task = { + id: taskId, + threadId: parentThreadId, + runId: parentRunId, + parentNodeId, + origin: "app_owned", + createdBy: "agent", + driver: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + providerThreadId: null, + childThreadId, + nativeTaskRef: null, + prompt: "Plan the change.", + title: null, + model: "gpt-5.4", + status: "running", + result: null, + startedAt: null, + completedAt: null, + }; + let delegated = false; + const layerDependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + hasReceipt: () => Effect.succeed(false), + assertProviderInstanceEnabled: () => Effect.void, + isProviderInstanceEnabledForProject: () => Effect.succeed(true), + getThreadRecords: (threadId) => + Effect.succeed( + threadId === parentThreadId + ? parentProjection(delegated ? [task] : []) + : ({ + ...childProjection, + thread: { + ...childProjection.thread, + modelSelection: { instanceId: codexInstanceId, model: "gpt-5.4" }, + }, + } as OrchestrationV2ThreadProjection), + ), + dispatch: (command) => + Ref.update(dispatched, (commands) => [...commands, command]).pipe( + Effect.andThen( + Effect.sync(() => { + delegated ||= command.type === "delegated_task.request"; + }), + ), + Effect.as({ + sequence: 1, + storedEvents: [ + { + sequence: 1, + commandId: null, + event: { type: "subagent.updated", payload: task }, + }, + ], + } as never), + ), + }), + providerRegistryLayer([ + providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + model: "gpt-5.4", + }), + ]), + adapterRegistryLayer([codexInstanceId]), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ServerSettings.layerTest(), + ); + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const delegation = { + task: "Plan the change.", + mode: "async", + interactionMode: "plan", + clientRequestId: "delegate-plan-from-default", + } as const; + yield* service.delegateTask(scope, delegation); + const creation = { + threads: [{ prompt: "Plan the change.", interactionMode: "plan" }], + clientRequestId: "create-plan-from-default", + } as const; + yield* service.createThreads(scope, creation); + + const started = (yield* Ref.get(dispatched)).flatMap((command) => + command.type === "delegated_task.request" || command.type === "thread.create" + ? [command as { readonly type: string; readonly interactionMode: string }] + : [], + ); + assert.deepEqual( + started.map(({ type, interactionMode }) => ({ type, interactionMode })), + [ + { type: "delegated_task.request", interactionMode: "default" }, + { type: "thread.create", interactionMode: "default" }, + ], + "a default-mode thread's children must not start in plan mode", + ); + }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); + }), + ); + it.effect( "retrying an accepted driver-only delegation replays even after the project disables every candidate", () => diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 03b97c32af6db..1ad7d7af50e14 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -281,6 +281,32 @@ it.effect("a launch retried with the same clientRequestId replays the first one" }), ); +it.effect( + "a launch from a default-mode caller starts in default mode even when plan is asked", + () => + Effect.gen(function* () { + const launched: Array = []; + const { projectId, dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "full-access", + launched, + }); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe( + Layer.provide(dependencies), + ), + ), + ); + const params = { title: "Plan", projectId, interactionMode: "plan" } as const; + yield* toolkit + .handle("t3_thread_launch", params) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + + expect(launched).toHaveLength(1); + expect(launched[0]?.interactionMode).toBe("default"); + }), +); + it.effect("a launch key of any length or content gives a short id and never a defect", () => Effect.gen(function* () { const launched: Array = []; From 697830795fad6c425352f4dc127ef937d873d7f9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 21:17:42 +0000 Subject: [PATCH 2/5] fix(server): agents no longer start threads in plan mode t3_thread_launch, delegate_task, and create_threads took an interactionMode argument, and only a mode broader than the caller's was refused. An agent in a default-mode thread could start plan-mode children, and with the legacy Plan mode setting off the user could neither see nor leave that mode. The argument is gone from all three tools. A new thread always runs in its caller's interaction mode, and a linked environment gets the parent's mode from T3-Mode-Limit as before. An interactionMode key an agent still sends is dropped by MCP decoding. runtimeMode is unchanged. Fixes pingdotgg/t3code#27819 Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/McpHttpServer.test.ts | 9 +++------ apps/server/src/mcp/McpToolAccess.test.ts | 16 ++-------------- apps/server/src/mcp/McpToolAccess.ts | 16 +++++----------- apps/server/src/mcp/OrchestratorMcpService.ts | 12 ++---------- apps/server/src/mcp/delegatedTaskModes.ts | 6 ++---- apps/server/src/mcp/toolkits/project/tools.ts | 4 +--- apps/server/src/peer/PeerForwarding.test.ts | 3 --- apps/server/src/peer/RemoteDelegation.ts | 17 ++++------------- .../src/provider/agentContextBudget.json | 2 +- .../orchestrator-mcp-server.md | 18 ++++++++---------- packages/contracts/src/orchestratorMcp.test.ts | 1 - packages/contracts/src/orchestratorMcp.ts | 8 -------- 12 files changed, 28 insertions(+), 84 deletions(-) diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 1f77dbfcc087d..6e66d587e8a96 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -10,7 +10,6 @@ import { OrchestratorMcpFailure, PreviewTabId, ProviderInstanceId, - ProviderInteractionMode, RuntimeMode, ThreadId, } from "@t3tools/contracts"; @@ -1094,10 +1093,7 @@ it.effect( const StartsToolkit = Toolkit.make( Tool.make("starts", { description: "Reports the modes a started thread would run with.", - parameters: Schema.Struct({ - runtimeMode: Schema.optional(RuntimeMode), - interactionMode: Schema.optional(ProviderInteractionMode), - }), + parameters: Schema.Struct({ runtimeMode: Schema.optional(RuntimeMode) }), success: Schema.Struct({ modes: Schema.String }), failure: OrchestratorMcpFailure, failureMode: "return", @@ -1206,8 +1202,9 @@ it.effect( expect(yield* start("approval-required/plan", { runtimeMode: "auto" })).toBe( "runtime_mode_escalation_denied", ); + // An interaction mode is not an argument: one sent anyway is dropped. expect(yield* start("auto-accept-edits/plan", { interactionMode: "default" })).toBe( - "interaction_mode_escalation_denied", + "auto-accept-edits/plan", ); // A broader header changes nothing: the approved ceiling still holds. expect(yield* start("full-access/default")).toBe("auto/default"); diff --git a/apps/server/src/mcp/McpToolAccess.test.ts b/apps/server/src/mcp/McpToolAccess.test.ts index 14888108502d5..1641ce4bf1dcc 100644 --- a/apps/server/src/mcp/McpToolAccess.test.ts +++ b/apps/server/src/mcp/McpToolAccess.test.ts @@ -4,7 +4,6 @@ import { OrchestratorMcpEnvironmentTarget, OrchestratorMcpFailure, ProviderInstanceId, - ProviderInteractionMode, RuntimeMode, ThreadId, } from "@t3tools/contracts"; @@ -123,10 +122,7 @@ const ProbeToolkit = Toolkit.make( }), Tool.make("starts_threads", { ...probe, - parameters: Schema.Struct({ - runtimeMode: Schema.optional(RuntimeMode), - interactionMode: Schema.optional(ProviderInteractionMode), - }), + parameters: Schema.Struct({ runtimeMode: Schema.optional(RuntimeMode) }), }), Tool.make("writes_environment", probe), ); @@ -303,8 +299,7 @@ it.effect.each([ ["starts_threads", supervised, {}, "approval-required/default"], ["starts_threads", supervised, { runtimeMode: "full-access" }, "runtime_mode_escalation_denied"], ["starts_threads", plan, {}, "approval-required/plan"], - ["starts_threads", plan, { interactionMode: "default" }, "interaction_mode_escalation_denied"], - ["starts_threads", fullAccess, { runtimeMode: "auto", interactionMode: "plan" }, "auto/plan"], + ["starts_threads", fullAccess, { runtimeMode: "auto" }, "auto/default"], ["starts_threads", ended, {}, "parent_not_active"], ["starts_threads", supervisedClient, {}, "approval-required/default"], ["starts_threads", supervisedClient, { runtimeMode: "auto" }, "runtime_mode_escalation_denied"], @@ -366,7 +361,6 @@ const linkedProbe = { environmentId: OrchestratorMcpEnvironmentTarget, threadId: Schema.optional(ThreadId), runtimeMode: Schema.optional(RuntimeMode), - interactionMode: Schema.optional(ProviderInteractionMode), }), }; const LinkedProbeToolkit = Toolkit.make( @@ -417,12 +411,6 @@ it.effect.each([ ["writes", ended, { environmentId: box }, "parent_not_active"], ["writes", supervised, { environmentId: box }, "writes in environment-box"], ["writes_with_capability", readOnlyClient, { environmentId: box }, "capability_denied"], - [ - "starts_threads", - plan, - { environmentId: box, interactionMode: "default" }, - "interaction_mode_escalation_denied", - ], ["starts_threads", plan, { environmentId: box }, "starts_threads in environment-box"], ["writes_environment", supervised, { environmentId: box }, "capability_denied"], [ diff --git a/apps/server/src/mcp/McpToolAccess.ts b/apps/server/src/mcp/McpToolAccess.ts index b82d9bb5ae59c..3df35cfeaf357 100644 --- a/apps/server/src/mcp/McpToolAccess.ts +++ b/apps/server/src/mcp/McpToolAccess.ts @@ -23,7 +23,7 @@ import { import * as PeerForwarding from "../peer/PeerForwarding.ts"; import { assertNotLinked, assertSameLink, callerLinkOrigin } from "./linkOrigin.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; -import { resolveInteractionMode, resolveRuntimeMode } from "./delegatedTaskModes.ts"; +import { resolveRuntimeMode } from "./delegatedTaskModes.ts"; import { assertFullAccess, assertLiveCaller, @@ -224,15 +224,12 @@ export interface StartedModes { } /** - * Starts threads with the modes `modes` requests, which may not be broader - * than the caller's. `linked` says what a linked caller gets: threads stamped + * Starts threads in the caller's interaction mode and the runtime mode + * `modes` requests, which may not be broader than the caller's. `linked` says what a linked caller gets: threads stamped * with its `linkOrigin`, or a refusal where the tool cannot carry one. */ export const startsThreads = ( - modes: (params: P) => { - readonly runtimeMode?: RuntimeMode | undefined; - readonly interactionMode?: ProviderInteractionMode | undefined; - }, + modes: (params: P) => { readonly runtimeMode?: RuntimeMode | undefined }, handle: (params: P, modes: StartedModes) => Effect.Effect, linked: "stamped" | { readonly refused: string }, ) => { @@ -240,10 +237,7 @@ export const startsThreads = ( const requested = modes(params); return { runtimeMode: yield* resolveRuntimeMode(caller.limits.runtimeMode, requested.runtimeMode), - interactionMode: yield* resolveInteractionMode( - caller.limits.interactionMode, - requested.interactionMode, - ), + interactionMode: caller.limits.interactionMode, linkOrigin: callerLinkOrigin(caller), } satisfies StartedModes; }); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 02f63741728b9..78a082a1ad26d 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -2258,10 +2258,6 @@ const make = Effect.gen(function* () { input.runtimeMode, target, ); - const interactionMode = yield* resolveInteractionMode( - parent.thread.interactionMode, - input.interactionMode, - ); let workspaceStrategy: OrchestrationV2ThreadLaunchWorkspaceStrategy | undefined; if (input.isolation === "worktree" && !isRetryOfAcceptedDelegation) { const project = yield* requireProject(parent.thread.projectId); @@ -2308,7 +2304,7 @@ const make = Effect.gen(function* () { ...(input.title === undefined ? {} : { title: input.title }), modelSelection: target.modelSelection, runtimeMode, - interactionMode, + interactionMode: parent.thread.interactionMode, ...(workspaceStrategy === undefined ? {} : { workspaceStrategy }), // Async delegations wake the parent on every child terminal; wait // delegations deliver through the blocking tool call, so a wake is @@ -2673,10 +2669,6 @@ const make = Effect.gen(function* () { request.runtimeMode, target, ); - const interactionMode = yield* resolveInteractionMode( - parent.thread.interactionMode, - request.interactionMode, - ); yield* assertTargetEnabledForProject(parent, commandId, target); const title = threadTitle({ parentTitle: parent.thread.title, @@ -2695,7 +2687,7 @@ const make = Effect.gen(function* () { title, modelSelection: target.modelSelection, runtimeMode, - interactionMode, + interactionMode: parent.thread.interactionMode, branch: parent.thread.branch, worktreePath: parent.thread.worktreePath, ...(parent.thread.linkOrigin === undefined diff --git a/apps/server/src/mcp/delegatedTaskModes.ts b/apps/server/src/mcp/delegatedTaskModes.ts index 54d25bbb36e02..9ce084def946a 100644 --- a/apps/server/src/mcp/delegatedTaskModes.ts +++ b/apps/server/src/mcp/delegatedTaskModes.ts @@ -2,7 +2,6 @@ import { type ModelSelection, OrchestratorMcpFailure, type OrchestratorMcpDelegateTaskInput, - type OrchestratorMcpInteractionMode, type OrchestratorMcpRuntimeMode, type ProviderInteractionMode, type RuntimeMode, @@ -71,12 +70,11 @@ export function resolveRuntimeMode( ); } -/** The same, for interaction modes. */ +/** Refuses a target interaction mode broader than its parent's. */ export function resolveInteractionMode( parentMode: ProviderInteractionMode, - requested: OrchestratorMcpInteractionMode | undefined, + resolved: ProviderInteractionMode, ): Effect.Effect { - const resolved = requested === undefined || requested === "inherit" ? parentMode : requested; return interactionModeRank(resolved) > interactionModeRank(parentMode) ? Effect.fail( new OrchestratorMcpFailure({ diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 3ea5a8ea9e48f..c7412e9a1850e 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -9,7 +9,6 @@ import { OrchestrationV2RunStatus, OrchestrationV2ThreadLaunchWorkspaceStrategy, RuntimeMode, - ProviderInteractionMode, Project, ProjectCreatePayload, ProjectUpdatePayload, @@ -121,7 +120,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", { const ThreadLaunchTool = Tool.make("t3_thread_launch", { ...shared, description: - "Create one new top-level thread, bound to its workspace before the agent starts. Task in `message`. Omit `projectId`, model and modes to inherit yours (outside a thread, pass `projectId` or `scratch`); modes cannot exceed yours. After an error or lost reply, retry with the same `clientRequestId`. Follow it with `t3_thread_read` or `t3_thread_wait`. Mention it with its `link`.", + "Create one new top-level thread, bound to its workspace before the agent starts. Task in `message`. Omit `projectId`, model and `runtimeMode` to inherit yours (outside a thread, pass `projectId` or `scratch`); `runtimeMode` cannot exceed yours. After an error or lost reply, retry with the same `clientRequestId`. Follow it with `t3_thread_read` or `t3_thread_wait`. Mention it with its `link`.", parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, projectId: Schema.optional(ProjectId), @@ -134,7 +133,6 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { title: TrimmedNonEmptyString, modelSelection: Schema.optional(ModelSelection), runtimeMode: Schema.optional(RuntimeMode), - interactionMode: Schema.optional(ProviderInteractionMode), workspaceStrategy: Schema.optional( OrchestrationV2ThreadLaunchWorkspaceStrategy.annotate({ description: diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts index 50be40682066b..e05d41e885707 100644 --- a/apps/server/src/peer/PeerForwarding.test.ts +++ b/apps/server/src/peer/PeerForwarding.test.ts @@ -512,9 +512,6 @@ it.effect("launches in a linked environment with the caller's modes, retry-safe" expect(relaunched?.threadId).toBe(launched?.threadId); expect(retried.structuredContent).toEqual(first.structuredContent); - // An escalation past the caller's own modes is refused here. - const escalated = yield* launch("thread:laptop-plan", { interactionMode: "default" }); - expect(failureOf(escalated)).toMatchObject({ code: "interaction_mode_escalation_denied" }); // A pending upload lives here, so it cannot go along. yield* Ref.set(b.bearers, []); const withUpload = yield* launch("thread:laptop-full", { diff --git a/apps/server/src/peer/RemoteDelegation.ts b/apps/server/src/peer/RemoteDelegation.ts index 739edc0a6039b..a4cecbbb5f97e 100644 --- a/apps/server/src/peer/RemoteDelegation.ts +++ b/apps/server/src/peer/RemoteDelegation.ts @@ -27,11 +27,7 @@ import type * as Tool from "effect/ai/Tool"; import type * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; import { ProjectToolkit } from "../mcp/toolkits/project/tools.ts"; -import { - resolveInteractionMode, - resolveRuntimeMode, - taskPrompt, -} from "../mcp/delegatedTaskModes.ts"; +import { resolveRuntimeMode, taskPrompt } from "../mcp/delegatedTaskModes.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; @@ -236,17 +232,13 @@ const make = Effect.gen(function* () { (instanceId === inherited.instanceId && model === inherited.model ? inherited.options : undefined); - // Modes asked for are checked against this thread. Omitted ones are left - // to the other side, which inherits this thread's modes capped by the - // link's access, so a narrower link still takes the task. + // A runtime mode asked for is checked against this thread. Otherwise the + // other side inherits this thread's modes capped by the link's access, + // so a narrower link still takes the task. const runtimeMode = input.runtimeMode === undefined || input.runtimeMode === "inherit" ? undefined : yield* resolveRuntimeMode(parent.thread.runtimeMode, input.runtimeMode); - const interactionMode = - input.interactionMode === undefined || input.interactionMode === "inherit" - ? undefined - : yield* resolveInteractionMode(parent.thread.interactionMode, input.interactionMode); const projectId = yield* resolveRemoteProject( scope, target.environmentId, @@ -267,7 +259,6 @@ const make = Effect.gen(function* () { title: input.title ?? parent.thread.title, modelSelection: { instanceId, model, ...(options === undefined ? {} : { options }) }, ...(runtimeMode === undefined ? {} : { runtimeMode }), - ...(interactionMode === undefined ? {} : { interactionMode }), message: task, clientRequestId: `delegate:${scope.thread.threadId}:${key}`, // The thread there shows it is this thread's subagent, with a way back. diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 1711e148d4ca4..53315105b42f8 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5135, "codexAdditionalContextBytes": 6865, - "mcpToolListJsonBytes": 46007 + "mcpToolListJsonBytes": 45817 } diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 6bd4dea35d7c5..48c650eac8539 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -234,13 +234,12 @@ type DelegateTaskInput = { timeoutMs?: number; clientRequestId?: string; runtimeMode?: "inherit" | "approval-required" | "auto-accept-edits" | "full-access"; - interactionMode?: "inherit" | "plan" | "default"; }; ``` -Provider, model, runtime mode, and interaction mode inherit from the parent -when omitted. A driver-only target inherits the parent's provider instance -when it can run child tasks, and otherwise selects an available instance of +Provider, model, and runtime mode inherit from the parent when omitted. A +driver-only target inherits the parent's provider instance when it can run +child tasks, and otherwise selects an available instance of that driver; an explicit `providerInstanceId` is honored exactly and fails when unavailable. Selecting a different provider without a model uses that provider's first advertised model. @@ -340,7 +339,6 @@ type CreateThreadsInput = { model?: string; }; runtimeMode?: "inherit" | "approval-required" | "auto-accept-edits" | "full-access"; - interactionMode?: "inherit" | "plan" | "default"; }>; clientRequestId?: string; }; @@ -360,11 +358,11 @@ project root (`root`, also the default). The thread is bound to that workspace before the agent starts. Creating a worktree in the task prompt does not update this binding. -Pass the task in `message`. Project, model, and modes inherit when omitted; +Pass the task in `message`. Project, model, and runtime mode 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 +`startFromOrigin: false`. The new thread may not run with a broader runtime +mode than the caller. 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` @@ -503,8 +501,8 @@ results use the latest assistant content from the final work turn. - A child runtime mode may stay equal to or become narrower than the parent mode. It may not escalate privileges. -- A child interaction mode may stay equal to or narrow from `default` to - `plan`. It may not escalate from `plan` to `default`. +- A child always runs in its caller's interaction mode. Agents cannot choose + one: the user may have plan mode hidden and could not leave it. - Thread tools take any thread in the environment as a target. For a thread caller, list and search cover one project: its own unless `projectId` is given. - A tool that changes another thread needs the calling thread's live run, and diff --git a/packages/contracts/src/orchestratorMcp.test.ts b/packages/contracts/src/orchestratorMcp.test.ts index 951727e68e1d3..7785e70bf8a0f 100644 --- a/packages/contracts/src/orchestratorMcp.test.ts +++ b/packages/contracts/src/orchestratorMcp.test.ts @@ -21,7 +21,6 @@ describe("orchestrator MCP contracts", () => { timeoutMs: 5_000, clientRequestId: "delegate-1", runtimeMode: "inherit", - interactionMode: "inherit", }); const result = decodeDelegateTaskResult({ taskId: "node-task-1", diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 1474a5d6e87b6..666dcd72555ff 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -129,12 +129,6 @@ export type OrchestratorMcpTarget = typeof OrchestratorMcpTarget.Type; export const OrchestratorMcpRuntimeMode = Schema.Union([Schema.Literal("inherit"), RuntimeMode]); export type OrchestratorMcpRuntimeMode = typeof OrchestratorMcpRuntimeMode.Type; -export const OrchestratorMcpInteractionMode = Schema.Union([ - Schema.Literal("inherit"), - ProviderInteractionMode, -]); -export type OrchestratorMcpInteractionMode = typeof OrchestratorMcpInteractionMode.Type; - export const OrchestratorMcpTaskRole = Schema.Literals([ "implementation", "research", @@ -192,7 +186,6 @@ export const OrchestratorMcpDelegateTaskInput = Schema.Struct({ "Omit unless the user asks for a supervised child. Narrow modes pause for human approval, not read-only. Above yours is rejected.", }), ), - interactionMode: Schema.optional(OrchestratorMcpInteractionMode), }); export type OrchestratorMcpDelegateTaskInput = typeof OrchestratorMcpDelegateTaskInput.Type; @@ -263,7 +256,6 @@ export const OrchestratorMcpCreateThreadRequest = Schema.Struct({ title: Schema.optional(OrchestratorMcpTitle), target: Schema.optional(OrchestratorMcpTarget), runtimeMode: Schema.optional(OrchestratorMcpRuntimeMode), - interactionMode: Schema.optional(OrchestratorMcpInteractionMode), }); export type OrchestratorMcpCreateThreadRequest = typeof OrchestratorMcpCreateThreadRequest.Type; From a5c674f7f630c02fbccf6be7624ef09b22813207 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 21:26:37 +0000 Subject: [PATCH 3/5] docs(server): clarify why agents cannot choose interaction modes Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/McpToolAccess.ts | 5 +++-- docs/orchestration-v2/orchestrator-mcp-server.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/server/src/mcp/McpToolAccess.ts b/apps/server/src/mcp/McpToolAccess.ts index 3df35cfeaf357..af24e5f70655a 100644 --- a/apps/server/src/mcp/McpToolAccess.ts +++ b/apps/server/src/mcp/McpToolAccess.ts @@ -225,8 +225,9 @@ export interface StartedModes { /** * Starts threads in the caller's interaction mode and the runtime mode - * `modes` requests, which may not be broader than the caller's. `linked` says what a linked caller gets: threads stamped - * with its `linkOrigin`, or a refusal where the tool cannot carry one. + * `modes` requests, which may not be broader than the caller's. `linked` says + * what a linked caller gets: threads stamped with its `linkOrigin`, or a + * refusal where the tool cannot carry one. */ export const startsThreads = ( modes: (params: P) => { readonly runtimeMode?: RuntimeMode | undefined }, diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 48c650eac8539..0e83a86f2c03b 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -502,7 +502,7 @@ results use the latest assistant content from the final work turn. - A child runtime mode may stay equal to or become narrower than the parent mode. It may not escalate privileges. - A child always runs in its caller's interaction mode. Agents cannot choose - one: the user may have plan mode hidden and could not leave it. + one: the server does not know whether the user's client shows plan mode. - Thread tools take any thread in the environment as a target. For a thread caller, list and search cover one project: its own unless `projectId` is given. - A tool that changes another thread needs the calling thread's live run, and From f4b41d1af1733a3ecf52f55be1aa6254855c3186 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 00:09:05 +0000 Subject: [PATCH 4/5] test(server): cover agent-chosen plan mode once per creation path Fold the launch case into the starts_threads access table, which every launch goes through, and give the delegate/create_threads case the crypto layer main now needs. AI-assisted; verified with the tests and tools listed above. --- apps/server/src/mcp/McpToolAccess.test.ts | 2 +- .../src/mcp/OrchestratorMcpService.test.ts | 1 + .../src/mcp/toolkits/project/handlers.test.ts | 26 ------------------- 3 files changed, 2 insertions(+), 27 deletions(-) diff --git a/apps/server/src/mcp/McpToolAccess.test.ts b/apps/server/src/mcp/McpToolAccess.test.ts index f8872e8322023..f5d56bd0d003a 100644 --- a/apps/server/src/mcp/McpToolAccess.test.ts +++ b/apps/server/src/mcp/McpToolAccess.test.ts @@ -299,7 +299,7 @@ it.effect.each([ ["starts_threads", supervised, {}, "approval-required/default"], ["starts_threads", supervised, { runtimeMode: "full-access" }, "runtime_mode_escalation_denied"], ["starts_threads", plan, {}, "approval-required/plan"], - ["starts_threads", fullAccess, { runtimeMode: "auto" }, "auto/default"], + ["starts_threads", fullAccess, { runtimeMode: "auto", interactionMode: "plan" }, "auto/default"], ["starts_threads", ended, {}, "parent_not_active"], ["starts_threads", supervisedClient, {}, "approval-required/default"], ["starts_threads", supervisedClient, { runtimeMode: "auto" }, "runtime_mode_escalation_denied"], diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index c55eee627c635..a986335292689 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -1973,6 +1973,7 @@ describe("OrchestratorMcpService provider resolution", () => { }; let delegated = false; const layerDependencies = Layer.mergeAll( + NodeCrypto.layer, NodeServices.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ hasReceipt: () => Effect.succeed(false), diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 85fd074509fab..b77df3ac2075e 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -370,32 +370,6 @@ it.effect("a launch retried with the same clientRequestId replays the first one" }), ); -it.effect( - "a launch from a default-mode caller starts in default mode even when plan is asked", - () => - Effect.gen(function* () { - const launched: Array = []; - const { projectId, dependencies } = clientLaunchHarness({ - runtimeModeCeiling: "full-access", - launched, - }); - const toolkit = yield* ProjectToolkit.pipe( - Effect.provide( - McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe( - Layer.provide(dependencies), - ), - ), - ); - const params = { title: "Plan", projectId, interactionMode: "plan" } as const; - yield* toolkit - .handle("t3_thread_launch", params) - .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); - - expect(launched).toHaveLength(1); - expect(launched[0]?.interactionMode).toBe("default"); - }), -); - it.effect("a launch key of any length or content gives a short id and never a defect", () => Effect.gen(function* () { const launched: Array = []; From 1d58448d577fa61816130866e6758099a407f910 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 04:23:37 +0000 Subject: [PATCH 5/5] fix(server): refuse explicit interaction modes instead of dropping them Callers on the previous contract could send interactionMode "plan" and get a plan-mode child. Dropping the field silently started default-mode work instead. delegate_task, create_threads and t3_thread_launch now accept only "inherit", so an explicit mode fails decoding and nothing starts. AI-assisted; verified with the tests and tools listed above. --- apps/server/src/mcp/McpHttpServer.test.ts | 4 - apps/server/src/mcp/McpToolAccess.test.ts | 2 +- .../src/mcp/OrchestratorMcpService.test.ts | 109 ------------------ apps/server/src/mcp/toolkits/project/tools.ts | 2 + apps/server/src/peer/PeerForwarding.test.ts | 5 + .../src/provider/agentContextBudget.json | 2 +- .../orchestrator-mcp-server.md | 4 + .../contracts/src/orchestratorMcp.test.ts | 8 ++ packages/contracts/src/orchestratorMcp.ts | 6 + 9 files changed, 27 insertions(+), 115 deletions(-) diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 2cae2275df423..b90de7a7600e6 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -1509,10 +1509,6 @@ it.effect( expect(yield* start("approval-required/plan", { runtimeMode: "auto" })).toBe( "runtime_mode_escalation_denied", ); - // An interaction mode is not an argument: one sent anyway is dropped. - expect(yield* start("auto-accept-edits/plan", { interactionMode: "default" })).toBe( - "auto-accept-edits/plan", - ); // A broader header changes nothing: the approved ceiling still holds. expect(yield* start("full-access/default")).toBe("auto/default"); expect(yield* start("full-access/default", { runtimeMode: "full-access" })).toBe( diff --git a/apps/server/src/mcp/McpToolAccess.test.ts b/apps/server/src/mcp/McpToolAccess.test.ts index f5d56bd0d003a..f8872e8322023 100644 --- a/apps/server/src/mcp/McpToolAccess.test.ts +++ b/apps/server/src/mcp/McpToolAccess.test.ts @@ -299,7 +299,7 @@ it.effect.each([ ["starts_threads", supervised, {}, "approval-required/default"], ["starts_threads", supervised, { runtimeMode: "full-access" }, "runtime_mode_escalation_denied"], ["starts_threads", plan, {}, "approval-required/plan"], - ["starts_threads", fullAccess, { runtimeMode: "auto", interactionMode: "plan" }, "auto/default"], + ["starts_threads", fullAccess, { runtimeMode: "auto" }, "auto/default"], ["starts_threads", ended, {}, "parent_not_active"], ["starts_threads", supervisedClient, {}, "approval-required/default"], ["starts_threads", supervisedClient, { runtimeMode: "auto" }, "runtime_mode_escalation_denied"], diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index a986335292689..f26397040f0ff 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -1948,115 +1948,6 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); - it.effect("children of a default-mode thread start in default mode even when plan is asked", () => - Effect.gen(function* () { - const dispatched = yield* Ref.make>([]); - const task = { - id: taskId, - threadId: parentThreadId, - runId: parentRunId, - parentNodeId, - origin: "app_owned", - createdBy: "agent", - driver: ProviderDriverKind.make("codex"), - providerInstanceId: codexInstanceId, - providerThreadId: null, - childThreadId, - nativeTaskRef: null, - prompt: "Plan the change.", - title: null, - model: "gpt-5.4", - status: "running", - result: null, - startedAt: null, - completedAt: null, - }; - let delegated = false; - const layerDependencies = Layer.mergeAll( - NodeCrypto.layer, - NodeServices.layer, - Layer.mock(ThreadManagementService.ThreadManagementService)({ - hasReceipt: () => Effect.succeed(false), - assertProviderInstanceEnabled: () => Effect.void, - isProviderInstanceEnabledForProject: () => Effect.succeed(true), - getThreadRecords: (threadId) => - Effect.succeed( - threadId === parentThreadId - ? parentProjection(delegated ? [task] : []) - : ({ - ...childProjection, - thread: { - ...childProjection.thread, - modelSelection: { instanceId: codexInstanceId, model: "gpt-5.4" }, - }, - } as OrchestrationV2ThreadProjection), - ), - dispatch: (command) => - Ref.update(dispatched, (commands) => [...commands, command]).pipe( - Effect.andThen( - Effect.sync(() => { - delegated ||= command.type === "delegated_task.request"; - }), - ), - Effect.as({ - sequence: 1, - storedEvents: [ - { - sequence: 1, - commandId: null, - event: { type: "subagent.updated", payload: task }, - }, - ], - } as never), - ), - }), - providerRegistryLayer([ - providerSnapshot({ - instanceId: codexInstanceId, - driver: ProviderDriverKind.make("codex"), - model: "gpt-5.4", - }), - ]), - adapterRegistryLayer([codexInstanceId]), - Layer.mock(ProjectService.ProjectService)({}), - Layer.mock(SecretRequests.SecretRequests)({}), - Layer.mock(RemoteDelegation.RemoteDelegation)({}), - Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), - ServerSettings.layerTest(), - ); - - yield* Effect.gen(function* () { - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - const delegation = { - task: "Plan the change.", - mode: "async", - interactionMode: "plan", - clientRequestId: "delegate-plan-from-default", - } as const; - yield* service.delegateTask(scope, delegation); - const creation = { - threads: [{ prompt: "Plan the change.", interactionMode: "plan" }], - clientRequestId: "create-plan-from-default", - } as const; - yield* service.createThreads(scope, creation); - - const started = (yield* Ref.get(dispatched)).flatMap((command) => - command.type === "delegated_task.request" || command.type === "thread.create" - ? [command as { readonly type: string; readonly interactionMode: string }] - : [], - ); - assert.deepEqual( - started.map(({ type, interactionMode }) => ({ type, interactionMode })), - [ - { type: "delegated_task.request", interactionMode: "default" }, - { type: "thread.create", interactionMode: "default" }, - ], - "a default-mode thread's children must not start in plan mode", - ); - }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); - }), - ); - it.effect( "retrying an accepted driver-only delegation replays even after the project disables every candidate", () => diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 3efd583c76506..54b3c2f4f54a2 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -15,6 +15,7 @@ import { ProjectId, OrchestratorMcpClientRequestId, OrchestratorMcpEnvironmentTarget, + OrchestratorMcpInteractionMode, OrchestratorMcpFailure, SourceControlCloneRepositoryInput, SourceControlCloneRepositoryResult, @@ -136,6 +137,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { title: TrimmedNonEmptyString, modelSelection: Schema.optional(ModelSelection), runtimeMode: Schema.optional(RuntimeMode), + interactionMode: Schema.optional(OrchestratorMcpInteractionMode), workspaceStrategy: Schema.optional( OrchestrationV2ThreadLaunchWorkspaceStrategy.annotate({ description: diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts index 7c505ef1d004d..6ee557807f663 100644 --- a/apps/server/src/peer/PeerForwarding.test.ts +++ b/apps/server/src/peer/PeerForwarding.test.ts @@ -517,6 +517,11 @@ it.effect("launches in a linked environment with the caller's modes, retry-safe" expect(relaunched?.threadId).toBe(launched?.threadId); expect(retried.structuredContent).toEqual(first.structuredContent); + // An explicit mode from an older caller is refused, not dropped. + const chosen = yield* Effect.flip( + launch("thread:laptop-full", { clientRequestId: "launch-3", interactionMode: "plan" }), + ); + expect(String(chosen)).toContain('["interactionMode"]'); // A pending upload lives here, so it cannot go along. yield* Ref.set(b.bearers, []); const withUpload = yield* launch("thread:laptop-full", { diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 5bbbe99ac571c..de12e5f31d7de 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5597, "codexAdditionalContextBytes": 7327, - "mcpToolListJsonBytes": 48027 + "mcpToolListJsonBytes": 48192 } diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index bc95b1f5407f8..17fa82320206d 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -244,6 +244,7 @@ type DelegateTaskInput = { timeoutMs?: number; clientRequestId?: string; runtimeMode?: "inherit" | "approval-required" | "auto-accept-edits" | "full-access"; + interactionMode?: "inherit"; }; ``` @@ -349,6 +350,7 @@ type CreateThreadsInput = { model?: string; }; runtimeMode?: "inherit" | "approval-required" | "auto-accept-edits" | "full-access"; + interactionMode?: "inherit"; }>; clientRequestId?: string; }; @@ -536,6 +538,8 @@ results use the latest assistant content from the final work turn. mode. It may not escalate privileges. - A child always runs in its caller's interaction mode. Agents cannot choose one: the server does not know whether the user's client shows plan mode. + `interactionMode` accepts only `"inherit"`, so an explicit mode from an older + caller is refused rather than silently dropped. - Thread tools take any thread in the environment as a target. For a thread caller, list and search cover one project: its own unless `projectId` is given. - A tool that changes another thread needs the calling thread's live run, and diff --git a/packages/contracts/src/orchestratorMcp.test.ts b/packages/contracts/src/orchestratorMcp.test.ts index 7785e70bf8a0f..4f0cba30d67bf 100644 --- a/packages/contracts/src/orchestratorMcp.test.ts +++ b/packages/contracts/src/orchestratorMcp.test.ts @@ -2,12 +2,14 @@ import { describe, expect, it } from "@effect/vitest"; import * as Schema from "effect/Schema"; import { + OrchestratorMcpCreateThreadRequest, OrchestratorMcpDelegateTaskInput, OrchestratorMcpDelegateTaskResult, } from "./orchestratorMcp.ts"; const decodeDelegateTaskInput = Schema.decodeUnknownSync(OrchestratorMcpDelegateTaskInput); const decodeDelegateTaskResult = Schema.decodeUnknownSync(OrchestratorMcpDelegateTaskResult); +const decodeCreateThreadRequest = Schema.decodeUnknownSync(OrchestratorMcpCreateThreadRequest); describe("orchestrator MCP contracts", () => { it("decodes cross-provider delegated task requests and durable results", () => { @@ -21,7 +23,13 @@ describe("orchestrator MCP contracts", () => { timeoutMs: 5_000, clientRequestId: "delegate-1", runtimeMode: "inherit", + interactionMode: "inherit", }); + // Children run in their caller's interaction mode; an explicit one is refused, not dropped. + expect(() => decodeDelegateTaskInput({ task: "Plan it.", interactionMode: "plan" })).toThrow(); + expect(() => + decodeCreateThreadRequest({ prompt: "Plan it.", interactionMode: "plan" }), + ).toThrow(); const result = decodeDelegateTaskResult({ taskId: "node-task-1", childThreadId: "thread-child-1", diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 97106d3f11a20..6e1de8861fa79 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -130,6 +130,10 @@ export type OrchestratorMcpTarget = typeof OrchestratorMcpTarget.Type; export const OrchestratorMcpRuntimeMode = Schema.Union([Schema.Literal("inherit"), RuntimeMode]); export type OrchestratorMcpRuntimeMode = typeof OrchestratorMcpRuntimeMode.Type; +// Children always run in their caller's interaction mode. The field stays so an +// explicit mode from an older caller fails decoding instead of being dropped. +export const OrchestratorMcpInteractionMode = Schema.Literal("inherit"); + export const OrchestratorMcpTaskRole = Schema.Literals([ "implementation", "research", @@ -187,6 +191,7 @@ export const OrchestratorMcpDelegateTaskInput = Schema.Struct({ "Omit unless the user asks for a supervised child. Narrow modes pause for human approval, not read-only. Above yours is rejected.", }), ), + interactionMode: Schema.optional(OrchestratorMcpInteractionMode), }); export type OrchestratorMcpDelegateTaskInput = typeof OrchestratorMcpDelegateTaskInput.Type; @@ -264,6 +269,7 @@ export const OrchestratorMcpCreateThreadRequest = Schema.Struct({ title: Schema.optional(OrchestratorMcpTitle), target: Schema.optional(OrchestratorMcpTarget), runtimeMode: Schema.optional(OrchestratorMcpRuntimeMode), + interactionMode: Schema.optional(OrchestratorMcpInteractionMode), }); export type OrchestratorMcpCreateThreadRequest = typeof OrchestratorMcpCreateThreadRequest.Type;