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

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 (129) hide show
  1. package/AGENTS.md +26 -5
  2. package/README.md +30 -0
  3. package/docs/architecture.md +129 -1
  4. package/package.json +6 -5
  5. package/packages/extension/package.json +2 -2
  6. package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
  7. package/packages/extension/src/__tests__/command-handler.test.ts +10 -8
  8. package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
  9. package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
  10. package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
  11. package/packages/extension/src/bridge-context.ts +67 -3
  12. package/packages/extension/src/bridge.ts +20 -8
  13. package/packages/extension/src/command-handler.ts +36 -13
  14. package/packages/extension/src/prompt-expander.ts +74 -63
  15. package/packages/extension/src/server-launcher.ts +31 -70
  16. package/packages/extension/src/slash-dispatch.ts +123 -0
  17. package/packages/server/bin/pi-dashboard.mjs +84 -0
  18. package/packages/server/package.json +6 -5
  19. package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
  20. package/packages/server/src/__tests__/cli-parse.test.ts +12 -18
  21. package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
  22. package/packages/server/src/__tests__/directory-service.test.ts +1 -1
  23. package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
  24. package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
  25. package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
  26. package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
  27. package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
  28. package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
  29. package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
  30. package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
  31. package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
  32. package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
  33. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
  34. package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
  35. package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
  36. package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
  37. package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
  38. package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
  39. package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
  40. package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
  41. package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
  42. package/packages/server/src/__tests__/recommended-routes.test.ts +2 -2
  43. package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
  44. package/packages/server/src/auth-plugin.ts +3 -0
  45. package/packages/server/src/bootstrap-state.ts +10 -0
  46. package/packages/server/src/browser-gateway.ts +15 -7
  47. package/packages/server/src/browser-handlers/session-action-handler.ts +30 -4
  48. package/packages/server/src/cli.ts +61 -81
  49. package/packages/server/src/config-api.ts +14 -2
  50. package/packages/server/src/directory-service.ts +106 -4
  51. package/packages/server/src/event-wiring.ts +31 -1
  52. package/packages/server/src/headless-pid-registry.ts +299 -41
  53. package/packages/server/src/legacy-pi-cleanup.ts +151 -0
  54. package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
  55. package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
  56. package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
  57. package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
  58. package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
  59. package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
  60. package/packages/server/src/model-proxy/api-key-store.ts +87 -0
  61. package/packages/server/src/model-proxy/auth-gate.ts +116 -0
  62. package/packages/server/src/model-proxy/concurrency.ts +76 -0
  63. package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
  64. package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
  65. package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
  66. package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
  67. package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
  68. package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
  69. package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
  70. package/packages/server/src/model-proxy/convert/index.ts +8 -0
  71. package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
  72. package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
  73. package/packages/server/src/model-proxy/convert/types.ts +70 -0
  74. package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
  75. package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
  76. package/packages/server/src/model-proxy/internal-registry.ts +157 -0
  77. package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
  78. package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
  79. package/packages/server/src/model-proxy/request-log.ts +53 -0
  80. package/packages/server/src/model-proxy/streamer.ts +59 -0
  81. package/packages/server/src/openspec-group-store.ts +490 -0
  82. package/packages/server/src/process-manager.ts +128 -0
  83. package/packages/server/src/provider-auth-storage.ts +29 -47
  84. package/packages/server/src/restart-helper.ts +17 -16
  85. package/packages/server/src/routes/bootstrap-routes.ts +37 -0
  86. package/packages/server/src/routes/jj-routes.ts +3 -0
  87. package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
  88. package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
  89. package/packages/server/src/routes/model-proxy-routes.ts +330 -0
  90. package/packages/server/src/routes/openspec-group-routes.ts +231 -0
  91. package/packages/server/src/routes/provider-auth-routes.ts +3 -0
  92. package/packages/server/src/routes/provider-routes.ts +24 -1
  93. package/packages/server/src/routes/system-routes.ts +44 -2
  94. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
  95. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
  96. package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
  97. package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
  98. package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
  99. package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
  100. package/packages/server/src/server.ts +178 -2
  101. package/packages/server/src/session-api.ts +9 -1
  102. package/packages/server/src/tunnel-watchdog.ts +230 -0
  103. package/packages/server/src/tunnel.ts +5 -1
  104. package/packages/shared/package.json +1 -1
  105. package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
  106. package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
  107. package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
  108. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
  109. package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
  110. package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
  111. package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
  112. package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
  113. package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
  114. package/packages/shared/src/bootstrap-install.ts +1 -1
  115. package/packages/shared/src/browser-protocol.ts +27 -0
  116. package/packages/shared/src/config.ts +172 -2
  117. package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
  118. package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
  119. package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
  120. package/packages/shared/src/platform/binary-lookup.ts +204 -0
  121. package/packages/shared/src/platform/node-spawn.ts +42 -5
  122. package/packages/shared/src/protocol.ts +19 -1
  123. package/packages/shared/src/recommended-extensions.ts +18 -0
  124. package/packages/shared/src/rest-api.ts +219 -1
  125. package/packages/shared/src/server-launcher.ts +277 -0
  126. package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
  127. package/packages/shared/src/types.ts +55 -0
  128. package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -184
  129. package/packages/shared/src/resolve-jiti.ts +0 -155
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Regression test pinning the slash-command routing contract.
3
+ * Drives `command-handler.handle({type:"send_prompt"...})` against a stub pi
4
+ * and asserts the call counts + emitted command_feedback events from
5
+ * `design.md` Decision 5 table.
6
+ *
7
+ * regression: see openspec/changes/fix-extension-slash-commands-in-dashboard/
8
+ */
9
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
10
+ import { createCommandHandler } from "../command-handler.js";
11
+ import { hasDispatchCommand } from "../bridge-context.js";
12
+ import { tryDispatchExtensionCommand, type DispatchConnection } from "../slash-dispatch.js";
13
+ import type { ExtensionToServerMessage } from "@blackbelt-technology/pi-dashboard-shared/protocol.js";
14
+
15
+ interface StubOpts {
16
+ withDispatch?: boolean;
17
+ dispatchRejects?: Error;
18
+ getCommandsThrows?: boolean;
19
+ commands?: Array<{ name: string; source: string }>;
20
+ }
21
+
22
+ function makeStubPi(opts: StubOpts = {}) {
23
+ const dispatchCommand = opts.withDispatch
24
+ ? vi.fn(async (_text: string, _options?: any) => {
25
+ if (opts.dispatchRejects) throw opts.dispatchRejects;
26
+ })
27
+ : undefined;
28
+ const sendUserMessage = vi.fn();
29
+ const setSessionName = vi.fn();
30
+ const events = { emit: vi.fn() };
31
+ const getCommands = vi.fn(() => {
32
+ if (opts.getCommandsThrows) throw new Error("stale ctx");
33
+ return opts.commands ?? [
34
+ { name: "ctx-stats", source: "extension" },
35
+ { name: "skill:foo", source: "skill" },
36
+ { name: "review", source: "prompt" },
37
+ { name: "__dashboard_reload", source: "extension" },
38
+ ];
39
+ });
40
+ const pi: any = {
41
+ sendUserMessage,
42
+ getCommands,
43
+ setSessionName,
44
+ events,
45
+ };
46
+ if (dispatchCommand) pi.dispatchCommand = dispatchCommand;
47
+ return { pi, sendUserMessage, dispatchCommand, getCommands, events };
48
+ }
49
+
50
+ function feedbackEvents(sink: ReturnType<typeof vi.fn>, command: string) {
51
+ return sink.mock.calls
52
+ .map((c) => c[0] as ExtensionToServerMessage)
53
+ .filter(
54
+ (m) =>
55
+ m.type === "event_forward" &&
56
+ (m as any).event?.eventType === "command_feedback" &&
57
+ ((m as any).event?.data?.command === command),
58
+ )
59
+ .map((m) => (m as any).event.data);
60
+ }
61
+
62
+ async function drive(text: string, stub: ReturnType<typeof makeStubPi>) {
63
+ const sink = vi.fn();
64
+ const handler = createCommandHandler(stub.pi as any, "s1", { eventSink: sink });
65
+ await handler.handle({ type: "send_prompt", sessionId: "s1", text } as any);
66
+ return sink;
67
+ }
68
+
69
+ describe("bridge slash command routing (regression contract)", () => {
70
+ it("extension cmd with dispatchCommand → dispatch called, no sendUserMessage, started+completed", async () => {
71
+ // regression: see openspec/changes/fix-extension-slash-commands-in-dashboard/
72
+ const stub = makeStubPi({ withDispatch: true });
73
+ const sink = await drive("/ctx-stats", stub);
74
+
75
+ expect(stub.dispatchCommand).toHaveBeenCalledTimes(1);
76
+ expect(stub.dispatchCommand).toHaveBeenCalledWith("/ctx-stats", { streamingBehavior: "followUp" });
77
+ expect(stub.sendUserMessage).not.toHaveBeenCalled();
78
+
79
+ const evs = feedbackEvents(sink, "/ctx-stats");
80
+ expect(evs.map((e) => e.status)).toEqual(["started", "completed"]);
81
+ });
82
+
83
+ it("extension cmd, NO dispatchCommand → stopgap error, no sendUserMessage, started+error", async () => {
84
+ // regression: see openspec/changes/fix-extension-slash-commands-in-dashboard/
85
+ const stub = makeStubPi({ withDispatch: false });
86
+ const sink = await drive("/ctx-stats", stub);
87
+
88
+ expect(stub.sendUserMessage).not.toHaveBeenCalled();
89
+
90
+ const evs = feedbackEvents(sink, "/ctx-stats");
91
+ expect(evs.map((e) => e.status)).toEqual(["started", "error"]);
92
+ expect(evs[1].message).toMatch(/pi 0\.71\+/);
93
+ });
94
+
95
+ it("extension cmd dispatch rejects → started+error with err.message, no sendUserMessage", async () => {
96
+ const stub = makeStubPi({ withDispatch: true, dispatchRejects: new Error("boom") });
97
+ const sink = await drive("/ctx-stats", stub);
98
+
99
+ expect(stub.dispatchCommand).toHaveBeenCalledTimes(1);
100
+ expect(stub.sendUserMessage).not.toHaveBeenCalled();
101
+
102
+ const evs = feedbackEvents(sink, "/ctx-stats");
103
+ expect(evs.map((e) => e.status)).toEqual(["started", "error"]);
104
+ expect(evs[1].message).toBe("boom");
105
+ });
106
+
107
+ it("skill command → no dispatch, sendUserMessage called once, no command_feedback", async () => {
108
+ const stub = makeStubPi({ withDispatch: true });
109
+ const sink = await drive("/skill:foo", stub);
110
+
111
+ expect(stub.dispatchCommand).not.toHaveBeenCalled();
112
+ expect(stub.sendUserMessage).toHaveBeenCalledTimes(1);
113
+
114
+ expect(feedbackEvents(sink, "/skill:foo")).toEqual([]);
115
+ });
116
+
117
+ it("prompt template → no dispatch, sendUserMessage called once, no command_feedback", async () => {
118
+ const stub = makeStubPi({ withDispatch: true });
119
+ const sink = await drive("/review", stub);
120
+
121
+ expect(stub.dispatchCommand).not.toHaveBeenCalled();
122
+ expect(stub.sendUserMessage).toHaveBeenCalledTimes(1);
123
+ expect(feedbackEvents(sink, "/review")).toEqual([]);
124
+ });
125
+
126
+ it("passthrough text → no dispatch, sendUserMessage called once, no command_feedback", async () => {
127
+ const stub = makeStubPi({ withDispatch: true });
128
+ const sink = await drive("hello world", stub);
129
+
130
+ expect(stub.dispatchCommand).not.toHaveBeenCalled();
131
+ expect(stub.sendUserMessage).toHaveBeenCalledTimes(1);
132
+ });
133
+
134
+ it("unrecognized slash → no dispatch, sendUserMessage called once, no command_feedback", async () => {
135
+ const stub = makeStubPi({ withDispatch: true });
136
+ const sink = await drive("/totally-unknown-command", stub);
137
+
138
+ expect(stub.dispatchCommand).not.toHaveBeenCalled();
139
+ expect(stub.sendUserMessage).toHaveBeenCalledTimes(1);
140
+ expect(feedbackEvents(sink, "/totally-unknown-command")).toEqual([]);
141
+ });
142
+
143
+ it("bridge-native /__dashboard_reload → no dispatch, no error feedback, sendUserMessage fallback", async () => {
144
+ const stub = makeStubPi({ withDispatch: true });
145
+ const sink = await drive("/__dashboard_reload", stub);
146
+
147
+ expect(stub.dispatchCommand).not.toHaveBeenCalled();
148
+ // It IS in the command list with source: extension, but DASHBOARD_NATIVE_COMMANDS
149
+ // / __-prefix exclusion suppresses it. The slash branch falls through to sendUserMessage.
150
+ expect(stub.sendUserMessage).toHaveBeenCalledTimes(1);
151
+ expect(feedbackEvents(sink, "/__dashboard_reload")).toEqual([]);
152
+ });
153
+
154
+ it("getCommands throws → no crash, no command_feedback, sendUserMessage fallback fires", async () => {
155
+ const stub = makeStubPi({ withDispatch: true, getCommandsThrows: true });
156
+ const sink = await drive("/ctx-stats", stub);
157
+
158
+ expect(stub.dispatchCommand).not.toHaveBeenCalled();
159
+ // helper returns false on throw → caller falls through to sendUserMessage path
160
+ expect(stub.sendUserMessage).toHaveBeenCalledTimes(1);
161
+ expect(feedbackEvents(sink, "/ctx-stats")).toEqual([]);
162
+ });
163
+
164
+ it("never duplicates command_feedback on dispatch path (success)", async () => {
165
+ const stub = makeStubPi({ withDispatch: true });
166
+ const sink = await drive("/ctx-stats", stub);
167
+ const evs = feedbackEvents(sink, "/ctx-stats");
168
+ expect(evs.filter((e) => e.status === "started")).toHaveLength(1);
169
+ expect(evs.filter((e) => e.status === "completed" || e.status === "error")).toHaveLength(1);
170
+ });
171
+
172
+ it("never duplicates command_feedback on stopgap path", async () => {
173
+ const stub = makeStubPi({ withDispatch: false });
174
+ const sink = await drive("/ctx-stats", stub);
175
+ const evs = feedbackEvents(sink, "/ctx-stats");
176
+ expect(evs.filter((e) => e.status === "started")).toHaveLength(1);
177
+ expect(evs.filter((e) => e.status === "completed" || e.status === "error")).toHaveLength(1);
178
+ });
179
+
180
+ it("anti-regression: /ctx-stats NEVER reaches sendUserMessage", async () => {
181
+ // regression: see openspec/changes/fix-extension-slash-commands-in-dashboard/
182
+ for (const withDispatch of [true, false]) {
183
+ const stub = makeStubPi({ withDispatch });
184
+ await drive("/ctx-stats", stub);
185
+ expect(stub.sendUserMessage, `withDispatch=${withDispatch}`).not.toHaveBeenCalled();
186
+ }
187
+ });
188
+ });
189
+
190
+ // See change: add-rpc-stdin-dispatch-with-keeper-sidecar (task 7.7 + 9.1).
191
+ // Direct-driver tests for tryDispatchExtensionCommand covering the
192
+ // three-way decision (Paths B / C / D) and asserting mutual exclusion:
193
+ // for any single dispatch, EXACTLY ONE of (pi.dispatchCommand call,
194
+ // connection.send dispatch_extension_command, sink error feedback) fires.
195
+ describe("tryDispatchExtensionCommand: Path B/C/D mutual exclusion", () => {
196
+ const ORIGINAL_ENV_FLAG = process.env.PI_DASHBOARD_SPAWNED;
197
+ const ORIGINAL_ARGV = process.argv;
198
+
199
+ function setHeadless(headless: boolean) {
200
+ if (headless) {
201
+ process.env.PI_DASHBOARD_SPAWNED = "1";
202
+ process.argv = ["node", "pi", "--mode", "rpc"];
203
+ } else {
204
+ delete process.env.PI_DASHBOARD_SPAWNED;
205
+ process.argv = ["node", "pi"];
206
+ }
207
+ }
208
+
209
+ beforeEach(() => { setHeadless(false); });
210
+ afterEach(() => {
211
+ if (ORIGINAL_ENV_FLAG === undefined) delete process.env.PI_DASHBOARD_SPAWNED;
212
+ else process.env.PI_DASHBOARD_SPAWNED = ORIGINAL_ENV_FLAG;
213
+ process.argv = ORIGINAL_ARGV;
214
+ });
215
+
216
+ function makePi(opts: { withDispatch?: boolean } = {}) {
217
+ const dispatchCommand = opts.withDispatch ? vi.fn(async () => undefined) : undefined;
218
+ const getCommands = vi.fn(() => [{ name: "ctx-stats", source: "extension" }]);
219
+ const pi: any = { getCommands };
220
+ if (dispatchCommand) pi.dispatchCommand = dispatchCommand;
221
+ return { pi, dispatchCommand };
222
+ }
223
+
224
+ function makeConn(): { conn: DispatchConnection; sent: ExtensionToServerMessage[] } {
225
+ const sent: ExtensionToServerMessage[] = [];
226
+ return { conn: { send: (m) => sent.push(m) }, sent };
227
+ }
228
+
229
+ it("Path B: pi.dispatchCommand present → dispatch called; no connection.send; sink gets started+completed", async () => {
230
+ const { pi, dispatchCommand } = makePi({ withDispatch: true });
231
+ const sink = vi.fn();
232
+ const { conn, sent } = makeConn();
233
+ setHeadless(true); // headless detection irrelevant when dispatchCommand exists
234
+
235
+ const handled = await tryDispatchExtensionCommand(pi, "/ctx-stats", "sid", sink, conn);
236
+ expect(handled).toBe(true);
237
+ expect(dispatchCommand).toHaveBeenCalledTimes(1);
238
+ expect(sent.filter((m) => m.type === "dispatch_extension_command")).toEqual([]);
239
+ const evs = sink.mock.calls
240
+ .map((c: any[]) => c[0])
241
+ .filter((m: any) => m?.event?.eventType === "command_feedback")
242
+ .map((m: any) => m.event.data.status);
243
+ expect(evs).toEqual(["started", "completed"]);
244
+ });
245
+
246
+ it("Path C: no dispatchCommand + headless + connection → dispatch_extension_command emitted; no terminal feedback from bridge", async () => {
247
+ const { pi, dispatchCommand } = makePi({ withDispatch: false });
248
+ expect(dispatchCommand).toBeUndefined();
249
+ const sink = vi.fn();
250
+ const { conn, sent } = makeConn();
251
+ setHeadless(true);
252
+
253
+ const handled = await tryDispatchExtensionCommand(pi, "/ctx-stats", "sid-abc", sink, conn);
254
+ expect(handled).toBe(true);
255
+
256
+ // Exactly one dispatch_extension_command emission with the right shape.
257
+ const dispatches = sent.filter((m): m is Extract<ExtensionToServerMessage, { type: "dispatch_extension_command" }> =>
258
+ m.type === "dispatch_extension_command");
259
+ expect(dispatches).toHaveLength(1);
260
+ expect(dispatches[0].sessionId).toBe("sid-abc");
261
+ expect(dispatches[0].command).toBe("/ctx-stats");
262
+ expect(typeof dispatches[0].requestId).toBe("string");
263
+ expect(dispatches[0].requestId.length).toBeGreaterThan(0);
264
+
265
+ // Bridge emitted started ONLY — server is responsible for the terminal event.
266
+ const evs = sink.mock.calls
267
+ .map((c: any[]) => c[0])
268
+ .filter((m: any) => m?.event?.eventType === "command_feedback")
269
+ .map((m: any) => m.event.data.status);
270
+ expect(evs).toEqual(["started"]);
271
+ });
272
+
273
+ it("Path D: no dispatchCommand + non-headless + connection → stopgap error; no connection.send", async () => {
274
+ const { pi } = makePi({ withDispatch: false });
275
+ const sink = vi.fn();
276
+ const { conn, sent } = makeConn();
277
+ setHeadless(false);
278
+
279
+ const handled = await tryDispatchExtensionCommand(pi, "/ctx-stats", "sid", sink, conn);
280
+ expect(handled).toBe(true);
281
+ expect(sent.filter((m) => m.type === "dispatch_extension_command")).toEqual([]);
282
+ const evs = sink.mock.calls
283
+ .map((c: any[]) => c[0])
284
+ .filter((m: any) => m?.event?.eventType === "command_feedback");
285
+ expect(evs).toHaveLength(2);
286
+ expect(evs[0].event.data.status).toBe("started");
287
+ expect(evs[1].event.data.status).toBe("error");
288
+ expect(evs[1].event.data.message).toMatch(/pi 0\.71\+/);
289
+ });
290
+
291
+ it("Path C degrades to Path D when connection arg is undefined", async () => {
292
+ const { pi } = makePi({ withDispatch: false });
293
+ const sink = vi.fn();
294
+ setHeadless(true);
295
+
296
+ const handled = await tryDispatchExtensionCommand(pi, "/ctx-stats", "sid", sink, undefined);
297
+ expect(handled).toBe(true);
298
+ const evs = sink.mock.calls
299
+ .map((c: any[]) => c[0])
300
+ .filter((m: any) => m?.event?.eventType === "command_feedback");
301
+ // started + error (Path D fallback) — NOT just started.
302
+ expect(evs.map((e: any) => e.event.data.status)).toEqual(["started", "error"]);
303
+ });
304
+
305
+ it("non-extension /skill:foo → returns false; no path fires; no events", async () => {
306
+ const pi: any = {
307
+ getCommands: () => [{ name: "skill:foo", source: "skill" }],
308
+ dispatchCommand: vi.fn(),
309
+ };
310
+ const sink = vi.fn();
311
+ const { conn, sent } = makeConn();
312
+ setHeadless(true);
313
+
314
+ const handled = await tryDispatchExtensionCommand(pi, "/skill:foo", "sid", sink, conn);
315
+ expect(handled).toBe(false);
316
+ expect(pi.dispatchCommand).not.toHaveBeenCalled();
317
+ expect(sent).toEqual([]);
318
+ expect(sink).not.toHaveBeenCalled();
319
+ });
320
+
321
+ it("mutual exclusion: across all single-dispatch invocations, exactly one of (B, C, D) fires", async () => {
322
+ type Scenario = { withDispatch: boolean; headless: boolean; expect: "B" | "C" | "D" };
323
+ const scenarios: Scenario[] = [
324
+ { withDispatch: true, headless: true, expect: "B" },
325
+ { withDispatch: true, headless: false, expect: "B" },
326
+ { withDispatch: false, headless: true, expect: "C" },
327
+ { withDispatch: false, headless: false, expect: "D" },
328
+ ];
329
+ for (const s of scenarios) {
330
+ const { pi, dispatchCommand } = makePi({ withDispatch: s.withDispatch });
331
+ const sink = vi.fn();
332
+ const { conn, sent } = makeConn();
333
+ setHeadless(s.headless);
334
+
335
+ await tryDispatchExtensionCommand(pi, "/ctx-stats", "sid", sink, conn);
336
+
337
+ const dispatchedB = !!dispatchCommand && dispatchCommand.mock.calls.length > 0;
338
+ const dispatchedC = sent.some((m) => m.type === "dispatch_extension_command");
339
+ const errorD = sink.mock.calls.some((c: any[]) =>
340
+ (c[0] as any)?.event?.data?.status === "error");
341
+
342
+ const fired = [dispatchedB && "B", dispatchedC && "C", errorD && "D"].filter(Boolean);
343
+ expect(fired, JSON.stringify(s)).toEqual([s.expect]);
344
+ }
345
+ });
346
+ });
347
+
348
+ describe("hasDispatchCommand", () => {
349
+ it("returns true when field is a function", () => {
350
+ expect(hasDispatchCommand({ dispatchCommand: () => {} })).toBe(true);
351
+ });
352
+ it("returns false when field is absent", () => {
353
+ expect(hasDispatchCommand({})).toBe(false);
354
+ });
355
+ it("returns false when field is not a function", () => {
356
+ expect(hasDispatchCommand({ dispatchCommand: "yes" })).toBe(false);
357
+ });
358
+ it("returns false on null/undefined", () => {
359
+ expect(hasDispatchCommand(null)).toBe(false);
360
+ expect(hasDispatchCommand(undefined)).toBe(false);
361
+ });
362
+ });
@@ -502,20 +502,22 @@ describe("CommandHandler", () => {
502
502
  }));
