@blackbelt-technology/pi-agent-dashboard 0.2.0 → 0.2.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 (49) hide show
  1. package/AGENTS.md +3 -1
  2. package/docs/architecture.md +30 -23
  3. package/package.json +8 -1
  4. package/packages/extension/package.json +1 -1
  5. package/packages/extension/src/__tests__/dashboard-default-adapter.test.ts +77 -0
  6. package/packages/extension/src/__tests__/dev-build.test.ts +2 -2
  7. package/packages/extension/src/__tests__/prompt-bus-wiring.test.ts +791 -0
  8. package/packages/extension/src/__tests__/prompt-bus.test.ts +469 -0
  9. package/packages/extension/src/__tests__/server-launcher.test.ts +35 -34
  10. package/packages/extension/src/__tests__/tui-prompt-adapter.test.ts +207 -0
  11. package/packages/extension/src/ask-user-tool.ts +1 -1
  12. package/packages/extension/src/bridge-context.ts +1 -1
  13. package/packages/extension/src/bridge.ts +214 -59
  14. package/packages/extension/src/command-handler.ts +2 -2
  15. package/packages/extension/src/dashboard-default-adapter.ts +37 -0
  16. package/packages/extension/src/flow-event-wiring.ts +6 -23
  17. package/packages/extension/src/pi-env.d.ts +13 -0
  18. package/packages/extension/src/prompt-bus.ts +240 -0
  19. package/packages/extension/src/server-launcher.ts +2 -2
  20. package/packages/extension/src/session-sync.ts +2 -1
  21. package/packages/server/package.json +1 -1
  22. package/packages/server/src/__tests__/bridge-register-nondestructive.test.ts +108 -0
  23. package/packages/server/src/__tests__/extension-register-appimage.test.ts +39 -0
  24. package/packages/server/src/__tests__/extension-register.test.ts +26 -22
  25. package/packages/server/src/__tests__/process-manager.test.ts +4 -1
  26. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +5 -5
  27. package/packages/server/src/__tests__/tunnel.test.ts +2 -2
  28. package/packages/server/src/browser-gateway.ts +55 -16
  29. package/packages/server/src/cli.ts +1 -1
  30. package/packages/server/src/editor-manager.ts +1 -1
  31. package/packages/server/src/event-status-extraction.ts +7 -0
  32. package/packages/server/src/event-wiring.ts +16 -19
  33. package/packages/server/src/package-manager-wrapper.ts +1 -1
  34. package/packages/server/src/process-manager.ts +8 -69
  35. package/packages/server/src/routes/system-routes.ts +3 -1
  36. package/packages/server/src/server.ts +6 -4
  37. package/packages/shared/package.json +1 -1
  38. package/packages/shared/src/__tests__/bridge-register.test.ts +136 -0
  39. package/packages/shared/src/__tests__/tool-resolver.test.ts +164 -0
  40. package/packages/shared/src/bridge-register.ts +95 -0
  41. package/packages/shared/src/browser-protocol.ts +10 -0
  42. package/packages/shared/src/managed-paths.ts +15 -0
  43. package/packages/shared/src/mdns-discovery.ts +1 -1
  44. package/packages/shared/src/protocol.ts +46 -0
  45. package/packages/shared/src/tool-resolver.ts +201 -0
  46. package/packages/shared/src/types.ts +24 -0
  47. package/packages/extension/src/__tests__/ui-proxy.test.ts +0 -583
  48. package/packages/extension/src/ui-proxy.ts +0 -269
  49. package/packages/server/src/extension-register.ts +0 -92
