@blackbelt-technology/pi-agent-dashboard 0.4.0 → 0.4.2
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 +104 -35
- package/README.md +390 -494
- package/docs/architecture.md +423 -20
- package/package.json +11 -8
- package/packages/extension/package.json +11 -4
- package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +91 -15
- package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
- package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
- package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
- package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
- package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
- package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
- package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
- package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
- package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
- package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
- package/packages/extension/src/ask-user-tool.ts +170 -61
- package/packages/extension/src/bridge.ts +199 -19
- package/packages/extension/src/multiselect-decode.ts +40 -0
- package/packages/extension/src/multiselect-list.ts +146 -0
- package/packages/extension/src/multiselect-polyfill.ts +73 -0
- package/packages/extension/src/server-launcher.ts +15 -3
- package/packages/extension/src/ui-modules.ts +272 -0
- package/packages/server/package.json +11 -5
- package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
- package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
- package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
- package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
- package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
- package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
- package/packages/server/src/__tests__/directory-service.test.ts +174 -0
- package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
- package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
- package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
- package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
- package/packages/server/src/__tests__/package-routes.test.ts +136 -3
- package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
- package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
- package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
- package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
- package/packages/server/src/__tests__/pi-version-skew.test.ts +72 -0
- package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
- package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
- package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
- package/packages/server/src/__tests__/restart-helper.test.ts +34 -6
- package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
- package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
- package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
- package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
- package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
- package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
- package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
- package/packages/server/src/browse.ts +118 -13
- package/packages/server/src/browser-gateway.ts +19 -0
- package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
- package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
- package/packages/server/src/browser-handlers/handler-context.ts +15 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
- package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
- package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
- package/packages/server/src/cli.ts +61 -15
- package/packages/server/src/directory-service.ts +156 -15
- package/packages/server/src/event-wiring.ts +111 -10
- package/packages/server/src/installed-package-enricher.ts +143 -0
- package/packages/server/src/package-manager-wrapper.ts +305 -8
- package/packages/server/src/package-source-helpers.ts +104 -0
- package/packages/server/src/pending-attach-registry.ts +112 -0
- package/packages/server/src/pending-resume-intent-registry.ts +107 -0
- package/packages/server/src/pi-core-checker.ts +9 -14
- package/packages/server/src/pi-gateway.ts +14 -0
- package/packages/server/src/pi-version-skew.ts +12 -1
- package/packages/server/src/proposal-attach-naming.ts +47 -0
- package/packages/server/src/restart-helper.ts +13 -2
- package/packages/server/src/routes/file-routes.ts +29 -3
- package/packages/server/src/routes/package-routes.ts +72 -3
- package/packages/server/src/routes/plugin-config-routes.ts +129 -0
- package/packages/server/src/routes/system-routes.ts +2 -0
- package/packages/server/src/server.ts +339 -10
- package/packages/server/src/session-api.ts +30 -5
- package/packages/server/src/session-order-manager.ts +22 -0
- package/packages/server/src/session-scanner.ts +10 -1
- package/packages/shared/package.json +9 -2
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
- package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
- package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
- package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
- package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
- package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
- package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
- package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
- package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
- package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
- package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
- package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
- package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
- package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
- package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
- package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
- package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
- package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
- package/packages/shared/src/browser-protocol.ts +110 -4
- package/packages/shared/src/config.ts +45 -0
- package/packages/shared/src/dashboard-plugin/index.ts +11 -0
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
- package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
- package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
- package/packages/shared/src/openspec-activity-detector.ts +18 -22
- package/packages/shared/src/openspec-design-evidence.ts +109 -0
- package/packages/shared/src/openspec-poller.ts +117 -3
- package/packages/shared/src/openspec-specs-evidence.ts +79 -0
- package/packages/shared/src/platform/binary-lookup.ts +96 -1
- package/packages/shared/src/platform/index.ts +1 -0
- package/packages/shared/src/platform/node-spawn.ts +154 -0
- package/packages/shared/src/plugin-bridge-register.ts +139 -0
- package/packages/shared/src/protocol.ts +79 -2
- package/packages/shared/src/recommended-extensions.ts +7 -1
- package/packages/shared/src/rest-api.ts +68 -3
- package/packages/shared/src/state-replay.ts +20 -1
- package/packages/shared/src/tool-registry/definitions.ts +92 -0
- package/packages/shared/src/tool-registry/strategies.ts +17 -3
- package/packages/shared/src/types.ts +160 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo-level invariant: the bridge MUST NOT call pi's session-replacement
|
|
3
|
+
* APIs (`pi.newSession(...)`, `ctx.fork(...)`, `ctx.switchSession(...)`)
|
|
4
|
+
* from any code under `packages/extension/src/`.
|
|
5
|
+
*
|
|
6
|
+
* Rationale: pi 0.69.0+ invalidates captured pre-replacement `pi`/`ctx`/
|
|
7
|
+
* session-bound objects on next access after these calls. The bridge
|
|
8
|
+
* holds long-lived caches (`cachedCtx`, `cachedModelRegistry`,
|
|
9
|
+
* `cachedHasUI` in `bridge.ts`; `modelRegistry` in `provider-register.ts`)
|
|
10
|
+
* that depend on pi being the ONLY originator of session replacement, so
|
|
11
|
+
* we can re-capture inside the resulting `session_start` handler keyed on
|
|
12
|
+
* `event.reason ∈ {"new","fork","resume"}`.
|
|
13
|
+
*
|
|
14
|
+
* If this test fails: do NOT add the call. Either drive the user-facing
|
|
15
|
+
* action through the dashboard server (which prompts the user, who
|
|
16
|
+
* triggers replacement via pi's UI), or wrap your post-switch work in
|
|
17
|
+
* the `withSession` callback that pi 0.69+ exposes on each replacement
|
|
18
|
+
* API and capture the freshly-emitted ReplacedSessionContext there.
|
|
19
|
+
*
|
|
20
|
+
* See change: pi-zero-seventy-compat.
|
|
21
|
+
*/
|
|
22
|
+
import { describe, it, expect } from "vitest";
|
|
23
|
+
import fs from "node:fs/promises";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import url from "node:url";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Each pattern matches `<receiver>.<method>(` allowing for whitespace and
|
|
29
|
+
* tolerating common variations like `await pi.newSession(...)`. Prefixed
|
|
30
|
+
* with a non-word boundary so we don't flag method names embedded in
|
|
31
|
+
* longer identifiers.
|
|
32
|
+
*/
|
|
33
|
+
const PATTERNS: ReadonlyArray<{ name: string; re: RegExp }> = [
|
|
34
|
+
{ name: "pi.newSession", re: /(?:^|[^.\w])pi\.newSession\s*\(/ },
|
|
35
|
+
{ name: "ctx.fork", re: /(?:^|[^.\w])ctx\.fork\s*\(/ },
|
|
36
|
+
{ name: "ctx.switchSession", re: /(?:^|[^.\w])ctx\.switchSession\s*\(/ },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Per-line opt-out marker. Use only for documented exceptions (e.g. a
|
|
41
|
+
* future migration cell that intentionally drives a replacement and
|
|
42
|
+
* fully re-binds via `withSession`):
|
|
43
|
+
* await pi.newSession({ withSession: ... }); // ban:session-replacement-ok
|
|
44
|
+
*/
|
|
45
|
+
const OPT_OUT_MARKER = "ban:session-replacement-ok";
|
|
46
|
+
|
|
47
|
+
async function* walk(dir: string): AsyncGenerator<string> {
|
|
48
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
const full = path.join(dir, entry.name);
|
|
51
|
+
if (entry.isDirectory()) {
|
|
52
|
+
if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "__tests__") continue;
|
|
53
|
+
yield* walk(full);
|
|
54
|
+
} else if (entry.isFile() && /\.(ts|tsx|mts|cts)$/.test(entry.name)) {
|
|
55
|
+
yield full;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("no session-replacement API calls in packages/extension/src/", () => {
|
|
61
|
+
it("bridge code never invokes pi.newSession / ctx.fork / ctx.switchSession", async () => {
|
|
62
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
63
|
+
const srcDir = path.resolve(here, "..");
|
|
64
|
+
const repoRoot = path.resolve(here, "..", "..", "..", "..");
|
|
65
|
+
|
|
66
|
+
const violations: Array<{ file: string; line: number; pattern: string; text: string }> = [];
|
|
67
|
+
|
|
68
|
+
for await (const file of walk(srcDir)) {
|
|
69
|
+
const content = await fs.readFile(file, "utf-8");
|
|
70
|
+
const lines = content.split(/\r?\n/);
|
|
71
|
+
lines.forEach((line, idx) => {
|
|
72
|
+
if (line.includes(OPT_OUT_MARKER)) return;
|
|
73
|
+
for (const { name, re } of PATTERNS) {
|
|
74
|
+
if (re.test(line)) {
|
|
75
|
+
violations.push({
|
|
76
|
+
file: path.relative(repoRoot, file),
|
|
77
|
+
line: idx + 1,
|
|
78
|
+
pattern: name,
|
|
79
|
+
text: line.trim(),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (violations.length > 0) {
|
|
87
|
+
const msg =
|
|
88
|
+
`Bridge code MUST NOT call pi session-replacement APIs.\n` +
|
|
89
|
+
`pi 0.69.0+ invalidates captured pre-replacement pi/ctx after these calls;\n` +
|
|
90
|
+
`the bridge relies on pi being the sole originator of replacement so it can\n` +
|
|
91
|
+
`re-capture state inside the resulting session_start handler.\n\n` +
|
|
92
|
+
`Offenders (${violations.length}):\n` +
|
|
93
|
+
violations
|
|
94
|
+
.map((v) => ` ${v.file}:${v.line} [${v.pattern}] ${v.text}`)
|
|
95
|
+
.join("\n");
|
|
96
|
+
expect(violations, msg).toEqual([]);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo-level invariant: `packages/extension/src/bridge.ts` MUST NOT
|
|
3
|
+
* contain the co-occurrence of two substrings:
|
|
4
|
+
*
|
|
5
|
+
* 1. `originals.custom`
|
|
6
|
+
* 2. `prompt.type === "multiselect"`
|
|
7
|
+
*
|
|
8
|
+
* If both appear in the same file, a contributor has (re)introduced the
|
|
9
|
+
* TUI PromptBus-adapter multiselect arm that was removed by change
|
|
10
|
+
* `fix-multiselect-tui-arm-self-cancel`. The arm is forbidden because
|
|
11
|
+
* pi 0.70's RPC mode (the only mode dashboard headless sessions run
|
|
12
|
+
* under) defines `ExtensionUIContext.custom` as an unconditional no-op
|
|
13
|
+
* (`async custom() { return undefined; }`, see
|
|
14
|
+
* `~/.nvm/.../@mariozechner/pi-coding-agent/dist/modes/rpc/rpc-mode.js`
|
|
15
|
+
* lines 150-153). Awaiting that primitive resolves to `undefined`
|
|
16
|
+
* synchronously, and the TUI arm's `bus.respond({ cancelled: true,
|
|
17
|
+
* source: "tui" })` triggers the PromptBus's first-response-wins
|
|
18
|
+
* dismissal — which auto-cancels the dashboard's already-rendered
|
|
19
|
+
* `MultiselectRenderer` within ~1 event-loop tick.
|
|
20
|
+
*
|
|
21
|
+
* The bus-routed `(ctx.ui as any).multiselect = (...) => bus.request(...)`
|
|
22
|
+
* patch site uses the substring `type: "multiselect"` (object-literal
|
|
23
|
+
* shape), not `prompt.type === "multiselect"` (equality-check shape),
|
|
24
|
+
* so it is unaffected by this lint.
|
|
25
|
+
*
|
|
26
|
+
* To remove a legitimate `originals.custom` reference (e.g. for a
|
|
27
|
+
* future use that does not include multiselect prompt routing), keep
|
|
28
|
+
* one substring and ensure the other does not co-occur.
|
|
29
|
+
*
|
|
30
|
+
* See change: fix-multiselect-tui-arm-self-cancel.
|
|
31
|
+
*/
|
|
32
|
+
import { describe, it, expect } from "vitest";
|
|
33
|
+
import fs from "node:fs/promises";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
import url from "node:url";
|
|
36
|
+
|
|
37
|
+
const FORBIDDEN_A = "originals.custom";
|
|
38
|
+
const FORBIDDEN_B = 'prompt.type === "multiselect"';
|
|
39
|
+
|
|
40
|
+
function findLineNumbers(src: string, needle: string): number[] {
|
|
41
|
+
const lines = src.split(/\r?\n/);
|
|
42
|
+
const hits: number[] = [];
|
|
43
|
+
lines.forEach((line, idx) => {
|
|
44
|
+
if (line.includes(needle)) hits.push(idx + 1);
|
|
45
|
+
});
|
|
46
|
+
return hits;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("no TUI multiselect arm regression in bridge.ts", () => {
|
|
50
|
+
it("bridge.ts MUST NOT contain both `originals.custom` and `prompt.type === \"multiselect\"`", async () => {
|
|
51
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
52
|
+
const bridgePath = path.resolve(here, "..", "bridge.ts");
|
|
53
|
+
const src = await fs.readFile(bridgePath, "utf-8");
|
|
54
|
+
|
|
55
|
+
const hasA = src.includes(FORBIDDEN_A);
|
|
56
|
+
const hasB = src.includes(FORBIDDEN_B);
|
|
57
|
+
|
|
58
|
+
if (hasA && hasB) {
|
|
59
|
+
const linesA = findLineNumbers(src, FORBIDDEN_A);
|
|
60
|
+
const linesB = findLineNumbers(src, FORBIDDEN_B);
|
|
61
|
+
const msg =
|
|
62
|
+
`Forbidden co-occurrence in ${path.relative(process.cwd(), bridgePath)}:\n` +
|
|
63
|
+
` - "${FORBIDDEN_A}" found on line(s): ${linesA.join(", ")}\n` +
|
|
64
|
+
` - "${FORBIDDEN_B}" found on line(s): ${linesB.join(", ")}\n` +
|
|
65
|
+
`\n` +
|
|
66
|
+
`This pattern was removed by change "fix-multiselect-tui-arm-self-cancel".\n` +
|
|
67
|
+
`pi 0.70 RPC mode's ctx.ui.custom is a no-op, so a TUI multiselect\n` +
|
|
68
|
+
`arm that awaits originals.custom auto-cancels the dashboard-rendered\n` +
|
|
69
|
+
`dialog within ~1 event-loop tick. The bus-routed ctx.ui.multiselect\n` +
|
|
70
|
+
`patch + DashboardDefaultAdapter handle multiselect end-to-end without\n` +
|
|
71
|
+
`any TUI arm participation. See:\n` +
|
|
72
|
+
` openspec/changes/archive/<date>-fix-multiselect-tui-arm-self-cancel/\n` +
|
|
73
|
+
`(or openspec/changes/fix-multiselect-tui-arm-self-cancel/ if not yet archived).`;
|
|
74
|
+
expect.fail(msg);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Guardrail: at least one of the two substrings absent (we already
|
|
78
|
+
// assert above that both-present is illegal). Either-alone is fine.
|
|
79
|
+
expect(hasA && hasB).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -229,4 +229,41 @@ describe("detectOpenSpecActivity", () => {
|
|
|
229
229
|
expect(result).toBeNull();
|
|
230
230
|
});
|
|
231
231
|
});
|
|
232
|
+
|
|
233
|
+
describe("flag-shaped change names", () => {
|
|
234
|
+
it("returns null for `openspec archive --help`", () => {
|
|
235
|
+
const result = detectOpenSpecActivity("bash", {
|
|
236
|
+
command: "openspec archive --help",
|
|
237
|
+
});
|
|
238
|
+
expect(result).toBeNull();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("returns null for `openspec new change --help`", () => {
|
|
242
|
+
const result = detectOpenSpecActivity("bash", {
|
|
243
|
+
command: "openspec new change --help",
|
|
244
|
+
});
|
|
245
|
+
expect(result).toBeNull();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("returns null when --change is followed by another flag", () => {
|
|
249
|
+
const result = detectOpenSpecActivity("bash", {
|
|
250
|
+
command: "openspec foo --change --help",
|
|
251
|
+
});
|
|
252
|
+
expect(result).toBeNull();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("still extracts a real change name from `openspec archive add-auth`", () => {
|
|
256
|
+
const result = detectOpenSpecActivity("bash", {
|
|
257
|
+
command: "openspec archive add-auth",
|
|
258
|
+
});
|
|
259
|
+
expect(result).toEqual({ changeName: "add-auth", isActive: true });
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("still extracts a quoted change name from `openspec archive \"add-auth\"`", () => {
|
|
263
|
+
const result = detectOpenSpecActivity("bash", {
|
|
264
|
+
command: 'openspec archive "add-auth"',
|
|
265
|
+
});
|
|
266
|
+
expect(result).toEqual({ changeName: "add-auth", isActive: true });
|
|
267
|
+
});
|
|
268
|
+
});
|
|
232
269
|
});
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { refreshUiModules, subscribeUiInvalidate, type UiModulesBridgeCtx } from "../ui-modules.js";
|
|
3
|
+
import type { DecoratorDescriptor, ExtensionUiModule } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Phase-2 (`add-extension-ui-decorations`) bridge contract:
|
|
7
|
+
*
|
|
8
|
+
* - `refreshUiModules` partitions probe.modules by `kind` — `management-modal`
|
|
9
|
+
* keeps flowing through `ui_modules_list`; the five Phase-2 kinds each
|
|
10
|
+
* forward as one `ext_ui_decorator` message.
|
|
11
|
+
* - Decorators MUST carry a `namespace` matching `/^[a-z0-9-]+$/`; malformed
|
|
12
|
+
* namespaces are dropped with a warning.
|
|
13
|
+
* - `(kind, namespace, id)` collisions within one probe → warning + last-write-wins.
|
|
14
|
+
* - `removed: true` is forwarded verbatim.
|
|
15
|
+
* - `ui:invalidate` re-runs the partitioned probe.
|
|
16
|
+
* - Per-session invalidate rate cap: ≤20 invalidations/second; excess
|
|
17
|
+
* coalesced to a trailing-edge probe with one warning.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
function createTestCtx(sessionId = "s1") {
|
|
21
|
+
const listeners = new Map<string, Array<(...args: any[]) => any>>();
|
|
22
|
+
const sent: any[] = [];
|
|
23
|
+
|
|
24
|
+
const ctx: UiModulesBridgeCtx & { _sent: any[]; _listeners: typeof listeners } = {
|
|
25
|
+
pi: {
|
|
26
|
+
events: {
|
|
27
|
+
on: vi.fn((event: string, fn: (...args: any[]) => any) => {
|
|
28
|
+
if (!listeners.has(event)) listeners.set(event, []);
|
|
29
|
+
listeners.get(event)!.push(fn);
|
|
30
|
+
}) as any,
|
|
31
|
+
emit: vi.fn((event: string, ...args: any[]) => {
|
|
32
|
+
const handlers = listeners.get(event) ?? [];
|
|
33
|
+
for (const h of handlers) h(...args);
|
|
34
|
+
}) as any,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
connection: {
|
|
38
|
+
send: vi.fn((msg: unknown) => {
|
|
39
|
+
sent.push(msg);
|
|
40
|
+
}) as any,
|
|
41
|
+
},
|
|
42
|
+
getSessionId: () => sessionId,
|
|
43
|
+
_sent: sent,
|
|
44
|
+
_listeners: listeners,
|
|
45
|
+
};
|
|
46
|
+
return ctx;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const sampleModule = (id: string, command: string): ExtensionUiModule => ({
|
|
50
|
+
kind: "management-modal",
|
|
51
|
+
id,
|
|
52
|
+
command,
|
|
53
|
+
title: id,
|
|
54
|
+
view: { kind: "table", dataEvent: `${id}:rows`, fields: [{ key: "id", label: "ID", kind: "text" }] },
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const footerSeg = (namespace: string, id: string, text: string): DecoratorDescriptor => ({
|
|
58
|
+
kind: "footer-segment",
|
|
59
|
+
namespace,
|
|
60
|
+
id,
|
|
61
|
+
payload: { text },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const gate = (namespace: string, id: string, flowId: string, available: boolean, reason?: string): DecoratorDescriptor => ({
|
|
65
|
+
kind: "gate",
|
|
66
|
+
namespace,
|
|
67
|
+
id,
|
|
68
|
+
payload: { flowId, available, reason },
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const toast = (namespace: string, id: string, message: string): DecoratorDescriptor => ({
|
|
72
|
+
kind: "toast",
|
|
73
|
+
namespace,
|
|
74
|
+
id,
|
|
75
|
+
payload: { level: "info", message },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("refreshUiModules — Phase-2 partitioning", () => {
|
|
79
|
+
it("partitions a mixed probe into one ui_modules_list (modal-only) plus one ext_ui_decorator per decorator", () => {
|
|
80
|
+
const ctx = createTestCtx("S");
|
|
81
|
+
ctx._listeners.set("ui:list-modules", [
|
|
82
|
+
(probe: { modules: any[] }) => {
|
|
83
|
+
probe.modules.push(sampleModule("judo-status", "/judo:status"));
|
|
84
|
+
probe.modules.push(footerSeg("judo", "model-state", "3 mut"));
|
|
85
|
+
probe.modules.push(gate("judo", "save", "judo:save", false, "Not in workspace"));
|
|
86
|
+
probe.modules.push(toast("flows", "done", "Flow finished"));
|
|
87
|
+
},
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
refreshUiModules(ctx);
|
|
91
|
+
|
|
92
|
+
// Exactly one ui_modules_list, exactly three ext_ui_decorator.
|
|
93
|
+
const moduleMsgs = ctx._sent.filter((m) => m.type === "ui_modules_list");
|
|
94
|
+
const decoratorMsgs = ctx._sent.filter((m) => m.type === "ext_ui_decorator");
|
|
95
|
+
expect(moduleMsgs).toHaveLength(1);
|
|
96
|
+
expect(decoratorMsgs).toHaveLength(3);
|
|
97
|
+
|
|
98
|
+
expect(moduleMsgs[0]).toMatchObject({
|
|
99
|
+
type: "ui_modules_list",
|
|
100
|
+
sessionId: "S",
|
|
101
|
+
modules: [expect.objectContaining({ id: "judo-status", kind: "management-modal" })],
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const kinds = decoratorMsgs.map((m) => m.descriptor.kind).sort();
|
|
105
|
+
expect(kinds).toEqual(["footer-segment", "gate", "toast"]);
|
|
106
|
+
for (const m of decoratorMsgs) {
|
|
107
|
+
expect(m.sessionId).toBe("S");
|
|
108
|
+
expect(m.removed).toBeUndefined();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("forwards no decorator messages when only modal modules are pushed", () => {
|
|
113
|
+
const ctx = createTestCtx();
|
|
114
|
+
ctx._listeners.set("ui:list-modules", [
|
|
115
|
+
(probe: { modules: any[] }) => {
|
|
116
|
+
probe.modules.push(sampleModule("a", "/a"));
|
|
117
|
+
},
|
|
118
|
+
]);
|
|
119
|
+
refreshUiModules(ctx);
|
|
120
|
+
expect(ctx._sent.filter((m) => m.type === "ext_ui_decorator")).toHaveLength(0);
|
|
121
|
+
expect(ctx._sent.filter((m) => m.type === "ui_modules_list")).toHaveLength(1);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("forwards decorator-only probe with empty modules list", () => {
|
|
125
|
+
const ctx = createTestCtx();
|
|
126
|
+
ctx._listeners.set("ui:list-modules", [
|
|
127
|
+
(probe: { modules: any[] }) => {
|
|
128
|
+
probe.modules.push(footerSeg("judo", "f1", "x"));
|
|
129
|
+
probe.modules.push(footerSeg("judo", "f2", "y"));
|
|
130
|
+
},
|
|
131
|
+
]);
|
|
132
|
+
refreshUiModules(ctx);
|
|
133
|
+
const modulesMsg = ctx._sent.find((m) => m.type === "ui_modules_list");
|
|
134
|
+
expect(modulesMsg).toBeDefined();
|
|
135
|
+
expect(modulesMsg.modules).toEqual([]);
|
|
136
|
+
expect(ctx._sent.filter((m) => m.type === "ext_ui_decorator")).toHaveLength(2);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("rejects decorators with malformed namespace and warns", () => {
|
|
140
|
+
const ctx = createTestCtx();
|
|
141
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
142
|
+
try {
|
|
143
|
+
ctx._listeners.set("ui:list-modules", [
|
|
144
|
+
(probe: { modules: any[] }) => {
|
|
145
|
+
probe.modules.push(footerSeg("", "id1", "bad-empty"));
|
|
146
|
+
probe.modules.push(footerSeg("UPPER", "id2", "bad-case"));
|
|
147
|
+
probe.modules.push(footerSeg("with space", "id3", "bad-space"));
|
|
148
|
+
probe.modules.push(footerSeg("ok-ns", "id4", "good"));
|
|
149
|
+
},
|
|
150
|
+
]);
|
|
151
|
+
refreshUiModules(ctx);
|
|
152
|
+
|
|
153
|
+
const decoratorMsgs = ctx._sent.filter((m) => m.type === "ext_ui_decorator");
|
|
154
|
+
expect(decoratorMsgs).toHaveLength(1);
|
|
155
|
+
expect(decoratorMsgs[0].descriptor.namespace).toBe("ok-ns");
|
|
156
|
+
// Three bad descriptors → at least one warning each (or one combined).
|
|
157
|
+
expect(warn).toHaveBeenCalled();
|
|
158
|
+
const allWarnText = warn.mock.calls.map((c) => String(c[0])).join("\n");
|
|
159
|
+
expect(allWarnText).toMatch(/namespace/i);
|
|
160
|
+
} finally {
|
|
161
|
+
warn.mockRestore();
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("collisions on (kind, namespace, id) within one probe warn once and last-write-wins", () => {
|
|
166
|
+
const ctx = createTestCtx();
|
|
167
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
168
|
+
try {
|
|
169
|
+
ctx._listeners.set("ui:list-modules", [
|
|
170
|
+
(probe: { modules: any[] }) => {
|
|
171
|
+
probe.modules.push(footerSeg("judo", "x", "first"));
|
|
172
|
+
probe.modules.push(footerSeg("judo", "x", "second"));
|
|
173
|
+
probe.modules.push(footerSeg("judo", "x", "third"));
|
|
174
|
+
},
|
|
175
|
+
]);
|
|
176
|
+
refreshUiModules(ctx);
|
|
177
|
+
|
|
178
|
+
const decoratorMsgs = ctx._sent.filter((m) => m.type === "ext_ui_decorator");
|
|
179
|
+
expect(decoratorMsgs).toHaveLength(1);
|
|
180
|
+
expect((decoratorMsgs[0].descriptor.payload as any).text).toBe("third");
|
|
181
|
+
// One warning per colliding key, regardless of how many duplicates.
|
|
182
|
+
const collisionWarnings = warn.mock.calls.filter((c) => /footer-segment:judo:x/.test(String(c[0])));
|
|
183
|
+
expect(collisionWarnings.length).toBe(1);
|
|
184
|
+
} finally {
|
|
185
|
+
warn.mockRestore();
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("different namespaces with the same id are NOT a collision", () => {
|
|
190
|
+
const ctx = createTestCtx();
|
|
191
|
+
ctx._listeners.set("ui:list-modules", [
|
|
192
|
+
(probe: { modules: any[] }) => {
|
|
193
|
+
probe.modules.push(footerSeg("judo", "model-state", "judo-text"));
|
|
194
|
+
probe.modules.push(footerSeg("flows", "model-state", "flows-text"));
|
|
195
|
+
},
|
|
196
|
+
]);
|
|
197
|
+
refreshUiModules(ctx);
|
|
198
|
+
const decoratorMsgs = ctx._sent.filter((m) => m.type === "ext_ui_decorator");
|
|
199
|
+
expect(decoratorMsgs).toHaveLength(2);
|
|
200
|
+
const namespaces = decoratorMsgs.map((m) => m.descriptor.namespace).sort();
|
|
201
|
+
expect(namespaces).toEqual(["flows", "judo"]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("forwards `removed: true` verbatim on decorator descriptors", () => {
|
|
205
|
+
const ctx = createTestCtx();
|
|
206
|
+
ctx._listeners.set("ui:list-modules", [
|
|
207
|
+
(probe: { modules: any[] }) => {
|
|
208
|
+
probe.modules.push({ ...gate("judo", "save", "judo:save", true), removed: true });
|
|
209
|
+
},
|
|
210
|
+
]);
|
|
211
|
+
refreshUiModules(ctx);
|
|
212
|
+
const decoratorMsgs = ctx._sent.filter((m) => m.type === "ext_ui_decorator");
|
|
213
|
+
expect(decoratorMsgs).toHaveLength(1);
|
|
214
|
+
expect(decoratorMsgs[0].removed).toBe(true);
|
|
215
|
+
expect(decoratorMsgs[0].descriptor.kind).toBe("gate");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("does not regress Phase-1 module-only probes", () => {
|
|
219
|
+
// Mirrors the Phase-1 test "emits ui:list-modules and forwards collected
|
|
220
|
+
// modules as ui_modules_list" — Phase-2 partitioning MUST be a no-op when
|
|
221
|
+
// no decorators are pushed.
|
|
222
|
+
const ctx = createTestCtx("session-A");
|
|
223
|
+
ctx._listeners.set("ui:list-modules", [
|
|
224
|
+
(probe: { modules: ExtensionUiModule[] }) => {
|
|
225
|
+
probe.modules.push(sampleModule("judo-status", "/judo:status"));
|
|
226
|
+
probe.modules.push(sampleModule("ragger-workspaces", "/ragger:workspaces"));
|
|
227
|
+
},
|
|
228
|
+
]);
|
|
229
|
+
refreshUiModules(ctx);
|
|
230
|
+
const moduleMsgs = ctx._sent.filter((m) => m.type === "ui_modules_list");
|
|
231
|
+
expect(moduleMsgs).toHaveLength(1);
|
|
232
|
+
expect(moduleMsgs[0].modules).toHaveLength(2);
|
|
233
|
+
expect(ctx._sent.filter((m) => m.type === "ext_ui_decorator")).toHaveLength(0);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("subscribeUiInvalidate — Phase-2 re-forwarding", () => {
|
|
238
|
+
it("re-runs the partitioned probe on every ui:invalidate (leading + trailing under throttle)", () => {
|
|
239
|
+
vi.useFakeTimers();
|
|
240
|
+
try {
|
|
241
|
+
const ctx = createTestCtx();
|
|
242
|
+
let counter = 0;
|
|
243
|
+
ctx._listeners.set("ui:list-modules", [
|
|
244
|
+
(probe: { modules: any[] }) => {
|
|
245
|
+
counter++;
|
|
246
|
+
probe.modules.push(footerSeg("judo", "model-state", `count=${counter}`));
|
|
247
|
+
},
|
|
248
|
+
]);
|
|
249
|
+
subscribeUiInvalidate(ctx);
|
|
250
|
+
|
|
251
|
+
// Leading-edge probe.
|
|
252
|
+
ctx.pi.events!.emit("ui:invalidate", { id: "model-state" });
|
|
253
|
+
// Coalesced into trailing-edge probe — flush by advancing timers past
|
|
254
|
+
// the 50ms throttle window.
|
|
255
|
+
ctx.pi.events!.emit("ui:invalidate", { id: "model-state" });
|
|
256
|
+
vi.advanceTimersByTime(100);
|
|
257
|
+
|
|
258
|
+
const decoratorMsgs = ctx._sent.filter((m) => m.type === "ext_ui_decorator");
|
|
259
|
+
expect(decoratorMsgs).toHaveLength(2);
|
|
260
|
+
expect((decoratorMsgs[0].descriptor.payload as any).text).toBe("count=1");
|
|
261
|
+
expect((decoratorMsgs[1].descriptor.payload as any).text).toBe("count=2");
|
|
262
|
+
} finally {
|
|
263
|
+
vi.useRealTimers();
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe("Per-session ui:invalidate rate cap", () => {
|
|
269
|
+
beforeEach(() => {
|
|
270
|
+
vi.useFakeTimers();
|
|
271
|
+
});
|
|
272
|
+
afterEach(() => {
|
|
273
|
+
vi.useRealTimers();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("coalesces a 100-invalidation burst to a small bounded number of probes with exactly one warning", () => {
|
|
277
|
+
const ctx = createTestCtx();
|
|
278
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
279
|
+
try {
|
|
280
|
+
let probeCount = 0;
|
|
281
|
+
ctx._listeners.set("ui:list-modules", [
|
|
282
|
+
(probe: { modules: any[] }) => {
|
|
283
|
+
probeCount++;
|
|
284
|
+
probe.modules.push(footerSeg("judo", "model-state", `p${probeCount}`));
|
|
285
|
+
},
|
|
286
|
+
]);
|
|
287
|
+
|
|
288
|
+
subscribeUiInvalidate(ctx);
|
|
289
|
+
|
|
290
|
+
// Fire 100 invalidations within ~200ms (well over the 20/sec cap).
|
|
291
|
+
for (let i = 0; i < 100; i++) {
|
|
292
|
+
ctx.pi.events!.emit("ui:invalidate", { id: "x" });
|
|
293
|
+
vi.advanceTimersByTime(2);
|
|
294
|
+
}
|
|
295
|
+
// Allow any trailing-edge timer to fire.
|
|
296
|
+
vi.advanceTimersByTime(2000);
|
|
297
|
+
|
|
298
|
+
// Probes are bounded — at minimum 1 (the first), at most a handful, NOT 100.
|
|
299
|
+
expect(probeCount).toBeGreaterThanOrEqual(1);
|
|
300
|
+
expect(probeCount).toBeLessThanOrEqual(10);
|
|
301
|
+
|
|
302
|
+
// Exactly one rate-cap warning per offending burst.
|
|
303
|
+
const rateWarnings = warn.mock.calls.filter((c) => /rate|invalidat/i.test(String(c[0])));
|
|
304
|
+
expect(rateWarnings.length).toBe(1);
|
|
305
|
+
} finally {
|
|
306
|
+
warn.mockRestore();
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
});
|