@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,221 @@
1
+ /**
2
+ * Tests for the bridge's per-entry shadow-queue drain on user message_start.
3
+ *
4
+ * Pi mirrors this exact algorithm internally (see
5
+ * `@earendil-works/pi-coding-agent/dist/core/agent-session.js`
6
+ * `_processAgentEvent`, around line 270-292):
7
+ *
8
+ * if (event.type === "message_start" && event.message.role === "user") {
9
+ * const text = _getUserMessageText(event.message);
10
+ * const steeringIdx = _steeringMessages.indexOf(text);
11
+ * if (steeringIdx !== -1) {
12
+ * _steeringMessages.splice(steeringIdx, 1);
13
+ * _emitQueueUpdate();
14
+ * } else {
15
+ * const followUpIdx = _followUpMessages.indexOf(text);
16
+ * if (followUpIdx !== -1) {
17
+ * _followUpMessages.splice(followUpIdx, 1);
18
+ * _emitQueueUpdate();
19
+ * }
20
+ * }
21
+ * }
22
+ *
23
+ * Pre-fix bug: bridge bulk-cleared `bridgeFollowUp = []` at every `agent_end`
24
+ * and `bridgeSteering = []` at every `turn_end`. With pi's `mode:"all"`
25
+ * (default), pi drains all queued follow-ups across multiple turns before
26
+ * emitting the final `agent_end`. The dashboard saw the entire queue stay
27
+ * visible for the whole drain window, then disappear all at once at the
28
+ * end — instead of shrinking one entry per drain as the user observes them
29
+ * being processed.
30
+ *
31
+ * Fix: bridge mirrors pi's per-entry matcher on user `message_start`. Bulk
32
+ * clears at `agent_end` / `turn_end` are removed (would otherwise wipe
33
+ * entries the user added DURING a drain).
34
+ *
35
+ * See change: add-followup-edit-and-steer-cancel (per-entry-drain scenario).
36
+ */
37
+ import { describe, it, expect } from "vitest";
38
+
39
+ interface ShadowQueue {
40
+ steering: string[];
41
+ followUp: string[];
42
+ }
43
+
44
+ /**
45
+ * Pure mirror of the per-entry drain matcher as it lives in bridge.ts'
46
+ * `message_start` handler. If production drifts from this shape, the
47
+ * test should drift in lockstep.
48
+ */
49
+ function makeShadowDrainMatcher() {
50
+ const queue: ShadowQueue = { steering: [], followUp: [] };
51
+ const emits: ShadowQueue[] = [];
52
+ function emit() {
53
+ emits.push({ steering: [...queue.steering], followUp: [...queue.followUp] });
54
+ }
55
+
56
+ /** Mirrors pi's `_getUserMessageText` exactly. */
57
+ function getUserMessageText(message: { role: string; content: unknown }): string {
58
+ if (message.role !== "user") return "";
59
+ const content = message.content;
60
+ if (typeof content === "string") return content;
61
+ if (!Array.isArray(content)) return "";
62
+ return content
63
+ .filter((c: any) => c && c.type === "text")
64
+ .map((c: any) => c.text ?? "")
65
+ .join("");
66
+ }
67
+
68
+ /** Call when pi emits a user `message_start`. Mirrors pi's internal logic. */
69
+ function onUserMessageStart(message: { role: string; content: unknown }): void {
70
+ const text = getUserMessageText(message);
71
+ if (!text) return;
72
+ const steeringIdx = queue.steering.indexOf(text);
73
+ if (steeringIdx !== -1) {
74
+ queue.steering.splice(steeringIdx, 1);
75
+ emit();
76
+ return;
77
+ }
78
+ const followUpIdx = queue.followUp.indexOf(text);
79
+ if (followUpIdx !== -1) {
80
+ queue.followUp.splice(followUpIdx, 1);
81
+ emit();
82
+ }
83
+ }
84
+
85
+ return {
86
+ queue,
87
+ emits,
88
+ onUserMessageStart,
89
+ recordSteer: (t: string) => { queue.steering.push(t); emit(); },
90
+ recordFollowup: (t: string) => { queue.followUp.push(t); emit(); },
91
+ snapshotQueue: () => ({ steering: [...queue.steering], followUp: [...queue.followUp] }),
92
+ snapshotEmits: () => emits.map((e) => ({ steering: [...e.steering], followUp: [...e.followUp] })),
93
+ };
94
+ }
95
+
96
+ describe("Bridge shadow-queue per-entry drain on user message_start", () => {
97
+ it("removes the matching follow-up entry when pi drains it, leaves the rest", () => {
98
+ const m = makeShadowDrainMatcher();
99
+ m.recordFollowup("a");
100
+ m.recordFollowup("b");
101
+ m.recordFollowup("c");
102
+ expect(m.snapshotQueue().followUp).toEqual(["a", "b", "c"]);
103
+
104
+ // Pi drains "a" first.
105
+ m.onUserMessageStart({ role: "user", content: "a" });
106
+ expect(m.snapshotQueue().followUp).toEqual(["b", "c"]);
107
+
108
+ // Then "b".
109
+ m.onUserMessageStart({ role: "user", content: "b" });
110
+ expect(m.snapshotQueue().followUp).toEqual(["c"]);
111
+
112
+ // Then "c".
113
+ m.onUserMessageStart({ role: "user", content: "c" });
114
+ expect(m.snapshotQueue().followUp).toEqual([]);
115
+
116
+ // One emit per drain (plus the three initial record emits).
117
+ expect(m.emits).toHaveLength(6);
118
+ });
119
+
120
+ it("removes the matching steering entry when pi drains it", () => {
121
+ const m = makeShadowDrainMatcher();
122
+ m.recordSteer("focus on X");
123
+ m.recordSteer("ignore Y");
124
+ expect(m.snapshotQueue().steering).toEqual(["focus on X", "ignore Y"]);
125
+
126
+ m.onUserMessageStart({ role: "user", content: "focus on X" });
127
+ expect(m.snapshotQueue().steering).toEqual(["ignore Y"]);
128
+ expect(m.snapshotQueue().followUp).toEqual([]);
129
+ });
130
+
131
+ it("steering queue checked BEFORE follow-up when same text is in both", () => {
132
+ const m = makeShadowDrainMatcher();
133
+ m.recordSteer("hello");
134
+ m.recordFollowup("hello");
135
+
136
+ m.onUserMessageStart({ role: "user", content: "hello" });
137
+ // Steering entry consumed, follow-up untouched.
138
+ expect(m.snapshotQueue().steering).toEqual([]);
139
+ expect(m.snapshotQueue().followUp).toEqual(["hello"]);
140
+
141
+ // Second drain consumes the follow-up.
142
+ m.onUserMessageStart({ role: "user", content: "hello" });
143
+ expect(m.snapshotQueue().followUp).toEqual([]);
144
+ });
145
+
146
+ it("non-matching user message_start is a no-op (no queue mutation, no emit)", () => {
147
+ const m = makeShadowDrainMatcher();
148
+ m.recordFollowup("queued");
149
+ const baselineEmits = m.emits.length;
150
+
151
+ // Fresh user send not in any queue (e.g., the user typed something
152
+ // new on an idle session, or a steer was added by a non-dashboard
153
+ // consumer).
154
+ m.onUserMessageStart({ role: "user", content: "fresh send" });
155
+ expect(m.snapshotQueue().followUp).toEqual(["queued"]);
156
+ expect(m.emits.length).toBe(baselineEmits);
157
+ });
158
+
159
+ it("user message_start with array content joins text blocks (matches pi's _getUserMessageText)", () => {
160
+ const m = makeShadowDrainMatcher();
161
+ m.recordFollowup("describe this");
162
+
163
+ m.onUserMessageStart({
164
+ role: "user",
165
+ content: [
166
+ { type: "text", text: "describe " },
167
+ { type: "image", data: "<base64>", mimeType: "image/png" },
168
+ { type: "text", text: "this" },
169
+ ],
170
+ });
171
+ expect(m.snapshotQueue().followUp).toEqual([]);
172
+ });
173
+
174
+ it("ignores non-user message_start (assistant role does not touch the queue)", () => {
175
+ const m = makeShadowDrainMatcher();
176
+ m.recordFollowup("a");
177
+ const baselineEmits = m.emits.length;
178
+
179
+ m.onUserMessageStart({ role: "assistant", content: "a" });
180
+ expect(m.snapshotQueue().followUp).toEqual(["a"]);
181
+ expect(m.emits.length).toBe(baselineEmits);
182
+ });
183
+
184
+ it("removes only the FIRST occurrence on duplicate text (FIFO)", () => {
185
+ const m = makeShadowDrainMatcher();
186
+ m.recordFollowup("dup");
187
+ m.recordFollowup("other");
188
+ m.recordFollowup("dup");
189
+ expect(m.snapshotQueue().followUp).toEqual(["dup", "other", "dup"]);
190
+
191
+ m.onUserMessageStart({ role: "user", content: "dup" });
192
+ // First "dup" removed; second one still queued (FIFO).
193
+ expect(m.snapshotQueue().followUp).toEqual(["other", "dup"]);
194
+
195
+ m.onUserMessageStart({ role: "user", content: "dup" });
196
+ expect(m.snapshotQueue().followUp).toEqual(["other"]);
197
+ });
198
+
199
+ it("entries added DURING drain are preserved (no bulk wipe)", () => {
200
+ const m = makeShadowDrainMatcher();
201
+ m.recordFollowup("a");
202
+ m.recordFollowup("b");
203
+
204
+ // Pi drains "a"
205
+ m.onUserMessageStart({ role: "user", content: "a" });
206
+ expect(m.snapshotQueue().followUp).toEqual(["b"]);
207
+
208
+ // User adds a new entry "c" while pi is still draining
209
+ m.recordFollowup("c");
210
+ expect(m.snapshotQueue().followUp).toEqual(["b", "c"]);
211
+
212
+ // Pi drains "b"
213
+ m.onUserMessageStart({ role: "user", content: "b" });
214
+ // "c" must still be present
215
+ expect(m.snapshotQueue().followUp).toEqual(["c"]);
216
+
217
+ // Eventually pi drains "c"
218
+ m.onUserMessageStart({ role: "user", content: "c" });
219
+ expect(m.snapshotQueue().followUp).toEqual([]);
220
+ });
221
+ });
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Tests for the bridge's shadow-queue streaming gate.
3
+ *
4
+ * Repro for the bug "STEERING (1) appears on the very first message to an
5
+ * idle session". The fix has TWO layers:
6
+ *
7
+ * 1. Capture-before-send (primary gate): at the call site of
8
+ * `pi.sendUserMessage`, the caller MUST snapshot
9
+ * `isAgentStreaming` BEFORE invoking sendUserMessage. Pi flips
10
+ * idle→streaming synchronously inside sendUserMessage by emitting
11
+ * `agent_start`, whose handler in bridge.ts flips the flag in its
12
+ * first sync line. Checking the flag AFTER the send always reads
13
+ * true — the original bug. Tests `record*Sent + wasStreaming`
14
+ * encode that contract.
15
+ *
16
+ * 2. Internal gate (defense in depth): `recordSteerSent` /
17
+ * `recordFollowupSent` themselves re-check `isStreaming()` so a
18
+ * caller that forgets to capture pre-send still doesn't corrupt
19
+ * the shadow queue.
20
+ *
21
+ * See change: add-followup-edit-and-steer-cancel.
22
+ */
23
+ import { describe, it, expect, vi } from "vitest";
24
+
25
+ interface ShadowQueue {
26
+ steering: string[];
27
+ followUp: string[];
28
+ }
29
+
30
+ /**
31
+ * Pure version of the gate as it lives in bridge.ts. Mirrors the closure
32
+ * logic 1:1; if the production code drifts from this shape, this test
33
+ * should drift in lockstep.
34
+ */
35
+ function makeShadowRecorder(opts: {
36
+ isStreaming: () => boolean;
37
+ onEmit: (snapshot: ShadowQueue) => void;
38
+ }) {
39
+ const queue: ShadowQueue = { steering: [], followUp: [] };
40
+ function emit() { opts.onEmit({ steering: [...queue.steering], followUp: [...queue.followUp] }); }
41
+ function recordSteer(text: string) {
42
+ if (!opts.isStreaming()) return;
43
+ queue.steering.push(text);
44
+ emit();
45
+ }
46
+ function recordFollowup(text: string) {
47
+ if (!opts.isStreaming()) return;
48
+ queue.followUp = [text];
49
+ emit();
50
+ }
51
+ function clearSteer() { queue.steering = []; emit(); }
52
+ function clearFollowup() { queue.followUp = []; emit(); }
53
+ function drainSteerOnTurnEnd() { if (queue.steering.length > 0) { queue.steering = []; emit(); } }
54
+ function drainFollowupOnAgentEnd() { if (queue.followUp.length > 0) { queue.followUp = []; emit(); } }
55
+ return { recordSteer, recordFollowup, clearSteer, clearFollowup, drainSteerOnTurnEnd, drainFollowupOnAgentEnd, snapshot: () => ({ ...queue, steering: [...queue.steering], followUp: [...queue.followUp] }) };
56
+ }
57
+
58
+ describe("bridge shadow queue: streaming gate", () => {
59
+ it("recordSteer is a no-op when isStreaming === false (idle first message)", () => {
60
+ let streaming = false;
61
+ const onEmit = vi.fn();
62
+ const r = makeShadowRecorder({ isStreaming: () => streaming, onEmit });
63
+
64
+ r.recordSteer("hello");
65
+
66
+ expect(r.snapshot().steering).toEqual([]);
67
+ expect(onEmit).not.toHaveBeenCalled();
68
+ });
69
+
70
+ it("recordFollowup is a no-op when isStreaming === false (idle first message)", () => {
71
+ let streaming = false;
72
+ const onEmit = vi.fn();
73
+ const r = makeShadowRecorder({ isStreaming: () => streaming, onEmit });
74
+
75
+ r.recordFollowup("after done");
76
+
77
+ expect(r.snapshot().followUp).toEqual([]);
78
+ expect(onEmit).not.toHaveBeenCalled();
79
+ });
80
+
81
+ it("recordSteer appends + emits when streaming", () => {
82
+ let streaming = true;
83
+ const onEmit = vi.fn();
84
+ const r = makeShadowRecorder({ isStreaming: () => streaming, onEmit });
85
+
86
+ r.recordSteer("A");
87
+ r.recordSteer("B");
88
+
89
+ expect(r.snapshot().steering).toEqual(["A", "B"]);
90
+ expect(onEmit).toHaveBeenCalledTimes(2);
91
+ expect(onEmit.mock.calls[1][0]).toEqual({ steering: ["A", "B"], followUp: [] });
92
+ });
93
+
94
+ it("recordFollowup replaces slot + emits when streaming (capacity 1)", () => {
95
+ let streaming = true;
96
+ const onEmit = vi.fn();
97
+ const r = makeShadowRecorder({ isStreaming: () => streaming, onEmit });
98
+
99
+ r.recordFollowup("first");
100
+ r.recordFollowup("second");
101
+
102
+ expect(r.snapshot().followUp).toEqual(["second"]);
103
+ expect(onEmit).toHaveBeenCalledTimes(2);
104
+ });
105
+ });
106
+
107
+ describe("bridge shadow queue: drain boundaries", () => {
108
+ it("turn_end drains steering only (followUp untouched)", () => {
109
+ let streaming = true;
110
+ const onEmit = vi.fn();
111
+ const r = makeShadowRecorder({ isStreaming: () => streaming, onEmit });
112
+ r.recordSteer("s1");
113
+ r.recordFollowup("f1");
114
+ onEmit.mockClear();
115
+
116
+ r.drainSteerOnTurnEnd();
117
+
118
+ expect(r.snapshot()).toEqual({ steering: [], followUp: ["f1"] });
119
+ expect(onEmit).toHaveBeenCalledTimes(1);
120
+ });
121
+
122
+ it("agent_end drains followUp only (steering untouched)", () => {
123
+ let streaming = true;
124
+ const onEmit = vi.fn();
125
+ const r = makeShadowRecorder({ isStreaming: () => streaming, onEmit });
126
+ r.recordSteer("s1");
127
+ r.recordFollowup("f1");
128
+ onEmit.mockClear();
129
+
130
+ r.drainFollowupOnAgentEnd();
131
+
132
+ expect(r.snapshot()).toEqual({ steering: ["s1"], followUp: [] });
133
+ expect(onEmit).toHaveBeenCalledTimes(1);
134
+ });
135
+
136
+ it("turn_end on empty steering does NOT emit (idempotent / no spurious broadcasts)", () => {
137
+ const onEmit = vi.fn();
138
+ const r = makeShadowRecorder({ isStreaming: () => true, onEmit });
139
+
140
+ r.drainSteerOnTurnEnd();
141
+
142
+ expect(onEmit).not.toHaveBeenCalled();
143
+ });
144
+
145
+ it("agent_end on empty followUp does NOT emit", () => {
146
+ const onEmit = vi.fn();
147
+ const r = makeShadowRecorder({ isStreaming: () => true, onEmit });
148
+
149
+ r.drainFollowupOnAgentEnd();
150
+
151
+ expect(onEmit).not.toHaveBeenCalled();
152
+ });
153
+ });
154
+
155
+ describe("bridge shadow queue: clears", () => {
156
+ it("clearSteer wipes + emits regardless of streaming state", () => {
157
+ let streaming = false;
158
+ const onEmit = vi.fn();
159
+ const r = makeShadowRecorder({ isStreaming: () => streaming, onEmit });
160
+ // Populate via a streaming-on phase, then go idle and clear.
161
+ streaming = true; r.recordSteer("x"); streaming = false; onEmit.mockClear();
162
+
163
+ r.clearSteer();
164
+
165
+ expect(r.snapshot().steering).toEqual([]);
166
+ expect(onEmit).toHaveBeenCalledTimes(1);
167
+ });
168
+
169
+ it("clearFollowup wipes + emits regardless of streaming state", () => {
170
+ let streaming = false;
171
+ const onEmit = vi.fn();
172
+ const r = makeShadowRecorder({ isStreaming: () => streaming, onEmit });
173
+ streaming = true; r.recordFollowup("y"); streaming = false; onEmit.mockClear();
174
+
175
+ r.clearFollowup();
176
+
177
+ expect(r.snapshot().followUp).toEqual([]);
178
+ expect(onEmit).toHaveBeenCalledTimes(1);
179
+ });
180
+ });
181
+
182
+ describe("bridge shadow queue: capture-before-send semantics (PRIMARY gate)", () => {
183
+ // Simulates the command-handler / sessionPrompt call sites where the bug
184
+ // originally lived. The fix: capture streaming state into a local var
185
+ // BEFORE calling pi.sendUserMessage. The internal recordX gate is a
186
+ // safety net only.
187
+
188
+ /** Stand-in for the simplified call-site logic in command-handler.ts. */
189
+ function send(opts: {
190
+ text: string;
191
+ delivery: "steer" | "followUp";
192
+ isStreaming: () => boolean;
193
+ piSendUserMessage: () => void; // simulates the synchronous agent_start flip
194
+ onSteer: (text: string) => void;
195
+ onFollowup: (text: string) => void;
196
+ }) {
197
+ const wasStreaming = opts.isStreaming();
198
+ opts.piSendUserMessage();
199
+ if (wasStreaming) {
200
+ if (opts.delivery === "steer") opts.onSteer(opts.text);
201
+ else opts.onFollowup(opts.text);
202
+ }
203
+ }
204
+
205
+ it("idle send DOES NOT record even when pi flips isStreaming synchronously inside sendUserMessage", () => {
206
+ let streaming = false;
207
+ const onEmit = vi.fn();
208
+ const r = makeShadowRecorder({ isStreaming: () => streaming, onEmit });
209
+
210
+ send({
211
+ text: "first message",
212
+ delivery: "steer",
213
+ isStreaming: () => streaming,
214
+ // Pi receives the message, fires agent_start synchronously, which
215
+ // flips `streaming` to true. This was the original bug.
216
+ piSendUserMessage: () => { streaming = true; },
217
+ onSteer: r.recordSteer,
218
+ onFollowup: r.recordFollowup,
219
+ });
220
+
221
+ expect(r.snapshot()).toEqual({ steering: [], followUp: [] });
222
+ expect(onEmit).not.toHaveBeenCalled();
223
+ });
224
+
225
+ it("streaming send DOES record (chip appears for mid-turn steer)", () => {
226
+ let streaming = true;
227
+ const onEmit = vi.fn();
228
+ const r = makeShadowRecorder({ isStreaming: () => streaming, onEmit });
229
+
230
+ send({
231
+ text: "redirect",
232
+ delivery: "steer",
233
+ isStreaming: () => streaming,
234
+ piSendUserMessage: () => { /* still streaming */ },
235
+ onSteer: r.recordSteer,
236
+ onFollowup: r.recordFollowup,
237
+ });
238
+
239
+ expect(r.snapshot().steering).toEqual(["redirect"]);
240
+ expect(onEmit).toHaveBeenCalledTimes(1);
241
+ });
242
+
243
+ it("idle followUp also DOES NOT record (same race shape)", () => {
244
+ let streaming = false;
245
+ const onEmit = vi.fn();
246
+ const r = makeShadowRecorder({ isStreaming: () => streaming, onEmit });
247
+
248
+ send({
249
+ text: "after done",
250
+ delivery: "followUp",
251
+ isStreaming: () => streaming,
252
+ piSendUserMessage: () => { streaming = true; },
253
+ onSteer: r.recordSteer,
254
+ onFollowup: r.recordFollowup,
255
+ });
256
+
257
+ expect(r.snapshot()).toEqual({ steering: [], followUp: [] });
258
+ expect(onEmit).not.toHaveBeenCalled();
259
+ });
260
+ });
261
+
262
+ describe("bridge shadow queue: realistic follow-up scenario", () => {
263
+ it("send followUp while idle (first message of session) shows no chip", () => {
264
+ let streaming = false;
265
+ const onEmit = vi.fn();
266
+ const r = makeShadowRecorder({ isStreaming: () => streaming, onEmit });
267
+
268
+ // User sends their initial prompt; pi starts a new turn directly,
269
+ // it doesn't queue. The bridge must NOT record a chip.
270
+ r.recordFollowup("kick off the task");
271
+
272
+ expect(r.snapshot().followUp).toEqual([]);
273
+ expect(onEmit).not.toHaveBeenCalled();
274
+
275
+ // Pi fires agent_start → streaming flips on.
276
+ streaming = true;
277
+
278
+ // User adds a follow-up mid-stream → chip appears.
279
+ r.recordFollowup("when you finish, run the tests");
280
+ expect(r.snapshot().followUp).toEqual(["when you finish, run the tests"]);
281
+ expect(onEmit).toHaveBeenCalledTimes(1);
282
+
283
+ // Agent finishes → followUp drains.
284
+ r.drainFollowupOnAgentEnd();
285
+ expect(r.snapshot().followUp).toEqual([]);
286
+ });
287
+
288
+ it("rapid edit (steering still active) replaces slot atomically", () => {
289
+ const onEmit = vi.fn();
290
+ const r = makeShadowRecorder({ isStreaming: () => true, onEmit });
291
+
292
+ r.recordFollowup("v1");
293
+ r.recordFollowup("v2");
294
+ r.recordFollowup("v3");
295
+
296
+ expect(r.snapshot().followUp).toEqual(["v3"]);
297
+ expect(onEmit).toHaveBeenCalledTimes(3);
298
+ });
299
+ });