Skip to content

Commit 680b076

Browse files
authored
extensions: add agent_spawn tool for bounded dev-agent startup (#188)
1 parent 285e181 commit 680b076

6 files changed

Lines changed: 667 additions & 18 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"private": true,
55
"scripts": {
66
"test": "vitest run --config vitest.config.mjs",
7-
"test:js": "vitest run --config vitest.config.mjs pi/extensions/heartbeat.test.mjs pi/extensions/memory.test.mjs test/legacy-node-tests.test.mjs test/broker-bridge.integration.test.mjs test/integrity-status-check.test.mjs",
7+
"test:js": "vitest run --config vitest.config.mjs pi/extensions/heartbeat.test.mjs pi/extensions/agent-spawn.test.mjs pi/extensions/memory.test.mjs test/legacy-node-tests.test.mjs test/broker-bridge.integration.test.mjs test/integrity-status-check.test.mjs",
88
"test:shell": "vitest run --config vitest.config.mjs test/shell-scripts.test.mjs test/security-audit.test.mjs",
99
"test:coverage": "vitest run --config vitest.config.mjs --coverage pi/extensions/heartbeat.test.mjs pi/extensions/memory.test.mjs test/legacy-node-tests.test.mjs",
1010
"lint": "npm run lint:js && npm run lint:shell",

pi/extensions/agent-spawn.test.mjs

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, unlinkSync } from "node:fs";
3+
import net from "node:net";
4+
import { tmpdir } from "node:os";
5+
import path from "node:path";
6+
import agentSpawnExtension from "./agent-spawn.ts";
7+
8+
const CONTROL_DIR_ENV = "PI_SESSION_CONTROL_DIR";
9+
const ORIGINAL_CONTROL_DIR = process.env[CONTROL_DIR_ENV];
10+
11+
function randomId() {
12+
return Math.random().toString(16).slice(2, 10);
13+
}
14+
15+
function createExtensionHarness(execImpl) {
16+
let registeredTool = null;
17+
const pi = {
18+
registerTool(tool) {
19+
registeredTool = tool;
20+
},
21+
exec: execImpl,
22+
};
23+
agentSpawnExtension(pi);
24+
if (!registeredTool) throw new Error("agent_spawn tool was not registered");
25+
return registeredTool;
26+
}
27+
28+
function startUnixSocketServer(socketPath) {
29+
return new Promise((resolve, reject) => {
30+
const server = net.createServer((client) => {
31+
client.end();
32+
});
33+
34+
const onError = (err) => {
35+
server.close();
36+
reject(err);
37+
};
38+
39+
server.once("error", onError);
40+
server.listen(socketPath, () => {
41+
server.off("error", onError);
42+
resolve(server);
43+
});
44+
});
45+
}
46+
47+
describe("agent_spawn extension tool", () => {
48+
const tempDirs = [];
49+
const servers = [];
50+
const cleanupPaths = [];
51+
52+
afterEach(async () => {
53+
for (const server of servers) {
54+
await new Promise((resolve) => server.close(() => resolve(undefined)));
55+
}
56+
servers.length = 0;
57+
58+
for (const p of cleanupPaths) {
59+
try {
60+
if (existsSync(p)) unlinkSync(p);
61+
} catch {
62+
// Ignore cleanup failures.
63+
}
64+
}
65+
cleanupPaths.length = 0;
66+
67+
for (const dir of tempDirs) {
68+
rmSync(dir, { recursive: true, force: true });
69+
}
70+
tempDirs.length = 0;
71+
72+
if (ORIGINAL_CONTROL_DIR === undefined) {
73+
delete process.env[CONTROL_DIR_ENV];
74+
} else {
75+
process.env[CONTROL_DIR_ENV] = ORIGINAL_CONTROL_DIR;
76+
}
77+
});
78+
79+
it("spawns and reports ready when alias/socket becomes available", async () => {
80+
const root = mkdtempSync(path.join(tmpdir(), "agent-spawn-test-"));
81+
tempDirs.push(root);
82+
const worktree = path.join(root, "worktree");
83+
const skillPath = path.join(root, "dev-skill");
84+
const controlDir = path.join(root, "session-control");
85+
process.env[CONTROL_DIR_ENV] = controlDir;
86+
mkdirSync(worktree, { recursive: true });
87+
mkdirSync(skillPath, { recursive: true });
88+
mkdirSync(controlDir, { recursive: true });
89+
90+
const sessionName = `dev-agent-test-${randomId()}`;
91+
const aliasPath = path.join(controlDir, `${sessionName}.alias`);
92+
const socketPath = path.join(controlDir, `${sessionName}.sock`);
93+
cleanupPaths.push(aliasPath, socketPath);
94+
95+
const execSpy = vi.fn(async (command, args) => {
96+
expect(command).toBe("tmux");
97+
expect(args.slice(0, 4)).toEqual(["new-session", "-d", "-s", sessionName]);
98+
expect(args[4]).toContain(`export PI_SESSION_NAME='${sessionName}'`);
99+
expect(args[4]).toContain("--session-control");
100+
expect(args[4]).toContain(`--skill '${skillPath}'`);
101+
expect(args[4]).toContain("--model 'anthropic/claude-opus-4-6'");
102+
103+
const server = await startUnixSocketServer(socketPath);
104+
servers.push(server);
105+
symlinkSync(path.basename(socketPath), aliasPath);
106+
return { stdout: "", stderr: "", code: 0, killed: false };
107+
});
108+
109+
const tool = createExtensionHarness(execSpy);
110+
const result = await tool.execute(
111+
"tool-call-id",
112+
{
113+
session_name: sessionName,
114+
cwd: worktree,
115+
skill_path: skillPath,
116+
model: "anthropic/claude-opus-4-6",
117+
ready_timeout_sec: 5,
118+
},
119+
undefined,
120+
undefined,
121+
{},
122+
);
123+
124+
expect(result.isError).not.toBe(true);
125+
expect(result.details.spawned).toBe(true);
126+
expect(result.details.ready).toBe(true);
127+
expect(result.details.session_name).toBe(sessionName);
128+
expect(result.details.ready_alias).toBe(sessionName);
129+
expect(result.details.alias_path).toBe(aliasPath);
130+
expect(result.details.socket_path).toBe(socketPath);
131+
expect(execSpy).toHaveBeenCalledTimes(1);
132+
});
133+
134+
it("returns readiness timeout and does not issue cleanup commands", async () => {
135+
const root = mkdtempSync(path.join(tmpdir(), "agent-spawn-test-"));
136+
tempDirs.push(root);
137+
const worktree = path.join(root, "worktree");
138+
const skillPath = path.join(root, "dev-skill");
139+
const controlDir = path.join(root, "session-control");
140+
process.env[CONTROL_DIR_ENV] = controlDir;
141+
mkdirSync(worktree, { recursive: true });
142+
mkdirSync(skillPath, { recursive: true });
143+
mkdirSync(controlDir, { recursive: true });
144+
145+
const sessionName = `dev-agent-timeout-${randomId()}`;
146+
const calls = [];
147+
const execSpy = vi.fn(async (command, args) => {
148+
calls.push([command, args]);
149+
return { stdout: "", stderr: "", code: 0, killed: false };
150+
});
151+
152+
const tool = createExtensionHarness(execSpy);
153+
const result = await tool.execute(
154+
"tool-call-id",
155+
{
156+
session_name: sessionName,
157+
cwd: worktree,
158+
skill_path: skillPath,
159+
model: "anthropic/claude-opus-4-6",
160+
ready_timeout_sec: 1,
161+
},
162+
undefined,
163+
undefined,
164+
{},
165+
);
166+
167+
expect(result.isError).toBe(true);
168+
expect(result.details.spawned).toBe(true);
169+
expect(result.details.ready).toBe(false);
170+
expect(result.details.error).toBe("readiness_timeout");
171+
expect(calls).toHaveLength(1);
172+
expect(calls[0][0]).toBe("tmux");
173+
expect(String(result.content[0].text)).toContain("left intact");
174+
});
175+
176+
it("rejects invalid session_name before executing tmux", async () => {
177+
const root = mkdtempSync(path.join(tmpdir(), "agent-spawn-test-"));
178+
tempDirs.push(root);
179+
const worktree = path.join(root, "worktree");
180+
const skillPath = path.join(root, "dev-skill");
181+
const controlDir = path.join(root, "session-control");
182+
process.env[CONTROL_DIR_ENV] = controlDir;
183+
mkdirSync(worktree, { recursive: true });
184+
mkdirSync(skillPath, { recursive: true });
185+
mkdirSync(controlDir, { recursive: true });
186+
187+
const execSpy = vi.fn(async () => ({ stdout: "", stderr: "", code: 0, killed: false }));
188+
const tool = createExtensionHarness(execSpy);
189+
const result = await tool.execute(
190+
"tool-call-id",
191+
{
192+
session_name: "bad name",
193+
cwd: worktree,
194+
skill_path: skillPath,
195+
model: "anthropic/claude-opus-4-6",
196+
},
197+
undefined,
198+
undefined,
199+
{},
200+
);
201+
202+
expect(result.isError).toBe(true);
203+
expect(String(result.content[0].text)).toContain("Invalid session_name");
204+
expect(execSpy).not.toHaveBeenCalled();
205+
});
206+
207+
it("honors abort signal while waiting for readiness", async () => {
208+
const root = mkdtempSync(path.join(tmpdir(), "agent-spawn-test-"));
209+
tempDirs.push(root);
210+
const worktree = path.join(root, "worktree");
211+
const skillPath = path.join(root, "dev-skill");
212+
const controlDir = path.join(root, "session-control");
213+
process.env[CONTROL_DIR_ENV] = controlDir;
214+
mkdirSync(worktree, { recursive: true });
215+
mkdirSync(skillPath, { recursive: true });
216+
mkdirSync(controlDir, { recursive: true });
217+
218+
const sessionName = `dev-agent-abort-${randomId()}`;
219+
const execSpy = vi.fn(async () => ({ stdout: "", stderr: "", code: 0, killed: false }));
220+
const tool = createExtensionHarness(execSpy);
221+
222+
const controller = new AbortController();
223+
const abortTimer = setTimeout(() => controller.abort(), 25);
224+
const startedAt = Date.now();
225+
const result = await tool.execute(
226+
"tool-call-id",
227+
{
228+
session_name: sessionName,
229+
cwd: worktree,
230+
skill_path: skillPath,
231+
model: "anthropic/claude-opus-4-6",
232+
ready_timeout_sec: 60,
233+
},
234+
controller.signal,
235+
undefined,
236+
{},
237+
);
238+
clearTimeout(abortTimer);
239+
240+
expect(result.isError).toBe(true);
241+
expect(result.details.error).toBe("readiness_aborted");
242+
expect(result.details.aborted).toBe(true);
243+
expect(Date.now() - startedAt).toBeLessThan(1000);
244+
});
245+
});

0 commit comments

Comments
 (0)