@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.
- package/AGENTS.md +26 -5
- package/README.md +30 -0
- package/docs/architecture.md +129 -1
- package/package.json +6 -5
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +10 -8
- package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
- package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
- package/packages/extension/src/bridge-context.ts +67 -3
- package/packages/extension/src/bridge.ts +20 -8
- package/packages/extension/src/command-handler.ts +36 -13
- package/packages/extension/src/prompt-expander.ts +74 -63
- package/packages/extension/src/server-launcher.ts +31 -70
- package/packages/extension/src/slash-dispatch.ts +123 -0
- package/packages/server/bin/pi-dashboard.mjs +84 -0
- package/packages/server/package.json +6 -5
- package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +12 -18
- package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
- package/packages/server/src/__tests__/directory-service.test.ts +1 -1
- package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
- package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
- package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
- package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
- package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
- package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
- package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
- package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
- package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
- package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
- package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
- package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
- package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
- package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
- package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
- package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
- package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
- package/packages/server/src/__tests__/recommended-routes.test.ts +2 -2
- package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
- package/packages/server/src/auth-plugin.ts +3 -0
- package/packages/server/src/bootstrap-state.ts +10 -0
- package/packages/server/src/browser-gateway.ts +15 -7
- package/packages/server/src/browser-handlers/session-action-handler.ts +30 -4
- package/packages/server/src/cli.ts +61 -81
- package/packages/server/src/config-api.ts +14 -2
- package/packages/server/src/directory-service.ts +106 -4
- package/packages/server/src/event-wiring.ts +31 -1
- package/packages/server/src/headless-pid-registry.ts +299 -41
- package/packages/server/src/legacy-pi-cleanup.ts +151 -0
- package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
- package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
- package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
- package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
- package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
- package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
- package/packages/server/src/model-proxy/api-key-store.ts +87 -0
- package/packages/server/src/model-proxy/auth-gate.ts +116 -0
- package/packages/server/src/model-proxy/concurrency.ts +76 -0
- package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
- package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
- package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
- package/packages/server/src/model-proxy/convert/index.ts +8 -0
- package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
- package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
- package/packages/server/src/model-proxy/convert/types.ts +70 -0
- package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
- package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
- package/packages/server/src/model-proxy/internal-registry.ts +157 -0
- package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
- package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
- package/packages/server/src/model-proxy/request-log.ts +53 -0
- package/packages/server/src/model-proxy/streamer.ts +59 -0
- package/packages/server/src/openspec-group-store.ts +490 -0
- package/packages/server/src/process-manager.ts +128 -0
- package/packages/server/src/provider-auth-storage.ts +29 -47
- package/packages/server/src/restart-helper.ts +17 -16
- package/packages/server/src/routes/bootstrap-routes.ts +37 -0
- package/packages/server/src/routes/jj-routes.ts +3 -0
- package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
- package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
- package/packages/server/src/routes/model-proxy-routes.ts +330 -0
- package/packages/server/src/routes/openspec-group-routes.ts +231 -0
- package/packages/server/src/routes/provider-auth-routes.ts +3 -0
- package/packages/server/src/routes/provider-routes.ts +24 -1
- package/packages/server/src/routes/system-routes.ts +44 -2
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
- package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
- package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
- package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
- package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
- package/packages/server/src/server.ts +178 -2
- package/packages/server/src/session-api.ts +9 -1
- package/packages/server/src/tunnel-watchdog.ts +230 -0
- package/packages/server/src/tunnel.ts +5 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
- package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
- package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
- package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
- package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
- package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
- package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
- package/packages/shared/src/bootstrap-install.ts +1 -1
- package/packages/shared/src/browser-protocol.ts +27 -0
- package/packages/shared/src/config.ts +172 -2
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
- package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
- package/packages/shared/src/platform/binary-lookup.ts +204 -0
- package/packages/shared/src/platform/node-spawn.ts +42 -5
- package/packages/shared/src/protocol.ts +19 -1
- package/packages/shared/src/recommended-extensions.ts +18 -0
- package/packages/shared/src/rest-api.ts +219 -1
- package/packages/shared/src/server-launcher.ts +277 -0
- package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
- package/packages/shared/src/types.ts +55 -0
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -184
- 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
|
|
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(
|
|
513
|
-
|
|
514
|
-
event
|
|
515
|
-
|
|
516
|
-
|
|
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
|
});
|