@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.
- package/AGENTS.md +30 -8
- package/README.md +386 -494
- package/docs/architecture.md +63 -9
- package/package.json +8 -5
- package/packages/extension/package.json +6 -4
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +40 -8
- package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
- package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
- package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
- package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
- package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
- package/packages/extension/src/ask-user-tool.ts +5 -4
- package/packages/extension/src/bridge.ts +102 -15
- package/packages/extension/src/multiselect-list.ts +146 -0
- package/packages/extension/src/multiselect-polyfill.ts +43 -0
- package/packages/extension/src/server-launcher.ts +15 -3
- package/packages/server/package.json +5 -5
- package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
- package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
- package/packages/server/src/__tests__/pi-version-skew.test.ts +72 -0
- package/packages/server/src/__tests__/restart-helper.test.ts +34 -6
- package/packages/server/src/cli.ts +56 -9
- package/packages/server/src/pi-version-skew.ts +12 -1
- package/packages/server/src/restart-helper.ts +13 -2
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
- package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
- package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
- package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
- package/packages/shared/src/platform/index.ts +1 -0
- package/packages/shared/src/platform/node-spawn.ts +154 -0
- package/packages/shared/src/protocol.ts +23 -0
- package/packages/shared/src/state-replay.ts +9 -0
- package/packages/shared/src/tool-registry/definitions.ts +92 -0
package/docs/architecture.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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/
|
|
664
|
-
- `packages/
|
|
665
|
-
- `packages/server/src/cli.ts` —
|
|
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
|
|
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
|
-
-
|
|
802
|
-
-
|
|
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 <host>", 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 <host>. 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.
|
|
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.
|
|
70
|
-
"@blackbelt-technology/pi-dashboard-server": "^0.4.
|
|
71
|
-
"@blackbelt-technology/pi-dashboard-web": "^0.4.
|
|
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
|
-
"
|
|
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.
|
|
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.
|
|
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("
|
|
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
|
-
|
|
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("
|
|
92
|
+
it("dispatches multiselect through the polyfill via ctx.ui.custom", async () => {
|
|
76
93
|
const { tool, ctx } = getToolAndMockCtx();
|
|
77
|
-
await tool.execute(
|
|
78
|
-
|
|
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.
|
|
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
|
-
|
|
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",
|