@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.
Files changed (129) hide show
  1. package/AGENTS.md +104 -35
  2. package/README.md +390 -494
  3. package/docs/architecture.md +423 -20
  4. package/package.json +11 -8
  5. package/packages/extension/package.json +11 -4
  6. package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
  7. package/packages/extension/src/__tests__/ask-user-tool.test.ts +91 -15
  8. package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
  9. package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
  10. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
  11. package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
  12. package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
  13. package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
  14. package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
  15. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
  16. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
  17. package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
  18. package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
  19. package/packages/extension/src/ask-user-tool.ts +170 -61
  20. package/packages/extension/src/bridge.ts +199 -19
  21. package/packages/extension/src/multiselect-decode.ts +40 -0
  22. package/packages/extension/src/multiselect-list.ts +146 -0
  23. package/packages/extension/src/multiselect-polyfill.ts +73 -0
  24. package/packages/extension/src/server-launcher.ts +15 -3
  25. package/packages/extension/src/ui-modules.ts +272 -0
  26. package/packages/server/package.json +11 -5
  27. package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
  28. package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
  29. package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
  30. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
  31. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
  32. package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
  33. package/packages/server/src/__tests__/directory-service.test.ts +174 -0
  34. package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
  35. package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
  36. package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
  37. package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
  38. package/packages/server/src/__tests__/package-routes.test.ts +136 -3
  39. package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
  40. package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
  41. package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
  42. package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
  43. package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
  44. package/packages/server/src/__tests__/pi-version-skew.test.ts +72 -0
  45. package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
  46. package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
  47. package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
  48. package/packages/server/src/__tests__/restart-helper.test.ts +34 -6
  49. package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
  50. package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
  51. package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
  52. package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
  53. package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
  54. package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
  55. package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
  56. package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
  57. package/packages/server/src/browse.ts +118 -13
  58. package/packages/server/src/browser-gateway.ts +19 -0
  59. package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
  60. package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
  61. package/packages/server/src/browser-handlers/handler-context.ts +15 -0
  62. package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
  63. package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
  64. package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
  65. package/packages/server/src/cli.ts +61 -15
  66. package/packages/server/src/directory-service.ts +156 -15
  67. package/packages/server/src/event-wiring.ts +111 -10
  68. package/packages/server/src/installed-package-enricher.ts +143 -0
  69. package/packages/server/src/package-manager-wrapper.ts +305 -8
  70. package/packages/server/src/package-source-helpers.ts +104 -0
  71. package/packages/server/src/pending-attach-registry.ts +112 -0
  72. package/packages/server/src/pending-resume-intent-registry.ts +107 -0
  73. package/packages/server/src/pi-core-checker.ts +9 -14
  74. package/packages/server/src/pi-gateway.ts +14 -0
  75. package/packages/server/src/pi-version-skew.ts +12 -1
  76. package/packages/server/src/proposal-attach-naming.ts +47 -0
  77. package/packages/server/src/restart-helper.ts +13 -2
  78. package/packages/server/src/routes/file-routes.ts +29 -3
  79. package/packages/server/src/routes/package-routes.ts +72 -3
  80. package/packages/server/src/routes/plugin-config-routes.ts +129 -0
  81. package/packages/server/src/routes/system-routes.ts +2 -0
  82. package/packages/server/src/server.ts +339 -10
  83. package/packages/server/src/session-api.ts +30 -5
  84. package/packages/server/src/session-order-manager.ts +22 -0
  85. package/packages/server/src/session-scanner.ts +10 -1
  86. package/packages/shared/package.json +9 -2
  87. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
  88. package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
  89. package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
  90. package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
  91. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
  92. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
  93. package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
  94. package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
  95. package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
  96. package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
  97. package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
  98. package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
  99. package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
  100. package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
  101. package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
  102. package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
  103. package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
  104. package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
  105. package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
  106. package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
  107. package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
  108. package/packages/shared/src/browser-protocol.ts +110 -4
  109. package/packages/shared/src/config.ts +45 -0
  110. package/packages/shared/src/dashboard-plugin/index.ts +11 -0
  111. package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
  112. package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
  113. package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
  114. package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
  115. package/packages/shared/src/openspec-activity-detector.ts +18 -22
  116. package/packages/shared/src/openspec-design-evidence.ts +109 -0
  117. package/packages/shared/src/openspec-poller.ts +117 -3
  118. package/packages/shared/src/openspec-specs-evidence.ts +79 -0
  119. package/packages/shared/src/platform/binary-lookup.ts +96 -1
  120. package/packages/shared/src/platform/index.ts +1 -0
  121. package/packages/shared/src/platform/node-spawn.ts +154 -0
  122. package/packages/shared/src/plugin-bridge-register.ts +139 -0
  123. package/packages/shared/src/protocol.ts +79 -2
  124. package/packages/shared/src/recommended-extensions.ts +7 -1
  125. package/packages/shared/src/rest-api.ts +68 -3
  126. package/packages/shared/src/state-replay.ts +20 -1
  127. package/packages/shared/src/tool-registry/definitions.ts +92 -0
  128. package/packages/shared/src/tool-registry/strategies.ts +17 -3
  129. 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 "@sinclair/typebox";
