Files
dotfiles/modules/agents/pi/extensions/subagents/runner.test.ts

119 lines
4.5 KiB
TypeScript

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 forwards structured activity before collecting the final result", async (t) => {
const running: unknown[] = [];
const completed: Array<{ result: string; stopReason?: 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;
};
t.mock.method(fakeChild.stdin, "write", (chunk, callback?: (error?: Error | null) => void) => {
const request = JSON.parse(String(chunk)) as { id: string; type: string };
callback?.();
if (request.type === "get_last_assistant_text") {
queueMicrotask(() => {
fakeChild.stdout.emit("data", `${JSON.stringify({ id: request.id, type: "response", success: true, data: { text: "final answer" } })}\n`);
});
}
return true;
});
t.mock.method(childProcess, "spawn", () => fakeChild as unknown as childProcess.ChildProcessWithoutNullStreams);
const { SubprocessRpcRunner } = await import("./runner.ts");
const runner = new SubprocessRpcRunner();
await runner.start("child-1", { prompt: "work", label: "Review migration" }, "/tmp", {
...events(),
running: (event) => running.push(event),
completed: (result, stopReason) => completed.push({ result, stopReason }),
});
const firstActivity = { type: "message_start", role: "assistant", message: { id: "msg-1" } };
const secondActivity = { type: "tool_execution_start", tool: "read", input: { path: "runner.ts" } };
const settledActivity = { type: "agent_settled" };
fakeChild.stdout.emit("data", `${JSON.stringify(firstActivity)}\n${JSON.stringify(secondActivity)}\n${JSON.stringify(settledActivity)}\n`);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(running, [firstActivity, secondActivity, settledActivity]);
assert.deepEqual(completed, [{ result: "final answer", stopReason: "agent_settled" }]);
});
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", label: "Review migration" }, "/tmp", events());
assert.equal(spawn.mock.callCount(), 1);
const args = calls[0].args;
const noExtensionsIndex = args.indexOf("--no-extensions");
const extensionIndex = args.indexOf("--extension");
const nameIndex = args.indexOf("--name");
assert.notEqual(noExtensionsIndex, -1, "child args keep automatic extension discovery disabled");
assert.notEqual(nameIndex, -1, "child args include a process name");
assert.equal(args[nameIndex + 1], "subagent Review migration");
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);
});