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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/AGENTS.md +64 -8
  2. package/README.md +308 -101
  3. package/docs/architecture.md +515 -16
  4. package/package.json +14 -7
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +300 -3
  7. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  8. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +100 -0
  9. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  10. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  11. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  12. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  13. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  14. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  15. package/packages/extension/src/ask-user-tool.ts +289 -20
  16. package/packages/extension/src/bridge.ts +107 -6
  17. package/packages/extension/src/command-handler.ts +34 -39
  18. package/packages/extension/src/dev-build.ts +1 -1
  19. package/packages/extension/src/git-info.ts +9 -19
  20. package/packages/extension/src/pi-env.d.ts +1 -0
  21. package/packages/extension/src/process-scanner.ts +72 -38
  22. package/packages/extension/src/prompt-expander.ts +25 -4
  23. package/packages/extension/src/provider-register.ts +304 -16
  24. package/packages/extension/src/server-auto-start.ts +27 -1
  25. package/packages/extension/src/server-launcher.ts +71 -27
  26. package/packages/server/package.json +17 -2
  27. package/packages/server/src/__tests__/auto-attach.test.ts +10 -1
  28. package/packages/server/src/__tests__/auto-shutdown.test.ts +8 -2
  29. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  30. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  31. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  32. package/packages/server/src/__tests__/browse-endpoint.test.ts +246 -10
  33. package/packages/server/src/__tests__/browser-gateway-handler-errors.test.ts +129 -0
  34. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  35. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  36. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  37. package/packages/server/src/__tests__/cors.test.ts +34 -2
  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-manager-pid-registry.test.ts +168 -0
  41. package/packages/server/src/__tests__/editor-manager.test.ts +33 -0
  42. package/packages/server/src/__tests__/editor-pid-registry.test.ts +191 -0
  43. package/packages/server/src/__tests__/editor-registry.test.ts +29 -15
  44. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  45. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  46. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  47. package/packages/server/src/__tests__/fix-pty-permissions.test.ts +59 -0
  48. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  49. package/packages/server/src/__tests__/git-operations.test.ts +9 -7
  50. package/packages/server/src/__tests__/health-endpoint.test.ts +11 -13
  51. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  52. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  53. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  54. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  55. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  56. package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +178 -0
  57. package/packages/server/src/__tests__/openspec-tasks-routes.test.ts +180 -0
  58. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +126 -0
  59. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  60. package/packages/server/src/__tests__/pi-core-checker.test.ts +195 -0
  61. package/packages/server/src/__tests__/pi-core-routes.test.ts +184 -0
  62. package/packages/server/src/__tests__/pi-core-updater.test.ts +214 -0
  63. package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
  64. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  65. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  66. package/packages/server/src/__tests__/provider-auth-routes.test.ts +13 -3
  67. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  68. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  69. package/packages/server/src/__tests__/recommended-routes.test.ts +389 -0
  70. package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
  71. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  72. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  73. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  74. package/packages/server/src/__tests__/session-file-dedup.test.ts +10 -10
  75. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +8 -2
  76. package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +3 -1
  77. package/packages/server/src/__tests__/smoke-integration.test.ts +10 -10
  78. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  79. package/packages/server/src/__tests__/test-server-canary.test.ts +31 -0
  80. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  81. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  82. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  83. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  84. package/packages/server/src/__tests__/tunnel.test.ts +103 -6
  85. package/packages/server/src/__tests__/ws-ping-pong.test.ts +10 -2
  86. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  87. package/packages/server/src/bootstrap-queue.ts +130 -0
  88. package/packages/server/src/bootstrap-state.ts +131 -0
  89. package/packages/server/src/browse.ts +108 -9
  90. package/packages/server/src/browser-gateway.ts +16 -3
  91. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  92. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  93. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  94. package/packages/server/src/cli.ts +256 -32
  95. package/packages/server/src/config-api.ts +16 -0
  96. package/packages/server/src/directory-service.ts +270 -39
  97. package/packages/server/src/editor-detection.ts +12 -9
  98. package/packages/server/src/editor-manager.ts +39 -5
  99. package/packages/server/src/editor-pid-registry.ts +199 -0
  100. package/packages/server/src/editor-registry.ts +22 -25
  101. package/packages/server/src/fix-pty-permissions.ts +44 -0
  102. package/packages/server/src/git-operations.ts +1 -1
  103. package/packages/server/src/headless-pid-registry.ts +16 -20
  104. package/packages/server/src/home-lock-release.ts +72 -0
  105. package/packages/server/src/home-lock.ts +389 -0
  106. package/packages/server/src/node-guard.ts +52 -0
  107. package/packages/server/src/npm-search-proxy.ts +71 -0
  108. package/packages/server/src/openspec-tasks.ts +158 -0
  109. package/packages/server/src/package-manager-wrapper.ts +225 -34
  110. package/packages/server/src/pi-core-checker.ts +290 -0
  111. package/packages/server/src/pi-core-updater.ts +172 -0
  112. package/packages/server/src/pi-gateway.ts +7 -0
  113. package/packages/server/src/pi-resource-scanner.ts +5 -8
  114. package/packages/server/src/pi-version-skew.ts +196 -0
  115. package/packages/server/src/preferences-store.ts +17 -3
  116. package/packages/server/src/process-manager.ts +403 -222
  117. package/packages/server/src/provider-probe.ts +234 -0
  118. package/packages/server/src/restart-helper.ts +130 -0
  119. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  120. package/packages/server/src/routes/file-routes.ts +30 -3
  121. package/packages/server/src/routes/openspec-routes.ts +107 -1
  122. package/packages/server/src/routes/pi-core-routes.ts +140 -0
  123. package/packages/server/src/routes/provider-auth-routes.ts +12 -10
  124. package/packages/server/src/routes/provider-routes.ts +55 -2
  125. package/packages/server/src/routes/recommended-routes.ts +225 -0
  126. package/packages/server/src/routes/system-routes.ts +30 -34
  127. package/packages/server/src/routes/tool-routes.ts +153 -0
  128. package/packages/server/src/server-pid.ts +5 -9
  129. package/packages/server/src/server.ts +363 -26
  130. package/packages/server/src/session-api.ts +77 -8
  131. package/packages/server/src/session-bootstrap.ts +17 -3
  132. package/packages/server/src/session-diff.ts +21 -21
  133. package/packages/server/src/terminal-manager.ts +65 -20
  134. package/packages/server/src/test-env-guard.ts +26 -0
  135. package/packages/server/src/test-support/test-server.ts +63 -0
  136. package/packages/server/src/tunnel.ts +172 -34
  137. package/packages/shared/package.json +10 -3
  138. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  139. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  140. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  141. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  142. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  143. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  144. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  145. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  146. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  147. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  148. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  149. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  150. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  151. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  152. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  153. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  154. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  155. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  156. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  157. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  158. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  159. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  160. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  161. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  162. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  163. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  164. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  165. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  166. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  167. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  168. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  169. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  170. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  171. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  172. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  173. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  174. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  175. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  176. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  177. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  178. package/packages/shared/src/__tests__/config.test.ts +59 -3
  179. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  180. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  181. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  182. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  183. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  184. package/packages/shared/src/__tests__/openspec-poller.test.ts +44 -0
  185. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  186. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  187. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  188. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  189. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  190. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  191. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  192. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  193. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  194. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  195. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  196. package/packages/shared/src/__tests__/recommended-extensions.test.ts +156 -0
  197. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  198. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  199. package/packages/shared/src/__tests__/source-matching.test.ts +143 -0
  200. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  201. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  202. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  203. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  204. package/packages/shared/src/bootstrap-install.ts +212 -0
  205. package/packages/shared/src/bridge-register.ts +87 -20
  206. package/packages/shared/src/browser-protocol.ts +93 -1
  207. package/packages/shared/src/config.ts +87 -15
  208. package/packages/shared/src/managed-paths.ts +31 -4
  209. package/packages/shared/src/openspec-poller.ts +71 -49
  210. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  211. package/packages/shared/src/platform/commands.ts +100 -0
  212. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  213. package/packages/shared/src/platform/exec.ts +220 -0
  214. package/packages/shared/src/platform/git.ts +155 -0
  215. package/packages/shared/src/platform/index.ts +15 -0
  216. package/packages/shared/src/platform/npm.ts +162 -0
  217. package/packages/shared/src/platform/openspec.ts +91 -0
  218. package/packages/shared/src/platform/paths.ts +276 -0
  219. package/packages/shared/src/platform/process-identify.ts +126 -0
  220. package/packages/shared/src/platform/process-scan.ts +94 -0
  221. package/packages/shared/src/platform/process.ts +168 -0
  222. package/packages/shared/src/platform/runner.ts +369 -0
  223. package/packages/shared/src/platform/shell.ts +44 -0
  224. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  225. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  226. package/packages/shared/src/recommended-extensions.ts +196 -0
  227. package/packages/shared/src/resolve-jiti.ts +62 -3
  228. package/packages/shared/src/rest-api.ts +97 -0
  229. package/packages/shared/src/semaphore.ts +83 -0
  230. package/packages/shared/src/source-matching.ts +126 -0
  231. package/packages/shared/src/test-support/setup-home.ts +74 -0
  232. package/packages/shared/src/tool-registry/definitions.ts +342 -0
  233. package/packages/shared/src/tool-registry/index.ts +56 -0
  234. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  235. package/packages/shared/src/tool-registry/registry.ts +262 -0
  236. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  237. package/packages/shared/src/tool-registry/types.ts +180 -0
  238. package/packages/shared/src/types.ts +7 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-agent-dashboard",