9
+ import { Type } from "typebox";
10
+ import { polyfillMultiselect } from "./multiselect-polyfill.js";
10
11
 
11
12
  // ──────────────────────────────────────────────────────────────────────────
12
- // Single-question schema arms (reused inside the batch arm's questions array)
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 ConfirmSchema = Type.Object({
16
- method: Type.Literal("confirm", { description: "Yes/no question" }),
17
- title: Type.String({ description: "The question to confirm" }),
18
- message: Type.Optional(Type.String({ description: "Additional context or detailed question body" })),
19
- });
20
-
21
- const SelectSchema = Type.Object({
22
- method: Type.Literal("select", { description: "Pick one option from a list" }),
23
- title: Type.String({ description: "Short title for the question" }),
24
- options: Type.Array(Type.String(), {
25
- minItems: 2,
26
- description: "Options the user chooses between (at least 2; use 'confirm' for yes/no)",
27
- }),
28
- message: Type.Optional(Type.String({ description: "Additional context" })),
29
- });
30
-
31
- const MultiselectSchema = Type.Object({
32
- method: Type.Literal("multiselect", { description: "Pick multiple options from a list" }),
33
- title: Type.String({ description: "Short title for the question" }),
34
- options: Type.Array(Type.String(), {
35
- minItems: 1,
36
- description: "Options the user can multi-select",
37
- }),
38
- message: Type.Optional(Type.String({ description: "Additional context" })),
39
- });
40
-
41
- const InputSchema = Type.Object({
42
- method: Type.Literal("input", { description: "Free-text input" }),
43
- title: Type.String({ description: "Short title for the question" }),
44
- placeholder: Type.Optional(Type.String({ description: "Placeholder text for the input field" })),
45
- message: Type.Optional(Type.String({ description: "Additional context" })),
46
- });
47
-
48
- // Sub-question union deliberately omits the batch arm (no nesting).
49
- const SubQuestionSchema = Type.Union([ConfirmSchema, SelectSchema, MultiselectSchema, InputSchema], {
50
- description: "A single question inside a batch. Must not itself be a batch.",
51
- });
52
-
53
- const BatchSchema = Type.Object({
54
- method: Type.Literal("batch", {
55
- description: "Ask multiple related questions in one call; answers are returned as an ordered array.",
56
- }),
57
- title: Type.String({ description: "Header shown above the sequence of dialogs" }),
58
- questions: Type.Array(SubQuestionSchema, {
59
- minItems: 1,
60
- description: "One or more sub-questions (confirm/select/multiselect/input). Cannot nest batch.",
61
- }),
62
- message: Type.Optional(Type.String({ description: "Additional context for the whole batch" })),
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
- parameters: Type.Union(
135
- [ConfirmSchema, SelectSchema, MultiselectSchema, InputSchema, BatchSchema],
136
- { description: "Parameters for ask_user, discriminated by method." },
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(subTitle, sq.message ?? params.message ?? "");
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.ui as any).multiselect(subTitle, opts, subMsg);
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.ui as any).multiselect(title, options, msgOpts);
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);