@kata-sh/pi-symphony-extension 0.1.0

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.
@@ -0,0 +1,873 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { handleActiveConsoleShortcut, openConsole, SymphonyConsoleComponent } from "./console.ts";
4
+ import { startSymphonyEventStream } from "./event-stream.ts";
5
+ import type { SymphonyEventEnvelope, SymphonyStateResponse } from "./http-client.ts";
6
+ import type { SymphonyRuntime } from "./runtime.ts";
7
+ import { createDefaultState } from "./state.ts";
8
+
9
+ vi.mock("./event-stream.ts", () => ({
10
+ startSymphonyEventStream: vi.fn(),
11
+ }));
12
+
13
+ beforeEach(() => {
14
+ vi.mocked(startSymphonyEventStream).mockReset();
15
+ vi.mocked(startSymphonyEventStream).mockImplementation(() => ({ close: vi.fn() }));
16
+ });
17
+
18
+ function workerStateFixture(): SymphonyStateResponse {
19
+ return {
20
+ tracker_project_url: "https://linear.app/kata-sh/project/symphony",
21
+ running: {
22
+ "issue-123": {
23
+ issue_id: "issue-123",
24
+ issue_identifier: "SIM-123",
25
+ issue_title: "Worker one",
26
+ attempt: 2,
27
+ workspace_path: "/tmp/symphony/issue-123",
28
+ started_at: "2026-05-14T12:00:00Z",
29
+ status: "running",
30
+ worker_host: "worker-a",
31
+ tracker_state: "In Progress",
32
+ },
33
+ "issue-777": {
34
+ issue_id: "issue-777",
35
+ issue_identifier: "SIM-777",
36
+ issue_title: "Worker two",
37
+ workspace_path: "/tmp/symphony/issue-777",
38
+ started_at: "2026-05-14T12:05:00Z",
39
+ status: "running",
40
+ error: "usage limit",
41
+ tracker_state: "Agent Review",
42
+ },
43
+ },
44
+ running_sessions: {
45
+ "issue-123": {
46
+ turn_count: 2,
47
+ last_activity_at: "2026-05-14T12:03:00Z",
48
+ last_event: "tool_call_completed",
49
+ last_event_message: "running cargo test",
50
+ },
51
+ },
52
+ running_session_info: {
53
+ "issue-123": { turn_count: 3, max_turns: 20, last_activity_ms: Date.parse("2026-05-14T12:04:00Z"), last_error: null },
54
+ "issue-777": { turn_count: 1, max_turns: 10, last_activity_ms: null, last_error: "usage limit" },
55
+ },
56
+ retry_queue: [{ issue_id: "issue-retry", identifier: "SIM-200", attempt: 3, due_in_ms: 90000, error: "rate limit", worker_host: "host-b", workspace_path: "/tmp/retry" }],
57
+ blocked: [{ issue_id: "issue-blocked", identifier: "SIM-300", title: "Blocked work", state: "Todo", blocker_identifiers: ["SIM-100", "SIM-101"] }],
58
+ pending_escalations: [{ request_id: "esc-1", issue_id: "issue-123", issue_identifier: "SIM-123", method: "approval", preview: "Approve cargo test?", created_at: "2026-05-14T12:06:00Z", timeout_ms: 600000 }],
59
+ completed: [{ issue_id: "issue-done", identifier: "SIM-400", title: "Done work", completed_at: "2026-05-14T13:00:00Z" }],
60
+ polling: { checking: false, next_poll_in_ms: 1000, poll_interval_ms: 30000 },
61
+ shared_context: { total_entries: 0, entries_by_scope: {}, oldest_entry_at: null, newest_entry_at: null },
62
+ supervisor: { active: true, steers_issued: 0, conflicts_detected: 0, patterns_detected: 0, escalations_created: 0 },
63
+ codex_totals: { input_tokens: 0, output_tokens: 0, total_tokens: 0, event_count: 0, seconds_running: 0 },
64
+ codex_rate_limits: null,
65
+ };
66
+ }
67
+
68
+ function runtimeEventsFixture(): SymphonyEventEnvelope[] {
69
+ return [
70
+ {
71
+ version: "v1",
72
+ sequence: 1,
73
+ timestamp: "2026-05-14T12:01:00Z",
74
+ kind: "runtime",
75
+ severity: "info",
76
+ event: "poll_completed",
77
+ payload: { summary: "checked tracker" },
78
+ },
79
+ {
80
+ version: "v1",
81
+ sequence: 2,
82
+ timestamp: "2026-05-14T12:02:00Z",
83
+ kind: "worker",
84
+ severity: "error",
85
+ issue: "SIM-777",
86
+ event: "worker_failed",
87
+ payload: { error_preview: "usage limit" },
88
+ },
89
+ ];
90
+ }
91
+
92
+ function fakeTheme() {
93
+ return {
94
+ fg: (color: string, text: string) => `<${color}>${text}</${color}>`,
95
+ bg: (color: string, text: string) => `<bg:${color}>${text}</bg:${color}>`,
96
+ bold: (text: string) => `<bold>${text}</bold>`,
97
+ };
98
+ }
99
+
100
+ describe("SymphonyConsoleComponent", () => {
101
+ it("renders Slice 1 health fields", () => {
102
+ const state = createDefaultState();
103
+ state.attachedBaseUrl = "http://127.0.0.1:8080";
104
+ state.ownedProcess = { pid: 123, command: "symphony --no-tui", cwd: "/repo", baseUrl: state.attachedBaseUrl, startedAt: "2026-05-14T00:00:00Z" };
105
+ state.lastKnownState = {
106
+ baseUrl: state.attachedBaseUrl,
107
+ trackerProjectUrl: "https://github.com/gannonh/kata/projects/1",
108
+ runningCount: 2,
109
+ retryCount: 1,
110
+ blockedCount: 0,
111
+ completedCount: 4,
112
+ pollingChecking: false,
113
+ nextPollInMs: 5000,
114
+ updatedAt: "2026-05-14T00:00:01Z",
115
+ };
116
+
117
+ const consoleComponent = new SymphonyConsoleComponent({
118
+ state,
119
+ getState: () => undefined,
120
+ getEvents: () => [],
121
+ refresh: async () => undefined,
122
+ steer: async () => undefined,
123
+ respondToEscalation: async () => undefined,
124
+ prompt: async () => undefined,
125
+ close: () => undefined,
126
+ requestRender: () => undefined,
127
+ notify: () => undefined,
128
+ });
129
+
130
+ const output = consoleComponent.render(120).join("\n");
131
+ expect(output).toContain("Symphony Console");
132
+ expect(output).toContain("dashboard: http://127.0.0.1:8080");
133
+ expect(output).toContain("project: https://github.com/gannonh/kata/projects/1");
134
+ expect(output).toContain("running: 2");
135
+ expect(output).toContain("retry: 1");
136
+ expect(output).toContain("owned process: pid 123");
137
+ });
138
+
139
+ it("renders running workers, selected issue details, and recent runtime events", () => {
140
+ const state = createDefaultState();
141
+ state.console.showDetails = true;
142
+ const consoleComponent = new SymphonyConsoleComponent({
143
+ state,
144
+ getState: () => workerStateFixture(),
145
+ getEvents: () => runtimeEventsFixture(),
146
+ refresh: async () => undefined,
147
+ steer: async () => undefined,
148
+ respondToEscalation: async () => undefined,
149
+ prompt: async () => undefined,
150
+ close: () => undefined,
151
+ requestRender: () => undefined,
152
+ notify: () => undefined,
153
+ });
154
+
155
+ const output = consoleComponent.render(160).join("\n");
156
+
157
+ expect(output).toContain("Running Workers");
158
+ expect(output).toContain("> SIM-123");
159
+ expect(output).toContain("SIM-777");
160
+ expect(output).toContain("Selected Issue");
161
+ expect(output).toContain("issue: SIM-123 Worker one");
162
+ expect(output).toContain("tracker state: In Progress");
163
+ expect(output).toContain("attempt: 2");
164
+ expect(output).toContain("turns: 3 / 20");
165
+ expect(output).toContain("last activity: 2026-05-14T12:04:00.000Z");
166
+ expect(output).toContain("worker host: worker-a");
167
+ expect(output).toContain("workspace: /tmp/symphony/issue-123");
168
+ expect(output).toContain("Events");
169
+ expect(output).toContain("worker_failed usage limit");
170
+ });
171
+
172
+ it("renders retry, blocked, completed, and selected issue sections", () => {
173
+ const state = createDefaultState();
174
+ state.console.showDetails = true;
175
+ const consoleComponent = new SymphonyConsoleComponent({
176
+ state,
177
+ getState: () => workerStateFixture(),
178
+ getEvents: () => [],
179
+ refresh: async () => undefined,
180
+ steer: async () => undefined,
181
+ respondToEscalation: async () => undefined,
182
+ prompt: async () => undefined,
183
+ close: () => undefined,
184
+ requestRender: () => undefined,
185
+ notify: () => undefined,
186
+ });
187
+
188
+ const output = consoleComponent.render(180).join("\n");
189
+
190
+ expect(output).toContain("Retry Queue");
191
+ expect(output).toContain("Blocked Issues");
192
+ expect(output).toContain("Completed Issues");
193
+ expect(output).toContain("Selected Issue");
194
+ expect(output).toContain("SIM-200");
195
+ expect(output).toContain("retry in 1m 30s");
196
+ expect(output).toContain("SIM-300");
197
+ expect(output).toContain("SIM-100, SIM-101");
198
+ expect(output).toContain("SIM-400");
199
+ expect(output).toContain("2026-05-14T13:00:00Z");
200
+ });
201
+
202
+ it("moves selection across retry, blocked, and completed rows and limits steering to running rows", () => {
203
+ const state = createDefaultState();
204
+ state.console.showDetails = true;
205
+ const steer = vi.fn(async () => undefined);
206
+ const notify = vi.fn();
207
+ const consoleComponent = new SymphonyConsoleComponent({
208
+ state,
209
+ getState: () => workerStateFixture(),
210
+ getEvents: () => [],
211
+ refresh: async () => undefined,
212
+ steer,
213
+ respondToEscalation: async () => undefined,
214
+ prompt: async () => "not used",
215
+ close: () => undefined,
216
+ requestRender: () => undefined,
217
+ notify,
218
+ });
219
+
220
+ consoleComponent.handleInput("\u001b[B");
221
+ consoleComponent.handleInput("\u001b[B");
222
+ let output = consoleComponent.render(180).join("\n");
223
+ expect(output).toContain("> SIM-200");
224
+ expect(output).toContain("issue: SIM-200 rate limit");
225
+ expect(output).toContain("kind: retry");
226
+ expect(output).toContain("status: retry in 1m 30s");
227
+ expect(output).toContain("workspace: /tmp/retry");
228
+
229
+ consoleComponent.handleInput("s");
230
+ expect(steer).not.toHaveBeenCalled();
231
+ expect(notify).toHaveBeenCalledWith("Select a running worker before steering", "warning");
232
+
233
+ consoleComponent.handleInput("\u001b[B");
234
+ output = consoleComponent.render(180).join("\n");
235
+ expect(output).toContain("> SIM-300");
236
+ expect(output).toContain("issue: SIM-300 Blocked work");
237
+ expect(output).toContain("kind: blocked");
238
+ expect(output).toContain("blockers: SIM-100, SIM-101");
239
+
240
+ consoleComponent.handleInput("\u001b[B");
241
+ output = consoleComponent.render(180).join("\n");
242
+ expect(output).toContain("> SIM-400");
243
+ expect(output).toContain("issue: SIM-400 Done work");
244
+ expect(output).toContain("kind: completed");
245
+ expect(output).toContain("completed at: 2026-05-14T13:00:00Z");
246
+ });
247
+
248
+ it("renders pending escalations and selected escalation details", () => {
249
+ const state = createDefaultState();
250
+ state.console.showDetails = true;
251
+ const consoleComponent = new SymphonyConsoleComponent({
252
+ state,
253
+ getState: () => workerStateFixture(),
254
+ getEvents: () => [],
255
+ refresh: async () => undefined,
256
+ steer: async () => undefined,
257
+ respondToEscalation: async () => undefined,
258
+ prompt: async () => undefined,
259
+ close: () => undefined,
260
+ requestRender: () => undefined,
261
+ notify: () => undefined,
262
+ });
263
+
264
+ consoleComponent.handleInput("\u001b[B");
265
+ consoleComponent.handleInput("\u001b[B");
266
+ consoleComponent.handleInput("\u001b[B");
267
+ consoleComponent.handleInput("\u001b[B");
268
+ consoleComponent.handleInput("\u001b[B");
269
+
270
+ const output = consoleComponent.render(180).join("\n");
271
+ expect(output).toContain("Pending Escalations");
272
+ expect(output).toContain("> esc-1");
273
+ expect(output).toContain("SIM-123");
274
+ expect(output).toContain("approval");
275
+ expect(output).toContain("Approve cargo test?");
276
+ expect(output).toContain("Selected Escalation");
277
+ expect(output).toContain("request: esc-1");
278
+ expect(output).toContain("timeout: 10m 0s");
279
+ });
280
+
281
+ it("expands keyboard shortcuts onto one row when the terminal is wide", () => {
282
+ const state = createDefaultState();
283
+ state.attachedBaseUrl = "http://127.0.0.1:8080";
284
+ const consoleComponent = new SymphonyConsoleComponent({
285
+ state,
286
+ getState: () => workerStateFixture(),
287
+ getEvents: () => [],
288
+ refresh: async () => undefined,
289
+ steer: async () => undefined,
290
+ respondToEscalation: async () => undefined,
291
+ prompt: async () => undefined,
292
+ close: () => undefined,
293
+ requestRender: () => undefined,
294
+ notify: () => undefined,
295
+ });
296
+
297
+ const output = consoleComponent.render(220).join("\n");
298
+
299
+ expect(output).toContain("Keyboard: ctrl+shift+↑/↓ select • ctrl+shift+r refresh • ctrl+shift+t steer • ctrl+shift+e escalation • ctrl+shift+i details • ctrl+shift+q close");
300
+ });
301
+
302
+ it("renders boxed colored sections and command actions for console readability", () => {
303
+ const state = createDefaultState();
304
+ state.attachedBaseUrl = "http://127.0.0.1:8080";
305
+ state.lastKnownState = {
306
+ baseUrl: state.attachedBaseUrl,
307
+ trackerProjectUrl: "https://github.com/gannonh/kata/projects/1",
308
+ runningCount: 2,
309
+ retryCount: 1,
310
+ blockedCount: 1,
311
+ completedCount: 4,
312
+ pollingChecking: false,
313
+ nextPollInMs: 5000,
314
+ updatedAt: "2026-05-14T00:00:01Z",
315
+ };
316
+ const consoleComponent = new SymphonyConsoleComponent({
317
+ state,
318
+ getState: () => workerStateFixture(),
319
+ getEvents: () => runtimeEventsFixture(),
320
+ refresh: async () => undefined,
321
+ steer: async () => undefined,
322
+ respondToEscalation: async () => undefined,
323
+ prompt: async () => undefined,
324
+ close: () => undefined,
325
+ requestRender: () => undefined,
326
+ notify: () => undefined,
327
+ theme: fakeTheme(),
328
+ });
329
+
330
+ const output = consoleComponent.render(220).join("\n");
331
+
332
+ expect(output).toContain("┌");
333
+ expect(output).toContain("└");
334
+ expect(output).toContain("Status");
335
+ expect(output).toContain("Worker Summary");
336
+ expect(output).toContain("Running Workers");
337
+ expect(output).toContain("Selected Issue");
338
+ expect(output).toContain("Events");
339
+ expect(output).toContain("Keyboard");
340
+ expect(output).toContain("ctrl+shift+↑/↓ select");
341
+ expect(output).toContain("ctrl+shift+r refresh");
342
+ expect(output).toContain("ctrl+shift+t steer");
343
+ expect(output).toContain("ctrl+shift+e escalation");
344
+ expect(output).toContain("ctrl+shift+i details");
345
+ expect(output).toContain("ctrl+shift+q close");
346
+ expect(output).toContain("Keyboard: ctrl+shift+↑/↓ select • ctrl+shift+r refresh • ctrl+shift+t steer • ctrl+shift+e escalation • ctrl+shift+i details • ctrl+shift+q close");
347
+ expect(output).toContain("<borderAccent>┌</borderAccent>");
348
+ expect(output).toContain("<success>running: 2</success>");
349
+ expect(output).toContain("<warning>retry: 1</warning>");
350
+ expect(output).toContain("<error>blocked: 1</error>");
351
+ expect(output).toContain("<bg:selectedBg><accent><bold>> SIM-123");
352
+ expect(output).not.toContain("Running workers");
353
+ expect(output).not.toContain("Selected worker");
354
+ expect(output).not.toContain("Recent worker/runtime events");
355
+ });
356
+
357
+ it("moves selection with arrow keys", () => {
358
+ const state = createDefaultState();
359
+ const consoleComponent = new SymphonyConsoleComponent({
360
+ state,
361
+ getState: () => workerStateFixture(),
362
+ getEvents: () => [],
363
+ refresh: async () => undefined,
364
+ steer: async () => undefined,
365
+ respondToEscalation: async () => undefined,
366
+ prompt: async () => undefined,
367
+ close: () => undefined,
368
+ requestRender: () => undefined,
369
+ notify: () => undefined,
370
+ });
371
+
372
+ consoleComponent.handleInput("\u001b[B");
373
+
374
+ const output = consoleComponent.render(160).join("\n");
375
+ expect(output).toContain("> SIM-777");
376
+ expect(output).toContain("issue: SIM-777 Worker two");
377
+ });
378
+
379
+ it("toggles selected issue details", () => {
380
+ const state = createDefaultState();
381
+ state.console.showDetails = true;
382
+ const consoleComponent = new SymphonyConsoleComponent({
383
+ state,
384
+ getState: () => workerStateFixture(),
385
+ getEvents: () => [],
386
+ refresh: async () => undefined,
387
+ steer: async () => undefined,
388
+ respondToEscalation: async () => undefined,
389
+ prompt: async () => undefined,
390
+ close: () => undefined,
391
+ requestRender: () => undefined,
392
+ notify: () => undefined,
393
+ });
394
+
395
+ consoleComponent.handleInput("d");
396
+
397
+ expect(consoleComponent.render(160).join("\n")).not.toContain("issue: SIM-123 Worker one");
398
+ });
399
+
400
+ it("prompts for a steer instruction and sends it to the selected worker", async () => {
401
+ let resolveSteered: (() => void) | undefined;
402
+ const steered = new Promise<void>((resolve) => {
403
+ resolveSteered = resolve;
404
+ });
405
+ const steer = vi.fn(async () => {
406
+ resolveSteered?.();
407
+ });
408
+ let resolveNotified: (() => void) | undefined;
409
+ const notified = new Promise<void>((resolve) => {
410
+ resolveNotified = resolve;
411
+ });
412
+ const notify = vi.fn(() => {
413
+ resolveNotified?.();
414
+ });
415
+ const consoleComponent = new SymphonyConsoleComponent({
416
+ state: createDefaultState(),
417
+ getState: () => workerStateFixture(),
418
+ getEvents: () => [],
419
+ refresh: async () => undefined,
420
+ steer,
421
+ respondToEscalation: async () => undefined,
422
+ prompt: async () => "Use the existing auth module",
423
+ close: () => undefined,
424
+ requestRender: () => undefined,
425
+ notify,
426
+ });
427
+
428
+ consoleComponent.handleInput("s");
429
+ await steered;
430
+ await notified;
431
+
432
+ expect(steer).toHaveBeenCalledWith("SIM-123", "Use the existing auth module");
433
+ expect(notify).toHaveBeenCalledWith("Steer delivered to SIM-123", "info");
434
+ });
435
+
436
+ it("responds to the selected escalation with parsed JSON input", async () => {
437
+ let resolveResponded: (() => void) | undefined;
438
+ const responded = new Promise<void>((resolve) => {
439
+ resolveResponded = resolve;
440
+ });
441
+ const respondToEscalation = vi.fn(async () => {
442
+ resolveResponded?.();
443
+ });
444
+ let resolveNotified: (() => void) | undefined;
445
+ const notified = new Promise<void>((resolve) => {
446
+ resolveNotified = resolve;
447
+ });
448
+ const notify = vi.fn(() => {
449
+ resolveNotified?.();
450
+ });
451
+ const consoleComponent = new SymphonyConsoleComponent({
452
+ state: createDefaultState(),
453
+ getState: () => workerStateFixture(),
454
+ getEvents: () => [],
455
+ refresh: async () => undefined,
456
+ steer: async () => undefined,
457
+ respondToEscalation,
458
+ prompt: async () => '{"approved":true}',
459
+ close: () => undefined,
460
+ requestRender: () => undefined,
461
+ notify,
462
+ });
463
+
464
+ for (let index = 0; index < 5; index += 1) consoleComponent.handleInput("\u001b[B");
465
+ consoleComponent.handleInput("e");
466
+ await responded;
467
+ await notified;
468
+
469
+ expect(respondToEscalation).toHaveBeenCalledWith("esc-1", { approved: true });
470
+ expect(notify).toHaveBeenCalledWith("Escalation response sent for esc-1", "info");
471
+ });
472
+
473
+ it("responds to the selected escalation with plain text when input is not JSON", async () => {
474
+ let resolveResponded: (() => void) | undefined;
475
+ const responded = new Promise<void>((resolve) => {
476
+ resolveResponded = resolve;
477
+ });
478
+ const respondToEscalation = vi.fn(async () => {
479
+ resolveResponded?.();
480
+ });
481
+ const consoleComponent = new SymphonyConsoleComponent({
482
+ state: createDefaultState(),
483
+ getState: () => workerStateFixture(),
484
+ getEvents: () => [],
485
+ refresh: async () => undefined,
486
+ steer: async () => undefined,
487
+ respondToEscalation,
488
+ prompt: async () => "approved",
489
+ close: () => undefined,
490
+ requestRender: () => undefined,
491
+ notify: () => undefined,
492
+ });
493
+
494
+ for (let index = 0; index < 5; index += 1) consoleComponent.handleInput("\u001b[B");
495
+ consoleComponent.handleInput("e");
496
+ await responded;
497
+
498
+ expect(respondToEscalation).toHaveBeenCalledWith("esc-1", "approved");
499
+ });
500
+
501
+ it("cancels escalation response when prompt returns only whitespace", async () => {
502
+ const respondToEscalation = vi.fn(async () => undefined);
503
+ const requestRender = vi.fn();
504
+ const consoleComponent = new SymphonyConsoleComponent({
505
+ state: createDefaultState(),
506
+ getState: () => workerStateFixture(),
507
+ getEvents: () => [],
508
+ refresh: async () => undefined,
509
+ steer: async () => undefined,
510
+ respondToEscalation,
511
+ prompt: async () => " ",
512
+ close: () => undefined,
513
+ requestRender,
514
+ notify: () => undefined,
515
+ });
516
+
517
+ for (let index = 0; index < 5; index += 1) consoleComponent.handleInput("\u001b[B");
518
+ consoleComponent.handleInput("e");
519
+ await expect.poll(() => requestRender.mock.calls.length, { interval: 10, timeout: 1000 }).toBeGreaterThan(0);
520
+
521
+ expect(respondToEscalation).not.toHaveBeenCalled();
522
+ });
523
+
524
+ it.each(["{approved: true}", "\"approved"])("rejects malformed JSON-like escalation response before sending: %s", async (input) => {
525
+ const respondToEscalation = vi.fn(async () => undefined);
526
+ const notify = vi.fn();
527
+ const consoleComponent = new SymphonyConsoleComponent({
528
+ state: createDefaultState(),
529
+ getState: () => workerStateFixture(),
530
+ getEvents: () => [],
531
+ refresh: async () => undefined,
532
+ steer: async () => undefined,
533
+ respondToEscalation,
534
+ prompt: async () => input,
535
+ close: () => undefined,
536
+ requestRender: () => undefined,
537
+ notify,
538
+ });
539
+
540
+ for (let index = 0; index < 5; index += 1) consoleComponent.handleInput("\u001b[B");
541
+ consoleComponent.handleInput("e");
542
+ await expect.poll(() => notify.mock.calls.length, { interval: 10, timeout: 1000 }).toBe(1);
543
+
544
+ expect(respondToEscalation).not.toHaveBeenCalled();
545
+ expect(notify).toHaveBeenCalledWith("Escalation response must be valid JSON or plain text", "error");
546
+ });
547
+
548
+ it("notifies when escalation response is requested without a selected escalation", () => {
549
+ const notify = vi.fn();
550
+ const consoleComponent = new SymphonyConsoleComponent({
551
+ state: createDefaultState(),
552
+ getState: () => workerStateFixture(),
553
+ getEvents: () => [],
554
+ refresh: async () => undefined,
555
+ steer: async () => undefined,
556
+ respondToEscalation: async () => undefined,
557
+ prompt: async () => undefined,
558
+ close: () => undefined,
559
+ requestRender: () => undefined,
560
+ notify,
561
+ });
562
+
563
+ consoleComponent.handleInput("e");
564
+
565
+ expect(notify).toHaveBeenCalledWith("Select an escalation before responding", "warning");
566
+ });
567
+
568
+ it("notifies and requests render when steering prompt fails", async () => {
569
+ const notify = vi.fn();
570
+ const requestRender = vi.fn();
571
+ const consoleComponent = new SymphonyConsoleComponent({
572
+ state: createDefaultState(),
573
+ getState: () => workerStateFixture(),
574
+ getEvents: () => [],
575
+ refresh: async () => undefined,
576
+ steer: async () => undefined,
577
+ respondToEscalation: async () => undefined,
578
+ prompt: async () => {
579
+ throw new Error("prompt failed");
580
+ },
581
+ close: () => undefined,
582
+ requestRender,
583
+ notify,
584
+ });
585
+
586
+ consoleComponent.handleInput("s");
587
+ await expect.poll(() => notify.mock.calls.length, { interval: 10, timeout: 1000 }).toBe(1);
588
+
589
+ expect(notify).toHaveBeenCalledWith("prompt failed", "error");
590
+ expect(requestRender).toHaveBeenCalledOnce();
591
+ });
592
+
593
+ it("closes on q", () => {
594
+ const close = vi.fn();
595
+ const consoleComponent = new SymphonyConsoleComponent({
596
+ state: createDefaultState(),
597
+ getState: () => undefined,
598
+ getEvents: () => [],
599
+ refresh: async () => undefined,
600
+ steer: async () => undefined,
601
+ respondToEscalation: async () => undefined,
602
+ prompt: async () => undefined,
603
+ close,
604
+ requestRender: () => undefined,
605
+ notify: () => undefined,
606
+ });
607
+
608
+ consoleComponent.handleInput("q");
609
+ expect(close).toHaveBeenCalledOnce();
610
+ });
611
+
612
+ it("ignores refresh input while a refresh is already running", async () => {
613
+ let resolveRefresh: (() => void) | undefined;
614
+ const refreshDone = new Promise<void>((resolve) => {
615
+ resolveRefresh = resolve;
616
+ });
617
+ const refresh = vi.fn(() => refreshDone);
618
+ const consoleComponent = new SymphonyConsoleComponent({
619
+ state: createDefaultState(),
620
+ getState: () => undefined,
621
+ getEvents: () => [],
622
+ refresh,
623
+ steer: async () => undefined,
624
+ respondToEscalation: async () => undefined,
625
+ prompt: async () => undefined,
626
+ close: () => undefined,
627
+ requestRender: () => undefined,
628
+ notify: () => undefined,
629
+ });
630
+
631
+ consoleComponent.handleInput("r");
632
+ consoleComponent.handleInput("r");
633
+
634
+ expect(refresh).toHaveBeenCalledOnce();
635
+ resolveRefresh?.();
636
+ await refreshDone;
637
+ });
638
+
639
+ it("notifies when a refresh fails", async () => {
640
+ let resolveNotified: (() => void) | undefined;
641
+ const notified = new Promise<void>((resolve) => {
642
+ resolveNotified = resolve;
643
+ });
644
+ const notify = vi.fn((_message: string, _level: "info" | "warning" | "error") => {
645
+ resolveNotified?.();
646
+ });
647
+ const consoleComponent = new SymphonyConsoleComponent({
648
+ state: createDefaultState(),
649
+ getState: () => undefined,
650
+ getEvents: () => [],
651
+ refresh: async () => {
652
+ throw new Error("refresh failed");
653
+ },
654
+ steer: async () => undefined,
655
+ respondToEscalation: async () => undefined,
656
+ prompt: async () => undefined,
657
+ close: () => undefined,
658
+ requestRender: () => undefined,
659
+ notify,
660
+ });
661
+
662
+ consoleComponent.handleInput("r");
663
+ await notified;
664
+
665
+ expect(notify).toHaveBeenCalledWith("refresh failed", "error");
666
+ });
667
+ });
668
+
669
+ describe("openConsole", () => {
670
+ it("renders as an above-editor widget and refreshes stale state from events", async () => {
671
+ const state = createDefaultState();
672
+ state.attachedBaseUrl = "http://127.0.0.1:8080";
673
+ state.lastKnownState = {
674
+ baseUrl: state.attachedBaseUrl,
675
+ runningCount: 0,
676
+ retryCount: 0,
677
+ blockedCount: 0,
678
+ completedCount: 0,
679
+ pollingChecking: false,
680
+ nextPollInMs: 1000,
681
+ updatedAt: "2026-05-14T12:00:00Z",
682
+ };
683
+
684
+ let capturedOnEvent: ((event: SymphonyEventEnvelope) => void) | undefined;
685
+ vi.mocked(startSymphonyEventStream).mockImplementation((options) => {
686
+ capturedOnEvent = options.onEvent;
687
+ return { close: vi.fn() };
688
+ });
689
+
690
+ const requestRender = vi.fn();
691
+ const custom = vi.fn();
692
+ const setWidget = vi.fn((_key: string, factory: unknown) => {
693
+ const component = (factory as (tui: { requestRender: () => void }, theme: ReturnType<typeof fakeTheme>) => SymphonyConsoleComponent)({ requestRender }, fakeTheme());
694
+ capturedOnEvent?.({
695
+ version: "v1",
696
+ sequence: 1,
697
+ timestamp: "2026-05-14T12:00:00Z",
698
+ kind: "worker",
699
+ severity: "info",
700
+ issue: "SIM-123",
701
+ event: "worker_started",
702
+ payload: {},
703
+ });
704
+ return component;
705
+ });
706
+ const ctx = { ui: { notify: vi.fn(), custom, input: vi.fn(), setWidget } } as unknown as ExtensionContext;
707
+ const refreshState = vi.fn(async function (this: { lastState?: SymphonyStateResponse }) {
708
+ this.lastState = workerStateFixture();
709
+ return this.lastState;
710
+ });
711
+ const runtime = {
712
+ client: {},
713
+ state,
714
+ lastState: undefined,
715
+ recentEvents: [],
716
+ recordEvent: vi.fn(function (this: { recentEvents: SymphonyEventEnvelope[] }, event: SymphonyEventEnvelope) {
717
+ this.recentEvents.push(event);
718
+ }),
719
+ requestRefresh: vi.fn(async () => undefined),
720
+ refreshState,
721
+ steerWorker: vi.fn(async () => undefined),
722
+ errorText: vi.fn((error: unknown) => (error instanceof Error ? error.message : String(error))),
723
+ } as unknown as SymphonyRuntime;
724
+
725
+ await openConsole(ctx, runtime);
726
+
727
+ expect(custom).not.toHaveBeenCalled();
728
+ expect(setWidget).toHaveBeenCalledWith("symphony-console", expect.any(Function));
729
+ expect(startSymphonyEventStream).toHaveBeenCalledWith(expect.objectContaining({ baseUrl: "http://127.0.0.1:8080" }));
730
+ await expect.poll(() => refreshState.mock.calls.length, { interval: 10, timeout: 1000 }).toBe(2);
731
+ expect(requestRender).toHaveBeenCalled();
732
+ });
733
+
734
+ it("reports stream close errors after malformed stream messages", async () => {
735
+ const state = createDefaultState();
736
+ state.attachedBaseUrl = "http://127.0.0.1:8080";
737
+
738
+ let capturedOnError: ((error: Error) => void) | undefined;
739
+ vi.mocked(startSymphonyEventStream).mockImplementation((options) => {
740
+ capturedOnError = options.onError;
741
+ return { close: vi.fn() };
742
+ });
743
+
744
+ const notify = vi.fn();
745
+ const setWidget = vi.fn((_key: string, factory: unknown) => {
746
+ (factory as (tui: { requestRender: () => void }, theme: ReturnType<typeof fakeTheme>) => SymphonyConsoleComponent)({ requestRender: vi.fn() }, fakeTheme());
747
+ });
748
+ const ctx = { ui: { notify, custom: vi.fn(), input: vi.fn(), setWidget } } as unknown as ExtensionContext;
749
+ const runtime = {
750
+ client: {},
751
+ state,
752
+ lastState: workerStateFixture(),
753
+ recentEvents: [],
754
+ refreshState: vi.fn(async () => workerStateFixture()),
755
+ requestRefresh: vi.fn(async () => workerStateFixture()),
756
+ steerWorker: vi.fn(async () => undefined),
757
+ errorText: vi.fn((error: unknown) => (error instanceof Error ? error.message : String(error))),
758
+ } as unknown as SymphonyRuntime;
759
+
760
+ await openConsole(ctx, runtime);
761
+ capturedOnError?.(new Error("invalid Symphony event JSON"));
762
+ capturedOnError?.(new Error("Symphony event stream closed with code 1006"));
763
+
764
+ expect(notify).toHaveBeenCalledWith("Symphony event stream unavailable: invalid Symphony event JSON", "warning");
765
+ expect(notify).toHaveBeenCalledWith("Symphony event stream unavailable: Symphony event stream closed with code 1006", "warning");
766
+ });
767
+
768
+ it("lets global shortcuts control the active above-editor console", async () => {
769
+ const state = createDefaultState();
770
+ state.attachedBaseUrl = "http://127.0.0.1:8080";
771
+ state.console.showDetails = true;
772
+
773
+ vi.mocked(startSymphonyEventStream).mockImplementation(() => ({ close: vi.fn() }));
774
+
775
+ let component: SymphonyConsoleComponent | undefined;
776
+ const requestRender = vi.fn();
777
+ const setWidget = vi.fn((_key: string, factory: unknown) => {
778
+ component = (factory as (tui: { requestRender: () => void }, theme: ReturnType<typeof fakeTheme>) => SymphonyConsoleComponent)({ requestRender }, fakeTheme());
779
+ });
780
+ const ctx = { ui: { notify: vi.fn(), custom: vi.fn(), input: vi.fn(), setWidget } } as unknown as ExtensionContext;
781
+ const runtime = {
782
+ client: {},
783
+ state,
784
+ lastState: workerStateFixture(),
785
+ recentEvents: [],
786
+ refreshState: vi.fn(async () => workerStateFixture()),
787
+ requestRefresh: vi.fn(async () => workerStateFixture()),
788
+ steerWorker: vi.fn(async () => undefined),
789
+ respondToEscalation: vi.fn(async () => undefined),
790
+ errorText: vi.fn((error: unknown) => (error instanceof Error ? error.message : String(error))),
791
+ } as unknown as SymphonyRuntime;
792
+
793
+ await openConsole(ctx, runtime);
794
+ await handleActiveConsoleShortcut("selectNext", ctx);
795
+ await handleActiveConsoleShortcut("toggleDetails", ctx);
796
+
797
+ const output = component?.render(160).join("\n") ?? "";
798
+ expect(output).toContain("> SIM-777");
799
+ expect(output).not.toContain("issue: <accent>SIM-777</accent> Worker two");
800
+ expect(requestRender).toHaveBeenCalled();
801
+ });
802
+
803
+ it("lets a global shortcut respond to the selected active-console escalation", async () => {
804
+ const state = createDefaultState();
805
+ state.attachedBaseUrl = "http://127.0.0.1:8080";
806
+
807
+ vi.mocked(startSymphonyEventStream).mockImplementation(() => ({ close: vi.fn() }));
808
+
809
+ const requestRender = vi.fn();
810
+ const setWidget = vi.fn((_key: string, factory: unknown) => {
811
+ (factory as (tui: { requestRender: () => void }, theme: ReturnType<typeof fakeTheme>) => SymphonyConsoleComponent)({ requestRender }, fakeTheme());
812
+ });
813
+ const ctx = { ui: { notify: vi.fn(), custom: vi.fn(), input: vi.fn(async () => "approved"), setWidget } } as unknown as ExtensionContext;
814
+ const runtime = {
815
+ client: {},
816
+ state,
817
+ lastState: workerStateFixture(),
818
+ recentEvents: [],
819
+ refreshState: vi.fn(async () => workerStateFixture()),
820
+ requestRefresh: vi.fn(async () => workerStateFixture()),
821
+ steerWorker: vi.fn(async () => undefined),
822
+ respondToEscalation: vi.fn(async () => undefined),
823
+ errorText: vi.fn((error: unknown) => (error instanceof Error ? error.message : String(error))),
824
+ } as unknown as SymphonyRuntime;
825
+
826
+ await openConsole(ctx, runtime);
827
+ for (let index = 0; index < 5; index += 1) await handleActiveConsoleShortcut("selectNext", ctx);
828
+ await handleActiveConsoleShortcut("respondEscalation", ctx);
829
+
830
+ expect(runtime.respondToEscalation).toHaveBeenCalledWith("esc-1", "approved");
831
+ expect(requestRender).toHaveBeenCalled();
832
+ });
833
+
834
+ it("notifies and still renders the widget when launch refresh fails", async () => {
835
+ const state = createDefaultState();
836
+ state.attachedBaseUrl = "http://127.0.0.1:8080";
837
+ state.lastKnownState = {
838
+ baseUrl: state.attachedBaseUrl,
839
+ trackerProjectUrl: "https://github.com/gannonh/kata/projects/1",
840
+ runningCount: 1,
841
+ retryCount: 0,
842
+ blockedCount: 0,
843
+ completedCount: 2,
844
+ pollingChecking: false,
845
+ nextPollInMs: 1000,
846
+ updatedAt: "2026-05-14T00:00:01Z",
847
+ };
848
+
849
+ const notify = vi.fn();
850
+ const setWidget = vi.fn((_key: string, factory: unknown) => {
851
+ const component = (factory as (tui: { requestRender: () => void }, theme: ReturnType<typeof fakeTheme>) => SymphonyConsoleComponent)({ requestRender: vi.fn() }, fakeTheme());
852
+ expect(component.render(120).join("\n")).toContain("running: 1");
853
+ });
854
+ const ctx = { ui: { notify, custom: vi.fn(), input: vi.fn(), setWidget } } as unknown as ExtensionContext;
855
+ const runtime = {
856
+ client: {},
857
+ state,
858
+ lastState: undefined,
859
+ recentEvents: [],
860
+ refreshState: vi.fn(async () => {
861
+ throw new Error("launch refresh failed");
862
+ }),
863
+ requestRefresh: vi.fn(async () => undefined),
864
+ steerWorker: vi.fn(async () => undefined),
865
+ errorText: vi.fn((error: unknown) => (error instanceof Error ? `formatted: ${error.message}` : String(error))),
866
+ } as unknown as SymphonyRuntime;
867
+
868
+ await openConsole(ctx, runtime);
869
+
870
+ expect(notify).toHaveBeenCalledWith("formatted: launch refresh failed", "error");
871
+ expect(setWidget).toHaveBeenCalledOnce();
872
+ });
873
+ });