@blackbelt-technology/pi-agent-dashboard 0.3.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 (216) hide show
  1. package/AGENTS.md +87 -114
  2. package/README.md +408 -430
  3. package/docs/architecture.md +465 -12
  4. package/package.json +10 -5
  5. package/packages/extension/package.json +14 -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__/enrich-model-metadata.test.ts +201 -0
  9. package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
  10. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
  11. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  12. package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
  13. package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
  14. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  15. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  16. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  17. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  18. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  19. package/packages/extension/src/ask-user-tool.ts +5 -4
  20. package/packages/extension/src/bridge.ts +171 -17
  21. package/packages/extension/src/dev-build.ts +1 -1
  22. package/packages/extension/src/git-info.ts +9 -19
  23. package/packages/extension/src/multiselect-list.ts +146 -0
  24. package/packages/extension/src/multiselect-polyfill.ts +43 -0
  25. package/packages/extension/src/pi-env.d.ts +1 -0
  26. package/packages/extension/src/process-scanner.ts +72 -38
  27. package/packages/extension/src/provider-register.ts +304 -16
  28. package/packages/extension/src/server-auto-start.ts +27 -1
  29. package/packages/extension/src/server-launcher.ts +83 -27
  30. package/packages/server/package.json +16 -2
  31. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  32. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  33. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  34. package/packages/server/src/__tests__/browse-endpoint.test.ts +17 -0
  35. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  36. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  37. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  38. package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
  39. package/packages/server/src/__tests__/directory-service.test.ts +234 -8
  40. package/packages/server/src/__tests__/editor-registry.test.ts +28 -15
  41. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  42. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  43. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  44. package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
  45. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  46. package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
  47. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  48. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  49. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  50. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  51. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  52. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +5 -1
  53. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  54. package/packages/server/src/__tests__/pi-version-skew.test.ts +237 -0
  55. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  56. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  57. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  58. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  59. package/packages/server/src/__tests__/restart-helper.test.ts +111 -0
  60. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  61. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  62. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  63. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  64. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  65. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  66. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  67. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  68. package/packages/server/src/__tests__/tunnel.test.ts +13 -7
  69. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  70. package/packages/server/src/bootstrap-queue.ts +130 -0
  71. package/packages/server/src/bootstrap-state.ts +131 -0
  72. package/packages/server/src/browse.ts +8 -3
  73. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  74. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  75. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  76. package/packages/server/src/cli.ts +310 -39
  77. package/packages/server/src/config-api.ts +16 -0
  78. package/packages/server/src/directory-service.ts +270 -39
  79. package/packages/server/src/editor-detection.ts +12 -9
  80. package/packages/server/src/editor-manager.ts +19 -4
  81. package/packages/server/src/editor-pid-registry.ts +9 -8
  82. package/packages/server/src/editor-registry.ts +22 -25
  83. package/packages/server/src/git-operations.ts +1 -1
  84. package/packages/server/src/headless-pid-registry.ts +7 -20
  85. package/packages/server/src/home-lock-release.ts +72 -0
  86. package/packages/server/src/home-lock.ts +389 -0
  87. package/packages/server/src/node-guard.ts +52 -0
  88. package/packages/server/src/package-manager-wrapper.ts +207 -47
  89. package/packages/server/src/pi-core-checker.ts +1 -1
  90. package/packages/server/src/pi-core-updater.ts +7 -1
  91. package/packages/server/src/pi-resource-scanner.ts +5 -8
  92. package/packages/server/src/pi-version-skew.ts +207 -0
  93. package/packages/server/src/preferences-store.ts +17 -3
  94. package/packages/server/src/process-manager.ts +403 -222
  95. package/packages/server/src/provider-probe.ts +234 -0
  96. package/packages/server/src/restart-helper.ts +141 -0
  97. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  98. package/packages/server/src/routes/openspec-routes.ts +25 -1
  99. package/packages/server/src/routes/pi-core-routes.ts +24 -1
  100. package/packages/server/src/routes/provider-auth-routes.ts +8 -8
  101. package/packages/server/src/routes/provider-routes.ts +43 -0
  102. package/packages/server/src/routes/recommended-routes.ts +10 -12
  103. package/packages/server/src/routes/system-routes.ts +20 -33
  104. package/packages/server/src/routes/tool-routes.ts +153 -0
  105. package/packages/server/src/server-pid.ts +5 -9
  106. package/packages/server/src/server.ts +211 -10
  107. package/packages/server/src/session-api.ts +77 -8
  108. package/packages/server/src/session-bootstrap.ts +17 -3
  109. package/packages/server/src/session-diff.ts +21 -21
  110. package/packages/server/src/terminal-manager.ts +61 -20
  111. package/packages/server/src/tunnel.ts +42 -28
  112. package/packages/shared/package.json +10 -3
  113. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  114. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  115. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  116. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  117. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  118. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  119. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  120. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  121. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  122. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  123. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  124. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  125. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  126. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  127. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  128. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  129. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  130. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  131. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  132. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  133. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  134. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  135. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  136. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  137. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  138. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  139. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  140. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  141. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  142. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  143. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  144. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  145. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  146. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  147. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  148. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  149. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  150. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  151. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  152. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  153. package/packages/shared/src/__tests__/config.test.ts +56 -0
  154. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  155. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  156. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  157. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  158. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  159. package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
  160. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
  161. package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
  162. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  163. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  164. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  165. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  166. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  167. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  168. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  169. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  170. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  171. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  172. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  173. package/packages/shared/src/__tests__/recommended-extensions.test.ts +40 -7
  174. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  175. package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
  176. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  177. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  178. package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
  179. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  180. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  181. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  182. package/packages/shared/src/bootstrap-install.ts +212 -0
  183. package/packages/shared/src/bridge-register.ts +87 -20
  184. package/packages/shared/src/browser-protocol.ts +71 -1
  185. package/packages/shared/src/config.ts +87 -15
  186. package/packages/shared/src/managed-paths.ts +31 -4
  187. package/packages/shared/src/openspec-poller.ts +63 -46
  188. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  189. package/packages/shared/src/platform/commands.ts +100 -0
  190. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  191. package/packages/shared/src/platform/exec.ts +220 -0
  192. package/packages/shared/src/platform/git.ts +155 -0
  193. package/packages/shared/src/platform/index.ts +16 -0
  194. package/packages/shared/src/platform/node-spawn.ts +154 -0
  195. package/packages/shared/src/platform/npm.ts +162 -0
  196. package/packages/shared/src/platform/openspec.ts +91 -0
  197. package/packages/shared/src/platform/paths.ts +276 -0
  198. package/packages/shared/src/platform/process-identify.ts +126 -0
  199. package/packages/shared/src/platform/process-scan.ts +94 -0
  200. package/packages/shared/src/platform/process.ts +168 -0
  201. package/packages/shared/src/platform/runner.ts +369 -0
  202. package/packages/shared/src/platform/shell.ts +44 -0
  203. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  204. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  205. package/packages/shared/src/protocol.ts +23 -0
  206. package/packages/shared/src/recommended-extensions.ts +18 -2
  207. package/packages/shared/src/resolve-jiti.ts +62 -3
  208. package/packages/shared/src/rest-api.ts +26 -0
  209. package/packages/shared/src/semaphore.ts +83 -0
  210. package/packages/shared/src/state-replay.ts +9 -0
  211. package/packages/shared/src/tool-registry/definitions.ts +434 -0
  212. package/packages/shared/src/tool-registry/index.ts +56 -0
  213. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  214. package/packages/shared/src/tool-registry/registry.ts +262 -0
  215. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  216. package/packages/shared/src/tool-registry/types.ts +180 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-agent-dashboard",
