@pi-archimedes/core 2.7.3 → 2.9.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.
@@ -0,0 +1,722 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import * as net from "node:net";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+ import * as fs from "node:fs";
7
+ import { randomUUID } from "node:crypto";
8
+ import { getBus, Events } from "../bus.js";
9
+ import {
10
+ getBridge,
11
+ ask,
12
+ confirm,
13
+ password,
14
+ registerBridge,
15
+ dispatch,
16
+ BridgeInactiveError,
17
+ } from "./index.js";
18
+ import { configure, sendEvent, request, BridgeTransportError, __resetForTests as resetChannel } from "./channel.js";
19
+ import {
20
+ state,
21
+ start,
22
+ onAgentStart,
23
+ onAgentSettled,
24
+ onSessionStart,
25
+ __resetForTests as resetEvents,
26
+ } from "./events.js";
27
+
28
+ // ── env helpers ─────────────────────────────────────────────────────────
29
+
30
+ const ENV_KEYS = [
31
+ "PI_ARCHIMEDES_BRIDGE",
32
+ "PI_ARCHIMEDES_BRIDGE_SOCKET",
33
+ "PI_ARCHIMEDES_BRIDGE_SESSION",
34
+ "PI_ARCHIMEDES_BRIDGE_SERVER_PID",
35
+ "PI_SUBAGENT_SOCKET",
36
+ ] as const;
37
+
38
+ const savedEnv: Record<string, string | undefined> = {};
39
+
40
+ function clearBridgeEnv(): void {
41
+ for (const k of ENV_KEYS) delete process.env[k];
42
+ }
43
+
44
+ function setBridgeEnv(overrides: Record<string, string> = {}): void {
45
+ clearBridgeEnv();
46
+ process.env.PI_ARCHIMEDES_BRIDGE = "1";
47
+ process.env.PI_ARCHIMEDES_BRIDGE_SOCKET = "/tmp/nonexistent-bridge.sock";
48
+ process.env.PI_ARCHIMEDES_BRIDGE_SESSION = "desktop-session-1";
49
+ process.env.PI_ARCHIMEDES_BRIDGE_SERVER_PID = "12345";
50
+ for (const [k, v] of Object.entries(overrides)) process.env[k] = v;
51
+ }
52
+
53
+ function makeFakeCtx(mode: "tui" | "rpc" | "json" | "print"): { mode: string } {
54
+ return { mode };
55
+ }
56
+
57
+ /** Register the bridge and fire its session_start handler with the given ctx. */
58
+ function registerAndFireSessionStart(ctx: { mode: string }): void {
59
+ const onSpy = vi.fn();
60
+ registerBridge({ on: onSpy } as unknown as ExtensionAPI);
61
+ const calls = onSpy.mock.calls as Array<[string, (e: unknown, ctx: unknown) => void]>;
62
+ const startCall = calls.find((c) => c[0] === "session_start");
63
+ expect(startCall).toBeTruthy();
64
+ startCall![1](null, ctx);
65
+ }
66
+
67
+ function tempSocketPath(): string {
68
+ return path.join(os.tmpdir(), `bridge-test-${randomUUID()}.sock`);
69
+ }
70
+
71
+ /** A minimal valid AskQuestion (matches AskRequestPayload["questions"][n]). */
72
+ function makeQuestion(overrides: Partial<{ id: string; question: string }> = {}): {
73
+ id: string;
74
+ question: string;
75
+ options: Array<{ label: string }>;
76
+ } {
77
+ return {
78
+ id: overrides.id ?? "q1",
79
+ question: overrides.question ?? "Which option?",
80
+ options: [{ label: "A" }, { label: "B" }],
81
+ };
82
+ }
83
+
84
+ function startServer(
85
+ sockPath: string,
86
+ onFrame: (frame: Record<string, unknown>) => void,
87
+ ): Promise<net.Server> {
88
+ const server = net.createServer((socket) => {
89
+ let buffer = "";
90
+ socket.on("data", (chunk: Buffer) => {
91
+ buffer += chunk.toString("utf-8");
92
+ const lines = buffer.split("\n");
93
+ buffer = lines.pop() ?? "";
94
+ for (const line of lines) {
95
+ const trimmed = line.trim();
96
+ if (!trimmed) continue;
97
+ try {
98
+ onFrame(JSON.parse(trimmed) as Record<string, unknown>);
99
+ } catch {
100
+ /* malformed — ignore */
101
+ }
102
+ }
103
+ });
104
+ socket.on("error", () => { /* connection dropped */ });
105
+ });
106
+ return new Promise((resolve) => server.listen(sockPath, () => resolve(server)));
107
+ }
108
+
109
+ beforeEach(() => {
110
+ for (const k of ENV_KEYS) savedEnv[k] = process.env[k];
111
+ clearBridgeEnv();
112
+ resetChannel();
113
+ resetEvents();
114
+ });
115
+
116
+ afterEach(() => {
117
+ for (const k of ENV_KEYS) {
118
+ if (savedEnv[k] === undefined) delete process.env[k];
119
+ else process.env[k] = savedEnv[k];
120
+ }
121
+ clearBridgeEnv();
122
+ resetChannel();
123
+ resetEvents();
124
+ });
125
+
126
+ // ── 1. Env matrix (getBridge().active at session_start) ─────────────────
127
+
128
+ describe("env matrix (getBridge().active at session_start)", () => {
129
+ it("(a) no env → active === false", () => {
130
+ clearBridgeEnv();
131
+ registerAndFireSessionStart(makeFakeCtx("rpc"));
132
+ expect(getBridge().active).toBe(false);
133
+ });
134
+
135
+ it("(b) all env + tui mode → active === false (TUI wins)", () => {
136
+ setBridgeEnv();
137
+ registerAndFireSessionStart(makeFakeCtx("tui"));
138
+ expect(getBridge().active).toBe(false);
139
+ });
140
+
141
+ it("(c) all env + rpc mode + no PI_SUBAGENT_SOCKET → active === true", () => {
142
+ setBridgeEnv();
143
+ registerAndFireSessionStart(makeFakeCtx("rpc"));
144
+ expect(getBridge().active).toBe(true);
145
+ });
146
+
147
+ it("(d) all env + rpc mode + PI_SUBAGENT_SOCKET set → active === false (root-only)", () => {
148
+ setBridgeEnv({ PI_SUBAGENT_SOCKET: "/tmp/subagent.sock" });
149
+ registerAndFireSessionStart(makeFakeCtx("rpc"));
150
+ expect(getBridge().active).toBe(false);
151
+ });
152
+
153
+ it("missing one env var (SERVER_PID) → active === false", () => {
154
+ setBridgeEnv();
155
+ delete process.env.PI_ARCHIMEDES_BRIDGE_SERVER_PID;
156
+ registerAndFireSessionStart(makeFakeCtx("rpc"));
157
+ expect(getBridge().active).toBe(false);
158
+ });
159
+ });
160
+
161
+ // ── 2. Inactive API ──────────────────────────────────────────────────────
162
+
163
+ describe("inactive API", () => {
164
+ it("ask throws BridgeInactiveError", () => {
165
+ clearBridgeEnv();
166
+ expect(() => ask({ questions: [makeQuestion()] }, "tool-1")).toThrow(BridgeInactiveError);
167
+ expect(() => ask({ questions: [makeQuestion()] }, "tool-1")).toThrow("bridge is not active");
168
+ });
169
+
170
+ it("confirm resolves false", async () => {
171
+ clearBridgeEnv();
172
+ await expect(confirm({ command: "rm -rf /", reason: "test" })).resolves.toBe(false);
173
+ });
174
+
175
+ it("password resolves empty string", async () => {
176
+ clearBridgeEnv();
177
+ await expect(password({ command: "sudo apt install", reason: "test" })).resolves.toBe("");
178
+ });
179
+
180
+ it("dispatch throws BridgeInactiveError", () => {
181
+ clearBridgeEnv();
182
+ expect(() => dispatch({ agentName: "a", task: "t", systemPrompt: null, model: null, thinking: null, tools: null }, "tool-1")).toThrow(BridgeInactiveError);
183
+ });
184
+ });
185
+
186
+ // ── 3. State machine ─────────────────────────────────────────────────────
187
+
188
+ describe("state machine", () => {
189
+ it("onAgentSettled → idle; onAgentStart → working", () => {
190
+ start();
191
+ onAgentSettled();
192
+ expect(state()).toBe("idle");
193
+ onAgentStart();
194
+ expect(state()).toBe("working");
195
+ });
196
+
197
+ it("ASK_REQUEST → blocked; stays blocked through onAgentSettled; ASK_RESPONSE → idle (settled)", () => {
198
+ start();
199
+ onAgentSettled(); // settled = true
200
+ getBus().emit(Events.ASK_REQUEST, { source: "main", requestId: "r1", questions: [makeQuestion()] });
201
+ expect(state()).toBe("blocked");
202
+ onAgentSettled(); // still blocked (refcount > 0)
203
+ expect(state()).toBe("blocked");
204
+ getBus().emit(Events.ASK_RESPONSE, { requestId: "r1", cancelled: false, results: [] });
205
+ expect(state()).toBe("idle"); // settled
206
+ });
207
+
208
+ it("ASK_RESPONSE → working (not settled)", () => {
209
+ start();
210
+ onAgentStart(); // settled = false
211
+ getBus().emit(Events.ASK_REQUEST, { source: "main", requestId: "r2", questions: [makeQuestion()] });
212
+ expect(state()).toBe("blocked");
213
+ getBus().emit(Events.ASK_RESPONSE, { requestId: "r2", cancelled: false, results: [] });
214
+ expect(state()).toBe("working"); // not settled
215
+ });
216
+
217
+ it("an unpaired ASK_RESPONSE does not drive the refcount negative (floor at 0)", () => {
218
+ start();
219
+ onAgentSettled(); // settled = true
220
+ expect(state()).toBe("idle");
221
+
222
+ // A spurious/duplicated ASK_RESPONSE with no matching ASK_REQUEST must not
223
+ // push the refcount below zero (the floor keeps the machine honest).
224
+ getBus().emit(Events.ASK_RESPONSE, { requestId: "orphan", cancelled: false, results: [] });
225
+ expect(state()).toBe("idle"); // floor at 0 (not -1)
226
+
227
+ // A legitimate ask is still blocked (the floor prevented the negative count).
228
+ getBus().emit(Events.ASK_REQUEST, { source: "main", requestId: "r3", questions: [makeQuestion()] });
229
+ expect(state()).toBe("blocked");
230
+
231
+ // ...and it still unblocks cleanly.
232
+ getBus().emit(Events.ASK_RESPONSE, { requestId: "r3", cancelled: false, results: [] });
233
+ expect(state()).toBe("idle"); // settled
234
+ });
235
+ });
236
+
237
+ // ── 4. ASK_CANCEL flow ───────────────────────────────────────────────────
238
+
239
+ describe("ASK_CANCEL flow", () => {
240
+ it("a cancelled subagent ask emits a cancelled ASK_RESPONSE and refcount returns to 0", async () => {
241
+ const sockPath = tempSocketPath();
242
+ const server = await startServer(sockPath, () => {
243
+ /* accept but never respond */
244
+ });
245
+
246
+ start();
247
+ configure({ active: true, socketPath: sockPath });
248
+
249
+ const responses: unknown[] = [];
250
+ const unsub = getBus().on(Events.ASK_RESPONSE, (p) => responses.push(p));
251
+
252
+ getBus().emit(Events.ASK_REQUEST, {
253
+ source: "subagent:x",
254
+ requestId: "sub-req-1",
255
+ questions: [makeQuestion()],
256
+ });
257
+ expect(state()).toBe("blocked");
258
+
259
+ // Give the forwarded request a moment to connect to the (silent) server.
260
+ await new Promise((r) => setTimeout(r, 50));
261
+
262
+ getBus().emit(Events.ASK_CANCEL, { requestId: "sub-req-1", source: "subagent:x" });
263
+
264
+ // The cancel closes the socket → the request rejects → a cancelled ASK_RESPONSE is emitted.
265
+ await vi.waitFor(() => {
266
+ expect(responses.length).toBeGreaterThanOrEqual(1);
267
+ }, 3000);
268
+
269
+ const resp = responses[0] as { requestId: string; cancelled: boolean; results: unknown[] };
270
+ expect(resp.requestId).toBe("sub-req-1");
271
+ expect(resp.cancelled).toBe(true);
272
+ expect(resp.results).toEqual([{ id: "q1", selectedOptions: [] }]);
273
+ expect(state()).toBe("working"); // refcount back to 0, not settled
274
+
275
+ unsub();
276
+ server.close();
277
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
278
+ });
279
+
280
+ it("ASK_CANCEL with no pending entry is a no-op (no crash, refcount untouched)", () => {
281
+ start();
282
+ getBus().emit(Events.ASK_CANCEL, { requestId: "does-not-exist", source: "subagent:x" });
283
+ expect(state()).toBe("working"); // refcount still 0
284
+ });
285
+ });
286
+
287
+ // ── 5. request round-trip + cancel ───────────────────────────────────────
288
+
289
+ describe("request round-trip + cancel", () => {
290
+ it("resolves with the response result", async () => {
291
+ const sockPath = tempSocketPath();
292
+ const server = await startServer(sockPath, (frame) => {
293
+ /* the server's onFrame is used below via a dedicated handler */
294
+ });
295
+ // Replace with a server that echoes a response for a matching id.
296
+ server.close();
297
+ const echoServer = net.createServer((socket) => {
298
+ let buffer = "";
299
+ socket.on("data", (chunk: Buffer) => {
300
+ buffer += chunk.toString("utf-8");
301
+ const lines = buffer.split("\n");
302
+ buffer = lines.pop() ?? "";
303
+ for (const line of lines) {
304
+ const trimmed = line.trim();
305
+ if (!trimmed) continue;
306
+ try {
307
+ const msg = JSON.parse(trimmed) as { type?: string; id?: string };
308
+ if (msg.type === "request" && msg.id) {
309
+ socket.write(JSON.stringify({ v: 1, type: "response", id: msg.id, result: "roundtrip-ok" }) + "\n");
310
+ }
311
+ } catch { /* malformed — ignore */
312
+ }
313
+ }
314
+ });
315
+ socket.on("error", () => { /* connection dropped */ });
316
+ });
317
+ await new Promise<void>((resolve) => echoServer.listen(sockPath, resolve));
318
+
319
+ configure({ active: true, socketPath: sockPath });
320
+ const { promise } = request("ask", { questions: [makeQuestion()] });
321
+ await expect(promise).resolves.toBe("roundtrip-ok");
322
+
323
+ echoServer.close();
324
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
325
+ });
326
+
327
+ it("cancel() before the response rejects with a cancel error", async () => {
328
+ const sockPath = tempSocketPath();
329
+ const server = await startServer(sockPath, () => {
330
+ /* accept but never respond */
331
+ });
332
+
333
+ configure({ active: true, socketPath: sockPath });
334
+ const { promise, cancel } = request("ask", { questions: [makeQuestion()] });
335
+ await new Promise((r) => setTimeout(r, 50)); // let the socket connect
336
+ cancel();
337
+ await expect(promise).rejects.toThrow();
338
+ // Idempotent: a second cancel is a no-op.
339
+ cancel();
340
+
341
+ server.close();
342
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
343
+ });
344
+
345
+ it("an unreachable channel fails fast (rejects on connect error)", async () => {
346
+ configure({ active: true, socketPath: "/tmp/definitely-does-not-exist-bridge.sock" });
347
+ const { promise } = request("ask", { questions: [makeQuestion()] });
348
+ await expect(promise).rejects.toThrow();
349
+ });
350
+
351
+ it("ask() with an AbortSignal cancels the request on abort", async () => {
352
+ const sockPath = tempSocketPath();
353
+ const server = await startServer(sockPath, () => {
354
+ /* accept but never respond */
355
+ });
356
+
357
+ configure({ active: true, socketPath: sockPath });
358
+ const controller = new AbortController();
359
+ const p = ask({ questions: [makeQuestion()] }, "tool-1", controller.signal);
360
+ await new Promise((r) => setTimeout(r, 50)); // let the socket connect
361
+ controller.abort();
362
+ await expect(p).rejects.toThrow();
363
+
364
+ server.close();
365
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
366
+ });
367
+
368
+ it("ask() with a PRE-ABORTED signal cancels the request immediately (no lingering)", async () => {
369
+ const sockPath = tempSocketPath();
370
+ const server = await startServer(sockPath, () => {
371
+ /* accept but never respond */
372
+ });
373
+
374
+ configure({ active: true, socketPath: sockPath });
375
+ const controller = new AbortController();
376
+ controller.abort(); // pre-aborted BEFORE ask() is invoked
377
+ const p = ask({ questions: [makeQuestion()] }, "tool-1", controller.signal);
378
+
379
+ // A pre-aborted signal never dispatches its "abort" event (it fires exactly
380
+ // once, at abort time), so addEventListener alone would leave the request
381
+ // lingering until the Client responds or the 5-minute timeout. The fix
382
+ // calls cancel() directly when the signal is already aborted → the promise
383
+ // rejects quickly (the socket-close handler settles it).
384
+ await expect(p).rejects.toThrow();
385
+
386
+ server.close();
387
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
388
+ });
389
+ });
390
+
391
+ // ── 8b. request timeout override + BridgeTransportError ─────────────────
392
+
393
+ describe("request timeout override + BridgeTransportError", () => {
394
+ it("timeoutMs: null does NOT reject at 5 minutes (no timer)", async () => {
395
+ vi.useFakeTimers();
396
+ try {
397
+ const sockPath = tempSocketPath();
398
+ const server = await startServer(sockPath, () => {
399
+ /* accept but never respond */
400
+ });
401
+
402
+ configure({ active: true, socketPath: sockPath });
403
+ const { promise } = request("dispatch_subagent", { task: "x" }, { toolCallId: "t1", source: "main", timeoutMs: null });
404
+ let settled = false;
405
+ promise.then(() => { settled = true; }, () => { settled = true; });
406
+
407
+ // Advance 6 minutes of fake time — the 5-minute timer (if any) would
408
+ // have fired. The promise must still be pending.
409
+ await vi.advanceTimersByTimeAsync(6 * 60 * 1000);
410
+ expect(settled).toBe(false);
411
+
412
+ server.close();
413
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
414
+ } finally {
415
+ vi.useRealTimers();
416
+ }
417
+ });
418
+
419
+ it("the default (timeoutMs omitted) still rejects after 5 minutes", async () => {
420
+ vi.useFakeTimers();
421
+ try {
422
+ const sockPath = tempSocketPath();
423
+ const server = await startServer(sockPath, () => {
424
+ /* accept but never respond */
425
+ });
426
+
427
+ configure({ active: true, socketPath: sockPath });
428
+ const { promise } = request("ask", { questions: [makeQuestion()] });
429
+ // Attach the handler BEFORE advancing (a rejection during the advance
430
+ // with no handler attached would be an unhandled rejection).
431
+ const outcome = promise.then(() => null, (e: unknown) => e);
432
+
433
+ await vi.advanceTimersByTimeAsync(6 * 60 * 1000);
434
+ const err = await outcome;
435
+ expect(err).toBeInstanceOf(Error);
436
+ expect((err as Error).message).toBe("bridge request timed out");
437
+
438
+ server.close();
439
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
440
+ } finally {
441
+ vi.useRealTimers();
442
+ }
443
+ });
444
+
445
+ it("a negative timeoutMs is clamped to the 1 ms minimum (the timer still fires — NOT 'no timer')", async () => {
446
+ vi.useFakeTimers();
447
+ try {
448
+ const sockPath = tempSocketPath();
449
+ const server = await startServer(sockPath, () => {
450
+ /* accept but never respond */
451
+ });
452
+
453
+ configure({ active: true, socketPath: sockPath });
454
+ const { promise } = request("ask", { questions: [makeQuestion()] }, { timeoutMs: -5000 });
455
+ // `null` is the "never" case; a negative value is clamped to the 1 ms
456
+ // minimum, so a short advance must fire the timeout (not skip it).
457
+ const outcome = promise.then(() => null, (e: unknown) => e);
458
+
459
+ await vi.advanceTimersByTimeAsync(2);
460
+ const err = await outcome;
461
+ expect(err).toBeInstanceOf(Error);
462
+ expect((err as Error).message).toBe("bridge request timed out");
463
+
464
+ server.close();
465
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
466
+ } finally {
467
+ vi.useRealTimers();
468
+ }
469
+ });
470
+
471
+ it("a mid-connection close (no response frame) rejects with a BridgeTransportError", async () => {
472
+ const sockPath = tempSocketPath();
473
+ const server = net.createServer((socket) => {
474
+ // Accept then destroy — the desktop is effectively gone.
475
+ socket.destroy();
476
+ });
477
+ await new Promise<void>((resolve) => server.listen(sockPath, resolve));
478
+
479
+ configure({ active: true, socketPath: sockPath });
480
+ const { promise } = request("ask", { questions: [makeQuestion()] });
481
+ const err = await promise.catch((e: unknown) => e);
482
+ expect(err).toBeInstanceOf(BridgeTransportError);
483
+
484
+ server.close();
485
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
486
+ });
487
+
488
+ it("a response-carrying error frame rejects with a plain Error (NOT BridgeTransportError)", async () => {
489
+ const sockPath = tempSocketPath();
490
+ const server = net.createServer((socket) => {
491
+ let buffer = "";
492
+ socket.on("data", (chunk: Buffer) => {
493
+ buffer += chunk.toString("utf-8");
494
+ const lines = buffer.split("\n");
495
+ buffer = lines.pop() ?? "";
496
+ for (const line of lines) {
497
+ const trimmed = line.trim();
498
+ if (!trimmed) continue;
499
+ try {
500
+ const msg = JSON.parse(trimmed) as { type?: string; id?: string };
501
+ if (msg.type === "request" && msg.id) {
502
+ socket.write(JSON.stringify({ v: 1, type: "response", id: msg.id, error: "cancelled" }) + "\n");
503
+ }
504
+ } catch { /* malformed — ignore */ }
505
+ }
506
+ });
507
+ socket.on("error", () => { /* connection dropped */ });
508
+ });
509
+ await new Promise<void>((resolve) => server.listen(sockPath, resolve));
510
+
511
+ configure({ active: true, socketPath: sockPath });
512
+ const { promise } = request("dispatch_subagent", { task: "x" }, { toolCallId: "t1", source: "main", timeoutMs: null });
513
+ const err = await promise.catch((e: unknown) => e);
514
+ // The desktop answered — it is alive and the outcome is authoritative.
515
+ expect(err).toBeInstanceOf(Error);
516
+ expect(err).not.toBeInstanceOf(BridgeTransportError);
517
+ expect((err as Error).message).toBe("cancelled");
518
+
519
+ server.close();
520
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
521
+ });
522
+
523
+ it("an unreachable channel (no socket target) rejects with a BridgeTransportError", async () => {
524
+ configure({ active: true, socketPath: "/tmp/definitely-does-not-exist-bridge2.sock" });
525
+ const { promise } = request("ask", { questions: [makeQuestion()] });
526
+ const err = await promise.catch((e: unknown) => e);
527
+ expect(err).toBeInstanceOf(BridgeTransportError);
528
+ });
529
+ });
530
+
531
+ // ── 6. seq counter ───────────────────────────────────────────────────────
532
+
533
+ describe("seq counter", () => {
534
+ it("two sendEvent calls produce seq 1 then seq 2 (coalesced, latest wins)", async () => {
535
+ const sockPath = tempSocketPath();
536
+ const frames: Array<{ seq: number; event: string; payload: unknown }> = [];
537
+ const server = net.createServer((socket) => {
538
+ let buffer = "";
539
+ socket.on("data", (chunk: Buffer) => {
540
+ buffer += chunk.toString("utf-8");
541
+ const lines = buffer.split("\n");
542
+ buffer = lines.pop() ?? "";
543
+ for (const line of lines) {
544
+ const trimmed = line.trim();
545
+ if (!trimmed) continue;
546
+ try {
547
+ const msg = JSON.parse(trimmed) as { type?: string; seq?: number; event?: string; payload?: unknown };
548
+ if (msg.type === "push") frames.push({ seq: msg.seq!, event: msg.event!, payload: msg.payload });
549
+ } catch { /* malformed — ignore */
550
+ }
551
+ }
552
+ socket.write("ack\n"); // ack so the sender destroys
553
+ });
554
+ socket.on("error", () => { /* connection dropped */ });
555
+ });
556
+ await new Promise<void>((resolve) => server.listen(sockPath, resolve));
557
+
558
+ configure({ active: true, socketPath: sockPath });
559
+ sendEvent("state", { state: "working" });
560
+ sendEvent("state", { state: "idle" }); // coalesced (latest wins)
561
+
562
+ await vi.waitFor(() => {
563
+ expect(frames.length).toBeGreaterThanOrEqual(2);
564
+ }, 3000);
565
+
566
+ expect(frames[0]!.seq).toBe(1);
567
+ expect(frames[1]!.seq).toBe(2);
568
+
569
+ server.close();
570
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
571
+ });
572
+
573
+ it("the session push is re-emitted on session_start and echoes PI_ARCHIMEDES_BRIDGE_SESSION", async () => {
574
+ const sockPath = tempSocketPath();
575
+ const frames: Array<{ event: string; payload: unknown }> = [];
576
+ const server = net.createServer((socket) => {
577
+ let buffer = "";
578
+ socket.on("data", (chunk: Buffer) => {
579
+ buffer += chunk.toString("utf-8");
580
+ const lines = buffer.split("\n");
581
+ buffer = lines.pop() ?? "";
582
+ for (const line of lines) {
583
+ const trimmed = line.trim();
584
+ if (!trimmed) continue;
585
+ try {
586
+ const msg = JSON.parse(trimmed) as { type?: string; event?: string; payload?: unknown };
587
+ if (msg.type === "push") frames.push({ event: msg.event!, payload: msg.payload! });
588
+ } catch { /* malformed — ignore */
589
+ }
590
+ }
591
+ socket.write("ack\n");
592
+ });
593
+ socket.on("error", () => { /* connection dropped */ });
594
+ });
595
+ await new Promise<void>((resolve) => server.listen(sockPath, resolve));
596
+
597
+ configure({ active: true, socketPath: sockPath });
598
+ process.env.PI_ARCHIMEDES_BRIDGE_SESSION = "my-desktop-session";
599
+ onSessionStart();
600
+
601
+ await vi.waitFor(() => {
602
+ expect(frames.some((f) => f.event === "session")).toBe(true);
603
+ }, 3000);
604
+
605
+ const sessionFrame = frames.find((f) => f.event === "session")!;
606
+ expect((sessionFrame.payload as { bridgeSession?: string }).bridgeSession).toBe("my-desktop-session");
607
+
608
+ server.close();
609
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
610
+ });
611
+ });
612
+
613
+ // ── 7. sendEvent retry/drop (unreachable channel) ────────────────────────
614
+
615
+ describe("sendEvent retry/drop", () => {
616
+ // The legitimate timeline is ~4.5 s (initial + 2 retries at 500/1500 ms +
617
+ // the 1 s no-ack window each), so give the test an explicit timeout with
618
+ // real headroom against vitest's 5 s default. A unique event name keeps
619
+ // this test hermetic: inFlight is keyed by event type, so a foreign "state"
620
+ // retry chain from an earlier test can't cross-wire into "retry-probe".
621
+ it("retries twice then drops on a channel that never acks (no hang, no leaked timer)", { timeout: 10_000 }, async () => {
622
+ const sockPath = tempSocketPath();
623
+ let connections = 0;
624
+ const server = net.createServer((socket) => {
625
+ connections++;
626
+ // Accept but never ack and never close → the sender's no-ack window
627
+ // (1 s) treats it as a failure and retries (same finish(false) path as
628
+ // an unreachable socket; the retry/backoff/drop machinery is identical).
629
+ socket.on("error", () => { /* connection dropped */ });
630
+ });
631
+ await new Promise<void>((resolve) => server.listen(sockPath, resolve));
632
+
633
+ configure({ active: true, socketPath: sockPath });
634
+
635
+ // A unique event name (not "state") so a foreign "state" retry chain from
636
+ // an earlier test can't cross-wire into this one (inFlight is keyed by
637
+ // event type).
638
+ sendEvent("retry-probe", { state: "working" });
639
+
640
+ // initial + 2 retries (500 ms + 1500 ms) → exactly 3 attempts, then drop.
641
+ await vi.waitFor(() => {
642
+ expect(connections).toBe(3);
643
+ }, 6000);
644
+
645
+ // Dropped (not looping): a short wait later it is still exactly 3 (no 4th attempt).
646
+ await new Promise((r) => setTimeout(r, 500));
647
+ expect(connections).toBe(3);
648
+
649
+ server.close();
650
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
651
+ });
652
+ });
653
+
654
+ // ── 8. subagent ask success path (end-to-end through the bridge) ─────────
655
+
656
+ describe("subagent ask success path", () => {
657
+ it("forwards to the Client and settles with the response verbatim (state unblocked)", async () => {
658
+ const sockPath = tempSocketPath();
659
+ const frames: Array<Record<string, unknown>> = [];
660
+ const server = net.createServer((socket) => {
661
+ let buffer = "";
662
+ socket.on("data", (chunk: Buffer) => {
663
+ buffer += chunk.toString("utf-8");
664
+ const lines = buffer.split("\n");
665
+ buffer = lines.pop() ?? "";
666
+ for (const line of lines) {
667
+ const trimmed = line.trim();
668
+ if (!trimmed) continue;
669
+ try {
670
+ const msg = JSON.parse(trimmed) as Record<string, unknown>;
671
+ frames.push(msg);
672
+ if (msg.type === "request") {
673
+ socket.write(JSON.stringify({
674
+ v: 1,
675
+ type: "response",
676
+ id: msg.id,
677
+ result: { cancelled: false, results: [{ id: "q1", selectedOptions: ["B"], customInput: "extra" }] },
678
+ }) + "\n");
679
+ }
680
+ } catch { /* malformed — ignore */ }
681
+ }
682
+ });
683
+ socket.on("error", () => { /* connection dropped */ });
684
+ });
685
+ await new Promise<void>((resolve) => server.listen(sockPath, resolve));
686
+
687
+ start();
688
+ configure({ active: true, socketPath: sockPath });
689
+
690
+ const responses: unknown[] = [];
691
+ const unsub = getBus().on(Events.ASK_RESPONSE, (p) => responses.push(p));
692
+
693
+ getBus().emit(Events.ASK_REQUEST, {
694
+ source: "subagent:x",
695
+ requestId: "sub-req-2",
696
+ toolCallId: "child-tool-1",
697
+ questions: [makeQuestion()],
698
+ });
699
+ expect(state()).toBe("blocked"); // refcount 1
700
+
701
+ // The Client answers → the bridge emits the paired ASK_RESPONSE verbatim
702
+ // (the same .then also drains `pending`, so the emission implies it).
703
+ await vi.waitFor(() => {
704
+ expect(responses.length).toBeGreaterThanOrEqual(1);
705
+ }, 3000);
706
+
707
+ const resp = responses[0] as { requestId: string; cancelled: boolean; results: unknown[] };
708
+ expect(resp.requestId).toBe("sub-req-2");
709
+ expect(resp.cancelled).toBe(false);
710
+ expect(resp.results).toEqual([{ id: "q1", selectedOptions: ["B"], customInput: "extra" }]);
711
+ expect(state()).toBe("working"); // refcount back to 0, not settled
712
+
713
+ // The forwarded request frame carried the source + toolCallId.
714
+ const reqFrame = frames.find((f) => f.type === "request")!;
715
+ expect(reqFrame.source).toBe("subagent:x");
716
+ expect(reqFrame.toolCallId).toBe("child-tool-1");
717
+
718
+ unsub();
719
+ server.close();
720
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
721
+ });
722
+ });