@blackbelt-technology/pi-agent-dashboard 0.4.0 → 0.4.1

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.
Files changed (35) hide show
  1. package/AGENTS.md +30 -8
  2. package/README.md +386 -494
  3. package/docs/architecture.md +63 -9
  4. package/package.json +8 -5
  5. package/packages/extension/package.json +6 -4
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +40 -8
  7. package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
  8. package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
  9. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
  10. package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
  11. package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
  12. package/packages/extension/src/ask-user-tool.ts +5 -4
  13. package/packages/extension/src/bridge.ts +102 -15
  14. package/packages/extension/src/multiselect-list.ts +146 -0
  15. package/packages/extension/src/multiselect-polyfill.ts +43 -0
  16. package/packages/extension/src/server-launcher.ts +15 -3
  17. package/packages/server/package.json +5 -5
  18. package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
  19. package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
  20. package/packages/server/src/__tests__/pi-version-skew.test.ts +72 -0
  21. package/packages/server/src/__tests__/restart-helper.test.ts +34 -6
  22. package/packages/server/src/cli.ts +56 -9
  23. package/packages/server/src/pi-version-skew.ts +12 -1
  24. package/packages/server/src/restart-helper.ts +13 -2
  25. package/packages/shared/package.json +1 -1
  26. package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
  27. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
  28. package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
  29. package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
  30. package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
  31. package/packages/shared/src/platform/index.ts +1 -0
  32. package/packages/shared/src/platform/node-spawn.ts +154 -0
  33. package/packages/shared/src/protocol.ts +23 -0
  34. package/packages/shared/src/state-replay.ts +9 -0
  35. package/packages/shared/src/tool-registry/definitions.ts +92 -0
@@ -200,7 +200,18 @@ with `upgradeRecommended` / `upgradeDashboard` flags consumed by
200
200
  `BootstrapBanner`. Versions below `minimum` set a blocking `error`
201
201
  message that `session-api gateOrEnqueue` translates to 503 responses.
202
202
 
203
- See change: `unified-bootstrap-install`.
203
+ The pinned range is `minimum: "0.70.0"`, `recommended: "0.70.0"`,
204
+ `maximum: null` — deliberately in lockstep. The dashboard does NOT carry
205
+ backward-compatibility shims for older pi releases; one supported pi
206
+ means no conditional code paths in the bridge and no dual-import
207
+ fallbacks (e.g. `@sinclair/typebox` vs `typebox`). Bumping `recommended`
208
+ in a future change SHOULD be matched by an equal bump to `minimum` and a
209
+ lockstep bump of the offline-bundled pi version in
210
+ `packages/electron/offline-packages.json`.
211
+
212
+ The CLI also surfaces skew on stderr at startup: `cli.ts::logCompatibilityWarning` emits a three-line red block on below-minimum (including the exact `pi-dashboard upgrade-pi` remediation command) and a single advisory line on below-recommended. Silent when in range. This is in addition to the browser banner and the 503 gating, so terminal-only users (headless servers, CI) don't miss the signal. Note: `readCurrentPiVersion` uses `fs.realpathSync` on the registry-resolved bin path so the common npm-global symlink layout (`~/.nvm/.../bin/pi` → `../lib/node_modules/@mariozechner/pi-coding-agent/dist/cli.js`) resolves to the real `package.json` — without this, `compatibility.current` was silently `undefined` in every response.
213
+
214
+ See changes: `unified-bootstrap-install`, `pi-zero-seventy-compat`, `warn-pi-version-skew-in-cli`.
204
215
 
205
216
  ### Force Kill Escalation
206
217
  The Stop button supports two-click escalation for stuck sessions:
@@ -551,6 +562,18 @@ When a session sends `flows_list`, the server notifies other sessions in the sam
551
562
  ### Event Broadcast During Replay
552
563
  During bridge session replay (while `replayingSessions` set contains the session), `event_forward` messages are stored but NOT broadcast individually to browser subscribers. Instead, when `replay_complete` arrives (or the 5s safety timeout fires), the server sends all accumulated events as a single `event_replay` batch to subscribers. This prevents per-event serialization overhead during replay while still delivering the full history to browsers.
