@blackbelt-technology/pi-agent-dashboard 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/AGENTS.md +342 -0
  2. package/README.md +619 -0
  3. package/docs/architecture.md +646 -0
  4. package/package.json +92 -0
  5. package/packages/extension/package.json +33 -0
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +85 -0
  7. package/packages/extension/src/__tests__/command-handler.test.ts +712 -0
  8. package/packages/extension/src/__tests__/connection.test.ts +344 -0
  9. package/packages/extension/src/__tests__/credentials-updated.test.ts +26 -0
  10. package/packages/extension/src/__tests__/dev-build.test.ts +79 -0
  11. package/packages/extension/src/__tests__/event-forwarder.test.ts +89 -0
  12. package/packages/extension/src/__tests__/git-info.test.ts +112 -0
  13. package/packages/extension/src/__tests__/git-link-builder.test.ts +102 -0
  14. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +232 -0
  15. package/packages/extension/src/__tests__/openspec-poller.test.ts +119 -0
  16. package/packages/extension/src/__tests__/process-metrics.test.ts +47 -0
  17. package/packages/extension/src/__tests__/process-scanner.test.ts +202 -0
  18. package/packages/extension/src/__tests__/prompt-expander.test.ts +54 -0
  19. package/packages/extension/src/__tests__/server-auto-start.test.ts +167 -0
  20. package/packages/extension/src/__tests__/server-launcher.test.ts +44 -0
  21. package/packages/extension/src/__tests__/server-probe.test.ts +25 -0
  22. package/packages/extension/src/__tests__/session-switch.test.ts +139 -0
  23. package/packages/extension/src/__tests__/session-sync.test.ts +55 -0
  24. package/packages/extension/src/__tests__/source-detector.test.ts +73 -0
  25. package/packages/extension/src/__tests__/stats-extractor.test.ts +92 -0
  26. package/packages/extension/src/__tests__/ui-proxy.test.ts +583 -0
  27. package/packages/extension/src/__tests__/watchdog.test.ts +161 -0
  28. package/packages/extension/src/ask-user-tool.ts +63 -0
  29. package/packages/extension/src/bridge-context.ts +64 -0
  30. package/packages/extension/src/bridge.ts +926 -0
  31. package/packages/extension/src/command-handler.ts +538 -0
  32. package/packages/extension/src/connection.ts +204 -0
  33. package/packages/extension/src/dev-build.ts +39 -0
  34. package/packages/extension/src/event-forwarder.ts +40 -0
  35. package/packages/extension/src/flow-event-wiring.ts +102 -0
  36. package/packages/extension/src/git-info.ts +65 -0
  37. package/packages/extension/src/git-link-builder.ts +112 -0
  38. package/packages/extension/src/model-tracker.ts +56 -0
  39. package/packages/extension/src/pi-env.d.ts +23 -0
  40. package/packages/extension/src/process-metrics.ts +70 -0
  41. package/packages/extension/src/process-scanner.ts +396 -0
  42. package/packages/extension/src/prompt-expander.ts +87 -0
  43. package/packages/extension/src/provider-register.ts +276 -0
  44. package/packages/extension/src/server-auto-start.ts +87 -0
  45. package/packages/extension/src/server-launcher.ts +82 -0
  46. package/packages/extension/src/server-probe.ts +33 -0
  47. package/packages/extension/src/session-sync.ts +154 -0
  48. package/packages/extension/src/source-detector.ts +26 -0
  49. package/packages/extension/src/ui-proxy.ts +269 -0
  50. package/packages/extension/tsconfig.json +11 -0
  51. package/packages/server/package.json +37 -0
  52. package/packages/server/src/__tests__/auth-plugin.test.ts +117 -0
  53. package/packages/server/src/__tests__/auth.test.ts +224 -0
  54. package/packages/server/src/__tests__/auto-attach.test.ts +246 -0
  55. package/packages/server/src/__tests__/auto-resume.test.ts +135 -0
  56. package/packages/server/src/__tests__/auto-shutdown.test.ts +136 -0
  57. package/packages/server/src/__tests__/browse-endpoint.test.ts +104 -0
  58. package/packages/server/src/__tests__/bulk-archive-handler.test.ts +15 -0
  59. package/packages/server/src/__tests__/cli-parse.test.ts +73 -0
  60. package/packages/server/src/__tests__/client-discovery.test.ts +39 -0
  61. package/packages/server/src/__tests__/config-api.test.ts +104 -0
  62. package/packages/server/src/__tests__/cors.test.ts +48 -0
  63. package/packages/server/src/__tests__/directory-service.test.ts +240 -0
  64. package/packages/server/src/__tests__/editor-detection.test.ts +60 -0
  65. package/packages/server/src/__tests__/editor-endpoints.test.ts +26 -0
  66. package/packages/server/src/__tests__/editor-manager.test.ts +73 -0
  67. package/packages/server/src/__tests__/editor-registry.test.ts +151 -0
  68. package/packages/server/src/__tests__/event-status-extraction-flow.test.ts +55 -0
  69. package/packages/server/src/__tests__/event-status-extraction.test.ts +58 -0
  70. package/packages/server/src/__tests__/extension-register.test.ts +61 -0
  71. package/packages/server/src/__tests__/file-endpoint.test.ts +49 -0
  72. package/packages/server/src/__tests__/force-kill-handler.test.ts +109 -0
  73. package/packages/server/src/__tests__/git-operations.test.ts +251 -0
  74. package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
  75. package/packages/server/src/__tests__/headless-shutdown-fallback.test.ts +109 -0
  76. package/packages/server/src/__tests__/health-endpoint.test.ts +35 -0
  77. package/packages/server/src/__tests__/heartbeat-ack.test.ts +63 -0
  78. package/packages/server/src/__tests__/json-store.test.ts +70 -0
  79. package/packages/server/src/__tests__/localhost-guard.test.ts +149 -0
  80. package/packages/server/src/__tests__/memory-event-store.test.ts +260 -0
  81. package/packages/server/src/__tests__/memory-session-manager.test.ts +80 -0
  82. package/packages/server/src/__tests__/meta-persistence.test.ts +107 -0
  83. package/packages/server/src/__tests__/migrate-persistence.test.ts +180 -0
  84. package/packages/server/src/__tests__/npm-search-proxy.test.ts +153 -0
  85. package/packages/server/src/__tests__/oauth-callback-server.test.ts +165 -0
  86. package/packages/server/src/__tests__/openspec-archive.test.ts +87 -0
  87. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +163 -0
  88. package/packages/server/src/__tests__/package-routes.test.ts +172 -0
  89. package/packages/server/src/__tests__/pending-fork-registry.test.ts +69 -0
  90. package/packages/server/src/__tests__/pending-load-manager.test.ts +144 -0
  91. package/packages/server/src/__tests__/pending-resume-registry.test.ts +130 -0
  92. package/packages/server/src/__tests__/pi-resource-scanner.test.ts +235 -0
  93. package/packages/server/src/__tests__/preferences-store.test.ts +108 -0
  94. package/packages/server/src/__tests__/process-manager.test.ts +184 -0
  95. package/packages/server/src/__tests__/provider-auth-handlers.test.ts +93 -0
  96. package/packages/server/src/__tests__/provider-auth-routes.test.ts +143 -0
  97. package/packages/server/src/__tests__/provider-auth-storage.test.ts +114 -0
  98. package/packages/server/src/__tests__/resolve-path.test.ts +38 -0
  99. package/packages/server/src/__tests__/ring-buffer.test.ts +45 -0
  100. package/packages/server/src/__tests__/server-pid.test.ts +89 -0
  101. package/packages/server/src/__tests__/session-api.test.ts +244 -0
  102. package/packages/server/src/__tests__/session-diff.test.ts +138 -0
  103. package/packages/server/src/__tests__/session-file-dedup.test.ts +102 -0
  104. package/packages/server/src/__tests__/session-file-reader.test.ts +85 -0
  105. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +138 -0
  106. package/packages/server/src/__tests__/session-order-manager.test.ts +135 -0
  107. package/packages/server/src/__tests__/session-ordering-integration.test.ts +102 -0
  108. package/packages/server/src/__tests__/session-scanner.test.ts +199 -0
  109. package/packages/server/src/__tests__/shutdown-endpoint.test.ts +42 -0
  110. package/packages/server/src/__tests__/skip-wipe.test.ts +123 -0
  111. package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +126 -0
  112. package/packages/server/src/__tests__/smoke-integration.test.ts +175 -0
  113. package/packages/server/src/__tests__/spa-fallback.test.ts +68 -0
  114. package/packages/server/src/__tests__/subscription-handler.test.ts +155 -0
  115. package/packages/server/src/__tests__/terminal-gateway.test.ts +61 -0
  116. package/packages/server/src/__tests__/terminal-manager.test.ts +257 -0
  117. package/packages/server/src/__tests__/trusted-networks-config.test.ts +84 -0
  118. package/packages/server/src/__tests__/tunnel.test.ts +206 -0
  119. package/packages/server/src/__tests__/ws-ping-pong.test.ts +112 -0
  120. package/packages/server/src/auth-plugin.ts +302 -0
  121. package/packages/server/src/auth.ts +323 -0
  122. package/packages/server/src/browse.ts +55 -0
  123. package/packages/server/src/browser-gateway.ts +495 -0
  124. package/packages/server/src/browser-handlers/directory-handler.ts +137 -0
  125. package/packages/server/src/browser-handlers/handler-context.ts +45 -0
  126. package/packages/server/src/browser-handlers/session-action-handler.ts +271 -0
  127. package/packages/server/src/browser-handlers/session-meta-handler.ts +95 -0
  128. package/packages/server/src/browser-handlers/subscription-handler.ts +154 -0
  129. package/packages/server/src/browser-handlers/terminal-handler.ts +37 -0
  130. package/packages/server/src/cli.ts +347 -0
  131. package/packages/server/src/config-api.ts +130 -0
  132. package/packages/server/src/directory-service.ts +162 -0
  133. package/packages/server/src/editor-detection.ts +60 -0
  134. package/packages/server/src/editor-manager.ts +352 -0
  135. package/packages/server/src/editor-proxy.ts +134 -0
  136. package/packages/server/src/editor-registry.ts +108 -0
  137. package/packages/server/src/event-status-extraction.ts +131 -0
  138. package/packages/server/src/event-wiring.ts +589 -0
  139. package/packages/server/src/extension-register.ts +92 -0
  140. package/packages/server/src/git-operations.ts +200 -0
  141. package/packages/server/src/headless-pid-registry.ts +207 -0
  142. package/packages/server/src/idle-timer.ts +61 -0
  143. package/packages/server/src/json-store.ts +32 -0
  144. package/packages/server/src/localhost-guard.ts +117 -0
  145. package/packages/server/src/memory-event-store.ts +193 -0
  146. package/packages/server/src/memory-session-manager.ts +123 -0
  147. package/packages/server/src/meta-persistence.ts +64 -0
  148. package/packages/server/src/migrate-persistence.ts +195 -0
  149. package/packages/server/src/npm-search-proxy.ts +143 -0
  150. package/packages/server/src/oauth-callback-server.ts +177 -0
  151. package/packages/server/src/openspec-archive.ts +60 -0
  152. package/packages/server/src/package-manager-wrapper.ts +200 -0
  153. package/packages/server/src/pending-fork-registry.ts +53 -0
  154. package/packages/server/src/pending-load-manager.ts +110 -0
  155. package/packages/server/src/pending-resume-registry.ts +69 -0
  156. package/packages/server/src/pi-gateway.ts +419 -0
  157. package/packages/server/src/pi-resource-scanner.ts +369 -0
  158. package/packages/server/src/preferences-store.ts +116 -0
  159. package/packages/server/src/process-manager.ts +311 -0
  160. package/packages/server/src/provider-auth-handlers.ts +438 -0
  161. package/packages/server/src/provider-auth-storage.ts +200 -0
  162. package/packages/server/src/resolve-path.ts +12 -0
  163. package/packages/server/src/routes/editor-routes.ts +86 -0
  164. package/packages/server/src/routes/file-routes.ts +116 -0
  165. package/packages/server/src/routes/git-routes.ts +89 -0
  166. package/packages/server/src/routes/openspec-routes.ts +99 -0
  167. package/packages/server/src/routes/package-routes.ts +172 -0
  168. package/packages/server/src/routes/provider-auth-routes.ts +244 -0
  169. package/packages/server/src/routes/provider-routes.ts +101 -0
  170. package/packages/server/src/routes/route-deps.ts +23 -0
  171. package/packages/server/src/routes/session-routes.ts +91 -0
  172. package/packages/server/src/routes/system-routes.ts +271 -0
  173. package/packages/server/src/server-pid.ts +84 -0
  174. package/packages/server/src/server.ts +554 -0
  175. package/packages/server/src/session-api.ts +330 -0
  176. package/packages/server/src/session-bootstrap.ts +80 -0
  177. package/packages/server/src/session-diff.ts +178 -0
  178. package/packages/server/src/session-discovery.ts +134 -0
  179. package/packages/server/src/session-file-reader.ts +135 -0
  180. package/packages/server/src/session-order-manager.ts +73 -0
  181. package/packages/server/src/session-scanner.ts +233 -0
  182. package/packages/server/src/session-stats-reader.ts +99 -0
  183. package/packages/server/src/terminal-gateway.ts +51 -0
  184. package/packages/server/src/terminal-manager.ts +241 -0
  185. package/packages/server/src/tunnel.ts +329 -0
  186. package/packages/server/tsconfig.json +11 -0
  187. package/packages/shared/package.json +15 -0
  188. package/packages/shared/src/__tests__/config.test.ts +358 -0
  189. package/packages/shared/src/__tests__/deriveChangeState.test.ts +95 -0
  190. package/packages/shared/src/__tests__/mdns-discovery.test.ts +80 -0
  191. package/packages/shared/src/__tests__/protocol.test.ts +243 -0
  192. package/packages/shared/src/__tests__/resolve-jiti.test.ts +17 -0
  193. package/packages/shared/src/__tests__/server-identity.test.ts +73 -0
  194. package/packages/shared/src/__tests__/session-meta.test.ts +125 -0
  195. package/packages/shared/src/archive-types.ts +11 -0
  196. package/packages/shared/src/browser-protocol.ts +534 -0
  197. package/packages/shared/src/config.ts +245 -0
  198. package/packages/shared/src/diff-types.ts +41 -0
  199. package/packages/shared/src/editor-types.ts +18 -0
  200. package/packages/shared/src/mdns-discovery.ts +248 -0
  201. package/packages/shared/src/openspec-activity-detector.ts +109 -0
  202. package/packages/shared/src/openspec-poller.ts +96 -0
  203. package/packages/shared/src/protocol.ts +369 -0
  204. package/packages/shared/src/resolve-jiti.ts +43 -0
  205. package/packages/shared/src/rest-api.ts +255 -0
  206. package/packages/shared/src/server-identity.ts +51 -0
  207. package/packages/shared/src/session-meta.ts +86 -0
  208. package/packages/shared/src/state-replay.ts +174 -0
  209. package/packages/shared/src/stats-extractor.ts +54 -0
  210. package/packages/shared/src/terminal-types.ts +18 -0
  211. package/packages/shared/src/types.ts +351 -0
  212. package/packages/shared/tsconfig.json +8 -0