3
- "version": "0.3.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",
@@ -51,6 +51,8 @@
51
51
  "build": "npm run build --workspace=@blackbelt-technology/pi-dashboard-web",
52
52
  "test": "HOME=$(mktemp -d -t pi-test-XXXXXX) vitest run",
53
53
  "test:watch": "HOME=$(mktemp -d -t pi-test-XXXXXX) vitest",
54
+ "test:bootstrap": "HOME=$(mktemp -d -t pi-test-XXXXXX) vitest run packages/shared/src/__tests__/bootstrap",
55
+ "test:bootstrap:watch": "HOME=$(mktemp -d -t pi-test-XXXXXX) vitest packages/shared/src/__tests__/bootstrap",
54
56
  "lint": "tsc --noEmit",
55
57
  "reload": "./scripts/reload-all.sh",
56
58
  "reload:check": "./scripts/reload-all.sh --check",
@@ -64,9 +66,9 @@
64
66
  "screenshots": "npm --prefix site run screenshots"
65
67
  },
66
68
  "dependencies": {
67
- "@blackbelt-technology/pi-dashboard-extension": "*",
68
- "@blackbelt-technology/pi-dashboard-server": "*",
69
- "@blackbelt-technology/pi-dashboard-web": "*"
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"
70
72
  },