503
503
  });
504
504
 
505
- it("should emit command_feedback for slash commands even without sessionPrompt", async () => {
505
+ it("should NOT emit command_feedback for unrecognized slash commands (no sessionPrompt)", async () => {
506
+ // Per fix-extension-slash-commands-in-dashboard, unrecognized slashes
507
+ // (not extension commands, not bridge-handled) fall through to
508
+ // sendUserMessage with NO command_feedback events. Only registered
509
+ // extension commands emit started/{completed,error}.
506
510
  const pi = createMockPi();
507
511
  const eventSink = vi.fn();
508
512
  const handler = createCommandHandler(pi as any, "s1", { eventSink });
509
513
 
510
514
  await handler.handle({ type: "send_prompt", sessionId: "s1", text: "/some-command" });
511
515
 
512
- expect(eventSink).toHaveBeenCalledWith(expect.objectContaining({
513
- type: "event_forward",
514
- event: expect.objectContaining({
515
- eventType: "command_feedback",
516
- data: expect.objectContaining({ command: "/some-command", status: "completed" }),
517
- }),
518
- }));
516
+ expect(pi.sendUserMessage).toHaveBeenCalledWith("/some-command");
517
+ const feedbackCalls = eventSink.mock.calls.filter(
518
+ (c) => (c[0] as any)?.event?.eventType === "command_feedback",
519
+ );
520
+ expect(feedbackCalls).toHaveLength(0);
519
521
  });
520
522
 
521
523
  it("should fallback to sendUserMessage when sessionPrompt is not available for slash commands", async () => {
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Pure-predicate tests for `isExtensionSlashCommand`.
3
+ * One scenario per ADDED Requirement in
4
+ * openspec/changes/fix-extension-slash-commands-in-dashboard/specs/command-routing/spec.md.
5
+ *
6
+ * regression: see openspec/changes/fix-extension-slash-commands-in-dashboard/
7
+ */
8
+ import { describe, it, expect } from "vitest";
9
+ import { isExtensionSlashCommand, isHeadlessRpcSession } from "../bridge-context.js";
10
+
11
+ describe("isExtensionSlashCommand", () => {
12
+ it("detects a bare extension command", () => {
13
+ expect(
14
+ isExtensionSlashCommand("/ctx-stats", [{ name: "ctx-stats", source: "extension" }]),
15
+ ).toBe(true);
16
+ });
17
+
18
+ it("detects an extension command with arguments", () => {
19
+ expect(
20
+ isExtensionSlashCommand("/ctx-stats verbose=1", [
21
+ { name: "ctx-stats", source: "extension" },
22
+ ]),
23
+ ).toBe(true);
24
+ });
25
+
26
+ it("rejects a skill command (source: skill)", () => {
27
+ expect(
28
+ isExtensionSlashCommand("/skill:foo", [{ name: "skill:foo", source: "skill" }]),
29
+ ).toBe(false);
30
+ });
31
+
32
+ it("rejects a prompt template (source: prompt)", () => {
33
+ expect(
34
+ isExtensionSlashCommand("/review", [{ name: "review", source: "prompt" }]),
35
+ ).toBe(false);
36
+ });
37
+
38
+ it("rejects bridge-native dashboard command (DASHBOARD_NATIVE_COMMANDS)", () => {
39
+ // `roles` is in DASHBOARD_NATIVE_COMMANDS even though pi-flows registers it
40
+ // with source: extension.
41
+ expect(
42
+ isExtensionSlashCommand("/roles", [{ name: "roles", source: "extension" }]),
43
+ ).toBe(false);
44
+ });
45
+
46
+ it("rejects __-prefixed bridge-native command", () => {
47
+ expect(
48
+ isExtensionSlashCommand("/__dashboard_reload", [
49
+ { name: "__dashboard_reload", source: "extension" },
50
+ ]),
51
+ ).toBe(false);
52
+ });
53
+
54
+ it("rejects an unknown slash", () => {
55
+ expect(isExtensionSlashCommand("/totally-unknown", [])).toBe(false);
56
+ });
57
+
58
+ it("rejects multi-line input", () => {
59
+ expect(
60
+ isExtensionSlashCommand("/ctx-stats\nuser context", [
61
+ { name: "ctx-stats", source: "extension" },
62
+ ]),
63
+ ).toBe(false);
64
+ });
65
+
66
+ it("rejects non-slash input", () => {
67
+ expect(
68
+ isExtensionSlashCommand("hello world", [{ name: "ctx-stats", source: "extension" }]),
69
+ ).toBe(false);
70
+ });
71
+
72
+ it("rejects empty slash `/`", () => {
73
+ expect(
74
+ isExtensionSlashCommand("/", [{ name: "ctx-stats", source: "extension" }]),
75
+ ).toBe(false);
76
+ });
77
+ });
78
+
79
+ // See change: add-rpc-stdin-dispatch-with-keeper-sidecar (task 7.2).
80
+ describe("isHeadlessRpcSession", () => {
81
+ it("returns true when env=1 AND argv contains --mode rpc", () => {
82
+ expect(
83
+ isHeadlessRpcSession(
84
+ { PI_DASHBOARD_SPAWNED: "1" },
85
+ ["node", "pi", "--mode", "rpc"],
86
+ ),
87
+ ).toBe(true);
88
+ });
89
+
90
+ it("returns false when env unset (non-dashboard RPC)", () => {
91
+ expect(isHeadlessRpcSession({}, ["node", "pi", "--mode", "rpc"])).toBe(false);
92
+ });
93
+
94
+ it("returns false when argv has no --mode rpc (dashboard tmux)", () => {
95
+ expect(isHeadlessRpcSession({ PI_DASHBOARD_SPAWNED: "1" }, ["node", "pi"])).toBe(false);
96
+ });
97
+
98
+ it("returns false when neither env nor argv match", () => {
99
+ expect(isHeadlessRpcSession({}, ["node", "pi"])).toBe(false);
100
+ });
101
+
102
+ it("returns false when --mode is followed by non-rpc value", () => {
103
+ expect(
104
+ isHeadlessRpcSession({ PI_DASHBOARD_SPAWNED: "1" }, ["node", "pi", "--mode", "interactive"]),
105
+ ).toBe(false);
106
+ });
107
+ });
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
2
  import { mkdirSync, writeFileSync, rmSync } from "node:fs";
3
- import { join } from "node:path";
3
+ import { dirname, join } from "node:path";
4
4
  import { expandPromptTemplateFromDisk } from "../prompt-expander.js";
5
5
  import { parseSkillBlock } from "@blackbelt-technology/pi-dashboard-shared/skill-block-parser.js";
6
6
 
@@ -96,4 +96,113 @@ describe("expandPromptTemplateFromDisk", () => {
96
96
  const result = expandPromptTemplateFromDisk("/opsx:continue x", tmpDir);
97
97
  expect(result).not.toContain("<skill name=");
98
98
  });
99
+
100
+ // Change: unify-opsx-colon-hyphen-aliases — symmetric : ↔ - resolution.
101
+
102
+ function makeSkillFile(relPath: string, body = "skill body"): string {
103
+ const abs = join(tmpDir, relPath);
104
+ mkdirSync(dirname(abs), { recursive: true });
105
+ writeFileSync(abs, `---\nname: ignored\n---\n${body}`);
106
+ return abs;
107
+ }
108
+
109
+ it("expands hyphen-typed slash command resolving a colon-registered pi.getCommands skill", () => {
110
+ const skillPath = makeSkillFile("registry/colon/SKILL.md");
111
+ const pi = {
112
+ getCommands: () => [{ name: "opsx:archive", source: "skill", path: skillPath }],
113
+ };
114
+ const result = expandPromptTemplateFromDisk("/opsx-archive my-change", tmpDir, pi);
115
+ expect(result.startsWith('<skill name="opsx:archive" location="')).toBe(true);
116
+ expect(result.endsWith("\n\nmy-change")).toBe(true);
117
+ const parsed = parseSkillBlock(result);
118
+ expect(parsed!.name).toBe("opsx:archive");
119
+ expect(parsed!.args).toBe("my-change");
120
+ });
121
+
122
+ it("expands colon-typed slash command resolving a hyphen-registered pi.getCommands skill", () => {
123
+ const skillPath = makeSkillFile("registry/hyphen/SKILL.md");
124
+ const pi = {
125
+ getCommands: () => [{ name: "opsx-archive", source: "skill", path: skillPath }],
126
+ };
127
+ const result = expandPromptTemplateFromDisk("/opsx:archive my-change", tmpDir, pi);
128
+ expect(result.startsWith('<skill name="opsx-archive" location="')).toBe(true);
129
+ expect(result.endsWith("\n\nmy-change")).toBe(true);
130
+ const parsed = parseSkillBlock(result);
131
+ expect(parsed!.name).toBe("opsx-archive");
132
+ });
133
+
134
+ it("expands colon-typed slash command resolving a hyphen-named local SKILL.md directory", () => {
135
+ mkdirSync(join(skillsDir, "opsx-archive"), { recursive: true });
136
+ writeFileSync(join(skillsDir, "opsx-archive", "SKILL.md"), "---\nname: x\n---\nbody");
137
+ const result = expandPromptTemplateFromDisk("/opsx:archive arg", tmpDir);
138
+ expect(result.startsWith('<skill name="opsx-archive" location="')).toBe(true);
139
+ const parsed = parseSkillBlock(result);
140
+ expect(parsed!.name).toBe("opsx-archive");
141
+ expect(parsed!.args).toBe("arg");
142
+ });
143
+
144
+ it("expands hyphen-typed slash command resolving a colon-named local SKILL.md directory", () => {
145
+ mkdirSync(join(skillsDir, "opsx:archive"), { recursive: true });
146
+ writeFileSync(join(skillsDir, "opsx:archive", "SKILL.md"), "---\nname: x\n---\nbody");
147
+ const result = expandPromptTemplateFromDisk("/opsx-archive arg", tmpDir);
148
+ expect(result.startsWith('<skill name="opsx:archive" location="')).toBe(true);
149
+ const parsed = parseSkillBlock(result);
150
+ expect(parsed!.name).toBe("opsx:archive");
151
+ });
152
+
153
+ it("original-form precedence: colon-typed prefers colon-registered skill over hyphen-form prompt template", () => {
154
+ // Local prompt opsx-foo.md exists; registry has skill opsx:foo.
155
+ writeFileSync(join(promptsDir, "opsx-foo.md"), "prompt body");
156
+ const skillPath = makeSkillFile("registry/precedence/SKILL.md", "skill body");
157
+ const pi = {
158
+ getCommands: () => [{ name: "opsx:foo", source: "skill", path: skillPath }],
159
+ };
160
+ // /opsx:foo → must wrap as skill (registry hit on original form).
161
+ const colon = expandPromptTemplateFromDisk("/opsx:foo", tmpDir, pi);
162
+ expect(colon.startsWith('<skill name="opsx:foo" location="')).toBe(true);
163
+ // /opsx-foo → must NOT wrap (local prompt hit on original form).
164
+ const hyphen = expandPromptTemplateFromDisk("/opsx-foo", tmpDir, pi);
165
+ expect(hyphen).not.toContain("<skill name=");
166
+ expect(hyphen).toContain("prompt body");
167
+ });
168
+
169
+ it("original-form-first across distinct pi.getCommands entries", () => {
170
+ const aPath = makeSkillFile("registry/A/SKILL.md", "A body");
171
+ const bPath = makeSkillFile("registry/B/SKILL.md", "B body");
172
+ const pi = {
173
+ getCommands: () => [
174
+ { name: "opsx:foo", source: "skill", path: aPath },
175
+ { name: "opsx-foo", source: "skill", path: bPath },
176
+ ],
177
+ };
178
+ const colon = expandPromptTemplateFromDisk("/opsx:foo arg", tmpDir, pi);
179
+ expect(colon).toContain(`location="${aPath}"`);
180
+ expect(colon).toContain('name="opsx:foo"');
181
+ expect(colon).not.toContain(`location="${bPath}"`);
182
+ const hyphen = expandPromptTemplateFromDisk("/opsx-foo arg", tmpDir, pi);
183
+ expect(hyphen).toContain(`location="${bPath}"`);
184
+ expect(hyphen).toContain('name="opsx-foo"');
185
+ expect(hyphen).not.toContain(`location="${aPath}"`);
186
+ });
187
+
188
+ it("original form in pi-registry beats remapped form in local-scan", () => {
189
+ // Local prompt opsx-foo.md exists; registry has skill opsx:foo.
190
+ writeFileSync(join(promptsDir, "opsx-foo.md"), "prompt body");
191
+ const skillPath = makeSkillFile("registry/outer/SKILL.md", "skill body");
192
+ const pi = {
193
+ getCommands: () => [{ name: "opsx:foo", source: "skill", path: skillPath }],
194
+ };
195
+ // /opsx:foo: outer-loop probes original form across ALL stores first.
196
+ // Step 3 hit on registry — must NOT fall through to remapped opsx-foo local prompt.
197
+ const result = expandPromptTemplateFromDisk("/opsx:foo", tmpDir, pi);
198
+ expect(result.startsWith('<skill name="opsx:foo" location="')).toBe(true);
199
+ expect(result).not.toContain("prompt body");
200
+ });
201
+
202
+ it("misspelled name with wrong separator returns input unchanged", () => {
203
+ rmSync(tmpDir, { recursive: true, force: true });
204
+ mkdirSync(tmpDir, { recursive: true });
205
+ const result = expandPromptTemplateFromDisk("/opsx:nonexistent foo", tmpDir);
206
+ expect(result).toBe("/opsx:nonexistent foo");
207
+ });
99
208
  });