@@ -0,0 +1,583 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { createUiProxy, type UiProxyOptions } from "../ui-proxy.js";
3
+
4
+ function createMockUi() {
5
+ return {
6
+ confirm: vi.fn().mockImplementation(() => new Promise(() => {})), // never resolves by default
7
+ select: vi.fn().mockImplementation(() => new Promise(() => {})),
8
+ input: vi.fn().mockImplementation(() => new Promise(() => {})),
9
+ editor: vi.fn().mockImplementation(() => new Promise(() => {})),
10
+ notify: vi.fn(),
11
+ };
12
+ }
13
+
14
+ function createMockConnection() {
15
+ return {
16
+ send: vi.fn(),
17
+ };
18
+ }
19
+
20
+ describe("createUiProxy", () => {
21
+ let mockUi: ReturnType<typeof createMockUi>;
22
+ let mockConnection: ReturnType<typeof createMockConnection>;
23
+ let proxy: ReturnType<typeof createUiProxy>;
24
+ let sessionId: string;
25
+
26
+ beforeEach(() => {
27
+ mockUi = createMockUi();
28
+ mockConnection = createMockConnection();
29
+ sessionId = "test-session";
30
+ });
31
+
32
+ function setup(hasUI: boolean) {
33
+ proxy = createUiProxy({
34
+ ui: mockUi as any,
35
+ hasUI,
36
+ getSessionId: () => sessionId,
37
+ send: mockConnection.send,
38
+ });
39
+ }
40
+
41
+ describe("confirm forwarding", () => {
42
+ it("should send extension_ui_request for confirm", () => {
43
+ setup(false);
44
+ proxy.wrappedUi.confirm("Delete?", "This is permanent");
45
+
46
+ expect(mockConnection.send).toHaveBeenCalledWith(
47
+ expect.objectContaining({
48
+ type: "extension_ui_request",
49
+ sessionId: "test-session",
50
+ method: "confirm",
51
+ params: { title: "Delete?", message: "This is permanent" },
52
+ }),
53
+ );
54
+ });
55
+
56
+ it("should resolve when dashboard responds with confirmed", async () => {
57
+ setup(false);
58
+ const promise = proxy.wrappedUi.confirm("Delete?", "Sure?");
59
+
60
+ const requestId = mockConnection.send.mock.calls[0][0].requestId;
61
+ proxy.handleResponse({ type: "extension_ui_response", sessionId, requestId, result: { confirmed: true } });
62
+
63
+ expect(await promise).toBe(true);
64
+ });
65
+
66
+ it("should resolve false when cancelled", async () => {
67
+ setup(false);
68
+ const promise = proxy.wrappedUi.confirm("Delete?", "Sure?");
69
+
70
+ const requestId = mockConnection.send.mock.calls[0][0].requestId;
71
+ proxy.handleResponse({ type: "extension_ui_response", sessionId, requestId, cancelled: true });
72
+
73
+ expect(await promise).toBe(false);
74
+ });
75
+ });
76
+
77
+ describe("select forwarding", () => {
78
+ it("should send extension_ui_request for select", () => {
79
+ setup(false);
80
+ proxy.wrappedUi.select("Pick:", ["A", "B", "C"]);
81
+
82
+ expect(mockConnection.send).toHaveBeenCalledWith(
83
+ expect.objectContaining({
84
+ type: "extension_ui_request",
85
+ method: "select",
86
+ params: { title: "Pick:", options: ["A", "B", "C"] },
87
+ }),
88
+ );
89
+ });
90
+
91
+ it("should resolve with selected value", async () => {
92
+ setup(false);
93
+ const promise = proxy.wrappedUi.select("Pick:", ["A", "B"]);
94
+
95
+ const requestId = mockConnection.send.mock.calls[0][0].requestId;
96
+ proxy.handleResponse({ type: "extension_ui_response", sessionId, requestId, result: { value: "B" } });
97
+
98
+ expect(await promise).toBe("B");
99
+ });
100
+
101
+ it("should resolve undefined when cancelled", async () => {
102
+ setup(false);
103
+ const promise = proxy.wrappedUi.select("Pick:", ["A"]);
104
+
105
+ const requestId = mockConnection.send.mock.calls[0][0].requestId;
106
+ proxy.handleResponse({ type: "extension_ui_response", sessionId, requestId, cancelled: true });
107
+
108
+ expect(await promise).toBeUndefined();
109
+ });
110
+ });
111
+
112
+ describe("input forwarding", () => {
113
+ it("should send extension_ui_request for input", () => {
114
+ setup(false);
115
+ proxy.wrappedUi.input("Name:", "placeholder");
116
+
117
+ expect(mockConnection.send).toHaveBeenCalledWith(
118
+ expect.objectContaining({
119
+ type: "extension_ui_request",
120
+ method: "input",
121
+ params: { title: "Name:", placeholder: "placeholder" },
122
+ }),
123
+ );
124
+ });
125
+
126
+ it("should resolve with entered value", async () => {
127
+ setup(false);
128
+ const promise = proxy.wrappedUi.input("Name:");
129
+
130
+ const requestId = mockConnection.send.mock.calls[0][0].requestId;
131
+ proxy.handleResponse({ type: "extension_ui_response", sessionId, requestId, result: { value: "hello" } });
132
+
133
+ expect(await promise).toBe("hello");
134
+ });
135
+ });
136
+
137
+ describe("editor forwarding", () => {
138
+ it("should send extension_ui_request for editor", () => {
139
+ setup(false);
140
+ proxy.wrappedUi.editor("Edit:", "prefill text");
141
+
142
+ expect(mockConnection.send).toHaveBeenCalledWith(
143
+ expect.objectContaining({
144
+ type: "extension_ui_request",
145
+ method: "editor",
146
+ params: { title: "Edit:", prefill: "prefill text" },
147
+ }),
148
+ );
149
+ });
150
+ });
151
+
152
+ describe("notify forwarding", () => {
153
+ it("should call original notify AND send to dashboard", () => {
154
+ setup(true);
155
+ proxy.wrappedUi.notify("Done!", "success");
156
+
157
+ expect(mockUi.notify).toHaveBeenCalledWith("Done!", "success");
158
+ expect(mockConnection.send).toHaveBeenCalledWith(
159
+ expect.objectContaining({
160
+ type: "extension_ui_request",
161
+ method: "notify",
162
+ params: { message: "Done!", level: "success" },
163
+ }),
164
+ );
165
+ });
166
+
167
+ it("should call original notify in headless mode too", () => {
168
+ setup(false);
169
+ proxy.wrappedUi.notify("Info", "info");
170
+
171
+ expect(mockUi.notify).toHaveBeenCalledWith("Info", "info");
172
+ expect(mockConnection.send).toHaveBeenCalled();
173
+ });
174
+ });
175
+
176
+ describe("race pattern (hasUI=true)", () => {
177
+ it("should race TUI and dashboard for confirm", async () => {
178
+ // Make original resolve after a tick
179
+ mockUi.confirm.mockResolvedValue(true);
180
+ setup(true);
181
+ const result = await proxy.wrappedUi.confirm("Title", "Msg");
182
+ // Original wins (resolves immediately)
183
+ expect(result).toBe(true);
184
+ });
185
+
186
+ it("should let dashboard win the race if faster", async () => {
187
+ // Original never resolves
188
+ setup(true);
189
+ const promise = proxy.wrappedUi.confirm("Title", "Msg");
190
+
191
+ const requestId = mockConnection.send.mock.calls[0][0].requestId;
192
+ proxy.handleResponse({ type: "extension_ui_response", sessionId, requestId, result: { confirmed: false } });
193
+
194
+ expect(await promise).toBe(false);
195
+ });
196
+ });
197
+
198
+ describe("race cancellation", () => {
199
+ it("should pass AbortSignal to TUI dialog calls", () => {
200
+ setup(true);
201
+ // Make TUI never resolve so we can inspect the call
202
+ proxy.wrappedUi.confirm("Title", "Msg");
203
+
204
+ expect(mockUi.confirm).toHaveBeenCalledWith(
205
+ "Title",
206
+ "Msg",
207
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
208
+ );
209
+ });
210
+
211
+ it("should abort TUI dialog when dashboard wins confirm", async () => {
212
+ let capturedSignal: AbortSignal | undefined;
213
+ mockUi.confirm.mockImplementation((_t: string, _m: string, opts?: any) => {
214
+ capturedSignal = opts?.signal;
215
+ return new Promise(() => {}); // never resolves
216
+ });
217
+ setup(true);
218
+ const promise = proxy.wrappedUi.confirm("Title", "Msg");
219
+
220
+ const requestId = mockConnection.send.mock.calls[0][0].requestId;
221
+ proxy.handleResponse({ type: "extension_ui_response", sessionId, requestId, result: { confirmed: true } });
222
+ await promise;
223
+
224
+ // Wait a tick for the .then() cleanup to run
225
+ await new Promise((r) => setTimeout(r, 0));
226
+ expect(capturedSignal?.aborted).toBe(true);
227
+ });
228
+
229
+ it("should send extension_ui_dismiss when TUI wins confirm", async () => {
230
+ mockUi.confirm.mockResolvedValue(true);
231
+ setup(true);
232
+ await proxy.wrappedUi.confirm("Title", "Msg");
233
+
234
+ // Wait a tick for the .then() cleanup to run
235
+ await new Promise((r) => setTimeout(r, 0));
236
+
237
+ const dismissMsg = mockConnection.send.mock.calls.find(
238
+ (c: any) => c[0].type === "extension_ui_dismiss",
239
+ );
240
+ expect(dismissMsg).toBeDefined();
241
+ expect(dismissMsg![0]).toMatchObject({
242
+ type: "extension_ui_dismiss",
243
+ sessionId: "test-session",
244
+ });
245
+ });
246
+
247
+ it("should clean up pending Map when TUI wins", async () => {
248
+ mockUi.confirm.mockResolvedValue(false);
249
+ setup(true);
250
+ await proxy.wrappedUi.confirm("Title", "Msg");
251
+
252
+ // Wait for cleanup
253
+ await new Promise((r) => setTimeout(r, 0));
254
+
255
+ // Dashboard response after TUI won should be silently ignored
256
+ const requestId = mockConnection.send.mock.calls[0][0].requestId;
257
+ proxy.handleResponse({ type: "extension_ui_response", sessionId, requestId, result: { confirmed: true } });
258
+ // No error — entry already cleaned up
259
+ });
260
+
261
+ it("should abort TUI dialog when dashboard wins select", async () => {
262
+ let capturedSignal: AbortSignal | undefined;
263
+ mockUi.select.mockImplementation((_t: string, _opts: string[], opts?: any) => {
264
+ capturedSignal = opts?.signal;
265
+ return new Promise(() => {});
266
+ });
267
+ setup(true);
268
+ const promise = proxy.wrappedUi.select("Pick:", ["A", "B"]);
269
+
270
+ const requestId = mockConnection.send.mock.calls[0][0].requestId;
271
+ proxy.handleResponse({ type: "extension_ui_response", sessionId, requestId, result: { value: "A" } });
272
+ await promise;
273
+ await new Promise((r) => setTimeout(r, 0));
274
+
275
+ expect(capturedSignal?.aborted).toBe(true);
276
+ });
277
+
278
+ it("should abort TUI dialog when dashboard wins input", async () => {
279
+ let capturedSignal: AbortSignal | undefined;
280
+ mockUi.input.mockImplementation((_t: string, _p?: string, opts?: any) => {
281
+ capturedSignal = opts?.signal;
282
+ return new Promise(() => {});
283
+ });
284
+ setup(true);
285
+ const promise = proxy.wrappedUi.input("Name:");
286
+
287
+ const requestId = mockConnection.send.mock.calls[0][0].requestId;
288
+ proxy.handleResponse({ type: "extension_ui_response", sessionId, requestId, result: { value: "hello" } });
289
+ await promise;
290
+ await new Promise((r) => setTimeout(r, 0));
291
+
292
+ expect(capturedSignal?.aborted).toBe(true);
293
+ });
294
+
295
+ it("should send dismiss when TUI wins select", async () => {
296
+ mockUi.select.mockResolvedValue("B");
297
+ setup(true);
298
+ await proxy.wrappedUi.select("Pick:", ["A", "B"]);
299
+ await new Promise((r) => setTimeout(r, 0));
300
+
301
+ const dismissMsg = mockConnection.send.mock.calls.find(
302
+ (c: any) => c[0].type === "extension_ui_dismiss",
303
+ );
304
+ expect(dismissMsg).toBeDefined();
305
+ });
306
+
307
+ it("should abort TUI input when dashboard wins multiselect", async () => {
308
+ let capturedSignal: AbortSignal | undefined;
309
+ mockUi.input.mockImplementation((_t: string, _p?: string, opts?: any) => {
310
+ capturedSignal = opts?.signal;
311
+ return new Promise(() => {});
312
+ });
313
+ setup(true);
314
+ const promise = proxy.wrappedUi.multiselect("Pick:", ["A", "B"]);
315
+
316
+ const requestId = mockConnection.send.mock.calls[0][0].requestId;
317
+ proxy.handleResponse({ type: "extension_ui_response", sessionId, requestId, result: { values: ["A"] } });
318
+ await promise;
319
+ await new Promise((r) => setTimeout(r, 0));
320
+
321
+ expect(capturedSignal?.aborted).toBe(true);
322
+ });
323
+ });
324
+
325
+ describe("headless-only mode (hasUI=false)", () => {
326
+ it("should NOT call original dialog methods", () => {
327
+ setup(false);
328
+ proxy.wrappedUi.confirm("Title", "Msg");
329
+ expect(mockUi.confirm).not.toHaveBeenCalled();
330
+ });
331
+
332
+ it("should only await dashboard response", async () => {
333
+ setup(false);
334
+ const promise = proxy.wrappedUi.select("Pick:", ["A"]);
335
+
336
+ expect(mockUi.select).not.toHaveBeenCalled();
337
+
338
+ const requestId = mockConnection.send.mock.calls[0][0].requestId;
339
+ proxy.handleResponse({ type: "extension_ui_response", sessionId, requestId, result: { value: "A" } });
340
+
341
+ expect(await promise).toBe("A");
342
+ });
343
+ });
344
+
345
+ describe("multiselect forwarding", () => {
346
+ it("should send extension_ui_request for multiselect", () => {
347
+ setup(false);
348
+ proxy.wrappedUi.multiselect("Pick files:", ["a.ts", "b.ts", "c.ts"]);
349
+
350
+ expect(mockConnection.send).toHaveBeenCalledWith(
351
+ expect.objectContaining({
352
+ type: "extension_ui_request",
353
+ method: "multiselect",
354
+ params: { title: "Pick files:", options: ["a.ts", "b.ts", "c.ts"] },
355
+ }),
356
+ );
357
+ });
358
+
359
+ it("should resolve with selected values array", async () => {
360
+ setup(false);
361
+ const promise = proxy.wrappedUi.multiselect("Pick:", ["A", "B", "C"]);
362
+
363
+ const requestId = mockConnection.send.mock.calls[0][0].requestId;
364
+ proxy.handleResponse({
365
+ type: "extension_ui_response",
366
+ sessionId,
367
+ requestId,
368
+ result: { values: ["A", "C"] },
369
+ });
370
+
371
+ expect(await promise).toEqual(["A", "C"]);
372
+ });
373
+
374
+ it("should resolve with empty array when cancelled", async () => {
375
+ setup(false);
376
+ const promise = proxy.wrappedUi.multiselect("Pick:", ["A", "B"]);
377
+
378
+ const requestId = mockConnection.send.mock.calls[0][0].requestId;
379
+ proxy.handleResponse({
380
+ type: "extension_ui_response",
381
+ sessionId,
382
+ requestId,
383
+ cancelled: true,
384
+ });
385
+
386
+ expect(await promise).toEqual([]);
387
+ });
388
+
389
+ it("should use TUI input fallback with numbered options when hasUI=true", async () => {
390
+ mockUi.input.mockResolvedValue("1,3");
391
+ setup(true);
392
+ const promise = proxy.wrappedUi.multiselect("Pick:", ["a.ts", "b.ts", "c.ts"]);
393
+
394
+ const result = await promise;
395
+ expect(result).toEqual(["a.ts", "c.ts"]);
396
+ expect(mockUi.input).toHaveBeenCalledWith(
397
+ expect.stringContaining("1. a.ts"),
398
+ expect.any(String),
399
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
400
+ );
401
+ });
402
+
403
+ it("should return empty array when TUI input is empty or cancelled", async () => {
404
+ mockUi.input.mockResolvedValue(undefined);
405
+ setup(true);
406
+ const result = await proxy.wrappedUi.multiselect("Pick:", ["a.ts"]);
407
+ expect(result).toEqual([]);
408
+ });
409
+
410
+ it("should ignore invalid numbers in TUI input", async () => {
411
+ mockUi.input.mockResolvedValue("1, 99, abc, 2");
412
+ setup(true);
413
+ const result = await proxy.wrappedUi.multiselect("Pick:", ["a.ts", "b.ts", "c.ts"]);
414
+ expect(result).toEqual(["a.ts", "b.ts"]);
415
+ });
416
+
417
+ it("should only await dashboard in headless mode", async () => {
418
+ setup(false);
419
+ const promise = proxy.wrappedUi.multiselect("Pick:", ["A"]);
420
+
421
+ expect(mockUi.input).not.toHaveBeenCalled();
422
+
423
+ const requestId = mockConnection.send.mock.calls[0][0].requestId;
424
+ proxy.handleResponse({
425
+ type: "extension_ui_response",
426
+ sessionId,
427
+ requestId,
428
+ result: { values: ["A"] },
429
+ });
430
+
431
+ expect(await promise).toEqual(["A"]);
432
+ });
433
+ });
434
+
435
+ describe("message forwarding", () => {
436
+ it("should include message in input params when provided via opts", () => {
437
+ setup(false);
438
+ proxy.wrappedUi.input("Name:", "placeholder", { message: "Detailed question" });
439
+
440
+ expect(mockConnection.send).toHaveBeenCalledWith(
441
+ expect.objectContaining({
442
+ type: "extension_ui_request",
443
+ method: "input",
444
+ params: { title: "Name:", placeholder: "placeholder", message: "Detailed question" },
445
+ }),
446
+ );
447
+ });
448
+
449
+ it("should include message in select params when provided via opts", () => {
450
+ setup(false);
451
+ proxy.wrappedUi.select("Pick:", ["A", "B"], { message: "Context info" });
452
+
453
+ expect(mockConnection.send).toHaveBeenCalledWith(
454
+ expect.objectContaining({
455
+ type: "extension_ui_request",
456
+ method: "select",
457
+ params: { title: "Pick:", options: ["A", "B"], message: "Context info" },
458
+ }),
459
+ );
460
+ });
461
+
462
+ it("should include message in multiselect params when provided via opts", () => {
463
+ setup(false);
464
+ proxy.wrappedUi.multiselect("Pick:", ["A", "B"], { message: "Select carefully" });
465
+
466
+ expect(mockConnection.send).toHaveBeenCalledWith(
467
+ expect.objectContaining({
468
+ type: "extension_ui_request",
469
+ method: "multiselect",
470
+ params: { title: "Pick:", options: ["A", "B"], message: "Select carefully" },
471
+ }),
472
+ );
473
+ });
474
+
475
+ it("should not include message key when opts has no message", () => {
476
+ setup(false);
477
+ proxy.wrappedUi.input("Name:", "placeholder");
478
+
479
+ const params = mockConnection.send.mock.calls[0][0].params;
480
+ expect(params).toEqual({ title: "Name:", placeholder: "placeholder" });
481
+ expect("message" in params).toBe(false);
482
+ });
483
+
484
+ it("should concatenate message into TUI title for input when hasUI=true", async () => {
485
+ mockUi.input.mockResolvedValue("answer");
486
+ setup(true);
487
+ await proxy.wrappedUi.input("Title", "ph", { message: "Body text" });
488
+
489
+ expect(mockUi.input).toHaveBeenCalledWith(
490
+ "Title\n\nBody text",
491
+ "ph",
492
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
493
+ );
494
+ });
495
+
496
+ it("should concatenate message into TUI title for select when hasUI=true", async () => {
497
+ mockUi.select.mockResolvedValue("A");
498
+ setup(true);
499
+ await proxy.wrappedUi.select("Pick", ["A"], { message: "Extra context" });
500
+
501
+ expect(mockUi.select).toHaveBeenCalledWith(
502
+ "Pick\n\nExtra context",
503
+ ["A"],
504
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
505
+ );
506
+ });
507
+
508
+ it("should concatenate message into TUI title for multiselect when hasUI=true", async () => {
509
+ mockUi.input.mockResolvedValue("1");
510
+ setup(true);
511
+ await proxy.wrappedUi.multiselect("Pick", ["A", "B"], { message: "Choose wisely" });
512
+
513
+ expect(mockUi.input).toHaveBeenCalledWith(
514
+ expect.stringContaining("Pick\n\nChoose wisely"),
515
+ expect.any(String),
516
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
517
+ );
518
+ });
519
+ });
520
+
521
+ describe("unknown requestId", () => {
522
+ it("should silently ignore responses with unknown requestId", () => {
523
+ setup(false);
524
+ // No pending requests — should not throw
525
+ proxy.handleResponse({ type: "extension_ui_response", sessionId, requestId: "unknown-id", result: {} });
526
+ });
527
+ });
528
+
529
+ describe("pending request cleanup", () => {
530
+ it("should remove pending request after resolution", async () => {
531
+ setup(false);
532
+ const promise = proxy.wrappedUi.confirm("Title", "Msg");
533
+
534
+ const requestId = mockConnection.send.mock.calls[0][0].requestId;
535
+ proxy.handleResponse({ type: "extension_ui_response", sessionId, requestId, result: { confirmed: true } });
536
+ await promise;
537
+
538
+ // Second response with same ID should be ignored (already cleaned up)
539
+ proxy.handleResponse({ type: "extension_ui_response", sessionId, requestId, result: { confirmed: false } });
540
+ // No error thrown — silently ignored
541
+ });
542
+ });
543
+
544
+ describe("recursion guard", () => {
545
+ it("should not recurse when ui.confirm calls back into the proxy", async () => {
546
+ setup(true);
547
+
548
+ // Simulate ctx.ui being patched: make ui.confirm call the proxy's wrappedUi.confirm
549
+ let callCount = 0;
550
+ mockUi.confirm.mockImplementation(() => {
551
+ callCount++;
552
+ // This simulates the scenario where ui.confirm IS the proxy (ctx.ui was patched)
553
+ return proxy.wrappedUi.confirm("re-entrant", "msg");
554
+ });
555
+
556
+ const promise = proxy.wrappedUi.confirm("Test?", "msg");
557
+
558
+ // Should have called ui.confirm exactly once (inProxy guard prevents re-entry)
559
+ expect(callCount).toBe(1);
560
+
561
+ // The re-entrant call should go dashboard-only (no TUI race)
562
+ // Two sendRequest calls: original + re-entrant
563
+ expect(mockConnection.send).toHaveBeenCalledTimes(2);
564
+
565
+ // Resolve via dashboard to clean up
566
+ const requestId = mockConnection.send.mock.calls[0][0].requestId;
567
+ proxy.handleResponse({ type: "extension_ui_response", sessionId, requestId, result: { confirmed: true } });
568
+ });
569
+
570
+ it("should not recurse when ui.input calls back into the proxy", async () => {
571
+ setup(true);
572
+
573
+ let callCount = 0;
574
+ mockUi.input.mockImplementation(() => {
575
+ callCount++;
576
+ return proxy.wrappedUi.input("re-entrant", "placeholder");
577
+ });
578
+
579
+ proxy.wrappedUi.input("Test input", "placeholder");
580
+ expect(callCount).toBe(1);
581
+ });
582
+ });
583
+ });