3
- "version": "0.2.9",
3
+ "version": "0.4.0",
4
4
  "description": "Web dashboard for monitoring and interacting with pi agent sessions",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,22 +46,29 @@
46
46
  "LICENSE"
47
47
  ],
48
48
  "scripts": {
49
+ "postinstall": "node packages/server/scripts/fix-pty-permissions.cjs",
49
50
  "dev": "npm run dev --workspace=@blackbelt-technology/pi-dashboard-web",
50
51
  "build": "npm run build --workspace=@blackbelt-technology/pi-dashboard-web",
51
- "test": "vitest run",
52
- "test:watch": "vitest",
52
+ "test": "HOME=$(mktemp -d -t pi-test-XXXXXX) vitest run",
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",
53
56
  "lint": "tsc --noEmit",
54
57
  "reload": "./scripts/reload-all.sh",
55
58
  "reload:check": "./scripts/reload-all.sh --check",
56
59
  "electron:dev": "npm run start:dev --workspace=@blackbelt-technology/pi-dashboard-electron",
57
60
  "electron:start": "npm run start --workspace=@blackbelt-technology/pi-dashboard-electron",
58
61
  "electron:make": "npm run make --workspace=@blackbelt-technology/pi-dashboard-electron",
59
- "electron:build": "bash packages/electron/scripts/build-installer.sh"
62
+ "electron:build": "bash packages/electron/scripts/build-installer.sh",
63
+ "site:dev": "npm --prefix site run dev",
64
+ "site:build": "npm --prefix site run build",
65
+ "site:preview": "npm --prefix site run preview",
66
+ "screenshots": "npm --prefix site run screenshots"
60
67
  },
