@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
@@ -1,100 +1,88 @@
1
1
  /**
2
- * Tests for fork entryId timing fix.
2
+ * Tests for bridge entryId stamping on message_end events.
3
3
  *
4
- * Verifies that the bridge's message_end entryId enrichment captures the
5
- * correct leaf ID (after pi core persists the entry), not the stale one
6
- * (before appendMessage runs).
4
+ * HISTORY: Originally this file modelled pi <0.69's synchronous emit pattern,
5
+ * where the bridge's `queueMicrotask` deferral ran BEFORE
6
+ * sessionManager.appendMessage. That design no longer reflects pi 0.70.x:
7
+ * pi awaits extension handlers inside _emitExtensionEvent, so the microtask
8
+ * resolves *inside* the awaited dispatcher, before persistence. The fix
9
+ * (see change: fix-per-message-fork) is `setTimeout(0)` (a macrotask)
10
+ * combined with reading `event.message.id` (which pi mutates in place
11
+ * during appendMessage) or a WeakMap populated by the wrapped appendMessage.
7
12
  *
8
- * The bug: pi core emits message_end via _emit() BEFORE calling
9
- * sessionManager.appendMessage(), so getLeafId() returns the previous leaf.
10
- * The fix: the bridge defers getLeafId() for message_end using queueMicrotask,
11
- * allowing appendMessage to run first.
13
+ * The previous test "message_start should still capture entryId immediately
14
+ * (no deferral)" codified the off-by-one bug as expected behavior — it has
15
+ * been REMOVED. The current test suite below models pi 0.70.x semantics
16
+ * directly. Detailed pi-0.70-specific scenarios live in
17
+ * `bridge-entry-id-pi-070.test.ts`.
12
18
  */
13
- import { describe, it, expect, vi } from "vitest";
19
+ import { describe, it, expect } from "vitest";
14
20
 
