@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,712 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { createCommandHandler, parseSendPrompt } from "../command-handler.js";
3
+ import type { ServerToExtensionMessage } from "@blackbelt-technology/pi-dashboard-shared/protocol.js";
4
+
5
+ describe("CommandHandler", () => {
6
+ function createMockPi() {
7
+ return {
8
+ sendUserMessage: vi.fn(),
9
+ getCommands: vi.fn().mockReturnValue([
10
+ { name: "test", description: "Test cmd", source: "extension" as const },
11
+ ]),
12
+ setSessionName: vi.fn(),
13
+ getSessionName: vi.fn(),
14
+ on: vi.fn(),
15
+ };
16
+ }
17
+
18
+ it("should call sendUserMessage on send_prompt when idle", async () => {
19
+ const pi = createMockPi();
20
+ const handler = createCommandHandler(pi as any, "s1");
21
+
22
+ const msg: ServerToExtensionMessage = {
23
+ type: "send_prompt",
24
+ sessionId: "s1",
25
+ text: "Hello agent",
26
+ };
27
+
28
+ await handler.handle(msg);
29
+
30
+ expect(pi.sendUserMessage).toHaveBeenCalledWith("Hello agent", { deliverAs: "followUp" });
31
+ });
32
+
33
+ it("should ignore messages for different sessionIds", async () => {
34
+ const pi = createMockPi();
35
+ const handler = createCommandHandler(pi as any, "s1");
36
+
37
+ const msg: ServerToExtensionMessage = {
38
+ type: "send_prompt",
39
+ sessionId: "s2",
40
+ text: "Hello",
41
+ };
42
+
43
+ await handler.handle(msg);
44
+ expect(pi.sendUserMessage).not.toHaveBeenCalled();
45
+ });
46
+
47
+ it("should send images with valid mimeType via sendUserMessage", async () => {
48
+ const pi = createMockPi();
49
+ const handler = createCommandHandler(pi as any, "s1");
50
+
51
+ await handler.handle({
52
+ type: "send_prompt",
53
+ sessionId: "s1",
54
+ text: "check this",
55
+ images: [
56
+ { type: "image", data: "abc123", mimeType: "image/png" },
57
+ ],
58
+ });
59
+
60
+ expect(pi.sendUserMessage).toHaveBeenCalledWith([
61
+ { type: "text", text: "check this" },
62
+ { type: "image", data: "abc123", mimeType: "image/png" },
63
+ ], { deliverAs: "followUp" });
64
+ });
65
+
66
+ it("should drop images with invalid mimeType and send text only", async () => {
67
+ const pi = createMockPi();
68
+ const handler = createCommandHandler(pi as any, "s1");
69
+
70
+ await handler.handle({
71
+ type: "send_prompt",
72
+ sessionId: "s1",
73
+ text: "check this",
74
+ images: [
75
+ { type: "image", data: "abc123", mimeType: "image/bmp" },
76
+ ],
77
+ });
78
+
79
+ // Invalid mimeType → dropped, sends text only
80
+ expect(pi.sendUserMessage).toHaveBeenCalledWith("check this", { deliverAs: "followUp" });
81
+ });
82
+
83
+ it("should drop images with undefined or null mimeType", async () => {
84
+ const pi = createMockPi();
85
+ const handler = createCommandHandler(pi as any, "s1");
86
+
87
+ await handler.handle({
88
+ type: "send_prompt",
89
+ sessionId: "s1",
90
+ text: "check this",
91
+ images: [
92
+ { type: "image", data: "abc123", mimeType: undefined as any },
93
+ { type: "image", data: "abc123", mimeType: null as any },
94
+ ],
95
+ });
96
+
97
+ expect(pi.sendUserMessage).toHaveBeenCalledWith("check this", { deliverAs: "followUp" });
98
+ });
99
+
100
+ it("should drop images with empty or non-string data", async () => {
101
+ const pi = createMockPi();
102
+ const handler = createCommandHandler(pi as any, "s1");
103
+
104
+ await handler.handle({
105
+ type: "send_prompt",
106
+ sessionId: "s1",
107
+ text: "check this",
108
+ images: [
109
+ { type: "image", data: "", mimeType: "image/png" },
110
+ ],
111
+ });
112
+
113
+ expect(pi.sendUserMessage).toHaveBeenCalledWith("check this", { deliverAs: "followUp" });
114
+ });
115
+
116
+ it("should drop non-object image entries", async () => {
117
+ const pi = createMockPi();
118
+ const handler = createCommandHandler(pi as any, "s1");
119
+
120
+ await handler.handle({
121
+ type: "send_prompt",
122
+ sessionId: "s1",
123
+ text: "check this",
124
+ images: [null as any, "bad" as any],
125
+ });
126
+
127
+ expect(pi.sendUserMessage).toHaveBeenCalledWith("check this", { deliverAs: "followUp" });
128
+ });
129
+
130
+ it("should keep valid images and drop invalid ones", async () => {
131
+ const pi = createMockPi();
132
+ const handler = createCommandHandler(pi as any, "s1");
133
+
134
+ await handler.handle({
135
+ type: "send_prompt",
136
+ sessionId: "s1",
137
+ text: "check this",
138
+ images: [
139
+ { type: "image", data: "good", mimeType: "image/jpeg" },
140
+ { type: "image", data: "bad", mimeType: "image/bmp" },
141
+ { type: "image", data: "also-good", mimeType: "image/webp" },
142
+ ],
143
+ });
144
+
145
+ expect(pi.sendUserMessage).toHaveBeenCalledWith([
146
+ { type: "text", text: "check this" },
147
+ { type: "image", data: "good", mimeType: "image/jpeg" },
148
+ { type: "image", data: "also-good", mimeType: "image/webp" },
149
+ ], { deliverAs: "followUp" });
150
+ });
151
+
152
+ it("should handle rename_session by calling setSessionName and returning confirmation", async () => {
153
+ const pi = createMockPi();
154
+ const handler = createCommandHandler(pi as any, "s1");
155
+
156
+ const result = await handler.handle({
157
+ type: "rename_session",
158
+ sessionId: "s1",
159
+ name: "My New Name",
160
+ });
161
+
162
+ expect(pi.setSessionName).toHaveBeenCalledWith("My New Name");
163
+ expect(result).toEqual({
164
+ type: "session_name_update",
165
+ sessionId: "s1",
166
+ name: "My New Name",
167
+ });
168
+ });
169
+
170
+ it("should call shutdown option when shutdown message received", async () => {
171
+ const pi = createMockPi();
172
+ const shutdown = vi.fn();
173
+ const handler = createCommandHandler(pi as any, "s1", { shutdown });
174
+
175
+ await handler.handle({ type: "shutdown", sessionId: "s1" } as ServerToExtensionMessage);
176
+ expect(shutdown).toHaveBeenCalled();
177
+ });
178
+
179
+ it("should not crash when shutdown called without option", async () => {
180
+ const pi = createMockPi();
181
+ const handler = createCommandHandler(pi as any, "s1");
182
+
183
+ // Should not throw
184
+ await handler.handle({ type: "shutdown", sessionId: "s1" } as ServerToExtensionMessage);
185
+ });
186
+
187
+ it("should call abort option when abort message received", async () => {
188
+ const pi = createMockPi();
189
+ const abort = vi.fn();
190
+ const handler = createCommandHandler(pi as any, "s1", { abort });
191
+
192
+ await handler.handle({ type: "abort", sessionId: "s1" } as ServerToExtensionMessage);
193
+ expect(abort).toHaveBeenCalled();
194
+ });
195
+
196
+ it("should not crash when abort called without option", async () => {
197
+ const pi = createMockPi();
198
+ const handler = createCommandHandler(pi as any, "s1");
199
+
200
+ // Should not throw
201
+ await handler.handle({ type: "abort", sessionId: "s1" } as ServerToExtensionMessage);
202
+ });
203
+
204
+ it("should handle request_commands message", async () => {
205
+ const pi = createMockPi();
206
+ const handler = createCommandHandler(pi as any, "s1");
207
+
208
+ const msg: ServerToExtensionMessage = {
209
+ type: "request_commands",
210
+ sessionId: "s1",
211
+ };
212
+
213
+ const result = await handler.handle(msg);
214
+ expect(pi.getCommands).toHaveBeenCalled();
215
+ expect(result).toBeDefined();
216
+ expect(result?.type).toBe("commands_list");
217
+ });
218
+
219
+ it("should send flows_list via eventSink on request_commands", async () => {
220
+ const pi = createMockPi();
221
+ (pi as any).events = {
222
+ emit: vi.fn((event: string, probe: any) => {
223
+ if (event === "flow:list-flows") {
224
+ probe.flows = [{ name: "my-flow", description: "A flow", taskRequired: false }];
225
+ }
226
+ }),
227
+ };
228
+ const eventSink = vi.fn();
229
+ const handler = createCommandHandler(pi as any, "s1", { eventSink });
230
+
231
+ await handler.handle({ type: "request_commands", sessionId: "s1" });
232
+ expect(eventSink).toHaveBeenCalledWith({
233
+ type: "flows_list",
234
+ sessionId: "s1",
235
+ flows: [{ name: "my-flow", description: "A flow", taskRequired: false }],
236
+ });
237
+ });
238
+
239
+ it("should send empty flows_list when pi-flows is not installed", async () => {
240
+ const pi = createMockPi();
241
+ // No events property — pi-flows not installed
242
+ const eventSink = vi.fn();
243
+ const handler = createCommandHandler(pi as any, "s1", { eventSink });
244
+
245
+ await handler.handle({ type: "request_commands", sessionId: "s1" });
246
+ expect(eventSink).toHaveBeenCalledWith({
247
+ type: "flows_list",
248
+ sessionId: "s1",
249
+ flows: [],
250
+ });
251
+ });
252
+
253
+ it("should filter hidden commands (starting with __) from commands list", async () => {
254
+ const pi = createMockPi();
255
+ pi.getCommands.mockReturnValue([
256
+ { name: "test", description: "Test cmd", source: "extension" as const },
257
+ { name: "__dashboard", source: "extension" as const },
258
+ { name: "__internal", source: "extension" as const },
259
+ { name: "review", description: "Review", source: "prompt" as const },
260
+ ]);
261
+ const handler = createCommandHandler(pi as any, "s1");
262
+
263
+ const result = await handler.handle({ type: "request_commands", sessionId: "s1" });
264
+ expect(result?.type).toBe("commands_list");
265
+ const commands = (result as any).commands;
266
+ expect(commands).toHaveLength(2);
267
+ expect(commands.map((c: any) => c.name)).toEqual(["test", "review"]);
268
+ });
269
+
270
+ it("should handle list_sessions gracefully when SessionManager is unavailable", async () => {
271
+ const pi = createMockPi();
272
+ const handler = createCommandHandler(pi as any, "s1");
273
+
274
+ const result = await handler.handle({
275
+ type: "list_sessions",
276
+ sessionId: "s1",
277
+ cwd: "/some/path",
278
+ } as any);
279
+
280
+ // Should return empty array on import failure
281
+ expect(result).toBeDefined();
282
+ expect(result!.type).toBe("sessions_list");
283
+ expect((result as any).sessions).toEqual([]);
284
+ });
285
+
286
+ it("should use sessionId getter for dynamic session ID", async () => {
287
+ const pi = createMockPi();
288
+ let currentId = "s1";
289
+ const handler = createCommandHandler(pi as any, () => currentId);
290
+
291
+ // Message for s1 should work
292
+ await handler.handle({ type: "send_prompt", sessionId: "s1", text: "hello" });
293
+ expect(pi.sendUserMessage).toHaveBeenCalledWith("hello", { deliverAs: "followUp" });
294
+
295
+ pi.sendUserMessage.mockClear();
296
+
297
+ // Change the session ID
298
+ currentId = "s2";
299
+
300
+ // Now message for s1 should be ignored
301
+ await handler.handle({ type: "send_prompt", sessionId: "s1", text: "ignored" });
302
+ expect(pi.sendUserMessage).not.toHaveBeenCalled();
303
+
304
+ // And message for s2 should work
305
+ await handler.handle({ type: "send_prompt", sessionId: "s2", text: "accepted" });
306
+ expect(pi.sendUserMessage).toHaveBeenCalledWith("accepted", { deliverAs: "followUp" });
307
+ });
308
+
309
+ describe("command routing", () => {
310
+ it("should route !!command as silent bash execution", async () => {
311
+ const pi = createMockPi();
312
+ const exec = vi.fn().mockResolvedValue({ stdout: "output", stderr: "", exitCode: 0 });
313
+ (pi as any).exec = exec;
314
+ const eventSink = vi.fn();
315
+ const handler = createCommandHandler(pi as any, "s1", { eventSink });
316
+
317
+ await handler.handle({ type: "send_prompt", sessionId: "s1", text: "!!ls -la" });
318
+
319
+ expect(exec).toHaveBeenCalledWith("sh", ["-c", "ls -la"], expect.objectContaining({ timeout: 30000 }));
320
+ expect(pi.sendUserMessage).not.toHaveBeenCalled();
321
+ expect(eventSink).toHaveBeenCalledWith(expect.objectContaining({
322
+ type: "event_forward",
323
+ event: expect.objectContaining({
324
+ eventType: "bash_output",
325
+ data: expect.objectContaining({ command: "ls -la", excludeFromContext: true }),
326
+ }),
327
+ }));
328
+ });
329
+
330
+ it("should route !command as bash execution + LLM send", async () => {
331
+ const pi = createMockPi();
332
+ const exec = vi.fn().mockResolvedValue({ stdout: "file.txt", stderr: "", exitCode: 0 });
333
+ (pi as any).exec = exec;
334
+ const eventSink = vi.fn();
335
+ const handler = createCommandHandler(pi as any, "s1", { eventSink });
336
+
337
+ await handler.handle({ type: "send_prompt", sessionId: "s1", text: "!ls" });
338
+
339
+ expect(exec).toHaveBeenCalledWith("sh", ["-c", "ls"], expect.objectContaining({ timeout: 30000 }));
340
+ expect(pi.sendUserMessage).toHaveBeenCalled();
341
+ expect(eventSink).toHaveBeenCalledWith(expect.objectContaining({
342
+ type: "event_forward",
343
+ event: expect.objectContaining({
344
+ eventType: "bash_output",
345
+ data: expect.objectContaining({ command: "ls", excludeFromContext: false }),
346
+ }),
347
+ }));
348
+ });
349
+
350
+ it("should fall through for empty bang commands", async () => {
351
+ const pi = createMockPi();
352
+ const handler = createCommandHandler(pi as any, "s1");
353
+
354
+ await handler.handle({ type: "send_prompt", sessionId: "s1", text: "!" });
355
+ expect(pi.sendUserMessage).toHaveBeenCalledWith("!", { deliverAs: "followUp" });
356
+
357
+ pi.sendUserMessage.mockClear();
358
+ await handler.handle({ type: "send_prompt", sessionId: "s1", text: "!!" });
359
+ expect(pi.sendUserMessage).toHaveBeenCalledWith("!!", { deliverAs: "followUp" });
360
+ });
361
+
362
+ it("should route /compact to ctx.compact()", async () => {
363
+ const pi = createMockPi();
364
+ const compact = vi.fn();
365
+ const eventSink = vi.fn();
366
+ const handler = createCommandHandler(pi as any, "s1", { compact, eventSink });
367
+
368
+ await handler.handle({ type: "send_prompt", sessionId: "s1", text: "/compact" });
369
+
370
+ expect(compact).toHaveBeenCalledWith({});
371
+ expect(pi.sendUserMessage).not.toHaveBeenCalled();
372
+ expect(eventSink).toHaveBeenCalledWith(expect.objectContaining({
373
+ type: "event_forward",
374
+ event: expect.objectContaining({
375
+ eventType: "command_feedback",
376
+ data: expect.objectContaining({ command: "/compact", status: "started" }),
377
+ }),
378
+ }));
379
+ });
380
+
381
+ it("should route /compact with custom instructions", async () => {
382
+ const pi = createMockPi();
383
+ const compact = vi.fn();
384
+ const eventSink = vi.fn();
385
+ const handler = createCommandHandler(pi as any, "s1", { compact, eventSink });
386
+
387
+ await handler.handle({ type: "send_prompt", sessionId: "s1", text: "/compact summarize only code" });
388
+
389
+ expect(compact).toHaveBeenCalledWith({ customInstructions: "summarize only code" });
390
+ });
391
+
392
+ it("should send error feedback when compact fails", async () => {
393
+ const pi = createMockPi();
394
+ const compact = vi.fn().mockImplementation(() => { throw new Error("Already compacted"); });
395
+ const eventSink = vi.fn();
396
+ const handler = createCommandHandler(pi as any, "s1", { compact, eventSink });
397
+
398
+ await handler.handle({ type: "send_prompt", sessionId: "s1", text: "/compact" });
399
+
400
+ expect(eventSink).toHaveBeenCalledWith(expect.objectContaining({
401
+ type: "event_forward",
402
+ event: expect.objectContaining({
403
+ eventType: "command_feedback",
404
+ data: expect.objectContaining({ command: "/compact", status: "error", message: "Already compacted" }),
405
+ }),
406
+ }));
407
+ });
408
+
409
+ it("should route /slash commands through sessionPrompt when available", async () => {
410
+ const pi = createMockPi();
411
+ const sessionPrompt = vi.fn();
412
+ const handler = createCommandHandler(pi as any, "s1", { sessionPrompt });
413
+
414
+ await handler.handle({ type: "send_prompt", sessionId: "s1", text: "/some-command args" });
415
+
416
+ expect(sessionPrompt).toHaveBeenCalledWith("/some-command args");
417
+ expect(pi.sendUserMessage).not.toHaveBeenCalled();
418
+ });
419
+
420
+ it("should emit command_feedback for slash commands", async () => {
421
+ const pi = createMockPi();
422
+ const sessionPrompt = vi.fn();
423
+ const eventSink = vi.fn();
424
+ const handler = createCommandHandler(pi as any, "s1", { sessionPrompt, eventSink });
425
+
426
+ await handler.handle({ type: "send_prompt", sessionId: "s1", text: "/reload" });
427
+
428
+ expect(eventSink).toHaveBeenCalledWith(expect.objectContaining({
429
+ type: "event_forward",
430
+ event: expect.objectContaining({
431
+ eventType: "command_feedback",
432
+ data: expect.objectContaining({ command: "/reload", status: "completed" }),
433
+ }),
434
+ }));
435
+ });
436
+
437
+ it("should emit command_feedback for slash commands even without sessionPrompt", async () => {
438
+ const pi = createMockPi();
439
+ const eventSink = vi.fn();
440
+ const handler = createCommandHandler(pi as any, "s1", { eventSink });
441
+
442
+ await handler.handle({ type: "send_prompt", sessionId: "s1", text: "/some-command" });
443
+
444
+ expect(eventSink).toHaveBeenCalledWith(expect.objectContaining({
445
+ type: "event_forward",
446
+ event: expect.objectContaining({
447
+ eventType: "command_feedback",
448
+ data: expect.objectContaining({ command: "/some-command", status: "completed" }),
449
+ }),
450
+ }));
451
+ });
452
+
453
+ it("should fallback to sendUserMessage when sessionPrompt is not available for slash commands", async () => {
454
+ const pi = createMockPi();
455
+ const handler = createCommandHandler(pi as any, "s1");
456
+
457
+ await handler.handle({ type: "send_prompt", sessionId: "s1", text: "/some-command args" });
458
+
459
+ expect(pi.sendUserMessage).toHaveBeenCalledWith("/some-command args");
460
+ });
461
+
462
+ it("should route /quit to shutdown", async () => {
463
+ const pi = createMockPi();
464
+ const shutdown = vi.fn();
465
+ const handler = createCommandHandler(pi as any, "s1", { shutdown });
466
+
467
+ await handler.handle({ type: "send_prompt", sessionId: "s1", text: "/quit" });
468
+
469
+ expect(shutdown).toHaveBeenCalled();
470
+ expect(pi.sendUserMessage).not.toHaveBeenCalled();
471
+ });
472
+
473
+ it("should route /exit to shutdown", async () => {
474
+ const pi = createMockPi();
475
+ const shutdown = vi.fn();
476
+ const handler = createCommandHandler(pi as any, "s1", { shutdown });
477
+
478
+ await handler.handle({ type: "send_prompt", sessionId: "s1", text: "/exit" });
479
+
480
+ expect(shutdown).toHaveBeenCalled();
481
+ expect(pi.sendUserMessage).not.toHaveBeenCalled();
482
+ });
483
+
484
+ it("should route /reload to reload callback", async () => {
485
+ const pi = createMockPi();
486
+ const reload = vi.fn();
487
+ const eventSink = vi.fn();
488
+ const handler = createCommandHandler(pi as any, "s1", { reload, eventSink });
489
+
490
+ await handler.handle({ type: "send_prompt", sessionId: "s1", text: "/reload" });
491
+
492
+ expect(reload).toHaveBeenCalled();
493
+ expect(pi.sendUserMessage).not.toHaveBeenCalled();
494
+ expect(eventSink).toHaveBeenCalledWith(expect.objectContaining({
495
+ type: "event_forward",
496
+ event: expect.objectContaining({
497
+ eventType: "command_feedback",
498
+ data: expect.objectContaining({ command: "/reload", status: "completed" }),
499
+ }),
500
+ }));
501
+ });
502
+
503
+ it("should not crash when /reload called without option", async () => {
504
+ const pi = createMockPi();
505
+ const handler = createCommandHandler(pi as any, "s1");
506
+
507
+ await handler.handle({ type: "send_prompt", sessionId: "s1", text: "/reload" });
508
+ expect(pi.sendUserMessage).not.toHaveBeenCalled();
509
+ });
510
+
511
+ it("should route /new to spawnNew callback", async () => {
512
+ const pi = createMockPi();
513
+ const spawnNew = vi.fn();
514
+ const eventSink = vi.fn();
515
+ const handler = createCommandHandler(pi as any, "s1", { spawnNew, eventSink });
516
+
517
+ await handler.handle({ type: "send_prompt", sessionId: "s1", text: "/new" });
518
+
519
+ expect(spawnNew).toHaveBeenCalled();
520
+ expect(pi.sendUserMessage).not.toHaveBeenCalled();
521
+ expect(eventSink).toHaveBeenCalledWith(expect.objectContaining({
522
+ type: "event_forward",
523
+ event: expect.objectContaining({
524
+ eventType: "command_feedback",
525
+ data: expect.objectContaining({ command: "/new", status: "completed" }),
526
+ }),
527
+ }));
528
+ });
529
+
530
+ it("should pass plain text through to sendUserMessage", async () => {
531
+ const pi = createMockPi();
532
+ const handler = createCommandHandler(pi as any, "s1");
533
+
534
+ await handler.handle({ type: "send_prompt", sessionId: "s1", text: "explain this code" });
535
+
536
+ expect(pi.sendUserMessage).toHaveBeenCalledWith("explain this code", { deliverAs: "followUp" });
537
+ });
538
+
539
+ it("should handle bash execution with non-zero exit code", async () => {
540
+ const pi = createMockPi();
541
+ const exec = vi.fn().mockResolvedValue({ stdout: "", stderr: "not found", exitCode: 127 });
542
+ (pi as any).exec = exec;
543
+ const eventSink = vi.fn();
544
+ const handler = createCommandHandler(pi as any, "s1", { eventSink });
545
+
546
+ await handler.handle({ type: "send_prompt", sessionId: "s1", text: "!!badcmd" });
547
+
548
+ expect(eventSink).toHaveBeenCalledWith(expect.objectContaining({
549
+ type: "event_forward",
550
+ event: expect.objectContaining({
551
+ eventType: "bash_output",
552
+ data: expect.objectContaining({ exitCode: 127, output: "not found" }),
553
+ }),
554
+ }));
555
+ });
556
+ });
557
+
558
+ describe("set_model", () => {
559
+ it("should call setModel with provider and modelId", async () => {
560
+ const pi = createMockPi();
561
+ const setModel = vi.fn().mockResolvedValue(undefined);
562
+ const handler = createCommandHandler(pi as any, "s1", { setModel });
563
+
564
+ await handler.handle({
565
+ type: "set_model",
566
+ sessionId: "s1",
567
+ provider: "anthropic",
568
+ modelId: "claude-sonnet-4-20250514",
569
+ } as ServerToExtensionMessage);
570
+
571
+ expect(setModel).toHaveBeenCalledWith("anthropic", "claude-sonnet-4-20250514");
572
+ });
573
+
574
+ it("should not throw when setModel option is not provided", async () => {
575
+ const pi = createMockPi();
576
+ const handler = createCommandHandler(pi as any, "s1");
577
+
578
+ await expect(handler.handle({
579
+ type: "set_model",
580
+ sessionId: "s1",
581
+ provider: "anthropic",
582
+ modelId: "unknown-model",
583
+ } as ServerToExtensionMessage)).resolves.toBeUndefined();
584
+ });
585
+
586
+ it("should route /model slash command through setModel callback", async () => {
587
+ const pi = createMockPi();
588
+ const setModel = vi.fn().mockResolvedValue(undefined);
589
+ const eventSink = vi.fn();
590
+ const handler = createCommandHandler(pi as any, "s1", { setModel, eventSink });
591
+
592
+ await handler.handle({
593
+ type: "send_prompt",
594
+ sessionId: "s1",
595
+ text: "/model anthropic/claude-haiku-4-5",
596
+ });
597
+
598
+ expect(setModel).toHaveBeenCalledWith("anthropic", "claude-haiku-4-5");
599
+ expect(pi.sendUserMessage).not.toHaveBeenCalled();
600
+ expect(eventSink).toHaveBeenCalledWith(expect.objectContaining({
601
+ type: "event_forward",
602
+ event: expect.objectContaining({
603
+ eventType: "command_feedback",
604
+ data: expect.objectContaining({ command: "/model anthropic/claude-haiku-4-5", status: "completed" }),
605
+ }),
606
+ }));
607
+ });
608
+ });
609
+ });
610
+
611
+ describe("parseSendPrompt", () => {
612
+ it("should detect !! prefix (silent bash)", () => {
613
+ const result = parseSendPrompt("!!ls -la");
614
+ expect(result).toEqual({ type: "bash", command: "ls -la", excludeFromContext: true });
615
+ });
616
+
617
+ it("should detect ! prefix (bash with LLM)", () => {
618
+ const result = parseSendPrompt("!git status");
619
+ expect(result).toEqual({ type: "bash", command: "git status", excludeFromContext: false });
620
+ });
621
+
622
+ it("should return passthrough for empty !! ", () => {
623
+ const result = parseSendPrompt("!!");
624
+ expect(result).toEqual({ type: "passthrough", text: "!!" });
625
+ });
626
+
627
+ it("should return passthrough for empty !", () => {
628
+ const result = parseSendPrompt("!");
629
+ expect(result).toEqual({ type: "passthrough", text: "!" });
630
+ });
631
+
632
+ it("should detect /compact without args", () => {
633
+ const result = parseSendPrompt("/compact");
634
+ expect(result).toEqual({ type: "compact", customInstructions: undefined });
635
+ });
636
+
637
+ it("should detect /compact with args", () => {
638
+ const result = parseSendPrompt("/compact focus on code changes");
639
+ expect(result).toEqual({ type: "compact", customInstructions: "focus on code changes" });
640
+ });
641
+
642
+ it("should detect generic slash commands", () => {
643
+ const result = parseSendPrompt("/some-command arg1 arg2");
644
+ expect(result).toEqual({ type: "slash", text: "/some-command arg1 arg2" });
645
+ });
646
+
647
+ it("should return passthrough for plain text", () => {
648
+ const result = parseSendPrompt("explain this code");
649
+ expect(result).toEqual({ type: "passthrough", text: "explain this code" });
650
+ });
651
+
652
+ it("should return passthrough for text with / in the middle", () => {
653
+ const result = parseSendPrompt("look at src/index.ts");
654
+ expect(result).toEqual({ type: "passthrough", text: "look at src/index.ts" });
655
+ });
656
+
657
+ it("should trim bang command text", () => {
658
+ const result = parseSendPrompt("!! ls -la ");
659
+ expect(result).toEqual({ type: "bash", command: "ls -la", excludeFromContext: true });
660
+ });
661
+
662
+ it("should return passthrough for !! with only whitespace after", () => {
663
+ const result = parseSendPrompt("!! ");
664
+ expect(result).toEqual({ type: "passthrough", text: "!! " });
665
+ });
666
+
667
+ it("should detect /quit as shutdown", () => {
668
+ expect(parseSendPrompt("/quit")).toEqual({ type: "shutdown" });
669
+ });
670
+
671
+ it("should detect /exit as shutdown", () => {
672
+ expect(parseSendPrompt("/exit")).toEqual({ type: "shutdown" });
673
+ });
674
+
675
+ it("should detect /reload as reload", () => {
676
+ expect(parseSendPrompt("/reload")).toEqual({ type: "reload" });
677
+ });
678
+
679
+ it("should detect /new as new", () => {
680
+ expect(parseSendPrompt("/new")).toEqual({ type: "new" });
681
+ });
682
+
683
+ it("should detect /model provider/id as model command", () => {
684
+ expect(parseSendPrompt("/model anthropic/claude-haiku-4-5")).toEqual({
685
+ type: "model",
686
+ provider: "anthropic",
687
+ modelId: "claude-haiku-4-5",
688
+ });
689
+ });
690
+
691
+ it("should treat /model without slash in arg as generic slash", () => {
692
+ expect(parseSendPrompt("/model something")).toEqual({ type: "slash", text: "/model something" });
693
+ });
694
+
695
+ it("should treat bare /model as generic slash", () => {
696
+ expect(parseSendPrompt("/model")).toEqual({ type: "slash", text: "/model" });
697
+ });
698
+
699
+ it("should detect /flows:new as generic slash (routed by bridge sessionPrompt)", () => {
700
+ expect(parseSendPrompt("/flows:new create a test flow")).toEqual({
701
+ type: "slash",
702
+ text: "/flows:new create a test flow",
703
+ });
704
+ });
705
+
706
+ it("should detect /flows:delete as generic slash (routed by session.prompt)", () => {
707
+ expect(parseSendPrompt("/flows:delete my-flow")).toEqual({
708
+ type: "slash",
709
+ text: "/flows:delete my-flow",
710
+ });
711
+ });
712
+ });