61
68
  "dependencies": {
62
- "@blackbelt-technology/pi-dashboard-extension": "*",
63
- "@blackbelt-technology/pi-dashboard-server": "*",
64
- "@blackbelt-technology/pi-dashboard-web": "*"
69
+ "@blackbelt-technology/pi-dashboard-extension": "^0.4.0",
70
+ "@blackbelt-technology/pi-dashboard-server": "^0.4.0",
71
+ "@blackbelt-technology/pi-dashboard-web": "^0.4.0"
65
72
  },
66
73
  "devDependencies": {
67
74
  "jsdom": "^29.0.2",
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-dashboard-extension",
3
- "version": "0.2.9",
3
+ "version": "0.4.0",
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,23 @@
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.0",
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": "*"
24
28
  },
25
29
  "peerDependenciesMeta": {
26
30
  "@mariozechner/pi-coding-agent": {
27
31
  "optional": true
32
+ },
33
+ "@mariozechner/pi-tui": {
34
+ "optional": true
28
35
  }
29
36
  },
30
37
  "devDependencies": {
38
+ "@mariozechner/pi-tui": "*",
31
39
  "@types/ws": "^8.18.1"
32
40
  }
33
41
  }
@@ -7,6 +7,8 @@ vi.mock("@sinclair/typebox", () => ({
7
7
  String: vi.fn(() => ({})),
8
8
  Optional: vi.fn((x: any) => x),
9
9
  Array: vi.fn(() => ({})),
10
+ Union: vi.fn(() => ({})),
11
+ Literal: vi.fn(() => ({})),
10
12
  },
11
13
  }));
