@pi-archimedes/core 2.10.4 → 2.11.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/README.md +3 -3
- package/package.json +7 -4
- package/src/bridge/events.test.ts +438 -0
- package/src/bridge/events.ts +98 -39
- package/src/bridge/index.test.ts +50 -22
- package/src/bridge/index.ts +18 -5
- package/src/bridge/self-usage.test.ts +90 -12
- package/src/bridge/self-usage.ts +19 -6
- package/src/chrome-old-host.test.ts +84 -0
- package/src/chrome.test.ts +435 -2
- package/src/chrome.ts +138 -20
- package/src/events-host-bus.test.ts +113 -0
- package/src/events.test.ts +85 -0
- package/src/events.ts +107 -0
- package/src/index.test.ts +3 -9
- package/src/index.ts +5 -5
- package/src/log-discipline.test.ts +187 -0
- package/src/log.test.ts +172 -0
- package/src/log.ts +69 -0
- package/src/overlay.test.ts +178 -6
- package/src/overlay.ts +46 -62
- package/src/pi-support.test.ts +444 -0
- package/src/pi-support.ts +190 -0
- package/src/profiler.ts +14 -5
- package/src/screen-mode-discipline.test.ts +214 -0
- package/src/screen-mode.test.ts +48 -0
- package/src/screen-mode.ts +45 -0
- package/src/settings-io.test.ts +174 -1
- package/src/settings-io.ts +54 -0
- package/src/text.test.ts +58 -0
- package/src/text.ts +21 -20
- package/src/tool-render.test.ts +48 -0
- package/src/tool-render.ts +44 -0
- package/src/bus.test.ts +0 -154
- package/src/bus.ts +0 -137
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Foundational non-UI runtime for the Pi Archimedes suite.**
|
|
4
4
|
|
|
5
|
-
Core is the foundational runtime that keeps the rest of the suite talking. It provides the shared
|
|
5
|
+
Core is the foundational runtime that keeps the rest of the suite talking. It provides the shared channel vocabulary the packages talk to each other on, message handling, settings-io, pure text/color/tool-render utilities, overlay chrome, and a startup profiler.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -28,12 +28,12 @@ After installing Pi, choose one installation command above, then `cd` into your
|
|
|
28
28
|
|
|
29
29
|
## What you get
|
|
30
30
|
|
|
31
|
-
- **The
|
|
31
|
+
- **The channel vocabulary** (`@pi-archimedes/core/events`) — the names and payload shapes the packages speak to each other with: `ASK_REQUEST`, `ASK_RESPONSE`, `ASK_CANCEL`, `TODOS_UPDATE`, `TODOS_CLEAR`, `COST_UPDATE`. Subagent todos flow to the task board through them and subagent questions to the ask UI. The module carries **no transport** — the transport is pi's own process-wide `pi.events`, and a package emits or listens on the handle it was given. `emitOn()` wraps emitting, because that handle is invalidated when its session is torn down and a dropped channel beats a crash. Cost no longer flows this way: a subagent reports its usage on its own tool result, which is what `/session` and the footer already total (`packages/subagent/src/usage.ts`).
|
|
32
32
|
- **Shared utilities** — text truncation and width measurement, colour formatting, settings I/O, and startup profiling.
|
|
33
33
|
- **Overlay Chrome** — shared base for UI components.
|
|
34
34
|
|
|
35
35
|
## Part of the suite
|
|
36
36
|
|
|
37
|
-
In [pi-archimedes](https://github.com/danielcherubini/pi-archimedes), core is always registered — it isn't one of the `/plugins` toggles — and it underpins what the other components share: the
|
|
37
|
+
In [pi-archimedes](https://github.com/danielcherubini/pi-archimedes), core is always registered — it isn't one of the `/plugins` toggles — and it underpins what the other components share: the channel vocabulary that carries subagent todos to the task board and subagent questions to the ask UI, and the bridge that reaches the Archimedes Client. The diff renderer is standalone and does not depend on core's chrome — it only shares the suite when it loads.
|
|
38
38
|
|
|
39
39
|
← [Back to pi-archimedes](https://github.com/danielcherubini/pi-archimedes)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/danielcherubini/pi-archimedes.git"
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
".": "./src/index.ts",
|
|
19
19
|
"./bridge": "./src/bridge/index.ts",
|
|
20
20
|
"./bridge/channel": "./src/bridge/channel.ts",
|
|
21
|
-
"./
|
|
21
|
+
"./events": "./src/events.ts",
|
|
22
22
|
"./chrome": "./src/chrome.ts",
|
|
23
23
|
"./text": "./src/text.ts",
|
|
24
24
|
"./color": "./src/color.ts",
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
"./config": "./src/config.ts",
|
|
27
27
|
"./settings-io": "./src/settings-io.ts",
|
|
28
28
|
"./profiler": "./src/profiler.ts",
|
|
29
|
+
"./screen-mode": "./src/screen-mode.ts",
|
|
30
|
+
"./log": "./src/log.ts",
|
|
31
|
+
"./pi-support": "./src/pi-support.ts",
|
|
29
32
|
"./tool-render": "./src/tool-render.ts"
|
|
30
33
|
},
|
|
31
34
|
"peerDependencies": {
|
|
@@ -33,8 +36,8 @@
|
|
|
33
36
|
"@earendil-works/pi-tui": ">=0.85.0"
|
|
34
37
|
},
|
|
35
38
|
"devDependencies": {
|
|
36
|
-
"@earendil-works/pi-coding-agent": "
|
|
37
|
-
"@earendil-works/pi-tui": "
|
|
39
|
+
"@earendil-works/pi-coding-agent": "1.2.0",
|
|
40
|
+
"@earendil-works/pi-tui": "1.2.0",
|
|
38
41
|
"typescript": "^6.0.0"
|
|
39
42
|
},
|
|
40
43
|
"pi": {
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge relay over the HOST event bus (`pi.events`), including its lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* The relay used to subscribe to the suite's own `globalThis` bus. It now
|
|
5
|
+
* subscribes through the host handle the caller passes in, which changes what
|
|
6
|
+
* it owes the process:
|
|
7
|
+
*
|
|
8
|
+
* - `pi.events.on()` returns an unsubscribe, and the host **drops every
|
|
9
|
+
* subscription of a runtime** when that runtime is invalidated (session
|
|
10
|
+
* replacement, `/reload`) — see `createExtensionRuntime().invalidate` in
|
|
11
|
+
* `dist/core/extensions/loader.js`. So a module-level "already subscribed"
|
|
12
|
+
* flag that outlives the session wedges the relay: the subscriptions are gone
|
|
13
|
+
* but the flag says otherwise, and the bridge goes silently deaf. `stop()` is
|
|
14
|
+
* the counterpart that makes re-`start()` correct, and the `/new` test below
|
|
15
|
+
* is the proof.
|
|
16
|
+
* - Subscribing per `session_start` without tearing down first would
|
|
17
|
+
* double-deliver. The single-delivery test is the guard.
|
|
18
|
+
*
|
|
19
|
+
* `./channel.js` (the socket transport) is mocked — it is unchanged by this
|
|
20
|
+
* migration and covered by `channel.test.ts`.
|
|
21
|
+
*/
|
|
22
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
23
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { Events } from "../events.js";
|
|
25
|
+
import { start, stop, state, __resetForTests } from "./events.js";
|
|
26
|
+
import { registerBridge } from "./index.js";
|
|
27
|
+
import { registerSelfUsage } from "./self-usage.js";
|
|
28
|
+
|
|
29
|
+
const sent: Array<{ name: string; payload: unknown }> = [];
|
|
30
|
+
const asked: Array<{ method: string; payload: unknown; opts: unknown }> = [];
|
|
31
|
+
let settle: { resolve: (r: { cancelled: boolean; results: unknown[] }) => void; reject: (e: unknown) => void } = {
|
|
32
|
+
resolve: () => {},
|
|
33
|
+
reject: () => {},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
vi.mock("./channel.js", () => ({
|
|
37
|
+
sendEvent: (name: string, payload: unknown) => sent.push({ name, payload }),
|
|
38
|
+
request: (method: string, payload: unknown, opts: unknown) => {
|
|
39
|
+
asked.push({ method, payload, opts });
|
|
40
|
+
const promise = new Promise<{ cancelled: boolean; results: unknown[] }>((resolve, reject) => {
|
|
41
|
+
settle = { resolve, reject };
|
|
42
|
+
});
|
|
43
|
+
return {
|
|
44
|
+
promise,
|
|
45
|
+
// The real channel settles a cancel by REJECTING it (the socket closes),
|
|
46
|
+
// which is the path events.ts pairs with a cancelled ASK_RESPONSE.
|
|
47
|
+
cancel: () => settle.reject(new Error("cancelled")),
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
configure: vi.fn(),
|
|
51
|
+
isActive: () => true,
|
|
52
|
+
BridgeTransportError: class extends Error {},
|
|
53
|
+
BridgeCancelledError: class extends Error {},
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
function fakeBus() {
|
|
57
|
+
const listeners = new Map<string, Array<(data: unknown) => void>>();
|
|
58
|
+
return {
|
|
59
|
+
emit(channel: string, data: unknown): void {
|
|
60
|
+
for (const handler of [...(listeners.get(channel) ?? [])]) handler(data);
|
|
61
|
+
},
|
|
62
|
+
on(channel: string, handler: (data: unknown) => void): () => void {
|
|
63
|
+
const subs = listeners.get(channel) ?? [];
|
|
64
|
+
subs.push(handler);
|
|
65
|
+
listeners.set(channel, subs);
|
|
66
|
+
return () => {
|
|
67
|
+
const current = listeners.get(channel) ?? [];
|
|
68
|
+
const idx = current.indexOf(handler);
|
|
69
|
+
if (idx !== -1) current.splice(idx, 1);
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
listenerCount(channel: string): number {
|
|
73
|
+
return (listeners.get(channel) ?? []).length;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const COST = { source: "main", inputTokens: 10, outputTokens: 2, cacheReadTokens: 0, cacheWriteTokens: 0, cost: 0.01 };
|
|
79
|
+
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
sent.length = 0;
|
|
82
|
+
asked.length = 0;
|
|
83
|
+
__resetForTests();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
__resetForTests();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── 1. the relay still relays, on the host handle ───────────────────────────
|
|
91
|
+
|
|
92
|
+
describe("relay over pi.events", () => {
|
|
93
|
+
it("COST_UPDATE reaches the Client as a cost_update frame", () => {
|
|
94
|
+
const bus = fakeBus();
|
|
95
|
+
start(bus);
|
|
96
|
+
bus.emit(Events.COST_UPDATE, COST);
|
|
97
|
+
expect(sent).toEqual([{ name: "cost_update", payload: COST }]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("TODOS_UPDATE / TODOS_CLEAR are forwarded verbatim under their wire names", () => {
|
|
101
|
+
const bus = fakeBus();
|
|
102
|
+
start(bus);
|
|
103
|
+
bus.emit(Events.TODOS_UPDATE, { source: "subagent:x", todos: [] });
|
|
104
|
+
bus.emit(Events.TODOS_CLEAR, { source: "subagent:x" });
|
|
105
|
+
expect(sent).toEqual([
|
|
106
|
+
{ name: "todos_update", payload: { source: "subagent:x", todos: [] } },
|
|
107
|
+
{ name: "todos_clear", payload: { source: "subagent:x" } },
|
|
108
|
+
]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("drives the state machine: an ask blocks, its response unblocks it", () => {
|
|
112
|
+
const bus = fakeBus();
|
|
113
|
+
start(bus);
|
|
114
|
+
expect(state()).toBe("working");
|
|
115
|
+
bus.emit(Events.ASK_REQUEST, { source: "main", requestId: "r1", questions: [{ id: "q" }] });
|
|
116
|
+
expect(state()).toBe("blocked");
|
|
117
|
+
bus.emit(Events.ASK_RESPONSE, { requestId: "r1", cancelled: false, results: [] });
|
|
118
|
+
expect(state()).toBe("working");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("a spurious ASK_RESPONSE never drives the refcount negative", () => {
|
|
122
|
+
const bus = fakeBus();
|
|
123
|
+
start(bus);
|
|
124
|
+
bus.emit(Events.ASK_RESPONSE, { requestId: "orphan", cancelled: false, results: [] });
|
|
125
|
+
bus.emit(Events.ASK_REQUEST, { source: "main", requestId: "r2", questions: [{ id: "q" }] });
|
|
126
|
+
expect(state()).toBe("blocked");
|
|
127
|
+
bus.emit(Events.ASK_RESPONSE, { requestId: "r2", cancelled: false, results: [] });
|
|
128
|
+
expect(state()).toBe("working");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The ask round-trip, end to end on the new transport: a subagent ask arrives
|
|
133
|
+
* on the bus, the Client is asked, and the Client's answer comes BACK on the
|
|
134
|
+
* same handle — which is what `subagent/spawn.ts` writes to the child socket.
|
|
135
|
+
*/
|
|
136
|
+
it("forwards a subagent ask to the Client and answers on the same bus", async () => {
|
|
137
|
+
const bus = fakeBus();
|
|
138
|
+
const responses: unknown[] = [];
|
|
139
|
+
start(bus);
|
|
140
|
+
bus.on(Events.ASK_RESPONSE, (p) => responses.push(p));
|
|
141
|
+
|
|
142
|
+
bus.emit(Events.ASK_REQUEST, {
|
|
143
|
+
source: "subagent:x",
|
|
144
|
+
requestId: "sub-req-1",
|
|
145
|
+
toolCallId: "tc1",
|
|
146
|
+
questions: [{ id: "q1" }],
|
|
147
|
+
});
|
|
148
|
+
expect(asked[0]?.method).toBe("ask");
|
|
149
|
+
expect(asked[0]?.opts).toEqual({ source: "subagent:x", toolCallId: "tc1" });
|
|
150
|
+
|
|
151
|
+
settle.resolve({ cancelled: false, results: [{ id: "q1", selectedOptions: ["yes"] }] });
|
|
152
|
+
await Promise.resolve();
|
|
153
|
+
await Promise.resolve();
|
|
154
|
+
expect(responses).toEqual([
|
|
155
|
+
{ requestId: "sub-req-1", cancelled: false, results: [{ id: "q1", selectedOptions: ["yes"] }] },
|
|
156
|
+
]);
|
|
157
|
+
expect(state()).toBe("working");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("ASK_CANCEL cancels the pending Client request", async () => {
|
|
161
|
+
const bus = fakeBus();
|
|
162
|
+
const responses: unknown[] = [];
|
|
163
|
+
start(bus);
|
|
164
|
+
bus.on(Events.ASK_RESPONSE, (p) => responses.push(p));
|
|
165
|
+
bus.emit(Events.ASK_REQUEST, { source: "subagent:x", requestId: "sub-req-2", questions: [{ id: "q1" }] });
|
|
166
|
+
bus.emit(Events.ASK_CANCEL, { requestId: "sub-req-2", source: "subagent:x" });
|
|
167
|
+
await Promise.resolve();
|
|
168
|
+
await Promise.resolve();
|
|
169
|
+
expect(responses).toEqual([
|
|
170
|
+
{ requestId: "sub-req-2", cancelled: true, results: [{ id: "q1", selectedOptions: [] }] },
|
|
171
|
+
]);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ── 2. lifecycle ────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
describe("subscription lifecycle", () => {
|
|
178
|
+
it("start() twice delivers one event once (a second session_start must not double-subscribe)", () => {
|
|
179
|
+
const bus = fakeBus();
|
|
180
|
+
start(bus);
|
|
181
|
+
start(bus);
|
|
182
|
+
expect(bus.listenerCount(Events.COST_UPDATE)).toBe(1);
|
|
183
|
+
bus.emit(Events.COST_UPDATE, COST);
|
|
184
|
+
expect(sent).toHaveLength(1);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("stop() unsubscribes everything it subscribed", () => {
|
|
188
|
+
const bus = fakeBus();
|
|
189
|
+
start(bus);
|
|
190
|
+
expect(bus.listenerCount(Events.COST_UPDATE)).toBe(1);
|
|
191
|
+
stop();
|
|
192
|
+
expect(bus.listenerCount(Events.COST_UPDATE)).toBe(0);
|
|
193
|
+
bus.emit(Events.COST_UPDATE, COST);
|
|
194
|
+
expect(sent).toEqual([]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The `/new` case. pi fires `session_shutdown`, then invalidates the runtime,
|
|
199
|
+
* which drops the host-side subscriptions; the factories run again and the
|
|
200
|
+
* next `session_start` calls `start()` again. A module-level "started" flag
|
|
201
|
+
* that survived the teardown would make that call a permanent no-op and the
|
|
202
|
+
* bridge would stay deaf for the rest of the process.
|
|
203
|
+
*/
|
|
204
|
+
it("start() after stop() subscribes again (a replacement session must not inherit a dead relay)", () => {
|
|
205
|
+
const first = fakeBus();
|
|
206
|
+
start(first);
|
|
207
|
+
stop();
|
|
208
|
+
const second = fakeBus();
|
|
209
|
+
start(second);
|
|
210
|
+
second.emit(Events.COST_UPDATE, COST);
|
|
211
|
+
expect(sent).toEqual([{ name: "cost_update", payload: COST }]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("stop() without start() is a no-op", () => {
|
|
215
|
+
expect(() => stop()).not.toThrow();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* A Client reply that settles AFTER the relay restarted belongs to the dead
|
|
220
|
+
* subscription set, not the new one. `stop()` cancels the pending asks, and
|
|
221
|
+
* `channel.request().cancel()` settles synchronously — so the paired
|
|
222
|
+
* `.catch` lands on the very next microtask, by which time `start()` has
|
|
223
|
+
* already re-subscribed on the (possibly replacement) handle. Without a
|
|
224
|
+
* generation guard that stale `ASK_RESPONSE` is emitted into the NEW set.
|
|
225
|
+
*
|
|
226
|
+
* This is not a hypothetical restart: pi fires `session_start` twice per
|
|
227
|
+
* `new_session` in RPC mode (`runtimeHost.newSession()` rebinds through
|
|
228
|
+
* `setRebindSession`, then `rpc-mode.js` calls `rebindSession()` again), so a
|
|
229
|
+
* restart with an ask still pending is a real sequence, verified live against
|
|
230
|
+
* pi 1.2.0.
|
|
231
|
+
*/
|
|
232
|
+
it("a reply that settles after the relay restarted does not reach the new subscription set", async () => {
|
|
233
|
+
const bus = fakeBus();
|
|
234
|
+
start(bus);
|
|
235
|
+
bus.emit(Events.ASK_REQUEST, { source: "subagent:x", requestId: "gen1-req", questions: [{ id: "q" }] });
|
|
236
|
+
|
|
237
|
+
// Restart while that Client request is still pending.
|
|
238
|
+
start(bus);
|
|
239
|
+
const crossed: unknown[] = [];
|
|
240
|
+
const unsub = bus.on(Events.ASK_RESPONSE, (p) => crossed.push(p));
|
|
241
|
+
await Promise.resolve();
|
|
242
|
+
await Promise.resolve();
|
|
243
|
+
|
|
244
|
+
expect(crossed).toEqual([]);
|
|
245
|
+
unsub();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The consequence the guard buys: an ask opened AFTER the restart keeps its
|
|
250
|
+
* own refcount. Without the guard the stale reply is indistinguishable from a
|
|
251
|
+
* real one to the fresh listener, and it unblocks a prompt still on screen.
|
|
252
|
+
*/
|
|
253
|
+
it("a stale reply cannot unblock an ask opened after the restart", async () => {
|
|
254
|
+
const bus = fakeBus();
|
|
255
|
+
start(bus);
|
|
256
|
+
bus.emit(Events.ASK_REQUEST, { source: "subagent:x", requestId: "gen1-req", questions: [{ id: "q" }] });
|
|
257
|
+
|
|
258
|
+
start(bus); // restart with gen1-req pending
|
|
259
|
+
bus.emit(Events.ASK_REQUEST, { source: "subagent:x", requestId: "gen2-req", questions: [{ id: "q" }] });
|
|
260
|
+
expect(state()).toBe("blocked");
|
|
261
|
+
|
|
262
|
+
await Promise.resolve();
|
|
263
|
+
await Promise.resolve();
|
|
264
|
+
|
|
265
|
+
// gen1-req's cancelled reply must not be counted against gen2-req.
|
|
266
|
+
expect(state()).toBe("blocked");
|
|
267
|
+
bus.emit(Events.ASK_RESPONSE, { requestId: "gen2-req", cancelled: false, results: [] });
|
|
268
|
+
expect(state()).toBe("working");
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Three session generations, each with its own handle: exactly one live
|
|
273
|
+
* subscription per channel in the generation that opened it, and none after
|
|
274
|
+
* the last teardown. The leak this guards is a dropped unsubscribe — the host
|
|
275
|
+
* also drops a runtime's subscriptions on invalidation, but a module that
|
|
276
|
+
* still holds the old unsubscribers would be describing subscriptions that no
|
|
277
|
+
* longer exist, and a same-handle restart would double-deliver.
|
|
278
|
+
*/
|
|
279
|
+
it("three generations subscribe once, deliver once, and leave nothing behind", () => {
|
|
280
|
+
const buses = [fakeBus(), fakeBus(), fakeBus()];
|
|
281
|
+
const channels = [
|
|
282
|
+
Events.COST_UPDATE,
|
|
283
|
+
Events.TODOS_UPDATE,
|
|
284
|
+
Events.TODOS_CLEAR,
|
|
285
|
+
Events.ASK_REQUEST,
|
|
286
|
+
Events.ASK_RESPONSE,
|
|
287
|
+
Events.ASK_CANCEL,
|
|
288
|
+
];
|
|
289
|
+
for (const bus of buses) {
|
|
290
|
+
// Twice, because pi really does fire `session_start` twice for one
|
|
291
|
+
// replacement in RPC mode (`setRebindSession` + rpc-mode's own rebind).
|
|
292
|
+
start(bus);
|
|
293
|
+
start(bus);
|
|
294
|
+
// Every channel the relay opens is counted, not just one: a teardown that
|
|
295
|
+
// forgets a single channel is otherwise invisible to this file.
|
|
296
|
+
for (const channel of channels) expect(bus.listenerCount(channel)).toBe(1);
|
|
297
|
+
sent.length = 0;
|
|
298
|
+
bus.emit(Events.COST_UPDATE, COST);
|
|
299
|
+
expect(sent).toHaveLength(1);
|
|
300
|
+
stop();
|
|
301
|
+
// And every channel is closed again — on every handle, so a dropped
|
|
302
|
+
// unsubscribe cannot hide on the bus the generation just left.
|
|
303
|
+
for (const other of buses) {
|
|
304
|
+
for (const channel of channels) expect(other.listenerCount(channel)).toBe(0);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// ── 3. the real wiring: registerBridge's own handlers ───────────────────────
|
|
311
|
+
|
|
312
|
+
describe("registerBridge wiring", () => {
|
|
313
|
+
const ENV_KEYS = [
|
|
314
|
+
"PI_ARCHIMEDES_BRIDGE",
|
|
315
|
+
"PI_ARCHIMEDES_BRIDGE_SOCKET",
|
|
316
|
+
"PI_ARCHIMEDES_BRIDGE_SESSION",
|
|
317
|
+
"PI_ARCHIMEDES_BRIDGE_SERVER_PID",
|
|
318
|
+
"PI_SUBAGENT_SOCKET",
|
|
319
|
+
] as const;
|
|
320
|
+
const saved: Record<string, string | undefined> = {};
|
|
321
|
+
|
|
322
|
+
beforeEach(() => {
|
|
323
|
+
for (const k of ENV_KEYS) saved[k] = process.env[k];
|
|
324
|
+
process.env.PI_ARCHIMEDES_BRIDGE = "1";
|
|
325
|
+
process.env.PI_ARCHIMEDES_BRIDGE_SOCKET = "/tmp/relay-test.sock";
|
|
326
|
+
process.env.PI_ARCHIMEDES_BRIDGE_SESSION = "s1";
|
|
327
|
+
process.env.PI_ARCHIMEDES_BRIDGE_SERVER_PID = "4242";
|
|
328
|
+
delete process.env.PI_SUBAGENT_SOCKET;
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
afterEach(() => {
|
|
332
|
+
for (const k of ENV_KEYS) {
|
|
333
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
334
|
+
else process.env[k] = saved[k];
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
function fakePi() {
|
|
339
|
+
const handlers = new Map<string, Array<(e: unknown, ctx: unknown) => unknown>>();
|
|
340
|
+
const pi = {
|
|
341
|
+
__handlers: handlers,
|
|
342
|
+
on(event: string, handler: (e: unknown, ctx: unknown) => unknown) {
|
|
343
|
+
const list = handlers.get(event) ?? [];
|
|
344
|
+
list.push(handler);
|
|
345
|
+
handlers.set(event, list);
|
|
346
|
+
return () => {
|
|
347
|
+
const current = handlers.get(event) ?? [];
|
|
348
|
+
const idx = current.indexOf(handler);
|
|
349
|
+
if (idx !== -1) current.splice(idx, 1);
|
|
350
|
+
};
|
|
351
|
+
},
|
|
352
|
+
events: fakeBus(),
|
|
353
|
+
};
|
|
354
|
+
const fire = async (event: string, e: unknown = { type: event }, ctx: unknown = { mode: "rpc" }) => {
|
|
355
|
+
for (const handler of [...(handlers.get(event) ?? [])]) await handler(e, ctx);
|
|
356
|
+
};
|
|
357
|
+
return { pi: pi as unknown as ExtensionAPI, fire, bus: pi.events };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
it("two session_starts cause ONE delivery of one event", async () => {
|
|
361
|
+
const { pi, fire, bus } = fakePi();
|
|
362
|
+
registerBridge(pi);
|
|
363
|
+
await fire("session_start");
|
|
364
|
+
await fire("session_start");
|
|
365
|
+
sent.length = 0; // drop the `session` pushes
|
|
366
|
+
bus.emit(Events.COST_UPDATE, COST);
|
|
367
|
+
expect(sent).toEqual([{ name: "cost_update", payload: COST }]);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("session_shutdown tears the relay down: a later emit reaches nothing", async () => {
|
|
371
|
+
const { pi, fire, bus } = fakePi();
|
|
372
|
+
registerBridge(pi);
|
|
373
|
+
await fire("session_start");
|
|
374
|
+
await fire("session_shutdown", { type: "session_shutdown", reason: "new" });
|
|
375
|
+
sent.length = 0;
|
|
376
|
+
bus.emit(Events.COST_UPDATE, COST);
|
|
377
|
+
expect(sent).toEqual([]);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("the handler is registered at the top level of register(), not nested in session_start (AGENTS.md)", () => {
|
|
381
|
+
const onSpy = vi.fn();
|
|
382
|
+
registerBridge({ on: onSpy, events: fakeBus() } as unknown as ExtensionAPI);
|
|
383
|
+
const events = onSpy.mock.calls.map((c) => c[0] as string);
|
|
384
|
+
expect(events).toContain("session_shutdown");
|
|
385
|
+
// Exactly one registration per event, however many times the handlers fire.
|
|
386
|
+
expect(events.filter((e) => e === "session_shutdown")).toHaveLength(1);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The one emitter that is adjacent to startup, ordered against the relay.
|
|
391
|
+
*
|
|
392
|
+
* `pi.events` has no pre-subscriber queue, so the migration had to answer
|
|
393
|
+
* whether anything emits before its listener exists. `COST_UPDATE` is the
|
|
394
|
+
* candidate — core registers the emitter unconditionally, while the relay only
|
|
395
|
+
* subscribes once `session_start` has decided activation. It is safe: pi fires
|
|
396
|
+
* `session_start` before the first turn, so the relay is listening before the
|
|
397
|
+
* first `turn_end` can emit. This test is that claim, in the order pi uses.
|
|
398
|
+
*/
|
|
399
|
+
it("a turn_end COST_UPDATE after session_start reaches the relay (no queue needed)", async () => {
|
|
400
|
+
const { pi, fire, bus } = fakePi();
|
|
401
|
+
registerBridge(pi);
|
|
402
|
+
const turnEnd = captureTurnEnd(pi);
|
|
403
|
+
await fire("session_start");
|
|
404
|
+
sent.length = 0;
|
|
405
|
+
|
|
406
|
+
turnEnd({
|
|
407
|
+
type: "turn_end",
|
|
408
|
+
message: { role: "assistant", usage: { input: 7, output: 3, cost: { total: 0.12 } } },
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
expect(sent).toEqual([
|
|
412
|
+
{
|
|
413
|
+
name: "cost_update",
|
|
414
|
+
payload: {
|
|
415
|
+
source: "main",
|
|
416
|
+
inputTokens: 7,
|
|
417
|
+
outputTokens: 3,
|
|
418
|
+
cacheReadTokens: 0,
|
|
419
|
+
cacheWriteTokens: 0,
|
|
420
|
+
cost: 0.12,
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
]);
|
|
424
|
+
// …and the same emit has no subscriber before session_start: the host drops
|
|
425
|
+
// it, which is correct — a turn cannot have ended before its session began.
|
|
426
|
+
expect(bus.listenerCount(Events.COST_UPDATE)).toBe(1);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
/** Register self-usage against the same fake pi and return its turn_end handler. */
|
|
431
|
+
function captureTurnEnd(pi: ExtensionAPI): (event: unknown) => void {
|
|
432
|
+
const handlers = (pi as unknown as { __handlers: Map<string, Array<(e: unknown, c: unknown) => unknown>> }).__handlers;
|
|
433
|
+
registerSelfUsage(pi);
|
|
434
|
+
const handler = [...handlers.get("turn_end")!].at(-1)!;
|
|
435
|
+
return (event: unknown) => {
|
|
436
|
+
void handler(event, { mode: "rpc" });
|
|
437
|
+
};
|
|
438
|
+
}
|