15
- /**
16
- * Simulates the pi core + bridge interaction for entryId enrichment.
17
- *
18
- * Pi core's _processAgentEvent does:
19
- * 1. _emit(event) — bridge handler called (async, not awaited)
20
- * 2. appendMessage(msg) — updates leafId synchronously
21
- *
22
- * The bridge handler (async) should yield via queueMicrotask before reading
23
- * getLeafId(), so that appendMessage has already run.
24
- */
25
- describe("message_end entryId timing", () => {
26
- it("deferred getLeafId() captures the post-persist entry ID", async () => {
27
- // Simulate sessionManager with mutable leafId
28
- let leafId = "user-entry-100"; // stale leaf before appendMessage
21
+ describe("message_end entryId timing on pi 0.70.x", () => {
22
+ it("setTimeout(0) deferral captures the post-persist entry ID", async () => {
23
+ // Simulate pi 0.70.x: bridge handler runs awaited, THEN appendMessage runs.
24
+ let leafId = "user-entry-100";
29
25
  const sessionManager = {
30
26
  getLeafId: () => leafId,
27
+ appendMessage: (msg: any) => {
28
+ msg.id = "assistant-entry-101";
29
+ leafId = msg.id;
30
+ return msg.id;
31
+ },
31
32
  };
32
33
 
33
- let capturedEntryId: string | undefined;
34
-
35
- // Simulate the bridge handler (with the fix: defers via queueMicrotask)
36
- const bridgeHandler = async () => {
37
- // This is what the fixed bridge does for message_end:
38
- await new Promise<void>(resolve => queueMicrotask(resolve));
39
- capturedEntryId = sessionManager.getLeafId();
34
+ const event = { type: "message_end", message: { role: "assistant" } as any };
35
+ let captured: string | undefined;
36
+ let sendDone!: () => void;
37
+ const sentP = new Promise<void>((r) => { sendDone = r; });
38
+
39
+ // Bridge handler: schedules a setTimeout(0) and returns synchronously.
40
+ // The awaited dispatcher then unwinds, appendMessage runs, AND finally
41
+ // the timeout fires.
42
+ const bridgeHandler = (ev: any) => {
43
+ setTimeout(() => {
44
+ captured = ev.message.id ?? sessionManager.getLeafId();
45
+ sendDone();
46
+ }, 0);
40
47
  };
41
48
 
42
- // Simulate pi core's _processAgentEvent:
43
- // 1. _emit calls handler (async, NOT awaited)
44
- const handlerPromise = bridgeHandler();
45
- // 2. appendMessage runs synchronously, updating leafId
46
- leafId = "assistant-entry-101";
47
-
48
- // Wait for the deferred handler to complete
49
- await handlerPromise;
49
+ // Simulate the dispatcher: await handler, then call appendMessage.
50
+ await bridgeHandler(event);
51
+ sessionManager.appendMessage(event.message);
52
+ await sentP;
50
53
 
51
- expect(capturedEntryId).toBe("assistant-entry-101");
54
+ expect(captured).toBe("assistant-entry-101");
52
55
  });
53
56
 
54
- it("immediate getLeafId() would capture the stale entry ID (demonstrates the bug)", async () => {
57
+ it("queueMicrotask deferral would NOT work on pi 0.70.x (regression demonstration)", async () => {
58
+ // Reproduces why we abandoned queueMicrotask: it resolves inside the
59
+ // awaited dispatcher, before appendMessage runs.
55
60
  let leafId = "user-entry-100";
56
- const sessionManager = {
57
- getLeafId: () => leafId,
58
- };
61
+ let captured: string | undefined;
59
62
 
60
- let capturedEntryId: string | undefined;
61
-
62
- // Simulate the OLD (buggy) bridge handler: reads getLeafId() immediately
63
- const buggyBridgeHandler = async () => {
64
- // No deferral — reads leafId before appendMessage runs
65
- capturedEntryId = sessionManager.getLeafId();
63
+ const buggyBridge = async () => {
64
+ await new Promise<void>((resolve) => queueMicrotask(resolve));
65
+ captured = leafId;
66
66
  };
67
67
 
68
- // Simulate pi core's _processAgentEvent:
69
- const handlerPromise = buggyBridgeHandler();
70
- leafId = "assistant-entry-101"; // too late — handler already read it
71
-
72
- await handlerPromise;
68
+ // Pi 0.70.x: await the bridge, THEN persist. Mirroring the real ordering:
69
+ const handlerP = buggyBridge();
70
+ await handlerP;
71
+ leafId = "assistant-entry-101"; // appendMessage runs after await
73
72
 
74
- // Bug: captures the stale leaf, not the assistant's entry
75
- expect(capturedEntryId).toBe("user-entry-100");
73
+ expect(captured).toBe("user-entry-100");
74
+ expect(captured).not.toBe("assistant-entry-101");
76
75
  });
77
76
 
78
- it("message_start should still capture entryId immediately (no deferral)", async () => {
79
- let leafId = "previous-assistant-entry-99";
80
- const sessionManager = {
81
- getLeafId: () => leafId,
82
- };
83
-
84
- let capturedEntryId: string | undefined;
85
-
86
- // Simulate bridge handler for message_start (immediate, no deferral)
87
- const messageStartHandler = async () => {
88
- capturedEntryId = sessionManager.getLeafId();
89
- };
90
-
91
- const handlerPromise = messageStartHandler();
92
- // User entry gets written after message_start
93
- leafId = "user-entry-100";
94
-
95
- await handlerPromise;
77
+ it("entry_persisted is the back-fill mechanism for user message_start (where event.message.id is unavailable)", () => {
78
+ // Behavioural assertion (pure data shape): when the bridge sends a
79
+ // user message_start, it stamps a nonce; later when pi persists the
80
+ // user entry, the bridge sends entry_persisted { nonce, entryId }.
81
+ // The reducer pairs them by nonce. See change: fix-per-message-fork.
82
+ const start = { type: "message_start", message: { role: "user" }, nonce: "n-1" };
83
+ const persisted = { type: "entry_persisted", entryId: "user-200", nonce: "n-1" };
96
84
 
97
- // message_start should capture the leaf BEFORE the user entry is written
98
- expect(capturedEntryId).toBe("previous-assistant-entry-99");
85
+ expect(start.nonce).toBe(persisted.nonce);
86
+ expect(persisted.entryId).toBe("user-200");
99
87
  });
100
88
  });
@@ -1,112 +1,124 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import { gatherGitInfo, detectBranch, detectRemoteUrl, detectPrNumber } from "../git-info.js";
3
-
4
- const { execSyncMock } = vi.hoisted(() => ({
5
- execSyncMock: vi.fn(),
1
+ /**
2
+ * Tests for git-info.ts.
3
+ *
4
+ * The file now delegates to `@blackbelt-technology/pi-dashboard-shared/platform/git.js`
5
+ * (the Recipe-based tool module). We mock that module so the tests focus
6
+ * on the git-info orchestration logic (branch detection, detached HEAD
7
+ * fallback, PR detection) without spawning git.
8
+ *
9
+ * See change: platform-command-executor.
10
+ */
11
+ import { describe, it, expect, vi, beforeEach } from "vitest";
12
+
13
+ const { currentBranchOr, headShaOr, remoteUrlOr, prNumberOr } = vi.hoisted(() => ({
14
+ currentBranchOr: vi.fn(),
15
+ headShaOr: vi.fn(),
16
+ remoteUrlOr: vi.fn(),
17
+ prNumberOr: vi.fn(),
6
18
  }));
7
19
 
8
- vi.mock("node:child_process", () => ({
9
- default: { execSync: execSyncMock },
10
- execSync: execSyncMock,
20
+ vi.mock("@blackbelt-technology/pi-dashboard-shared/platform/git.js", () => ({
21
+ currentBranchOr,
22
+ headShaOr,
23
+ remoteUrlOr,
24
+ prNumberOr,
11
25
  }));
12
26
 
27
+ import { gatherGitInfo, detectBranch, detectRemoteUrl, detectPrNumber } from "../git-info.js";
28
+
13
29
  describe("git-info", () => {
14
30
  beforeEach(() => {
15
- execSyncMock.mockReset();
31
+ currentBranchOr.mockReset();
32
+ headShaOr.mockReset();
33
+ remoteUrlOr.mockReset();
34
+ prNumberOr.mockReset();
16
35
  });
17
36
 
18
37
  describe("detectBranch", () => {
19
38
  it("returns branch name", () => {
20
- execSyncMock.mockReturnValue("main\n");
39
+ currentBranchOr.mockReturnValue("main");
21
40
  expect(detectBranch("/test")).toBe("main");
22
41
  });
23
42
 
24
43
  it("returns undefined when not a git repo", () => {
25
- execSyncMock.mockImplementation(() => { throw new Error("not a git repo"); });
44
+ currentBranchOr.mockReturnValue(undefined);
26
45
  expect(detectBranch("/test")).toBeUndefined();
27
46
  });
28
47
 
29
48
  it("returns short SHA for detached HEAD", () => {
30
- execSyncMock
31
- .mockReturnValueOnce("HEAD\n") // rev-parse --abbrev-ref HEAD
32
- .mockReturnValueOnce("abc1234\n"); // rev-parse --short HEAD
49
+ currentBranchOr.mockReturnValue("HEAD");
50
+ headShaOr.mockReturnValue("abc1234");
33
51
  expect(detectBranch("/test")).toBe("abc1234");
34
52
  });
35
53
 
36
54
  it("returns 'HEAD' as fallback if short SHA fails", () => {
37
- execSyncMock
38
- .mockReturnValueOnce("HEAD\n")
39
- .mockImplementationOnce(() => { throw new Error("fail"); });
55
+ currentBranchOr.mockReturnValue("HEAD");
56
+ headShaOr.mockReturnValue(undefined);
40
57
  expect(detectBranch("/test")).toBe("HEAD");
41
58
  });
42
59
  });
43
60
 
44
61
  describe("detectRemoteUrl", () => {
45
- it("returns remote URL", () => {
46
- execSyncMock.mockReturnValue("git@github.com:user/repo.git\n");
47
- expect(detectRemoteUrl("/test")).toBe("git@github.com:user/repo.git");
62
+ it("returns origin remote URL", () => {
63
+ remoteUrlOr.mockReturnValue("git@github.com:org/repo.git");
64
+ expect(detectRemoteUrl("/test")).toBe("git@github.com:org/repo.git");
48
65
  });
49
66
 
50
- it("returns undefined when no origin", () => {
51
- execSyncMock.mockImplementation(() => { throw new Error("no remote"); });
67
+ it("returns undefined when no remote is configured", () => {
68
+ remoteUrlOr.mockReturnValue(undefined);
52
69
  expect(detectRemoteUrl("/test")).toBeUndefined();
53
70
  });
54
71
  });
55
72
 
56
73
  describe("detectPrNumber", () => {
57
- it("returns PR number from gh CLI", () => {
58
- execSyncMock.mockReturnValue("42\n");
74
+ it("returns PR number when gh finds one", () => {
75
+ prNumberOr.mockReturnValue(42);
59
76
  expect(detectPrNumber("/test")).toBe(42);
60
77
  });
61
78
 
62
- it("returns undefined when gh CLI fails", () => {
63
- execSyncMock.mockImplementation(() => { throw new Error("gh not found"); });
79
+ it("returns undefined when gh is missing or no PR exists", () => {
80
+ prNumberOr.mockReturnValue(undefined);
64
81
  expect(detectPrNumber("/test")).toBeUndefined();
65
82
  });
66
83
  });
67
84
 
68
85
  describe("gatherGitInfo", () => {
69
- it("returns full git info with links", () => {
70
- execSyncMock
71
- .mockReturnValueOnce("feat/foo\n") // branch
72
- .mockReturnValueOnce("git@github.com:user/repo.git\n") // remote
73
- .mockReturnValueOnce("7\n"); // PR
74
-
75
- const info = gatherGitInfo("/test");
76
- expect(info).toEqual({
77
- gitBranch: "feat/foo",
78
- gitBranchUrl: "https://github.com/user/repo/tree/feat%2Ffoo",
79
- gitPrNumber: 7,
80
- gitPrUrl: "https://github.com/user/repo/pull/7",
81
- });
82
- });
83
-
84
86
  it("returns undefined when not a git repo", () => {
85
- execSyncMock.mockImplementation(() => { throw new Error("not a git repo"); });
87
+ currentBranchOr.mockReturnValue(undefined);
86
88
  expect(gatherGitInfo("/test")).toBeUndefined();
87
89
  });
88
90
 
89
- it("returns info without PR when gh fails", () => {
90
- execSyncMock
91
- .mockReturnValueOnce("main\n")
92
- .mockReturnValueOnce("git@github.com:user/repo.git\n")
93
- .mockImplementationOnce(() => { throw new Error("gh not found"); });
91
+ it("returns GitInfo for a repo with branch + remote + PR", () => {
92
+ currentBranchOr.mockReturnValue("feature/x");
93
+ remoteUrlOr.mockReturnValue("git@github.com:org/repo.git");
94
+ prNumberOr.mockReturnValue(123);
94
95
 
95
96
  const info = gatherGitInfo("/test");
96
- expect(info?.gitBranch).toBe("main");
97
- expect(info?.gitPrNumber).toBeUndefined();
98
- expect(info?.gitPrUrl).toBeUndefined();
97
+ expect(info?.gitBranch).toBe("feature/x");
98
+ expect(info?.gitPrNumber).toBe(123);
99
+ // Branch URLs URL-encode slashes (feature/x → feature%2Fx) in some builders
100
+ expect(info?.gitBranchUrl).toMatch(/feature(\/|%2F)x/);
101
+ expect(info?.gitPrUrl).toContain("123");
99
102
  });
100
103
 
101
- it("returns info without links when no remote", () => {
102
- execSyncMock
103
- .mockReturnValueOnce("main\n")
104
- .mockImplementationOnce(() => { throw new Error("no remote"); })
105
- .mockImplementationOnce(() => { throw new Error("gh not found"); });
104
+ it("returns GitInfo without links when there's no remote", () => {
105
+ currentBranchOr.mockReturnValue("main");
106
+ remoteUrlOr.mockReturnValue(undefined);
107
+ prNumberOr.mockReturnValue(undefined);
106
108
 
107
109
  const info = gatherGitInfo("/test");
108
110
  expect(info?.gitBranch).toBe("main");
109
111
  expect(info?.gitBranchUrl).toBeUndefined();
110
112
  });
113
+
114
+ it("handles detached HEAD with short SHA", () => {
115
+ currentBranchOr.mockReturnValue("HEAD");
116
+ headShaOr.mockReturnValue("abc1234");
117
+ remoteUrlOr.mockReturnValue(undefined);
118
+ prNumberOr.mockReturnValue(undefined);
119
+
120
+ const info = gatherGitInfo("/test");
121
+ expect(info?.gitBranch).toBe("abc1234");
122
+ });
111
123
  });
112
124
  });
@@ -0,0 +1,137 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { MultiSelectList } from "../multiselect-list.js";
3
+
4
+ function make(options: string[] = ["A", "B", "C"], title = "Pick", message?: string) {
5
+ const list = new MultiSelectList(title, options, message);
6
+ const onConfirm = vi.fn();
7
+ const onCancel = vi.fn();
8
+ list.onConfirm = onConfirm;
9
+ list.onCancel = onCancel;
10
+ return { list, onConfirm, onCancel };
11
+ }
12
+
13
+ describe("MultiSelectList", () => {
14
+ describe("keybindings", () => {
15
+ it("space toggles the checked state of the current item and nothing else", () => {
16
+ const { list, onConfirm } = make();
17
+ list.handleInput(" "); // toggle index 0 → A checked
18
+ list.handleInput("\r");
19
+ expect(onConfirm).toHaveBeenCalledWith(["A"]);
20
+ });
21
+
22
+ it("arrow down moves cursor and space toggles the new current item", () => {
23
+ const { list, onConfirm } = make();
24
+ list.handleInput("\u001b[B"); // cursor → 1 (B)
25
+ list.handleInput(" "); // B checked
26
+ list.handleInput("\r");
27
+ expect(onConfirm).toHaveBeenCalledWith(["B"]);
28
+ });
29
+
30
+ it("j / k navigation works like arrows", () => {
31
+ const { list, onConfirm } = make();
32
+ list.handleInput("j"); // cursor → 1
33
+ list.handleInput("j"); // cursor → 2
34
+ list.handleInput(" "); // C checked
35
+ list.handleInput("k"); // cursor → 1
36
+ list.handleInput(" "); // B checked
37
+ list.handleInput("\r");
38
+ // selected values returned in ORIGINAL order
39
+ expect(onConfirm).toHaveBeenCalledWith(["B", "C"]);
40
+ });
41
+
42
+ it("enter with nothing checked confirms with []", () => {
43
+ const { list, onConfirm } = make();
44
+ list.handleInput("\r");
45
+ expect(onConfirm).toHaveBeenCalledWith([]);
46
+ });
47
+
48
+ it("escape cancels; no confirm is fired", () => {
49
+ const { list, onConfirm, onCancel } = make();
50
+ list.handleInput(" "); // check A
51
+ list.handleInput("\u001b");
52
+ expect(onCancel).toHaveBeenCalledTimes(1);
53
+ expect(onConfirm).not.toHaveBeenCalled();
54
+ });
55
+
56
+ it("pressing 'a' does NOT bulk-toggle (no select-all in TUI)", () => {
57
+ const { list, onConfirm } = make();
58
+ list.handleInput("a");
59
+ list.handleInput("\r");
60
+ expect(onConfirm).toHaveBeenCalledWith([]);
61
+ });
62
+
63
+ it("cursor does not go below 0", () => {
64
+ const { list } = make();
65
+ list.handleInput("k");
66
+ list.handleInput("k");
67
+ expect(list.getCursor()).toBe(0);
68
+ });
69
+
70
+ it("cursor does not go past last item", () => {
71
+ const { list } = make(["A", "B"]);
72
+ list.handleInput("j");
73
+ list.handleInput("j");
74
+ list.handleInput("j");
75
+ expect(list.getCursor()).toBe(1);
76
+ });
77
+
78
+ it("toggling twice returns item to unchecked", () => {
79
+ const { list, onConfirm } = make();
80
+ list.handleInput(" ");
81
+ list.handleInput(" ");
82
+ list.handleInput("\r");
83
+ expect(onConfirm).toHaveBeenCalledWith([]);
84
+ });
85
+
86
+ it("selected order follows original option order, not toggle order", () => {
87
+ const { list, onConfirm } = make(["A", "B", "C", "D"]);
88
+ // toggle D first, then A, then C
89
+ list.handleInput("j");
90
+ list.handleInput("j");
91
+ list.handleInput("j");
92
+ list.handleInput(" "); // D
93
+ list.handleInput("k");
94
+ list.handleInput("k");
95
+ list.handleInput("k");
96
+ list.handleInput(" "); // A
97
+ list.handleInput("j");
98
+ list.handleInput("j");
99
+ list.handleInput(" "); // C
100
+ list.handleInput("\r");
101
+ expect(onConfirm).toHaveBeenCalledWith(["A", "C", "D"]);
102
+ });
103
+ });
104
+
105
+ describe("render", () => {
106
+ it("includes footer hint", () => {
107
+ const { list } = make();
108
+ const lines = list.render(80);
109
+ expect(lines.some((l) => l.includes("space toggle"))).toBe(true);
110
+ expect(lines.some((l) => l.includes("enter confirm"))).toBe(true);
111
+ expect(lines.some((l) => l.includes("esc cancel"))).toBe(true);
112
+ });
113
+
114
+ it("renders [ ] for unchecked and [x] for checked items", () => {
115
+ const { list } = make();
116
+ list.handleInput(" "); // check A
117
+ const lines = list.render(80);
118
+ expect(lines.some((l) => l.includes("[x] A"))).toBe(true);
119
+ expect(lines.some((l) => l.includes("[ ] B"))).toBe(true);
120
+ });
121
+
122
+ it("renders cursor marker on current item", () => {
123
+ const { list } = make();
124
+ list.handleInput("j"); // cursor → 1
125
+ const lines = list.render(80);
126
+ // Cursor line should start with "▸ " somewhere
127
+ expect(lines.some((l) => l.startsWith("▸ ") && l.includes("B"))).toBe(true);
128
+ });
129
+
130
+ it("includes title and message when provided", () => {
131
+ const { list } = make(["A", "B"], "Pick one or more", "Some context");
132
+ const lines = list.render(80);
133
+ expect(lines.some((l) => l.includes("Pick one or more"))).toBe(true);
134
+ expect(lines.some((l) => l.includes("Some context"))).toBe(true);
135
+ });
136
+ });
137
+ });
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Repo-level invariant: the bridge MUST NOT call pi's session-replacement
3
+ * APIs (`pi.newSession(...)`, `ctx.fork(...)`, `ctx.switchSession(...)`)
4
+ * from any code under `packages/extension/src/`.
5
+ *
6
+ * Rationale: pi 0.69.0+ invalidates captured pre-replacement `pi`/`ctx`/
7
+ * session-bound objects on next access after these calls. The bridge
8
+ * holds long-lived caches (`cachedCtx`, `cachedModelRegistry`,
9
+ * `cachedHasUI` in `bridge.ts`; `modelRegistry` in `provider-register.ts`)
10
+ * that depend on pi being the ONLY originator of session replacement, so
11
+ * we can re-capture inside the resulting `session_start` handler keyed on
12
+ * `event.reason ∈ {"new","fork","resume"}`.
13
+ *
14
+ * If this test fails: do NOT add the call. Either drive the user-facing
15
+ * action through the dashboard server (which prompts the user, who
16
+ * triggers replacement via pi's UI), or wrap your post-switch work in
17
+ * the `withSession` callback that pi 0.69+ exposes on each replacement
18
+ * API and capture the freshly-emitted ReplacedSessionContext there.
19
+ *
20
+ * See change: pi-zero-seventy-compat.
21
+ */
22
+ import { describe, it, expect } from "vitest";
23
+ import fs from "node:fs/promises";
24
+ import path from "node:path";
25
+ import url from "node:url";
26
+
27
+ /**
28
+ * Each pattern matches `<receiver>.<method>(` allowing for whitespace and
29
+ * tolerating common variations like `await pi.newSession(...)`. Prefixed
30
+ * with a non-word boundary so we don't flag method names embedded in
31
+ * longer identifiers.
32
+ */
33
+ const PATTERNS: ReadonlyArray<{ name: string; re: RegExp }> = [
34
+ { name: "pi.newSession", re: /(?:^|[^.\w])pi\.newSession\s*\(/ },
35
+ { name: "ctx.fork", re: /(?:^|[^.\w])ctx\.fork\s*\(/ },
36
+ { name: "ctx.switchSession", re: /(?:^|[^.\w])ctx\.switchSession\s*\(/ },
37
+ ];
38
+
39
+ /**
40
+ * Per-line opt-out marker. Use only for documented exceptions (e.g. a
41
+ * future migration cell that intentionally drives a replacement and
42
+ * fully re-binds via `withSession`):
43
+ * await pi.newSession({ withSession: ... }); // ban:session-replacement-ok
44
+ */
45
+ const OPT_OUT_MARKER = "ban:session-replacement-ok";
46
+
47
+ async function* walk(dir: string): AsyncGenerator<string> {
48
+ const entries = await fs.readdir(dir, { withFileTypes: true });
49
+ for (const entry of entries) {
50
+ const full = path.join(dir, entry.name);
51
+ if (entry.isDirectory()) {
52
+ if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "__tests__") continue;
53
+ yield* walk(full);
54
+ } else if (entry.isFile() && /\.(ts|tsx|mts|cts)$/.test(entry.name)) {
55
+ yield full;
56
+ }
57
+ }
58
+ }
59
+
60
+ describe("no session-replacement API calls in packages/extension/src/", () => {
61
+ it("bridge code never invokes pi.newSession / ctx.fork / ctx.switchSession", async () => {
62
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
63
+ const srcDir = path.resolve(here, "..");
64
+ const repoRoot = path.resolve(here, "..", "..", "..", "..");
65
+
66
+ const violations: Array<{ file: string; line: number; pattern: string; text: string }> = [];
67
+
68
+ for await (const file of walk(srcDir)) {
69
+ const content = await fs.readFile(file, "utf-8");
70
+ const lines = content.split(/\r?\n/);
71
+ lines.forEach((line, idx) => {
72
+ if (line.includes(OPT_OUT_MARKER)) return;
73
+ for (const { name, re } of PATTERNS) {
74
+ if (re.test(line)) {
75
+ violations.push({
76
+ file: path.relative(repoRoot, file),
77
+ line: idx + 1,
78
+ pattern: name,
79
+ text: line.trim(),
80
+ });
81
+ }
82
+ }
83
+ });
84
+ }
85
+
86
+ if (violations.length > 0) {
87
+ const msg =
88
+ `Bridge code MUST NOT call pi session-replacement APIs.\n` +
89
+ `pi 0.69.0+ invalidates captured pre-replacement pi/ctx after these calls;\n` +
90
+ `the bridge relies on pi being the sole originator of replacement so it can\n` +
91
+ `re-capture state inside the resulting session_start handler.\n\n` +
92
+ `Offenders (${violations.length}):\n` +
93
+ violations
94
+ .map((v) => ` ${v.file}:${v.line} [${v.pattern}] ${v.text}`)
95
+ .join("\n");
96
+ expect(violations, msg).toEqual([]);
97
+ }
98
+ });
99
+ });