12
14
 
@@ -100,10 +102,28 @@ describe("registerAskUserTool", () => {
100
102
  expect(ctx.ui.select).toHaveBeenCalledWith("Pick", ["A", "B"], undefined);
101
103
  });
102
104
 
103
- it("handles malformed options string gracefully", async () => {
105
+ it("throws when select reaches execute with unparseable options string", async () => {
104
106
  const { tool, ctx } = getToolAndMockCtx();
105
- await tool.execute("id", { method: "select", title: "Pick", options: "not json" }, undefined, undefined, ctx);
106
- expect(ctx.ui.select).toHaveBeenCalledWith("Pick", [], undefined);
107
+ await expect(
108
+ tool.execute("id", { method: "select", title: "Pick", options: "not json" }, undefined, undefined, ctx),
109
+ ).rejects.toThrow(/options/i);
110
+ expect(ctx.ui.select).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it("throws when select is invoked with empty options array", async () => {
114
+ const { tool, ctx } = getToolAndMockCtx();
115
+ await expect(
116
+ tool.execute("id", { method: "select", title: "Pick", options: [] }, undefined, undefined, ctx),
117
+ ).rejects.toThrow(/options.*input/is);
118
+ expect(ctx.ui.select).not.toHaveBeenCalled();
119
+ });
120
+
121
+ it("throws when multiselect is invoked without options", async () => {
122
+ const { tool, ctx } = getToolAndMockCtx();
123
+ await expect(
124
+ tool.execute("id", { method: "multiselect", title: "Pick" }, undefined, undefined, ctx),
125
+ ).rejects.toThrow(/options/i);
126
+ expect(ctx.ui.multiselect).not.toHaveBeenCalled();
107
127
  });
108
128
  });
109
129
 
@@ -131,5 +151,282 @@ describe("registerAskUserTool", () => {
131
151
  const result = tool.prepareArguments({ method: "select", title: "Pick", options: "not json" });
132
152
  expect(result.options).toBe("not json");
133
153
  });
154
+
155
+ it("unwraps stringified params wrapper", () => {
156
+ const tool = getTool();
157
+ const result = tool.prepareArguments({
158
+ method: "select",
159
+ params: '{"title":"X","options":["a","b"]}',
160
+ });
161
+ expect(result.method).toBe("select");
162
+ expect(result.title).toBe("X");
163
+ expect(result.options).toEqual(["a", "b"]);
164
+ expect(result.params).toBeUndefined();
165
+ });
166
+
167
+ it("unwraps object-form params wrapper", () => {
168
+ const tool = getTool();
169
+ const result = tool.prepareArguments({
170
+ method: "select",
171
+ params: { title: "X", options: ["a", "b"] },
172
+ });
173
+ expect(result.method).toBe("select");
174
+ expect(result.title).toBe("X");
175
+ expect(result.options).toEqual(["a", "b"]);
176
+ expect(result.params).toBeUndefined();
177
+ });
178
+
179
+ it("copies question into title when title is absent", () => {
180
+ const tool = getTool();
181
+ const result = tool.prepareArguments({ method: "input", question: "Your name?" });
182
+ expect(result.title).toBe("Your name?");
183
+ });
184
+
185
+ it("does not overwrite explicit title with question", () => {
186
+ const tool = getTool();
187
+ const result = tool.prepareArguments({ method: "input", title: "T", question: "Q" });
188
+ expect(result.title).toBe("T");
189
+ });
190
+
191
+ it("top-level fields win over params wrapper", () => {
192
+ const tool = getTool();
193
+ const result = tool.prepareArguments({
194
+ method: "select",
195
+ title: "OuterTitle",
196
+ params: { title: "InnerTitle", options: ["a", "b"] },
197
+ });
198
+ expect(result.title).toBe("OuterTitle");
199
+ expect(result.options).toEqual(["a", "b"]);
200
+ });
201
+
202
+ it("rescues options JSON string inside params wrapper", () => {
203
+ const tool = getTool();
204
+ const result = tool.prepareArguments({
205
+ method: "select",
206
+ params: '{"title":"X","options":"[\\"a\\",\\"b\\"]"}',
207
+ });
208
+ expect(result.options).toEqual(["a", "b"]);
209
+ });
210
+
211
+ // ── batch rescue ────────────────────────────────────────────────
212
+
213
+ it("parses stringified questions array and synthesizes method=batch", () => {
214
+ const tool = getTool();
215
+ const result = tool.prepareArguments({
216
+ questions:
217
+ '[{"title":"Pick","method":"select","options":["a","b"]}]',
218
+ });
219
+ expect(result.method).toBe("batch");
220
+ expect(Array.isArray(result.questions)).toBe(true);
221
+ expect(result.questions).toHaveLength(1);
222
+ expect(result.title).toBe("Pick");
223
+ });
224
+
225
+ it("backfills missing outer title on explicit method=batch call", () => {
226
+ const tool = getTool();
227
+ const result = tool.prepareArguments({
228
+ method: "batch",
229
+ questions: [
230
+ { method: "confirm", question: "Proceed?" },
231
+ { method: "select", question: "Scope?", options: ["A", "B"] },
232
+ ],
233
+ });
234
+ expect(result.title).toBe("Proceed?");
235
+ expect(result.questions[0].title).toBe("Proceed?"); // sub-question rename also fired
236
+ expect(result.questions[1].title).toBe("Scope?");
237
+ });
238
+
239
+ it("bare questions array with no method synthesizes method=batch and pulls title", () => {
240
+ const tool = getTool();
241
+ const result = tool.prepareArguments({
242
+ questions: [{ method: "confirm", title: "Proceed?" }],
243
+ });
244
+ expect(result.method).toBe("batch");
245
+ expect(result.title).toBe("Proceed?");
246
+ });
247
+
248
+ it("pulls title from question or header if sub-question lacks title", () => {
249
+ const tool = getTool();
250
+ const result = tool.prepareArguments({
251
+ questions: [{ method: "input", question: "Your name?" }],
252
+ });
253
+ expect(result.method).toBe("batch");
254
+ expect(result.title).toBe("Your name?");
255
+ });
256
+
257
+ it("flattens input_type wrapper inside a sub-question", () => {
258
+ const tool = getTool();
259
+ const result = tool.prepareArguments({
260
+ method: "batch",
261
+ title: "T",
262
+ questions: [
263
+ {
264
+ title: "Pick",
265
+ input_type: { method: "select", options: ["a", "b"] },
266
+ },
267
+ ],
268
+ });
269
+ const sq = result.questions[0];
270
+ expect(sq.method).toBe("select");
271
+ expect(sq.options).toEqual(["a", "b"]);
272
+ expect(sq.input_type).toBeUndefined();
273
+ });
274
+
275
+ it("converts {label, value} options to labels and records a warning", () => {
276
+ const tool = getTool();
277
+ const result = tool.prepareArguments({
278
+ method: "batch",
279
+ title: "T",
280
+ questions: [
281
+ {
282
+ method: "select",
283
+ title: "Pick",
284
+ options: [
285
+ { label: "Sync now", value: "sync" },
286
+ { label: "Skip", value: "skip" },
287
+ ],
288
+ },
289
+ ],
290
+ });
291
+ expect(result.questions[0].options).toEqual(["Sync now", "Skip"]);
292
+ const warnings = (result as any).__normalizations as string[];
293
+ expect(warnings).toBeDefined();
294
+ expect(warnings.length).toBeGreaterThan(0);
295
+ expect(warnings[0]).toMatch(/label.*value/);
296
+ });
297
+
298
+ it("renames sub-question header to title", () => {
299
+ const tool = getTool();
300
+ const result = tool.prepareArguments({
301
+ method: "batch",
302
+ title: "T",
303
+ questions: [{ method: "input", header: "Enter name" }],
304
+ });
305
+ expect(result.questions[0].title).toBe("Enter name");
306
+ });
307
+ });
308
+
309
+ describe("batch execution", () => {
310
+ function getToolAndMockCtx() {
311
+ const pi = createMockPi();
312
+ registerAskUserTool(pi as any);
313
+ const tool = pi.registerTool.mock.calls[0][0];
314
+ const ctx = {
315
+ ui: {
316
+ confirm: vi.fn().mockResolvedValue(true),
317
+ select: vi.fn().mockResolvedValue("A"),
318
+ input: vi.fn().mockResolvedValue("hello"),
319
+ multiselect: vi.fn().mockResolvedValue(["A"]),
320
+ },
321
+ };
322
+ return { tool, ctx };
323
+ }
324
+
325
+ it("invokes ctx.ui primitives sequentially for each sub-question", async () => {
326
+ const { tool, ctx } = getToolAndMockCtx();
327
+ const result = await tool.execute(
328
+ "id",
329
+ {
330
+ method: "batch",
331
+ title: "Setup",
332
+ questions: [
333
+ { method: "input", title: "Name?" },
334
+ { method: "select", title: "Lang?", options: ["TS", "Py"] },
335
+ { method: "confirm", title: "Init git?" },
336
+ ],
337
+ },
338
+ undefined,
339
+ undefined,
340
+ ctx,
341
+ );
342
+ expect(ctx.ui.input).toHaveBeenCalledTimes(1);
343
+ expect(ctx.ui.select).toHaveBeenCalledTimes(1);
344
+ expect(ctx.ui.confirm).toHaveBeenCalledTimes(1);
345
+ expect(result.details.method).toBe("batch");
346
+ expect(result.details.results).toEqual(["hello", "A", true]);
347
+ expect(result.details.cancelled).toBe(false);
348
+ });
349
+
350
+ it("prepends batch title to sub-question titles", async () => {
351
+ const { tool, ctx } = getToolAndMockCtx();
352
+ await tool.execute(
353
+ "id",
354
+ {
355
+ method: "batch",
356
+ title: "Setup",
357
+ questions: [{ method: "input", title: "Name?" }],
358
+ },
359
+ undefined,
360
+ undefined,
361
+ ctx,
362
+ );
363
+ const firstCallTitle = ctx.ui.input.mock.calls[0][0];
364
+ expect(firstCallTitle).toContain("Setup");
365
+ expect(firstCallTitle).toContain("Name?");
366
+ });
367
+
368
+ it("stops on cancellation and returns partial results with cancelled=true", async () => {
369
+ const { tool, ctx } = getToolAndMockCtx();
370
+ // First sub-question returns a value; second cancels (undefined); third should not be called.
371
+ ctx.ui.input.mockResolvedValueOnce("first");
372
+ ctx.ui.select.mockResolvedValueOnce(undefined); // cancel
373
+ const result = await tool.execute(
374
+ "id",
375
+ {
376
+ method: "batch",
377
+ title: "T",
378
+ questions: [
379
+ { method: "input", title: "Q1" },
380
+ { method: "select", title: "Q2", options: ["a", "b"] },
381
+ { method: "confirm", title: "Q3" },
382
+ ],
383
+ },
384
+ undefined,
385
+ undefined,
386
+ ctx,
387
+ );
388
+ expect(result.details.cancelled).toBe(true);
389
+ expect(result.details.results).toEqual(["first", null]);
390
+ expect(ctx.ui.confirm).not.toHaveBeenCalled();
391
+ });
392
+
393
+ it("surfaces __normalizations warnings in details.warnings", async () => {
394
+ const { tool, ctx } = getToolAndMockCtx();
395
+ const prepared = tool.prepareArguments({
396
+ method: "batch",
397
+ title: "T",
398
+ questions: [
399
+ {
400
+ method: "select",
401
+ title: "Pick",
402
+ options: [
403
+ { label: "A", value: "a" },
404
+ { label: "B", value: "b" },
405
+ ],
406
+ },
407
+ ],
408
+ });
409
+ const result = await tool.execute("id", prepared, undefined, undefined, ctx);
410
+ expect(result.details.warnings).toBeDefined();
411
+ expect(result.details.warnings.length).toBeGreaterThan(0);
412
+ expect(result.details.warnings[0]).toMatch(/label.*value/);
413
+ });
414
+
415
+ it("throws if a batch sub-question is select with empty options", async () => {
416
+ const { tool, ctx } = getToolAndMockCtx();
417
+ await expect(
418
+ tool.execute(
419
+ "id",
420
+ {
421
+ method: "batch",
422
+ title: "T",
423
+ questions: [{ method: "select", title: "Pick", options: [] }],
424
+ },
425
+ undefined,
426
+ undefined,
427
+ ctx,
428
+ ),
429
+ ).rejects.toThrow(/options/i);
430
+ });
134
431
  });
135
432
  });
@@ -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
+ });