@blackbelt-technology/pi-agent-dashboard 0.5.3 → 0.5.4

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 (212) hide show
  1. package/AGENTS.md +19 -30
  2. package/README.md +69 -1
  3. package/docs/architecture.md +89 -165
  4. package/package.json +10 -7
  5. package/packages/extension/package.json +2 -2
  6. package/packages/extension/src/__tests__/bridge-default-model-gate.test.ts +47 -0
  7. package/packages/extension/src/__tests__/bridge-followup-chat-order.test.ts +215 -0
  8. package/packages/extension/src/__tests__/bridge-followup-multi-entry.test.ts +202 -0
  9. package/packages/extension/src/__tests__/bridge-queue-update-forward.test.ts +77 -0
  10. package/packages/extension/src/__tests__/bridge-retry-ordering.test.ts +148 -0
  11. package/packages/extension/src/__tests__/bridge-shadow-queue-drain.test.ts +221 -0
  12. package/packages/extension/src/__tests__/bridge-shadow-queue-gate.test.ts +299 -0
  13. package/packages/extension/src/__tests__/bridge-shutdown-reset.test.ts +238 -0
  14. package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +127 -31
  15. package/packages/extension/src/__tests__/command-handler.test.ts +105 -3
  16. package/packages/extension/src/__tests__/fixtures/usage-limit-error-strings.ts +127 -0
  17. package/packages/extension/src/__tests__/source-detector.test.ts +15 -0
  18. package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +12 -0
  19. package/packages/extension/src/bridge-default-model-gate.ts +32 -0
  20. package/packages/extension/src/bridge.ts +299 -20
  21. package/packages/extension/src/command-handler.ts +53 -7
  22. package/packages/extension/src/dashboard-default-adapter.ts +5 -0
  23. package/packages/extension/src/prompt-bus.ts +15 -0
  24. package/packages/extension/src/slash-dispatch.ts +30 -15
  25. package/packages/extension/src/source-detector.ts +13 -5
  26. package/packages/extension/src/usage-limit-orderer.ts +18 -1
  27. package/packages/server/bin/pi-dashboard.mjs +62 -14
  28. package/packages/server/package.json +9 -5
  29. package/packages/server/src/__tests__/browser-gateway-register-handler.test.ts +69 -0
  30. package/packages/server/src/__tests__/cli-env-no-clobber.test.ts +46 -0
  31. package/packages/server/src/__tests__/cli-no-bootstrap-references.test.ts +69 -0
  32. package/packages/server/src/__tests__/cli-parse.test.ts +9 -10
  33. package/packages/server/src/__tests__/cli-version.test.ts +151 -0
  34. package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +9 -0
  35. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +9 -0
  36. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +9 -0
  37. package/packages/server/src/__tests__/directory-service-toctou.test.ts +9 -0
  38. package/packages/server/src/__tests__/directory-service.test.ts +9 -0
  39. package/packages/server/src/__tests__/doctor-route.test.ts +53 -0
  40. package/packages/server/src/__tests__/event-wiring-queue-state.test.ts +156 -0
  41. package/packages/server/src/__tests__/event-wiring-resume-clear.test.ts +105 -0
  42. package/packages/server/src/__tests__/health-shape.test.ts +35 -12
  43. package/packages/server/src/__tests__/installed-package-enricher.test.ts +12 -12
  44. package/packages/server/src/__tests__/is-activity-event.test.ts +4 -7
  45. package/packages/server/src/__tests__/package-routes.test.ts +6 -2
  46. package/packages/server/src/__tests__/pi-changelog-routes.test.ts +10 -13
  47. package/packages/server/src/__tests__/pi-core-checker.test.ts +2 -2
  48. package/packages/server/src/__tests__/pi-version-skew.test.ts +3 -2
  49. package/packages/server/src/__tests__/plugin-activation-routes.test.ts +267 -0
  50. package/packages/server/src/__tests__/plugin-intent-cache.test.ts +75 -0
  51. package/packages/server/src/__tests__/preferences-store.test.ts +196 -0
  52. package/packages/server/src/__tests__/reattach-placement.test.ts +9 -0
  53. package/packages/server/src/__tests__/recommended-routes.test.ts +2 -2
  54. package/packages/server/src/__tests__/recovery-server.test.ts +203 -0
  55. package/packages/server/src/__tests__/session-action-handler-clear-queue.test.ts +153 -0
  56. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +43 -0
  57. package/packages/server/src/__tests__/session-order-manager.test.ts +9 -0
  58. package/packages/server/src/__tests__/session-order-reboot.test.ts +9 -0
  59. package/packages/server/src/__tests__/session-ordering-integration.test.ts +9 -0
  60. package/packages/server/src/browser-gateway.ts +83 -5
  61. package/packages/server/src/browser-handlers/directory-handler.ts +69 -0
  62. package/packages/server/src/browser-handlers/session-action-handler.ts +89 -0
  63. package/packages/server/src/browser-handlers/subscription-handler.ts +23 -0
  64. package/packages/server/src/changelog-parser.ts +1 -1
  65. package/packages/server/src/cli.ts +68 -250
  66. package/packages/server/src/event-status-extraction.ts +14 -62
  67. package/packages/server/src/event-wiring.ts +23 -10
  68. package/packages/server/src/memory-session-manager.ts +4 -0
  69. package/packages/server/src/pi-core-checker.ts +1 -1
  70. package/packages/server/src/pi-dev-version-check.ts +1 -1
  71. package/packages/server/src/pi-version-skew.ts +24 -46
  72. package/packages/server/src/plugin-intent-cache.ts +67 -0
  73. package/packages/server/src/preferences-store.ts +199 -13
  74. package/packages/server/src/recovery-server.ts +366 -0
  75. package/packages/server/src/routes/__tests__/manifest-route.test.ts +138 -0
  76. package/packages/server/src/routes/doctor-routes.ts +26 -21
  77. package/packages/server/src/routes/manifest-route.ts +162 -0
  78. package/packages/server/src/routes/openspec-routes.ts +4 -25
  79. package/packages/server/src/routes/pi-changelog-routes.ts +5 -24
  80. package/packages/server/src/routes/pi-core-routes.ts +3 -23
  81. package/packages/server/src/routes/plugin-activation-routes.ts +193 -0
  82. package/packages/server/src/routes/recommended-routes.ts +21 -0
  83. package/packages/server/src/routes/system-routes.ts +73 -11
  84. package/packages/server/src/server.ts +105 -307
  85. package/packages/server/src/session-api.ts +5 -63
  86. package/packages/shared/package.json +1 -1
  87. package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +28 -0
  88. package/packages/shared/src/__tests__/binary-lookup-spawn-env.test.ts +61 -0
  89. package/packages/shared/src/__tests__/binary-lookup.test.ts +16 -0
  90. package/packages/shared/src/__tests__/bridge-register.test.ts +67 -0
  91. package/packages/shared/src/__tests__/ci-electron-no-side-effects.test.ts +129 -0
  92. package/packages/shared/src/__tests__/config.test.ts +40 -0
  93. package/packages/shared/src/__tests__/dashboard-paths.test.ts +81 -0
  94. package/packages/shared/src/__tests__/ensure-windows-path.test.ts +112 -0
  95. package/packages/shared/src/__tests__/intent-types.test.ts +120 -0
  96. package/packages/shared/src/__tests__/jiti-packages-parity.test.ts +85 -0
  97. package/packages/shared/src/__tests__/legacy-managed-dir.test.ts +59 -0
  98. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +12 -0
  99. package/packages/shared/src/__tests__/no-electron-execpath-spawn.test.ts +149 -0
  100. package/packages/shared/src/__tests__/no-flow-command-route-claims.test.ts +71 -0
  101. package/packages/shared/src/__tests__/no-flow-references-in-shell.test.ts +221 -0
  102. package/packages/shared/src/__tests__/no-managed-dir-reference.test.ts +134 -0
  103. package/packages/shared/src/__tests__/no-pi-dashboard-version-jiti-gate.test.ts +41 -0
  104. package/packages/shared/src/__tests__/no-primitive-direct-import.test.ts +235 -0
  105. package/packages/shared/src/__tests__/no-server-imports-in-resolver.test.ts +53 -0
  106. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +54 -101
  107. package/packages/shared/src/__tests__/node-spawn.test.ts +29 -13
  108. package/packages/shared/src/__tests__/pi-package-resolver.test.ts +300 -0
  109. package/packages/shared/src/__tests__/plugin-activation-contracts.test.ts +74 -0
  110. package/packages/shared/src/__tests__/plugin-bridge-classify-source.test.ts +73 -0
  111. package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +17 -5
  112. package/packages/shared/src/__tests__/plugin-bridge-register-packages.test.ts +233 -0
  113. package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +19 -9
  114. package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +154 -15
  115. package/packages/shared/src/__tests__/recommended-extensions.test.ts +28 -10
  116. package/packages/shared/src/__tests__/resolver-parity-with-scanner.test.ts +76 -0
  117. package/packages/shared/src/__tests__/server-identity.test.ts +127 -0
  118. package/packages/shared/src/__tests__/server-launcher.test.ts +35 -0
  119. package/packages/shared/src/__tests__/source-matching.test.ts +5 -5
  120. package/packages/shared/src/__tests__/sync-versions-spec.test.ts +76 -0
  121. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +50 -2
  122. package/packages/shared/src/bridge-register.ts +35 -2
  123. package/packages/shared/src/browser-protocol.ts +176 -2
  124. package/packages/shared/src/config.ts +12 -0
  125. package/packages/shared/src/dashboard-paths.ts +69 -0
  126. package/packages/shared/src/dashboard-plugin/index.ts +2 -0
  127. package/packages/shared/src/dashboard-plugin/intent-types.ts +93 -0
  128. package/packages/shared/src/dashboard-plugin/manifest-types.ts +55 -1
  129. package/packages/shared/src/dashboard-plugin/plugin-status.ts +82 -0
  130. package/packages/shared/src/dashboard-plugin/slot-props.ts +11 -0
  131. package/packages/shared/src/dashboard-plugin/slot-types.ts +16 -2
  132. package/packages/shared/src/dashboard-plugin/ui-primitives.ts +287 -0
  133. package/packages/shared/src/dashboard-starter.ts +22 -0
  134. package/packages/shared/src/doctor-core.ts +49 -27
  135. package/packages/shared/src/launch-source-types.ts +9 -9
  136. package/packages/shared/src/legacy-managed-dir.ts +97 -0
  137. package/packages/shared/src/mdns-discovery.ts +4 -1
  138. package/packages/shared/src/pi-package-resolver.ts +388 -0
  139. package/packages/shared/src/platform/binary-lookup.ts +27 -3
  140. package/packages/shared/src/platform/ensure-windows-path.ts +95 -0
  141. package/packages/shared/src/platform/exec.ts +22 -0
  142. package/packages/shared/src/platform/node-spawn.ts +42 -41
  143. package/packages/shared/src/plugin-bridge-register.ts +275 -18
  144. package/packages/shared/src/protocol.ts +94 -2
  145. package/packages/shared/src/recommended-extensions.ts +34 -10
  146. package/packages/shared/src/server-identity.ts +74 -5
  147. package/packages/shared/src/server-launcher.ts +20 -0
  148. package/packages/shared/src/source-matching.ts +1 -1
  149. package/packages/shared/src/tool-registry/__tests__/node-script-toargv-fallback.test.ts +84 -0
  150. package/packages/shared/src/tool-registry/definitions.ts +91 -7
  151. package/packages/shared/src/types.ts +12 -8
  152. package/scripts/maybe-patch-package.cjs +44 -0
  153. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +0 -263
  154. package/packages/server/src/__tests__/bootstrap-queue.test.ts +0 -120
  155. package/packages/server/src/__tests__/bootstrap-routes.test.ts +0 -125
  156. package/packages/server/src/__tests__/bootstrap-state.test.ts +0 -119
  157. package/packages/server/src/__tests__/cli-bootstrap.test.ts +0 -36
  158. package/packages/server/src/__tests__/event-status-extraction-flow.test.ts +0 -55
  159. package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +0 -149
  160. package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +0 -180
  161. package/packages/server/src/__tests__/post-install-rescan.test.ts +0 -134
  162. package/packages/server/src/__tests__/system-routes-reextract.test.ts +0 -91
  163. package/packages/server/src/bootstrap-install-from-list.ts +0 -232
  164. package/packages/server/src/bootstrap-queue.ts +0 -130
  165. package/packages/server/src/bootstrap-state.ts +0 -159
  166. package/packages/server/src/legacy-pi-cleanup.ts +0 -151
  167. package/packages/server/src/routes/bootstrap-routes.ts +0 -125
  168. package/packages/shared/src/__tests__/bootstrap/README.md +0 -133
  169. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +0 -378
  170. package/packages/shared/src/__tests__/bootstrap/assertions.ts +0 -136
  171. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +0 -47
  172. package/packages/shared/src/__tests__/bootstrap/cube.ts +0 -66
  173. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +0 -84
  174. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +0 -90
  175. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +0 -34
  176. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +0 -20
  177. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +0 -62
  178. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +0 -34
  179. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +0 -49
  180. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +0 -12
  181. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +0 -156
  182. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +0 -157
  183. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +0 -102
  184. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +0 -76
  185. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +0 -94
  186. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +0 -87
  187. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +0 -143
  188. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +0 -64
  189. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +0 -77
  190. package/packages/shared/src/__tests__/bootstrap/families/index.ts +0 -19
  191. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +0 -61
  192. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +0 -50
  193. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +0 -272
  194. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +0 -58
  195. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +0 -84
  196. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +0 -9
  197. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +0 -85
  198. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +0 -122
  199. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +0 -36
  200. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +0 -39
  201. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +0 -220
  202. package/packages/shared/src/__tests__/bootstrap/harness.ts +0 -413
  203. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +0 -125
  204. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +0 -132
  205. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +0 -72
  206. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +0 -68
  207. package/packages/shared/src/__tests__/install-managed-node.test.ts +0 -192
  208. package/packages/shared/src/__tests__/installable-list.test.ts +0 -130
  209. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +0 -52
  210. package/packages/shared/src/bootstrap-install.ts +0 -406
  211. package/packages/shared/src/installable-list.ts +0 -152
  212. package/packages/shared/src/launch-source-flag.ts +0 -14
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Bridge wire-ordering invariant for drained queued user messages.
3
+ *
4
+ * Applies to BOTH drain boundaries:
5
+ * - Steer drain at `turn_end` (Enter while streaming)
6
+ * - Follow-up drain at `agent_end` (Alt+Enter while streaming)
7
+ *
8
+ * Pi emits four events synchronously back-to-back at the drain boundary:
9
+ *
10
+ * 1. message_end (assistant — the final response of the just-completed turn)
11
+ * 2. turn_end OR agent_end (the drain boundary)
12
+ * 3. message_start (user — the drained steer or follow-up text, e.g. "asd")
13
+ * 4. message_end (user — same text)
14
+ *
15
+ * Pre-fix bug: the bridge defers `message_end` sends via `setTimeout(0)`
16
+ * (for entryId capture per `fix-per-message-fork`) but sends `message_start`
17
+ * synchronously. The drained user `message_start` therefore lands on the
18
+ * wire BEFORE the preceding assistant `message_end`. The client's reducer
19
+ * appends the user message to `state.messages[]` at `message_start`, then
20
+ * the assistant message at the (later) `message_end` — so the chat shows
21
+ * the follow-up "asd" ABOVE the assistant's final response.
22
+ *
23
+ * Fix: defer user `message_start` sends via the same `setTimeout(0)`. All
24
+ * user-role messages are queued in the timer FIFO behind any pending
25
+ * `message_end` deferrals, preserving pi's emit order on the wire.
26
+ *
27
+ * Assistant `message_start` MUST stay sync — `message_update` events fire
28
+ * sync and depend on the reducer having seen `message_start` first (to
29
+ * reset `streamingTextFlushed`).
30
+ *
31
+ * See change: add-followup-edit-and-steer-cancel (chat-order scenario).
32
+ */
33
+
34
+ import { describe, it, expect } from "vitest";
35
+
36
+ interface WireEvent {
37
+ eventType: string;
38
+ role?: "user" | "assistant";
39
+ content?: string;
40
+ }
41
+
42
+ /**
43
+ * Simulates the bridge's event-forwarding pipeline with the new
44
+ * deferral rule applied to USER message_start. Runs everything in
45
+ * a single synchronous tick to mirror pi's emit cadence, then drains
46
+ * the macrotask queue to capture the final wire order.
47
+ */
48
+ class BridgeSim {
49
+ readonly wire: WireEvent[] = [];
50
+
51
+ onMessageStart(role: "user" | "assistant", content: string): void {
52
+ if (role === "user") {
53
+ // FIX: defer user message_start to match message_end's deferral.
54
+ setTimeout(() => {
55
+ this.wire.push({ eventType: "message_start", role, content });
56
+ }, 0);
57
+ return;
58
+ }
59
+ // Assistant message_start sent sync (message_update depends on it).
60
+ this.wire.push({ eventType: "message_start", role, content });
61
+ }
62
+
63
+ onMessageEnd(role: "user" | "assistant", content: string): void {
64
+ // Existing behaviour: ALL message_end sends are deferred via setTimeout(0)
65
+ // for entryId capture (fix-per-message-fork).
66
+ setTimeout(() => {
67
+ this.wire.push({ eventType: "message_end", role, content });
68
+ }, 0);
69
+ }
70
+
71
+ onAgentEnd(): void {
72
+ // Sent sync.
73
+ this.wire.push({ eventType: "agent_end" });
74
+ }
75
+
76
+ onTurnEnd(): void {
77
+ // Sent sync (mirrors agent_end).
78
+ this.wire.push({ eventType: "turn_end" });
79
+ }
80
+
81
+ /** Flush pending setTimeout(0) callbacks. */
82
+ async flush(): Promise<void> {
83
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
84
+ // Two extra ticks in case any callback re-queues.
85
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
86
+ }
87
+ }
88
+
89
+ describe("Bridge drained-followup chat-order invariant", () => {
90
+ it("user message_start for drained follow-up lands AFTER preceding assistant message_end", async () => {
91
+ const sim = new BridgeSim();
92
+
93
+ // Pi's sync emit order at agent_end with a queued follow-up:
94
+ // 1. assistant message_end (the weather report)
95
+ // 2. agent_end
96
+ // 3. user message_start ("asd" — the drained follow-up)
97
+ // 4. user message_end ("asd")
98
+ sim.onMessageEnd("assistant", "weather report");
99
+ sim.onAgentEnd();
100
+ sim.onMessageStart("user", "asd");
101
+ sim.onMessageEnd("user", "asd");
102
+
103
+ await sim.flush();
104
+
105
+ const types = sim.wire.map((e) => `${e.eventType}:${e.role ?? "-"}`);
106
+ // Expected wire order after the fix:
107
+ // 1. agent_end (sync, fires first)
108
+ // 2. assistant message_end (deferred, FIFO #1)
109
+ // 3. user message_start (deferred, FIFO #2 — after the fix)
110
+ // 4. user message_end (deferred, FIFO #3)
111
+ expect(types).toEqual([
112
+ "agent_end:-",
113
+ "message_end:assistant",
114
+ "message_start:user",
115
+ "message_end:user",
116
+ ]);
117
+
118
+ // Critical invariant: the drained user message_start MUST NOT come
119
+ // before the preceding assistant message_end.
120
+ const userStartIdx = sim.wire.findIndex(
121
+ (e) => e.eventType === "message_start" && e.role === "user"
122
+ );
123
+ const assistantEndIdx = sim.wire.findIndex(
124
+ (e) => e.eventType === "message_end" && e.role === "assistant"
125
+ );
126
+ expect(assistantEndIdx).toBeGreaterThanOrEqual(0);
127
+ expect(userStartIdx).toBeGreaterThan(assistantEndIdx);
128
+ });
129
+
130
+ it("assistant message_start stays SYNC (message_update relies on reducer seeing it first)", () => {
131
+ const sim = new BridgeSim();
132
+ sim.onMessageStart("assistant", "hello");
133
+ // No flush — the event must already be on the wire.
134
+ expect(sim.wire).toEqual([
135
+ { eventType: "message_start", role: "assistant", content: "hello" },
136
+ ]);
137
+ });
138
+
139
+ it("multiple drained follow-ups preserve their relative pi emit order", async () => {
140
+ const sim = new BridgeSim();
141
+
142
+ // Pi delivers two queued follow-ups in order ["a", "b"].
143
+ sim.onMessageEnd("assistant", "final");
144
+ sim.onAgentEnd();
145
+ sim.onMessageStart("user", "a");
146
+ sim.onMessageEnd("user", "a");
147
+ sim.onMessageStart("user", "b");
148
+ sim.onMessageEnd("user", "b");
149
+
150
+ await sim.flush();
151
+
152
+ const summary = sim.wire.map((e) =>
153
+ e.eventType === "agent_end"
154
+ ? "agent_end"
155
+ : `${e.eventType}:${e.role}:${e.content}`
156
+ );
157
+ expect(summary).toEqual([
158
+ "agent_end",
159
+ "message_end:assistant:final",
160
+ "message_start:user:a",
161
+ "message_end:user:a",
162
+ "message_start:user:b",
163
+ "message_end:user:b",
164
+ ]);
165
+ });
166
+
167
+ it("drained STEER at turn_end lands AFTER preceding assistant message_end (same bug, different drain boundary)", async () => {
168
+ const sim = new BridgeSim();
169
+
170
+ // Pi's sync emit order at turn_end with a queued steer:
171
+ // 1. assistant message_end (the weather report)
172
+ // 2. turn_end
173
+ // 3. user message_start ("asd" — the drained steer)
174
+ // 4. user message_end ("asd")
175
+ // (Identical to the follow-up case but the drain boundary is turn_end
176
+ // instead of agent_end. The fix is uniform: defer USER message_start.)
177
+ sim.onMessageEnd("assistant", "weather report");
178
+ sim.onTurnEnd();
179
+ sim.onMessageStart("user", "asd");
180
+ sim.onMessageEnd("user", "asd");
181
+
182
+ await sim.flush();
183
+
184
+ const types = sim.wire.map((e) => `${e.eventType}:${e.role ?? "-"}`);
185
+ expect(types).toEqual([
186
+ "turn_end:-",
187
+ "message_end:assistant",
188
+ "message_start:user",
189
+ "message_end:user",
190
+ ]);
191
+
192
+ const userStartIdx = sim.wire.findIndex(
193
+ (e) => e.eventType === "message_start" && e.role === "user"
194
+ );
195
+ const assistantEndIdx = sim.wire.findIndex(
196
+ (e) => e.eventType === "message_end" && e.role === "assistant"
197
+ );
198
+ expect(userStartIdx).toBeGreaterThan(assistantEndIdx);
199
+ });
200
+
201
+ it("idle user send (no preceding deferred message_end) still arrives intact", async () => {
202
+ const sim = new BridgeSim();
203
+
204
+ // No pending deferrals in flight — a fresh user prompt.
205
+ sim.onMessageStart("user", "hi");
206
+ sim.onMessageEnd("user", "hi");
207
+
208
+ await sim.flush();
209
+
210
+ expect(sim.wire.map((e) => `${e.eventType}:${e.role}`)).toEqual([
211
+ "message_start:user",
212
+ "message_end:user",
213
+ ]);
214
+ });
215
+ });
@@ -0,0 +1,202 @@
1
+ /**
2
+ * v2 bridge behavior tests for the multi-entry follow-up queue.
3
+ *
4
+ * Encodes the contract of the bridge's `rewriteFollowupQueue` helper and the
5
+ * three new browser-message handlers (promote / remove / edit_entry). Tests
6
+ * use a pure-helper reproduction (same shape as the production code) so the
7
+ * contract is verifiable without instantiating the full bridge.
8
+ *
9
+ * See change: add-followup-edit-and-steer-cancel (tasks 13.6, 13.7, 13.13).
10
+ */
11
+ import { describe, it, expect, vi } from "vitest";
12
+
13
+ const FOLLOWUP_QUEUE_CAP = 20;
14
+
15
+ /** Pure reproduction of the bridge's follow-up shadow + rewrite logic. */
16
+ function makeShadow() {
17
+ const calls: Array<{ kind: "clear" } | { kind: "send"; text: string }> = [];
18
+ let bridgeFollowUp: string[] = [];
19
+ const fakePiClearFollowUpQueue = () => { calls.push({ kind: "clear" }); };
20
+ const fakePiSendUserMessage = (text: string, opts: { deliverAs: string }) => {
21
+ calls.push({ kind: "send", text });
22
+ expect(opts).toEqual({ deliverAs: "followUp" });
23
+ };
24
+
25
+ function rewriteFollowupQueue(newEntries: string[]) {
26
+ const capped = newEntries.slice(0, FOLLOWUP_QUEUE_CAP);
27
+ fakePiClearFollowUpQueue();
28
+ for (const t of capped) fakePiSendUserMessage(t, { deliverAs: "followUp" });
29
+ bridgeFollowUp = [...capped];
30
+ }
31
+
32
+ function recordFollowupSent(text: string, isStreaming: boolean) {
33
+ if (!isStreaming) return;
34
+ if (bridgeFollowUp.length >= FOLLOWUP_QUEUE_CAP) return;
35
+ bridgeFollowUp.push(text);
36
+ }
37
+
38
+ // Browser-message handlers (mirror bridge.ts shape)
39
+ function handlePromoteEntry(index: number) {
40
+ if (index < 0 || index >= bridgeFollowUp.length) return;
41
+ const head = bridgeFollowUp[index];
42
+ const rest = bridgeFollowUp.filter((_, i) => i !== index);
43
+ rewriteFollowupQueue([head, ...rest]);
44
+ }
45
+ function handleRemoveEntry(index: number) {
46
+ if (index < 0 || index >= bridgeFollowUp.length) return;
47
+ const surviving = bridgeFollowUp.filter((_, i) => i !== index);
48
+ rewriteFollowupQueue(surviving);
49
+ }
50
+ function handleEditEntry(index: number, text: string) {
51
+ if (index < 0 || index >= bridgeFollowUp.length) return;
52
+ const next = bridgeFollowUp.map((t, i) => (i === index ? text : t));
53
+ rewriteFollowupQueue(next);
54
+ }
55
+ function handleEditFollowupSlotV1Compat(text: string) {
56
+ rewriteFollowupQueue([text]);
57
+ }
58
+
59
+ return {
60
+ snapshot: () => [...bridgeFollowUp],
61
+ calls,
62
+ rewriteFollowupQueue,
63
+ recordFollowupSent,
64
+ handlePromoteEntry,
65
+ handleRemoveEntry,
66
+ handleEditEntry,
67
+ handleEditFollowupSlotV1Compat,
68
+ };
69
+ }
70
+
71
+ describe("bridge follow-up multi-entry queue: rewrite helper", () => {
72
+ it("clears pi + sends each entry in new order", () => {
73
+ const s = makeShadow();
74
+ s.rewriteFollowupQueue(["a", "b", "c"]);
75
+ expect(s.calls).toEqual([
76
+ { kind: "clear" },
77
+ { kind: "send", text: "a" },
78
+ { kind: "send", text: "b" },
79
+ { kind: "send", text: "c" },
80
+ ]);
81
+ expect(s.snapshot()).toEqual(["a", "b", "c"]);
82
+ });
83
+
84
+ it("clears + sends nothing for empty rewrite (queue drained)", () => {
85
+ const s = makeShadow();
86
+ s.rewriteFollowupQueue([]);
87
+ expect(s.calls).toEqual([{ kind: "clear" }]);
88
+ expect(s.snapshot()).toEqual([]);
89
+ });
90
+
91
+ it("caps at FOLLOWUP_QUEUE_CAP (20)", () => {
92
+ const s = makeShadow();
93
+ const big = Array.from({ length: 25 }, (_, i) => `entry-${i}`);
94
+ s.rewriteFollowupQueue(big);
95
+ const sends = s.calls.filter((c) => c.kind === "send");
96
+ expect(sends).toHaveLength(20);
97
+ expect(s.snapshot()).toHaveLength(20);
98
+ expect(s.snapshot()[0]).toBe("entry-0");
99
+ expect(s.snapshot()[19]).toBe("entry-19");
100
+ });
101
+ });
102
+
103
+ describe("bridge follow-up multi-entry queue: recordFollowupSent (append)", () => {
104
+ it("appends when streaming", () => {
105
+ const s = makeShadow();
106
+ s.recordFollowupSent("a", true);
107
+ s.recordFollowupSent("b", true);
108
+ expect(s.snapshot()).toEqual(["a", "b"]);
109
+ });
110
+
111
+ it("does NOT append when idle (race fix)", () => {
112
+ const s = makeShadow();
113
+ s.recordFollowupSent("a", false);
114
+ expect(s.snapshot()).toEqual([]);
115
+ });
116
+
117
+ it("drops silently at soft cap", () => {
118
+ const s = makeShadow();
119
+ for (let i = 0; i < FOLLOWUP_QUEUE_CAP; i++) {
120
+ s.recordFollowupSent(`e${i}`, true);
121
+ }
122
+ expect(s.snapshot()).toHaveLength(FOLLOWUP_QUEUE_CAP);
123
+ s.recordFollowupSent("over-cap", true);
124
+ expect(s.snapshot()).toHaveLength(FOLLOWUP_QUEUE_CAP);
125
+ expect(s.snapshot()).not.toContain("over-cap");
126
+ });
127
+ });
128
+
129
+ describe("bridge follow-up multi-entry queue: promote handler", () => {
130
+ it("moves entry at index N to position 0", () => {
131
+ const s = makeShadow();
132
+ s.rewriteFollowupQueue(["a", "b", "c"]);
133
+ s.handlePromoteEntry(2); // promote "c" to head
134
+ expect(s.snapshot()).toEqual(["c", "a", "b"]);
135
+ });
136
+
137
+ it("promoting index 0 is a no-op (already at head)", () => {
138
+ const s = makeShadow();
139
+ s.rewriteFollowupQueue(["a", "b", "c"]);
140
+ s.handlePromoteEntry(0);
141
+ expect(s.snapshot()).toEqual(["a", "b", "c"]);
142
+ });
143
+
144
+ it("out-of-bounds index is ignored", () => {
145
+ const s = makeShadow();
146
+ s.rewriteFollowupQueue(["a"]);
147
+ const before = s.snapshot();
148
+ s.handlePromoteEntry(5);
149
+ s.handlePromoteEntry(-1);
150
+ expect(s.snapshot()).toEqual(before);
151
+ });
152
+ });
153
+
154
+ describe("bridge follow-up multi-entry queue: remove handler", () => {
155
+ it("removes entry at index N", () => {
156
+ const s = makeShadow();
157
+ s.rewriteFollowupQueue(["a", "b", "c"]);
158
+ s.handleRemoveEntry(1);
159
+ expect(s.snapshot()).toEqual(["a", "c"]);
160
+ });
161
+
162
+ it("removes last entry to leave empty queue", () => {
163
+ const s = makeShadow();
164
+ s.rewriteFollowupQueue(["only"]);
165
+ s.handleRemoveEntry(0);
166
+ expect(s.snapshot()).toEqual([]);
167
+ });
168
+
169
+ it("out-of-bounds index is ignored", () => {
170
+ const s = makeShadow();
171
+ s.rewriteFollowupQueue(["a", "b"]);
172
+ const before = s.snapshot();
173
+ s.handleRemoveEntry(99);
174
+ expect(s.snapshot()).toEqual(before);
175
+ });
176
+ });
177
+
178
+ describe("bridge follow-up multi-entry queue: edit handler", () => {
179
+ it("replaces entry at index N with new text", () => {
180
+ const s = makeShadow();
181
+ s.rewriteFollowupQueue(["a", "b", "c"]);
182
+ s.handleEditEntry(1, "b-revised");
183
+ expect(s.snapshot()).toEqual(["a", "b-revised", "c"]);
184
+ });
185
+
186
+ it("out-of-bounds index is ignored", () => {
187
+ const s = makeShadow();
188
+ s.rewriteFollowupQueue(["only"]);
189
+ const before = s.snapshot();
190
+ s.handleEditEntry(99, "nope");
191
+ expect(s.snapshot()).toEqual(before);
192
+ });
193
+ });
194
+
195
+ describe("bridge follow-up multi-entry queue: v1 edit_followup_slot back-compat", () => {
196
+ it("replaces the ENTIRE queue with a single entry (v1 semantic)", () => {
197
+ const s = makeShadow();
198
+ s.rewriteFollowupQueue(["a", "b", "c"]);
199
+ s.handleEditFollowupSlotV1Compat("replacement");
200
+ expect(s.snapshot()).toEqual(["replacement"]);
201
+ });
202
+ });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Tests that the bridge forwards pi's `queue_update` event as a typed
3
+ * QueueUpdateToServerMessage. Also covers idempotent listener registration
4
+ * via pi.on("queue_update", ...).
5
+ *
6
+ * See change: add-followup-edit-and-steer-cancel.
7
+ */
8
+ import { describe, it, expect, vi } from "vitest";
9
+
10
+ // We don't test the full bridge here (too much wiring) — we drive the
11
+ // listener-registration-and-forward shape directly with a fake pi.
12
+
13
+ describe("bridge queue_update forwarding (shape contract)", () => {
14
+ it("registered queue_update listener emits a typed QueueUpdateToServerMessage on event", () => {
15
+ // Simulate the listener registration the bridge performs.
16
+ const listeners: Record<string, (event: any) => void> = {};
17
+ const fakePi = {
18
+ on: vi.fn((eventType: string, handler: any) => { listeners[eventType] = handler; }),
19
+ };
20
+ const sent: any[] = [];
21
+ const fakeConnection = { send: (m: any) => sent.push(m) };
22
+ const sessionId = "S1";
23
+
24
+ // Equivalent of the bridge's pi.on("queue_update", ...) registration.
25
+ fakePi.on("queue_update", (event: any) => {
26
+ const steering = Array.isArray(event?.steering) ? Array.from(event.steering as readonly string[]) : [];
27
+ const followUp = Array.isArray(event?.followUp) ? Array.from(event.followUp as readonly string[]) : [];
28
+ fakeConnection.send({ type: "queue_update", sessionId, steering, followUp });
29
+ });
30
+
31
+ // Fire pi's queue_update event.
32
+ listeners["queue_update"]({ type: "queue_update", steering: ["a", "b"], followUp: ["c"] });
33
+
34
+ expect(sent).toHaveLength(1);
35
+ expect(sent[0]).toEqual({
36
+ type: "queue_update",
37
+ sessionId: "S1",
38
+ steering: ["a", "b"],
39
+ followUp: ["c"],
40
+ });
41
+ });
42
+
43
+ it("forwards empty arrays when pi reports empty queues", () => {
44
+ const listeners: Record<string, (event: any) => void> = {};
45
+ const fakePi = { on: vi.fn((t: string, h: any) => { listeners[t] = h; }) };
46
+ const sent: any[] = [];
47
+ const sessionId = "S2";
48
+
49
+ fakePi.on("queue_update", (event: any) => {
50
+ const steering = Array.isArray(event?.steering) ? Array.from(event.steering as readonly string[]) : [];
51
+ const followUp = Array.isArray(event?.followUp) ? Array.from(event.followUp as readonly string[]) : [];
52
+ sent.push({ type: "queue_update", sessionId, steering, followUp });
53
+ });
54
+
55
+ listeners["queue_update"]({ type: "queue_update", steering: [], followUp: [] });
56
+
57
+ expect(sent).toEqual([{ type: "queue_update", sessionId: "S2", steering: [], followUp: [] }]);
58
+ });
59
+
60
+ it("defends against malformed event payloads (missing arrays)", () => {
61
+ const listeners: Record<string, (event: any) => void> = {};
62
+ const fakePi = { on: vi.fn((t: string, h: any) => { listeners[t] = h; }) };
63
+ const sent: any[] = [];
64
+ const sessionId = "S3";
65
+
66
+ fakePi.on("queue_update", (event: any) => {
67
+ const steering = Array.isArray(event?.steering) ? Array.from(event.steering as readonly string[]) : [];
68
+ const followUp = Array.isArray(event?.followUp) ? Array.from(event.followUp as readonly string[]) : [];
69
+ sent.push({ type: "queue_update", sessionId, steering, followUp });
70
+ });
71
+
72
+ // Pi returns object missing the expected fields.
73
+ listeners["queue_update"]({ type: "queue_update" });
74
+
75
+ expect(sent).toEqual([{ type: "queue_update", sessionId: "S3", steering: [], followUp: [] }]);
76
+ });
77
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Bridge wire-ordering invariant for synthesized retry events.
3
+ *
4
+ * Verifies that the bridge updates `RetryTracker` + `UsageLimitOrderer` state
5
+ * SYNCHRONOUSLY when handling `message_end`, so that a back-to-back `agent_end`
6
+ * (fired in the same event-loop tick by pi-coding-agent) observes the
7
+ * up-to-date state.
8
+ *
9
+ * Pre-fix bug: the synthesizer state lived inside `setTimeout(0)` (intended
10
+ * for entryId capture per `fix-per-message-fork`), so `agent_end` was
11
+ * processed BEFORE the trackers had been updated, the orderer's pending
12
+ * flag was never set, and `auto_retry_start` shipped on the wire AFTER
13
+ * `agent_end` — leaving the dashboard's `retryState` stuck (yellow + red
14
+ * banners both visible).
15
+ *
16
+ * See change: fix-retry-banner-stuck-on-limit-exceeded.
17
+ */
18
+
19
+ import { describe, it, expect } from "vitest";
20
+ import { RetryTracker } from "../retry-tracker.js";
21
+ import { UsageLimitOrderer } from "../usage-limit-orderer.js";
22
+
23
+ interface WireEvent {
24
+ eventType: string;
25
+ data?: Record<string, unknown>;
26
+ }
27
+
28
+ /**
29
+ * Simulates the bridge's synthesizer pipeline as it runs synchronously
30
+ * inside the message_end / agent_end handlers, capturing all wire sends
31
+ * in order.
32
+ */
33
+ class BridgeSim {
34
+ readonly wire: WireEvent[] = [];
35
+ private tracker = new RetryTracker();
36
+ private orderer = new UsageLimitOrderer();
37
+
38
+ /** Mirrors bridge.ts message_end handler synthesizer block. */
39
+ onMessageEnd(sessionId: string, message: { role: string; stopReason?: string; errorMessage?: string }): void {
40
+ const synthetic = this.tracker.observeMessageEnd(sessionId, message);
41
+ if (synthetic) {
42
+ if (synthetic.eventType === "auto_retry_start") {
43
+ this.orderer.noteRetryStart(sessionId);
44
+ } else {
45
+ this.orderer.noteRetryEnd(sessionId);
46
+ }
47
+ this.wire.push({ eventType: synthetic.eventType, data: synthetic.data });
48
+ }
49
+ // The actual message_end body send is deferred via setTimeout(0) in the
50
+ // real bridge for entryId capture; for ordering tests we only care about
51
+ // the synthetic events relative to agent_end.
52
+ this.wire.push({ eventType: "message_end" });
53
+ }
54
+
55
+ /** Mirrors bridge.ts agent_end handler synthesizer block. */
56
+ onAgentEnd(sessionId: string, agentEnd: { messages?: Array<Record<string, unknown>> }): void {
57
+ const orderedSynth = this.orderer.maybeSynthesize(sessionId, agentEnd);
58
+ if (orderedSynth) {
59
+ this.wire.push({ eventType: orderedSynth.eventType, data: orderedSynth.data });
60
+ this.tracker.noteAbort(sessionId);
61
+ } else {
62
+ const trackerSynth = this.tracker.observeAgentEnd(sessionId, agentEnd);
63
+ if (trackerSynth) {
64
+ this.wire.push({ eventType: trackerSynth.eventType, data: trackerSynth.data });
65
+ }
66
+ }
67
+ this.wire.push({ eventType: "agent_end" });
68
+ }
69
+ }
70
+
71
+ describe("Bridge retry-event wire ordering", () => {
72
+ it("agent_end fired back-to-back after retryable message_end observes pending retry", () => {
73
+ const sim = new BridgeSim();
74
+ const sessionId = "s1";
75
+ const errorMsg = "429 too many requests";
76
+
77
+ // Pi fires both events synchronously back-to-back.
78
+ sim.onMessageEnd(sessionId, { role: "assistant", stopReason: "error", errorMessage: errorMsg });
79
+ sim.onAgentEnd(sessionId, {
80
+ messages: [{ role: "assistant", stopReason: "error", errorMessage: errorMsg }],
81
+ });
82
+
83
+ const types = sim.wire.map((e) => e.eventType);
84
+ // auto_retry_start must precede message_end, which must precede the
85
+ // agent_end-side synthesis. Since the same retryable error is the
86
+ // terminal message, retryTracker.observeAgentEnd surfaces a final
87
+ // auto_retry_end{success:false, finalError:errorMsg} BEFORE agent_end.
88
+ expect(types).toEqual([
89
+ "auto_retry_start",
90
+ "message_end",
91
+ "auto_retry_end",
92
+ "agent_end",
93
+ ]);
94
+ // auto_retry_start MUST come before agent_end on the wire.
95
+ const startIdx = types.indexOf("auto_retry_start");
96
+ const agentEndIdx = types.indexOf("agent_end");
97
+ expect(startIdx).toBeLessThan(agentEndIdx);
98
+ });
99
+
100
+ it("Gemini monthly-spending-cap error orders auto_retry_end before agent_end", () => {
101
+ const sim = new BridgeSim();
102
+ const sessionId = "s2";
103
+ // Real fixture from ~/.pi/agent/sessions/...BME-szakdoga.../*.jsonl line 363
104
+ const errorMsg = JSON.stringify({
105
+ error: {
106
+ message:
107
+ "Your project has exceeded its monthly spending cap. Please go to AI Studio at https://ai.studio/spend to manage your project spend cap.",
108
+ status: "RESOURCE_EXHAUSTED",
109
+ },
110
+ code: 429,
111
+ status: "Too Many Requests",
112
+ });
113
+
114
+ sim.onMessageEnd(sessionId, { role: "assistant", stopReason: "error", errorMessage: errorMsg });
115
+ sim.onAgentEnd(sessionId, {
116
+ messages: [{ role: "assistant", stopReason: "error", errorMessage: errorMsg }],
117
+ });
118
+
119
+ const types = sim.wire.map((e) => e.eventType);
120
+ expect(types).toEqual([
121
+ "auto_retry_start",
122
+ "message_end",
123
+ "auto_retry_end",
124
+ "agent_end",
125
+ ]);
126
+ // The synthetic auto_retry_end MUST come from the usage-limit orderer
127
+ // (not the retry-tracker fallback) because the broadened
128
+ // USAGE_LIMIT_PATTERN matches "monthly spending cap" / RESOURCE_EXHAUSTED.
129
+ const retryEnd = sim.wire.find((e) => e.eventType === "auto_retry_end")!;
130
+ expect(retryEnd.data).toMatchObject({ success: false, finalError: errorMsg });
131
+ });
132
+
133
+ it("non-retryable message_end produces no synthesis (only message_end on wire)", () => {
134
+ const sim = new BridgeSim();
135
+ sim.onMessageEnd("s3", {
136
+ role: "assistant",
137
+ stopReason: "error",
138
+ errorMessage: "prompt is too long: 300000 tokens > 200000 maximum",
139
+ });
140
+ expect(sim.wire.map((e) => e.eventType)).toEqual(["message_end"]);
141
+ });
142
+
143
+ it("successful message_end with no prior retry produces no synthesis", () => {
144
+ const sim = new BridgeSim();
145
+ sim.onMessageEnd("s4", { role: "assistant", stopReason: "end_turn" });
146
+ expect(sim.wire.map((e) => e.eventType)).toEqual(["message_end"]);
147
+ });
148
+ });