@blackbelt-technology/pi-agent-dashboard 0.5.0 → 0.5.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/README.md +19 -7
- package/package.json +13 -13
- package/packages/extension/package.json +11 -3
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
- package/packages/extension/src/__tests__/command-handler.test.ts +68 -0
- package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
- package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
- package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
- package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
- package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
- package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
- package/packages/extension/src/ask-user-tool.ts +1 -1
- package/packages/extension/src/bridge-context.ts +1 -1
- package/packages/extension/src/bridge.ts +59 -3
- package/packages/extension/src/command-handler.ts +59 -2
- package/packages/extension/src/flow-event-wiring.ts +1 -1
- package/packages/extension/src/multiselect-list.ts +1 -1
- package/packages/extension/src/pi-env.d.ts +16 -9
- package/packages/extension/src/provider-register.ts +16 -9
- package/packages/extension/src/retry-tracker.ts +123 -0
- package/packages/extension/src/session-sync.ts +10 -1
- package/packages/extension/src/usage-limit-orderer.ts +76 -0
- package/packages/server/package.json +6 -6
- package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
- package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
- package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +22 -4
- package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service.test.ts +1 -1
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +68 -1
- package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
- package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +83 -0
- package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
- package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
- package/packages/server/src/__tests__/package-routes.test.ts +1 -1
- package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
- package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
- package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
- package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +62 -3
- package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
- package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
- package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
- package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +13 -23
- package/packages/server/src/__tests__/recommended-routes.test.ts +1 -1
- package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +84 -0
- package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
- package/packages/server/src/browser-gateway.ts +12 -3
- package/packages/server/src/browser-handlers/handler-context.ts +9 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +100 -17
- package/packages/server/src/changelog-fs.ts +167 -0
- package/packages/server/src/changelog-parser.ts +321 -0
- package/packages/server/src/changelog-remote.ts +134 -0
- package/packages/server/src/cli.ts +2 -2
- package/packages/server/src/event-wiring.ts +59 -5
- package/packages/server/src/headless-pid-registry.ts +54 -5
- package/packages/server/src/pending-client-correlations.ts +73 -0
- package/packages/server/src/pending-fork-registry.ts +24 -12
- package/packages/server/src/pi-core-checker.ts +77 -17
- package/packages/server/src/pi-core-updater.ts +16 -6
- package/packages/server/src/pi-dev-version-check.ts +145 -0
- package/packages/server/src/pi-gateway.ts +4 -0
- package/packages/server/src/pi-version-skew.ts +12 -4
- package/packages/server/src/process-manager.ts +54 -11
- package/packages/server/src/provider-catalogue-cache.ts +24 -18
- package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
- package/packages/server/src/routes/pi-core-routes.ts +1 -1
- package/packages/server/src/routes/provider-auth-routes.ts +5 -1
- package/packages/server/src/routes/provider-routes.ts +4 -4
- package/packages/server/src/server.ts +77 -59
- package/packages/server/src/session-api.ts +54 -3
- package/packages/server/src/session-discovery.ts +1 -1
- package/packages/server/src/session-file-reader.ts +1 -1
- package/packages/server/src/spawn-register-watchdog.ts +62 -7
- package/packages/server/src/spawn-token.ts +20 -0
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +24 -17
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +5 -3
- package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
- package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
- package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +140 -9
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
- package/packages/shared/src/bootstrap-install.ts +1 -1
- package/packages/shared/src/browser-protocol.ts +43 -0
- package/packages/shared/src/changelog-types.ts +111 -0
- package/packages/shared/src/platform/node-spawn.ts +29 -21
- package/packages/shared/src/protocol.ts +8 -0
- package/packages/shared/src/resolve-jiti.ts +62 -9
- package/packages/shared/src/skill-block-parser.ts +1 -1
- package/packages/shared/src/tool-registry/definitions.ts +15 -3
- package/packages/shared/src/types.ts +7 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/@blackbelt-technology/pi-agent-dashboard)
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
**One browser tab to command an army of [pi](https://github.com/badlogic/pi-mono) agents.** Spawn parallel sessions, watch reasoning live, attach OpenSpec changes, ship work — from your laptop or phone.
|
|
8
8
|
|
|
9
9
|
🌐 **Website & demo:** [blackbelttechnology.github.io/pi-agent-dashboard](https://blackbelttechnology.github.io/pi-agent-dashboard) — animated tour, screenshots, and install guide.
|
|
10
10
|
📝 **Changelog:** [`CHANGELOG.md`](CHANGELOG.md)
|
|
@@ -13,6 +13,18 @@ A web-based dashboard for monitoring and interacting with [pi](https://github.co
|
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
+
## Screenshots
|
|
17
|
+
|
|
18
|
+
<table>
|
|
19
|
+
<tr>
|
|
20
|
+
<td width="33%" align="center"><a href="docs/screenshots/readme-overview.png"><img src="docs/screenshots/readme-overview.png" alt="Sessions overview — folders, branches, OpenSpec changes, live token spend" /></a><br/><sub><b>Overview</b> — sessions grouped by folder, branch & OpenSpec context, live cost</sub></td>
|
|
21
|
+
<td width="33%" align="center"><a href="docs/screenshots/readme-session.png"><img src="docs/screenshots/readme-session.png" alt="Active session — chat, attached OpenSpec change, ask_user prompt, token gauge" /></a><br/><sub><b>Session</b> — chat, OpenSpec apply, interactive <code>ask_user</code>, context gauge</sub></td>
|
|
22
|
+
<td width="33%" align="center"><a href="docs/screenshots/readme-settings.png"><img src="docs/screenshots/readme-settings.png" alt="Settings — ports, spawn strategy, tunnel, resolved tools table" /></a><br/><sub><b>Settings</b> — ports, spawn strategy, zrok tunnel, tool resolution</sub></td>
|
|
23
|
+
</tr>
|
|
24
|
+
</table>
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
16
28
|
## Table of contents
|
|
17
29
|
|
|
18
30
|
- [Quickstart](#quickstart)
|
|
@@ -44,7 +56,7 @@ Download a pre-built installer from [GitHub Releases](https://github.com/BlackBe
|
|
|
44
56
|
|----------|----------|
|
|
45
57
|
| **macOS** (Apple Silicon / Intel) | `.dmg` (arm64 / x64) |
|
|
46
58
|
| **Linux** (x64 / ARM64) | `.deb` or `.AppImage` |
|
|
47
|
-
| **Windows** (x64 / ARM64) | `.
|
|
59
|
+
| **Windows** (x64 / ARM64) | `.zip` |
|
|
48
60
|
|
|
49
61
|
On first launch a setup wizard walks you through mode selection (standalone vs. power-user), API key / OAuth sign-in, and [recommended extensions](#recommended-extensions). The standalone mode bundles Node.js and auto-installs pi + dashboard + openspec into `~/.pi-dashboard/` — **no terminal, npm, or Node.js required**.
|
|
50
62
|
|
|
@@ -640,7 +652,7 @@ Output by platform:
|
|
|
640
652
|
|----------|--------|----------|
|
|
641
653
|
| macOS | `.dmg` | `packages/electron/out/make/` |
|
|
642
654
|
| Linux | `.deb` + `.AppImage` | `packages/electron/out/make/` |
|
|
643
|
-
| Windows | `.
|
|
655
|
+
| Windows | `.zip` | `packages/electron/out/make/` |
|
|
644
656
|
|
|
645
657
|
### Cross-platform builds (Docker)
|
|
646
658
|
|
|
@@ -649,7 +661,7 @@ From macOS or Linux, build installers for all platforms:
|
|
|
649
661
|
```bash
|
|
650
662
|
npm run electron:build -- --all # macOS (native) + Linux + Windows (Docker)
|
|
651
663
|
npm run electron:build -- --linux # Linux .deb + .AppImage only
|
|
652
|
-
npm run electron:build -- --windows # Windows .
|
|
664
|
+
npm run electron:build -- --windows # Windows .zip only
|
|
653
665
|
npm run electron:build -- --linux --windows # Both, skip native
|
|
654
666
|
```
|
|
655
667
|
|
|
@@ -663,7 +675,7 @@ npm run electron:build -- --mac-both
|
|
|
663
675
|
|
|
664
676
|
Requires Rosetta 2 (`softwareupdate --install-rosetta --agree-to-license`) so node-pty's x64 prebuilt binary can be unpacked during the cross-arch run. The script wipes per-arch caches between the two builds (`resources/.last-arch` sentinel) so back-to-back runs don't accidentally ship arm64 binaries inside an x64 DMG. Intel macs cannot cross-build arm64 locally (Rosetta is one-way) — use CI for arm64 validation.
|
|
665
677
|
|
|
666
|
-
Docker builds use a Node 22 Debian container
|
|
678
|
+
Docker builds use a Node 22 Debian container for Windows cross-compilation. Output goes to `packages/electron/out/make/`.
|
|
667
679
|
|
|
668
680
|
### Electron dev mode
|
|
669
681
|
|
|
@@ -718,8 +730,8 @@ This runs CI, publishes to npm with `--provenance` for supply-chain transparency
|
|
|
718
730
|
| `macos-15-intel` | macOS x64 | `.dmg` (Intel; last GitHub-hosted x86_64 image, EOL 2027-08) |
|
|
719
731
|
| `ubuntu-latest` | Linux x64 | `.deb` + `.AppImage` |
|
|
720
732
|
| `ubuntu-24.04-arm` | Linux arm64 | `.deb` |
|
|
721
|
-
| `windows-latest` | Windows x64 | `.
|
|
722
|
-
| `windows-latest` | Windows arm64 | `.zip`
|
|
733
|
+
| `windows-latest` | Windows x64 | `.zip` |
|
|
734
|
+
| `windows-latest` | Windows arm64 | `.zip` (x64 Node.js via WoW64) |
|
|
723
735
|
|
|
724
736
|
All artifacts are uploaded to a **draft GitHub Release**. Release notes are extracted automatically from the matching `## [<version>]` section of [`CHANGELOG.md`](CHANGELOG.md).
|
|
725
737
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blackbelt-technology/pi-agent-dashboard",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Web dashboard for monitoring and interacting with pi agent sessions",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -73,9 +73,9 @@
|
|
|
73
73
|
"node": ">=22.12.0 <25"
|
|
74
74
|
},
|
|
75
75
|
"dependencies": {
|
|
76
|
-
"@blackbelt-technology/pi-dashboard-extension": "^0.5.
|
|
77
|
-
"@blackbelt-technology/pi-dashboard-server": "^0.5.
|
|
78
|
-
"@blackbelt-technology/pi-dashboard-web": "^0.5.
|
|
76
|
+
"@blackbelt-technology/pi-dashboard-extension": "^0.5.1",
|
|
77
|
+
"@blackbelt-technology/pi-dashboard-server": "^0.5.1",
|
|
78
|
+
"@blackbelt-technology/pi-dashboard-web": "^0.5.1"
|
|
79
79
|
},
|
|
80
80
|
"optionalDependencies": {
|
|
81
81
|
"appdmg": "^0.6.6"
|
|
@@ -87,31 +87,31 @@
|
|
|
87
87
|
"vitest": "^4.0.0"
|
|
88
88
|
},
|
|
89
89
|
"peerDependencies": {
|
|
90
|
+
"@earendil-works/pi-ai": "*",
|
|
91
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
92
|
+
"@earendil-works/pi-tui": "*",
|
|
90
93
|
"@mariozechner/pi-ai": "*",
|
|
91
94
|
"@mariozechner/pi-coding-agent": "*",
|
|
92
95
|
"@mariozechner/pi-tui": "*",
|
|
93
|
-
"@oh-my-pi/pi-ai": "*",
|
|
94
|
-
"@oh-my-pi/pi-coding-agent": "*",
|
|
95
|
-
"@oh-my-pi/pi-tui": "*",
|
|
96
96
|
"typebox": "*"
|
|
97
97
|
},
|
|
98
98
|
"peerDependenciesMeta": {
|
|
99
|
-
"@
|
|
99
|
+
"@earendil-works/pi-coding-agent": {
|
|
100
100
|
"optional": true
|
|
101
101
|
},
|
|
102
|
-
"@
|
|
102
|
+
"@earendil-works/pi-ai": {
|
|
103
103
|
"optional": true
|
|
104
104
|
},
|
|
105
|
-
"@
|
|
105
|
+
"@earendil-works/pi-tui": {
|
|
106
106
|
"optional": true
|
|
107
107
|
},
|
|
108
|
-
"@
|
|
108
|
+
"@mariozechner/pi-coding-agent": {
|
|
109
109
|
"optional": true
|
|
110
110
|
},
|
|
111
|
-
"@
|
|
111
|
+
"@mariozechner/pi-ai": {
|
|
112
112
|
"optional": true
|
|
113
113
|
},
|
|
114
|
-
"@
|
|
114
|
+
"@mariozechner/pi-tui": {
|
|
115
115
|
"optional": true
|
|
116
116
|
},
|
|
117
117
|
"typebox": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blackbelt-technology/pi-dashboard-extension",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Pi bridge extension for pi-dashboard",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -24,15 +24,23 @@
|
|
|
24
24
|
".pi/skills/pi-dashboard/"
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@blackbelt-technology/pi-dashboard-shared": "^0.5.
|
|
27
|
+
"@blackbelt-technology/pi-dashboard-shared": "^0.5.1",
|
|
28
28
|
"ws": "^8.18.0"
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
32
|
+
"@earendil-works/pi-tui": "*",
|
|
31
33
|
"@mariozechner/pi-coding-agent": "*",
|
|
32
34
|
"@mariozechner/pi-tui": "*",
|
|
33
35
|
"typebox": "*"
|
|
34
36
|
},
|
|
35
37
|
"peerDependenciesMeta": {
|
|
38
|
+
"@earendil-works/pi-coding-agent": {
|
|
39
|
+
"optional": true
|
|
40
|
+
},
|
|
41
|
+
"@earendil-works/pi-tui": {
|
|
42
|
+
"optional": true
|
|
43
|
+
},
|
|
36
44
|
"@mariozechner/pi-coding-agent": {
|
|
37
45
|
"optional": true
|
|
38
46
|
},
|
|
@@ -41,7 +49,7 @@
|
|
|
41
49
|
}
|
|
42
50
|
},
|
|
43
51
|
"devDependencies": {
|
|
44
|
-
"@
|
|
52
|
+
"@earendil-works/pi-tui": "*",
|
|
45
53
|
"@types/ws": "^8.18.1",
|
|
46
54
|
"typebox": "^1.1.33"
|
|
47
55
|
}
|
|
@@ -201,6 +201,74 @@ describe("CommandHandler", () => {
|
|
|
201
201
|
await handler.handle({ type: "abort", sessionId: "s1" } as ServerToExtensionMessage);
|
|
202
202
|
});
|
|
203
203
|
|
|
204
|
+
it("abort schedules persistent-abort retries until isIdle returns true", async () => {
|
|
205
|
+
// See change: fix-provider-retry-infinite-loop.
|
|
206
|
+
vi.useFakeTimers();
|
|
207
|
+
const pi = createMockPi();
|
|
208
|
+
const abort = vi.fn();
|
|
209
|
+
let idleAfter = 3; // become idle after 3 polls
|
|
210
|
+
const isIdle = vi.fn(() => --idleAfter <= 0);
|
|
211
|
+
const handler = createCommandHandler(pi as any, "s1", { abort, isIdle, eventSink: vi.fn() });
|
|
212
|
+
|
|
213
|
+
await handler.handle({ type: "abort", sessionId: "s1" } as ServerToExtensionMessage);
|
|
214
|
+
expect(abort).toHaveBeenCalledOnce();
|
|
215
|
+
|
|
216
|
+
// Advance through the persistent-abort schedule. Each 200ms tick
|
|
217
|
+
// checks isIdle first, then calls abort if not idle.
|
|
218
|
+
vi.advanceTimersByTime(200); // tick 1: idleAfter 3→2, abort
|
|
219
|
+
vi.advanceTimersByTime(200); // tick 2: idleAfter 2→1, abort
|
|
220
|
+
vi.advanceTimersByTime(200); // tick 3: idleAfter 1→0, isIdle true, no abort, scheduler stops
|
|
221
|
+
vi.advanceTimersByTime(1000); // no more aborts
|
|
222
|
+
|
|
223
|
+
expect(abort.mock.calls.length).toBe(3); // initial + 2 retries
|
|
224
|
+
vi.useRealTimers();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("persistent-abort scheduler stops after 2 seconds even if never idle", async () => {
|
|
228
|
+
vi.useFakeTimers();
|
|
229
|
+
const pi = createMockPi();
|
|
230
|
+
const abort = vi.fn();
|
|
231
|
+
const isIdle = vi.fn(() => false); // never idle
|
|
232
|
+
const handler = createCommandHandler(pi as any, "s1", { abort, isIdle, eventSink: vi.fn() });
|
|
233
|
+
|
|
234
|
+
await handler.handle({ type: "abort", sessionId: "s1" } as ServerToExtensionMessage);
|
|
235
|
+
|
|
236
|
+
vi.advanceTimersByTime(2500); // safely past 2s cap
|
|
237
|
+
// initial + ~10 retries (2000ms / 200ms)
|
|
238
|
+
const calls = abort.mock.calls.length;
|
|
239
|
+
expect(calls).toBeGreaterThanOrEqual(10);
|
|
240
|
+
expect(calls).toBeLessThanOrEqual(11);
|
|
241
|
+
|
|
242
|
+
// Past cap, no more calls
|
|
243
|
+
const before = abort.mock.calls.length;
|
|
244
|
+
vi.advanceTimersByTime(1000);
|
|
245
|
+
expect(abort.mock.calls.length).toBe(before);
|
|
246
|
+
vi.useRealTimers();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("abort synthesizes auto_retry_end event after invoking abort callback (provider-retry-state)", async () => {
|
|
250
|
+
// See change: fix-provider-retry-infinite-loop.
|
|
251
|
+
const pi = createMockPi();
|
|
252
|
+
const calls: Array<{ name: string; arg?: unknown }> = [];
|
|
253
|
+
const abort = vi.fn(() => calls.push({ name: "abort" }));
|
|
254
|
+
const eventSink = vi.fn((m: unknown) => calls.push({ name: "eventSink", arg: m }));
|
|
255
|
+
const handler = createCommandHandler(pi as any, "s1", { abort, eventSink });
|
|
256
|
+
|
|
257
|
+
await handler.handle({ type: "abort", sessionId: "s1" } as ServerToExtensionMessage);
|
|
258
|
+
|
|
259
|
+
expect(abort).toHaveBeenCalledOnce();
|
|
260
|
+
expect(eventSink).toHaveBeenCalledOnce();
|
|
261
|
+
// Order: abort() first, then synthesized event
|
|
262
|
+
expect(calls[0]!.name).toBe("abort");
|
|
263
|
+
expect(calls[1]!.name).toBe("eventSink");
|
|
264
|
+
const evt = (calls[1]!.arg as any);
|
|
265
|
+
expect(evt.type).toBe("event_forward");
|
|
266
|
+
expect(evt.sessionId).toBe("s1");
|
|
267
|
+
expect(evt.event.eventType).toBe("auto_retry_end");
|
|
268
|
+
expect(evt.event.data).toEqual({ success: false, attempt: -1, finalError: "Aborted by user" });
|
|
269
|
+
expect(typeof evt.event.timestamp).toBe("number");
|
|
270
|
+
});
|
|
271
|
+
|
|
204
272
|
it("should handle request_commands message", async () => {
|
|
205
273
|
const pi = createMockPi();
|
|
206
274
|
const handler = createCommandHandler(pi as any, "s1");
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* defaults when the probe has no match.
|
|
6
6
|
*
|
|
7
7
|
* The helper takes an optional `probe` parameter so unit tests can supply a
|
|
8
|
-
* fake catalog without needing `@
|
|
8
|
+
* fake catalog without needing `@earendil-works/pi-ai` installed — in
|
|
9
9
|
* production, registerEntry() injects `modelRegistry.find` as the probe.
|
|
10
10
|
*
|
|
11
11
|
* Spec: openspec/changes/enrich-custom-provider-model-metadata/specs/provider-auth-bridge/spec.md
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* pi 0.70's RPC mode (the only mode dashboard headless sessions run
|
|
12
12
|
* under) defines `ExtensionUIContext.custom` as an unconditional no-op
|
|
13
13
|
* (`async custom() { return undefined; }`, see
|
|
14
|
-
* `~/.nvm/.../@
|
|
14
|
+
* `~/.nvm/.../@earendil-works/pi-coding-agent/dist/modes/rpc/rpc-mode.js`
|
|
15
15
|
* lines 150-153). Awaiting that primitive resolves to `undefined`
|
|
16
16
|
* synchronously, and the TUI arm's `bus.respond({ cancelled: true,
|
|
17
17
|
* source: "tui" })` triggers the PromptBus's first-response-wins
|
|
@@ -367,6 +367,80 @@ describe("reloadProviders", () => {
|
|
|
367
367
|
expect(opus.input).toEqual(["text", "image"]);
|
|
368
368
|
});
|
|
369
369
|
|
|
370
|
+
// ── custom-flag race regression (see change: fix-custom-provider-flag-race) ──
|
|
371
|
+
// The bridge's first `providers_list` push fires from `session_start`
|
|
372
|
+
// shortly after `activate()` kicked off async `registerEntry()` calls.
|
|
373
|
+
// The catalogue's `custom: true` flag MUST be set on that first push,
|
|
374
|
+
// even when each provider's `/v1/models` endpoint hasn't responded yet —
|
|
375
|
+
// otherwise custom providers from `~/.pi/agent/providers.json` leak into
|
|
376
|
+
// Settings → Provider Authentication → API Keys (where they don't belong;
|
|
377
|
+
// the LLM Providers section already manages them).
|
|
378
|
+
|
|
379
|
+
it("custom flag is set on first providers_list push, before discoverModels resolves (regression)", async () => {
|
|
380
|
+
const mod = await importFresh();
|
|
381
|
+
const { pi } = makeMockPi();
|
|
382
|
+
|
|
383
|
+
// Capture event handlers so we can fire model_select to set modelRegistryRef.
|
|
384
|
+
const handlers = new Map<string, (event: any, ctx: any) => Promise<void> | void>();
|
|
385
|
+
pi.on = vi.fn((event: string, handler: any) => { handlers.set(event, handler); });
|
|
386
|
+
|
|
387
|
+
// Stub fetch with a never-resolving promise — simulates a slow or
|
|
388
|
+
// unreachable /v1/models endpoint. The fix's correctness does NOT depend
|
|
389
|
+
// on this resolving; the synchronous `lastRegistered.set` runs before the
|
|
390
|
+
// await.
|
|
391
|
+
let resolveFetch: ((value: Response) => void) | null = null;
|
|
392
|
+
globalThis.fetch = vi.fn(
|
|
393
|
+
() => new Promise<Response>((r) => { resolveFetch = r; }),
|
|
394
|
+
) as any;
|
|
395
|
+
|
|
396
|
+
// Two custom providers. With the fix, both end up in lastRegistered
|
|
397
|
+
// synchronously when activate() iterates them.
|
|
398
|
+
writeProvidersJson(tmpHome, {
|
|
399
|
+
proxy: { baseUrl: "https://example.com/v1", apiKey: "sk-test", api: "openai-completions" },
|
|
400
|
+
"your-llmproxy": { baseUrl: "https://example2.com/v1", apiKey: "sk-test", api: "openai-completions" },
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// activate() fires registerEntry async (.catch(() => {})). The synchronous
|
|
404
|
+
// body runs to the first await before yielding.
|
|
405
|
+
mod.activate(pi);
|
|
406
|
+
|
|
407
|
+
// Capture modelRegistry via a model_select event — buildProviderCatalogue()
|
|
408
|
+
// returns [] when modelRegistryRef is null. We use model_select rather
|
|
409
|
+
// than session_start because session_start would re-register every entry
|
|
410
|
+
// (also stalling on the never-resolving fetch).
|
|
411
|
+
const fakeRegistry = {
|
|
412
|
+
find: () => null,
|
|
413
|
+
getAll: () => [
|
|
414
|
+
{ provider: "proxy", id: "some-model" },
|
|
415
|
+
{ provider: "your-llmproxy", id: "some-model" },
|
|
416
|
+
{ provider: "deepseek", id: "deepseek-chat" },
|
|
417
|
+
],
|
|
418
|
+
getProviderDisplayName: (id: string) => id,
|
|
419
|
+
authStorage: {
|
|
420
|
+
getOAuthProviders: () => [],
|
|
421
|
+
getAuthStatus: () => ({ configured: false }),
|
|
422
|
+
get: () => undefined,
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
const modelSelectHandler = handlers.get("model_select");
|
|
426
|
+
expect(modelSelectHandler).toBeDefined();
|
|
427
|
+
await modelSelectHandler!({}, { modelRegistry: fakeRegistry, model: undefined });
|
|
428
|
+
|
|
429
|
+
// Build the catalogue while discovery is still in flight. With the fix,
|
|
430
|
+
// both custom providers are flagged custom: true. Without it, lastRegistered
|
|
431
|
+
// is still empty (the post-await `lastRegistered.set` never runs because
|
|
432
|
+
// fetch never resolves) and the flags are missing.
|
|
433
|
+
const cat = mod.buildProviderCatalogue();
|
|
434
|
+
|
|
435
|
+
expect(cat.find((c) => c.id === "proxy")?.custom).toBe(true);
|
|
436
|
+
expect(cat.find((c) => c.id === "your-llmproxy")?.custom).toBe(true);
|
|
437
|
+
// Built-in pi-ai providers must remain unflagged.
|
|
438
|
+
expect(cat.find((c) => c.id === "deepseek")?.custom).toBeUndefined();
|
|
439
|
+
|
|
440
|
+
// Cleanup: settle the dangling fetches so the test process doesn't leak.
|
|
441
|
+
if (resolveFetch) (resolveFetch as (value: Response) => void)(new Response(JSON.stringify({ data: [] }), { status: 200 }));
|
|
442
|
+
});
|
|
443
|
+
|
|
370
444
|
it("discovered unknown model falls back to api-appropriate defaults (openai-completions → 128k)", async () => {
|
|
371
445
|
const mod = await importFresh();
|
|
372
446
|
const { pi, registerProvider } = makeMockPi();
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { RetryTracker, RETRYABLE_PATTERN } from "../retry-tracker.js";
|
|
3
|
+
|
|
4
|
+
describe("RetryTracker", () => {
|
|
5
|
+
it("synthesizes auto_retry_start on retryable assistant error", () => {
|
|
6
|
+
const t = new RetryTracker();
|
|
7
|
+
const ev = t.observeMessageEnd("s1", {
|
|
8
|
+
role: "assistant",
|
|
9
|
+
stopReason: "error",
|
|
10
|
+
errorMessage: "rate limit exceeded",
|
|
11
|
+
});
|
|
12
|
+
expect(ev).not.toBeNull();
|
|
13
|
+
expect(ev!.eventType).toBe("auto_retry_start");
|
|
14
|
+
expect(ev!.data).toEqual({
|
|
15
|
+
attempt: 1,
|
|
16
|
+
maxAttempts: -1,
|
|
17
|
+
delayMs: -1,
|
|
18
|
+
errorMessage: "rate limit exceeded",
|
|
19
|
+
});
|
|
20
|
+
expect(t.isRetrying("s1")).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("does not synthesize for non-retryable error (e.g. context overflow)", () => {
|
|
24
|
+
const t = new RetryTracker();
|
|
25
|
+
const ev = t.observeMessageEnd("s1", {
|
|
26
|
+
role: "assistant",
|
|
27
|
+
stopReason: "error",
|
|
28
|
+
errorMessage: "prompt is too long: 300000 tokens > 200000 maximum",
|
|
29
|
+
});
|
|
30
|
+
expect(ev).toBeNull();
|
|
31
|
+
expect(t.isRetrying("s1")).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("does not synthesize for non-assistant messages", () => {
|
|
35
|
+
const t = new RetryTracker();
|
|
36
|
+
expect(t.observeMessageEnd("s1", { role: "user" })).toBeNull();
|
|
37
|
+
expect(t.observeMessageEnd("s1", { role: "toolResult", stopReason: "error" })).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("does not synthesize for missing or empty errorMessage", () => {
|
|
41
|
+
const t = new RetryTracker();
|
|
42
|
+
expect(t.observeMessageEnd("s1", { role: "assistant", stopReason: "error" })).toBeNull();
|
|
43
|
+
expect(
|
|
44
|
+
t.observeMessageEnd("s1", { role: "assistant", stopReason: "error", errorMessage: "" }),
|
|
45
|
+
).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("increments attempt counter across multiple retryable errors", () => {
|
|
49
|
+
const t = new RetryTracker();
|
|
50
|
+
const a = t.observeMessageEnd("s1", { role: "assistant", stopReason: "error", errorMessage: "429" });
|
|
51
|
+
const b = t.observeMessageEnd("s1", { role: "assistant", stopReason: "error", errorMessage: "429" });
|
|
52
|
+
const c = t.observeMessageEnd("s1", { role: "assistant", stopReason: "error", errorMessage: "429" });
|
|
53
|
+
expect((a!.data as any).attempt).toBe(1);
|
|
54
|
+
expect((b!.data as any).attempt).toBe(2);
|
|
55
|
+
expect((c!.data as any).attempt).toBe(3);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("synthesizes auto_retry_end success on successful assistant message_end after retry", () => {
|
|
59
|
+
const t = new RetryTracker();
|
|
60
|
+
t.observeMessageEnd("s1", { role: "assistant", stopReason: "error", errorMessage: "429" });
|
|
61
|
+
const ev = t.observeMessageEnd("s1", { role: "assistant", stopReason: "end_turn" });
|
|
62
|
+
expect(ev).not.toBeNull();
|
|
63
|
+
expect(ev!.eventType).toBe("auto_retry_end");
|
|
64
|
+
expect(ev!.data).toEqual({ success: true, attempt: 1 });
|
|
65
|
+
expect(t.isRetrying("s1")).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("does not synthesize auto_retry_end when no retry was tracked", () => {
|
|
69
|
+
const t = new RetryTracker();
|
|
70
|
+
expect(t.observeMessageEnd("s1", { role: "assistant", stopReason: "end_turn" })).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("synthesizes auto_retry_end failure on agent_end with terminal error", () => {
|
|
74
|
+
const t = new RetryTracker();
|
|
75
|
+
t.observeMessageEnd("s1", { role: "assistant", stopReason: "error", errorMessage: "rate limit" });
|
|
76
|
+
t.observeMessageEnd("s1", { role: "assistant", stopReason: "error", errorMessage: "rate limit" });
|
|
77
|
+
const ev = t.observeAgentEnd("s1", {
|
|
78
|
+
messages: [{ role: "assistant", stopReason: "error", errorMessage: "Rate limit exceeded permanently" }],
|
|
79
|
+
});
|
|
80
|
+
expect(ev).not.toBeNull();
|
|
81
|
+
expect(ev!.eventType).toBe("auto_retry_end");
|
|
82
|
+
expect(ev!.data).toEqual({
|
|
83
|
+
success: false,
|
|
84
|
+
attempt: 2,
|
|
85
|
+
finalError: "Rate limit exceeded permanently",
|
|
86
|
+
});
|
|
87
|
+
expect(t.isRetrying("s1")).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("synthesizes auto_retry_end success on agent_end with non-error terminal message", () => {
|
|
91
|
+
const t = new RetryTracker();
|
|
92
|
+
t.observeMessageEnd("s1", { role: "assistant", stopReason: "error", errorMessage: "429" });
|
|
93
|
+
const ev = t.observeAgentEnd("s1", {
|
|
94
|
+
messages: [{ role: "assistant", stopReason: "end_turn" }],
|
|
95
|
+
});
|
|
96
|
+
expect(ev).not.toBeNull();
|
|
97
|
+
expect((ev!.data as any).success).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("agent_end without prior retry returns null", () => {
|
|
101
|
+
const t = new RetryTracker();
|
|
102
|
+
expect(t.observeAgentEnd("s1", { messages: [] })).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("noteAbort clears tracker so subsequent agent_end does not double-emit", () => {
|
|
106
|
+
const t = new RetryTracker();
|
|
107
|
+
t.observeMessageEnd("s1", { role: "assistant", stopReason: "error", errorMessage: "429" });
|
|
108
|
+
t.noteAbort("s1");
|
|
109
|
+
expect(t.isRetrying("s1")).toBe(false);
|
|
110
|
+
expect(t.observeAgentEnd("s1", { messages: [] })).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("scopes retry state per-session", () => {
|
|
114
|
+
const t = new RetryTracker();
|
|
115
|
+
t.observeMessageEnd("s1", { role: "assistant", stopReason: "error", errorMessage: "429" });
|
|
116
|
+
expect(t.isRetrying("s1")).toBe(true);
|
|
117
|
+
expect(t.isRetrying("s2")).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it.each([
|
|
121
|
+
"rate limit exceeded",
|
|
122
|
+
"Rate Limit hit",
|
|
123
|
+
"overloaded_error",
|
|
124
|
+
"too many requests",
|
|
125
|
+
"HTTP 429",
|
|
126
|
+
"HTTP 500 Internal Server Error",
|
|
127
|
+
"service unavailable",
|
|
128
|
+
"fetch failed",
|
|
129
|
+
"socket hang up",
|
|
130
|
+
"connection refused",
|
|
131
|
+
"connection lost",
|
|
132
|
+
"request timed out",
|
|
133
|
+
"terminated",
|
|
134
|
+
"retry delay exceeded",
|
|
135
|
+
])("RETRYABLE_PATTERN matches: %s", (msg) => {
|
|
136
|
+
expect(RETRYABLE_PATTERN.test(msg)).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it.each([
|
|
140
|
+
"prompt is too long: 300000 tokens > 200000 maximum",
|
|
141
|
+
"tool execution failed",
|
|
142
|
+
"invalid input",
|
|
143
|
+
"",
|
|
144
|
+
])("RETRYABLE_PATTERN does NOT match: %s", (msg) => {
|
|
145
|
+
expect(RETRYABLE_PATTERN.test(msg)).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -133,3 +133,75 @@ describe("handleSessionChange", () => {
|
|
|
133
133
|
expect(registerMsg.registerReason).toBe("spawn");
|
|
134
134
|
});
|
|
135
135
|
});
|
|
136
|
+
|
|
137
|
+
// See change: spawn-correlation-token — bridge token inclusion contract.
|
|
138
|
+
describe("sendStateSync: spawnToken from env", () => {
|
|
139
|
+
const ENV_VAR = "PI_DASHBOARD_SPAWN_TOKEN";
|
|
140
|
+
|
|
141
|
+
function withEnvVar<T>(value: string | undefined, fn: () => T): T {
|
|
142
|
+
const prior = process.env[ENV_VAR];
|
|
143
|
+
if (value === undefined) delete process.env[ENV_VAR];
|
|
144
|
+
else process.env[ENV_VAR] = value;
|
|
145
|
+
try {
|
|
146
|
+
return fn();
|
|
147
|
+
} finally {
|
|
148
|
+
if (prior === undefined) delete process.env[ENV_VAR];
|
|
149
|
+
else process.env[ENV_VAR] = prior;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
it("first register includes spawnToken from env", () => {
|
|
154
|
+
withEnvVar("tok_first", () => {
|
|
155
|
+
const bc = createMockBridgeContext({ hasRegisteredOnce: false } as any);
|
|
156
|
+
sendStateSync(bc, () => []);
|
|
157
|
+
const sent = (bc as any)._sent;
|
|
158
|
+
const registerMsg = sent.find((m: any) => m.type === "session_register");
|
|
159
|
+
expect(registerMsg.spawnToken).toBe("tok_first");
|
|
160
|
+
expect(registerMsg.registerReason).toBe("spawn");
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("reattach register omits spawnToken (even when env still set)", () => {
|
|
165
|
+
withEnvVar("tok_first", () => {
|
|
166
|
+
const bc = createMockBridgeContext({ hasRegisteredOnce: true } as any);
|
|
167
|
+
sendStateSync(bc, () => []);
|
|
168
|
+
const sent = (bc as any)._sent;
|
|
169
|
+
const registerMsg = sent.find((m: any) => m.type === "session_register");
|
|
170
|
+
expect(registerMsg.spawnToken).toBeUndefined();
|
|
171
|
+
expect(registerMsg.registerReason).toBe("reattach");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("first register without env var omits spawnToken", () => {
|
|
176
|
+
withEnvVar(undefined, () => {
|
|
177
|
+
const bc = createMockBridgeContext({ hasRegisteredOnce: false } as any);
|
|
178
|
+
sendStateSync(bc, () => []);
|
|
179
|
+
const sent = (bc as any)._sent;
|
|
180
|
+
const registerMsg = sent.find((m: any) => m.type === "session_register");
|
|
181
|
+
expect(registerMsg.spawnToken).toBeUndefined();
|
|
182
|
+
expect(registerMsg.registerReason).toBe("spawn");
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("handleSessionChange register omits spawnToken (in-process new/fork/resume)", () => {
|
|
187
|
+
withEnvVar("tok_first", () => {
|
|
188
|
+
const bc = createMockBridgeContext({ hasRegisteredOnce: true } as any);
|
|
189
|
+
const ctx = {
|
|
190
|
+
cwd: "/proj",
|
|
191
|
+
sessionManager: {
|
|
192
|
+
getSessionId: () => "sess-fork",
|
|
193
|
+
getSessionFile: () => "/path/new.json",
|
|
194
|
+
getSessionDir: () => "/path",
|
|
195
|
+
getBranch: () => [],
|
|
196
|
+
getEntries: () => [],
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
handleSessionChange(bc, ctx as any, () => []);
|
|
200
|
+
const sent = (bc as any)._sent;
|
|
201
|
+
const registerMsg = sent.find((m: any) => m.type === "session_register" && m.sessionId === "sess-fork");
|
|
202
|
+
expect(registerMsg).toBeDefined();
|
|
203
|
+
expect(registerMsg.spawnToken).toBeUndefined();
|
|
204
|
+
expect(registerMsg.registerReason).toBe("spawn");
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
});
|