@@ -0,0 +1,791 @@
1
+ /**
2
+ * PromptBus wiring integration tests.
3
+ *
4
+ * Validates that prompts wire correctly to BOTH TUI and dashboard simultaneously,
5
+ * with proper cross-cancellation and first-response-wins semantics.
6
+ * Uses mock agent messages to simulate real prompt flows.
7
+ */
8
+
9
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
10
+ import { PromptBus, type PromptAdapter, type PromptRequest, type PromptResponse, type PromptClaim, type PromptComponent } from "../prompt-bus.js";
11
+
12
+ // ── Mock infrastructure (tasks 9.1) ────────────────────────────────
13
+
14
+ interface MockEventBusHandler {
15
+ event: string;
16
+ handler: (...args: unknown[]) => void;
17
+ }
18
+
19
+ function createMockEventBus() {
20
+ const handlers: MockEventBusHandler[] = [];
21
+ return {
22
+ on(event: string, handler: (...args: unknown[]) => void): () => void {
23
+ const entry = { event, handler };
24
+ handlers.push(entry);
25
+ return () => {
26
+ const idx = handlers.indexOf(entry);
27
+ if (idx >= 0) handlers.splice(idx, 1);
28
+ };
29
+ },
30
+ emit(event: string, ...args: unknown[]): void {
31
+ for (const h of handlers) {
32
+ if (h.event === event) h.handler(...args);
33
+ }
34
+ },
35
+ _handlers: handlers,
36
+ };
37
+ }
38
+
39
+ function createMockTuiUi() {
40
+ // Each method returns a controllable promise and captures the AbortSignal
41
+ const signals: Record<string, AbortSignal | undefined> = {};
42
+ const resolvers: Record<string, { resolve: (v: any) => void; reject: (e: any) => void }> = {};
43
+
44
+ function makeMock(name: string) {
45
+ return vi.fn().mockImplementation((_question: string, _arg2?: any, opts?: any) => {
46
+ signals[name] = opts?.signal;
47
+ return new Promise((resolve, reject) => {
48
+ resolvers[name] = { resolve, reject };
49
+ // If signal already aborted, reject immediately
50
+ if (opts?.signal?.aborted) {
51
+ reject(new DOMException("Aborted", "AbortError"));
52
+ return;
53
+ }
54
+ opts?.signal?.addEventListener("abort", () => {
55
+ reject(new DOMException("Aborted", "AbortError"));
56
+ }, { once: true });
57
+ });
58
+ });
59
+ }
60
+
61
+ return {
62
+ select: makeMock("select"),
63
+ input: makeMock("input"),
64
+ confirm: makeMock("confirm"),
65
+ editor: makeMock("editor"),
66
+ notify: vi.fn(),
67
+ /** Resolve the pending TUI dialog for the given method */
68
+ _resolve(method: string, value: any) { resolvers[method]?.resolve(value); },
69
+ /** Get the AbortSignal passed to the given method */
70
+ _signal(method: string): AbortSignal | undefined { return signals[method]; },
71
+ /** Reset signals/resolvers for a fresh prompt */
72
+ _reset() {
73
+ Object.keys(signals).forEach(k => delete signals[k]);
74
+ Object.keys(resolvers).forEach(k => delete resolvers[k]);
75
+ },
76
+ };
77
+ }
78
+
79
+ function createMockConnection() {
80
+ return {
81
+ send: vi.fn(),
82
+ /** Get all sent messages of a given type */
83
+ _messagesOfType(type: string) {
84
+ return this.send.mock.calls
85
+ .map((c: any[]) => c[0])
86
+ .filter((m: any) => m?.type === type);
87
+ },
88
+ };
89
+ }
90
+
91
+ // ── Bridge TUI adapter mock (mimics bridge's built-in TUI adapter) ──
92
+
93
+ function createTuiPromptAdapter(mockUi: ReturnType<typeof createMockTuiUi>, bus: PromptBus): PromptAdapter {
94
+ const activeAbortControllers = new Map<string, AbortController>();
95
+
96
+ return {
97
+ name: "tui",
98
+ onRequest(prompt: PromptRequest): PromptClaim | null {
99
+ const ac = new AbortController();
100
+ activeAbortControllers.set(prompt.id, ac);
101
+
102
+ // Present TUI dialog asynchronously (like the real adapter)
103
+ const present = async () => {
104
+ try {
105
+ let answer: any;
106
+ if (prompt.type === "select" && prompt.options) {
107
+ answer = await mockUi.select(prompt.question, prompt.options, { signal: ac.signal });
108
+ } else if (prompt.type === "input") {
109
+ answer = await mockUi.input(prompt.question, prompt.defaultValue || "", { signal: ac.signal });
110
+ } else if (prompt.type === "confirm") {
111
+ answer = await mockUi.confirm(prompt.question, "", { signal: ac.signal });
112
+ } else {
113
+ return;
114
+ }
115
+
116
+ // If not aborted, respond via bus
117
+ if (!ac.signal.aborted) {
118
+ const answerStr = typeof answer === "boolean" ? (answer ? "true" : "false") : answer;
119
+ bus.respond({
120
+ id: prompt.id,
121
+ answer: answerStr ?? undefined,
122
+ cancelled: answerStr == null,
123
+ source: "tui",
124
+ });
125
+ }
126
+ } catch {
127
+ // Aborted — don't respond
128
+ } finally {
129
+ activeAbortControllers.delete(prompt.id);
130
+ }
131
+ };
132
+ present();
133
+
134
+ return {}; // Claim without component (TUI-only)
135
+ },
136
+ onResponse(response: PromptResponse): void {
137
+ if (response.source !== "tui") {
138
+ const ac = activeAbortControllers.get(response.id);
139
+ if (ac) {
140
+ ac.abort();
141
+ activeAbortControllers.delete(response.id);
142
+ }
143
+ }
144
+ },
145
+ onCancel(id: string): void {
146
+ const ac = activeAbortControllers.get(id);
147
+ if (ac) {
148
+ ac.abort();
149
+ activeAbortControllers.delete(id);
150
+ }
151
+ },
152
+ };
153
+ }
154
+
155
+ // ── ArchitectUIAdapter mock (mimics real adapter behavior) ─────────
156
+
157
+ function createArchitectUIAdapter(): PromptAdapter {
158
+ const claimedPrompts = new Set<string>();
159
+ return {
160
+ name: "architect-widget",
161
+ onRequest(prompt: PromptRequest): PromptClaim | null {
162
+ if (!prompt.pipeline.startsWith("architect-")) return null;
163
+ claimedPrompts.add(prompt.id);
164
+ return {
165
+ component: {
166
+ type: "architect-prompt",
167
+ props: {
168
+ id: prompt.id,
169
+ question: prompt.question,
170
+ promptType: prompt.type,
171
+ options: prompt.options,
172
+ defaultValue: prompt.defaultValue,
173
+ },
174
+ },
175
+ placement: "widget-bar",
176
+ };
177
+ },
178
+ onResponse(response: PromptResponse): void {
179
+ claimedPrompts.delete(response.id);
180
+ },
181
+ onCancel(id: string): void {
182
+ claimedPrompts.delete(id);
183
+ },
184
+ };
185
+ }
186
+
187
+ // ── Setup helper (task 9.2) ────────────────────────────────────────
188
+
189
+ interface PromptBusStack {
190
+ bus: PromptBus;
191
+ connection: ReturnType<typeof createMockConnection>;
192
+ tuiUi: ReturnType<typeof createMockTuiUi>;
193
+ eventBus: ReturnType<typeof createMockEventBus>;
194
+ /** Feed a dashboard response into the bus (simulates browser → server → extension) */
195
+ dashboardRespond(id: string, answer: string, source?: string): void;
196
+ /** Feed a dashboard cancel into the bus */
197
+ dashboardCancel(id: string): void;
198
+ }
199
+
200
+ function setupPromptBusStack(options: { hasUI?: boolean; hasArchitect?: boolean } = {}): PromptBusStack {
201
+ const { hasUI = true, hasArchitect = false } = options;
202
+ const connection = createMockConnection();
203
+ const tuiUi = createMockTuiUi();
204
+ const eventBus = createMockEventBus();
205
+
206
+ const bus = new PromptBus({
207
+ timeoutMs: 5000,
208
+ onDashboardRequest: (prompt, component, placement) => {
209
+ connection.send({
210
+ type: "prompt_request",
211
+ sessionId: "test-session",
212
+ promptId: prompt.id,
213
+ prompt: { question: prompt.question, type: prompt.type, options: prompt.options, defaultValue: prompt.defaultValue },
214
+ component,
215
+ placement,
216
+ });
217
+ },
218
+ onDashboardDismiss: (id) => {
219
+ connection.send({ type: "prompt_dismiss", sessionId: "test-session", promptId: id });
220
+ },
221
+ onDashboardCancel: (id) => {
222
+ connection.send({ type: "prompt_cancel", sessionId: "test-session", promptId: id });
223
+ },
224
+ });
225
+
226
+ // Always register default dashboard adapter (built-in)
227
+ // In production this claims with generic-dialog, but the bus itself handles the fallback
228
+ // so we don't need a separate adapter object in the test stack.
229
+
230
+ if (hasUI) {
231
+ bus.registerAdapter(createTuiPromptAdapter(tuiUi, bus));
232
+ }
233
+
234
+ if (hasArchitect) {
235
+ bus.registerAdapter(createArchitectUIAdapter());
236
+ }
237
+
238
+ return {
239
+ bus,
240
+ connection,
241
+ tuiUi,
242
+ eventBus,
243
+ dashboardRespond(id: string, answer: string, source = "dashboard-default") {
244
+ bus.respond({ id, answer, source });
245
+ },
246
+ dashboardCancel(id: string) {
247
+ bus.cancel(id);
248
+ },
249
+ };
250
+ }
251
+
252
+ /** Helper to extract prompt id from the adapter's onRequest call or connection message */
253
+ function getPromptId(connection: ReturnType<typeof createMockConnection>): string {
254
+ const req = connection._messagesOfType("prompt_request")[0];
255
+ return req?.promptId;
256
+ }
257
+
258
+ function getPromptIds(connection: ReturnType<typeof createMockConnection>): string[] {
259
+ return connection._messagesOfType("prompt_request").map((m: any) => m.promptId);
260
+ }
261
+
262
+ // ── Tests ──────────────────────────────────────────────────────────
263
+
264
+ describe("PromptBus wiring integration", () => {
265
+ beforeEach(() => {
266
+ vi.useFakeTimers();
267
+ });
268
+
269
+ afterEach(() => {
270
+ vi.useRealTimers();
271
+ });
272
+
273
+ // ── 9.3–9.5: Dual wiring verification ──
274
+
275
+ describe("dual wiring — prompts reach both TUI and dashboard", () => {
276
+ it("9.3: select prompt wires to both TUI and dashboard simultaneously", () => {
277
+ const stack = setupPromptBusStack({ hasUI: true });
278
+
279
+ stack.bus.request({ pipeline: "command", type: "select", question: "Pick:", options: ["A", "B"] });
280
+
281
+ // TUI should have been called
282
+ expect(stack.tuiUi.select).toHaveBeenCalledWith(
283
+ "Pick:",
284
+ ["A", "B"],
285
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
286
+ );
287
+
288
+ // Dashboard should have received prompt_request with generic-dialog
289
+ const requests = stack.connection._messagesOfType("prompt_request");
290
+ expect(requests).toHaveLength(1);
291
+ expect(requests[0].component).toEqual(expect.objectContaining({ type: "generic-dialog" }));
292
+ expect(requests[0].prompt.question).toBe("Pick:");
293
+ });
294
+
295
+ it("9.4: input prompt wires to both TUI and dashboard simultaneously", () => {
296
+ const stack = setupPromptBusStack({ hasUI: true });
297
+
298
+ stack.bus.request({ pipeline: "command", type: "input", question: "Name:" });
299
+
300
+ expect(stack.tuiUi.input).toHaveBeenCalledWith(
301
+ "Name:",
302
+ "",
303
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
304
+ );
305
+
306
+ const requests = stack.connection._messagesOfType("prompt_request");
307
+ expect(requests).toHaveLength(1);
308
+ expect(requests[0].prompt.type).toBe("input");
309
+ });
310
+
311
+ it("9.5: confirm prompt wires to both TUI and dashboard simultaneously", () => {
312
+ const stack = setupPromptBusStack({ hasUI: true });
313
+
314
+ stack.bus.request({ pipeline: "command", type: "confirm", question: "Sure?" });
315
+
316
+ expect(stack.tuiUi.confirm).toHaveBeenCalledWith(
317
+ "Sure?",
318
+ "",
319
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
320
+ );
321
+
322
+ const requests = stack.connection._messagesOfType("prompt_request");
323
+ expect(requests).toHaveLength(1);
324
+ expect(requests[0].prompt.type).toBe("confirm");
325
+ });
326
+ });
327
+
328
+ // ── 9.6–9.7: Cross-cancellation ──
329
+
330
+ describe("cross-cancellation — first answer wins", () => {
331
+ it("9.6: TUI answers first → dashboard gets dismiss, late dashboard response ignored", async () => {
332
+ const stack = setupPromptBusStack({ hasUI: true });
333
+
334
+ const promise = stack.bus.request({ pipeline: "command", type: "select", question: "Pick:", options: ["A", "B"] });
335
+ const id = getPromptId(stack.connection);
336
+
337
+ // TUI answers "A"
338
+ stack.tuiUi._resolve("select", "A");
339
+ await vi.advanceTimersByTimeAsync(0);
340
+
341
+ const result = await promise;
342
+ expect(result.answer).toBe("A");
343
+ expect(result.source).toBe("tui");
344
+
345
+ // Dashboard should have received a dismiss
346
+ const dismisses = stack.connection._messagesOfType("prompt_dismiss");
347
+ expect(dismisses.length).toBeGreaterThanOrEqual(1);
348
+ expect(dismisses[0].promptId).toBe(id);
349
+
350
+ // Late dashboard response should be silently ignored (no error)
351
+ stack.dashboardRespond(id, "B");
352
+ });
353
+
354
+ it("9.7: Dashboard answers first → TUI AbortSignal aborted, late TUI resolution ignored", async () => {
355
+ const stack = setupPromptBusStack({ hasUI: true });
356
+
357
+ const promise = stack.bus.request({ pipeline: "command", type: "select", question: "Pick:", options: ["A", "B"] });
358
+ const id = getPromptId(stack.connection);
359
+
360
+ // Dashboard answers "B"
361
+ stack.dashboardRespond(id, "B");
362
+
363
+ const result = await promise;
364
+ expect(result.answer).toBe("B");
365
+ expect(result.source).toBe("dashboard-default");
366
+
367
+ // TUI's AbortSignal should be aborted
368
+ await vi.advanceTimersByTimeAsync(0);
369
+ expect(stack.tuiUi._signal("select")?.aborted).toBe(true);
370
+ });
371
+ });
372
+
373
+ // ── 9.8–9.11: Architect custom UI wiring ──
374
+
375
+ describe("architect prompts — custom widget bar, not generic dialog", () => {
376
+ it("9.8: architect select wires to TUI + widget bar, NOT generic dialog", () => {
377
+ const stack = setupPromptBusStack({ hasUI: true, hasArchitect: true });
378
+
379
+ stack.bus.request({
380
+ pipeline: "architect-edit",
381
+ type: "select",
382
+ question: "What would you like to do?",
383
+ options: ["Save", "Replan", "Cancel"],
384
+ });
385
+
386
+ // TUI should show the select
387
+ expect(stack.tuiUi.select).toHaveBeenCalledWith(
388
+ "What would you like to do?",
389
+ ["Save", "Replan", "Cancel"],
390
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
391
+ );
392
+
393
+ // Dashboard should get architect-prompt component, NOT generic-dialog
394
+ const requests = stack.connection._messagesOfType("prompt_request");
395
+ expect(requests).toHaveLength(1);
396
+ expect(requests[0].component.type).toBe("architect-prompt");
397
+ expect(requests[0].placement).toBe("widget-bar");
398
+ // Should NOT have a generic-dialog
399
+ expect(requests.filter((r: any) => r.component.type === "generic-dialog")).toHaveLength(0);
400
+ });
401
+
402
+ it("9.9: architect input ('Additional guidance') wires to TUI + widget bar, not generic dialog", () => {
403
+ const stack = setupPromptBusStack({ hasUI: true, hasArchitect: true });
404
+
405
+ stack.bus.request({
406
+ pipeline: "architect-new",
407
+ type: "input",
408
+ question: "Additional guidance for the architect:",
409
+ });
410
+
411
+ expect(stack.tuiUi.input).toHaveBeenCalled();
412
+
413
+ const requests = stack.connection._messagesOfType("prompt_request");
414
+ expect(requests).toHaveLength(1);
415
+ expect(requests[0].component.type).toBe("architect-prompt");
416
+ expect(requests[0].placement).toBe("widget-bar");
417
+ });
418
+
419
+ it("9.10: TUI answers architect prompt → widget bar dismissed", async () => {
420
+ const stack = setupPromptBusStack({ hasUI: true, hasArchitect: true });
421
+
422
+ const promise = stack.bus.request({
423
+ pipeline: "architect-edit",
424
+ type: "select",
425
+ question: "What would you like to do?",
426
+ options: ["Save", "Replan", "Cancel"],
427
+ });
428
+ const id = getPromptId(stack.connection);
429
+
430
+ // TUI answers "Replan"
431
+ stack.tuiUi._resolve("select", "Replan");
432
+ await vi.advanceTimersByTimeAsync(0);
433
+
434
+ const result = await promise;
435
+ expect(result.answer).toBe("Replan");
436
+ expect(result.source).toBe("tui");
437
+
438
+ // Widget bar should be dismissed
439
+ const dismisses = stack.connection._messagesOfType("prompt_dismiss");
440
+ expect(dismisses.some((d: any) => d.promptId === id)).toBe(true);
441
+ });
442
+
443
+ it("9.11: Dashboard widget bar answers architect prompt → TUI AbortSignal aborted", async () => {
444
+ const stack = setupPromptBusStack({ hasUI: true, hasArchitect: true });
445
+
446
+ const promise = stack.bus.request({
447
+ pipeline: "architect-edit",
448
+ type: "select",
449
+ question: "What would you like to do?",
450
+ options: ["Save", "Replan", "Cancel"],
451
+ });
452
+ const id = getPromptId(stack.connection);
453
+
454
+ // Dashboard widget bar answers "Save"
455
+ stack.bus.respond({ id, answer: "Save", source: "architect-widget" });
456
+
457
+ const result = await promise;
458
+ expect(result.answer).toBe("Save");
459
+ expect(result.source).toBe("architect-widget");
460
+
461
+ // TUI should be aborted
462
+ await vi.advanceTimersByTimeAsync(0);
463
+ expect(stack.tuiUi._signal("select")?.aborted).toBe(true);
464
+ });
465
+ });
466
+
467
+ // ── 9.12–9.14: Mock agent messages ──
468
+
469
+ describe("mock agent messages trigger prompts on both UIs", () => {
470
+ it("9.12: mock agent ctx.ui.select reaches both TUI and dashboard", () => {
471
+ const stack = setupPromptBusStack({ hasUI: true });
472
+
473
+ // Simulate what the bus-wrapped ctx.ui.select would do:
474
+ stack.bus.request({
475
+ pipeline: "command",
476
+ type: "select",
477
+ question: "Agent question",
478
+ options: ["Yes", "No"],
479
+ });
480
+
481
+ expect(stack.tuiUi.select).toHaveBeenCalledWith(
482
+ "Agent question",
483
+ ["Yes", "No"],
484
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
485
+ );
486
+
487
+ const requests = stack.connection._messagesOfType("prompt_request");
488
+ expect(requests).toHaveLength(1);
489
+ expect(requests[0].prompt.question).toBe("Agent question");
490
+ });
491
+
492
+ it("9.13: mock architect failure → guidance input reaches TUI + widget bar, no generic dialog", () => {
493
+ const stack = setupPromptBusStack({ hasUI: true, hasArchitect: true });
494
+
495
+ // Simulate what emitPromptAndAwait does after architect failure:
496
+ stack.bus.request({
497
+ pipeline: "architect-new",
498
+ type: "input",
499
+ question: "Additional guidance for the architect:",
500
+ });
501
+
502
+ // TUI should show input
503
+ expect(stack.tuiUi.input).toHaveBeenCalledWith(
504
+ "Additional guidance for the architect:",
505
+ "",
506
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
507
+ );
508
+
509
+ // Dashboard should show architect widget, NOT generic dialog
510
+ const requests = stack.connection._messagesOfType("prompt_request");
511
+ expect(requests).toHaveLength(1);
512
+ expect(requests[0].component.type).toBe("architect-prompt");
513
+ expect(requests[0].placement).toBe("widget-bar");
514
+ });
515
+
516
+ it("9.14: mock architect preview → save/replan/cancel on both UIs, TUI answers Save, widget bar dismissed", async () => {
517
+ const stack = setupPromptBusStack({ hasUI: true, hasArchitect: true });
518
+
519
+ const promise = stack.bus.request({
520
+ pipeline: "architect-edit",
521
+ type: "select",
522
+ question: "What would you like to do?",
523
+ options: ["Save", "Replan", "Cancel"],
524
+ });
525
+ const id = getPromptId(stack.connection);
526
+
527
+ // Verify both show it
528
+ expect(stack.tuiUi.select).toHaveBeenCalled();
529
+ expect(stack.connection._messagesOfType("prompt_request")).toHaveLength(1);
530
+
531
+ // TUI answers "Save"
532
+ stack.tuiUi._resolve("select", "Save");
533
+ await vi.advanceTimersByTimeAsync(0);
534
+
535
+ const result = await promise;
536
+ expect(result.answer).toBe("Save");
537
+
538
+ // Widget bar dismissed
539
+ const dismisses = stack.connection._messagesOfType("prompt_dismiss");
540
+ expect(dismisses.some((d: any) => d.promptId === id)).toBe(true);
541
+ });
542
+ });
543
+
544
+ // ── 9.15–9.16: Late responses ──
545
+
546
+ describe("late responses after first-response-wins", () => {
547
+ it("9.15: late TUI response after dashboard answered — no error, bus keeps dashboard answer", async () => {
548
+ const stack = setupPromptBusStack({ hasUI: true });
549
+
550
+ const promise = stack.bus.request({ pipeline: "command", type: "select", question: "Q", options: ["A", "B"] });
551
+ const id = getPromptId(stack.connection);
552
+
553
+ // Dashboard answers first
554
+ stack.dashboardRespond(id, "A");
555
+ const result = await promise;
556
+ expect(result.answer).toBe("A");
557
+ expect(result.source).toBe("dashboard-default");
558
+
559
+ // TUI resolves later — should not throw or change anything
560
+ await vi.advanceTimersByTimeAsync(0);
561
+ // The TUI adapter's promise rejection (AbortError) is caught internally
562
+ });
563
+
564
+ it("9.16: late dashboard response after TUI answered — silently ignored", async () => {
565
+ const stack = setupPromptBusStack({ hasUI: true });
566
+
567
+ const promise = stack.bus.request({ pipeline: "command", type: "select", question: "Q", options: ["A", "B"] });
568
+ const id = getPromptId(stack.connection);
569
+
570
+ // TUI answers first
571
+ stack.tuiUi._resolve("select", "B");
572
+ await vi.advanceTimersByTimeAsync(0);
573
+
574
+ const result = await promise;
575
+ expect(result.answer).toBe("B");
576
+
577
+ // Late dashboard response — no error
578
+ stack.dashboardRespond(id, "A");
579
+ });
580
+ });
581
+
582
+ // ── 9.17: Timeout ──
583
+
584
+ describe("timeout cancels both UIs", () => {
585
+ it("9.17: timeout fires, TUI aborted and dashboard cancelled", async () => {
586
+ const stack = setupPromptBusStack({ hasUI: true });
587
+
588
+ const promise = stack.bus.request({ pipeline: "command", type: "select", question: "Q", options: ["A"] });
589
+
590
+ // Advance past timeout
591
+ vi.advanceTimersByTime(5000);
592
+ await vi.advanceTimersByTimeAsync(0);
593
+
594
+ const result = await promise;
595
+ expect(result.cancelled).toBe(true);
596
+
597
+ // TUI should be aborted
598
+ expect(stack.tuiUi._signal("select")?.aborted).toBe(true);
599
+
600
+ // Dashboard should get cancel
601
+ const cancels = stack.connection._messagesOfType("prompt_cancel");
602
+ expect(cancels).toHaveLength(1);
603
+ });
604
+ });
605
+
606
+ // ── 9.18–9.19: Degenerate modes ──
607
+
608
+ describe("degenerate modes", () => {
609
+ it("9.18: headless mode — prompt only reaches dashboard, no TUI mock called", async () => {
610
+ const stack = setupPromptBusStack({ hasUI: false });
611
+
612
+ const promise = stack.bus.request({ pipeline: "command", type: "select", question: "Pick:", options: ["A"] });
613
+ const id = getPromptId(stack.connection);
614
+
615
+ // TUI should NOT have been called
616
+ expect(stack.tuiUi.select).not.toHaveBeenCalled();
617
+
618
+ // Dashboard should have the request
619
+ expect(stack.connection._messagesOfType("prompt_request")).toHaveLength(1);
620
+
621
+ // Dashboard responds
622
+ stack.dashboardRespond(id, "A");
623
+ const result = await promise;
624
+ expect(result.answer).toBe("A");
625
+ });
626
+
627
+ it("9.19: no pi-flows adapters — only default generic-dialog", () => {
628
+ const stack = setupPromptBusStack({ hasUI: false, hasArchitect: false });
629
+
630
+ stack.bus.request({ pipeline: "command", type: "select", question: "Pick:", options: ["A", "B"] });
631
+
632
+ const requests = stack.connection._messagesOfType("prompt_request");
633
+ expect(requests).toHaveLength(1);
634
+ expect(requests[0].component.type).toBe("generic-dialog");
635
+ });
636
+ });
637
+
638
+ // ── 9.20–9.21: Concurrent prompts ──
639
+
640
+ describe("concurrent prompts from different pipelines", () => {
641
+ it("9.20: command + architect prompts wire independently to correct UIs", () => {
642
+ const stack = setupPromptBusStack({ hasUI: true, hasArchitect: true });
643
+
644
+ stack.bus.request({ pipeline: "command", type: "select", question: "Command Q", options: ["A"] });
645
+ stack.bus.request({ pipeline: "architect-edit", type: "select", question: "Architect Q", options: ["Save", "Cancel"] });
646
+
647
+ const requests = stack.connection._messagesOfType("prompt_request");
648
+ expect(requests).toHaveLength(2);
649
+
650
+ // Command prompt → generic-dialog
651
+ const commandReq = requests.find((r: any) => r.prompt.question === "Command Q");
652
+ expect(commandReq.component.type).toBe("generic-dialog");
653
+
654
+ // Architect prompt → architect-prompt widget bar
655
+ const archReq = requests.find((r: any) => r.prompt.question === "Architect Q");
656
+ expect(archReq.component.type).toBe("architect-prompt");
657
+ expect(archReq.placement).toBe("widget-bar");
658
+ });
659
+
660
+ it("9.21: answering one concurrent prompt does NOT dismiss the other", async () => {
661
+ const stack = setupPromptBusStack({ hasUI: true });
662
+
663
+ const promise1 = stack.bus.request({ pipeline: "command", type: "select", question: "Q1", options: ["A"] });
664
+ const promise2 = stack.bus.request({ pipeline: "command", type: "select", question: "Q2", options: ["B"] });
665
+
666
+ const ids = getPromptIds(stack.connection);
667
+ expect(ids).toHaveLength(2);
668
+
669
+ // Answer first prompt
670
+ stack.dashboardRespond(ids[0], "A");
671
+ const result1 = await promise1;
672
+ expect(result1.answer).toBe("A");
673
+
674
+ // Second prompt should still be pending
675
+ expect(stack.bus.pendingCount).toBe(1);
676
+
677
+ // Only first prompt's dismiss should be sent
678
+ const dismisses = stack.connection._messagesOfType("prompt_dismiss");
679
+ expect(dismisses.every((d: any) => d.promptId === ids[0])).toBe(true);
680
+
681
+ // Answer second prompt
682
+ stack.dashboardRespond(ids[1], "B");
683
+ const result2 = await promise2;
684
+ expect(result2.answer).toBe("B");
685
+ expect(stack.bus.pendingCount).toBe(0);
686
+ });
687
+ });
688
+
689
+ // ── Reconnect replay ──
690
+
691
+ describe("reconnect replay — getPendingRequests for bridge onReconnect", () => {
692
+ it("reconnect with pending prompt returns it for re-send", () => {
693
+ const stack = setupPromptBusStack({ hasUI: true });
694
+
695
+ stack.bus.request({ pipeline: "command", type: "select", question: "Pick:", options: ["A", "B"] });
696
+
697
+ // Simulate reconnect: bridge reads pending and re-sends
698
+ const pending = stack.bus.getPendingRequests();
699
+ expect(pending).toHaveLength(1);
700
+ expect(pending[0].request.question).toBe("Pick:");
701
+ expect(pending[0].component).toBeDefined();
702
+ expect(pending[0].placement).toBeDefined();
703
+
704
+ // Re-send (as bridge onReconnect would)
705
+ const beforeCount = stack.connection.send.mock.calls.length;
706
+ for (const { request, component, placement } of pending) {
707
+ stack.connection.send({
708
+ type: "prompt_request",
709
+ sessionId: "test-session",
710
+ promptId: request.id,
711
+ prompt: { question: request.question, type: request.type, options: request.options },
712
+ component,
713
+ placement,
714
+ });
715
+ }
716
+ // Should have sent exactly one re-send
717
+ const afterCount = stack.connection.send.mock.calls.length;
718
+ expect(afterCount - beforeCount).toBe(1);
719
+ });
720
+
721
+ it("reconnect with no pending prompts sends nothing", () => {
722
+ const stack = setupPromptBusStack({ hasUI: true });
723
+
724
+ const pending = stack.bus.getPendingRequests();
725
+ expect(pending).toEqual([]);
726
+ });
727
+
728
+ it("reconnect after TUI answered sends nothing", async () => {
729
+ const stack = setupPromptBusStack({ hasUI: true });
730
+
731
+ const promise = stack.bus.request({ pipeline: "command", type: "select", question: "Pick:", options: ["A"] });
732
+ const id = getPromptId(stack.connection);
733
+
734
+ // TUI answers
735
+ stack.tuiUi._resolve("select", "A");
736
+ await vi.advanceTimersByTimeAsync(0);
737
+ await promise;
738
+
739
+ // Pending should be empty
740
+ expect(stack.bus.getPendingRequests()).toEqual([]);
741
+ });
742
+
743
+ it("reconnect re-send uses stored component even if adapter was unregistered", () => {
744
+ const stack = setupPromptBusStack({ hasUI: true, hasArchitect: true });
745
+
746
+ stack.bus.request({
747
+ pipeline: "architect-edit",
748
+ type: "select",
749
+ question: "Save?",
750
+ options: ["Save", "Cancel"],
751
+ });
752
+
753
+ // Verify original sent architect-prompt
754
+ const origReqs = stack.connection._messagesOfType("prompt_request");
755
+ expect(origReqs[0].component.type).toBe("architect-prompt");
756
+
757
+ // Unregister the architect adapter (simulating reload or deactivation)
758
+ // Since we can't easily unregister by name, verify getPendingRequests still has the component
759
+ const pending = stack.bus.getPendingRequests();
760
+ expect(pending).toHaveLength(1);
761
+ expect(pending[0].component.type).toBe("architect-prompt");
762
+ expect(pending[0].placement).toBe("widget-bar");
763
+ });
764
+ });
765
+
766
+ // ── 9.22: Extension reload ──
767
+
768
+ describe("extension reload", () => {
769
+ it("9.22: adapter re-registration replaces old adapter", () => {
770
+ const stack = setupPromptBusStack({ hasUI: true });
771
+
772
+ // Register a new TUI adapter (simulating reload)
773
+ const newTuiUi = createMockTuiUi();
774
+ const newTuiAdapter = createTuiPromptAdapter(newTuiUi, stack.bus);
775
+ stack.bus.registerAdapter(newTuiAdapter);
776
+
777
+ // New prompt should use new adapter
778
+ stack.bus.request({ pipeline: "command", type: "select", question: "After reload", options: ["X"] });
779
+
780
+ // Old TUI should NOT have been called
781
+ expect(stack.tuiUi.select).not.toHaveBeenCalledWith("After reload", expect.anything(), expect.anything());
782
+
783
+ // New TUI should have been called
784
+ expect(newTuiUi.select).toHaveBeenCalledWith(
785
+ "After reload",
786
+ ["X"],
787
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
788
+ );
789
+ });
790
+ });
791
+ });