553
564
 
565
+ ### Per-message entry id stamping (live vs replay)
566
+
567
+ The per-message ⤘ Fork button needs each chat bubble to carry the entry id of the entry it represents in the persisted JSONL. Two paths populate this:
568
+
569
+ - **Replay path** (`packages/shared/src/state-replay.ts`): reads from the persisted JSONL directly, so each `message_start` / `message_end` event carries the stable `entryId` from the source entry. No back-fill needed.
570
+ - **Live path** (`packages/extension/src/bridge.ts`): pi 0.69+ awaits extension handlers BEFORE calling `sessionManager.appendMessage`, which means an entry id does NOT exist at the bridge's emit time. The bridge instead:
571
+ 1. Stamps a per-event `nonce` on `message_start` / `message_end` events so the client can correlate later.
572
+ 2. Defers the `message_end` SEND via `setTimeout(0)` (a macrotask) so pi's awaited dispatcher unwinds and `appendMessage` runs in between — by the time the timeout fires, pi has mutated `event.message.id` in place.
573
+ 3. Wraps `ctx.sessionManager.appendMessage` once per session at `session_start`. After a successful append, the wrapper emits an `entry_persisted { entryId, nonce }` event so the client reducer back-fills the matching ChatMessage's `entryId` (covers user messages, whose `message_start` fires before persistence).
574
+
575
+ `queueMicrotask` was used previously but no longer works: on pi 0.69+ the microtask resolves *inside* the awaited `_emitExtensionEvent`, before persistence. See change `fix-per-message-fork`.
576
+
554
577
  ## Persistence
555
578
 
556
579
  | Data | Storage | Details |
@@ -658,13 +681,16 @@ The restart endpoint accepts `{ dev: boolean }` to switch between dev/production
658
681
 
659
682
  ### Cross-Platform Server Launch
660
683
 
661
- The dashboard server is spawned via `node --import <loader> <cli.ts>` from three call sites (`packages/server/src/cli.ts` `cmdStart`, `packages/extension/src/server-launcher.ts` `launchServer`, `packages/electron/src/lib/server-lifecycle.ts` `launchServer`). On Node ≥ 20, Windows rejects raw absolute paths passed to `--import` because it parses the drive-letter prefix (e.g. `B:`) as a URL scheme (`ERR_UNSUPPORTED_ESM_URL_SCHEME`). Every resolver therefore returns a `file://` URL, not a raw path:
684
+ The dashboard server is spawned via `node --import <loader> <cli.ts>` from four call sites (`packages/server/src/cli.ts` `cmdStart`, `packages/extension/src/server-launcher.ts` `launchServer`, `packages/electron/src/lib/server-lifecycle.ts` `launchServer`, `packages/server/src/restart-helper.ts` `buildOrchestratorScript`). On Node ≥ 20, Windows's ESM loader parses **both** the `--import` loader position AND the entry-script position as URLs. A raw Windows path like `B:\Dev\cli.ts` parses with scheme `b:` (not in the ESM loader's `file`/`data`/`node` allowlist) and crashes with `ERR_UNSUPPORTED_ESM_URL_SCHEME`. Node has a drive-letter heuristic that auto-wraps common Windows paths with `file://` before the URL parse in the entry-script position, but the heuristic has known gaps for less-common drives (`A:`, `B:`, ...), so reliance on it is unsafe.
685
+
686
+ Both positions are wrapped as `file://` URLs universally:
662
687
 
