@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,293 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { refreshUiModules, subscribeUiInvalidate, handleUiManagement, type UiModulesBridgeCtx } from "../ui-modules.js";
|
|
3
|
+
import type { ExtensionUiModule } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Minimal bus + ctx harness. The real bridge wires this up via `pi.events`
|
|
7
|
+
* (a real `EventEmitter`); the contract this module relies on is `on` /
|
|
8
|
+
* `emit`, so a hand-rolled bus is sufficient for tests.
|
|
9
|
+
*/
|
|
10
|
+
function createTestCtx(sessionId = "s1") {
|
|
11
|
+
const listeners = new Map<string, Array<(...args: any[]) => any>>();
|
|
12
|
+
const sent: any[] = [];
|
|
13
|
+
|
|
14
|
+
const ctx: UiModulesBridgeCtx & { _sent: any[]; _listeners: typeof listeners } = {
|
|
15
|
+
pi: {
|
|
16
|
+
events: {
|
|
17
|
+
on: vi.fn((event: string, fn: (...args: any[]) => any) => {
|
|
18
|
+
if (!listeners.has(event)) listeners.set(event, []);
|
|
19
|
+
listeners.get(event)!.push(fn);
|
|
20
|
+
}) as any,
|
|
21
|
+
emit: vi.fn((event: string, ...args: any[]) => {
|
|
22
|
+
const handlers = listeners.get(event) ?? [];
|
|
23
|
+
for (const h of handlers) h(...args);
|
|
24
|
+
}) as any,
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
connection: {
|
|
28
|
+
send: vi.fn((msg: unknown) => {
|
|
29
|
+
sent.push(msg);
|
|
30
|
+
}) as any,
|
|
31
|
+
},
|
|
32
|
+
getSessionId: () => sessionId,
|
|
33
|
+
_sent: sent,
|
|
34
|
+
_listeners: listeners,
|
|
35
|
+
};
|
|
36
|
+
return ctx;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const sampleModule = (id: string, command: string): ExtensionUiModule => ({
|
|
40
|
+
kind: "management-modal",
|
|
41
|
+
id,
|
|
42
|
+
command,
|
|
43
|
+
title: id,
|
|
44
|
+
view: { kind: "table", dataEvent: `${id}:rows`, fields: [{ key: "id", label: "ID", kind: "text" }] },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("refreshUiModules", () => {
|
|
48
|
+
it("emits ui:list-modules and forwards collected modules as ui_modules_list", () => {
|
|
49
|
+
const ctx = createTestCtx("session-A");
|
|
50
|
+
ctx._listeners.set("ui:list-modules", [
|
|
51
|
+
(probe: { modules: ExtensionUiModule[] }) => {
|
|
52
|
+
probe.modules.push(sampleModule("judo-status", "/judo:status"));
|
|
53
|
+
probe.modules.push(sampleModule("ragger-workspaces", "/ragger:workspaces"));
|
|
54
|
+
},
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
refreshUiModules(ctx);
|
|
58
|
+
|
|
59
|
+
expect(ctx.pi.events!.emit).toHaveBeenCalledWith("ui:list-modules", expect.any(Object));
|
|
60
|
+
expect(ctx._sent).toHaveLength(1);
|
|
61
|
+
expect(ctx._sent[0]).toMatchObject({
|
|
62
|
+
type: "ui_modules_list",
|
|
63
|
+
sessionId: "session-A",
|
|
64
|
+
modules: [
|
|
65
|
+
expect.objectContaining({ id: "judo-status", command: "/judo:status" }),
|
|
66
|
+
expect.objectContaining({ id: "ragger-workspaces", command: "/ragger:workspaces" }),
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("forwards an empty modules list when no listeners push", () => {
|
|
72
|
+
const ctx = createTestCtx();
|
|
73
|
+
refreshUiModules(ctx);
|
|
74
|
+
expect(ctx._sent).toEqual([{ type: "ui_modules_list", sessionId: "s1", modules: [] }]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("last-write-wins on duplicate id and warns once per collision", () => {
|
|
78
|
+
const ctx = createTestCtx();
|
|
79
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
80
|
+
try {
|
|
81
|
+
ctx._listeners.set("ui:list-modules", [
|
|
82
|
+
(probe: { modules: ExtensionUiModule[] }) => {
|
|
83
|
+
// First push wins on insertion order; second push for same id replaces it.
|
|
84
|
+
const a: ExtensionUiModule = { ...sampleModule("dup", "/a"), title: "First" };
|
|
85
|
+
const b: ExtensionUiModule = { ...sampleModule("dup", "/b"), title: "Second" };
|
|
86
|
+
const c: ExtensionUiModule = { ...sampleModule("dup", "/c"), title: "Third" };
|
|
87
|
+
probe.modules.push(a, b, c);
|
|
88
|
+
},
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
refreshUiModules(ctx);
|
|
92
|
+
|
|
93
|
+
const sent = ctx._sent[0] as { modules: ExtensionUiModule[] };
|
|
94
|
+
expect(sent.modules).toHaveLength(1);
|
|
95
|
+
expect(sent.modules[0]).toMatchObject({ id: "dup", title: "Third", command: "/c" });
|
|
96
|
+
// Two collisions reported, but only one warning per id.
|
|
97
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
98
|
+
expect(warn.mock.calls[0]?.[0]).toMatch(/duplicate module id "dup"/);
|
|
99
|
+
} finally {
|
|
100
|
+
warn.mockRestore();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("ignores modules with missing/empty id", () => {
|
|
105
|
+
const ctx = createTestCtx();
|
|
106
|
+
ctx._listeners.set("ui:list-modules", [
|
|
107
|
+
(probe: { modules: any[] }) => {
|
|
108
|
+
probe.modules.push({ kind: "management-modal", id: "", command: "/x", title: "x", view: { kind: "table" } });
|
|
109
|
+
probe.modules.push({ kind: "management-modal", command: "/y", title: "y", view: { kind: "table" } });
|
|
110
|
+
probe.modules.push(sampleModule("ok", "/ok"));
|
|
111
|
+
},
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
refreshUiModules(ctx);
|
|
115
|
+
const sent = ctx._sent[0] as { modules: ExtensionUiModule[] };
|
|
116
|
+
expect(sent.modules.map((m) => m.id)).toEqual(["ok"]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("does not throw or send when pi.events is missing", () => {
|
|
120
|
+
const ctx = createTestCtx();
|
|
121
|
+
ctx.pi = { events: undefined as any };
|
|
122
|
+
expect(() => refreshUiModules(ctx)).not.toThrow();
|
|
123
|
+
expect(ctx._sent).toHaveLength(0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("absorbs handler errors without breaking the bridge", () => {
|
|
127
|
+
const ctx = createTestCtx();
|
|
128
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
129
|
+
try {
|
|
130
|
+
ctx._listeners.set("ui:list-modules", [
|
|
131
|
+
() => {
|
|
132
|
+
throw new Error("listener exploded");
|
|
133
|
+
},
|
|
134
|
+
]);
|
|
135
|
+
expect(() => refreshUiModules(ctx)).not.toThrow();
|
|
136
|
+
expect(errSpy).toHaveBeenCalled();
|
|
137
|
+
expect(ctx._sent).toHaveLength(0);
|
|
138
|
+
} finally {
|
|
139
|
+
errSpy.mockRestore();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("subscribeUiInvalidate", () => {
|
|
145
|
+
it("re-runs the probe whenever ui:invalidate fires (leading + trailing throttle, see Phase 2)", () => {
|
|
146
|
+
vi.useFakeTimers();
|
|
147
|
+
try {
|
|
148
|
+
const ctx = createTestCtx();
|
|
149
|
+
ctx._listeners.set("ui:list-modules", [
|
|
150
|
+
(probe: { modules: ExtensionUiModule[] }) => {
|
|
151
|
+
probe.modules.push(sampleModule("a", "/a"));
|
|
152
|
+
},
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
subscribeUiInvalidate(ctx);
|
|
156
|
+
// First emit triggers a leading-edge probe immediately.
|
|
157
|
+
ctx.pi.events!.emit("ui:invalidate", { id: "a" });
|
|
158
|
+
expect(ctx._sent).toHaveLength(1);
|
|
159
|
+
expect((ctx._sent[0] as any).type).toBe("ui_modules_list");
|
|
160
|
+
|
|
161
|
+
// Second emit within the throttle window coalesces into a trailing-edge
|
|
162
|
+
// probe; advance timers past the 50ms window to flush it.
|
|
163
|
+
ctx.pi.events!.emit("ui:invalidate", {});
|
|
164
|
+
vi.advanceTimersByTime(100);
|
|
165
|
+
expect(ctx._sent).toHaveLength(2);
|
|
166
|
+
} finally {
|
|
167
|
+
vi.useRealTimers();
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("is a no-op when pi.events.on is missing", () => {
|
|
172
|
+
const ctx = createTestCtx();
|
|
173
|
+
ctx.pi = { events: { emit: vi.fn() as any } as any };
|
|
174
|
+
expect(() => subscribeUiInvalidate(ctx)).not.toThrow();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("handleUiManagement", () => {
|
|
179
|
+
it("re-emits the event on pi.events with action and _reply injected, and forwards synchronous data.items", () => {
|
|
180
|
+
const ctx = createTestCtx("S");
|
|
181
|
+
ctx._listeners.set("judo:status-rows", [
|
|
182
|
+
(data: { action: string; items?: unknown[] }) => {
|
|
183
|
+
expect(data.action).toBe("list");
|
|
184
|
+
data.items = [{ id: 1 }, { id: 2 }];
|
|
185
|
+
},
|
|
186
|
+
]);
|
|
187
|
+
|
|
188
|
+
handleUiManagement(ctx, {
|
|
189
|
+
type: "ui_management",
|
|
190
|
+
sessionId: "S",
|
|
191
|
+
action: "list",
|
|
192
|
+
event: "judo:status-rows",
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
expect(ctx.pi.events!.emit).toHaveBeenCalledWith("judo:status-rows", expect.any(Object));
|
|
196
|
+
expect(ctx._sent).toEqual([
|
|
197
|
+
{ type: "ui_data_list", sessionId: "S", event: "judo:status-rows", items: [{ id: 1 }, { id: 2 }] },
|
|
198
|
+
]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("supports async _reply path (extension calls _reply asynchronously)", () => {
|
|
202
|
+
const ctx = createTestCtx();
|
|
203
|
+
ctx._listeners.set("judo:rows", [
|
|
204
|
+
(data: { _reply: (items: unknown[]) => void }) => {
|
|
205
|
+
// Simulate async: call _reply later in this tick.
|
|
206
|
+
setTimeout(() => data._reply([{ id: 7 }]), 0);
|
|
207
|
+
},
|
|
208
|
+
]);
|
|
209
|
+
|
|
210
|
+
handleUiManagement(ctx, { type: "ui_management", sessionId: "s1", action: "list", event: "judo:rows" });
|
|
211
|
+
// Synchronous fast-path didn't fire because data.items wasn't set.
|
|
212
|
+
expect(ctx._sent).toHaveLength(0);
|
|
213
|
+
return new Promise<void>((resolve) => {
|
|
214
|
+
setTimeout(() => {
|
|
215
|
+
expect(ctx._sent).toEqual([
|
|
216
|
+
{ type: "ui_data_list", sessionId: "s1", event: "judo:rows", items: [{ id: 7 }] },
|
|
217
|
+
]);
|
|
218
|
+
resolve();
|
|
219
|
+
}, 5);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("does not double-send if both data.items is set and _reply is called", () => {
|
|
224
|
+
const ctx = createTestCtx();
|
|
225
|
+
ctx._listeners.set("e", [
|
|
226
|
+
(data: { _reply: (items: unknown[]) => void; items?: unknown[] }) => {
|
|
227
|
+
data._reply([1, 2]);
|
|
228
|
+
data.items = [3, 4];
|
|
229
|
+
},
|
|
230
|
+
]);
|
|
231
|
+
handleUiManagement(ctx, { type: "ui_management", sessionId: "s1", action: "list", event: "e" });
|
|
232
|
+
// _reply ran synchronously inside the emit; data.items fast-path is gated by `replied`.
|
|
233
|
+
expect(ctx._sent).toHaveLength(1);
|
|
234
|
+
expect((ctx._sent[0] as any).items).toEqual([1, 2]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("does NOT send a ui_data_list for fire-and-forget actions (no items, no _reply)", () => {
|
|
238
|
+
const ctx = createTestCtx();
|
|
239
|
+
ctx._listeners.set("judo:delete-row", [() => { /* side-effect only */ }]);
|
|
240
|
+
handleUiManagement(ctx, {
|
|
241
|
+
type: "ui_management",
|
|
242
|
+
sessionId: "s1",
|
|
243
|
+
action: "delete",
|
|
244
|
+
event: "judo:delete-row",
|
|
245
|
+
params: { id: 42 },
|
|
246
|
+
});
|
|
247
|
+
expect(ctx._sent).toHaveLength(0);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("forwards the action and params verbatim to listeners", () => {
|
|
251
|
+
const ctx = createTestCtx();
|
|
252
|
+
let captured: any = null;
|
|
253
|
+
ctx._listeners.set("e", [(data: any) => { captured = { ...data }; }]);
|
|
254
|
+
handleUiManagement(ctx, {
|
|
255
|
+
type: "ui_management",
|
|
256
|
+
sessionId: "s1",
|
|
257
|
+
action: "delete",
|
|
258
|
+
event: "e",
|
|
259
|
+
params: { id: 42, force: true },
|
|
260
|
+
});
|
|
261
|
+
expect(captured).toMatchObject({ action: "delete", id: 42, force: true });
|
|
262
|
+
expect(typeof captured._reply).toBe("function");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("absorbs handler errors without sending data", () => {
|
|
266
|
+
const ctx = createTestCtx();
|
|
267
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
268
|
+
try {
|
|
269
|
+
ctx._listeners.set("explode", [() => { throw new Error("boom"); }]);
|
|
270
|
+
expect(() => handleUiManagement(ctx, { type: "ui_management", sessionId: "s1", action: "list", event: "explode" })).not.toThrow();
|
|
271
|
+
expect(errSpy).toHaveBeenCalled();
|
|
272
|
+
expect(ctx._sent).toHaveLength(0);
|
|
273
|
+
} finally {
|
|
274
|
+
errSpy.mockRestore();
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe("Bridge invariants", () => {
|
|
280
|
+
it("ui-modules.ts does not import pi.newSession / ctx.fork / ctx.switchSession", async () => {
|
|
281
|
+
// Mirrors the contract checked by `no-session-replacement-calls.test.ts`,
|
|
282
|
+
// but localized to the new module so a regression is caught at unit-test
|
|
283
|
+
// granularity in addition to the global lint test.
|
|
284
|
+
const fs = await import("node:fs/promises");
|
|
285
|
+
const url = await import("node:url");
|
|
286
|
+
const path = await import("node:path");
|
|
287
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
288
|
+
const src = await fs.readFile(path.join(here, "..", "ui-modules.ts"), "utf8");
|
|
289
|
+
expect(src).not.toMatch(/pi\.newSession\s*\(/);
|
|
290
|
+
expect(src).not.toMatch(/ctx\.fork\s*\(/);
|
|
291
|
+
expect(src).not.toMatch(/ctx\.switchSession\s*\(/);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
@@ -6,61 +6,93 @@
|
|
|
6
6
|
* register ask_user. Runtime registration bypasses detectExtensionConflicts.
|
|
7
7
|
*/
|
|
8
8
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
9
|
-
import { Type } from "
|
|
9
|
+
import { Type } from "typebox";
|
|
10
|
+
import { polyfillMultiselect } from "./multiselect-polyfill.js";
|
|
10
11
|
|
|
11
12
|
// ──────────────────────────────────────────────────────────────────────────
|
|
12
|
-
//
|
|
13
|
+
// Schema definition
|
|
14
|
+
//
|
|
15
|
+
// IMPORTANT: We use a single flat `Type.Object` at the root (rather than a
|
|
16
|
+
// `Type.Union` of per-method object arms) so the generated JSON Schema has
|
|
17
|
+
// `"type": "object"` at the root.
|
|
18
|
+
//
|
|
19
|
+
// Rationale: OpenAI's function-calling validator (and especially the strict
|
|
20
|
+
// mode used by GPT-4.1+/GPT-5.x/Codex/Responses API) REQUIRES the parameters
|
|
21
|
+
// schema to be an object at the root. A `Type.Union` produces `anyOf` at the
|
|
22
|
+
// root with no `type` field, which Anthropic accepts but OpenAI rejects with:
|
|
23
|
+
// "Invalid schema for function 'ask_user': schema must be a JSON Schema
|
|
24
|
+
// of 'type: \"object\"', got 'type: \"None\"'."
|
|
25
|
+
//
|
|
26
|
+
// Per-method validation (which fields are required for which `method`) is
|
|
27
|
+
// enforced at runtime by `prepareArguments` (rescue/normalization) and the
|
|
28
|
+
// `execute` switch below — the JSON Schema only needs to describe the union
|
|
29
|
+
// of allowed fields.
|
|
13
30
|
// ──────────────────────────────────────────────────────────────────────────
|
|
14
31
|
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
//
|
|
49
|
-
const SubQuestionSchema = Type.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
description: "
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
32
|
+
const MethodEnum = Type.Union(
|
|
33
|
+
[
|
|
34
|
+
Type.Literal("confirm"),
|
|
35
|
+
Type.Literal("select"),
|
|
36
|
+
Type.Literal("multiselect"),
|
|
37
|
+
Type.Literal("input"),
|
|
38
|
+
Type.Literal("batch"),
|
|
39
|
+
],
|
|
40
|
+
{
|
|
41
|
+
description:
|
|
42
|
+
"Question kind. 'confirm' = yes/no, 'select' = pick one of options[], 'multiselect' = pick many of options[], 'input' = free text, 'batch' = ask several questions in one call (provide questions[]).",
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// Sub-question schema for batch.method — flat object (root: type=object) so
|
|
47
|
+
// the emitted JSON Schema stays OpenAI-compatible at every level.
|
|
48
|
+
//
|
|
49
|
+
// IMPORTANT: this object MUST NOT carry a root-level `oneOf` / `anyOf` /
|
|
50
|
+
// `allOf` / `enum` / `not`. OpenAI strict mode (GPT-4.1+, GPT-5.x, Codex,
|
|
51
|
+
// Responses API) explicitly rejects those at *any* schema's top level
|
|
52
|
+
// with: "schema must have type 'object' and not have 'oneOf' / 'anyOf' /
|
|
53
|
+
// 'allOf' / 'enum' / 'not' at the top level." An earlier draft of
|
|
54
|
+
// fix-multiselect-auto-cancel-on-dashboard tried to add a body-level
|
|
55
|
+
// `oneOf` discriminator to restore Anthropic's per-arm strictness, but
|
|
56
|
+
// real-world OpenAI gpt-5 rejected it; the fallback path documented in
|
|
57
|
+
// tasks.md §9.7 was taken — Layer 2 dropped, Layer 1 ships alone.
|
|
58
|
+
//
|
|
59
|
+
// Per-method requirements (select/multiselect need `options`, batch
|
|
60
|
+
// needs `questions[]`, etc.) are enforced exclusively by
|
|
61
|
+
// `prepareArguments` rescue + the `execute` switch's runtime guards.
|
|
62
|
+
// Sub-questions cannot themselves be a batch (no nesting); enforced at
|
|
63
|
+
// runtime in `execute`.
|
|
64
|
+
//
|
|
65
|
+
// See change: fix-multiselect-auto-cancel-on-dashboard.
|
|
66
|
+
const SubQuestionSchema = Type.Object(
|
|
67
|
+
{
|
|
68
|
+
method: Type.Union(
|
|
69
|
+
[
|
|
70
|
+
Type.Literal("confirm"),
|
|
71
|
+
Type.Literal("select"),
|
|
72
|
+
Type.Literal("multiselect"),
|
|
73
|
+
Type.Literal("input"),
|
|
74
|
+
],
|
|
75
|
+
{ description: "Sub-question kind. Cannot be 'batch' (no nesting)." },
|
|
76
|
+
),
|
|
77
|
+
title: Type.String({ description: "Short title / question text for this sub-question" }),
|
|
78
|
+
options: Type.Optional(
|
|
79
|
+
Type.Array(Type.String(), {
|
|
80
|
+
description:
|
|
81
|
+
"Required for 'select' (>=2) and 'multiselect' (>=1). Plain string[] — not [{label,value}].",
|
|
82
|
+
}),
|
|
83
|
+
),
|
|
84
|
+
placeholder: Type.Optional(
|
|
85
|
+
Type.String({ description: "Placeholder for 'input' method" }),
|
|
86
|
+
),
|
|
87
|
+
message: Type.Optional(
|
|
88
|
+
Type.String({ description: "Additional context for this sub-question" }),
|
|
89
|
+
),
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
description:
|
|
93
|
+
"A single question inside a batch. Must not itself be a batch.",
|
|
94
|
+
},
|
|
95
|
+
);
|
|
64
96
|
|
|
65
97
|
// ──────────────────────────────────────────────────────────────────────────
|
|
66
98
|
// Argument rescue helpers
|
|
@@ -121,7 +153,7 @@ export function registerAskUserTool(pi: ExtensionAPI): void {
|
|
|
121
153
|
name: "ask_user",
|
|
122
154
|
label: "Ask User",
|
|
123
155
|
description:
|
|
124
|
-
"Ask the user a question interactively. Use this when you need clarification, confirmation, or a choice from the user before proceeding.",
|
|
156
|
+
"Ask the user a question interactively. Use this when you need clarification, confirmation, or a choice from the user before proceeding. UI provides a Select all toggle; do not add one.",
|
|
125
157
|
promptSnippet:
|
|
126
158
|
"Ask the user interactive questions (confirm, select, multiselect, input, or batch — multiple related questions at once)",
|
|
127
159
|
promptGuidelines: [
|
|
@@ -131,9 +163,67 @@ export function registerAskUserTool(pi: ExtensionAPI): void {
|
|
|
131
163
|
"Do not nest batches. Send `options` as a plain string[] — not [{label, value}].",
|
|
132
164
|
"This applies to all workflows including OpenSpec, planning, and any situation where you need user input before proceeding.",
|
|
133
165
|
],
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
166
|
+
// Flat object schema (root: type=object) for OpenAI strict-mode
|
|
167
|
+
// compatibility.
|
|
168
|
+
//
|
|
169
|
+
// IMPORTANT: this object MUST NOT carry a root-level `oneOf` / `anyOf`
|
|
170
|
+
// / `allOf` / `enum` / `not`. OpenAI strict mode (GPT-4.1+, GPT-5.x,
|
|
171
|
+
// Codex, Responses API) explicitly rejects those at the top level with:
|
|
172
|
+
// "schema must have type 'object' and not have 'oneOf' / 'anyOf' /
|
|
173
|
+
// 'allOf' / 'enum' / 'not' at the top level."
|
|
174
|
+
//
|
|
175
|
+
// An earlier iteration of fix-multiselect-auto-cancel-on-dashboard
|
|
176
|
+
// ("Layer 2: defense in depth") tried adding a body-level `oneOf`
|
|
177
|
+
// discriminator over `method` so Anthropic would regain per-arm
|
|
178
|
+
// `required` + `minItems` enforcement. That worked for Anthropic
|
|
179
|
+
// models but real-world OpenAI gpt-5 rejected the schema (verified by
|
|
180
|
+
// the user 2026-04-30). The fallback documented in tasks.md §9.7 was
|
|
181
|
+
// taken: Layer 2 was dropped; Layer 1 (multiselect dashboard routing)
|
|
182
|
+
// ships alone, which is what actually fixes the user-reported bug.
|
|
183
|
+
//
|
|
184
|
+
// Per-method shape requirements (select/multiselect need `options`,
|
|
185
|
+
// batch needs `questions[]`, etc.) are enforced exclusively at runtime
|
|
186
|
+
// by `prepareArguments` (rescue/normalization) and the `execute` switch.
|
|
187
|
+
//
|
|
188
|
+
// The `no-root-oneof-in-ask-user-schema` guard test at
|
|
189
|
+
// packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts
|
|
190
|
+
// pins this constraint so a future refactor cannot reintroduce it.
|
|
191
|
+
//
|
|
192
|
+
// See change: fix-multiselect-auto-cancel-on-dashboard.
|
|
193
|
+
parameters: Type.Object(
|
|
194
|
+
{
|
|
195
|
+
method: MethodEnum,
|
|
196
|
+
title: Type.Optional(
|
|
197
|
+
Type.String({
|
|
198
|
+
description:
|
|
199
|
+
"Short title / question text. Required for all methods except when 'questions' carry it (batch may omit and inherit from first sub-question).",
|
|
200
|
+
}),
|
|
201
|
+
),
|
|
202
|
+
message: Type.Optional(
|
|
203
|
+
Type.String({ description: "Additional context shown alongside the question(s)." }),
|
|
204
|
+
),
|
|
205
|
+
options: Type.Optional(
|
|
206
|
+
Type.Array(Type.String(), {
|
|
207
|
+
description:
|
|
208
|
+
"Required for method 'select' (>=2 items) and 'multiselect' (>=1 item). Plain string[] — not [{label,value}]. Ignored for other methods.",
|
|
209
|
+
}),
|
|
210
|
+
),
|
|
211
|
+
placeholder: Type.Optional(
|
|
212
|
+
Type.String({
|
|
213
|
+
description: "Placeholder for method 'input'. Ignored for other methods.",
|
|
214
|
+
}),
|
|
215
|
+
),
|
|
216
|
+
questions: Type.Optional(
|
|
217
|
+
Type.Array(SubQuestionSchema, {
|
|
218
|
+
description:
|
|
219
|
+
"Required for method 'batch' (>=1 sub-question). Each sub-question is its own confirm/select/multiselect/input — cannot nest 'batch'.",
|
|
220
|
+
}),
|
|
221
|
+
),
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
description:
|
|
225
|
+
"Parameters for ask_user. The required fields depend on `method`: confirm→title; select→title+options(>=2); multiselect→title+options(>=1); input→title (placeholder optional); batch→questions[] (title auto-derived from first question if omitted). Validation is enforced at runtime by prepareArguments + execute (no schema-level discriminator — OpenAI strict mode forbids root-level oneOf).",
|
|
226
|
+
},
|
|
137
227
|
),
|
|
138
228
|
prepareArguments(args: unknown) {
|
|
139
229
|
let obj = (args && typeof args === "object" ? { ...(args as Record<string, unknown>) } : {}) as Record<string, unknown>;
|
|
@@ -222,6 +312,21 @@ export function registerAskUserTool(pi: ExtensionAPI): void {
|
|
|
222
312
|
return obj as any;
|
|
223
313
|
},
|
|
224
314
|
async execute(_toolCallId: any, params: any, _signal: any, _onUpdate: any, ctx: any) {
|
|
315
|
+
// Capture the originating toolCallId so the resulting prompt_request
|
|
316
|
+
// metadata carries it; the client reducer pairs the interactiveUi
|
|
317
|
+
// row with its parent toolResult row using this id.
|
|
318
|
+
// See change: fix-interactive-ui-reorder.
|
|
319
|
+
const toolCallId: string | undefined =
|
|
320
|
+
typeof _toolCallId === "string" && _toolCallId.length > 0
|
|
321
|
+
? _toolCallId
|
|
322
|
+
: undefined;
|
|
323
|
+
const withTcid = (
|
|
324
|
+
opts: Record<string, unknown> | undefined,
|
|
325
|
+
): Record<string, unknown> | undefined => {
|
|
326
|
+
if (!toolCallId) return opts;
|
|
327
|
+
return { ...(opts ?? {}), toolCallId };
|
|
328
|
+
};
|
|
329
|
+
|
|
225
330
|
// ── Batch branch ─────────────────────────────────────────────────
|
|
226
331
|
if (params.method === "batch" && Array.isArray(params.questions)) {
|
|
227
332
|
const results: Array<unknown> = [];
|
|
@@ -229,13 +334,17 @@ export function registerAskUserTool(pi: ExtensionAPI): void {
|
|
|
229
334
|
|
|
230
335
|
for (const sq of params.questions) {
|
|
231
336
|
const subTitle = `${params.title} — ${sq.title ?? "Question"}`;
|
|
232
|
-
const subMsg = params.message ? { message: params.message } : undefined;
|
|
337
|
+
const subMsg = withTcid(params.message ? { message: params.message } : undefined);
|
|
233
338
|
|
|
234
339
|
let answer: unknown;
|
|
235
340
|
try {
|
|
236
341
|
switch (sq.method) {
|
|
237
342
|
case "confirm":
|
|
238
|
-
answer = await ctx.ui.confirm(
|
|
343
|
+
answer = await ctx.ui.confirm(
|
|
344
|
+
subTitle,
|
|
345
|
+
sq.message ?? params.message ?? "",
|
|
346
|
+
withTcid(undefined),
|
|
347
|
+
);
|
|
239
348
|
break;
|
|
240
349
|
case "select": {
|
|
241
350
|
const opts = Array.isArray(sq.options) ? sq.options : [];
|
|
@@ -254,7 +363,7 @@ export function registerAskUserTool(pi: ExtensionAPI): void {
|
|
|
254
363
|
`ask_user batch: sub-question method "multiselect" requires a non-empty "options" array.`,
|
|
255
364
|
);
|
|
256
365
|
}
|
|
257
|
-
answer = await (ctx
|
|
366
|
+
answer = await polyfillMultiselect(ctx, subTitle, opts, subMsg);
|
|
258
367
|
break;
|
|
259
368
|
}
|
|
260
369
|
case "input":
|
|
@@ -311,7 +420,7 @@ export function registerAskUserTool(pi: ExtensionAPI): void {
|
|
|
311
420
|
|
|
312
421
|
// ── Single-question branches (unchanged behavior) ────────────────
|
|
313
422
|
let result: unknown;
|
|
314
|
-
const msgOpts = params.message ? { message: params.message } : undefined;
|
|
423
|
+
const msgOpts = withTcid(params.message ? { message: params.message } : undefined);
|
|
315
424
|
const title = params.title || params.message || "Question";
|
|
316
425
|
|
|
317
426
|
const options: string[] = Array.isArray(params.options)
|
|
@@ -330,13 +439,13 @@ export function registerAskUserTool(pi: ExtensionAPI): void {
|
|
|
330
439
|
|
|
331
440
|
switch (params.method) {
|
|
332
441
|
case "confirm":
|
|
333
|
-
result = await ctx.ui.confirm(title, params.message ?? "");
|
|
442
|
+
result = await ctx.ui.confirm(title, params.message ?? "", withTcid(undefined));
|
|
334
443
|
break;
|
|
335
444
|
case "select":
|
|
336
445
|
result = await ctx.ui.select(title, options, msgOpts);
|
|
337
446
|
break;
|
|
338
447
|
case "multiselect":
|
|
339
|
-
result = await (ctx
|
|
448
|
+
result = await polyfillMultiselect(ctx, title, options, msgOpts);
|
|
340
449
|
break;
|
|
341
450
|
case "input":
|
|
342
451
|
result = await ctx.ui.input(title, params.placeholder, msgOpts);
|