diff --git a/modules/agents/pi/extensions/subagents/runner.test.ts b/modules/agents/pi/extensions/subagents/runner.test.ts new file mode 100644 index 0000000..cd5ca2f --- /dev/null +++ b/modules/agents/pi/extensions/subagents/runner.test.ts @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import childProcess from "node:child_process"; +import { EventEmitter } from "node:events"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import type { RunnerEvents } from "./types.ts"; + +class FakeStream extends EventEmitter { + setEncoding(_encoding: BufferEncoding): void {} + + write(_chunk: string, callback?: (error?: Error | null) => void): boolean { + callback?.(); + return true; + } + + end(): void {} +} + +function events(): RunnerEvents { + return { + accepted: () => {}, + running: () => {}, + settling: () => {}, + completed: () => {}, + failed: () => {}, + }; +} + +test("child RPC process disables discovery while explicitly loading subagents extension", async (t) => { + const calls: Array<{ command: string; args: string[] }> = []; + const fakeChild = new EventEmitter() as EventEmitter & { + stdout: FakeStream; + stderr: FakeStream; + stdin: FakeStream; + killed: boolean; + pid?: number; + kill(signal?: NodeJS.Signals): boolean; + }; + fakeChild.stdout = new FakeStream(); + fakeChild.stderr = new FakeStream(); + fakeChild.stdin = new FakeStream(); + fakeChild.killed = false; + fakeChild.kill = () => { + fakeChild.killed = true; + return true; + }; + const spawn = t.mock.method(childProcess, "spawn", (command, args) => { + calls.push({ command: String(command), args: Array.isArray(args) ? args.map(String) : [] }); + return fakeChild as unknown as childProcess.ChildProcessWithoutNullStreams; + }); + + const { SubprocessRpcRunner } = await import("./runner.ts"); + const runner = new SubprocessRpcRunner(); + await runner.start("child-1", { prompt: "work" }, "/tmp", events()); + + assert.equal(spawn.mock.callCount(), 1); + const args = calls[0].args; + const noExtensionsIndex = args.indexOf("--no-extensions"); + const extensionIndex = args.indexOf("--extension"); + + assert.notEqual(noExtensionsIndex, -1, "child args keep automatic extension discovery disabled"); + assert.notEqual(extensionIndex, -1, "child args explicitly load the subagents extension entry"); + assert.equal(args[extensionIndex + 1], fileURLToPath(new URL("./index.ts", import.meta.url))); + assert.ok(noExtensionsIndex < extensionIndex); +}); diff --git a/modules/agents/pi/extensions/subagents/runner.ts b/modules/agents/pi/extensions/subagents/runner.ts index 7231fa8..7804f7b 100644 --- a/modules/agents/pi/extensions/subagents/runner.ts +++ b/modules/agents/pi/extensions/subagents/runner.ts @@ -1,4 +1,5 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { fileURLToPath } from "node:url"; import type { ChildHandle, ChildRunner, RunnerEvents, SpawnRequest } from "./types.ts"; interface PendingResponse { @@ -156,7 +157,7 @@ class RpcChildHandle implements ChildHandle { export class SubprocessRpcRunner implements ChildRunner { async start(id: string, request: SpawnRequest, cwd: string, events: RunnerEvents): Promise { - const args = [process.argv[1], "--mode", "rpc", "--no-extensions", "--name", `subagent ${id}`, ...contextArgs(request), ...toolArgs(request), ...modelArgs(request)]; + const args = [process.argv[1], "--mode", "rpc", "--no-extensions", "--extension", subagentsExtensionPath(), "--name", `subagent ${id}`, ...contextArgs(request), ...toolArgs(request), ...modelArgs(request)]; const child = spawn(process.execPath, args, { cwd, env: childEnvironment(), @@ -174,6 +175,10 @@ function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +function subagentsExtensionPath(): string { + return fileURLToPath(new URL("./index.ts", import.meta.url)); +} + function contextArgs(request: SpawnRequest): string[] { if (request.context !== "fork" || !request.parentSessionFile) return []; return ["--fork", request.parentSessionFile];