663
- - `packages/shared/src/resolve-jiti.ts` — `resolveJitiImport()` (anchor = `process.argv[1]`) and `resolveJitiFromAnchor(anchorPath)` (anchor supplied explicitly) both return `pathToFileURL(registerPath).href`
664
- - `packages/electron/src/lib/server-lifecycle.ts` — `resolveJitiFromPi()` now imports `resolveJitiFromAnchor` from shared (previously duplicated; consolidated in the `consolidate-platform-handlers` change)
665
- - `packages/server/src/cli.ts` — the tsx fallback wraps `esm/index.mjs` the same way
688
+ - `packages/shared/src/platform/node-spawn.ts` — `toFileUrl(pathOrUrl)` (idempotent path → file:// URL, handles Windows drive letters on POSIX hosts) and `spawnNodeScript(opts)` (wraps both loader and entry before delegating to `platform/exec.ts::spawn`). This is the canonical chokepoint.
689
+ - `packages/shared/src/resolve-jiti.ts` — `resolveJitiImport()` and `resolveJitiFromAnchor(anchorPath)` return `pathToFileURL(registerPath).href` for the loader position.
690
+ - `packages/server/src/cli.ts` — routes through `spawnNodeScript`.
691
+ - `packages/extension/src/server-launcher.ts`, `packages/electron/src/lib/server-lifecycle.ts`, `packages/server/src/restart-helper.ts` — wrap the entry `cliPath` with `toFileUrl(cliPath)` before argv construction.
666
692
 
667
- The URL form is cross-platform safe (Linux/macOS accept both raw paths and `file://` URLs) so no platform gating is needed in the resolvers.
693
+ The URL form is cross-platform safe (Linux/macOS accept `file://` URLs identically to raw paths), so no platform gating is needed. A repo-level lint test (`packages/shared/src/__tests__/no-raw-node-import.test.ts`) refuses any new call site that passes a raw identifier as argv after `--import` / `--loader`, preventing regression. Mirrors the `platform/exec.ts` + `no-direct-child-process.test.ts` pattern. See changes: `fix-windows-server-parity` (loader position), `fix-windows-entry-script-url` (entry-script position).
668
694
 
669
695
  #### stdout + stderr capture parity
670
696
 
@@ -798,9 +824,21 @@ The dashboard uses mDNS (via `bonjour-service`) for zero-config server discovery
798
824
  ### Server Selector UI
799
825
  - The header dropdown shows persisted known servers (from config) plus localhost, not raw mDNS results
800
826
  - Each entry shows label (or hostname), host:port, Local/Remote badge, and availability status
801
- - Non-current servers are probed via health check when the dropdown opens
802
- - Switching closes the current WebSocket and connects to the selected server
803
- - Last-used server persisted in `localStorage` (`pi-dashboard-last-server`)
827
+ - **Probe lifecycle**: availability is probed via `/api/health` **only when the dropdown opens** — once per open. No mount probe, no timer, no probing while the dropdown is closed. Current-server status is derived from the live WebSocket state, not a separate probe.
828
+ - **Unreachable entries** are rendered with `opacity-50`, `cursor-not-allowed`, and the `disabled` attribute set; clicks are no-ops. To re-probe, close and reopen the dropdown. The transactional switch (below) still protects against races between the last probe and a click on a reachable entry.
829
+ - Last-used server persisted in `localStorage` (`pi-dashboard-last-server`) — **only after** a successful switch (see transactional switching below).
830
+
831
+ ### Transactional Server Switching
832
+ Switching servers is a two-phase transaction that never destructs state before verifying the target is reachable. Implemented by `performServerSwitch` (`packages/client/src/lib/server-switch.ts`) + `openStagingSocket` (`packages/client/src/lib/staging-socket.ts`):
833
+
834
+ 1. **Stage**: open a second ("staging") WebSocket to the target URL with a 5-second timeout. The live WebSocket stays connected.
835
+ 2. **Commit (on staging `OPEN`)**: close the staging socket, clear in-memory session/command/flow/openspec/terminal state, call `setWsUrl(newUrl)` so `useWebSocket` reconnects, and **only then** write `localStorage["pi-dashboard-last-server"]`.
836
+ 3. **Abort (on staging error/timeout)**: close the staging socket, show a toast "Couldn't reach &lt;host&gt;", leave the live connection and state untouched. localStorage is not written — so a subsequent refresh still recovers the last-known-good server.
837
+
838
+ An `inFlightSwitchKey` ref guards against duplicate clicks; the clicked dropdown entry renders a spinner while staging is in progress. The `POST /api/config { lastServer }` fire-and-forget call was removed as dead weight (no consumer read the field).
839
+
840
+ ### Connection Status Banner
841
+ `ConnectionStatusBanner` (`packages/client/src/components/ConnectionStatusBanner.tsx`) mounts above `<MobileShell>`. It shows "Disconnected from &lt;host&gt;. Retrying…" when the active WebSocket has been non-`OPEN` for more than 3 seconds continuously. The threshold is implemented via `setTimeout` cleared on any return-to-`OPEN` or unmount, so brief reconnects (laptop sleep, wifi hiccup) never flash the banner. During an in-flight staging switch the banner is suppressed — the live socket is still open, so no disconnection has actually occurred.
804
842
 
805
843
  ### Server Management (Settings Panel)
806
844
  - **Known Servers section**: lists persisted servers with remove buttons and an inline add form (host, port, label)
@@ -1022,6 +1060,22 @@ Every external binary, module, and directory the dashboard depends on is resolve
1022
1060
  | `pi-coding-agent` | module | override → bare-import → managed (`MANAGED_DIR/node_modules/.../dist/index.js`) → npm-global; probes both `@mariozechner/*` and `@oh-my-pi/*` aliases |
1023
1061
  | `openspec`, `npm`, `node`, `tsx`, `git`, `zrok` | binary | override → managed → where |
1024
1062
  | `pi-dashboard` | module | override → managed → npm-global (presence of `package.json` is enough) |
1063
+ | `electron` | module | override → bare-import (`paths: ["packages/electron"]`) → managed; resolves the package directory containing `install.js`, hoist-aware. See change: register-build-time-tools |
1064
+ | `node-pty` | module | override → bare-import; resolves the package directory containing `prebuilds/`. See change: register-build-time-tools |
1065
+
1066
+ ### Build-time consumers (shell-callable wrapper)
1067
+
1068
+ CI workflows, Dockerfiles, and root-level postinstall scripts cannot import the shared package's TypeScript directly — those run before any TS build has fired (or, for postinstall, before the shared package is even unpacked). For these consumers, `packages/shared/bin/pi-dashboard-resolve-tool.cjs` provides a CommonJS, dependency-free shell wrapper that mirrors the registry's `override` + `bare-import` strategies for the build-time tool subset (`electron`, `node-pty`):
1069
+
1070
+ ```bash
1071
+ # Resolve a build-time tool from any shell context
1072
+ ELECTRON_DIR=$(node packages/shared/bin/pi-dashboard-resolve-tool.cjs electron)
1073
+ cd "$ELECTRON_DIR" && node install.js
1074
+ ```
1075
+
1076
+ The wrapper is used by `.github/workflows/publish.yml` (linux/arm64 native rebuild) and `packages/electron/scripts/Dockerfile.build` (Docker cross-platform native rebuild). The root postinstall `scripts/fix-pty-permissions.cjs` reimplements the same `bare-import` semantics inline (it cannot shell out because it runs DURING `npm install`).
1077
+
1078
+ Reintroduction of hardcoded `node_modules/<dep>` paths in any of these sites is blocked by the lint test at `packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts`.
1025
1079
 
1026
1080
  ### Resolution record
1027
1081
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-agent-dashboard",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Web dashboard for monitoring and interacting with pi agent sessions",
5
5
  "repository": {
6
6
  "type": "git",
@@ -66,9 +66,9 @@
66
66
  "screenshots": "npm --prefix site run screenshots"
67
67
  },
68
68
  "dependencies": {
69
- "@blackbelt-technology/pi-dashboard-extension": "^0.4.0",
70
- "@blackbelt-technology/pi-dashboard-server": "^0.4.0",
71
- "@blackbelt-technology/pi-dashboard-web": "^0.4.0"
69
+ "@blackbelt-technology/pi-dashboard-extension": "^0.4.1",
70
+ "@blackbelt-technology/pi-dashboard-server": "^0.4.1",
71
+ "@blackbelt-technology/pi-dashboard-web": "^0.4.1"
72
72
  },
73
73
  "devDependencies": {
74
74
  "jsdom": "^29.0.2",
@@ -83,7 +83,7 @@
83
83
  "@oh-my-pi/pi-ai": "*",
84
84
  "@oh-my-pi/pi-coding-agent": "*",
85
85
  "@oh-my-pi/pi-tui": "*",
86
- "@sinclair/typebox": "*"
86
+ "typebox": "*"
87
87
  },
88
88
  "peerDependenciesMeta": {
89
89
  "@mariozechner/pi-coding-agent": {
@@ -103,6 +103,9 @@
103
103
  },
104
104
  "@oh-my-pi/pi-tui": {
105
105
  "optional": true
106
+ },
107
+ "typebox": {
108
+ "optional": true
106
109
  }
107
110
  }
108
111
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-dashboard-extension",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Pi bridge extension for pi-dashboard",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -19,12 +19,13 @@
19
19
  ".pi/skills/pi-dashboard/"
20
20
  ],
