@pi-archimedes/subagent 2.7.3 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/subagent",
3
- "version": "2.7.3",
3
+ "version": "2.8.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/danielcherubini/pi-archimedes.git"
@@ -15,8 +15,8 @@
15
15
  ],
16
16
  "main": "./src/index.ts",
17
17
  "dependencies": {
18
- "@pi-archimedes/core": "2.7.3",
19
- "@pi-archimedes/todo": "2.7.3"
18
+ "@pi-archimedes/core": "2.8.0",
19
+ "@pi-archimedes/todo": "2.8.0"
20
20
  },
21
21
  "peerDependencies": {
22
22
  "@earendil-works/pi-ai": ">=0.1.0",
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import * as net from "node:net";
3
+ import { getBus, Events } from "@pi-archimedes/core/bus";
4
+ import { startAskSocketServer } from "./spawn.js";
5
+
6
+ // startAskSocketServer is tested directly (not via spawnSubagent, which would
7
+ // spawn a real `pi` process). The real bus is used — subscriptions are
8
+ // unsubscribed in afterEach so they don't leak into other test files in the
9
+ // same worker (the bus is a globalThis singleton).
10
+
11
+ const cleanups: Array<() => void> = [];
12
+ const unsubs: Array<() => void> = [];
13
+
14
+ afterEach(() => {
15
+ unsubs.splice(0).forEach((u) => u());
16
+ cleanups.splice(0).forEach((c) => c());
17
+ });
18
+
19
+ function track(fn: () => void): void {
20
+ cleanups.push(fn);
21
+ }
22
+
23
+ function connectClient(socketPath: string): Promise<net.Socket> {
24
+ return new Promise((resolve, reject) => {
25
+ const client = net.connect(socketPath, () => resolve(client));
26
+ client.on("error", (err) => reject(err));
27
+ });
28
+ }
29
+
30
+ function writeAskRequest(client: net.Socket, requestId: string, toolCallId?: string): void {
31
+ client.write(
32
+ JSON.stringify({
33
+ type: "ask_request",
34
+ requestId,
35
+ toolCallId,
36
+ questions: [{ id: "q1", question: "Which?", options: [{ label: "A" }] }],
37
+ }) + "\n",
38
+ );
39
+ }
40
+
41
+ describe("startAskSocketServer", () => {
42
+ it("a child ask_request carrying toolCallId → the bus ASK_REQUEST carries the same toolCallId", async () => {
43
+ const { socketPath, cleanup } = startAskSocketServer("test-agent");
44
+ track(cleanup);
45
+ await new Promise((r) => setTimeout(r, 50)); // let the server listen
46
+
47
+ const requests: unknown[] = [];
48
+ unsubs.push(getBus().on(Events.ASK_REQUEST, (p) => requests.push(p)));
49
+
50
+ const client = await connectClient(socketPath);
51
+ writeAskRequest(client, "req-1", "tool-9");
52
+
53
+ await vi.waitFor(() => expect(requests.length).toBe(1));
54
+ const req = requests[0] as { source: string; requestId: string; toolCallId?: string; questions: unknown[] };
55
+ expect(req.source).toBe("subagent:test-agent");
56
+ expect(req.requestId).toBe("req-1");
57
+ expect(req.toolCallId).toBe("tool-9");
58
+ expect(req.questions).toEqual([{ id: "q1", question: "Which?", options: [{ label: "A" }] }]);
59
+
60
+ // Deliver the response (drains pending) before closing so no ASK_CANCEL
61
+ // leaks onto the global bus queue for the next test
62
+ getBus().emit(Events.ASK_RESPONSE, {
63
+ requestId: "req-1",
64
+ cancelled: false,
65
+ results: [{ id: "q1", selectedOptions: ["A"] }],
66
+ });
67
+ await new Promise((r) => setTimeout(r, 50));
68
+ client.destroy();
69
+ await new Promise((r) => setTimeout(r, 50)); // close handler ran (no-op)
70
+ });
71
+
72
+ it("closing the child socket → ASK_CANCEL for the pending requestId + source", async () => {
73
+ const { socketPath, cleanup } = startAskSocketServer("test-agent");
74
+ track(cleanup);
75
+ await new Promise((r) => setTimeout(r, 50)); // let the server listen
76
+
77
+ const cancels: unknown[] = [];
78
+ unsubs.push(getBus().on(Events.ASK_CANCEL, (p) => cancels.push(p)));
79
+ const requests: unknown[] = [];
80
+ unsubs.push(getBus().on(Events.ASK_REQUEST, (p) => requests.push(p)));
81
+
82
+ const client = await connectClient(socketPath);
83
+ writeAskRequest(client, "req-2");
84
+
85
+ // Wait until the request is registered (pending) before closing
86
+ await vi.waitFor(() => expect(requests.length).toBe(1));
87
+ client.destroy();
88
+
89
+ await vi.waitFor(() => expect(cancels.length).toBe(1));
90
+ expect(cancels[0]).toEqual({ requestId: "req-2", source: "subagent:test-agent" });
91
+ });
92
+
93
+ it("no ASK_CANCEL once the response has already been delivered (pending drained)", async () => {
94
+ const { socketPath, cleanup } = startAskSocketServer("test-agent");
95
+ track(cleanup);
96
+ await new Promise((r) => setTimeout(r, 50)); // let the server listen
97
+
98
+ const cancels: unknown[] = [];
99
+ unsubs.push(getBus().on(Events.ASK_CANCEL, (p) => cancels.push(p)));
100
+ const requests: unknown[] = [];
101
+ unsubs.push(getBus().on(Events.ASK_REQUEST, (p) => requests.push(p)));
102
+
103
+ const client = await connectClient(socketPath);
104
+ writeAskRequest(client, "req-3");
105
+ await vi.waitFor(() => expect(requests.length).toBe(1));
106
+
107
+ // Deliver the response (drains pending) before the socket closes
108
+ getBus().emit(Events.ASK_RESPONSE, {
109
+ requestId: "req-3",
110
+ cancelled: false,
111
+ results: [{ id: "q1", selectedOptions: ["A"] }],
112
+ });
113
+ await new Promise((r) => setTimeout(r, 50));
114
+ client.destroy();
115
+ await new Promise((r) => setTimeout(r, 50)); // close handler ran
116
+
117
+ expect(cancels.length).toBe(0);
118
+ });
119
+ });
package/src/spawn.ts CHANGED
@@ -67,17 +67,22 @@ function resolvePiBinary(): string {
67
67
  * Start a Unix socket server that bridges the child's ask tool to the parent bus.
68
68
  *
69
69
  * The child's ask tool (in packages/ask) connects to PI_SUBAGENT_SOCKET and sends:
70
- * { type: "ask_request", requestId, questions } as a JSON line
70
+ * { type: "ask_request", requestId, questions, toolCallId } as a JSON line
71
71
  * and waits for:
72
72
  * { type: "ask_response", requestId, cancelled, results } as a JSON line
73
73
  *
74
- * We forward the request onto the bus (ASK_REQUEST), the ask package shows the
75
- * parent TUI dialog, then emits ASK_RESPONSE on the bus, and we write it back
76
- * to the socket connection.
74
+ * We forward the request onto the bus (ASK_REQUEST, carrying toolCallId), the
75
+ * ask package (or the bridge, in bridge mode) answers it, then emits
76
+ * ASK_RESPONSE on the bus, and we write it back to the socket connection.
77
+ *
78
+ * When the socket closes (child exit, or the child's own 5-min timeout →
79
+ * socket.end()) we signal the bridge via ASK_CANCEL for any still-pending ask.
80
+ * We do NOT emit ASK_RESPONSE ourselves — the bridge is the sole emitter
81
+ * (no double-decrement of its refcount).
77
82
  *
78
83
  * Returns the socket path and a cleanup function.
79
84
  */
80
- function startAskSocketServer(agentName: string): { socketPath: string; cleanup: () => void } {
85
+ export function startAskSocketServer(agentName: string): { socketPath: string; cleanup: () => void } {
81
86
  // Use named pipes on Windows, Unix domain sockets elsewhere.
82
87
  // Linux socket path limit is 108 chars — keep it short.
83
88
  const id = randomUUID().slice(0, 8);
@@ -110,6 +115,8 @@ function startAskSocketServer(agentName: string): { socketPath: string; cleanup:
110
115
 
111
116
  const server = net.createServer((socket) => {
112
117
  let buffer = "";
118
+ // requestIds seen on this socket (for the ASK_CANCEL wiring below)
119
+ const socketRequestIds = new Set<string>();
113
120
 
114
121
  socket.on("data", (chunk: Buffer) => {
115
122
  buffer += chunk.toString("utf-8");
@@ -124,6 +131,7 @@ function startAskSocketServer(agentName: string): { socketPath: string; cleanup:
124
131
  type: string;
125
132
  requestId: string;
126
133
  questions: unknown[];
134
+ toolCallId?: string;
127
135
  };
128
136
  if (msg.type === "ask_request") {
129
137
  // Register write-back so ASK_RESPONSE handler can find this socket
@@ -134,11 +142,14 @@ function startAskSocketServer(agentName: string): { socketPath: string; cleanup:
134
142
  // socket already closed
135
143
  }
136
144
  });
137
- // Forward to bus — ask package will show the TUI dialog
145
+ socketRequestIds.add(msg.requestId);
146
+ // Forward to bus — the ask package (or the bridge, in bridge mode)
147
+ // answers it; toolCallId rides along for Client correlation
138
148
  getBus().emit(Events.ASK_REQUEST, {
139
149
  source: `subagent:${agentName}`,
140
150
  requestId: msg.requestId,
141
151
  questions: msg.questions,
152
+ toolCallId: msg.toolCallId,
142
153
  });
143
154
  }
144
155
  } catch {
@@ -147,6 +158,19 @@ function startAskSocketServer(agentName: string): { socketPath: string; cleanup:
147
158
  }
148
159
  });
149
160
 
161
+ // Child exit (or the child's own 5-min timeout → socket.end()) closes the
162
+ // socket: signal the bridge via ASK_CANCEL for any still-pending ask so it
163
+ // can cancel the relayed Client request. We only signal — the bridge is
164
+ // the sole ASK_RESPONSE emitter (no double-decrement of its refcount).
165
+ socket.on("close", () => {
166
+ for (const rid of socketRequestIds) {
167
+ if (pending.has(rid)) {
168
+ getBus().emit(Events.ASK_CANCEL, { requestId: rid, source: `subagent:${agentName}` });
169
+ pending.delete(rid);
170
+ }
171
+ }
172
+ });
173
+
150
174
  socket.on("error", () => { /* connection dropped */ });
151
175
  });
152
176
 
@@ -245,7 +269,12 @@ export function spawnSubagent(options: SpawnOptions): ChildProcess {
245
269
  forceKill.unref();
246
270
  }
247
271
  };
248
- options.signal.addEventListener("abort", abortHandler, { once: true });
272
+ // A signal that is ALREADY aborted never dispatches its "abort" event (it
273
+ // fires exactly once, at abort time), so addEventListener alone would never
274
+ // kill the child — call the handler directly in that case. (kill is
275
+ // idempotent via the child.killed guard, so both branches coexist safely.)
276
+ if (options.signal.aborted) abortHandler();
277
+ else options.signal.addEventListener("abort", abortHandler, { once: true });
249
278
  child.on("exit", () => options.signal!.removeEventListener("abort", abortHandler));
250
279
  }
251
280