Skip to content

Commit 1ada492

Browse files
committed
extensions: add agent_spawn tool and control-agent spawn runbook
1 parent a2dca5c commit 1ada492

6 files changed

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

0 commit comments

Comments
 (0)