21
21
  "dependencies": {
22
- "@blackbelt-technology/pi-dashboard-shared": "^0.4.0",
22
+ "@blackbelt-technology/pi-dashboard-shared": "^0.4.1",
23
23
  "ws": "^8.18.0"
24
24
  },
25
25
  "peerDependencies": {
26
26
  "@mariozechner/pi-coding-agent": "*",
27
- "@mariozechner/pi-tui": "*"
27
+ "@mariozechner/pi-tui": "*",
28
+ "typebox": "*"
28
29
  },
29
30
  "peerDependenciesMeta": {
30
31
  "@mariozechner/pi-coding-agent": {
@@ -36,6 +37,7 @@
36
37
  },
37
38
  "devDependencies": {
38
39
  "@mariozechner/pi-tui": "*",
39
- "@types/ws": "^8.18.1"
40
+ "@types/ws": "^8.18.1",
41
+ "typebox": "^1.1.33"
40
42
  }
41
43
  }
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, vi } from "vitest";
2
2
 
3
3
  // Mock modules before importing
4
- vi.mock("@sinclair/typebox", () => ({
4
+ vi.mock("typebox", () => ({
5
5
  Type: {
6
6
  Object: vi.fn(() => ({})),
7
7
  String: vi.fn(() => ({})),
@@ -44,20 +44,37 @@ describe("registerAskUserTool", () => {
44
44
  expect(tool.promptGuidelines.length).toBeGreaterThan(0);
45
45
  });
46
46
 
47
+ it("description instructs agents not to add a Select all option", () => {
48
+ const pi = createMockPi();
49
+ registerAskUserTool(pi as any);
50
+ const tool = pi.registerTool.mock.calls[0][0];
51
+ expect(tool.description).toMatch(/UI provides a Select all/i);
52
+ });
53
+
47
54
  describe("message passthrough", () => {
48
55
  function getToolAndMockCtx() {
49
56
  const pi = createMockPi();
50
57
  registerAskUserTool(pi as any);
51
58
  const tool = pi.registerTool.mock.calls[0][0];
59
+ // `custom` stands in for the multiselect polyfill: it invokes the factory
60
+ // with a `done` callback; the factory-returned component exposes
61
+ // onConfirm/onCancel. We auto-confirm with ["A"] to preserve the legacy
62
+ // mock return value that the multiselect assertions expected.
63
+ const custom = vi.fn().mockImplementation(async (factory: any) => {
64
+ return await new Promise<unknown>((resolve) => {
65
+ const component: any = factory({}, {}, {}, (r: unknown) => resolve(r));
66
+ component?.onConfirm?.(["A"]);
67
+ });
68
+ });
52
69
  const ctx = {
53
70
  ui: {
54
71
  confirm: vi.fn().mockResolvedValue(true),
55
72
  select: vi.fn().mockResolvedValue("A"),
56
73
  input: vi.fn().mockResolvedValue("hello"),
57
- multiselect: vi.fn().mockResolvedValue(["A"]),
74
+ custom,
58
75
  },
59
76
  };
60
- return { tool, ctx };
77
+ return { tool, ctx, custom };
61
78
  }
62
79
 
63
80
  it("passes message through opts for input", async () => {
@@ -72,10 +89,19 @@ describe("registerAskUserTool", () => {
72
89
  expect(ctx.ui.select).toHaveBeenCalledWith("Pick", ["A", "B"], { message: "Context" });
73
90
  });
74
91
 
75
- it("passes message through opts for multiselect", async () => {
92
+ it("dispatches multiselect through the polyfill via ctx.ui.custom", async () => {
76
93
  const { tool, ctx } = getToolAndMockCtx();
77
- await tool.execute("id", { method: "multiselect", title: "Multi", message: "Info", options: ["A"] }, undefined, undefined, ctx);
78
- expect(ctx.ui.multiselect).toHaveBeenCalledWith("Multi", ["A"], { message: "Info" });
94
+ const result = await tool.execute(
95
+ "id",
96
+ { method: "multiselect", title: "Multi", message: "Info", options: ["A"] },
97
+ undefined,
98
+ undefined,
99
+ ctx,
100
+ );
101
+ // Polyfill routes via custom(factory); multiselect is not called directly.
102
+ expect(ctx.ui.custom).toHaveBeenCalledTimes(1);
103
+ expect(result.details.method).toBe("multiselect");
104
+ expect(result.details.result).toEqual(["A"]);
79
105
  });
80
106
 
81
107
  it("does not pass opts when message is undefined", async () => {
@@ -123,7 +149,7 @@ describe("registerAskUserTool", () => {
123
149
  await expect(
124
150
  tool.execute("id", { method: "multiselect", title: "Pick" }, undefined, undefined, ctx),
125
151
  ).rejects.toThrow(/options/i);
126
- expect(ctx.ui.multiselect).not.toHaveBeenCalled();
152
+ expect(ctx.ui.custom).not.toHaveBeenCalled();
127
153
  });
128
154
  });
129
155
 
@@ -311,12 +337,18 @@ describe("registerAskUserTool", () => {
311
337
  const pi = createMockPi();
312
338
  registerAskUserTool(pi as any);
313
339
  const tool = pi.registerTool.mock.calls[0][0];
340
+ const custom = vi.fn().mockImplementation(async (factory: any) => {
341
+ return await new Promise<unknown>((resolve) => {
342
+ const component: any = factory({}, {}, {}, (r: unknown) => resolve(r));
343
+ component?.onConfirm?.(["A"]);
344
+ });
345
+ });
314
346
  const ctx = {
315
347
  ui: {
316
348
  confirm: vi.fn().mockResolvedValue(true),
317
349
  select: vi.fn().mockResolvedValue("A"),
318
350
  input: vi.fn().mockResolvedValue("hello"),
319
- multiselect: vi.fn().mockResolvedValue(["A"]),
351
+ custom,
320
352
  },
321
353
  };
322
354
  return { tool, ctx };
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Tests for the bridge entryId stamping under pi 0.70.x's emit-then-await-then-append
3
+ * ordering. Pi 0.70.x's _processAgentEvent does (paraphrased):
4
+ *
5
+ * await this._emitExtensionEvent(event); // <-- bridge runs here, awaited
6
+ * this._emit(event); // sync legacy listeners
7
+ * if (event.type === "message_end") {
8
+ * sessionManager.appendMessage(event.message); // <-- entry id GENERATED HERE
9
+ * }
10
+ *
11
+ * The bridge's old `queueMicrotask` deferral resolves INSIDE the awaited dispatcher,
12
+ * before appendMessage runs — so getLeafId() still returns the previous leaf. The fix
13
+ * is `setTimeout(0)` (macrotask) so the entire await chain unwinds and appendMessage
14
+ * runs first; OR reading `event.message.id` after pi mutates it in-place.
15
+ *
16
+ * This test simulates that ordering and asserts the correct mechanisms.
17
+ */
18
+ import { describe, it, expect } from "vitest";
19
+
20
+ interface SimMessage {
21
+ role: string;
22
+ content: string;
23
+ id?: string;
24
+ }
25
+
26
+ /**
27
+ * Simulate pi 0.70.x's _processAgentEvent ordering. Returns a promise that
28
+ * resolves when the entire event has been processed (including appendMessage).
29
+ *
30
+ * The `bridgeHandler` is registered as an "extension handler" — runs awaited
31
+ * inside _emitExtensionEvent. It receives the event and a pseudo-ctx with
32
+ * sessionManager.getLeafId().
33
+ */
34
+ async function simulatePi070Emit(opts: {
35
+ event: { type: string; message: SimMessage };
36
+ state: { leafId: string; nextId: string };
37
+ appendMessage: (msg: SimMessage) => string; // returns the new id
38
+ bridgeHandler: (event: any, ctx: any) => Promise<void> | void;
39
+ }): Promise<void> {
40
+ const ctx = {
41
+ sessionManager: { getLeafId: () => opts.state.leafId },
42
+ };
43
+
44
+ // Step 1: await _emitExtensionEvent — runs the bridge handler awaited.
45
+ await opts.bridgeHandler(opts.event, ctx);
46
+
47
+ // Step 2: _emit (sync legacy listeners) — no-op in this simulation.
48
+
49
+ // Step 3: persistence on message_end.
50
+ if (opts.event.type === "message_end") {
51
+ const id = opts.appendMessage(opts.event.message);
52
+ opts.state.leafId = id;
53
+ }
54
+ }
55
+
56
+ describe("pi 0.70 emit/append ordering", () => {
57
+ it("queueMicrotask deferral DOES NOT capture the post-persist id (the bug)", async () => {
58
+ const state = { leafId: "prev", nextId: "new-id-42" };
59
+ let captured: string | undefined;
60
+
61
+ const buggyBridge = async (event: any, ctx: any) => {
62
+ // What the OLD bridge does today:
63
+ await new Promise<void>((resolve) => queueMicrotask(resolve));
64
+ captured = ctx.sessionManager.getLeafId();
65
+ };
66
+
67
+ await simulatePi070Emit({
68
+ event: { type: "message_end", message: { role: "assistant", content: "hi" } },
69
+ state,
70
+ appendMessage: (m) => {
71
+ m.id = state.nextId;
72
+ return state.nextId;
73
+ },
74
+ bridgeHandler: buggyBridge,
75
+ });
76
+
77
+ // Bug: captured is the previous leaf, NOT the just-appended id.
78
+ expect(captured).toBe("prev");
79
+ expect(captured).not.toBe("new-id-42");
80
+ });
81
+
82
+ it("setTimeout(0) deferral DOES capture the post-persist id (the fix)", async () => {
83
+ const state = { leafId: "prev", nextId: "new-id-42" };
84
+ let capturedFromGetLeafId: string | undefined;
85
+ let capturedFromMessageId: string | undefined;
86
+ let sendDone!: () => void;
87
+ const sendCompleted = new Promise<void>((r) => { sendDone = r; });
88
+
89
+ // The bridge schedules and returns synchronously — the only way the
90
+ // awaited dispatcher can unwind so appendMessage runs before the timeout.
91
+ const fixedBridge = (event: any, ctx: any) => {
92
+ setTimeout(() => {
93
+ capturedFromMessageId = (event.message as any).id;
94
+ capturedFromGetLeafId = ctx.sessionManager.getLeafId();
95
+ sendDone();
96
+ }, 0);
97
+ };
98
+
99
+ await simulatePi070Emit({
100
+ event: { type: "message_end", message: { role: "assistant", content: "hi" } },
101
+ state,
102
+ appendMessage: (m) => {
103
+ m.id = state.nextId;
104
+ return state.nextId;
105
+ },
106
+ bridgeHandler: fixedBridge,
107
+ });
108
+ await sendCompleted;
109
+
110
+ // Both signals should now point at the just-persisted entry.
111
+ expect(capturedFromMessageId).toBe("new-id-42");
112
+ expect(capturedFromGetLeafId).toBe("new-id-42");
113
+ });
114
+
115
+ it("WeakMap-on-appendMessage captures the id even before the macrotask", async () => {
116
+ const state = { leafId: "prev", nextId: "new-id-77" };
117
+ const idByMessage = new WeakMap<object, string>();
118
+ const wrappedAppend = (m: SimMessage): string => {
119
+ m.id = state.nextId;
120
+ idByMessage.set(m as object, m.id);
121
+ return m.id;
122
+ };
123
+
124
+ let viaWeakMap: string | undefined;
125
+ let viaMutation: string | undefined;
126
+ let sendDone!: () => void;
127
+ const sentP = new Promise<void>((r) => { sendDone = r; });
128
+
129
+ // CRITICAL: bridge SCHEDULES the send and RETURNS IMMEDIATELY.
130
+ // It does NOT await its own setTimeout — that would keep the
131
+ // outer dispatcher awaiting and we'd be back to the queueMicrotask
132
+ // bug (timeout fires before appendMessage).
133
+ const fixedBridge = (event: any) => {
134
+ setTimeout(() => {
135
+ viaMutation = (event.message as any).id;
136
+ viaWeakMap = idByMessage.get(event.message as object);
137
+ sendDone();
138
+ }, 0);
139
+ // Return synchronously — let the awaited dispatcher unwind.
140
+ };
141
+
142
+ await simulatePi070Emit({
143
+ event: { type: "message_end", message: { role: "assistant", content: "hi" } },
144
+ state,
145
+ appendMessage: wrappedAppend,
146
+ bridgeHandler: fixedBridge,
147
+ });
148
+ await sentP;
149
+
150
+ expect(viaMutation).toBe("new-id-77");
151
+ expect(viaWeakMap).toBe("new-id-77");
152
+ });
153
+
154
+ it("user message_start has NO id (pi defers user persistence to message_end)", async () => {
155
+ const state = { leafId: "prev-assistant", nextId: "new-user-id" };
156
+ let captured: string | undefined;
157
+
158
+ const fixedBridge = async (event: any) => {
159
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
160
+ captured = (event.message as any).id; // still undefined for message_start
161
+ };
162
+
163
+ await simulatePi070Emit({
164
+ event: { type: "message_start", message: { role: "user", content: "hello" } },
165
+ state,
166
+ appendMessage: () => state.nextId, // not called for message_start
167
+ bridgeHandler: fixedBridge,
168
+ });
169
+
170
+ // No id available at message_start time — must rely on entry_persisted
171
+ // back-fill (delivered when the message_end of the SAME message fires later).
172
+ expect(captured).toBeUndefined();
173
+ });
174
+ });
@@ -74,6 +74,36 @@ describe("mapEventToProtocol", () => {
74
74
  expect(result.event.data).toEqual(piEvent);
75
75
  });
76
76
 
77
+ it("should map an entry_persisted event (per fix-per-message-fork)", () => {
78
+ const piEvent = {
79
+ type: "entry_persisted",
80
+ entryId: "abc-123",
81
+ nonce: "n-7",
82
+ };
83
+ const result = mapEventToProtocol(sessionId, piEvent);
84
+ expect(result.type).toBe("event_forward");
85
+ expect(result.sessionId).toBe(sessionId);
86
+ expect(result.event.eventType).toBe("entry_persisted");
87
+ expect(result.event.data).toMatchObject({
88
+ type: "entry_persisted",
89
+ entryId: "abc-123",
90
+ nonce: "n-7",
91
+ });
92
+ });
93
+
94
+ it("should map a message_end event with nonce (per fix-per-message-fork)", () => {
95
+ const piEvent = {
96
+ type: "message_end",
97
+ message: { role: "assistant", content: "hi", id: "asst-9" },
98
+ entryId: "asst-9",
99
+ nonce: "n-7",
100
+ };
101
+ const result = mapEventToProtocol(sessionId, piEvent);
102
+ expect(result.event.eventType).toBe("message_end");
103
+ expect((result.event.data as any).nonce).toBe("n-7");
104
+ expect((result.event.data as any).entryId).toBe("asst-9");
105
+ });
106
+
77
107
  it("should strip non-serializable fields", () => {
78
108
  const piEvent = {
79
109
  type: "test_event",