diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index a5094ccbf6fe..d7932220d04f 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -9,7 +9,10 @@ import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as McpSessionRegistry from "./McpSessionRegistry.ts"; import { ThreadToolkitHandlersLive } from "./toolkits/thread/handlers.ts"; import { ThreadToolkit } from "./toolkits/thread/tools.ts"; -import { SubagentToolkitHandlersLive } from "./toolkits/subagent/handlers.ts"; +import { + installPeerSubagentCompatibility, + SubagentToolkitHandlersLive, +} from "./toolkits/subagent/handlers.ts"; import { SubagentToolkit } from "./toolkits/subagent/tools.ts"; import { NotifyToolkitHandlersLive } from "./toolkits/notify/handlers.ts"; import { NotifyToolkit } from "./toolkits/notify/tools.ts"; @@ -91,9 +94,12 @@ export const ThreadToolkitRegistrationLive = McpServer.toolkit(ThreadToolkit).pi Layer.provide(ThreadToolkitHandlersLive), ); -export const SubagentToolkitRegistrationLive = McpServer.toolkit(SubagentToolkit).pipe( - Layer.provide(SubagentToolkitHandlersLive), -); +export const SubagentToolkitRegistrationLive = Layer.effectDiscard( + Effect.gen(function* () { + yield* McpServer.registerToolkit(SubagentToolkit); + yield* installPeerSubagentCompatibility; + }), +).pipe(Layer.provide(SubagentToolkitHandlersLive), Layer.provide(McpServer.McpServer.layer)); export const NotifyToolkitRegistrationLive = McpServer.toolkit(NotifyToolkit).pipe( Layer.provide(NotifyToolkitHandlersLive), diff --git a/apps/server/src/mcp/McpOfficialClientConformance.test.ts b/apps/server/src/mcp/McpOfficialClientConformance.test.ts index 3980870b9a0a..71c087beff2f 100644 --- a/apps/server/src/mcp/McpOfficialClientConformance.test.ts +++ b/apps/server/src/mcp/McpOfficialClientConformance.test.ts @@ -43,11 +43,9 @@ const environmentId = EnvironmentId.make("environment-mcp-conformance"); const threadId = ThreadId.make("thread-mcp-conformance"); const providerInstanceId = ProviderInstanceId.make("codex"); -const expectedToolNamesAfterPreviewRemoval = [ - "t3_check_subagent", +const expectedToolNamesAfterSpawnTrim = [ "t3_get_usage", "t3_list_backends", - "t3_list_subagents", "t3_notify", "t3_schedule_create", "t3_schedule_delete", @@ -55,7 +53,13 @@ const expectedToolNamesAfterPreviewRemoval = [ "t3_schedule_update", "t3_spawn_subagent", "t3_steer_subagent", + "t3_subagents", "t3_thread_start", +] as const; + +const removedSubagentToolNames = [ + "t3_check_subagent", + "t3_list_subagents", "t3_wait_subagent", ] as const; @@ -275,12 +279,35 @@ it.effect("conforms to the official streamable HTTP MCP client", () => const toolsResult = yield* Effect.promise(() => client.listTools()); ListToolsResultSchema.parse(toolsResult); const toolNames = new Set(toolsResult.tools.map((tool) => tool.name)); - expect([...toolNames].toSorted()).toEqual([...expectedToolNamesAfterPreviewRemoval]); + expect([...toolNames].toSorted()).toEqual([...expectedToolNamesAfterSpawnTrim]); for (const removedName of removedPreviewToolNames) { expect(toolNames.has(removedName), `${removedName} must be absent from tools/list`).toBe( false, ); } + for (const removedName of removedSubagentToolNames) { + expect(toolNames.has(removedName), `${removedName} must be absent from tools/list`).toBe( + false, + ); + } + + for (const toolName of ["t3_spawn_subagent", "t3_thread_start"] as const) { + const tool = toolsResult.tools.find((candidate) => candidate.name === toolName); + expect(tool, `${toolName} must be present`).toBeDefined(); + expect(Object.keys(tool?.inputSchema.properties ?? {}).toSorted()).toEqual([ + "branch", + "directory", + "model", + "prompt", + "reasoningEffort", + "title", + ]); + expect([...(tool?.inputSchema.required ?? [])].toSorted()).toEqual([ + "model", + "prompt", + "title", + ]); + } for (const tool of toolsResult.tools) { expect(tool.inputSchema.type, `${tool.name} input schema must be an object`).toBe("object"); diff --git a/apps/server/src/mcp/toolSchemas.test.ts b/apps/server/src/mcp/toolSchemas.test.ts index 421a14054d40..7d0f4fd447fd 100644 --- a/apps/server/src/mcp/toolSchemas.test.ts +++ b/apps/server/src/mcp/toolSchemas.test.ts @@ -32,6 +32,6 @@ it("every MCP tool advertises a top-level object input schema", () => { } // Guard the guard: if toolkit wiring changes shape, this test must not // silently pass on an empty list. - expect(checked).toHaveLength(13); + expect(checked).toHaveLength(11); expect(checked).toContain("t3_list_backends"); }); diff --git a/apps/server/src/mcp/toolkits/subagent/handlers.test.ts b/apps/server/src/mcp/toolkits/subagent/handlers.test.ts index 08f7ddc05ef5..b59df490d0bc 100644 --- a/apps/server/src/mcp/toolkits/subagent/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/subagent/handlers.test.ts @@ -30,9 +30,6 @@ import { ChildThreadCoordinator, type ChildListEntry, type ChildThreadCoordinatorShape, - type WaitDeliveredMark, - type WaitSliceInput, - type WaitSliceResult, } from "../../../orchestration/Services/ChildThreadCoordinator.ts"; import { GitWorkflowService } from "../../../git/GitWorkflowService.ts"; import { ActiveChildThreadCoordinatorLive } from "../../../orchestration/Layers/ChildThreadCoordinator.ts"; @@ -127,12 +124,6 @@ const entitledPeerInvocation: McpInvocationContext.PeerMcpInvocationScope = { allowedParentThreadIds: new Set([parentThreadId]), allowedChildThreadIds: new Set([childThreadId]), }; -const childOnlyPeerInvocation: McpInvocationContext.PeerMcpInvocationScope = { - ...peerInvocation, - peerTokenId: "peer-subagent-child-only-test", - allowedChildThreadIds: new Set([childThreadId]), -}; - const client = McpSchema.McpServerClient.of({ clientId: 1, initializePayload: { @@ -240,10 +231,7 @@ const makeChildShell = (turnState: "completed" | "running" = "completed") => ({ // Test seams: mutable holders the per-test layers reconfigure before driving a // tool call. Each test sets the slice result / records the side effects it // asserts on. -let waitSliceResult: WaitSliceResult | null = null; let failCoordinatorRegister = false; -let waitSliceEffect: ((input: WaitSliceInput) => Effect.Effect) | null = null; -const waitSliceCalls: WaitSliceInput[] = []; const registeredChildren: Array<{ readonly childThreadId: ThreadId; readonly parentThreadId: ThreadId; @@ -257,10 +245,6 @@ let childDetailCallCount = 0; // idle/mid-turn + defer/dispatch decision. Defaults: idle child, unknown driver. let childTurnState: "completed" | "running" = "completed"; let childDriverKind: string | undefined = undefined; -const promotedCalls: Array> = []; -let promoteToWakeDefect: unknown | null = null; -const markWaitDeliveredCalls: Array> = []; -const abandonWaitDeliveryCalls: Array> = []; let listChildrenOverride: ReadonlyArray | null = null; const enqueuedParentInjections: Array<{ readonly parentThreadId: ThreadId; @@ -313,7 +297,6 @@ const updatedTasks: Array = []; const peerEnvironmentId = EnvironmentId.make("environment-peer-b"); const remoteChildThreadId = ThreadId.make("thread-remote-child"); -const remoteProjectId = ProjectId.make("project-remote-child"); const bearerPeer = (): SubagentPeerRegistry.SubagentPeer => ({ alias: "peer-b", environmentId: peerEnvironmentId, @@ -356,6 +339,7 @@ type RemoteCheckFixture = { readonly turnCount: number; readonly latestAssistantText: string | null; }; +type RemoteCheckResponse = RemoteCheckFixture | { readonly missingToolRpc: string }; const decodeHttpClientRequestJson = (request: HttpClientRequest.HttpClientRequest) => { const rawBody = (request.body as { readonly body?: Uint8Array }).body; @@ -363,12 +347,15 @@ const decodeHttpClientRequestJson = (request: HttpClientRequest.HttpClientReques return JSON.parse(new TextDecoder().decode(rawBody)) as { readonly id?: number; readonly method: string; - readonly params?: { readonly arguments?: Record }; + readonly params?: { + readonly name?: string; + readonly arguments?: Record; + }; }; }; const remoteCheckPeerHandlerWith = - (resolveCheck: (body: ReturnType) => RemoteCheckFixture) => + (resolveCheck: (body: ReturnType) => RemoteCheckResponse) => (request: HttpClientRequest.HttpClientRequest) => { if (request.url === "https://peer.example/mcp" && request.method === "DELETE") { return Effect.succeed( @@ -401,6 +388,19 @@ const remoteCheckPeerHandlerWith = } if (body.method === "tools/call") { const check = resolveCheck(body); + if ("missingToolRpc" in check) { + return HttpClientResponse.fromWeb( + request, + Response.json({ + jsonrpc: "2.0", + id: body.id ?? 2, + error: { + code: -32602, + message: `InvalidParams: Tool '${check.missingToolRpc}' not found`, + }, + }), + ); + } return HttpClientResponse.fromWeb( request, Response.json( @@ -581,18 +581,7 @@ const coordinatorLayer = Layer.succeed(ChildThreadCoordinator, { parentThreadId: input.parentThreadId, }); }), - waitSlice: (input) => - Effect.sync(() => { - waitSliceCalls.push(input); - }).pipe( - Effect.flatMap(() => - waitSliceEffect !== null - ? waitSliceEffect(input) - : waitSliceResult - ? Effect.succeed(waitSliceResult) - : unsupported(), - ), - ), + waitSlice: () => unsupported(), assertParent: (parent, child) => Effect.sync(() => { assertParentCalls.push({ parentThreadId: parent, childThreadId: child }); @@ -603,14 +592,9 @@ const coordinatorLayer = Layer.succeed(ChildThreadCoordinator, { : Effect.void, ), ), - promoteToWake: (ids) => - Effect.sync(() => void promotedCalls.push(ids)).pipe( - Effect.flatMap(() => - promoteToWakeDefect === null ? Effect.void : Effect.die(promoteToWakeDefect), - ), - ), - markWaitDelivered: (marks) => Effect.sync(() => void markWaitDeliveredCalls.push(marks)), - abandonWaitDelivery: (ids) => Effect.sync(() => void abandonWaitDeliveryCalls.push(ids)), + promoteToWake: () => Effect.void, + markWaitDelivered: () => Effect.void, + abandonWaitDelivery: () => Effect.void, hasPendingInjections: () => Effect.succeed(false), enqueueParentInjection: (input) => Effect.gen(function* () { @@ -931,11 +915,11 @@ describe("SubagentToolkit", () => { Effect.gen(function* () { const server = yield* McpServer.McpServer; - const listTool = server.tools.find(({ tool }) => tool.name === "t3_list_subagents"); + const listTool = server.tools.find(({ tool }) => tool.name === "t3_subagents"); expect(listTool?.tool.annotations?.readOnlyHint).toBe(true); const result = yield* server - .callTool({ name: "t3_list_subagents", arguments: {} }) + .callTool({ name: "t3_subagents", arguments: {} }) .pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), Effect.provideService(McpSchema.McpServerClient, client), @@ -963,6 +947,7 @@ describe("SubagentToolkit", () => { Effect.gen(function* () { const server = yield* McpServer.McpServer; childDriverKind = "codex"; + modelInstances = [makeModelInstance("codex", "codex", ["gpt-5-codex"])]; dispatchedTurns.length = 0; dispatchedTurnCommands.length = 0; registeredChildren.length = 0; @@ -974,9 +959,8 @@ describe("SubagentToolkit", () => { name: "t3_spawn_subagent", arguments: { prompt: "run as a normal thread", + model: "gpt-5-codex", title: "plain child", - mode: "current_checkout", - detached: true, }, }) .pipe( @@ -998,6 +982,7 @@ describe("SubagentToolkit", () => { Effect.ensuring( Effect.sync(() => { childDriverKind = undefined; + modelInstances = []; dispatchedTurns.length = 0; dispatchedTurnCommands.length = 0; registeredChildren.length = 0; @@ -1009,100 +994,25 @@ describe("SubagentToolkit", () => { ), ); - it.effect("spawns a remote child through a resolved peer target and records it", () => + it.effect("defaults a bare Codex spawn to xhigh reasoning effort", () => Effect.scoped( Effect.gen(function* () { const server = yield* McpServer.McpServer; - peerRegistryPeers = [bearerPeer()]; - peerHttpRequests.length = 0; - insertedRemoteChildren.length = 0; - registeredChildren.length = 0; - let capturedRemoteArguments: Record | undefined; - peerHttpHandler = (request) => { - if (request.url === "https://peer.example/.well-known/t3/environment") { - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ - environmentId: peerEnvironmentId, - label: "Peer B", - platform: { os: "linux", arch: "x64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }), - ), - ); - } - if (request.url === "https://peer.example/mcp" && request.method === "DELETE") { - return Effect.succeed( - HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })), - ); - } - if (request.url === "https://peer.example/mcp" && request.method === "POST") { - return Effect.sync(() => { - const body = decodeHttpClientRequestJson(request); - if (body.method === "initialize") { - return HttpClientResponse.fromWeb( - request, - Response.json( - jsonRpcResponse(body.id ?? 1, { - protocolVersion: "2025-06-18", - capabilities: { tools: {} }, - serverInfo: { name: "peer", version: "0.0.0-test" }, - }), - { - headers: { - "mcp-session-id": "session-remote-spawn", - "mcp-protocol-version": "2025-06-18", - }, - }, - ), - ); - } - if (body.method === "notifications/initialized") { - return HttpClientResponse.fromWeb(request, new Response(null, { status: 202 })); - } - if (body.method === "tools/call") { - capturedRemoteArguments = body.params?.arguments; - return HttpClientResponse.fromWeb( - request, - Response.json( - jsonRpcResponse(body.id ?? 2, { - content: [{ type: "text", text: "spawned" }], - structuredContent: { - childThreadId: remoteChildThreadId, - projectId: remoteProjectId, - mode: "current_checkout", - branch: null, - worktreePath: "/remote/repo", - parentThreadId, - }, - isError: false, - }), - ), - ); - } - return HttpClientResponse.fromWeb( - request, - Response.json({ error: "unexpected method" }, { status: 500 }), - ); - }); - } - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ error: "unexpected request" }, { status: 404 }), - ), - ); - }; + childDriverKind = "codex"; + modelInstances = [ + makeModelInstance("codex", "codex", [ + { slug: "gpt-5.4", optionId: "reasoningEffort", value: "low" }, + ]), + ]; + dispatchedTurnCommands.length = 0; const result = yield* server .callTool({ name: "t3_spawn_subagent", arguments: { - prompt: "run elsewhere", - target: "peer-b", - directory: "/remote/repo", + prompt: "prove the default effort", + model: "gpt-5.4", + title: "xhigh default", }, }) .pipe( @@ -1111,340 +1021,142 @@ describe("SubagentToolkit", () => { ); expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ - childThreadId: remoteChildThreadId, - parentThreadId, - }); - expect(capturedRemoteArguments).toMatchObject({ - prompt: "run elsewhere", - directory: "/remote/repo", - detached: true, - remoteParentThreadId: parentThreadId, - remoteParentEnvironmentId: environmentId, - }); - expect(insertedRemoteChildren).toHaveLength(1); - expect(insertedRemoteChildren[0]).toMatchObject({ - parentThreadId, - childEnvironmentId: peerEnvironmentId, - childThreadId: remoteChildThreadId, - alias: "peer-b", - status: "running", + expect(dispatchedTurnCommands[0]).toMatchObject({ + type: "thread.turn.start", + modelSelection: { + instanceId: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, }); - expect(registeredChildren).toEqual([]); - expect(peerHttpRequests.map((request) => request.url)).toEqual([ - "https://peer.example/.well-known/t3/environment", - "https://peer.example/mcp", - "https://peer.example/mcp", - "https://peer.example/mcp", - "https://peer.example/mcp", - ]); }), ).pipe( Effect.ensuring( Effect.sync(() => { - peerRegistryPeers = []; - peerHttpHandler = null; - peerHttpRequests.length = 0; - insertedRemoteChildren.length = 0; - registeredChildren.length = 0; + childDriverKind = undefined; + modelInstances = []; + dispatchedTurnCommands.length = 0; }), ), Effect.provide(TestLayer), ), ); - it.effect("does not locally timeout a side-effecting remote spawn tools call", () => + it.effect("applies an explicit Codex spawn reasoning-effort override", () => Effect.scoped( Effect.gen(function* () { const server = yield* McpServer.McpServer; - const toolCallStarted = yield* Deferred.make(); - peerRegistryPeers = [bearerPeer()]; - insertedRemoteChildren.length = 0; - peerHttpHandler = (request) => { - if (request.url === "https://peer.example/.well-known/t3/environment") { - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ - environmentId: peerEnvironmentId, - label: "Peer B", - platform: { os: "linux", arch: "x64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }), - ), - ); - } - if (request.url === "https://peer.example/mcp" && request.method === "DELETE") { - return Effect.succeed( - HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })), - ); - } - if (request.url === "https://peer.example/mcp" && request.method === "POST") { - return Effect.gen(function* () { - const body = decodeHttpClientRequestJson(request); - if (body.method === "initialize") { - return HttpClientResponse.fromWeb( - request, - Response.json( - jsonRpcResponse(body.id ?? 1, { - protocolVersion: "2025-06-18", - capabilities: { tools: {} }, - serverInfo: { name: "peer", version: "0.0.0-test" }, - }), - { - headers: { - "mcp-session-id": "session-remote-spawn-slow", - "mcp-protocol-version": "2025-06-18", - }, - }, - ), - ); - } - if (body.method === "notifications/initialized") { - return HttpClientResponse.fromWeb(request, new Response(null, { status: 202 })); - } - if (body.method === "tools/call") { - yield* Deferred.succeed(toolCallStarted, void 0); - yield* Effect.sleep(Duration.seconds(6)); - return HttpClientResponse.fromWeb( - request, - Response.json( - jsonRpcResponse(body.id ?? 2, { - content: [{ type: "text", text: "spawned slowly" }], - structuredContent: { - childThreadId: remoteChildThreadId, - projectId: remoteProjectId, - mode: "current_checkout", - branch: null, - worktreePath: "/remote/repo", - parentThreadId, - }, - isError: false, - }), - ), - ); - } - return HttpClientResponse.fromWeb( - request, - Response.json({ error: "unexpected method" }, { status: 500 }), - ); - }); - } - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ error: "unexpected request" }, { status: 404 }), - ), - ); - }; + childDriverKind = "codex"; + modelInstances = [ + makeModelInstance("codex", "codex", [ + { slug: "gpt-5.4", optionId: "reasoningEffort", value: "low" }, + ]), + ]; + dispatchedTurnCommands.length = 0; - const fiber = yield* server + const result = yield* server .callTool({ name: "t3_spawn_subagent", arguments: { - prompt: "run slowly elsewhere", - target: "peer-b", - directory: "/remote/repo", + prompt: "use the requested effort", + model: "gpt-5.4", + title: "effort override", + reasoningEffort: "high", }, }) .pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), Effect.provideService(McpSchema.McpServerClient, client), - Effect.forkScoped, ); - yield* Deferred.await(toolCallStarted); - yield* TestClock.adjust(Duration.seconds(6)); - const result = yield* Fiber.join(fiber); expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ - childThreadId: remoteChildThreadId, - parentThreadId, - }); - expect(insertedRemoteChildren).toHaveLength(1); - expect(insertedRemoteChildren[0]).toMatchObject({ - parentThreadId, - childEnvironmentId: peerEnvironmentId, - childThreadId: remoteChildThreadId, - status: "running", + expect(dispatchedTurnCommands[0]).toMatchObject({ + type: "thread.turn.start", + modelSelection: { + instanceId: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "high" }], + }, }); }), ).pipe( Effect.ensuring( Effect.sync(() => { - peerRegistryPeers = []; - peerHttpHandler = null; - insertedRemoteChildren.length = 0; + childDriverKind = undefined; + modelInstances = []; + dispatchedTurnCommands.length = 0; }), ), Effect.provide(TestLayer), ), ); - it.effect("records a remote spawn when MCP session cleanup stalls", () => + it.effect("refuses sub-agent detail for a child owned by another parent", () => Effect.scoped( Effect.gen(function* () { const server = yield* McpServer.McpServer; - const deleteStarted = yield* Deferred.make(); - peerRegistryPeers = [bearerPeer()]; - insertedRemoteChildren.length = 0; - peerHttpHandler = (request) => { - if (request.url === "https://peer.example/.well-known/t3/environment") { - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ - environmentId: peerEnvironmentId, - label: "Peer B", - platform: { os: "linux", arch: "x64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }), - ), - ); - } - if (request.url === "https://peer.example/mcp" && request.method === "DELETE") { - return Effect.gen(function* () { - yield* Deferred.succeed(deleteStarted, void 0); - return yield* Effect.never; - }); - } - if (request.url === "https://peer.example/mcp" && request.method === "POST") { - return Effect.sync(() => { - const body = decodeHttpClientRequestJson(request); - if (body.method === "initialize") { - return HttpClientResponse.fromWeb( - request, - Response.json( - jsonRpcResponse(body.id ?? 1, { - protocolVersion: "2025-06-18", - capabilities: { tools: {} }, - serverInfo: { name: "peer", version: "0.0.0-test" }, - }), - { - headers: { - "mcp-session-id": "session-remote-spawn-cleanup-hangs", - "mcp-protocol-version": "2025-06-18", - }, - }, - ), - ); - } - if (body.method === "notifications/initialized") { - return HttpClientResponse.fromWeb(request, new Response(null, { status: 202 })); - } - if (body.method === "tools/call") { - return HttpClientResponse.fromWeb( - request, - Response.json( - jsonRpcResponse(body.id ?? 2, { - content: [{ type: "text", text: "spawned before cleanup stalled" }], - structuredContent: { - childThreadId: remoteChildThreadId, - projectId: remoteProjectId, - mode: "current_checkout", - branch: null, - worktreePath: "/remote/repo", - parentThreadId, - }, - isError: false, - }), - ), - ); - } - return HttpClientResponse.fromWeb( - request, - Response.json({ error: "unexpected method" }, { status: 500 }), - ); - }); - } - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ error: "unexpected request" }, { status: 404 }), - ), - ); - }; + assertParentFailureChild = childThreadId; + assertParentCalls.length = 0; - const fiber = yield* server - .callTool({ - name: "t3_spawn_subagent", - arguments: { - prompt: "run elsewhere before cleanup stalls", - target: "peer-b", - directory: "/remote/repo", - }, - }) + const result = yield* server + .callTool({ name: "t3_subagents", arguments: { childThreadId } }) .pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), Effect.provideService(McpSchema.McpServerClient, client), - Effect.forkScoped, ); - yield* Deferred.await(deleteStarted); - yield* TestClock.adjust(Duration.seconds(3)); - const result = yield* Fiber.join(fiber); - expect(result.isError).toBe(false); - expect(insertedRemoteChildren).toHaveLength(1); - expect(insertedRemoteChildren[0]).toMatchObject({ - parentThreadId, - childEnvironmentId: peerEnvironmentId, - childThreadId: remoteChildThreadId, - status: "running", - }); + expect(result.isError).toBe(true); + expect(assertParentCalls).toContainEqual({ parentThreadId, childThreadId }); }), ).pipe( Effect.ensuring( Effect.sync(() => { - peerRegistryPeers = []; - peerHttpHandler = null; - insertedRemoteChildren.length = 0; + assertParentFailureChild = null; + assertParentCalls.length = 0; }), ), Effect.provide(TestLayer), ), ); - it.effect("fails remote spawn before probing when target is unknown", () => + it.effect("keeps the legacy check alias available to authenticated peers", () => Effect.scoped( Effect.gen(function* () { const server = yield* McpServer.McpServer; - peerRegistryPeers = [bearerPeer()]; - peerHttpRequests.length = 0; - const result = yield* server - .callTool({ - name: "t3_spawn_subagent", - arguments: { - prompt: "run nowhere", - target: "missing", - directory: "/remote/repo", - }, - }) + .callTool({ name: "t3_check_subagent", arguments: { childThreadId } }) + .pipe( + Effect.provideService( + McpInvocationContext.McpInvocationContext, + entitledPeerInvocation, + ), + Effect.provideService(McpSchema.McpServerClient, client), + ); + + expect(result.isError).toBe(false); + expect(result.structuredContent).toMatchObject({ + threadId: childThreadId, + latestAssistantText: "child done", + }); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("keeps the legacy check alias unknown to provider sessions", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const error = yield* server + .callTool({ name: "t3_check_subagent", arguments: { childThreadId } }) .pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), Effect.provideService(McpSchema.McpServerClient, client), + Effect.flip, ); - expect(result.isError).toBe(true); - const content = result.content?.[0]; - expect(content?.type).toBe("text"); - if (content?.type === "text") { - expect(content.text).toContain("Subagent peer target 'missing' is not registered"); - expect(content.text).toContain("peer-b"); - } - expect(peerHttpRequests).toEqual([]); + expect(error.message).toContain("Tool 't3_check_subagent' not found"); }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - peerRegistryPeers = []; - peerHttpRequests.length = 0; - }), - ), - Effect.provide(TestLayer), - ), + ).pipe(Effect.provide(TestLayer)), ); it.effect("checks a remote child by environment id when its recorded alias was reused", () => @@ -1475,7 +1187,7 @@ describe("SubagentToolkit", () => { const result = yield* server .callTool({ - name: "t3_check_subagent", + name: "t3_subagents", arguments: { childThreadId: remoteChildThreadId }, }) .pipe( @@ -1509,6 +1221,48 @@ describe("SubagentToolkit", () => { ), ); + it.effect("falls back to the legacy peer check during a rolling upgrade", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + peerRegistryPeers = [bearerPeer()]; + remoteChildRows = [remoteChildRow("running")]; + remoteChildPollerRows = []; + const calledTools: string[] = []; + peerHttpHandler = remoteCheckPeerHandlerWith((body) => { + const toolName = body.params?.name ?? ""; + calledTools.push(toolName); + return toolName === "t3_subagents" + ? { missingToolRpc: toolName } + : { status: "running", turnCount: 1, latestAssistantText: null }; + }); + + const result = yield* server + .callTool({ + name: "t3_subagents", + arguments: { childThreadId: remoteChildThreadId }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + + expect(result.isError).toBe(false); + expect(calledTools).toEqual(["t3_subagents", "t3_check_subagent"]); + }), + ).pipe( + Effect.ensuring( + Effect.sync(() => { + peerRegistryPeers = []; + remoteChildRows = []; + remoteChildPollerRows = null; + peerHttpHandler = null; + }), + ), + Effect.provide(TestLayer), + ), + ); + it.effect( "fails remote child polling when environment-id fallback resolves an alias collision", () => @@ -1541,7 +1295,7 @@ describe("SubagentToolkit", () => { const result = yield* server .callTool({ - name: "t3_check_subagent", + name: "t3_subagents", arguments: { childThreadId: remoteChildThreadId }, }) .pipe( @@ -1593,7 +1347,7 @@ describe("SubagentToolkit", () => { const result = yield* server .callTool({ - name: "t3_check_subagent", + name: "t3_subagents", arguments: { childThreadId: remoteChildThreadId }, }) .pipe( @@ -1637,7 +1391,7 @@ describe("SubagentToolkit", () => { const duplicate = yield* server .callTool({ - name: "t3_check_subagent", + name: "t3_subagents", arguments: { childThreadId: remoteChildThreadId }, }) .pipe( @@ -1692,7 +1446,7 @@ describe("SubagentToolkit", () => { const first = yield* server .callTool({ - name: "t3_check_subagent", + name: "t3_subagents", arguments: { childThreadId: remoteChildThreadId }, }) .pipe( @@ -1708,7 +1462,7 @@ describe("SubagentToolkit", () => { const second = yield* server .callTool({ - name: "t3_check_subagent", + name: "t3_subagents", arguments: { childThreadId: remoteChildThreadId }, }) .pipe( @@ -1772,7 +1526,7 @@ describe("SubagentToolkit", () => { const fiber = yield* server .callTool({ - name: "t3_check_subagent", + name: "t3_subagents", arguments: { childThreadId: remoteChildThreadId }, }) .pipe( @@ -1821,7 +1575,7 @@ describe("SubagentToolkit", () => { const result = yield* server .callTool({ - name: "t3_check_subagent", + name: "t3_subagents", arguments: { childThreadId: remoteChildThreadId }, }) .pipe( @@ -1853,27 +1607,22 @@ describe("SubagentToolkit", () => { ), ); - it.effect("waits for a remote child through the peer check proxy", () => + it.effect("lists remote children alongside local children", () => Effect.scoped( Effect.gen(function* () { const server = yield* McpServer.McpServer; peerRegistryPeers = [bearerPeer()]; remoteChildRows = [remoteChildRow("running")]; - remoteChildPollerRows = []; - enqueuedParentInjections.length = 0; - updatedRemoteChildren.length = 0; - remoteTerminalDeliveryEvents.length = 0; - remoteTerminalDeliveryClaims.clear(); peerHttpHandler = remoteCheckPeerHandler({ - status: "completed", - turnCount: 2, - latestAssistantText: "wait saw remote done", + status: "running", + turnCount: 1, + latestAssistantText: null, }); const result = yield* server .callTool({ - name: "t3_wait_subagent", - arguments: { childThreadIds: [remoteChildThreadId], timeoutSeconds: 1 }, + name: "t3_subagents", + arguments: { parentThreadId }, }) .pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), @@ -1881,133 +1630,26 @@ describe("SubagentToolkit", () => { ); expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ - results: [ - { - childThreadId: remoteChildThreadId, - status: "completed", - turnCount: 2, - finalAssistantText: "wait saw remote done", - error: null, - }, - ], - settledCount: 1, - timedOutCount: 0, - pending: false, - }); - expect(enqueuedParentInjections).toHaveLength(0); - expect(updatedRemoteChildren).toEqual([ - expect.objectContaining({ - parentThreadId, - childEnvironmentId: peerEnvironmentId, - childThreadId: remoteChildThreadId, - status: "completed", - }), - ]); - expect(remoteTerminalDeliveryEvents).toEqual(["claim", "mark"]); - }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - peerRegistryPeers = []; - remoteChildRows = []; - remoteChildPollerRows = null; - enqueuedParentInjections.length = 0; - updatedRemoteChildren.length = 0; - remoteTerminalDeliveryEvents.length = 0; - remoteTerminalDeliveryClaims.clear(); - peerHttpHandler = null; - }), - ), - Effect.provide(TestLayer), - ), - ); - - it.effect("treats interrupted remote children as settled for wait any", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const runningChildThreadId = ThreadId.make("thread-remote-child-running"); - peerRegistryPeers = [bearerPeer()]; - remoteChildRows = [ - remoteChildRow("running"), - { - ...remoteChildRow("running"), - childThreadId: runningChildThreadId, - spawnParams: { prompt: "remote running", directory: "/remote/repo", detached: true }, - }, - ]; - remoteChildPollerRows = []; - enqueuedParentInjections.length = 0; - updatedRemoteChildren.length = 0; - remoteTerminalDeliveryEvents.length = 0; - remoteTerminalDeliveryClaims.clear(); - peerHttpHandler = remoteCheckPeerHandlerWith((body) => { - const requestedThreadId = body.params?.arguments?.childThreadId; - return requestedThreadId === runningChildThreadId - ? { - threadId: runningChildThreadId, - status: "running", - turnCount: 1, - latestAssistantText: null, - } - : { - threadId: remoteChildThreadId, - status: "interrupted", - turnCount: 2, - latestAssistantText: "interrupted final text", - }; - }); - - const result = yield* server - .callTool({ - name: "t3_wait_subagent", - arguments: { - childThreadIds: [remoteChildThreadId, runningChildThreadId], - timeoutSeconds: 1, - mode: "any", - }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ - settledCount: 1, - timedOutCount: 0, - pending: false, - }); - const results = (result.structuredContent as { readonly results?: ReadonlyArray }) - .results; - expect(results).toEqual( + const children = ( + result.structuredContent as { readonly children?: ReadonlyArray } + ).children; + expect(children).toEqual( expect.arrayContaining([ expect.objectContaining({ childThreadId: remoteChildThreadId, - status: "interrupted", - turnCount: 2, - finalAssistantText: "interrupted final text", - }), - expect.objectContaining({ - childThreadId: runningChildThreadId, - status: "pending", + parentThreadId, + detached: true, + status: "running", turnCount: 1, }), ]), ); - expect(enqueuedParentInjections).toHaveLength(0); }), ).pipe( Effect.ensuring( Effect.sync(() => { peerRegistryPeers = []; remoteChildRows = []; - remoteChildPollerRows = null; - enqueuedParentInjections.length = 0; - updatedRemoteChildren.length = 0; - remoteTerminalDeliveryEvents.length = 0; - remoteTerminalDeliveryClaims.clear(); peerHttpHandler = null; }), ), @@ -2015,2108 +1657,269 @@ describe("SubagentToolkit", () => { ), ); - it.effect("restores a remote terminal wake when a sibling wait poll fails", () => + it.effect("peer-scoped receiver spawn requires directory and records remote parent", () => Effect.scoped( Effect.gen(function* () { const server = yield* McpServer.McpServer; - const failingChildThreadId = ThreadId.make("thread-remote-child-failing"); - const wrongThreadId = ThreadId.make("thread-remote-child-wrong"); - peerRegistryPeers = [bearerPeer()]; - remoteChildRows = [ - remoteChildRow("running"), - { - ...remoteChildRow("running"), - childThreadId: failingChildThreadId, - spawnParams: { prompt: "remote failing", directory: "/remote/repo", detached: true }, - }, - ]; - remoteChildPollerRows = []; - enqueuedParentInjections.length = 0; - updatedRemoteChildren.length = 0; - remoteTerminalDeliveryEvents.length = 0; - remoteTerminalDeliveryClaims.clear(); - peerHttpHandler = remoteCheckPeerHandlerWith((body) => { - const requestedThreadId = body.params?.arguments?.childThreadId; - return requestedThreadId === failingChildThreadId - ? { - threadId: wrongThreadId, - status: "completed", - turnCount: 1, - latestAssistantText: "wrong child", - } - : { - threadId: remoteChildThreadId, - status: "completed", - turnCount: 2, - latestAssistantText: "wait consumed before sibling failed", - }; + const fileSystem = yield* FileSystem.FileSystem; + const targetDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-peer-spawn-target-", }); + activeProjectShell = { + ...parentProject, + workspaceRoot: targetDirectory, + repositoryIdentity: { + canonicalKey: `git-local:${targetDirectory}`, + locator: { + source: "git-local", + rootPath: targetDirectory, + }, + rootPath: targetDirectory, + }, + }; + engineCommands.length = 0; + dispatchedTurnCommands.length = 0; + registeredChildren.length = 0; + const sourceEnvironmentId = EnvironmentId.make("environment-source-a"); const result = yield* server .callTool({ - name: "t3_wait_subagent", + name: "t3_spawn_subagent", arguments: { - childThreadIds: [remoteChildThreadId, failingChildThreadId], - timeoutSeconds: 1, - mode: "all", + prompt: "run on receiver", + directory: targetDirectory, + detached: true, + remoteParentThreadId: parentThreadId, }, }) .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpInvocationContext.McpInvocationContext, { + ...unrestrictedPeerInvocation, + sourceEnvironmentId, + }), Effect.provideService(McpSchema.McpServerClient, client), ); - expect(result.isError).toBe(true); - expect(enqueuedParentInjections).toEqual([ - { - parentThreadId, - childThreadId: remoteChildThreadId, - status: "completed", - finalAssistantText: "wait consumed before sibling failed", - error: null, - }, - ]); - expect(updatedRemoteChildren).toEqual([ - expect.objectContaining({ - parentThreadId, - childEnvironmentId: peerEnvironmentId, - childThreadId: remoteChildThreadId, - status: "completed", - }), - ]); - expect(remoteTerminalDeliveryEvents).toEqual(["claim", "enqueue", "mark"]); + expect(result.isError).toBe(false); + expect(dispatchedTurnCommands).toHaveLength(1); + expect(registeredChildren).toEqual([]); + const parentSetCommand = engineCommands.find( + (command): command is Extract => + command.type === "thread.parent.set", + ); + expect(parentSetCommand).toMatchObject({ + type: "thread.parent.set", + parentThreadId, + parentEnvironmentId: sourceEnvironmentId, + }); + expect(result.structuredContent).toMatchObject({ + parentThreadId, + }); }), ).pipe( Effect.ensuring( Effect.sync(() => { - peerRegistryPeers = []; - remoteChildRows = []; - remoteChildPollerRows = null; - enqueuedParentInjections.length = 0; - updatedRemoteChildren.length = 0; - remoteTerminalDeliveryEvents.length = 0; - remoteTerminalDeliveryClaims.clear(); - peerHttpHandler = null; + activeProjectShell = parentProject; + engineCommands.length = 0; + dispatchedTurnCommands.length = 0; + registeredChildren.length = 0; }), ), Effect.provide(TestLayer), ), ); - it.effect("releases a suppressed remote wake claim when restoration enqueue fails", () => + it.effect("rejects peer-scoped receiver spawn with a spoofed parent environment id", () => Effect.scoped( Effect.gen(function* () { const server = yield* McpServer.McpServer; - const failingChildThreadId = ThreadId.make("thread-remote-child-failing"); - const wrongThreadId = ThreadId.make("thread-remote-child-wrong"); - peerRegistryPeers = [bearerPeer()]; - remoteChildRows = [ - remoteChildRow("running"), - { - ...remoteChildRow("running"), - childThreadId: failingChildThreadId, - spawnParams: { prompt: "remote failing", directory: "/remote/repo", detached: true }, - }, - ]; - remoteChildPollerRows = []; - enqueuedParentInjections.length = 0; - updatedRemoteChildren.length = 0; - remoteTerminalDeliveryEvents.length = 0; - remoteTerminalDeliveryClaims.clear(); - remoteTerminalDeliveryFailure = new ThreadStartToolError({ message: "enqueue failed" }); - peerHttpHandler = remoteCheckPeerHandlerWith((body) => { - const requestedThreadId = body.params?.arguments?.childThreadId; - return requestedThreadId === failingChildThreadId - ? { - threadId: wrongThreadId, - status: "completed", - turnCount: 1, - latestAssistantText: "wrong child", - } - : { - threadId: remoteChildThreadId, - status: "completed", - turnCount: 2, - latestAssistantText: "restore should release on enqueue failure", - }; + const fileSystem = yield* FileSystem.FileSystem; + const targetDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-peer-spawn-target-", }); + activeProjectShell = { + ...parentProject, + workspaceRoot: targetDirectory, + repositoryIdentity: { + canonicalKey: `git-local:${targetDirectory}`, + locator: { + source: "git-local", + rootPath: targetDirectory, + }, + rootPath: targetDirectory, + }, + }; + engineCommands.length = 0; + dispatchedTurnCommands.length = 0; + const sourceEnvironmentId = EnvironmentId.make("environment-source-a"); + const spoofedEnvironmentId = EnvironmentId.make("environment-spoofed"); const result = yield* server .callTool({ - name: "t3_wait_subagent", + name: "t3_spawn_subagent", arguments: { - childThreadIds: [remoteChildThreadId, failingChildThreadId], - timeoutSeconds: 1, - mode: "all", + prompt: "run on receiver", + directory: targetDirectory, + detached: true, + remoteParentThreadId: parentThreadId, + remoteParentEnvironmentId: spoofedEnvironmentId, }, }) .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpInvocationContext.McpInvocationContext, { + ...unrestrictedPeerInvocation, + sourceEnvironmentId, + }), Effect.provideService(McpSchema.McpServerClient, client), ); expect(result.isError).toBe(true); - expect(enqueuedParentInjections).toEqual([]); - expect(updatedRemoteChildren).toEqual([]); - expect(remoteTerminalDeliveryClaims.size).toBe(0); - expect(remoteTerminalDeliveryEvents).toEqual(["claim", "release"]); + const content = result.content?.[0]; + expect(content?.type).toBe("text"); + if (content?.type === "text") { + expect(content.text).toContain("does not match the authenticated caller backend"); + } + expect(dispatchedTurnCommands).toEqual([]); + expect(engineCommands).toEqual([]); }), ).pipe( Effect.ensuring( Effect.sync(() => { - peerRegistryPeers = []; - remoteChildRows = []; - remoteChildPollerRows = null; - enqueuedParentInjections.length = 0; - updatedRemoteChildren.length = 0; - remoteTerminalDeliveryEvents.length = 0; - remoteTerminalDeliveryClaims.clear(); - remoteTerminalDeliveryFailure = null; - peerHttpHandler = null; + activeProjectShell = parentProject; + engineCommands.length = 0; + dispatchedTurnCommands.length = 0; }), ), Effect.provide(TestLayer), ), ); - it.effect( - "fails a remote wait and restores only unmarked terminal wakes when marking fails", - () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const markFailingChildThreadId = ThreadId.make("thread-remote-child-mark-failing"); - peerRegistryPeers = [bearerPeer()]; - remoteChildRows = [ - remoteChildRow("running"), - { - ...remoteChildRow("running"), - childThreadId: markFailingChildThreadId, - spawnParams: { - prompt: "remote mark failing", - directory: "/remote/repo", - detached: true, - }, - }, - ]; - remoteChildPollerRows = []; - remoteTerminalMarkFailureChild = markFailingChildThreadId; - enqueuedParentInjections.length = 0; - updatedRemoteChildren.length = 0; - remoteTerminalDeliveryEvents.length = 0; - remoteTerminalDeliveryClaims.clear(); - peerHttpHandler = remoteCheckPeerHandlerWith((body) => { - const requestedThreadId = body.params?.arguments?.childThreadId; - return requestedThreadId === markFailingChildThreadId - ? { - threadId: markFailingChildThreadId, - status: "completed", - turnCount: 3, - latestAssistantText: "mark failed but caller saw me", - } - : { - threadId: remoteChildThreadId, - status: "completed", - turnCount: 2, - latestAssistantText: "mark succeeded and caller saw me", - }; - }); - - const result = yield* server - .callTool({ - name: "t3_wait_subagent", - arguments: { - childThreadIds: [remoteChildThreadId, markFailingChildThreadId], - timeoutSeconds: 1, - mode: "all", - }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(true); - const content = result.content?.[0]; - expect(content?.type).toBe("text"); - if (content?.type !== "text") throw new Error("Expected text error content."); - expect(content.text).toContain("SQL error in RemoteChildRepository.markTerminalStatus"); - expect(enqueuedParentInjections).toEqual( - expect.arrayContaining([ - { - parentThreadId, - childThreadId: remoteChildThreadId, - status: "completed", - finalAssistantText: "mark succeeded and caller saw me", - error: null, - }, - { - parentThreadId, - childThreadId: markFailingChildThreadId, - status: "completed", - finalAssistantText: "mark failed but caller saw me", - error: null, - }, - ]), - ); - expect(enqueuedParentInjections).toHaveLength(2); - expect(updatedRemoteChildren).toEqual([ - expect.objectContaining({ - parentThreadId, - childEnvironmentId: peerEnvironmentId, - childThreadId: remoteChildThreadId, - status: "completed", - }), - ]); - expect(remoteTerminalDeliveryClaims.size).toBe(0); - expect(remoteTerminalDeliveryEvents).toEqual( - expect.arrayContaining(["claim", "mark", "enqueue", "release"]), - ); - }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - peerRegistryPeers = []; - remoteChildRows = []; - remoteChildPollerRows = null; - remoteTerminalMarkFailureChild = null; - enqueuedParentInjections.length = 0; - updatedRemoteChildren.length = 0; - remoteTerminalDeliveryEvents.length = 0; - remoteTerminalDeliveryClaims.clear(); - peerHttpHandler = null; - }), - ), - Effect.provide(TestLayer), - ), - ); - - it.effect("restores suppressed remote wake claims when wait is interrupted", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const runningChildThreadId = ThreadId.make("thread-remote-child-running"); - const claimEntered = yield* Deferred.make(); - peerRegistryPeers = [bearerPeer()]; - remoteChildRows = [ - remoteChildRow("running"), - { - ...remoteChildRow("running"), - childThreadId: runningChildThreadId, - spawnParams: { prompt: "remote running", directory: "/remote/repo", detached: true }, - }, - ]; - remoteChildPollerRows = []; - remoteTerminalClaimEntered = claimEntered; - enqueuedParentInjections.length = 0; - updatedRemoteChildren.length = 0; - remoteTerminalDeliveryEvents.length = 0; - remoteTerminalDeliveryClaims.clear(); - peerHttpHandler = remoteCheckPeerHandlerWith((body) => { - const requestedThreadId = body.params?.arguments?.childThreadId; - return requestedThreadId === runningChildThreadId - ? { - threadId: runningChildThreadId, - status: "running", - turnCount: 1, - latestAssistantText: null, - } - : { - threadId: remoteChildThreadId, - status: "completed", - turnCount: 2, - latestAssistantText: "interrupted wait restored me", - }; - }); - - const fiber = yield* server - .callTool({ - name: "t3_wait_subagent", - arguments: { - childThreadIds: [remoteChildThreadId, runningChildThreadId], - timeoutSeconds: 10, - mode: "all", - }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - Effect.forkScoped, - ); - yield* Deferred.await(claimEntered); - yield* Fiber.interrupt(fiber); - - expect(enqueuedParentInjections).toEqual([ - { - parentThreadId, - childThreadId: remoteChildThreadId, - status: "completed", - finalAssistantText: "interrupted wait restored me", - error: null, - }, - ]); - expect(updatedRemoteChildren).toEqual([ - expect.objectContaining({ - parentThreadId, - childEnvironmentId: peerEnvironmentId, - childThreadId: remoteChildThreadId, - status: "completed", - }), - ]); - expect(remoteTerminalDeliveryClaims.size).toBe(0); - expect(remoteTerminalDeliveryEvents).toEqual(["claim", "enqueue", "mark"]); - }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - peerRegistryPeers = []; - remoteChildRows = []; - remoteChildPollerRows = null; - remoteTerminalClaimEntered = null; - enqueuedParentInjections.length = 0; - updatedRemoteChildren.length = 0; - remoteTerminalDeliveryEvents.length = 0; - remoteTerminalDeliveryClaims.clear(); - peerHttpHandler = null; - }), - ), - Effect.provide(TestLayer), - ), - ); - - it.effect("does not restore remote terminal wakes claimed by another poller", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const failingChildThreadId = ThreadId.make("thread-remote-child-failing"); - const wrongThreadId = ThreadId.make("thread-remote-child-wrong"); - peerRegistryPeers = [bearerPeer()]; - remoteChildRows = [ - remoteChildRow("running"), - { - ...remoteChildRow("running"), - childThreadId: failingChildThreadId, - spawnParams: { prompt: "remote failing", directory: "/remote/repo", detached: true }, - }, - ]; - remoteChildPollerRows = []; - enqueuedParentInjections.length = 0; - updatedRemoteChildren.length = 0; - remoteTerminalDeliveryEvents.length = 0; - remoteTerminalDeliveryClaims.clear(); - remoteTerminalDeliveryClaims.set( - remoteChildKey({ - parentThreadId, - childEnvironmentId: peerEnvironmentId, - childThreadId: remoteChildThreadId, - }), - "other-claim", - ); - peerHttpHandler = remoteCheckPeerHandlerWith((body) => { - const requestedThreadId = body.params?.arguments?.childThreadId; - return requestedThreadId === failingChildThreadId - ? { - threadId: wrongThreadId, - status: "completed", - turnCount: 1, - latestAssistantText: "wrong child", - } - : { - threadId: remoteChildThreadId, - status: "completed", - turnCount: 2, - latestAssistantText: "already claimed elsewhere", - }; - }); - - const result = yield* server - .callTool({ - name: "t3_wait_subagent", - arguments: { - childThreadIds: [remoteChildThreadId, failingChildThreadId], - timeoutSeconds: 1, - mode: "all", - }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(true); - expect(enqueuedParentInjections).toEqual([]); - expect(updatedRemoteChildren).toEqual([]); - expect(remoteTerminalDeliveryEvents).toEqual([]); - }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - peerRegistryPeers = []; - remoteChildRows = []; - remoteChildPollerRows = null; - enqueuedParentInjections.length = 0; - updatedRemoteChildren.length = 0; - remoteTerminalDeliveryEvents.length = 0; - remoteTerminalDeliveryClaims.clear(); - peerHttpHandler = null; - }), - ), - Effect.provide(TestLayer), - ), - ); - - it.effect("lists remote children alongside local children", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - peerRegistryPeers = [bearerPeer()]; - remoteChildRows = [remoteChildRow("running")]; - peerHttpHandler = remoteCheckPeerHandler({ - status: "running", - turnCount: 1, - latestAssistantText: null, - }); - - const result = yield* server - .callTool({ - name: "t3_list_subagents", - arguments: { parentThreadId }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(false); - const children = ( - result.structuredContent as { readonly children?: ReadonlyArray } - ).children; - expect(children).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - childThreadId: remoteChildThreadId, - parentThreadId, - detached: true, - status: "running", - turnCount: 1, - }), - ]), - ); - }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - peerRegistryPeers = []; - remoteChildRows = []; - peerHttpHandler = null; - }), - ), - Effect.provide(TestLayer), - ), - ); - - it.effect("peer-scoped receiver spawn requires directory and records remote parent", () => + it.effect("rejects peer-scoped receiver spawn through symlink outside target project", () => Effect.scoped( Effect.gen(function* () { const server = yield* McpServer.McpServer; const fileSystem = yield* FileSystem.FileSystem; - const targetDirectory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-peer-spawn-target-", + const projectDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-peer-spawn-project-", }); + const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-peer-spawn-outside-", + }); + const linkedDirectory = `${projectDirectory}/linked-outside`; + yield* fileSystem.symlink(outsideDirectory, linkedDirectory); activeProjectShell = { ...parentProject, - workspaceRoot: targetDirectory, - repositoryIdentity: { - canonicalKey: `git-local:${targetDirectory}`, - locator: { - source: "git-local", - rootPath: targetDirectory, - }, - rootPath: targetDirectory, - }, - }; - engineCommands.length = 0; - dispatchedTurnCommands.length = 0; - registeredChildren.length = 0; - const sourceEnvironmentId = EnvironmentId.make("environment-source-a"); - - const result = yield* server - .callTool({ - name: "t3_spawn_subagent", - arguments: { - prompt: "run on receiver", - directory: targetDirectory, - detached: true, - remoteParentThreadId: parentThreadId, - }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, { - ...unrestrictedPeerInvocation, - sourceEnvironmentId, - }), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(false); - expect(dispatchedTurnCommands).toHaveLength(1); - expect(registeredChildren).toEqual([]); - const parentSetCommand = engineCommands.find( - (command): command is Extract => - command.type === "thread.parent.set", - ); - expect(parentSetCommand).toMatchObject({ - type: "thread.parent.set", - parentThreadId, - parentEnvironmentId: sourceEnvironmentId, - }); - expect(result.structuredContent).toMatchObject({ - parentThreadId, - }); - }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - activeProjectShell = parentProject; - engineCommands.length = 0; - dispatchedTurnCommands.length = 0; - registeredChildren.length = 0; - }), - ), - Effect.provide(TestLayer), - ), - ); - - it.effect("rejects peer-scoped receiver spawn with a spoofed parent environment id", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const fileSystem = yield* FileSystem.FileSystem; - const targetDirectory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-peer-spawn-target-", - }); - activeProjectShell = { - ...parentProject, - workspaceRoot: targetDirectory, - repositoryIdentity: { - canonicalKey: `git-local:${targetDirectory}`, - locator: { - source: "git-local", - rootPath: targetDirectory, - }, - rootPath: targetDirectory, - }, - }; - engineCommands.length = 0; - dispatchedTurnCommands.length = 0; - const sourceEnvironmentId = EnvironmentId.make("environment-source-a"); - const spoofedEnvironmentId = EnvironmentId.make("environment-spoofed"); - - const result = yield* server - .callTool({ - name: "t3_spawn_subagent", - arguments: { - prompt: "run on receiver", - directory: targetDirectory, - detached: true, - remoteParentThreadId: parentThreadId, - remoteParentEnvironmentId: spoofedEnvironmentId, - }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, { - ...unrestrictedPeerInvocation, - sourceEnvironmentId, - }), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(true); - const content = result.content?.[0]; - expect(content?.type).toBe("text"); - if (content?.type === "text") { - expect(content.text).toContain("does not match the authenticated caller backend"); - } - expect(dispatchedTurnCommands).toEqual([]); - expect(engineCommands).toEqual([]); - }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - activeProjectShell = parentProject; - engineCommands.length = 0; - dispatchedTurnCommands.length = 0; - }), - ), - Effect.provide(TestLayer), - ), - ); - - it.effect("rejects peer-scoped receiver spawn through symlink outside target project", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const fileSystem = yield* FileSystem.FileSystem; - const projectDirectory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-peer-spawn-project-", - }); - const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-peer-spawn-outside-", - }); - const linkedDirectory = `${projectDirectory}/linked-outside`; - yield* fileSystem.symlink(outsideDirectory, linkedDirectory); - activeProjectShell = { - ...parentProject, - workspaceRoot: projectDirectory, - repositoryIdentity: { - canonicalKey: `git-local:${projectDirectory}`, - locator: { - source: "git-local", - rootPath: projectDirectory, - }, - rootPath: projectDirectory, - }, - }; - engineCommands.length = 0; - dispatchedTurnCommands.length = 0; - - const result = yield* server - .callTool({ - name: "t3_spawn_subagent", - arguments: { - prompt: "run outside through a symlink", - directory: linkedDirectory, - detached: true, - remoteParentThreadId: parentThreadId, - }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, { - ...unrestrictedPeerInvocation, - sourceEnvironmentId: EnvironmentId.make("environment-source-a"), - }), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(true); - const content = result.content?.[0]; - expect(content?.type).toBe("text"); - if (content?.type === "text") { - expect(content.text).toContain("not inside an active target project"); - } - expect(dispatchedTurnCommands).toEqual([]); - expect(engineCommands).toEqual([]); - }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - activeProjectShell = parentProject; - engineCommands.length = 0; - dispatchedTurnCommands.length = 0; - }), - ), - Effect.provide(TestLayer), - ), - ); - - it.effect("rejects peer-scoped receiver spawn for unauthorized remote parent ids", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - dispatchedTurnCommands.length = 0; - - const result = yield* server - .callTool({ - name: "t3_spawn_subagent", - arguments: { - prompt: "run on receiver", - directory: "/not-read-before-authz", - remoteParentThreadId: parentThreadId, - remoteParentEnvironmentId: EnvironmentId.make("environment-source-a"), - }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, { - ...peerInvocation, - sourceEnvironmentId: EnvironmentId.make("environment-source-a"), - }), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(true); - const content = result.content?.[0]; - expect(content?.type).toBe("text"); - if (content?.type === "text") { - expect(content.text).toContain("not authorized for parent thread"); - } - expect(dispatchedTurnCommands).toEqual([]); - }), - ).pipe( - Effect.ensuring(Effect.sync(() => void (dispatchedTurnCommands.length = 0))), - Effect.provide(TestLayer), - ), - ); - - it.effect("allows peer-scoped list when the parent thread is explicit", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - - const result = yield* server - .callTool({ - name: "t3_list_subagents", - arguments: { parentThreadId }, - }) - .pipe( - Effect.provideService( - McpInvocationContext.McpInvocationContext, - entitledPeerInvocation, - ), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ - parentThreadId, - children: [ - { - childThreadId, - parentThreadId, - detached: true, - }, - ], - }); - }), - ).pipe(Effect.provide(TestLayer)), - ); - - it.effect("allows unrestricted peer-scoped list when the parent thread is explicit", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - - const result = yield* server - .callTool({ - name: "t3_list_subagents", - arguments: { parentThreadId }, - }) - .pipe( - Effect.provideService( - McpInvocationContext.McpInvocationContext, - unrestrictedPeerInvocation, - ), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ - parentThreadId, - children: [ - { - childThreadId, - parentThreadId, - detached: true, - }, - ], - }); - }), - ).pipe(Effect.provide(TestLayer)), - ); - - it.effect("rejects peer-scoped list without an explicit parent thread", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - - const result = yield* server - .callTool({ name: "t3_list_subagents", arguments: {} }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, peerInvocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(true); - const content = result.content?.[0]; - expect(content?.type).toBe("text"); - if (content?.type !== "text") throw new Error("Expected text error content."); - expect(content.text).toContain( - "parentThreadId is required when listing with a peer-scoped credential", - ); - }), - ).pipe(Effect.provide(TestLayer)), - ); - - it.effect("rejects peer-scoped list for an unauthorized parent thread", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - - const result = yield* server - .callTool({ - name: "t3_list_subagents", - arguments: { parentThreadId }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, peerInvocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(true); - const content = result.content?.[0]; - expect(content?.type).toBe("text"); - if (content?.type !== "text") throw new Error("Expected text error content."); - expect(content.text).toContain( - `Peer-scoped sub-agent credential is not authorized for parent thread ${parentThreadId}`, - ); - }), - ).pipe(Effect.provide(TestLayer)), - ); - - it.effect( - "checks a current completed stopped child as completed with a checkpointless turn count", - () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - childDetailTurnState = "completed"; - childDetailSession = { - threadId: childThreadId, - status: "stopped", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: "2026-06-17T10:02:00.000Z", - }; - - const result = yield* server - .callTool({ - name: "t3_check_subagent", - arguments: { childThreadId }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ - threadId: childThreadId, - status: "completed", - turnCount: 1, - latestAssistantText: "child done", - }); - }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - childDetailSession = null; - }), - ), - Effect.provide(TestLayer), - ), - ); - - it.effect("allows peer-scoped check for an authorized child thread", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - - const result = yield* server - .callTool({ - name: "t3_check_subagent", - arguments: { childThreadId }, - }) - .pipe( - Effect.provideService( - McpInvocationContext.McpInvocationContext, - entitledPeerInvocation, - ), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ - threadId: childThreadId, - latestAssistantText: "child done", - }); - }), - ).pipe(Effect.provide(TestLayer)), - ); - - it.effect("allows unrestricted peer-scoped check for a receiver-spawned child", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const sourceEnvironmentId = EnvironmentId.make("environment-source-a"); - childShellParentEnvironmentId = sourceEnvironmentId; - - const result = yield* server - .callTool({ - name: "t3_check_subagent", - arguments: { childThreadId }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, { - ...unrestrictedPeerInvocation, - sourceEnvironmentId, - }), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ - threadId: childThreadId, - latestAssistantText: "child done", - }); - }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - childShellParentEnvironmentId = null; - }), - ), - Effect.provide(TestLayer), - ), - ); - - it.effect("rejects unrestricted peer-scoped check for an unrelated local child", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - childShellParentEnvironmentId = EnvironmentId.make("environment-other-source"); - - const result = yield* server - .callTool({ - name: "t3_check_subagent", - arguments: { childThreadId }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, { - ...unrestrictedPeerInvocation, - sourceEnvironmentId: EnvironmentId.make("environment-source-a"), - }), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(true); - const content = result.content?.[0]; - expect(content?.type).toBe("text"); - if (content?.type !== "text") throw new Error("Expected text error content."); - expect(content.text).toContain( - `Peer-scoped sub-agent credential is not authorized for child thread ${childThreadId}`, - ); - expect(content.text).not.toContain("child done"); - }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - childShellParentEnvironmentId = null; - }), - ), - Effect.provide(TestLayer), - ), - ); - - it.effect("rejects unrestricted peer-scoped wait for an unrelated local child", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - childShellParentEnvironmentId = EnvironmentId.make("environment-other-source"); - - const result = yield* server - .callTool({ - name: "t3_wait_subagent", - arguments: { childThreadIds: [childThreadId] }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, { - ...unrestrictedPeerInvocation, - sourceEnvironmentId: EnvironmentId.make("environment-source-a"), - }), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(true); - const content = result.content?.[0]; - expect(content?.type).toBe("text"); - if (content?.type !== "text") throw new Error("Expected text error content."); - expect(content.text).toContain( - `Peer-scoped sub-agent credential is not authorized for child thread ${childThreadId}`, - ); - expect(content.text).not.toContain("child done"); - }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - childShellParentEnvironmentId = null; - }), - ), - Effect.provide(TestLayer), - ), - ); - - it.effect("rejects peer-scoped check for an unauthorized child thread", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - - const result = yield* server - .callTool({ - name: "t3_check_subagent", - arguments: { childThreadId }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, peerInvocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(true); - const content = result.content?.[0]; - expect(content?.type).toBe("text"); - if (content?.type !== "text") throw new Error("Expected text error content."); - expect(content.text).toContain( - `Peer-scoped sub-agent credential is not authorized for child thread ${childThreadId}`, - ); - expect(content.text).not.toContain("child done"); - }), - ).pipe(Effect.provide(TestLayer)), - ); - - it.effect("rejects peer-scoped wait for an unauthorized child thread", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - - const result = yield* server - .callTool({ - name: "t3_wait_subagent", - arguments: { childThreadIds: [childThreadId] }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, peerInvocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(true); - const content = result.content?.[0]; - expect(content?.type).toBe("text"); - if (content?.type !== "text") throw new Error("Expected text error content."); - expect(content.text).toContain( - `Peer-scoped sub-agent credential is not authorized for child thread ${childThreadId}`, - ); - expect(content.text).not.toContain("child done"); - }), - ).pipe(Effect.provide(TestLayer)), - ); - - it.effect("allows peer-scoped wait for a receiver-spawned child that is only in projection", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const sourceEnvironmentId = EnvironmentId.make("environment-source-a"); - waitSliceResult = { - results: [ - { - childThreadId, - status: "failed", - finalAssistantText: null, - error: - "Sub-agent thread exists in the projection but is not tracked by this server instance.", - }, - ], - settledCount: 1, - timedOutCount: 0, - pending: false, - resumeToken: "coordinator-token", - }; - markWaitDeliveredCalls.length = 0; - abandonWaitDeliveryCalls.length = 0; - - const result = yield* server - .callTool({ - name: "t3_wait_subagent", - arguments: { childThreadIds: [childThreadId] }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, { - ...childOnlyPeerInvocation, - sourceEnvironmentId, - }), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ - pending: false, - settledCount: 1, - results: [ - { - childThreadId, - status: "completed", - finalAssistantText: "child done", - error: null, - }, - ], - }); - expect(markWaitDeliveredCalls).toEqual([]); - expect(abandonWaitDeliveryCalls).toEqual([]); - }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - waitSliceResult = null; - markWaitDeliveredCalls.length = 0; - abandonWaitDeliveryCalls.length = 0; - }), - ), - Effect.provide(TestLayer), - ), - ); - - it.effect("does not auto-promote an untracked peer-scoped projection wait", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const sourceEnvironmentId = EnvironmentId.make("environment-source-a"); - childDetailTurnState = "running"; - waitSliceResult = { - results: [ - { - childThreadId, - status: "failed", - finalAssistantText: null, - error: - "Sub-agent thread exists in the projection but is not tracked by this server instance.", - }, - ], - settledCount: 1, - timedOutCount: 0, - pending: false, - resumeToken: "coordinator-token", - }; - promotedCalls.length = 0; - - const result = yield* server - .callTool({ - name: "t3_wait_subagent", - arguments: { - childThreadIds: [childThreadId], - resumeToken: "-100000:coordinator-token", - }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, { - ...childOnlyPeerInvocation, - sourceEnvironmentId, - }), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ - pending: true, - settledCount: 0, - timedOutCount: 0, - results: [ - { - childThreadId, - status: "pending", - finalAssistantText: null, - error: null, - }, - ], - }); - expect(result.structuredContent).not.toHaveProperty("promoted"); - expect(promotedCalls).toEqual([]); - }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - childDetailTurnState = "completed"; - waitSliceResult = null; - promotedCalls.length = 0; - }), - ), - Effect.provide(TestLayer), - ), - ); - - it.effect( - "lists receiver-spawned peer children from projection when coordinator is untracked", - () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const sourceEnvironmentId = EnvironmentId.make("environment-source-a"); - childShellParentEnvironmentId = sourceEnvironmentId; - listChildrenOverride = []; - - const result = yield* server - .callTool({ - name: "t3_list_subagents", - arguments: { parentThreadId }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, { - ...unrestrictedPeerInvocation, - sourceEnvironmentId, - }), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ - parentThreadId, - children: [ - { - childThreadId, - parentThreadId, - detached: true, - status: "completed", - turnCount: 1, - }, - ], - }); - }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - childShellParentEnvironmentId = null; - listChildrenOverride = null; - }), - ), - Effect.provide(TestLayer), - ), - ); - - it.effect("reports a stopped child with only a stale completed latest turn as failed", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - childDetailTurnState = "completed"; - childDetailSession = { - threadId: childThreadId, - status: "stopped", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: "2026-06-17T10:02:00.000Z", - }; - childDetailMessages = [ - { - id: "msg-1" as never, - role: "assistant", - text: "child done", - turnId: "turn-1" as never, - streaming: false, - createdAt: "2026-06-17T10:01:00.000Z", - updatedAt: "2026-06-17T10:01:00.000Z", - }, - { - id: "msg-2" as never, - role: "user", - text: "new attempted turn", - turnId: null, - streaming: false, - createdAt: "2026-06-17T10:02:00.000Z", - updatedAt: "2026-06-17T10:02:00.000Z", - }, - ]; - - const result = yield* server - .callTool({ - name: "t3_check_subagent", - arguments: { childThreadId }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ - threadId: childThreadId, - status: "failed", - turnCount: 1, - }); - }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - childDetailMessages = null; - childDetailSession = null; - }), - ), - Effect.provide(TestLayer), - ), - ); - - it.effect("reports a stopped interrupted child as failed", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - childDetailTurnState = "interrupted"; - childDetailSession = { - threadId: childThreadId, - status: "stopped", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: "2026-06-17T10:02:00.000Z", - }; - - const checkResult = yield* server - .callTool({ - name: "t3_check_subagent", - arguments: { childThreadId }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - const listResult = yield* server - .callTool({ name: "t3_list_subagents", arguments: {} }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(checkResult.isError).toBe(false); - expect(checkResult.structuredContent).toMatchObject({ - threadId: childThreadId, - status: "failed", - turnCount: 1, - }); - expect(listResult.isError).toBe(false); - expect(listResult.structuredContent).toMatchObject({ - children: [{ childThreadId, status: "failed", turnCount: 1 }], - }); - }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - childDetailTurnState = "completed"; - childDetailSession = null; - }), - ), - Effect.provide(TestLayer), - ), - ); - - it.effect("does not count the active running turn from checkpointless messages", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - childDetailTurnState = "running"; - - const result = yield* server - .callTool({ - name: "t3_check_subagent", - arguments: { childThreadId }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ - threadId: childThreadId, - status: "running", - turnCount: 0, - }); - }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - childDetailTurnState = "completed"; - }), - ), - Effect.provide(TestLayer), - ), - ); - - it.effect("increments turn count for a checkpointless latest turn after checkpoints", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - childDetailCheckpoints = [ - { - turnId: "turn-0" as never, - checkpointTurnCount: NonNegativeInt.make(1), - checkpointRef: "checkpoint-turn-0" as never, - status: "ready", - files: [], - assistantMessageId: null, - completedAt: "2026-06-17T09:59:00.000Z", - }, - ]; - - const result = yield* server - .callTool({ - name: "t3_check_subagent", - arguments: { childThreadId }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ - threadId: childThreadId, - status: "completed", - turnCount: 2, - }); - }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - childDetailCheckpoints = null; - }), - ), - Effect.provide(TestLayer), - ), - ); - - it.effect("does not recount retained messages from capped checkpoint history", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - childDetailLatestTurnId = "turn-600" as never; - childDetailLatestTurnRequestedAt = "2026-06-17T10:00:00.000Z"; - childDetailLatestTurnCompletedAt = "2026-06-17T10:00:30.000Z"; - childDetailCheckpoints = [ - { - turnId: "turn-600" as never, - checkpointTurnCount: NonNegativeInt.make(600), - checkpointRef: "checkpoint-turn-600" as never, - status: "ready", - files: [], - assistantMessageId: null, - completedAt: "2026-06-17T10:00:30.000Z", - }, - ]; - childDetailMessages = [ - { - id: "msg-old" as never, - role: "assistant", - text: "old retained message", - turnId: "turn-1" as never, - streaming: false, - createdAt: "2026-06-17T09:00:00.000Z", - updatedAt: "2026-06-17T09:00:00.000Z", - }, - { - id: "msg-latest" as never, - role: "assistant", - text: "latest checkpointed message", - turnId: "turn-600" as never, - streaming: false, - createdAt: "2026-06-17T10:00:30.000Z", - updatedAt: "2026-06-17T10:00:30.000Z", - }, - ]; - - const result = yield* server - .callTool({ - name: "t3_check_subagent", - arguments: { childThreadId }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ - threadId: childThreadId, - status: "completed", - turnCount: 600, - }); - }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - childDetailLatestTurnId = "turn-1" as never; - childDetailLatestTurnRequestedAt = "2026-06-17T10:00:00.000Z"; - childDetailLatestTurnCompletedAt = "2026-06-17T10:01:00.000Z"; - childDetailMessages = null; - childDetailCheckpoints = null; - }), - ), - Effect.provide(TestLayer), - ), - ); - - it.effect("reports a completed latest turn with an errored session as failed", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - childDetailTurnState = "completed"; - childDetailSession = { - threadId: childThreadId, - status: "error", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: "provider failed", - updatedAt: "2026-06-17T10:02:00.000Z", - }; - - const result = yield* server - .callTool({ - name: "t3_check_subagent", - arguments: { childThreadId }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ - threadId: childThreadId, - status: "failed", - turnCount: 1, - }); - }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - childDetailSession = null; - }), - ), - Effect.provide(TestLayer), - ), - ); - - it.effect("rejects a credential without the thread-management capability", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const result = yield* server.callTool({ name: "t3_list_subagents", arguments: {} }).pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, { - ...invocation, - capabilities: new Set(), - }), - Effect.provideService(McpSchema.McpServerClient, client), - ); - expect(result.isError).toBe(true); - }), - ).pipe(Effect.provide(TestLayer)), - ); - - it.effect("releases the dispatch lease when cleanup delete succeeds after register failure", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const limiter = yield* SubagentDispatchLimiter.SubagentDispatchLimiter; - childDriverKind = "codex"; - failCoordinatorRegister = true; - engineCommands.length = 0; - dispatchedTurns.length = 0; - - const result = yield* server - .callTool({ - name: "t3_spawn_subagent", - arguments: { - prompt: "cleanup after failed registration", - title: "cleanup after failed registration", - mode: "current_checkout", - }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(true); - expect(dispatchedTurns.length).toBe(1); - expect(engineCommands.map((command) => command.type)).toEqual([ - "thread.parent.set", - "thread.delete", - ]); - - const acquired = yield* Deferred.make(); - const acquireFiber = yield* limiter.acquire.pipe( - Effect.flatMap((lease) => Deferred.succeed(acquired, lease)), - Effect.forkChild, - ); - yield* Effect.yieldNow; - const acquiredLease = yield* Deferred.poll(acquired); - if (Option.isSome(acquiredLease)) { - const lease = yield* acquiredLease.value; - yield* limiter.release(lease); - yield* Fiber.join(acquireFiber); - } else { - yield* Fiber.interrupt(acquireFiber); - } - expect(Option.isSome(acquiredLease)).toBe(true); - }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - childDriverKind = undefined; - failCoordinatorRegister = false; - engineCommands.length = 0; - dispatchedTurns.length = 0; - }), - ), - Effect.provide(TestLayer), - ), - ); - - it.effect("returns a fast error when dispatch capacity is saturated before child creation", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const limiter = yield* SubagentDispatchLimiter.SubagentDispatchLimiter; - const fileSystem = yield* FileSystem.FileSystem; - const targetDirectory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-subagent-saturated-target-", - }); - yield* Effect.acquireUseRelease( - limiter.acquire, - () => - Effect.gen(function* () { - childDriverKind = "codex"; - activeProjectShell = { - ...parentProject, - workspaceRoot: "/home/adam", - repositoryIdentity: null, - }; - dispatchedTurnCommands.length = 0; - engineCommands.length = 0; - registeredChildren.length = 0; - - const fiber = yield* server - .callTool({ - name: "t3_spawn_subagent", - arguments: { - prompt: "spawn from non-repo parent into explicit repo", - directory: targetDirectory, - mode: "current_checkout", - detached: true, - }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - Effect.timeoutOption(Duration.seconds(6)), - Effect.forkScoped, - ); - - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.seconds(6)); - const maybeResult = yield* Fiber.join(fiber); - - expect(Option.isSome(maybeResult)).toBe(true); - if (Option.isNone(maybeResult)) return; - expect(maybeResult.value.isError).toBe(true); - const content = maybeResult.value.content?.[0]; - expect(content?.type).toBe("text"); - if (content?.type === "text") { - expect(content.text).toContain("dispatch capacity"); - } - expect(dispatchedTurnCommands).toEqual([]); - expect(engineCommands).toEqual([]); - expect(registeredChildren).toEqual([]); - }), - (heldLease) => limiter.release(heldLease), - ); - }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - activeProjectShell = parentProject; - childDriverKind = undefined; - dispatchedTurnCommands.length = 0; - engineCommands.length = 0; - registeredChildren.length = 0; - }), - ), - Effect.provide(TestLayer), - ), - ); - - it.effect( - "foreground spawn returns launch metadata after one pending slice instead of blocking", - () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - childDriverKind = "codex"; - promotedCalls.length = 0; - registeredChildren.length = 0; - waitSliceCalls.length = 0; - engineCommands.length = 0; - waitSliceEffect = (input) => - Effect.sync(() => { - if (waitSliceCalls.length > 1) { - throw new Error("foreground spawn waited more than one coordinator slice"); - } - return { - results: [ - { - childThreadId: input.childThreadIds[0]!, - status: "pending", - finalAssistantText: null, - error: null, - }, - ], - settledCount: 0, - timedOutCount: 0, - pending: true, - resumeToken: "spawn-slice", - }; - }); - - const result = yield* server - .callTool({ - name: "t3_spawn_subagent", - arguments: { - prompt: "Long verification", - title: "Long verification", - mode: "current_checkout", - detached: false, - waitTimeoutSeconds: 900, - }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(false); - const content = result.structuredContent as { - readonly childThreadId: ThreadId; - readonly parentThreadId: ThreadId; - readonly status?: string; - readonly warning?: string; - readonly finalAssistantText?: string | null; - }; - expect(content.parentThreadId).toBe(parentThreadId); - expect(content.status).toBe("running"); - expect(content.finalAssistantText).toBeNull(); - expect(content.warning).toContain("still running"); - expect(content.warning).toContain("t3_wait_subagent"); - expect(content.warning).not.toContain("t3_check_subagent"); - expect(waitSliceCalls.length).toBe(1); - expect(registeredChildren).toEqual([ - { childThreadId: content.childThreadId, parentThreadId }, - ]); - expect(promotedCalls).toEqual([[content.childThreadId]]); - expect(engineCommands).toEqual([ - expect.objectContaining({ - type: "thread.parent.set", - threadId: content.childThreadId, - parentThreadId, - }), - ]); - }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - waitSliceEffect = null; - waitSliceResult = null; - waitSliceCalls.length = 0; - registeredChildren.length = 0; - engineCommands.length = 0; - promotedCalls.length = 0; - dispatchedTurns.length = 0; - childDriverKind = undefined; - }), - ), - Effect.provide(TestLayer), - ), - ); - - it.effect("cleans up foreground child when promotion persistence fails", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const limiter = yield* SubagentDispatchLimiter.SubagentDispatchLimiter; - childDriverKind = "codex"; - promoteToWakeDefect = new Error("promotion failed"); - promotedCalls.length = 0; - registeredChildren.length = 0; - waitSliceCalls.length = 0; - engineCommands.length = 0; - dispatchedTurns.length = 0; - waitSliceResult = { - results: [ - { - childThreadId, - status: "pending", - finalAssistantText: null, - error: null, - }, - ], - settledCount: 0, - timedOutCount: 0, - pending: true, - resumeToken: "spawn-slice", - }; - - const result = yield* server - .callTool({ - name: "t3_spawn_subagent", - arguments: { - prompt: "promotion failure cleanup", - title: "promotion failure cleanup", - mode: "current_checkout", - detached: false, - waitTimeoutSeconds: 900, - }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(true); - expect(promotedCalls).toHaveLength(1); - const promotedChildId = promotedCalls[0]?.[0]; - expect(promotedChildId).toBeDefined(); - expect(registeredChildren).toEqual([{ childThreadId: promotedChildId, parentThreadId }]); - expect(engineCommands.map((command) => command.type)).toEqual([ - "thread.parent.set", - "thread.delete", - ]); - expect(engineCommands[0]).toMatchObject({ - type: "thread.parent.set", - threadId: promotedChildId, - parentThreadId, - }); - expect(engineCommands[1]).toMatchObject({ - type: "thread.delete", - threadId: promotedChildId, - }); - - const acquired = yield* Deferred.make(); - const acquireFiber = yield* limiter.acquire.pipe( - Effect.flatMap((lease) => Deferred.succeed(acquired, lease)), - Effect.forkChild, - ); - yield* Effect.yieldNow; - const acquiredLease = yield* Deferred.poll(acquired); - if (Option.isSome(acquiredLease)) { - const lease = yield* acquiredLease.value; - yield* limiter.release(lease); - yield* Fiber.join(acquireFiber); - } else { - yield* Fiber.interrupt(acquireFiber); - } - expect(Option.isSome(acquiredLease)).toBe(true); - }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - childDriverKind = undefined; - promoteToWakeDefect = null; - waitSliceResult = null; - waitSliceCalls.length = 0; - registeredChildren.length = 0; - engineCommands.length = 0; - promotedCalls.length = 0; - dispatchedTurns.length = 0; - }), - ), - Effect.provide(TestLayer), - ), - ); - - it.effect("R-A: wait auto-promotes a still-running child once the budget elapses", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - promotedCalls.length = 0; - childDetailTurnState = "running"; - // The coordinator slice reports the child still pending. - waitSliceResult = { - results: [{ childThreadId, status: "pending", finalAssistantText: null, error: null }], - settledCount: 0, - timedOutCount: 0, - pending: true, - resumeToken: "coordinator-token", - }; - - // A resumeToken whose wait-start marker is well in the past puts the - // 90s auto-promote deadline before "now" (the test clock starts at 0), - // so this re-call promotes deterministically without a real 90s wait. - const result = yield* server - .callTool({ - name: "t3_wait_subagent", - arguments: { - childThreadIds: [childThreadId], - resumeToken: "-100000:coordinator-token", - }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ - promoted: true, - pending: false, - results: [{ childThreadId, status: "running" }], - }); - const row = (result.structuredContent as { results: ReadonlyArray<{ note?: string }> }) - .results[0]; - expect(row?.note).toContain("NOTIFIED"); - expect(promotedCalls).toEqual([[childThreadId]]); - }), - ).pipe(Effect.provide(TestLayer)), - ); - - it.effect("R-A: wait auto-promotes timeout rows when the auto-promote deadline was active", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - promotedCalls.length = 0; - childDetailTurnState = "running"; - // waitSlice converts pending children to "timeout" when the supplied - // budgetDeadlineMs has elapsed. If that deadline was the 90s - // auto-promote cap, the child is still running and must be promoted. - waitSliceResult = { - results: [ - { - childThreadId, - status: "timeout", - finalAssistantText: null, - error: "wait exceeded budget", + workspaceRoot: projectDirectory, + repositoryIdentity: { + canonicalKey: `git-local:${projectDirectory}`, + locator: { + source: "git-local", + rootPath: projectDirectory, }, - ], - settledCount: 0, - timedOutCount: 1, - pending: false, - resumeToken: "coordinator-token", + rootPath: projectDirectory, + }, }; + engineCommands.length = 0; + dispatchedTurnCommands.length = 0; const result = yield* server .callTool({ - name: "t3_wait_subagent", + name: "t3_spawn_subagent", arguments: { - childThreadIds: [childThreadId], - resumeToken: "-100000:coordinator-token", + prompt: "run outside through a symlink", + directory: linkedDirectory, + detached: true, + remoteParentThreadId: parentThreadId, }, }) .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpInvocationContext.McpInvocationContext, { + ...unrestrictedPeerInvocation, + sourceEnvironmentId: EnvironmentId.make("environment-source-a"), + }), Effect.provideService(McpSchema.McpServerClient, client), ); - expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ - promoted: true, - pending: false, - timedOutCount: 0, - results: [{ childThreadId, status: "running", error: null }], - }); - const row = (result.structuredContent as { results: ReadonlyArray<{ note?: string }> }) - .results[0]; - expect(row?.note).toContain("NOTIFIED"); - expect(promotedCalls).toEqual([[childThreadId]]); + expect(result.isError).toBe(true); + const content = result.content?.[0]; + expect(content?.type).toBe("text"); + if (content?.type === "text") { + expect(content.text).toContain("not inside an active target project"); + } + expect(dispatchedTurnCommands).toEqual([]); + expect(engineCommands).toEqual([]); }), - ).pipe(Effect.provide(TestLayer)), + ).pipe( + Effect.ensuring( + Effect.sync(() => { + activeProjectShell = parentProject; + engineCommands.length = 0; + dispatchedTurnCommands.length = 0; + }), + ), + Effect.provide(TestLayer), + ), ); - it.effect("R-A: wait auto-promotes timeout rows when projection enrichment is unavailable", () => + it.effect("rejects peer-scoped receiver spawn for unauthorized remote parent ids", () => Effect.scoped( Effect.gen(function* () { const server = yield* McpServer.McpServer; - promotedCalls.length = 0; - childDetailTurnState = "running"; - childDetailUnavailable = true; - waitSliceResult = { - results: [ - { - childThreadId, - status: "timeout", - finalAssistantText: null, - error: "wait exceeded budget", - }, - ], - settledCount: 0, - timedOutCount: 1, - pending: false, - resumeToken: "coordinator-token", - }; + dispatchedTurnCommands.length = 0; const result = yield* server .callTool({ - name: "t3_wait_subagent", + name: "t3_spawn_subagent", arguments: { - childThreadIds: [childThreadId], - resumeToken: "-100000:coordinator-token", + prompt: "run on receiver", + directory: "/not-read-before-authz", + remoteParentThreadId: parentThreadId, + remoteParentEnvironmentId: EnvironmentId.make("environment-source-a"), }, }) .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpInvocationContext.McpInvocationContext, { + ...peerInvocation, + sourceEnvironmentId: EnvironmentId.make("environment-source-a"), + }), Effect.provideService(McpSchema.McpServerClient, client), ); - expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ - promoted: true, - pending: false, - timedOutCount: 0, - results: [{ childThreadId, status: "running", turnCount: 0, error: null }], - }); - expect(promotedCalls).toEqual([[childThreadId]]); + expect(result.isError).toBe(true); + const content = result.content?.[0]; + expect(content?.type).toBe("text"); + if (content?.type === "text") { + expect(content.text).toContain("not authorized for parent thread"); + } + expect(dispatchedTurnCommands).toEqual([]); }), ).pipe( - Effect.ensuring( - Effect.sync(() => { - childDetailUnavailable = false; - }), - ), + Effect.ensuring(Effect.sync(() => void (dispatchedTurnCommands.length = 0))), Effect.provide(TestLayer), ), ); it.effect( - "keeps any-mode waits pending when rows are timeout plus pending and none settled", + "checks a current completed stopped child as completed with a checkpointless turn count", () => Effect.scoped( Effect.gen(function* () { const server = yield* McpServer.McpServer; - const stillPendingChildId = ThreadId.make("thread-subagent-still-pending"); - waitSliceResult = { - results: [ - { - childThreadId, - status: "timeout", - finalAssistantText: null, - error: "wait exceeded budget", - }, - { - childThreadId: stillPendingChildId, - status: "pending", - finalAssistantText: null, - error: null, - }, - ], - settledCount: 0, - timedOutCount: 1, - pending: true, - resumeToken: "coordinator-token", + childDetailTurnState = "completed"; + childDetailSession = { + threadId: childThreadId, + status: "stopped", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: "2026-06-17T10:02:00.000Z", }; const result = yield* server .callTool({ - name: "t3_wait_subagent", - arguments: { - childThreadIds: [childThreadId, stillPendingChildId], - mode: "any", - timeoutSeconds: 1, - }, + name: "t3_subagents", + arguments: { childThreadId }, }) .pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), @@ -4125,364 +1928,189 @@ describe("SubagentToolkit", () => { expect(result.isError).toBe(false); expect(result.structuredContent).toMatchObject({ - pending: true, - settledCount: 0, - timedOutCount: 1, - results: [ - { childThreadId, status: "timeout" }, - { childThreadId: stillPendingChildId, status: "pending" }, - ], + threadId: childThreadId, + status: "completed", + turnCount: 1, + latestAssistantText: "child done", }); - expect(result.structuredContent).not.toHaveProperty("promoted"); }), - ).pipe(Effect.provide(TestLayer)), + ).pipe( + Effect.ensuring( + Effect.sync(() => { + childDetailSession = null; + }), + ), + Effect.provide(TestLayer), + ), ); - it.effect("R-A: wait accepts sessionless projection-terminal children", () => + it.effect("allows peer-scoped check for an authorized child thread", () => Effect.scoped( Effect.gen(function* () { const server = yield* McpServer.McpServer; - promotedCalls.length = 0; - markWaitDeliveredCalls.length = 0; - childDetailTurnState = "completed"; - waitSliceResult = { - results: [ - { - childThreadId, - status: "timeout", - finalAssistantText: null, - error: "wait exceeded budget", - }, - ], - settledCount: 0, - timedOutCount: 1, - pending: false, - resumeToken: "coordinator-token", - }; const result = yield* server .callTool({ - name: "t3_wait_subagent", - arguments: { - childThreadIds: [childThreadId], - resumeToken: "-100000:coordinator-token", - }, + name: "t3_subagents", + arguments: { childThreadId }, }) .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService( + McpInvocationContext.McpInvocationContext, + entitledPeerInvocation, + ), Effect.provideService(McpSchema.McpServerClient, client), ); expect(result.isError).toBe(false); expect(result.structuredContent).toMatchObject({ - pending: false, - settledCount: 1, - timedOutCount: 0, - results: [ - { - childThreadId, - status: "completed", - finalAssistantText: "child done", - error: null, - }, - ], + threadId: childThreadId, + latestAssistantText: "child done", }); - expect(result.structuredContent).not.toHaveProperty("promoted"); - expect(promotedCalls).toEqual([]); - expect(markWaitDeliveredCalls).toEqual([ - [ - { - childThreadId, - status: "completed", - finalAssistantText: "child done", - error: null, - }, - ], - ]); }), ).pipe(Effect.provide(TestLayer)), ); - it.effect("R-A: wait rejects stale sessionless projection-terminal children", () => + it.effect("allows unrestricted peer-scoped check for a receiver-spawned child", () => Effect.scoped( Effect.gen(function* () { const server = yield* McpServer.McpServer; - promotedCalls.length = 0; - markWaitDeliveredCalls.length = 0; - childDetailTurnState = "completed"; - childDetailMessages = [ - { - id: "msg-1" as never, - role: "assistant", - text: "old child result", - turnId: "turn-1" as never, - streaming: false, - createdAt: "2026-06-17T10:01:00.000Z", - updatedAt: "2026-06-17T10:01:00.000Z", - }, - { - id: "msg-2" as never, - role: "user", - text: "newer queued child work", - turnId: "turn-2" as never, - streaming: false, - createdAt: "2026-06-17T10:02:00.000Z", - updatedAt: "2026-06-17T10:02:00.000Z", - }, - ]; - waitSliceResult = { - results: [ - { - childThreadId, - status: "timeout", - finalAssistantText: null, - error: "wait exceeded budget", - }, - ], - settledCount: 0, - timedOutCount: 1, - pending: false, - resumeToken: "coordinator-token", - }; + const sourceEnvironmentId = EnvironmentId.make("environment-source-a"); + childShellParentEnvironmentId = sourceEnvironmentId; const result = yield* server .callTool({ - name: "t3_wait_subagent", - arguments: { - childThreadIds: [childThreadId], - resumeToken: "-100000:coordinator-token", - }, + name: "t3_subagents", + arguments: { childThreadId }, }) .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpInvocationContext.McpInvocationContext, { + ...unrestrictedPeerInvocation, + sourceEnvironmentId, + }), Effect.provideService(McpSchema.McpServerClient, client), ); expect(result.isError).toBe(false); expect(result.structuredContent).toMatchObject({ - promoted: true, - pending: false, - settledCount: 0, - timedOutCount: 0, - results: [{ childThreadId, status: "running", error: null }], + threadId: childThreadId, + latestAssistantText: "child done", }); - expect(promotedCalls).toEqual([[childThreadId]]); - expect(markWaitDeliveredCalls).toEqual([]); }), ).pipe( Effect.ensuring( Effect.sync(() => { - childDetailMessages = null; + childShellParentEnvironmentId = null; }), ), Effect.provide(TestLayer), ), ); - it.effect("R-A: child-only peer waits do not mark the parent wake delivered", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - markWaitDeliveredCalls.length = 0; - abandonWaitDeliveryCalls.length = 0; - waitSliceResult = { - results: [ - { - childThreadId, - status: "completed", - finalAssistantText: "peer visible result", - error: null, - }, - ], - settledCount: 1, - timedOutCount: 0, - pending: false, - resumeToken: "coordinator-token", - }; - - const result = yield* server - .callTool({ - name: "t3_wait_subagent", - arguments: { - childThreadIds: [childThreadId], - resumeToken: "-100000:coordinator-token", - }, - }) - .pipe( - Effect.provideService( - McpInvocationContext.McpInvocationContext, - childOnlyPeerInvocation, - ), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ - pending: false, - settledCount: 1, - results: [{ childThreadId, status: "completed", error: null }], - }); - expect(markWaitDeliveredCalls).toEqual([]); - expect(abandonWaitDeliveryCalls).toEqual([[childThreadId]]); - }), - ).pipe(Effect.provide(TestLayer)), - ); - - it.effect("R-A: parent-authorized peer waits mark the parent wake delivered", () => + it.effect("rejects unrestricted peer-scoped check for an unrelated local child", () => Effect.scoped( Effect.gen(function* () { const server = yield* McpServer.McpServer; - assertParentCalls.length = 0; - markWaitDeliveredCalls.length = 0; - abandonWaitDeliveryCalls.length = 0; - waitSliceResult = { - results: [ - { - childThreadId, - status: "completed", - finalAssistantText: "parent-authorized peer result", - error: null, - }, - ], - settledCount: 1, - timedOutCount: 0, - pending: false, - resumeToken: "coordinator-token", - }; + childShellParentEnvironmentId = EnvironmentId.make("environment-other-source"); const result = yield* server .callTool({ - name: "t3_wait_subagent", - arguments: { - childThreadIds: [childThreadId], - resumeToken: "-100000:coordinator-token", - }, + name: "t3_subagents", + arguments: { childThreadId }, }) .pipe( - Effect.provideService( - McpInvocationContext.McpInvocationContext, - entitledPeerInvocation, - ), + Effect.provideService(McpInvocationContext.McpInvocationContext, { + ...unrestrictedPeerInvocation, + sourceEnvironmentId: EnvironmentId.make("environment-source-a"), + }), Effect.provideService(McpSchema.McpServerClient, client), ); - expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ - pending: false, - settledCount: 1, - results: [{ childThreadId, status: "completed", error: null }], - }); - expect(assertParentCalls).toEqual([{ parentThreadId, childThreadId }]); - expect(markWaitDeliveredCalls).toEqual([[waitSliceResult.results[0]!]]); - expect(abandonWaitDeliveryCalls).toEqual([]); + expect(result.isError).toBe(true); + const content = result.content?.[0]; + expect(content?.type).toBe("text"); + if (content?.type !== "text") throw new Error("Expected text error content."); + expect(content.text).toContain( + `Peer-scoped sub-agent credential is not authorized for child thread ${childThreadId}`, + ); + expect(content.text).not.toContain("child done"); }), - ).pipe(Effect.provide(TestLayer)), + ).pipe( + Effect.ensuring( + Effect.sync(() => { + childShellParentEnvironmentId = null; + }), + ), + Effect.provide(TestLayer), + ), ); - it.effect("R-A: mixed-authority peer waits mark only parent-authorized rows", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const otherChildThreadId = ThreadId.make("thread-subagent-other-parent-child"); - const mixedPeerInvocation: McpInvocationContext.PeerMcpInvocationScope = { - ...peerInvocation, - peerTokenId: "peer-subagent-mixed-authority-test", - allowedParentThreadIds: new Set([parentThreadId]), - allowedChildThreadIds: new Set([childThreadId, otherChildThreadId]), - }; - assertParentCalls.length = 0; - markWaitDeliveredCalls.length = 0; - abandonWaitDeliveryCalls.length = 0; - assertParentFailureChild = otherChildThreadId; - waitSliceResult = { - results: [ - { - childThreadId, - status: "completed", - finalAssistantText: "authorized parent result", - error: null, - }, - { - childThreadId: otherChildThreadId, - status: "completed", - finalAssistantText: "child-only result", - error: null, - }, - ], - settledCount: 2, - timedOutCount: 0, - pending: false, - resumeToken: "coordinator-token", - }; + it.effect("rejects peer-scoped check for an unauthorized child thread", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; const result = yield* server .callTool({ - name: "t3_wait_subagent", - arguments: { - childThreadIds: [childThreadId, otherChildThreadId], - resumeToken: "-100000:coordinator-token", - }, + name: "t3_subagents", + arguments: { childThreadId }, }) .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, mixedPeerInvocation), + Effect.provideService(McpInvocationContext.McpInvocationContext, peerInvocation), Effect.provideService(McpSchema.McpServerClient, client), ); - expect(result.isError).toBe(false); - expect(assertParentCalls.map((call) => call.childThreadId).sort()).toEqual( - [childThreadId, otherChildThreadId].sort(), + expect(result.isError).toBe(true); + const content = result.content?.[0]; + expect(content?.type).toBe("text"); + if (content?.type !== "text") throw new Error("Expected text error content."); + expect(content.text).toContain( + `Peer-scoped sub-agent credential is not authorized for child thread ${childThreadId}`, ); - expect(markWaitDeliveredCalls).toEqual([[waitSliceResult.results[0]!]]); - expect(abandonWaitDeliveryCalls).toEqual([[otherChildThreadId]]); + expect(content.text).not.toContain("child done"); }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - assertParentFailureChild = null; - }), - ), - Effect.provide(TestLayer), - ), + ).pipe(Effect.provide(TestLayer)), ); - it.effect("R-A: wait accepts current ready/idle projection-terminal children", () => + it.effect("reports a stopped child with only a stale completed latest turn as failed", () => Effect.scoped( Effect.gen(function* () { const server = yield* McpServer.McpServer; - promotedCalls.length = 0; - markWaitDeliveredCalls.length = 0; childDetailTurnState = "completed"; childDetailSession = { threadId: childThreadId, - status: "ready", + status: "stopped", providerName: "codex", runtimeMode: "full-access", activeTurnId: null, lastError: null, updatedAt: "2026-06-17T10:02:00.000Z", }; - waitSliceResult = { - results: [ - { - childThreadId, - status: "timeout", - finalAssistantText: null, - error: "wait exceeded budget", - }, - ], - settledCount: 0, - timedOutCount: 1, - pending: false, - resumeToken: "coordinator-token", - }; + childDetailMessages = [ + { + id: "msg-1" as never, + role: "assistant", + text: "child done", + turnId: "turn-1" as never, + streaming: false, + createdAt: "2026-06-17T10:01:00.000Z", + updatedAt: "2026-06-17T10:01:00.000Z", + }, + { + id: "msg-2" as never, + role: "user", + text: "new attempted turn", + turnId: null, + streaming: false, + createdAt: "2026-06-17T10:02:00.000Z", + updatedAt: "2026-06-17T10:02:00.000Z", + }, + ]; const result = yield* server .callTool({ - name: "t3_wait_subagent", - arguments: { - childThreadIds: [childThreadId], - resumeToken: "-100000:coordinator-token", - }, + name: "t3_subagents", + arguments: { childThreadId }, }) .pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), @@ -4491,27 +2119,15 @@ describe("SubagentToolkit", () => { expect(result.isError).toBe(false); expect(result.structuredContent).toMatchObject({ - pending: false, - settledCount: 1, - timedOutCount: 0, - results: [{ childThreadId, status: "completed", error: null }], + threadId: childThreadId, + status: "failed", + turnCount: 1, }); - expect(result.structuredContent).not.toHaveProperty("promoted"); - expect(promotedCalls).toEqual([]); - expect(markWaitDeliveredCalls).toEqual([ - [ - { - childThreadId, - status: "completed", - finalAssistantText: "child done", - error: null, - }, - ], - ]); }), ).pipe( Effect.ensuring( Effect.sync(() => { + childDetailMessages = null; childDetailSession = null; }), ), @@ -4519,144 +2135,69 @@ describe("SubagentToolkit", () => { ), ); - it.effect("R-A: wait does not mark delivered when final response enrichment fails", () => + it.effect("reports a stopped interrupted child as failed", () => Effect.scoped( Effect.gen(function* () { const server = yield* McpServer.McpServer; - markWaitDeliveredCalls.length = 0; - abandonWaitDeliveryCalls.length = 0; - childDetailCallCount = 0; - childDetailFailOnCall = 1; - waitSliceResult = { - results: [ - { - childThreadId, - status: "completed", - finalAssistantText: "coordinator result", - error: null, - }, - ], - settledCount: 1, - timedOutCount: 0, - pending: false, - resumeToken: "coordinator-token", + childDetailTurnState = "interrupted"; + childDetailSession = { + threadId: childThreadId, + status: "stopped", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: "2026-06-17T10:02:00.000Z", }; - const result = yield* server + const checkResult = yield* server .callTool({ - name: "t3_wait_subagent", - arguments: { - childThreadIds: [childThreadId], - resumeToken: "-100000:coordinator-token", - }, + name: "t3_subagents", + arguments: { childThreadId }, }) .pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), Effect.provideService(McpSchema.McpServerClient, client), ); - - expect(result.isError).toBe(true); - expect(markWaitDeliveredCalls).toEqual([]); - expect(abandonWaitDeliveryCalls).toEqual([[childThreadId]]); - }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - childDetailFailOnCall = null; - childDetailCallCount = 0; - }), - ), - Effect.provide(TestLayer), - ), - ); - - it.effect("R-A: wait abandons coordinator terminals when earlier enrichment fails", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const terminalChildId = ThreadId.make("thread-subagent-terminal-before-enrichment"); - markWaitDeliveredCalls.length = 0; - abandonWaitDeliveryCalls.length = 0; - childDetailCallCount = 0; - childDetailFailOnCall = 1; - waitSliceResult = { - results: [ - { - childThreadId: terminalChildId, - status: "completed", - finalAssistantText: "coordinator terminal", - error: null, - }, - { - childThreadId, - status: "pending", - finalAssistantText: null, - error: null, - }, - ], - settledCount: 1, - timedOutCount: 0, - pending: true, - resumeToken: "coordinator-token", - }; - - const result = yield* server - .callTool({ - name: "t3_wait_subagent", - arguments: { - childThreadIds: [terminalChildId, childThreadId], - resumeToken: "-100000:coordinator-token", - }, - }) + const listResult = yield* server + .callTool({ name: "t3_subagents", arguments: {} }) .pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), Effect.provideService(McpSchema.McpServerClient, client), ); - expect(result.isError).toBe(true); - expect(markWaitDeliveredCalls).toEqual([]); - expect(abandonWaitDeliveryCalls).toEqual([[terminalChildId]]); + expect(checkResult.isError).toBe(false); + expect(checkResult.structuredContent).toMatchObject({ + threadId: childThreadId, + status: "failed", + turnCount: 1, + }); + expect(listResult.isError).toBe(false); + expect(listResult.structuredContent).toMatchObject({ + children: [{ childThreadId, status: "failed", turnCount: 1 }], + }); }), ).pipe( Effect.ensuring( Effect.sync(() => { - childDetailFailOnCall = null; - childDetailCallCount = 0; + childDetailTurnState = "completed"; + childDetailSession = null; }), ), Effect.provide(TestLayer), ), ); - it.effect("R-A: wait dedupes duplicate child ids before coordinator wait", () => + it.effect("does not count the active running turn from checkpointless messages", () => Effect.scoped( Effect.gen(function* () { const server = yield* McpServer.McpServer; - assertParentCalls.length = 0; - waitSliceCalls.length = 0; - markWaitDeliveredCalls.length = 0; - waitSliceResult = { - results: [ - { - childThreadId, - status: "completed", - finalAssistantText: "deduped result", - error: null, - }, - ], - settledCount: 1, - timedOutCount: 0, - pending: false, - resumeToken: "coordinator-token", - }; + childDetailTurnState = "running"; const result = yield* server .callTool({ - name: "t3_wait_subagent", - arguments: { - childThreadIds: [childThreadId, childThreadId], - resumeToken: "-100000:coordinator-token", - }, + name: "t3_subagents", + arguments: { childThreadId }, }) .pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), @@ -4664,101 +2205,42 @@ describe("SubagentToolkit", () => { ); expect(result.isError).toBe(false); - expect(assertParentCalls).toEqual([{ parentThreadId, childThreadId }]); - expect(waitSliceCalls.map((call) => call.childThreadIds)).toEqual([[childThreadId]]); - expect(markWaitDeliveredCalls).toEqual([[waitSliceResult.results[0]!]]); - }), - ).pipe(Effect.provide(TestLayer)), - ); - - it.effect("R-A: wait refuses to deliver a child owned by another parent", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - assertParentCalls.length = 0; - waitSliceCalls.length = 0; - markWaitDeliveredCalls.length = 0; - assertParentFailureChild = childThreadId; - waitSliceResult = { - results: [ - { - childThreadId, - status: "completed", - finalAssistantText: "wrong parent result", - error: null, - }, - ], - settledCount: 1, - timedOutCount: 0, - pending: false, - resumeToken: "coordinator-token", - }; - - const result = yield* server - .callTool({ - name: "t3_wait_subagent", - arguments: { - childThreadIds: [childThreadId], - resumeToken: "-100000:coordinator-token", - }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(true); - expect(assertParentCalls).toEqual([{ parentThreadId, childThreadId }]); - expect(waitSliceCalls).toEqual([]); - expect(markWaitDeliveredCalls).toEqual([]); + expect(result.structuredContent).toMatchObject({ + threadId: childThreadId, + status: "running", + turnCount: 0, + }); }), ).pipe( Effect.ensuring( Effect.sync(() => { - assertParentFailureChild = null; + childDetailTurnState = "completed"; }), ), Effect.provide(TestLayer), ), ); - it.effect("R-A: stale completed projection with a newer active turn still auto-promotes", () => + it.effect("increments turn count for a checkpointless latest turn after checkpoints", () => Effect.scoped( Effect.gen(function* () { const server = yield* McpServer.McpServer; - promotedCalls.length = 0; - childDetailTurnState = "completed"; - childDetailSession = { - threadId: childThreadId, - status: "running", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: "turn-2" as never, - lastError: null, - updatedAt: "2026-06-17T10:02:00.000Z", - }; - waitSliceResult = { - results: [ - { - childThreadId, - status: "timeout", - finalAssistantText: null, - error: "wait exceeded budget", - }, - ], - settledCount: 0, - timedOutCount: 1, - pending: false, - resumeToken: "coordinator-token", - }; + childDetailCheckpoints = [ + { + turnId: "turn-0" as never, + checkpointTurnCount: NonNegativeInt.make(1), + checkpointRef: "checkpoint-turn-0" as never, + status: "ready", + files: [], + assistantMessageId: null, + completedAt: "2026-06-17T09:59:00.000Z", + }, + ]; const result = yield* server .callTool({ - name: "t3_wait_subagent", - arguments: { - childThreadIds: [childThreadId], - resumeToken: "-100000:coordinator-token", - }, + name: "t3_subagents", + arguments: { childThreadId }, }) .pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), @@ -4767,61 +2249,64 @@ describe("SubagentToolkit", () => { expect(result.isError).toBe(false); expect(result.structuredContent).toMatchObject({ - promoted: true, - pending: false, - timedOutCount: 0, - results: [{ childThreadId, status: "running", error: null }], + threadId: childThreadId, + status: "completed", + turnCount: 2, }); - expect(promotedCalls).toEqual([[childThreadId]]); }), ).pipe( Effect.ensuring( Effect.sync(() => { - childDetailSession = null; + childDetailCheckpoints = null; }), ), Effect.provide(TestLayer), ), ); - it.effect("returns completed projection waits when the completed child session has stopped", () => + it.effect("does not recount retained messages from capped checkpoint history", () => Effect.scoped( Effect.gen(function* () { const server = yield* McpServer.McpServer; - promotedCalls.length = 0; - markWaitDeliveredCalls.length = 0; - childDetailTurnState = "completed"; - childDetailSession = { - threadId: childThreadId, - status: "stopped", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: "2026-06-17T10:02:00.000Z", - }; - waitSliceResult = { - results: [ - { - childThreadId, - status: "timeout", - finalAssistantText: null, - error: "wait exceeded budget", - }, - ], - settledCount: 0, - timedOutCount: 1, - pending: false, - resumeToken: "coordinator-token", - }; + childDetailLatestTurnId = "turn-600" as never; + childDetailLatestTurnRequestedAt = "2026-06-17T10:00:00.000Z"; + childDetailLatestTurnCompletedAt = "2026-06-17T10:00:30.000Z"; + childDetailCheckpoints = [ + { + turnId: "turn-600" as never, + checkpointTurnCount: NonNegativeInt.make(600), + checkpointRef: "checkpoint-turn-600" as never, + status: "ready", + files: [], + assistantMessageId: null, + completedAt: "2026-06-17T10:00:30.000Z", + }, + ]; + childDetailMessages = [ + { + id: "msg-old" as never, + role: "assistant", + text: "old retained message", + turnId: "turn-1" as never, + streaming: false, + createdAt: "2026-06-17T09:00:00.000Z", + updatedAt: "2026-06-17T09:00:00.000Z", + }, + { + id: "msg-latest" as never, + role: "assistant", + text: "latest checkpointed message", + turnId: "turn-600" as never, + streaming: false, + createdAt: "2026-06-17T10:00:30.000Z", + updatedAt: "2026-06-17T10:00:30.000Z", + }, + ]; const result = yield* server .callTool({ - name: "t3_wait_subagent", - arguments: { - childThreadIds: [childThreadId], - resumeToken: "-100000:coordinator-token", - }, + name: "t3_subagents", + arguments: { childThreadId }, }) .pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), @@ -4830,40 +2315,29 @@ describe("SubagentToolkit", () => { expect(result.isError).toBe(false); expect(result.structuredContent).toMatchObject({ - pending: false, - settledCount: 1, - timedOutCount: 0, - results: [{ childThreadId, status: "completed", error: null }], + threadId: childThreadId, + status: "completed", + turnCount: 600, }); - expect(result.structuredContent).not.toHaveProperty("promoted"); - expect(promotedCalls).toEqual([]); - expect(markWaitDeliveredCalls).toEqual([ - [ - { - childThreadId, - status: "completed", - finalAssistantText: "child done", - error: null, - }, - ], - ]); }), ).pipe( Effect.ensuring( Effect.sync(() => { - childDetailSession = null; + childDetailLatestTurnId = "turn-1" as never; + childDetailLatestTurnRequestedAt = "2026-06-17T10:00:00.000Z"; + childDetailLatestTurnCompletedAt = "2026-06-17T10:01:00.000Z"; + childDetailMessages = null; + childDetailCheckpoints = null; }), ), Effect.provide(TestLayer), ), ); - it.effect("returns failed projection waits when the completed child session has errored", () => + it.effect("reports a completed latest turn with an errored session as failed", () => Effect.scoped( Effect.gen(function* () { const server = yield* McpServer.McpServer; - promotedCalls.length = 0; - markWaitDeliveredCalls.length = 0; childDetailTurnState = "completed"; childDetailSession = { threadId: childThreadId, @@ -4874,28 +2348,11 @@ describe("SubagentToolkit", () => { lastError: "provider failed", updatedAt: "2026-06-17T10:02:00.000Z", }; - waitSliceResult = { - results: [ - { - childThreadId, - status: "timeout", - finalAssistantText: null, - error: "wait exceeded budget", - }, - ], - settledCount: 0, - timedOutCount: 1, - pending: false, - resumeToken: "coordinator-token", - }; const result = yield* server .callTool({ - name: "t3_wait_subagent", - arguments: { - childThreadIds: [childThreadId], - resumeToken: "-100000:coordinator-token", - }, + name: "t3_subagents", + arguments: { childThreadId }, }) .pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), @@ -4904,29 +2361,10 @@ describe("SubagentToolkit", () => { expect(result.isError).toBe(false); expect(result.structuredContent).toMatchObject({ - pending: false, - settledCount: 1, - timedOutCount: 0, - results: [ - { - childThreadId, - status: "failed", - error: "Child thread ended with status failed.", - }, - ], + threadId: childThreadId, + status: "failed", + turnCount: 1, }); - expect(result.structuredContent).not.toHaveProperty("promoted"); - expect(promotedCalls).toEqual([]); - expect(markWaitDeliveredCalls).toEqual([ - [ - { - childThreadId, - status: "failed", - finalAssistantText: "child done", - error: "Child thread ended with status failed.", - }, - ], - ]); }), ).pipe( Effect.ensuring( @@ -4938,133 +2376,40 @@ describe("SubagentToolkit", () => { ), ); - it.effect("returns failed projection waits when the child session is interrupted", () => + it.effect("rejects a credential without the thread-management capability", () => Effect.scoped( Effect.gen(function* () { const server = yield* McpServer.McpServer; - promotedCalls.length = 0; - markWaitDeliveredCalls.length = 0; - childDetailTurnState = "interrupted"; - childDetailSession = { - threadId: childThreadId, - status: "interrupted", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: "2026-06-17T10:02:00.000Z", - }; - waitSliceResult = { - results: [ - { - childThreadId, - status: "timeout", - finalAssistantText: null, - error: "wait exceeded budget", - }, - ], - settledCount: 0, - timedOutCount: 1, - pending: false, - resumeToken: "coordinator-token", - }; - - const result = yield* server - .callTool({ - name: "t3_wait_subagent", - arguments: { - childThreadIds: [childThreadId], - resumeToken: "-100000:coordinator-token", - }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ - pending: false, - settledCount: 1, - timedOutCount: 0, - results: [ - { - childThreadId, - status: "failed", - error: "Child thread ended with status failed.", - }, - ], - }); - expect(result.structuredContent).not.toHaveProperty("promoted"); - expect(promotedCalls).toEqual([]); - expect(markWaitDeliveredCalls).toEqual([ - [ - { - childThreadId, - status: "failed", - finalAssistantText: "child done", - error: "Child thread ended with status failed.", - }, - ], - ]); + const result = yield* server.callTool({ name: "t3_subagents", arguments: {} }).pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, { + ...invocation, + capabilities: new Set(), + }), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(result.isError).toBe(true); }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - childDetailSession = null; - childDetailTurnState = "completed"; - }), - ), - Effect.provide(TestLayer), - ), + ).pipe(Effect.provide(TestLayer)), ); - it.effect("does not enrich completed waits with stale prior-turn assistant text", () => + it.effect("releases the dispatch lease when cleanup delete succeeds after register failure", () => Effect.scoped( Effect.gen(function* () { const server = yield* McpServer.McpServer; - childDetailTurnState = "completed"; - childDetailSession = { - threadId: childThreadId, - status: "stopped", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: "2026-06-17T10:02:00.000Z", - }; - childDetailMessages = [ - { - id: "msg-prior" as never, - role: "assistant", - text: "stale prior answer", - turnId: "turn-prior" as never, - streaming: false, - createdAt: "2026-06-17T09:59:00.000Z", - updatedAt: "2026-06-17T09:59:00.000Z", - }, - ]; - waitSliceResult = { - results: [ - { - childThreadId, - status: "timeout", - finalAssistantText: null, - error: "wait exceeded budget", - }, - ], - settledCount: 0, - timedOutCount: 1, - pending: false, - resumeToken: "coordinator-token", - }; + const limiter = yield* SubagentDispatchLimiter.SubagentDispatchLimiter; + childDriverKind = "codex"; + modelInstances = [makeModelInstance("codex", "codex", ["gpt-5-codex"])]; + failCoordinatorRegister = true; + engineCommands.length = 0; + dispatchedTurns.length = 0; const result = yield* server .callTool({ - name: "t3_wait_subagent", + name: "t3_spawn_subagent", arguments: { - childThreadIds: [childThreadId], - resumeToken: "-100000:coordinator-token", + prompt: "cleanup after failed registration", + model: "gpt-5-codex", + title: "cleanup after failed registration", }, }) .pipe( @@ -5072,106 +2417,112 @@ describe("SubagentToolkit", () => { Effect.provideService(McpSchema.McpServerClient, client), ); - expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ - pending: false, - settledCount: 1, - timedOutCount: 0, - results: [ - { - childThreadId, - status: "completed", - finalAssistantText: null, - error: null, - }, - ], - }); + expect(result.isError).toBe(true); + expect(dispatchedTurns.length).toBe(1); + expect(engineCommands.map((command) => command.type)).toEqual([ + "thread.parent.set", + "thread.delete", + ]); + + const acquired = yield* Deferred.make(); + const acquireFiber = yield* limiter.acquire.pipe( + Effect.flatMap((lease) => Deferred.succeed(acquired, lease)), + Effect.forkChild, + ); + yield* Effect.yieldNow; + const acquiredLease = yield* Deferred.poll(acquired); + if (Option.isSome(acquiredLease)) { + const lease = yield* acquiredLease.value; + yield* limiter.release(lease); + yield* Fiber.join(acquireFiber); + } else { + yield* Fiber.interrupt(acquireFiber); + } + expect(Option.isSome(acquiredLease)).toBe(true); }), ).pipe( Effect.ensuring( Effect.sync(() => { - childDetailSession = null; - childDetailMessages = null; + childDriverKind = undefined; + modelInstances = []; + failCoordinatorRegister = false; + engineCommands.length = 0; + dispatchedTurns.length = 0; }), ), Effect.provide(TestLayer), ), ); - it.effect("does not enrich failed waits with stale prior-turn assistant text", () => + it.effect("returns a fast error when dispatch capacity is saturated before child creation", () => Effect.scoped( Effect.gen(function* () { const server = yield* McpServer.McpServer; - childDetailTurnState = "error"; - childDetailSession = { - threadId: childThreadId, - status: "error", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: "provider failed", - updatedAt: "2026-06-17T10:02:00.000Z", - }; - childDetailMessages = [ - { - id: "msg-prior" as never, - role: "assistant", - text: "stale prior answer", - turnId: "turn-prior" as never, - streaming: false, - createdAt: "2026-06-17T09:59:00.000Z", - updatedAt: "2026-06-17T09:59:00.000Z", - }, - ]; - waitSliceResult = { - results: [ - { - childThreadId, - status: "timeout", - finalAssistantText: null, - error: "wait exceeded budget", - }, - ], - settledCount: 0, - timedOutCount: 1, - pending: false, - resumeToken: "coordinator-token", - }; + const limiter = yield* SubagentDispatchLimiter.SubagentDispatchLimiter; + const fileSystem = yield* FileSystem.FileSystem; + const targetDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-subagent-saturated-target-", + }); + yield* Effect.acquireUseRelease( + limiter.acquire, + () => + Effect.gen(function* () { + childDriverKind = "codex"; + modelInstances = [makeModelInstance("codex", "codex", ["gpt-5-codex"])]; + activeProjectShell = { + ...parentProject, + workspaceRoot: "/home/adam", + repositoryIdentity: null, + }; + dispatchedTurnCommands.length = 0; + engineCommands.length = 0; + registeredChildren.length = 0; - const result = yield* server - .callTool({ - name: "t3_wait_subagent", - arguments: { - childThreadIds: [childThreadId], - resumeToken: "-100000:coordinator-token", - }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); + const fiber = yield* server + .callTool({ + name: "t3_spawn_subagent", + arguments: { + prompt: "spawn from non-repo parent into explicit repo", + model: "gpt-5-codex", + title: "capacity saturation", + directory: targetDirectory, + }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + Effect.timeoutOption(Duration.seconds(6)), + Effect.forkScoped, + ); - expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ - pending: false, - settledCount: 1, - timedOutCount: 0, - results: [ - { - childThreadId, - status: "failed", - finalAssistantText: null, - error: "Child thread ended with status failed.", - }, - ], - }); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.seconds(6)); + const maybeResult = yield* Fiber.join(fiber); + + expect(Option.isSome(maybeResult)).toBe(true); + if (Option.isNone(maybeResult)) return; + expect(maybeResult.value.isError).toBe(true); + const content = maybeResult.value.content?.[0]; + expect(content?.type).toBe("text"); + if (content?.type === "text") { + expect(content.text).toContain("dispatch capacity"); + } + expect(dispatchedTurnCommands).toEqual([]); + expect(engineCommands).toEqual([]); + expect(registeredChildren).toEqual([]); + }), + (heldLease) => limiter.release(heldLease), + ); }), ).pipe( Effect.ensuring( Effect.sync(() => { - childDetailMessages = null; - childDetailSession = null; - childDetailTurnState = "completed"; + activeProjectShell = parentProject; + childDriverKind = undefined; + modelInstances = []; + dispatchedTurnCommands.length = 0; + engineCommands.length = 0; + registeredChildren.length = 0; }), ), Effect.provide(TestLayer), diff --git a/apps/server/src/mcp/toolkits/subagent/handlers.ts b/apps/server/src/mcp/toolkits/subagent/handlers.ts index 1576d3ce8a4d..c8fe860523cd 100644 --- a/apps/server/src/mcp/toolkits/subagent/handlers.ts +++ b/apps/server/src/mcp/toolkits/subagent/handlers.ts @@ -6,14 +6,13 @@ * services so the toolkit handlers can reach them without threading them * through the toolkit `Context`. The handlers reuse pr3107's * `activeThreadStartRuntime` for spawning, the live `ChildThreadCoordinator` - * for the never-hang wait/registration, and `dispatchActive` for steering. + * for registration/wake delivery, and `dispatchActive` for steering. * * @module subagent/handlers */ import { CommandId, EnvironmentId, - ExecutionEnvironmentDescriptor, isProviderAvailable, IsoDateTime, MessageId, @@ -39,18 +38,14 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { McpSchema, McpServer } from "effect/unstable/ai"; +import { HttpClient } from "effect/unstable/http"; -import { - coordinatorActive, - finalAssistantTextFromThread, -} from "../../../orchestration/Layers/ChildThreadCoordinator.ts"; +import { coordinatorActive } from "../../../orchestration/Layers/ChildThreadCoordinator.ts"; import type { ChildTerminalStatus, ChildThreadCoordinatorShape, - WaitSliceResult, } from "../../../orchestration/Services/ChildThreadCoordinator.ts"; -import { WAIT_SLICE_SECONDS } from "../../../orchestration/Services/ChildThreadCoordinator.ts"; import { dispatchActive } from "../../../orchestration/Services/BootstrapTurnStartDispatcher.ts"; import { OrchestrationEngineService } from "../../../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -76,31 +71,27 @@ import { import { ProviderInstanceRegistry } from "../../../provider/Services/ProviderInstanceRegistry.ts"; import * as McpPeerClient from "../../../subagents/McpPeerClient.ts"; import * as SubagentPeerRegistry from "../../../subagents/SubagentPeerRegistry.ts"; -import { cloudflareAccessHeaders, environmentUrl } from "../../../subagents/SubagentPeerHttp.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import { activeThreadStartRuntimeOf, type ActiveThreadStartRuntime } from "../thread/handlers.ts"; +import { applyMcpReasoningEffort } from "../thread/reasoningEffort.ts"; import { ThreadStartToolError } from "../thread/tools.ts"; import { SubagentDispatchLimiter } from "./SubagentDispatchLimiter.ts"; import { SubagentToolkit, - WAIT_AUTO_PROMOTE_SECONDS, - WAIT_TIMEOUT_DEFAULT_SECONDS, - WAIT_TIMEOUT_MAX_SECONDS, - WAIT_TIMEOUT_MIN_SECONDS, - type CheckSubagentInput, - CheckSubagentOutput, - type CheckSubagentOutput as CheckSubagentOutputType, - type ListSubagentsInput, + LegacyCheckSubagentInput, + type LegacyCheckSubagentInput as LegacyCheckSubagentInputType, + SubagentDetailOutput, + type SubagentDetailOutput as SubagentDetailOutputType, type ScheduleCreateInput, type ScheduleDeleteInput, type ScheduleListInput, type ScheduleUpdateInput, - type SpawnSubagentInput, + SpawnSubagentInternalInput, + type SpawnSubagentInternalInput as SpawnSubagentInternalInputType, SpawnSubagentOutput, type SpawnSubagentOutput as SpawnSubagentOutputType, type SteerSubagentInput, - type WaitSubagentInput, - type WaitSubagentOutput as WaitSubagentOutputType, + type SubagentsInput, } from "./tools.ts"; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -108,8 +99,6 @@ const isThreadStartToolError = Schema.is(ThreadStartToolError); const isSubagentPeerTargetNotFoundError = Schema.is( SubagentPeerRegistry.SubagentPeerTargetNotFoundError, ); -const WAIT_PROJECTION_ENRICHMENT_TIMEOUT_MS = 250; -const PEER_SPAWN_PROBE_TIMEOUT = Duration.seconds(5); const PEER_TOOL_CALL_TIMEOUT_SECONDS = 5; const PEER_TOOL_CALL_TIMEOUT = Duration.seconds(PEER_TOOL_CALL_TIMEOUT_SECONDS); const PEER_SESSION_CLOSE_TIMEOUT = Duration.seconds(2); @@ -118,14 +107,12 @@ const LOCAL_SPAWN_DISPATCH_LEASE_TIMEOUT = Duration.seconds( LOCAL_SPAWN_DISPATCH_LEASE_TIMEOUT_SECONDS, ); const REMOTE_TERMINAL_DELIVERY_CLAIM_TTL_MS = 5 * 60 * 1_000; -const UNTRACKED_PROJECTION_CHILD_ERROR = - "Sub-agent thread exists in the projection but is not tracked by this server instance."; -const FOREGROUND_SPAWN_PENDING_WARNING = - "Sub-agent is still running after the initial foreground wait; returning launch metadata now. Use t3_wait_subagent to wait for terminal delivery, or stop polling and let the parent wake automatically when it completes."; - const fail = (message: string) => new ThreadStartToolError({ message }); -const decodeSpawnSubagentOutput = Schema.decodeUnknownEffect(SpawnSubagentOutput); -const decodeCheckSubagentOutput = Schema.decodeUnknownEffect(CheckSubagentOutput); +const decodeLegacyCheckSubagentInput = Schema.decodeUnknownEffect(LegacyCheckSubagentInput); +const decodeLegacySpawnSubagentInput = Schema.decodeUnknownEffect(SpawnSubagentInternalInput); +const decodeSubagentDetailOutput = Schema.decodeUnknownEffect(SubagentDetailOutput); +const encodeSubagentDetailOutput = Schema.encodeUnknownEffect(SubagentDetailOutput); +const encodeSpawnSubagentOutput = Schema.encodeUnknownEffect(SpawnSubagentOutput); const toToolError = (error: unknown, fallback: string): ThreadStartToolError => isThreadStartToolError(error) ? error : fail(error instanceof Error ? error.message : fallback); @@ -144,12 +131,6 @@ const requireSpawnRuntime = (): Effect.Effect - value < min ? min : value > max ? max : value; - -const appendWarning = (existing: string | undefined, warning: string): string => - existing === undefined ? warning : `${existing} ${warning}`; - const acquireLocalDispatchLease = Effect.fn("SubagentToolkit.acquireLocalDispatchLease")(function* ( runtime: SubagentRuntime, parentThreadId: ThreadId, @@ -234,7 +215,7 @@ const latestAssistantTextOf = (thread: OrchestrationThread): string | null => { /** * Map a thread shell's turn/session state to a coarse readonly status used by - * `t3_check_subagent` / `t3_list_subagents` (matches the coordinator's terminal + * `t3_subagents` (matches the coordinator's terminal * vocabulary where it overlaps). */ const statusOf = ( @@ -296,65 +277,9 @@ const hasCurrentProjectedTerminalTurn = ( ); }; -const waitTerminalStatusOf = ( - thread: Pick, -): "completed" | "failed" | null => { - if (thread.session?.status === "error") return "failed"; - if (!hasCurrentProjectedTerminalTurn(thread)) return null; - const state = thread.latestTurn?.state; - if (state === "completed") return "completed"; - if (state === undefined || state === "running") return null; - return "failed"; -}; - -const hasNoNewerMessageAfterTerminalTurn = ( - thread: Pick, -): boolean => { - const latestTurn = thread.latestTurn; - if (latestTurn === null) return false; - const terminalAt = latestTurn.completedAt ?? latestTurn.startedAt ?? latestTurn.requestedAt; - return !thread.messages.some( - (message) => - !message.streaming && message.turnId !== latestTurn.turnId && message.createdAt > terminalAt, - ); -}; - -const reliableWaitTerminalStatusOf = ( - thread: Pick, -): "completed" | "failed" | null => { - const projectedStatus = waitTerminalStatusOf(thread); - if (projectedStatus === null) return null; - const session = thread.session; - if (session?.status === "error") return projectedStatus; - if (session === null) { - return hasNoNewerMessageAfterTerminalTurn(thread) ? projectedStatus : null; - } - if ( - session?.status === "stopped" || - session?.status === "idle" || - session?.status === "ready" || - session?.status === "interrupted" - ) { - return hasNoNewerMessageAfterTerminalTurn(thread) ? projectedStatus : null; - } - if (session?.activeTurnId != null && thread.latestTurn?.turnId === session.activeTurnId) { - return projectedStatus; - } - return null; -}; - const isWaitTerminal = (status: string): boolean => status === "completed" || status === "failed" || status === "interrupted" || status === "killed"; -const pendingForMode = ( - rows: ReadonlyArray<{ readonly status: string }>, - mode: "all" | "any", -): boolean => { - const settledCount = rows.filter((row) => isWaitTerminal(row.status)).length; - const pendingCount = rows.filter((row) => row.status === "pending").length; - return mode === "all" ? pendingCount > 0 : settledCount === 0 && pendingCount > 0; -}; - interface SubagentRuntime { readonly crypto: Crypto.Crypto; readonly orchestrationEngine: typeof OrchestrationEngineService.Service; @@ -400,34 +325,6 @@ const requirePeerParentAccess = ( ), ); -const markableWaitDeliveredRows = ( - invocation: McpInvocationContext.McpInvocationScope, - coordinator: ChildThreadCoordinatorShape, - rows: ReadonlyArray, -): Effect.Effect> => { - if (McpInvocationContext.isProviderInvocationScope(invocation)) return Effect.succeed(rows); - const allowedParents = [...(invocation.allowedParentThreadIds ?? [])]; - if (allowedParents.length === 0) return Effect.succeed([]); - return Effect.forEach( - rows, - (row) => - Effect.forEach( - allowedParents, - (parentThreadId) => - coordinator.assertParent(parentThreadId, row.childThreadId).pipe( - Effect.as(true), - Effect.orElseSucceed(() => false), - ), - { concurrency: "unbounded" }, - ).pipe(Effect.map((matches) => ({ row, markable: matches.some(Boolean) }))), - { concurrency: "unbounded" }, - ).pipe( - Effect.map((results) => - results.filter((result) => result.markable).map((result) => result.row), - ), - ); -}; - const loadThreadShell = (runtime: SubagentRuntime, threadId: ThreadId) => runtime.projectionSnapshotQuery .getThreadShellById(threadId) @@ -540,45 +437,25 @@ const resolveExplicitModelSelection = ( runtime: SubagentRuntime, model: string, preferInstanceId: ProviderInstanceId | undefined, + reasoningEffort?: string, ): Effect.Effect => buildModelSources(runtime).pipe( Effect.flatMap((modelSources) => { const resolved = pickModelSelectionFromInstances(model, modelSources, preferInstanceId); - return resolved === null - ? Effect.fail( - fail( - `Model "${model}" is not served by any configured provider. Pass a model shown in the model picker, or omit "model" to keep the thread's current model.`, - ), - ) - : Effect.succeed(resolved); + if (resolved === null) { + return Effect.fail( + fail( + `Model "${model}" is not served by any configured provider. Pass a model shown in the model picker, or omit "model" to keep the thread's current model.`, + ), + ); + } + const effort = applyMcpReasoningEffort(resolved, modelSources, reasoningEffort); + return effort.error === undefined + ? Effect.succeed(effort.selection) + : Effect.fail(fail(effort.error)); }), ); -const probePeerDescriptor = Effect.fn("SubagentToolkit.probePeerDescriptor")(function* ( - httpClient: HttpClient.HttpClient, - peer: SubagentPeerRegistry.SubagentPeer, -) { - const request = HttpClientRequest.get( - environmentUrl(peer.httpBaseUrl, "/.well-known/t3/environment"), - ).pipe(HttpClientRequest.setHeaders(cloudflareAccessHeaders(peer.cfAccess))); - const response = yield* httpClient.execute(request).pipe( - Effect.flatMap(HttpClientResponse.filterStatusOk), - Effect.flatMap(HttpClientResponse.schemaBodyJson(ExecutionEnvironmentDescriptor)), - Effect.timeout(PEER_SPAWN_PROBE_TIMEOUT), - Effect.mapError(() => - fail( - `Sub-agent target '${peer.alias}' is offline or did not answer its environment descriptor${peer.lastSeenAt ? ` (last seen ${peer.lastSeenAt})` : ""}.`, - ), - ), - ); - if (response.environmentId !== peer.environmentId) { - return yield* fail( - `Sub-agent target '${peer.alias}' resolved to environment ${response.environmentId}, expected ${peer.environmentId}.`, - ); - } - return response; -}); - const textContentOfToolResult = (result: { readonly content?: ReadonlyArray | undefined; }): string => { @@ -595,37 +472,18 @@ const textContentOfToolResult = (result: { ) .filter((value) => value.length > 0) .join("\n"); - return text && text.length > 0 ? text : "Remote sub-agent spawn failed."; -}; - -const decodeRemoteSpawnResult = (result: { - readonly isError?: boolean | undefined; - readonly structuredContent?: unknown; - readonly content?: ReadonlyArray | undefined; -}): Effect.Effect => { - if (result.isError === true) { - return Effect.fail(fail(textContentOfToolResult(result))); - } - return decodeSpawnSubagentOutput(result.structuredContent).pipe( - Effect.mapError((error) => - fail( - error instanceof Error - ? error.message - : "Remote sub-agent spawn returned an invalid response.", - ), - ), - ); + return text && text.length > 0 ? text : "Remote sub-agent status request failed."; }; const decodeRemoteCheckResult = (result: { readonly isError?: boolean | undefined; readonly structuredContent?: unknown; readonly content?: ReadonlyArray | undefined; -}): Effect.Effect => { +}): Effect.Effect => { if (result.isError === true) { return Effect.fail(fail(textContentOfToolResult(result))); } - return decodeCheckSubagentOutput(result.structuredContent).pipe( + return decodeSubagentDetailOutput(result.structuredContent).pipe( Effect.mapError((error) => fail( error instanceof Error @@ -636,6 +494,15 @@ const decodeRemoteCheckResult = (result: { ); }; +const isMissingPeerToolResult = ( + result: { + readonly isError?: boolean | undefined; + readonly content?: ReadonlyArray | undefined; + }, + toolName: string, +): boolean => + result.isError === true && textContentOfToolResult(result).includes(`'${toolName}' not found`); + const isRemoteTerminalStatus = (status: string): boolean => status === "completed" || status === "failed" || status === "killed" || status === "interrupted"; @@ -699,10 +566,10 @@ const resolveRemoteChildPeer = (runtime: SubagentRuntime, child: RemoteChild) => ); }); -const withPeerToolTimeout = ( - effect: Effect.Effect, +const withPeerToolTimeout = ( + effect: Effect.Effect, timeoutContext: string, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const fiber = yield* effect.pipe(Effect.forkDetach); const exit = yield* Fiber.await(fiber).pipe( @@ -743,10 +610,6 @@ const callPeerTool = ( return yield* session.callTool(input); }), (session) => closePeerSession(session, peer), - ).pipe( - Effect.mapError((error) => - isThreadStartToolError(error) ? error : fail(error.message ?? "Remote peer call failed."), - ), ), timeoutContext, ); @@ -785,23 +648,6 @@ const closePeerSession = ( ), ); -const connectPeerWithTimeout = ( - runtime: SubagentRuntime, - peer: SubagentPeerRegistry.SubagentPeer, - timeoutContext: string, -) => - withPeerToolTimeout( - McpPeerClient.connect(peer).pipe( - Effect.provideService(HttpClient.HttpClient, runtime.httpClient), - Effect.mapError((error) => - isThreadStartToolError(error) - ? error - : fail(error.message ?? "Remote peer connect failed."), - ), - ), - timeoutContext, - ); - const callRemoteChildTool = ( runtime: SubagentRuntime, child: RemoteChild, @@ -812,6 +658,24 @@ const callRemoteChildTool = ( return yield* callPeerTool(runtime, peer, input, `Remote peer '${peer.alias}'`); }); +const isMcpPeerClientError = Schema.is(McpPeerClient.McpPeerClientError); + +const isMissingPeerToolError = (error: unknown, toolName: string): boolean => { + if (!isMcpPeerClientError(error)) return false; + if (error.operation !== "json-rpc" || error.method !== "tools/call") return false; + const detail = error.detail.toLowerCase(); + return ( + /invalid[ _-]?(params|parameters)/u.test(detail) || + detail.includes("unknown tool") || + (detail.includes(toolName.toLowerCase()) && detail.includes("not found")) + ); +}; + +const toPeerToolError = (error: unknown): ThreadStartToolError => + isThreadStartToolError(error) + ? error + : fail(error instanceof Error ? error.message : "Remote peer call failed."); + const remoteChildrenByIdForParent = ( runtime: SubagentRuntime, parentThreadId: ThreadId, @@ -832,7 +696,7 @@ const remoteChildrenByIdForParent = ( const deliverRemoteCompletion = ( runtime: SubagentRuntime, child: RemoteChild, - check: CheckSubagentOutputType, + check: SubagentDetailOutputType, ) => Effect.gen(function* () { if (!isRemoteTerminalStatus(check.status)) return; @@ -887,7 +751,7 @@ type RemoteTerminalStatusUpdate = { type SuppressedRemoteTerminalWake = { readonly child: RemoteChild; - readonly check: CheckSubagentOutputType; + readonly check: SubagentDetailOutputType; readonly claimId: string; readonly update: RemoteTerminalStatusUpdate; }; @@ -912,50 +776,30 @@ const releaseSuppressedRemoteTerminalWake = ( updatedAt: input.update.updatedAt, }); -const markSuppressedRemoteTerminalWake = ( - runtime: SubagentRuntime, - input: SuppressedRemoteTerminalWake, -) => markRemoteTerminalClaim(runtime, input); - -const restoreSuppressedRemoteTerminalWake = ( - runtime: SubagentRuntime, - input: SuppressedRemoteTerminalWake, -) => - Effect.uninterruptible( - deliverRemoteCompletion(runtime, input.child, input.check).pipe( - Effect.catch((error) => - releaseSuppressedRemoteTerminalWake(runtime, input).pipe( - Effect.andThen(Effect.fail(error)), - ), - ), - Effect.andThen( - markRemoteTerminalClaim(runtime, input).pipe( - Effect.catch((error) => - releaseSuppressedRemoteTerminalWake(runtime, input).pipe( - Effect.andThen(Effect.fail(error)), - ), - ), - ), - ), - ), - ); - -const pollRemoteChildWithDeliveryResult = Effect.fn( - "SubagentToolkit.pollRemoteChildWithDeliveryResult", -)(function* ( +const pollRemoteChild = Effect.fn("SubagentToolkit.pollRemoteChild")(function* ( runtime: SubagentRuntime, child: RemoteChild, - options?: { - readonly deliverTerminalWake?: boolean; - readonly onSuppressedTerminalWake?: ( - suppressed: SuppressedRemoteTerminalWake, - ) => Effect.Effect; - }, ) { - const callResult = yield* callRemoteChildTool(runtime, child, { - name: "t3_check_subagent", + const subagentsResult = yield* callRemoteChildTool(runtime, child, { + name: "t3_subagents", arguments: { childThreadId: child.childThreadId }, - }); + }).pipe( + Effect.catch((error) => + isMissingPeerToolError(error, "t3_subagents") + ? callRemoteChildTool(runtime, child, { + name: "t3_check_subagent", + arguments: { childThreadId: child.childThreadId }, + }) + : Effect.fail(error), + ), + Effect.mapError(toPeerToolError), + ); + const callResult = isMissingPeerToolResult(subagentsResult, "t3_subagents") + ? yield* callRemoteChildTool(runtime, child, { + name: "t3_check_subagent", + arguments: { childThreadId: child.childThreadId }, + }).pipe(Effect.mapError(toPeerToolError)) + : subagentsResult; const check = yield* decodeRemoteCheckResult(callResult); if (check.threadId !== child.childThreadId) { return yield* fail( @@ -980,8 +824,7 @@ const pollRemoteChildWithDeliveryResult = Effect.fn( if (!isRemoteTerminalStatus(child.status)) { const claimId = yield* runtime.crypto.randomUUIDv4.pipe(Effect.orDie); const suppressed = { child, check, claimId, update } satisfies SuppressedRemoteTerminalWake; - const deliverTerminalWake = options?.deliverTerminalWake ?? true; - const claimEffect = runtime.remoteChildren + const claimed = yield* runtime.remoteChildren .claimTerminalDelivery({ parentThreadId: child.parentThreadId, childEnvironmentId: child.childEnvironmentId, @@ -997,24 +840,7 @@ const pollRemoteChildWithDeliveryResult = Effect.fn( toToolError(error, "Failed to claim remote child completion delivery."), ), ); - const claimed = yield* deliverTerminalWake - ? claimEffect - : Effect.uninterruptible( - claimEffect.pipe( - Effect.tap((claimed) => - Option.isSome(claimed) && options?.onSuppressedTerminalWake !== undefined - ? options.onSuppressedTerminalWake(suppressed) - : Effect.void, - ), - ), - ); if (Option.isSome(claimed)) { - if (!deliverTerminalWake) { - return { - check, - suppressedTerminalWake: suppressed, - }; - } yield* Effect.uninterruptible( deliverRemoteCompletion(runtime, child, check).pipe( Effect.catch((error) => @@ -1025,279 +851,15 @@ const pollRemoteChildWithDeliveryResult = Effect.fn( Effect.andThen(markRemoteTerminalClaim(runtime, suppressed)), ), ); - return { - check, - suppressedTerminalWake: null, - }; + return check; } } - return { check, suppressedTerminalWake: null }; + return check; } yield* runtime.remoteChildren .updateStatus(update) .pipe(Effect.mapError((error) => toToolError(error, "Failed to update remote child."))); - return { check, suppressedTerminalWake: null }; -}); - -const pollRemoteChild = Effect.fn("SubagentToolkit.pollRemoteChild")(function* ( - runtime: SubagentRuntime, - child: RemoteChild, -) { - const result = yield* pollRemoteChildWithDeliveryResult(runtime, child); - return result.check; -}); - -const callRemoteSpawnTool = ( - runtime: SubagentRuntime, - peer: SubagentPeerRegistry.SubagentPeer, - session: McpPeerClient.McpPeerClientSession, - arguments_: SpawnSubagentInput, -) => - Effect.gen(function* () { - yield* runtime.peerRegistry - .updateLastSeen(peer.alias) - .pipe(Effect.mapError((error) => fail(error.message))); - return yield* session - .callTool({ - name: "t3_spawn_subagent", - arguments: arguments_, - }) - .pipe(Effect.mapError((error) => fail(error.message))); - }).pipe(Effect.ensuring(closePeerSession(session, peer))); - -const remoteWaitRow = (input: { - readonly child: RemoteChild; - readonly check: CheckSubagentOutputType; - readonly status: string; - readonly promoted: boolean; -}): WaitSubagentOutputType["results"][number] => { - const terminal = isRemoteTerminalStatus(input.status); - const failed = terminal && input.status !== "completed"; - return { - childThreadId: input.child.childThreadId, - status: input.status, - turnCount: input.check.turnCount, - finalAssistantText: terminal ? input.check.latestAssistantText : null, - error: failed ? `Remote sub-agent ended with status ${input.check.status}.` : null, - ...(input.promoted - ? { - note: "still running — you will be NOTIFIED when it completes; stop calling wait and do other work", - } - : {}), - }; -}; - -const waitRemoteSubagents = Effect.fn("SubagentToolkit.waitRemoteSubagents")(function* (input: { - readonly runtime: SubagentRuntime; - readonly children: ReadonlyArray; - readonly mode: "all" | "any"; - readonly waitStartMs: number; - readonly callerDeadlineMs: number; - readonly autoPromoteDeadlineMs: number; - readonly resumeToken: string | undefined; -}) { - const suppressedTerminalWakes = new Map(); - - const restoreAllSuppressedTerminalWakes = (reason: string) => - Effect.gen(function* () { - const pending = Array.from(suppressedTerminalWakes.values()); - if (pending.length === 0) return; - const restoreExits = yield* Effect.forEach( - pending, - (suppressed) => - restoreSuppressedRemoteTerminalWake(input.runtime, suppressed).pipe(Effect.exit), - { concurrency: "unbounded" }, - ); - suppressedTerminalWakes.clear(); - const restoreFailure = restoreExits.find(Exit.isFailure); - if (restoreFailure !== undefined) { - yield* Effect.logWarning("failed to restore remote terminal wake after wait exit", { - reason, - cause: Cause.pretty(restoreFailure.cause), - }); - } - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("failed to clean up suppressed remote terminal wakes", { - reason, - cause: Cause.pretty(cause), - }), - ), - ); - - return yield* Effect.gen(function* () { - const sliceStartMs = yield* Effect.clockWith((clock) => clock.currentTimeMillis); - const autoPromoteDeadlineWasActive = input.autoPromoteDeadlineMs <= input.callerDeadlineMs; - const sliceDeadlineMs = Math.min( - input.callerDeadlineMs, - input.autoPromoteDeadlineMs, - sliceStartMs + WAIT_SLICE_SECONDS * 1_000, - ); - let rows: WaitSubagentOutputType["results"] = []; - let promoted = false; - - while (true) { - const checkExits = yield* Effect.forEach( - input.children, - (child) => - pollRemoteChildWithDeliveryResult(input.runtime, child, { - deliverTerminalWake: false, - onSuppressedTerminalWake: (suppressed) => - Effect.sync(() => { - suppressedTerminalWakes.set(String(suppressed.child.childThreadId), suppressed); - }), - }).pipe( - Effect.map((result) => ({ child, ...result })), - Effect.exit, - ), - { concurrency: "unbounded" }, - ); - const successes = checkExits.flatMap((exit) => (Exit.isSuccess(exit) ? [exit.value] : [])); - for (const success of successes) { - if (success.suppressedTerminalWake !== null) { - suppressedTerminalWakes.set( - String(success.suppressedTerminalWake.child.childThreadId), - success.suppressedTerminalWake, - ); - } - } - const failed = checkExits.find(Exit.isFailure); - if (failed !== undefined) { - yield* restoreAllSuppressedTerminalWakes("wait poll failure"); - return yield* Effect.failCause(failed.cause); - } - const checks = successes; - const afterPollMs = yield* Effect.clockWith((clock) => clock.currentTimeMillis); - promoted = - autoPromoteDeadlineWasActive && - afterPollMs >= input.autoPromoteDeadlineMs && - checks.some(({ check }) => !isRemoteTerminalStatus(check.status)); - rows = checks.map(({ child, check }) => { - const terminal = isRemoteTerminalStatus(check.status); - const status = terminal - ? check.status - : promoted - ? "running" - : afterPollMs >= input.callerDeadlineMs - ? "timeout" - : "pending"; - return remoteWaitRow({ child, check, status, promoted: promoted && !terminal }); - }); - const pending = promoted ? false : pendingForMode(rows, input.mode); - if (!pending || afterPollMs >= sliceDeadlineMs) { - const markResults = yield* Effect.forEach( - Array.from(suppressedTerminalWakes.values()), - (suppressed) => - markSuppressedRemoteTerminalWake(input.runtime, suppressed).pipe( - Effect.exit, - Effect.map((exit) => ({ suppressed, exit })), - ), - { concurrency: "unbounded" }, - ); - let markFailureCause: Cause.Cause | null = null; - for (const result of markResults) { - if (Exit.isFailure(result.exit)) { - markFailureCause ??= result.exit.cause; - } - } - if (markFailureCause !== null) { - yield* Effect.logWarning("failed to mark remote wait completion delivered", { - cause: Cause.pretty(markFailureCause), - }); - return yield* Effect.failCause(markFailureCause); - } - suppressedTerminalWakes.clear(); - const settledCount = rows.filter((row) => isWaitTerminal(row.status)).length; - const timedOutCount = promoted ? 0 : rows.filter((row) => row.status === "timeout").length; - return { - results: rows, - settledCount, - timedOutCount, - pending: promoted ? false : pending, - resumeToken: `${input.waitStartMs}:${input.resumeToken ?? "remote"}`, - ...(promoted ? { promoted: true } : {}), - } satisfies WaitSubagentOutputType; - } - const sleepMs = Math.max(1, Math.min(1_000, sliceDeadlineMs - afterPollMs)); - yield* Effect.sleep(Duration.millis(sleepMs)); - } - }).pipe( - Effect.onExit((exit) => - Exit.isSuccess(exit) ? Effect.void : restoreAllSuppressedTerminalWakes("wait interrupted"), - ), - ); -}); - -const isUntrackedProjectionWaitRow = (row: WaitSliceResult["results"][number]): boolean => - row.status === "failed" && row.error === UNTRACKED_PROJECTION_CHILD_ERROR; - -const peerProjectionWaitRow = ( - runtime: SubagentRuntime, - row: WaitSliceResult["results"][number], - callerDeadlineMs: number, - observedAtMs: number, -): Effect.Effect => - loadThreadDetail(runtime, row.childThreadId).pipe( - Effect.map((thread) => - Option.match(thread, { - onNone: () => row, - onSome: (detail): WaitSliceResult["results"][number] => { - const terminalStatus = reliableWaitTerminalStatusOf(detail); - if (terminalStatus === "completed") { - return { - childThreadId: row.childThreadId, - status: "completed", - finalAssistantText: finalAssistantTextFromThread(detail), - error: null, - }; - } - if (terminalStatus === "failed") { - return { - childThreadId: row.childThreadId, - status: "failed", - finalAssistantText: finalAssistantTextFromThread(detail), - error: "Child thread ended with status failed.", - }; - } - const timedOut = observedAtMs >= callerDeadlineMs; - return { - childThreadId: row.childThreadId, - status: timedOut ? "timeout" : "pending", - finalAssistantText: null, - error: timedOut ? "wait exceeded budget" : null, - }; - }, - }), - ), - ); - -const applyPeerProjectionWaitFallback = Effect.fn( - "SubagentToolkit.applyPeerProjectionWaitFallback", -)(function* (input: { - readonly invocation: McpInvocationContext.McpInvocationScope; - readonly runtime: SubagentRuntime; - readonly slice: WaitSliceResult; - readonly mode: "all" | "any"; - readonly callerDeadlineMs: number; - readonly observedAtMs: number; -}) { - if (McpInvocationContext.isProviderInvocationScope(input.invocation)) return input.slice; - if (!input.slice.results.some(isUntrackedProjectionWaitRow)) return input.slice; - const results = yield* Effect.forEach( - input.slice.results, - (row) => - isUntrackedProjectionWaitRow(row) - ? peerProjectionWaitRow(input.runtime, row, input.callerDeadlineMs, input.observedAtMs) - : Effect.succeed(row), - { concurrency: "unbounded" }, - ); - return { - results, - settledCount: results.filter((row) => isWaitTerminal(row.status)).length, - timedOutCount: results.filter((row) => row.status === "timeout").length, - pending: pendingForMode(results, input.mode), - resumeToken: input.slice.resumeToken, - } satisfies WaitSliceResult; + return check; }); const pollRemoteChildrenOnce = Effect.fn("SubagentToolkit.pollRemoteChildrenOnce")(function* ( @@ -1333,74 +895,38 @@ const remoteChildPoller = (runtime: SubagentRuntime) => Effect.repeat(Schedule.spaced(Duration.seconds(2))), ); -const spawnSubagent = Effect.fn("SubagentToolkit.spawn")(function* (input: SpawnSubagentInput) { +const PUBLIC_SPAWN_KEYS = new Set([ + "prompt", + "model", + "title", + "directory", + "branch", + "reasoningEffort", +]); + +const spawnSubagent = Effect.fn("SubagentToolkit.spawn")(function* ( + input: SpawnSubagentInternalInputType, +) { const invocation = yield* requireSubagentCapability("subagent:spawn"); const runtime = yield* requireRuntime(); const spawnRuntime = yield* requireSpawnRuntime(); const { - detached: detachedInput, - waitTimeoutSeconds, - target, + detached: _detached, + target: _target, remoteParentThreadId, remoteParentEnvironmentId, + waitTimeoutSeconds: _waitTimeoutSeconds, ...threadStartInput } = input; - if (target !== undefined) { - if (!McpInvocationContext.isProviderInvocationScope(invocation)) { - return yield* fail("target is only supported by a provider-scoped parent thread."); - } - if (detachedInput === false) { - return yield* fail( - "Foreground remote sub-agent spawn is not available yet; omit detached or set detached=true.", - ); - } - if (threadStartInput.directory === undefined) { - return yield* fail( - "Remote sub-agent spawn requires directory so the target backend can choose a local project.", - ); - } - const peer = yield* runtime.peerRegistry - .resolveTarget(target) - .pipe(Effect.mapError((error) => fail(error.message))); - const descriptor = yield* probePeerDescriptor(runtime.httpClient, peer); - const remoteArguments = { - ...threadStartInput, - detached: true, - remoteParentThreadId: invocation.threadId, - remoteParentEnvironmentId: invocation.environmentId, - } satisfies SpawnSubagentInput; - const session = yield* connectPeerWithTimeout(runtime, peer, `Remote peer '${peer.alias}'`); - const callResult = yield* callRemoteSpawnTool(runtime, peer, session, remoteArguments); - const started = yield* decodeRemoteSpawnResult(callResult); - const createdAt = yield* nowIso; - yield* runtime.remoteChildren - .upsert({ - parentThreadId: invocation.threadId, - childEnvironmentId: descriptor.environmentId, - childThreadId: started.childThreadId, - alias: peer.alias, - spawnParams: remoteArguments, - status: "running", - lastPolledAt: null, - createdAt, - updatedAt: createdAt, - }) - .pipe(Effect.mapError((error) => toToolError(error, "Failed to record remote child."))); - return { - ...started, - parentThreadId: invocation.threadId, - }; - } - if (!McpInvocationContext.isProviderInvocationScope(invocation)) { if (remoteParentThreadId === undefined) { return yield* fail( "Peer-scoped sub-agent spawn requires remoteParentThreadId from the caller backend.", ); } - if (detachedInput === false) { + if (input.detached === false) { return yield* fail("Peer-scoped remote sub-agent spawn must be detached."); } const parentEnvironmentId = invocation.sourceEnvironmentId; @@ -1443,7 +969,19 @@ const spawnSubagent = Effect.fn("SubagentToolkit.spawn")(function* (input: Spawn const providerInvocation = invocation; const coordinator = yield* requireCoordinator(); - const detached = detachedInput ?? true; + const unexpectedKeys = Object.keys(input).filter((key) => !PUBLIC_SPAWN_KEYS.has(key)); + if (unexpectedKeys.length > 0) { + return yield* fail( + `Unsupported t3_spawn_subagent argument${unexpectedKeys.length === 1 ? "" : "s"}: ${unexpectedKeys.join(", ")}. Valid arguments: prompt, model, title, directory, branch, reasoningEffort.`, + ); + } + if (input.model === undefined || input.title === undefined) { + const missing = [ + ...(input.model === undefined ? ["model"] : []), + ...(input.title === undefined ? ["title"] : []), + ]; + return yield* fail(`Missing required t3_spawn_subagent argument(s): ${missing.join(", ")}.`); + } // Fail-fast: refuse to spawn against a provider instance that no longer exists. const source = yield* loadThreadShell(runtime, providerInvocation.threadId).pipe( @@ -1458,14 +996,12 @@ const spawnSubagent = Effect.fn("SubagentToolkit.spawn")(function* (input: Spawn // An explicit bare `model` resolves against the live provider model lists; a // named model no provider serves fails loudly instead of silently spawning on // a different (inherited) model. Prefer the source thread's instance on ties. - const modelSelection: ModelSelection = - threadStartInput.model !== undefined - ? yield* resolveExplicitModelSelection( - runtime, - threadStartInput.model, - source.modelSelection.instanceId, - ) - : (threadStartInput.modelSelection ?? source.modelSelection); + const modelSelection: ModelSelection = yield* resolveExplicitModelSelection( + runtime, + input.model, + source.modelSelection.instanceId, + input.reasoningEffort, + ); const instance = yield* runtime.providerInstanceRegistry.getInstance(modelSelection.instanceId); if (instance === undefined) { return yield* fail(`Provider instance ${modelSelection.instanceId} is not available.`); @@ -1478,42 +1014,45 @@ const spawnSubagent = Effect.fn("SubagentToolkit.spawn")(function* (input: Spawn // Spawn with the ALREADY-resolved selection (drop `model`) so the thread // runtime does not re-resolve against a possibly-different registry snapshot — // the coordinator record and the started thread then share one selection. - const { model: _resolvedModel, ...threadStartInputWithSelection } = threadStartInput; - const { started, spawnedAtMs, cleanupAndReleaseFromDelete } = yield* Effect.uninterruptibleMask( - (restore) => - Effect.gen(function* () { - const dispatchLease = yield* restore( - acquireLocalDispatchLease(runtime, providerInvocation.threadId), - ); - const releaseDispatchLease: Effect.Effect = - runtime.dispatchLimiter.release(dispatchLease); - const started = yield* spawnRuntime( - { ...threadStartInputWithSelection, modelSelection }, - providerInvocation, - ).pipe(Effect.onError(() => releaseDispatchLease)); - yield* runtime.dispatchLimiter.bindChild(dispatchLease, started.threadId); - const cleanupAndReleaseFromDelete = (reason: string) => - cleanupStartedChild(runtime, started.threadId, reason, releaseDispatchLease); - const spawnedAtMs = yield* Effect.clockWith((clock) => clock.currentTimeMillis); - - // Persist the parent linkage before registration relies on the in-memory - // limiter binding; restart reconciliation seeds running children from - // this durable link. - yield* dispatchParentSet(runtime, started.threadId, providerInvocation.threadId).pipe( - Effect.mapError((error) => toToolError(error, "Failed to link sub-agent to parent.")), - Effect.onError(() => cleanupAndReleaseFromDelete("parent link failed")), - ); - yield* coordinator - .register({ - parentThreadId: providerInvocation.threadId, - childThreadId: started.threadId, - detached, - model: modelSelection, - spawnedAtMs, - }) - .pipe(Effect.onError(() => cleanupAndReleaseFromDelete("coordinator register failed"))); - return { started, spawnedAtMs, cleanupAndReleaseFromDelete }; - }), + const { + model: _resolvedModel, + reasoningEffort: _reasoningEffort, + ...threadStartInputBase + } = threadStartInput; + const { started } = yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const dispatchLease = yield* restore( + acquireLocalDispatchLease(runtime, providerInvocation.threadId), + ); + const releaseDispatchLease: Effect.Effect = + runtime.dispatchLimiter.release(dispatchLease); + const started = yield* spawnRuntime( + { ...threadStartInputBase, modelSelection }, + providerInvocation, + ).pipe(Effect.onError(() => releaseDispatchLease)); + yield* runtime.dispatchLimiter.bindChild(dispatchLease, started.threadId); + const cleanupAndReleaseFromDelete = (reason: string) => + cleanupStartedChild(runtime, started.threadId, reason, releaseDispatchLease); + const spawnedAtMs = yield* Effect.clockWith((clock) => clock.currentTimeMillis); + + // Persist the parent linkage before registration relies on the in-memory + // limiter binding; restart reconciliation seeds running children from + // this durable link. + yield* dispatchParentSet(runtime, started.threadId, providerInvocation.threadId).pipe( + Effect.mapError((error) => toToolError(error, "Failed to link sub-agent to parent.")), + Effect.onError(() => cleanupAndReleaseFromDelete("parent link failed")), + ); + yield* coordinator + .register({ + parentThreadId: providerInvocation.threadId, + childThreadId: started.threadId, + detached: true, + model: modelSelection, + spawnedAtMs, + }) + .pipe(Effect.onError(() => cleanupAndReleaseFromDelete("coordinator register failed"))); + return { started }; + }), ); const base: SpawnSubagentOutputType = { @@ -1526,37 +1065,7 @@ const spawnSubagent = Effect.fn("SubagentToolkit.spawn")(function* (input: Spawn ...(started.warning ? { warning: started.warning } : {}), }; - if (detached) return base; - - const budgetSeconds = clamp( - waitTimeoutSeconds ?? WAIT_TIMEOUT_DEFAULT_SECONDS, - WAIT_TIMEOUT_MIN_SECONDS, - WAIT_TIMEOUT_MAX_SECONDS, - ); - const budgetDeadlineMs = spawnedAtMs + budgetSeconds * 1_000; - const initialSlice = yield* coordinator.waitSlice({ - childThreadIds: [started.threadId], - mode: "all", - budgetDeadlineMs, - }); - const row = initialSlice.results[0]; - if (row !== undefined && isWaitTerminal(row.status)) { - return { - ...base, - status: row.status, - finalAssistantText: row.finalAssistantText ?? null, - }; - } - - yield* coordinator - .promoteToWake([started.threadId]) - .pipe(Effect.onError(() => cleanupAndReleaseFromDelete("foreground promotion failed"))); - return { - ...base, - warning: appendWarning(base.warning, FOREGROUND_SPAWN_PENDING_WARNING), - status: "running", - finalAssistantText: null, - }; + return base; }); const dispatchParentSet = Effect.fn("SubagentToolkit.dispatchParentSet")(function* ( @@ -1682,7 +1191,9 @@ const steerSubagent = Effect.fn("SubagentToolkit.steer")(function* (input: Steer }; }); -const checkSubagent = Effect.fn("SubagentToolkit.check")(function* (input: CheckSubagentInput) { +const checkSubagent = Effect.fn("SubagentToolkit.check")(function* ( + input: LegacyCheckSubagentInput, +) { const invocation = yield* requireSubagentCapability("subagent:check"); const runtime = yield* requireRuntime(); yield* requirePeerChildAccess(runtime, invocation, [input.childThreadId]); @@ -1695,6 +1206,8 @@ const checkSubagent = Effect.fn("SubagentToolkit.check")(function* (input: Check if (remoteChild !== undefined) { return yield* pollRemoteChild(runtime, remoteChild); } + const coordinator = yield* requireCoordinator(); + yield* coordinator.assertParent(invocation.threadId, input.childThreadId); } const detail = yield* loadThreadDetail(runtime, input.childThreadId).pipe( @@ -1719,310 +1232,9 @@ const checkSubagent = Effect.fn("SubagentToolkit.check")(function* (input: Check // (":"). The first call (no resumeToken) marks // now; subsequent calls recover the marker so the 90s budget spans the whole // wait regardless of how many slices the model re-issued. -const parseWaitStartMs = (resumeToken: string | undefined, nowMs: number): number => { - if (resumeToken === undefined) return nowMs; - const separator = resumeToken.indexOf(":"); - if (separator <= 0) return nowMs; - const parsed = Number(resumeToken.slice(0, separator)); - return Number.isFinite(parsed) ? parsed : nowMs; -}; - -const waitSubagent = Effect.fn("SubagentToolkit.wait")(function* (input: WaitSubagentInput) { - const invocation = yield* requireSubagentCapability("subagent:wait"); - const runtime = yield* requireRuntime(); - const coordinator = yield* requireCoordinator(); - const childThreadIds = Array.from(new Set(input.childThreadIds)); - - yield* requirePeerChildAccess(runtime, invocation, childThreadIds); - const remoteRowsById = McpInvocationContext.isProviderInvocationScope(invocation) - ? yield* remoteChildrenByIdForParent(runtime, invocation.threadId, childThreadIds) - : new Map(); - if (remoteRowsById.size > 0) { - if (remoteRowsById.size !== childThreadIds.length) { - return yield* fail( - "t3_wait_subagent cannot mix local and remote sub-agents in one call yet; wait for each backend group separately.", - ); - } - const budgetSeconds = clamp( - input.timeoutSeconds ?? WAIT_TIMEOUT_DEFAULT_SECONDS, - WAIT_TIMEOUT_MIN_SECONDS, - WAIT_TIMEOUT_MAX_SECONDS, - ); - const nowMs = yield* Effect.clockWith((clock) => clock.currentTimeMillis); - const waitStartMs = parseWaitStartMs(input.resumeToken, nowMs); - return yield* waitRemoteSubagents({ - runtime, - children: childThreadIds.map((childThreadId) => remoteRowsById.get(String(childThreadId))!), - mode: input.mode ?? "all", - waitStartMs, - callerDeadlineMs: nowMs + budgetSeconds * 1_000, - autoPromoteDeadlineMs: waitStartMs + WAIT_AUTO_PROMOTE_SECONDS * 1_000, - resumeToken: input.resumeToken, - }); - } - if (McpInvocationContext.isProviderInvocationScope(invocation)) { - yield* Effect.forEach( - childThreadIds, - (childThreadId) => coordinator.assertParent(invocation.threadId, childThreadId), - { discard: true }, - ); - } - - const budgetSeconds = clamp( - input.timeoutSeconds ?? WAIT_TIMEOUT_DEFAULT_SECONDS, - WAIT_TIMEOUT_MIN_SECONDS, - WAIT_TIMEOUT_MAX_SECONDS, - ); - const nowMs = yield* Effect.clockWith((clock) => clock.currentTimeMillis); - const waitStartMs = parseWaitStartMs(input.resumeToken, nowMs); - // Hand the coordinator only its own opaque token (the part after the marker). - const coordinatorToken = - input.resumeToken !== undefined && input.resumeToken.indexOf(":") > 0 - ? input.resumeToken.slice(input.resumeToken.indexOf(":") + 1) - : input.resumeToken; - // Cap the cumulative blocking budget at WAIT_AUTO_PROMOTE_SECONDS regardless - // of the caller's timeoutSeconds; the earlier of the two deadlines bounds the - // slice so the slice itself never blocks past the auto-promote horizon. - const autoPromoteDeadlineMs = waitStartMs + WAIT_AUTO_PROMOTE_SECONDS * 1_000; - const callerDeadlineMs = nowMs + budgetSeconds * 1_000; - const budgetDeadlineMs = Math.min(callerDeadlineMs, autoPromoteDeadlineMs); - const terminalWaitChildIds = new Set(); - const abandonTerminalWaits = () => - terminalWaitChildIds.size === 0 - ? Effect.void - : coordinator.abandonWaitDelivery([...terminalWaitChildIds]); - - return yield* Effect.gen(function* () { - // One bounded slice per invocation — the agent re-calls with the returned - // resumeToken while `pending` is true (never one long HTTP hold). - const rawSlice = yield* coordinator.waitSlice({ - childThreadIds, - mode: input.mode ?? "all", - budgetDeadlineMs, - ...(coordinatorToken !== undefined ? { resumeToken: coordinatorToken } : {}), - }); - const afterSliceMs = yield* Effect.clockWith((clock) => clock.currentTimeMillis); - const slice = yield* applyPeerProjectionWaitFallback({ - invocation, - runtime, - slice: rawSlice, - mode: input.mode ?? "all", - callerDeadlineMs, - observedAtMs: afterSliceMs, - }); - terminalWaitChildIds.clear(); - for (const row of slice.results) { - if (isWaitTerminal(row.status)) { - terminalWaitChildIds.add(row.childThreadId); - } - } - - const enrichedRows = yield* Effect.forEach( - slice.results, - ( - row, - ): Effect.Effect< - { - readonly row: WaitSliceResult["results"][number]; - readonly projectionTerminal: boolean; - }, - ThreadStartToolError, - never - > => { - if (row.status !== "pending" && row.status !== "timeout") { - return Effect.succeed({ row, projectionTerminal: false }); - } - return loadThreadDetail(runtime, row.childThreadId).pipe( - Effect.timeoutOption(`${WAIT_PROJECTION_ENRICHMENT_TIMEOUT_MS} millis`), - Effect.map((thread) => - Option.match(thread, { - onNone: () => ({ row, projectionTerminal: false }), - onSome: ( - detailOption, - ): { - readonly row: WaitSliceResult["results"][number]; - readonly projectionTerminal: boolean; - } => - Option.match(detailOption, { - onNone: () => ({ row, projectionTerminal: false }), - onSome: ( - detail, - ): { - readonly row: WaitSliceResult["results"][number]; - readonly projectionTerminal: boolean; - } => { - const projectedStatus = reliableWaitTerminalStatusOf(detail); - if (projectedStatus === null) return { row, projectionTerminal: false }; - if (projectedStatus === "completed") { - return { - row: { - childThreadId: row.childThreadId, - status: "completed", - finalAssistantText: - row.finalAssistantText ?? finalAssistantTextFromThread(detail), - error: null, - ...(row.parentTurnIdAtWait === undefined - ? {} - : { parentTurnIdAtWait: row.parentTurnIdAtWait }), - }, - projectionTerminal: true, - }; - } - if (projectedStatus === "failed") { - return { - row: { - childThreadId: row.childThreadId, - status: "failed", - finalAssistantText: - row.finalAssistantText ?? finalAssistantTextFromThread(detail), - error: `Child thread ended with status ${projectedStatus}.`, - ...(row.parentTurnIdAtWait === undefined - ? {} - : { parentTurnIdAtWait: row.parentTurnIdAtWait }), - }, - projectionTerminal: true, - }; - } - return { row, projectionTerminal: false }; - }, - }), - }), - ), - ); - }, - { concurrency: "unbounded" }, - ); - const effectiveRows: ReadonlyArray = enrichedRows.map( - (entry) => entry.row, - ); - const effectiveSlice: WaitSliceResult = { - results: effectiveRows, - settledCount: effectiveRows.filter((row) => isWaitTerminal(row.status)).length, - timedOutCount: effectiveRows.filter((row) => row.status === "timeout").length, - pending: pendingForMode(effectiveRows, input.mode ?? "all"), - resumeToken: slice.resumeToken, - }; - const untrackedProjectionChildIds = new Set( - rawSlice.results.filter(isUntrackedProjectionWaitRow).map((row) => String(row.childThreadId)), - ); - const coordinatorTerminalIds = new Set( - rawSlice.results - .filter((row) => isWaitTerminal(row.status) && !isUntrackedProjectionWaitRow(row)) - .map((row) => String(row.childThreadId)), - ); - const projectionTerminalIds = new Set( - enrichedRows - .filter((entry) => entry.projectionTerminal) - .map((entry) => String(entry.row.childThreadId)), - ); - const waitDeliveredRows = effectiveSlice.results.filter( - (row) => - isWaitTerminal(row.status) && - (coordinatorTerminalIds.has(String(row.childThreadId)) || - projectionTerminalIds.has(String(row.childThreadId))), - ); - for (const row of waitDeliveredRows) { - terminalWaitChildIds.add(row.childThreadId); - } - // Auto-promote (R-A): the 90s budget elapsed and one+ children are still - // running. The coordinator maps "pending when the supplied deadline elapsed" - // to status "timeout"; when the supplied deadline was our auto-promote cap - // rather than the caller's requested timeout, those timeout rows are still - // running children that must be promoted to wake-on-completion. - const autoPromoteDeadlineWasActive = autoPromoteDeadlineMs <= callerDeadlineMs; - const stillRunningIds = - autoPromoteDeadlineWasActive && afterSliceMs >= autoPromoteDeadlineMs - ? effectiveSlice.results - .filter( - (row) => - (row.status === "pending" || row.status === "timeout") && - !untrackedProjectionChildIds.has(String(row.childThreadId)), - ) - .map((row) => row.childThreadId) - : []; - const autoPromote = stillRunningIds.length > 0; - const autoPromotedChildIds = new Set( - stillRunningIds.map((childThreadId) => String(childThreadId)), - ); - if (autoPromote) { - yield* coordinator.promoteToWake(stillRunningIds); - } - - // Enrich each row with a turn count from the projection (the coordinator's - // terminal result intentionally does not track it). On auto-promote, a still - // "pending"/auto-promote "timeout" child is reported as "running" with a note - // telling the model to stop waiting. - const results: WaitSubagentOutputType["results"] = yield* Effect.forEach( - effectiveSlice.results, - (row) => - loadThreadDetail(runtime, row.childThreadId).pipe( - Effect.timeoutOption(`${WAIT_PROJECTION_ENRICHMENT_TIMEOUT_MS} millis`), - Effect.map((thread) => { - const promotedRunning = - autoPromotedChildIds.has(String(row.childThreadId)) && - (row.status === "pending" || row.status === "timeout"); - return { - childThreadId: row.childThreadId, - status: promotedRunning ? "running" : row.status, - turnCount: Option.match(thread, { - onNone: () => 0, - onSome: (detailOption) => - Option.match(detailOption, { onNone: () => 0, onSome: turnCountOf }), - }), - finalAssistantText: row.finalAssistantText, - error: promotedRunning ? null : row.error, - ...(promotedRunning - ? { - note: "still running — you will be NOTIFIED when it completes; stop calling wait and do other work", - } - : {}), - }; - }), - ), - { concurrency: "unbounded" }, - ); - const reportedTimedOutCount = autoPromote - ? effectiveSlice.results.filter( - (row) => row.status === "timeout" && !autoPromotedChildIds.has(String(row.childThreadId)), - ).length - : effectiveSlice.timedOutCount; - const reportedPending = autoPromote - ? pendingForMode(results, input.mode ?? "all") - : effectiveSlice.pending; - - if (waitDeliveredRows.length > 0) { - const markableRows = yield* markableWaitDeliveredRows( - invocation, - coordinator, - waitDeliveredRows, - ); - if (markableRows.length > 0) { - yield* coordinator.markWaitDelivered(markableRows); - } - const markableChildIds = new Set(markableRows.map((row) => String(row.childThreadId))); - const abandonedChildIds = waitDeliveredRows - .filter((row) => !markableChildIds.has(String(row.childThreadId))) - .map((row) => row.childThreadId); - if (abandonedChildIds.length > 0) { - yield* coordinator.abandonWaitDelivery(abandonedChildIds); - } - terminalWaitChildIds.clear(); - } - - return { - results, - settledCount: effectiveSlice.settledCount, - timedOutCount: reportedTimedOutCount, - // Auto-promoted rows have a wake path and are reported as running/notified; - // projection-only peer rows keep their polling status. - pending: reportedPending, - resumeToken: `${waitStartMs}:${effectiveSlice.resumeToken}`, - ...(autoPromote ? { promoted: true } : {}), - }; - }).pipe(Effect.onExit((exit) => (Exit.isSuccess(exit) ? Effect.void : abandonTerminalWaits()))); -}); - -const listSubagents = Effect.fn("SubagentToolkit.list")(function* (input: ListSubagentsInput) { +const listSubagents = Effect.fn("SubagentToolkit.list")(function* (input: { + readonly parentThreadId?: ThreadId; +}) { const invocation = yield* requireSubagentCapability("subagent:list"); const runtime = yield* requireRuntime(); const coordinator = yield* requireCoordinator(); @@ -2161,6 +1373,22 @@ const listSubagents = Effect.fn("SubagentToolkit.list")(function* (input: ListSu return { parentThreadId, children: [...children, ...projectionPeerChildren, ...remoteChildren] }; }); +const subagents = Effect.fn("SubagentToolkit.subagents")(function* (input: SubagentsInput) { + return input.childThreadId === undefined + ? yield* listSubagents({}) + : yield* checkSubagent({ childThreadId: input.childThreadId }); +}); + +const legacyPeerCheckSubagent = Effect.fn("SubagentToolkit.legacyPeerCheck")(function* ( + input: LegacyCheckSubagentInputType, +) { + const invocation = yield* McpInvocationContext.McpInvocationContext; + if (McpInvocationContext.isProviderInvocationScope(invocation)) { + return yield* fail("t3_check_subagent is available only to authenticated peer backends."); + } + return yield* checkSubagent(input); +}); + const validateCron = ( cronExpr: string, timezone: string, @@ -2398,9 +1626,7 @@ const scheduleDelete = Effect.fn("SubagentToolkit.scheduleDelete")(function* ( const handlers = { t3_spawn_subagent: spawnSubagent, t3_steer_subagent: steerSubagent, - t3_check_subagent: checkSubagent, - t3_wait_subagent: waitSubagent, - t3_list_subagents: listSubagents, + t3_subagents: subagents, t3_schedule_create: scheduleCreate, t3_schedule_list: scheduleList, t3_schedule_update: scheduleUpdate, @@ -2409,6 +1635,61 @@ const handlers = { export const SubagentToolkitHandlersLive = SubagentToolkit.toLayer(handlers); +const compatibilityFailure = (cause: Cause.Cause) => + new McpSchema.CallToolResult({ + isError: true, + content: [{ type: "text", text: Cause.pretty(cause) }], + }); + +const compatibilitySuccess = (encodedResult: unknown) => + new McpSchema.CallToolResult({ + isError: false, + structuredContent: + typeof encodedResult === "object" && encodedResult !== null ? encodedResult : undefined, + content: [{ type: "text", text: JSON.stringify(encodedResult) }], + }); + +const peerCompatibilityInstalled = new WeakSet(); + +/** Install mixed-version peer calls without registering legacy public tools. */ +export const installPeerSubagentCompatibility = Effect.gen(function* () { + const server = yield* McpServer.McpServer; + if (peerCompatibilityInstalled.has(server)) return; + peerCompatibilityInstalled.add(server); + const callPublicTool = server.callTool; + + const callTool = ((request: Parameters[0]) => + Effect.gen(function* () { + const invocation = yield* McpInvocationContext.McpInvocationContext; + if (McpInvocationContext.isProviderInvocationScope(invocation)) { + return yield* callPublicTool(request); + } + if (request.name === "t3_check_subagent") { + return yield* decodeLegacyCheckSubagentInput(request.arguments).pipe( + Effect.flatMap(legacyPeerCheckSubagent), + Effect.flatMap(encodeSubagentDetailOutput), + Effect.matchCause({ + onFailure: compatibilityFailure, + onSuccess: compatibilitySuccess, + }), + ); + } + if (request.name === "t3_spawn_subagent") { + return yield* decodeLegacySpawnSubagentInput(request.arguments).pipe( + Effect.flatMap(spawnSubagent), + Effect.flatMap(encodeSpawnSubagentOutput), + Effect.matchCause({ + onFailure: compatibilityFailure, + onSuccess: compatibilitySuccess, + }), + ); + } + return yield* callPublicTool(request); + })) as typeof callPublicTool; + + Object.assign(server, { callTool }); +}); + const makeSubagentRuntime = Effect.fn("SubagentToolkit.makeActiveRuntime")(function* () { const crypto = yield* Crypto.Crypto; const orchestrationEngine = yield* OrchestrationEngineService; diff --git a/apps/server/src/mcp/toolkits/subagent/tools.ts b/apps/server/src/mcp/toolkits/subagent/tools.ts index 38888073ef51..f6f8680bddb2 100644 --- a/apps/server/src/mcp/toolkits/subagent/tools.ts +++ b/apps/server/src/mcp/toolkits/subagent/tools.ts @@ -13,32 +13,27 @@ import { ScheduledTaskId, } from "../../../persistence/Services/ScheduledTasks.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; -import { ThreadStartToolError, ThreadStartToolInput, ThreadStartMode } from "../thread/tools.ts"; +import { + ThreadStartInternalInput, + ThreadStartPublicInput, + ThreadStartToolError, + ThreadStartMode, +} from "../thread/tools.ts"; const dependencies = [McpInvocationContext.McpInvocationContext]; -// Logical wait budget bounds (seconds) surfaced to `t3_wait_subagent`. The -// server clamps the requested timeout into this range; the per-invocation HTTP -// hold itself is bounded separately by WAIT_SLICE_SECONDS in the coordinator. -export const WAIT_TIMEOUT_DEFAULT_SECONDS = 600; -export const WAIT_TIMEOUT_MIN_SECONDS = 1; -export const WAIT_TIMEOUT_MAX_SECONDS = 3_900; - -// Cumulative blocking budget cap for a single `t3_wait_subagent` (across -// resumeToken re-calls), regardless of the caller's timeoutSeconds (R-A). Once -// this elapses with children still running, the wait auto-promotes them to -// wake-on-completion and returns so the model stops polling. -export const WAIT_AUTO_PROMOTE_SECONDS = 90; +export const SpawnSubagentInput = ThreadStartPublicInput; +export type SpawnSubagentInput = typeof SpawnSubagentInput.Type; -export const SpawnSubagentInput = Schema.Struct({ - ...ThreadStartToolInput.fields, +export const SpawnSubagentInternalInput = Schema.Struct({ + ...ThreadStartInternalInput.fields, target: Schema.optional(TrimmedNonEmptyString), remoteParentThreadId: Schema.optional(ThreadId), remoteParentEnvironmentId: Schema.optional(EnvironmentId), detached: Schema.optional(Schema.Boolean), waitTimeoutSeconds: Schema.optional(Schema.Int), }); -export type SpawnSubagentInput = typeof SpawnSubagentInput.Type; +export type SpawnSubagentInternalInput = typeof SpawnSubagentInternalInput.Type; export const SpawnSubagentOutput = Schema.Struct({ childThreadId: ThreadId, @@ -48,11 +43,6 @@ export const SpawnSubagentOutput = Schema.Struct({ worktreePath: Schema.NullOr(Schema.String), parentThreadId: ThreadId, warning: Schema.optional(Schema.String), - // Present only for a foreground (detached=false) spawn. If the initial - // bounded wait slice does not finish, status is "running" and callers should - // poll with t3_wait_subagent. - status: Schema.optional(Schema.String), - finalAssistantText: Schema.optional(Schema.NullOr(Schema.String)), }); export type SpawnSubagentOutput = typeof SpawnSubagentOutput.Type; @@ -81,59 +71,21 @@ export const SteerSubagentOutput = Schema.Struct({ }); export type SteerSubagentOutput = typeof SteerSubagentOutput.Type; -export const CheckSubagentInput = Schema.Struct({ - childThreadId: ThreadId, -}); -export type CheckSubagentInput = typeof CheckSubagentInput.Type; - -export const CheckSubagentOutput = Schema.Struct({ +export const SubagentDetailOutput = Schema.Struct({ threadId: ThreadId, status: Schema.String, turnCount: Schema.Int, latestAssistantText: Schema.NullOr(Schema.String), }); -export type CheckSubagentOutput = typeof CheckSubagentOutput.Type; - -export const WaitSubagentMode = Schema.Literals(["all", "any"]); -export type WaitSubagentMode = typeof WaitSubagentMode.Type; - -export const WaitSubagentInput = Schema.Struct({ - childThreadIds: Schema.Array(ThreadId).check(Schema.isMinLength(1)), - timeoutSeconds: Schema.optional(Schema.Int), - mode: Schema.optional(WaitSubagentMode), - resumeToken: Schema.optional(Schema.String), -}); -export type WaitSubagentInput = typeof WaitSubagentInput.Type; +export type SubagentDetailOutput = typeof SubagentDetailOutput.Type; -export const WaitSubagentResult = Schema.Struct({ - childThreadId: ThreadId, - status: Schema.String, - turnCount: Schema.Int, - finalAssistantText: Schema.NullOr(Schema.String), - error: Schema.NullOr(Schema.String), - // Present on a still-running child once the wait auto-promoted (R-A): the - // child will now notify the parent on completion, so the model should stop - // waiting and do other work. - note: Schema.optional(Schema.String), -}); - -export const WaitSubagentOutput = Schema.Struct({ - results: Schema.Array(WaitSubagentResult), - settledCount: Schema.Int, - timedOutCount: Schema.Int, - pending: Schema.Boolean, - resumeToken: Schema.String, - // True when the ~90s auto-promote budget elapsed with one+ children still - // running (R-A): those children were promoted to wake-on-completion and the - // model should stop calling wait. - promoted: Schema.optional(Schema.Boolean), -}); -export type WaitSubagentOutput = typeof WaitSubagentOutput.Type; +export const LegacyCheckSubagentInput = Schema.Struct({ childThreadId: ThreadId }); +export type LegacyCheckSubagentInput = typeof LegacyCheckSubagentInput.Type; -export const ListSubagentsInput = Schema.Struct({ - parentThreadId: Schema.optional(ThreadId), +export const SubagentsInput = Schema.Struct({ + childThreadId: Schema.optional(ThreadId), }); -export type ListSubagentsInput = typeof ListSubagentsInput.Type; +export type SubagentsInput = typeof SubagentsInput.Type; export const ListSubagentEntry = Schema.Struct({ childThreadId: ThreadId, @@ -146,11 +98,15 @@ export const ListSubagentEntry = Schema.Struct({ turnCount: Schema.Int, }); -export const ListSubagentsOutput = Schema.Struct({ - parentThreadId: ThreadId, - children: Schema.Array(ListSubagentEntry), +export const SubagentsOutput = Schema.Struct({ + parentThreadId: Schema.optional(ThreadId), + children: Schema.optional(Schema.Array(ListSubagentEntry)), + threadId: Schema.optional(ThreadId), + status: Schema.optional(Schema.String), + turnCount: Schema.optional(Schema.Int), + latestAssistantText: Schema.optional(Schema.NullOr(Schema.String)), }); -export type ListSubagentsOutput = typeof ListSubagentsOutput.Type; +export type SubagentsOutput = typeof SubagentsOutput.Type; export const ScheduleCreateInput = Schema.Struct({ threadId: Schema.optional(ThreadId), @@ -209,7 +165,7 @@ export type ScheduleDeleteOutput = typeof ScheduleDeleteOutput.Type; export const SpawnSubagentTool = Tool.make("t3_spawn_subagent", { description: - "Delegate a unit of work to an autonomous sub-agent thread. Use this freely to fan out background or parallel work — research, refactors, exploring an approach — without blocking yourself. Defaults to detached=true: the sub-agent runs independently and wakes you with its result when it finishes, so prefer spawning detached and continuing your own work. Set detached=false only when you must have the result before proceeding; foreground spawn waits for one bounded initial status slice, returns terminal output if already done, otherwise returns status=\"running\" plus launch metadata and promotes the child to wake you on completion. Use t3_wait_subagent to wait for a still-running foreground child, or stop polling and let the parent wake automatically; never expect spawn itself to block until long work completes. Defaults to a new Git worktree off the repository default branch when the project directory is a Git repository; non-Git projects start in the current directory with warning metadata. Pass `directory` (absolute path) to base the sub-agent somewhere else entirely: a Git directory gets a new worktree off that repository, a non-Git directory runs in place — use this when the calling thread's project is not the repository the work belongs in. Pass `target` as a registered peer alias or environment id to spawn on another backend; remote spawns require `directory` because target filesystems are independent. To pick the sub-agent's model, pass `model` as a plain model name (e.g. 'claude-opus-4-8' or 'gpt-5.4'); the provider/harness is inferred automatically, so you never guess a harness/instance id. This is the delegation primitive — for human-requested thread creation use t3_thread_start instead.", + "Delegate a unit of work to an autonomous sub-agent thread. The model and title are required. The child inherits the parent runtime and interaction modes, runs independently, and wakes the parent with its result on completion. Defaults to a new Git worktree from project configuration. Pass `directory` to target another local project and `branch` to name the new worktree branch. `reasoningEffort` defaults to `xhigh` for Codex models that advertise reasoning effort and overrides that default when supplied. For human-requested thread creation use t3_thread_start instead.", parameters: SpawnSubagentInput, success: SpawnSubagentOutput, failure: ThreadStartToolError, @@ -232,39 +188,15 @@ export const SteerSubagentTool = Tool.make("t3_steer_subagent", { .annotate(Tool.Destructive, true) .annotate(Tool.Idempotent, false); -export const CheckSubagentTool = Tool.make("t3_check_subagent", { - description: - "Read the current status of a sub-agent without waiting. Returns its status, turn count, and latest assistant text. Use this for a quick non-blocking poll; use t3_wait_subagent when you actually need to block until it finishes.", - parameters: CheckSubagentInput, - success: CheckSubagentOutput, - failure: ThreadStartToolError, - dependencies, -}) - .annotate(Tool.Title, "Check T3 Code sub-agent") - .annotate(Tool.Readonly, true) - .annotate(Tool.Idempotent, true); - -export const WaitSubagentTool = Tool.make("t3_wait_subagent", { - description: - 'Wait for one or more sub-agents to finish. This returns quickly with one result row per requested child; a child that has not finished yet has status "pending". While pending is true and you still want to wait, re-call this tool with the returned resumeToken (and the same childThreadIds) to keep waiting — never assume a single call blocks until completion. This waits at most ~90 seconds in total (across resumeToken re-calls); once that elapses with children still running, it returns promoted=true and those children have status "running" — STOP calling wait and go do other work, you will receive a new message automatically when each one finishes. mode "all" (default) waits for every child; "any" returns as soon as one settles. timeoutSeconds (default 600, clamped to [1,3900]) is the requested logical budget; children still unfinished once it is exhausted are returned with status "timeout".', - parameters: WaitSubagentInput, - success: WaitSubagentOutput, - failure: ThreadStartToolError, - dependencies, -}) - .annotate(Tool.Title, "Wait for T3 Code sub-agents") - .annotate(Tool.Readonly, true) - .annotate(Tool.Idempotent, false); - -export const ListSubagentsTool = Tool.make("t3_list_subagents", { +export const SubagentsTool = Tool.make("t3_subagents", { description: - "List the sub-agents spawned by a parent thread (defaults to the calling thread), merging in-memory registration metadata (spawn time, detached, depth) with each child's current status and turn count.", - parameters: ListSubagentsInput, - success: ListSubagentsOutput, + "List sub-agents spawned by the calling thread with their current statuses. Pass `childThreadId` to inspect one owned child in detail, including its latest assistant text. Omitting `childThreadId` defaults to listing all children of the calling thread.", + parameters: SubagentsInput, + success: SubagentsOutput, failure: ThreadStartToolError, dependencies, }) - .annotate(Tool.Title, "List T3 Code sub-agents") + .annotate(Tool.Title, "Inspect T3 Code sub-agents") .annotate(Tool.Readonly, true) .annotate(Tool.Idempotent, true); @@ -318,9 +250,7 @@ export const ScheduleDeleteTool = Tool.make("t3_schedule_delete", { export const SubagentToolkit = Toolkit.make( SpawnSubagentTool, SteerSubagentTool, - CheckSubagentTool, - WaitSubagentTool, - ListSubagentsTool, + SubagentsTool, ScheduleCreateTool, ScheduleListTool, ScheduleUpdateTool, diff --git a/apps/server/src/mcp/toolkits/thread/handlers.test.ts b/apps/server/src/mcp/toolkits/thread/handlers.test.ts index 8e1d15df6e4b..1575fedcc41f 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.test.ts @@ -32,7 +32,8 @@ import { ProviderInstanceRegistry } from "../../../provider/Services/ProviderIns import type { ProviderInstance } from "../../../provider/ProviderDriver.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import { ThreadToolkitRegistrationLive } from "../../McpHttpServer.ts"; -import { ThreadStartRuntimeLive } from "./handlers.ts"; +import { activeThreadStartRuntimeOf, ThreadStartRuntimeLive } from "./handlers.ts"; +import type { ThreadStartInternalInput } from "./tools.ts"; const projectId = ProjectId.make("project-thread-mcp"); const sourceThreadId = ThreadId.make("source-thread-mcp"); @@ -97,7 +98,6 @@ const client = McpSchema.McpServerClient.of({ }, getClient: Effect.die("unused"), }); - const makeTempDirectory = (prefix: string) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -335,15 +335,76 @@ const callStartTool = ( options: TestLayerOptions = {}, ) => Effect.gen(function* () { - const server = yield* McpServer.McpServer; - return yield* server - .callTool({ name: "t3_thread_start", arguments: arguments_ }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); + const runtime = activeThreadStartRuntimeOf(); + if (runtime === null) return yield* Effect.die("Thread start runtime is unavailable in test."); + return yield* runtime(arguments_ as ThreadStartInternalInput, invocation).pipe( + Effect.map((output) => ({ + isError: false as const, + structuredContent: output, + content: [{ type: "text" as const, text: JSON.stringify(output) }], + })), + Effect.catch((error) => + Effect.succeed({ + isError: true as const, + structuredContent: undefined, + content: [{ type: "text" as const, text: error.message }], + }), + ), + ); }).pipe(Effect.provide(makeTestLayer(commands, options))); +it.effect("starts through the slim MCP surface with xhigh Codex effort by default", () => + Effect.gen(function* () { + const commands: OrchestrationCommand[] = []; + const result = yield* Effect.gen(function* () { + const server = yield* McpServer.McpServer; + return yield* server + .callTool({ + name: "t3_thread_start", + arguments: { + prompt: "Investigate flaky tests", + model: "gpt-5.4", + title: "Flaky test investigation", + }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + }).pipe( + Effect.provide( + makeTestLayer(commands, { + providerInstances: [ + makeModelInstance("codex", "codex", [ + { slug: "gpt-5.4", optionId: "reasoningEffort", value: "low" }, + ]), + ], + }), + ), + ); + + expect(result.isError).toBe(false); + expect(commands[0]).toMatchObject({ + type: "thread.turn.start", + modelSelection: { + instanceId: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + bootstrap: { + createThread: { + title: "Flaky test investigation", + modelSelection: { + instanceId: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + }, + }, + }); + }), +); + it.effect("starts a new worktree thread by default and inherits source settings", () => Effect.gen(function* () { const commands: OrchestrationCommand[] = []; diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index 1c1f413fa265..0e6bcd0dbaab 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -36,11 +36,13 @@ import { GitWorkflowService } from "../../../git/GitWorkflowService.ts"; import * as VcsDriverRegistry from "../../../vcs/VcsDriverRegistry.ts"; import { ThreadStartToolError, + type ThreadStartInternalInput, type ThreadStartMode, - type ThreadStartToolInput, + type ThreadStartPublicInput, type ThreadStartToolOutput, ThreadToolkit, } from "./tools.ts"; +import { applyMcpReasoningEffort } from "./reasoningEffort.ts"; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const isThreadStartToolError = Schema.is(ThreadStartToolError); @@ -95,7 +97,7 @@ interface SourceCwdProjectContext { } export type ActiveThreadStartRuntime = ( - input: ThreadStartToolInput, + input: ThreadStartInternalInput, invocation: McpInvocationContext.McpInvocationScope, ) => Effect.Effect; @@ -377,7 +379,7 @@ const makeActiveThreadStartRuntime = Effect.fn("ThreadToolkit.makeActiveRuntime" const resolveNewWorktreeBaseBranch = Effect.fn("ThreadToolkit.resolveNewWorktreeBaseBranch")( function* ( - input: ThreadStartToolInput, + input: ThreadStartInternalInput, project: OrchestrationProjectShell, sourceThread: OrchestrationThreadShell, sourceCwd: string, @@ -417,7 +419,7 @@ const makeActiveThreadStartRuntime = Effect.fn("ThreadToolkit.makeActiveRuntime" const resolveInitialBranch = Effect.fn("ThreadToolkit.resolveInitialBranch")(function* ( mode: ThreadStartMode, - input: ThreadStartToolInput, + input: ThreadStartInternalInput, sourceThread: OrchestrationThreadShell, sourceCwd: string, canUseSourceBranch: boolean, @@ -465,7 +467,7 @@ const makeActiveThreadStartRuntime = Effect.fn("ThreadToolkit.makeActiveRuntime" }); const loadPeerSourceContext = Effect.fn("ThreadToolkit.loadPeerSourceContext")(function* ( - input: ThreadStartToolInput, + input: ThreadStartInternalInput, ) { if (input.directory === undefined) { return yield* fail( @@ -564,7 +566,7 @@ const makeActiveThreadStartRuntime = Effect.fn("ThreadToolkit.makeActiveRuntime" }); return Effect.fn("ThreadToolkit.startThread")(function* ( - input: ThreadStartToolInput, + input: ThreadStartInternalInput, invocation: McpInvocationContext.McpInvocationScope, ) { const { sourceThread, project } = McpInvocationContext.isProviderInvocationScope(invocation) @@ -812,7 +814,7 @@ export const ThreadStartRuntimeLive = Layer.effectDiscard( ); const resolveModelSelection = ( - input: ThreadStartToolInput, + input: ThreadStartInternalInput, sourceThread: OrchestrationThreadShell, modelSources: ReadonlyArray, ): Effect.Effect => { @@ -832,13 +834,18 @@ const resolveModelSelection = ( ), ); } - return Effect.succeed(resolved); + const effort = applyMcpReasoningEffort(resolved, modelSources, input.reasoningEffort); + return effort.error === undefined + ? Effect.succeed(effort.selection) + : Effect.fail(fail(effort.error)); } // Otherwise an explicit modelSelection wins, else inherit the source thread. return Effect.succeed(input.modelSelection ?? sourceThread.modelSelection); }; -const startThread = Effect.fn("ThreadToolkit.startThread")(function* (input: ThreadStartToolInput) { +const startThread = Effect.fn("ThreadToolkit.startThread")(function* ( + input: ThreadStartPublicInput, +) { const invocation = yield* McpInvocationContext.requireProviderMcpCapability( "thread-management", ).pipe(Effect.mapError((error) => fail(error.message))); diff --git a/apps/server/src/mcp/toolkits/thread/reasoningEffort.ts b/apps/server/src/mcp/toolkits/thread/reasoningEffort.ts new file mode 100644 index 000000000000..b05e7572774b --- /dev/null +++ b/apps/server/src/mcp/toolkits/thread/reasoningEffort.ts @@ -0,0 +1,41 @@ +import { type ModelSelection } from "@t3tools/contracts"; +import { type ProviderModelSource } from "@t3tools/shared/model"; + +export const DEFAULT_MCP_REASONING_EFFORT = "xhigh"; + +export type ReasoningEffortResolution = + | { readonly selection: ModelSelection; readonly error?: never } + | { readonly selection?: never; readonly error: string }; + +/** Apply the slim MCP surface's Codex effort default after model routing. */ +export const applyMcpReasoningEffort = ( + selection: ModelSelection, + sources: ReadonlyArray, + requestedEffort: string | undefined, +): ReasoningEffortResolution => { + const source = sources.find((candidate) => candidate.instanceId === selection.instanceId); + const model = source?.models.find((candidate) => candidate.slug === selection.model); + const descriptor = model?.optionDescriptors?.find( + (candidate) => candidate.id === "reasoningEffort", + ); + + if (source?.driverKind !== "codex" || descriptor?.type !== "select") { + return requestedEffort === undefined + ? { selection } + : { + error: `Model "${selection.model}" does not advertise a reasoningEffort option; received "${requestedEffort}".`, + }; + } + + const effort = requestedEffort ?? DEFAULT_MCP_REASONING_EFFORT; + const validEfforts = descriptor.options.map((option) => option.id); + if (!validEfforts.includes(effort)) { + return { + error: `Invalid reasoningEffort "${effort}" for model "${selection.model}". Valid values: ${validEfforts.join(", ")}.`, + }; + } + + const options = (selection.options ?? []).filter((option) => option.id !== "reasoningEffort"); + options.push({ id: "reasoningEffort", value: effort }); + return { selection: { ...selection, options } }; +}; diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index dc0662e832a7..85d987a828df 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -21,7 +21,22 @@ export type ThreadStartMode = typeof ThreadStartMode.Type; const ThreadStartBaseBranchSource = Schema.Literals(["default", "source"]); -export const ThreadStartToolInput = Schema.Struct({ +export const ThreadStartPublicInput = Schema.Struct({ + prompt: TrimmedNonEmptyString.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)), + model: TrimmedNonEmptyString, + title: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), + directory: Schema.optional(TrimmedNonEmptyString), + branch: Schema.optional(TrimmedNonEmptyString), + reasoningEffort: Schema.optional(TrimmedNonEmptyString), +}); +export type ThreadStartPublicInput = typeof ThreadStartPublicInput.Type; + +/** + * Full server-side thread-start request. The MCP tool deliberately exposes a + * much smaller input; orchestration callers still use these controls after the + * public request has been normalized. + */ +export const ThreadStartInternalInput = Schema.Struct({ prompt: TrimmedNonEmptyString.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)), title: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(255))), mode: Schema.optional(ThreadStartMode), @@ -38,8 +53,14 @@ export const ThreadStartToolInput = Schema.Struct({ modelSelection: Schema.optional(ModelSelection), runtimeMode: Schema.optional(RuntimeMode), interactionMode: Schema.optional(ProviderInteractionMode), + reasoningEffort: Schema.optional(TrimmedNonEmptyString), }); -export type ThreadStartToolInput = typeof ThreadStartToolInput.Type; +export type ThreadStartInternalInput = typeof ThreadStartInternalInput.Type; + +// Backward-compatible internal name used by orchestration callers. The MCP +// tool itself is wired to ThreadStartPublicInput below. +export const ThreadStartToolInput = ThreadStartInternalInput; +export type ThreadStartToolInput = ThreadStartInternalInput; export const ThreadStartToolOutput = Schema.Struct({ threadId: ThreadId, @@ -62,8 +83,8 @@ const dependencies = [McpInvocationContext.McpInvocationContext]; export const ThreadStartTool = Tool.make("t3_thread_start", { description: - "Start a new T3 Code thread with the supplied initial prompt, only when the user explicitly asks to start/spawn/create another thread or agent. Do not use for autonomous delegation or background parallel work. Defaults to creating a new Git worktree from the repository default branch when the project directory is a Git repository; non-Git projects start in the current directory with warning metadata. Pass `directory` (absolute path) to base the thread somewhere else entirely: a Git directory gets a new worktree off that repository, a non-Git directory runs in place. Use current_checkout only when the user explicitly asks for the same checkout. To choose the model, pass `model` as a plain model name (e.g. 'claude-opus-4-8' or 'gpt-5.4') — the provider/harness is inferred automatically, so you never need to know or pass a harness/instance id. This tool launches the child turn and returns metadata without waiting for completion.", - parameters: ThreadStartToolInput, + "Start a new T3 Code thread with the supplied initial prompt, only when the user explicitly asks to start/spawn/create another thread or agent. Do not use for autonomous delegation or background parallel work. The model and title are required. Defaults to creating a new Git worktree from the repository default branch when the project directory is a Git repository; non-Git projects start in the current directory with warning metadata. Pass `directory` (absolute path) to base the thread somewhere else entirely. `reasoningEffort` defaults to `xhigh` for Codex models that advertise reasoning effort and overrides that default when supplied. This tool launches the child turn and returns metadata without waiting for completion.", + parameters: ThreadStartPublicInput, success: ThreadStartToolOutput, failure: ThreadStartToolError, dependencies, diff --git a/apps/server/src/orchestration/Layers/ChildThreadCoordinator.ts b/apps/server/src/orchestration/Layers/ChildThreadCoordinator.ts index 2ca599de6c13..ab05f3d9c1ce 100644 --- a/apps/server/src/orchestration/Layers/ChildThreadCoordinator.ts +++ b/apps/server/src/orchestration/Layers/ChildThreadCoordinator.ts @@ -994,7 +994,7 @@ const make = Effect.gen(function* () { // Guard against unbounded growth when many children settle with large // payloads; the full per-child results remain queryable via t3_check. if (joined.length > CONSOLIDATED_INJECTION_MAX_CHARS) { - return `${joined.slice(0, CONSOLIDATED_INJECTION_MAX_CHARS)}\n[...${entries.length} sub-agent results truncated; use t3_check_subagent for full output]`; + return `${joined.slice(0, CONSOLIDATED_INJECTION_MAX_CHARS)}\n[...${entries.length} sub-agent results truncated; use t3_subagents with childThreadId for full output]`; } return joined; }; diff --git a/apps/server/src/orchestration/Services/ChildThreadCoordinator.ts b/apps/server/src/orchestration/Services/ChildThreadCoordinator.ts index 88eaf945a545..8f32c586cf69 100644 --- a/apps/server/src/orchestration/Services/ChildThreadCoordinator.ts +++ b/apps/server/src/orchestration/Services/ChildThreadCoordinator.ts @@ -3,8 +3,8 @@ * * Tracks parent/child thread relationships in memory, settles a per-child * terminal `Deferred` exactly once from death/completion domain events, and - * exposes a BOUNDED `waitSlice` so the `t3_wait_subagent` MCP tool never holds - * a single long HTTP call (see finalPlan §5/C6). Detached children wake their + * retains a BOUNDED `waitSlice` for internal coordinator callers and tests. + * Detached children wake their * parent (idle -> dispatch a turn, mid-turn -> enqueue) under a per-parent * lock; pending injections drain when the parent next completes a turn. * @@ -18,9 +18,7 @@ import type * as Scope from "effect/Scope"; import type { ThreadStartToolError } from "../../mcp/toolkits/thread/tools.ts"; /** - * Bound on a single `waitSlice`: each MCP `t3_wait_subagent` invocation waits - * at most this long before returning `pending` so the agent re-calls. Keeps - * every HTTP call well within the cross-provider timeout tolerance (C6). + * Bound on a single internal `waitSlice` observation before returning pending. */ export const WAIT_SLICE_SECONDS = 20; @@ -53,7 +51,7 @@ export interface ChildWaitResult { readonly error: string | null; } -/** Per-child wait status surfaced to the MCP tool (adds the non-terminal states). */ +/** Per-child wait status surfaced to internal coordinator callers. */ export type WaitChildStatus = ChildTerminalStatus | "timeout" | "pending"; export interface WaitChildResult { diff --git a/apps/server/src/persistence/Services/PendingDispatches.ts b/apps/server/src/persistence/Services/PendingDispatches.ts index e5b65c890303..e7ae651bec9a 100644 --- a/apps/server/src/persistence/Services/PendingDispatches.ts +++ b/apps/server/src/persistence/Services/PendingDispatches.ts @@ -40,8 +40,8 @@ export const PendingDispatch = Schema.Struct({ */ commandId: Schema.NullOr(Schema.String), /** - * True when a foreground promoted child result has already been returned by - * t3_wait_subagent. The row remains durable until the parent turn completes; + * True when a foreground-promoted child result has already been delivered. + * The row remains durable until the parent turn completes; * then the coordinator prunes it instead of dispatching a duplicate wake. */ deliveredByWait: Schema.Boolean, @@ -103,8 +103,8 @@ export interface PendingDispatchRepositoryShape { ) => Effect.Effect; /** - * Durably mark rows whose child result was delivered through t3_wait_subagent - * before the parent turn committed. A no-op for an empty id list. + * Durably mark rows whose child result was delivered before the parent turn + * committed. A no-op for an empty id list. */ readonly markWaitDelivered: ( input: MarkPendingDispatchesWaitDeliveredInput, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 6682e3d3607a..25a521245c84 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -3924,526 +3924,6 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("exits normally after a promoted subagent wait when the Claude stream ends", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - const runtimeEvents: Array = []; - - const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => { - runtimeEvents.push(event); - }), - ).pipe(Effect.forkChild); - - const session = yield* adapter.startSession({ - threadId: THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - runtimeMode: "full-access", - }); - - const turn = yield* adapter.sendTurn({ - threadId: session.threadId, - input: "wait for the child", - attachments: [], - }); - - harness.query.emit({ - type: "stream_event", - session_id: "sdk-session-waiting-subagent", - uuid: "stream-wait-subagent-start", - parent_tool_use_id: null, - event: { - type: "content_block_start", - index: 1, - content_block: { - type: "mcp_tool_use", - id: "tool-wait-subagent-1", - name: "t3_wait_subagent", - input: { - childThreadIds: ["child-thread-1"], - }, - }, - }, - } as unknown as SDKMessage); - - harness.query.emit({ - type: "stream_event", - session_id: "sdk-session-waiting-subagent", - uuid: "stream-wait-subagent-stop", - parent_tool_use_id: null, - event: { - type: "content_block_stop", - index: 1, - }, - } as unknown as SDKMessage); - - harness.query.emit({ - type: "user", - session_id: "sdk-session-waiting-subagent", - uuid: "user-wait-subagent-result", - parent_tool_use_id: null, - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-wait-subagent-1", - content: { - results: [ - { - childThreadId: "child-thread-1", - status: "running", - turnCount: 1, - finalAssistantText: null, - error: null, - note: "still running - you will be NOTIFIED when it completes", - }, - ], - settledCount: 0, - timedOutCount: 0, - pending: false, - resumeToken: "0:token", - promoted: true, - }, - }, - ], - }, - } as unknown as SDKMessage); - - harness.query.finish(); - - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; - runtimeEventsFiber.interruptUnsafe(); - - assert.equal( - runtimeEvents.some((event) => event.type === "session.exited"), - true, - ); - - const turnCompleted = runtimeEvents.find((event) => event.type === "turn.completed"); - assert.equal(turnCompleted?.type, "turn.completed"); - if (turnCompleted?.type === "turn.completed") { - assert.equal(String(turnCompleted.turnId), String(turn.turnId)); - assert.equal(turnCompleted.payload.state, "interrupted"); - } - - const waitingEvent = runtimeEvents.find( - (event) => - event.type === "session.state.changed" && - event.payload.state === "waiting" && - event.payload.reason === "awaiting-subagent-wake", - ); - assert.equal(waitingEvent, undefined); - - assert.equal(yield* adapter.hasSession(THREAD_ID), false); - const sessions = yield* adapter.listSessions(); - assert.equal(sessions.length, 0); - assert.equal(harness.query.closeCalls, 1); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("exits compacting promoted waits without dropping result metadata", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - const runtimeEvents: Array = []; - - const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => { - runtimeEvents.push(event); - }), - ).pipe(Effect.forkChild); - - const session = yield* adapter.startSession({ - threadId: THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - runtimeMode: "full-access", - }); - - const turn = yield* adapter.sendTurn({ - threadId: session.threadId, - input: "wait for the child", - attachments: [], - }); - - harness.query.emit({ - type: "stream_event", - session_id: "sdk-session-waiting-subagent-compacting", - uuid: "stream-wait-subagent-compacting-start", - parent_tool_use_id: null, - event: { - type: "content_block_start", - index: 1, - content_block: { - type: "mcp_tool_use", - id: "tool-wait-subagent-compacting-1", - name: "t3_wait_subagent", - input: { - childThreadIds: ["child-thread-1"], - }, - }, - }, - } as unknown as SDKMessage); - - harness.query.emit({ - type: "user", - session_id: "sdk-session-waiting-subagent-compacting", - uuid: "user-wait-subagent-compacting-result", - parent_tool_use_id: null, - tool_use_result: { - content: - '{"results":[{"childThreadId":"child-thread-1","status":"running"}],"promoted":true}', - structuredContent: { - results: [{ childThreadId: "child-thread-1", status: "running" }], - promoted: true, - }, - }, - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-wait-subagent-compacting-1", - content: 'promoted=true; [{"childThreadId":"child-thread-1","status":"running"}]', - }, - ], - }, - } as unknown as SDKMessage); - - harness.query.emit({ - type: "system", - subtype: "status", - session_id: "sdk-session-waiting-subagent-compacting", - uuid: "status-compacting-before-park", - status: "compacting", - } as unknown as SDKMessage); - - harness.query.emit({ - type: "system", - subtype: "task_started", - session_id: "sdk-session-waiting-subagent-compacting", - uuid: "task-started-before-park", - task_id: "task-before-park", - tool_use_id: "tool-before-park", - description: "Run slow command", - task_type: "local_bash", - } as unknown as SDKMessage); - - harness.query.emit({ - type: "result", - subtype: "success", - is_error: false, - errors: [], - result: "PARENT_PARKED", - stop_reason: "end_turn", - total_cost_usd: 0.42, - usage: { - input_tokens: 4, - output_tokens: 2, - }, - modelUsage: { - "claude-opus-4-6": { - contextWindow: 200000, - maxOutputTokens: 64000, - }, - }, - session_id: "sdk-session-waiting-subagent-compacting", - uuid: "result-wait-subagent-compacting", - } as unknown as SDKMessage); - - harness.query.finish(); - - for (let i = 0; i < 5; i += 1) { - yield* Effect.yieldNow; - } - runtimeEventsFiber.interruptUnsafe(); - - assert.equal( - runtimeEvents.some((event) => event.type === "session.exited"), - true, - ); - const turnCompleted = runtimeEvents.find((event) => event.type === "turn.completed"); - assert.equal(turnCompleted?.type, "turn.completed"); - if (turnCompleted?.type === "turn.completed") { - assert.equal(String(turnCompleted.turnId), String(turn.turnId)); - assert.equal(turnCompleted.payload.state, "completed"); - assert.equal(turnCompleted.payload.stopReason, "end_turn"); - assert.deepEqual(turnCompleted.payload.usage, { - input_tokens: 4, - output_tokens: 2, - }); - assert.deepEqual(turnCompleted.payload.modelUsage, { - "claude-opus-4-6": { - contextWindow: 200000, - maxOutputTokens: 64000, - }, - }); - assert.equal(turnCompleted.payload.totalCostUsd, 0.42); - } - const waitingEvent = runtimeEvents.find( - (event) => - event.type === "session.state.changed" && - event.payload.state === "waiting" && - event.payload.reason === "awaiting-subagent-wake", - ); - assert.equal(waitingEvent, undefined); - - assert.equal(yield* adapter.hasSession(THREAD_ID), false); - const sessions = yield* adapter.listSessions(); - assert.equal(sessions.length, 0); - assert.equal(harness.query.closeCalls, 1); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("does not park promoted subagent waits on result completion", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - const runtimeEvents: Array = []; - - const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => { - runtimeEvents.push(event); - }), - ).pipe(Effect.forkChild); - - const session = yield* adapter.startSession({ - threadId: THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - runtimeMode: "full-access", - }); - - yield* adapter.sendTurn({ - threadId: session.threadId, - input: "wait for the child", - attachments: [], - }); - - harness.query.emit({ - type: "stream_event", - session_id: "sdk-session-waiting-subagent-text", - uuid: "stream-wait-subagent-text-start", - parent_tool_use_id: null, - event: { - type: "content_block_start", - index: 1, - content_block: { - type: "mcp_tool_use", - id: "tool-wait-subagent-text-1", - name: "t3_wait_subagent", - input: { - childThreadIds: ["child-thread-1"], - }, - }, - }, - } as unknown as SDKMessage); - - harness.query.emit({ - type: "stream_event", - session_id: "sdk-session-waiting-subagent-text", - uuid: "stream-wait-subagent-text-stop", - parent_tool_use_id: null, - event: { - type: "content_block_stop", - index: 1, - }, - } as unknown as SDKMessage); - - harness.query.emit({ - type: "user", - session_id: "sdk-session-waiting-subagent-text", - uuid: "user-wait-subagent-text-result", - parent_tool_use_id: null, - tool_use_result: { - content: - '{"results":[{"childThreadId":"child-thread-1","status":"running"}],"promoted":true}', - structuredContent: { - results: [{ childThreadId: "child-thread-1", status: "running" }], - promoted: true, - }, - }, - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-wait-subagent-text-1", - content: 'promoted=true; [{"childThreadId":"child-thread-1","status":"running"}]', - }, - ], - }, - } as unknown as SDKMessage); - - harness.query.emit({ - type: "result", - subtype: "success", - is_error: false, - errors: [], - result: "PARENT_PARKED", - stop_reason: "end_turn", - session_id: "sdk-session-waiting-subagent-text", - uuid: "result-wait-subagent-text", - } as unknown as SDKMessage); - - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; - runtimeEventsFiber.interruptUnsafe(); - - assert.equal( - runtimeEvents.some((event) => event.type === "session.exited"), - false, - ); - - const waitingEvent = runtimeEvents.find( - (event) => - event.type === "session.state.changed" && - event.payload.state === "waiting" && - event.payload.reason === "awaiting-subagent-wake", - ); - assert.equal(waitingEvent, undefined); - - assert.equal(yield* adapter.hasSession(THREAD_ID), true); - const sessions = yield* adapter.listSessions(); - assert.equal(sessions.length, 1); - assert.equal(harness.query.closeCalls, 0); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("does not park subagent waits when promoted only appears in unstructured text", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - const runtimeEvents: Array = []; - - const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => { - runtimeEvents.push(event); - }), - ).pipe(Effect.forkChild); - - const session = yield* adapter.startSession({ - threadId: THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - runtimeMode: "full-access", - }); - - yield* adapter.sendTurn({ - threadId: session.threadId, - input: "wait for the child", - attachments: [], - }); - - harness.query.emit({ - type: "stream_event", - session_id: "sdk-session-waiting-subagent-text-false-positive", - uuid: "stream-wait-subagent-text-false-positive-start", - parent_tool_use_id: null, - event: { - type: "content_block_start", - index: 1, - content_block: { - type: "mcp_tool_use", - id: "tool-wait-subagent-text-false-positive-1", - name: "t3_wait_subagent", - input: { - childThreadIds: ["child-thread-1"], - }, - }, - }, - } as unknown as SDKMessage); - - harness.query.emit({ - type: "stream_event", - session_id: "sdk-session-waiting-subagent-text-false-positive", - uuid: "stream-wait-subagent-text-false-positive-stop", - parent_tool_use_id: null, - event: { - type: "content_block_stop", - index: 1, - }, - } as unknown as SDKMessage); - - harness.query.emit({ - type: "user", - session_id: "sdk-session-waiting-subagent-text-false-positive", - uuid: "user-wait-subagent-false-positive-result", - parent_tool_use_id: null, - tool_use_result: { - content: - '{"results":[{"childThreadId":"child-thread-1","status":"completed","finalAssistantText":"child wrote promoted=true"}],"pending":false}', - structuredContent: { - results: [ - { - childThreadId: "child-thread-1", - status: "completed", - finalAssistantText: "child wrote promoted=true", - }, - ], - pending: false, - }, - }, - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-wait-subagent-text-false-positive-1", - content: "child wrote promoted=true in its final answer", - }, - ], - }, - } as unknown as SDKMessage); - - harness.query.emit({ - type: "result", - subtype: "success", - is_error: false, - errors: [], - result: "DONE", - stop_reason: "end_turn", - session_id: "sdk-session-waiting-subagent-text-false-positive", - uuid: "result-wait-subagent-false-positive", - } as unknown as SDKMessage); - harness.query.finish(); - - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; - runtimeEventsFiber.interruptUnsafe(); - - const waitingEvent = runtimeEvents.find( - (event) => - event.type === "session.state.changed" && - event.payload.state === "waiting" && - event.payload.reason === "awaiting-subagent-wake", - ); - assert.equal(waitingEvent, undefined); - assert.equal( - runtimeEvents.some((event) => event.type === "session.exited"), - true, - ); - - const sessions = yield* adapter.listSessions(); - assert.equal(sessions.length, 0); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - it.effect("keeps Claude stream failure events structural", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/e2e/README.md b/e2e/README.md index 057b0964c470..ccc240677934 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -3,9 +3,9 @@ Manual (non-CI) end-to-end verification that a real Claude agent running INSIDE a t3 thread drives the sub-agent + scheduler MCP tools (migrations 033/034, `ChildThreadCoordinator` + `ScheduledTasksReactor`, the -`t3_spawn_subagent` / `t3_steer_subagent` / `t3_check_subagent` / -`t3_wait_subagent` / `t3_list_subagent` / `t3_schedule_create|list|update|delete` -tools). Full scenario design lives in **`/tmp/t3-design/e2ePlan.md`** — this +`t3_spawn_subagent` / `t3_steer_subagent` / `t3_subagents` / +`t3_schedule_create|list|update|delete` tools). Full scenario design lives in +**`/tmp/t3-design/e2ePlan.md`** — this README maps that plan onto the assets in this directory. All assertions come from observable persisted state: the SQLite projection @@ -55,35 +55,34 @@ healthy. **Always kill any server you start when done.** ### (b) Cross-provider spawn (claude+codex+cursor) — `e2ePlan.md` §(b) -- After `t3_spawn_subagent` ×3 (one per provider, `detached:true`): +- After `t3_spawn_subagent` ×3 (one required model/title pair per provider): `childrenOf(db, root)` returns 3 rows with `parent_thread_id=root`; the `model` column verifies per-provider routing. - `threadShell(db, child).latestTurn.state` goes `running` → `completed`. -- Fan-out + single `t3_wait_subagent(mode:"all")`: assert each child settled and - has non-null `assistantMessages(db, child)`. +- `t3_subagents()` lists all three children and `t3_subagents({childThreadId})` + reports each child's latest text after it settles. - Detached WAKE / consolidation: `assistantMessages(db, root, "user")` contains the `[sub-agent completed]` injection(s) — one turn carrying both for the two-child consolidation case. -### (c) Long wait ~1 hour (opt-in `E2E_ENABLE_1H=1`) — `e2ePlan.md` §(c) +### (c) Long-running child wakes its parent (opt-in `E2E_ENABLE_1H=1`) — `e2ePlan.md` §(c) - Child prompt runs `fib-sleep.sh` (default `FIB_SCALE=60`, 54 min cumulative), keeping its turn alive script-driven (reliable, not model-driven). -- Driver loops `t3_wait_subagent(timeoutSeconds:3900)` across ~20s slices. +- The parent continues independently after spawn; no polling/wait tool is used. - Assert with `threadShell` that the child turn does not settle early; measured wall-clock spawn→settle in [50,62] min; cross-check - `turnTimestamps(db, child)` `completed_at - requested_at` ≈ duration. Each - intermediate slice returns `status:"pending"` + resumeToken, HTTP <30s. + `turnTimestamps(db, child)` `completed_at - requested_at` ≈ duration. After + completion, the parent receives exactly one coordinator wake injection. - For local iteration, set the child prompt to use `FIB_SCALE=1` (54s) to - exercise the slice/resumeToken loop without the 1h hold. + exercise the same wake-on-completion path without the 1h hold. -### (d) Killed child → wait returns failure, not hang — `e2ePlan.md` §(d) +### (d) Killed child → parent receives failure wake — `e2ePlan.md` §(d) -- Spawn a ~10 min child (`FIB_SCALE` tuned), start the wait loop. +- Spawn a ~10 min child (`FIB_SCALE` tuned), then let the parent continue. - Kill via (i) `thread.delete`, (ii) `kill -9` the provider process, - (iii) `session.stop`. Assert wait reports `killed`/`failed` within one slice; + (iii) `session.stop`. Assert the parent wake reports `killed`/`failed`; `threadShell(db, child).session.last_error` is populated and `latestTurn.state` is `failed`/`interrupted`. -- CONTROL: `t3_wait_subagent(timeoutSeconds:5)` on a live child → `timeout`. - ORPHAN: kill the parent after a detached spawn; assert a WARN is logged and no - crash (documented preview limitation). + crash (documented limitation).