71
73
  "devDependencies": {
72
74
  "jsdom": "^29.0.2",
@@ -81,7 +83,7 @@
81
83
  "@oh-my-pi/pi-ai": "*",
82
84
  "@oh-my-pi/pi-coding-agent": "*",
83
85
  "@oh-my-pi/pi-tui": "*",
84
- "@sinclair/typebox": "*"
86
+ "typebox": "*"
85
87
  },
86
88
  "peerDependenciesMeta": {
87
89
  "@mariozechner/pi-coding-agent": {
@@ -101,6 +103,9 @@
101
103
  },
102
104
  "@oh-my-pi/pi-tui": {
103
105
  "optional": true
106
+ },
107
+ "typebox": {
108
+ "optional": true
104
109
  }
105
110
  }
106
111
  }
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-dashboard-extension",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Pi bridge extension for pi-dashboard",
5
5
  "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
6
9
  "pi": {
7
10
  "extensions": [
8
11
  "src/bridge.ts"
@@ -16,18 +19,25 @@
16
19
  ".pi/skills/pi-dashboard/"
17
20
  ],
18
21
  "dependencies": {
19
- "@blackbelt-technology/pi-dashboard-shared": "*",
22
+ "@blackbelt-technology/pi-dashboard-shared": "^0.4.1",
20
23
  "ws": "^8.18.0"
21
24
  },
22
25
  "peerDependencies": {
23
- "@mariozechner/pi-coding-agent": "*"
26
+ "@mariozechner/pi-coding-agent": "*",
27
+ "@mariozechner/pi-tui": "*",
28
+ "typebox": "*"
24
29
  },
25
30
  "peerDependenciesMeta": {
26
31
  "@mariozechner/pi-coding-agent": {
27
32
  "optional": true
33
+ },
34
+ "@mariozechner/pi-tui": {
35
+ "optional": true
28
36
  }
29
37
  },
30
38
  "devDependencies": {
31
- "@types/ws": "^8.18.1"
39
+ "@mariozechner/pi-tui": "*",
40
+ "@types/ws": "^8.18.1",
41
+ "typebox": "^1.1.33"
32
42
  }
33
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
+ });
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Tests for enrichModelMetadata — the pure helper that resolves a discovered
3
+ * custom-provider model id against a catalog probe (typically backed by pi's
4
+ * `modelRegistry.find(provider, id)`) and falls back to api-appropriate
5
+ * defaults when the probe has no match.
6
+ *
7
+ * The helper takes an optional `probe` parameter so unit tests can supply a
8
+ * fake catalog without needing `@mariozechner/pi-ai` installed — in
9
+ * production, registerEntry() injects `modelRegistry.find` as the probe.
10
+ *
11
+ * Spec: openspec/changes/enrich-custom-provider-model-metadata/specs/provider-auth-bridge/spec.md
12
+ */
13
+
14
+ import { describe, it, expect } from "vitest";
15
+ import { enrichModelMetadata, type CatalogProbe } from "../provider-register.js";
16
+
17
+ // Minimal fake catalog mirroring a subset of pi-ai's real MODELS export.
18
+ // Keys are `${provider}|${id}` so our probe is a single Map lookup.
19
+ const FAKE_CATALOG = new Map<string, any>([
20
+ [
21
+ "anthropic|claude-opus-4-7",
22
+ {
23
+ contextWindow: 1_000_000,
24
+ maxTokens: 128_000,
25
+ reasoning: true,
26
+ cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
27
+ input: ["text", "image"],
28
+ },
29
+ ],
30
+ [
31
+ "anthropic|claude-sonnet-4-6",
32
+ {
33
+ contextWindow: 1_000_000,
34
+ maxTokens: 64_000,
35
+ reasoning: true,
36
+ cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
37
+ input: ["text", "image"],
38
+ },
39
+ ],
40
+ [
41
+ "anthropic|claude-haiku-4-5",
42
+ {
43
+ contextWindow: 200_000,
44
+ maxTokens: 64_000,
45
+ reasoning: true,
46
+ cost: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
47
+ input: ["text", "image"],
48
+ },
49
+ ],
50
+ [
51
+ "openai|gpt-5",
52
+ {
53
+ contextWindow: 400_000,
54
+ maxTokens: 128_000,
55
+ reasoning: true,
56
+ cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 },
57
+ input: ["text", "image"],
58
+ },
59
+ ],
60
+ [
61
+ "opencode|claude-opus-4-7",
62
+ {
63
+ // Deliberately different values so tests can prove the candidate-
64
+ // provider probe order is deterministic (anthropic checked first).
65
+ contextWindow: 999,
66
+ maxTokens: 1,
67
+ reasoning: true,
68
+ cost: { input: 9, output: 9, cacheRead: 9, cacheWrite: 9 },
69
+ input: ["text"],
70
+ },
71
+ ],
72
+ ]);
73
+
74
+ const fakeProbe: CatalogProbe = (provider, id) =>
75
+ FAKE_CATALOG.get(`${provider}|${id}`) ?? null;
76
+
77
+ describe("enrichModelMetadata — catalog matches via probe", () => {
78
+ it("resolves `cc/claude-opus-4-7` + anthropic-messages to Opus 4.7 catalog entry (1M ctx)", () => {
79
+ const result = enrichModelMetadata("cc/claude-opus-4-7", "anthropic-messages", fakeProbe);
80
+ expect(result.contextWindow).toBe(1_000_000);
81
+ expect(result.maxTokens).toBe(128_000);
82
+ expect(result.reasoning).toBe(true);
83
+ expect(result.cost).toEqual({
84
+ input: 5,
85
+ output: 25,
86
+ cacheRead: 0.5,
87
+ cacheWrite: 6.25,
88
+ });
89
+ expect(result.input).toEqual(["text", "image"]);
90
+ });
91
+
92
+ it("resolves bare `claude-sonnet-4-6` + anthropic-messages to Sonnet 4.6 (1M ctx)", () => {
93
+ const result = enrichModelMetadata("claude-sonnet-4-6", "anthropic-messages", fakeProbe);
94
+ expect(result.contextWindow).toBe(1_000_000);
95
+ expect(result.reasoning).toBe(true);
96
+ });
97
+
98
+ it("resolves `anthropic/claude-opus-4-7` prefix to Opus 4.7 (1M ctx)", () => {
99
+ const result = enrichModelMetadata("anthropic/claude-opus-4-7", "anthropic-messages", fakeProbe);
100
+ expect(result.contextWindow).toBe(1_000_000);
101
+ expect(result.maxTokens).toBe(128_000);
102
+ });
103
+
104
+ it("resolves `claude-haiku-4-5` + anthropic-messages to Haiku 4.5 (200k ctx — verifies we don't over-claim)", () => {
105
+ const result = enrichModelMetadata("claude-haiku-4-5", "anthropic-messages", fakeProbe);
106
+ expect(result.contextWindow).toBe(200_000);
107
+ expect(result.reasoning).toBe(true);
108
+ expect(result.cost.input).toBe(1);
109
+ expect(result.cost.output).toBe(5);
110
+ });
111
+
112
+ it("resolves `openrouter/openai/gpt-5` prefix to gpt-5 via openai-completions candidates", () => {
113
+ const result = enrichModelMetadata("openrouter/openai/gpt-5", "openai-completions", fakeProbe);
114
+ expect(result.contextWindow).toBe(400_000);
115
+ expect(result.maxTokens).toBe(128_000);
116
+ expect(result.reasoning).toBe(true);
117
+ });
118
+ });
119
+
120
+ describe("enrichModelMetadata — fallback defaults (no probe or no match)", () => {
121
+ it("no probe supplied → falls back to api-appropriate default", () => {
122
+ const result = enrichModelMetadata("cc/claude-opus-4-7", "anthropic-messages");
123
+ expect(result.contextWindow).toBe(200_000);
124
+ expect(result.maxTokens).toBe(64_000);
125
+ });
126
+
127
+ it("unknown id + anthropic-messages returns 200k / 64k / no reasoning / zero cost / text+image", () => {
128
+ const result = enrichModelMetadata("some-unknown-anthropic-model", "anthropic-messages", fakeProbe);
129
+ expect(result.contextWindow).toBe(200_000);
130
+ expect(result.maxTokens).toBe(64_000);
131
+ expect(result.reasoning).toBe(false);
132
+ expect(result.cost).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
133
+ expect(result.input).toEqual(["text", "image"]);
134
+ });
135
+
136
+ it("unknown id + openai-completions returns 128k / 16k / zero cost / text+image", () => {
137
+ const result = enrichModelMetadata("some-unknown-openai-model", "openai-completions", fakeProbe);
138
+ expect(result.contextWindow).toBe(128_000);
139
+ expect(result.maxTokens).toBe(16_384);
140
+ expect(result.reasoning).toBe(false);
141
+ expect(result.cost).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
142
+ expect(result.input).toEqual(["text", "image"]);
143
+ });
144
+
145
+ it("unknown id + google-generative-ai returns 1M / 65k / zero cost / text+image", () => {
146
+ const result = enrichModelMetadata("some-unknown-gemini-model", "google-generative-ai", fakeProbe);
147
+ expect(result.contextWindow).toBe(1_000_000);
148
+ expect(result.maxTokens).toBe(65_536);
149
+ expect(result.reasoning).toBe(false);
150
+ expect(result.cost).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
151
+ expect(result.input).toEqual(["text", "image"]);
152
+ });
153
+
154
+ it("prefixed unknown + openai-completions falls back (both prefixed and bare miss)", () => {
155
+ const result = enrichModelMetadata("minimax/custom-private-model", "openai-completions", fakeProbe);
156
+ expect(result.contextWindow).toBe(128_000);
157
+ expect(result.maxTokens).toBe(16_384);
158
+ expect(result.cost.input).toBe(0);
159
+ });
160
+
161
+ it("no api argument + no probe defaults to openai-completions fallback", () => {
162
+ const result = enrichModelMetadata("some-unknown-model");
163
+ expect(result.contextWindow).toBe(128_000);
164
+ expect(result.maxTokens).toBe(16_384);
165
+ expect(result.input).toEqual(["text", "image"]);
166
+ });
167
+
168
+ it("unknown api string + probe still probes openai-completions candidates", () => {
169
+ const result = enrichModelMetadata("some-unknown-model", "some-weird-api", fakeProbe);
170
+ expect(result.contextWindow).toBe(128_000);
171
+ });
172
+
173
+ it("probe that throws is tolerated (falls through to fallback)", () => {
174
+ const throwingProbe: CatalogProbe = () => {
175
+ throw new Error("registry not ready");
176
+ };
177
+ const result = enrichModelMetadata("claude-opus-4-7", "anthropic-messages", throwingProbe);
178
+ expect(result.contextWindow).toBe(200_000); // anthropic-messages fallback
179
+ });
180
+ });
181
+
182
+ describe("enrichModelMetadata — probe order determinism", () => {
183
+ it("prefers `anthropic` over `opencode` for anthropic-messages (first in candidate list wins)", () => {
184
+ // The fake catalog has `claude-opus-4-7` under BOTH `anthropic` (1M) and
185
+ // `opencode` (999). With api=anthropic-messages, candidates = ["anthropic", "opencode"]
186
+ // so the anthropic entry must win.
187
+ const result = enrichModelMetadata("claude-opus-4-7", "anthropic-messages", fakeProbe);
188
+ expect(result.contextWindow).toBe(1_000_000);
189
+ expect(result.maxTokens).toBe(128_000);
190
+ });
191
+
192
+ it("multi-segment prefix `openrouter/anthropic/claude-opus-4-7` resolves via bare-id lookup", () => {
193
+ // After stripping the last segment, `claude-opus-4-7` matches `anthropic` (1M).
194
+ const result = enrichModelMetadata(
195
+ "openrouter/anthropic/claude-opus-4-7",
196
+ "anthropic-messages",
197
+ fakeProbe,
198
+ );
199
+ expect(result.contextWindow).toBe(1_000_000);
200
+ });
201
+ });
@@ -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",