@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,679 @@
1
+ import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import * as consoleModule from "./console.ts";
7
+ import { registerSymphonyCommands, setSymphonyStatus } from "./commands.ts";
8
+ import { SymphonyRuntime } from "./runtime.ts";
9
+ import { type LastKnownSymphonyState } from "./state.ts";
10
+
11
+ const borderedLoaderMocks = vi.hoisted(() => ({
12
+ nextController: undefined as AbortController | undefined,
13
+ }));
14
+
15
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
16
+ BorderedLoader: class MockBorderedLoader {
17
+ readonly controller: AbortController;
18
+ readonly signal: AbortSignal;
19
+ onAbort: (() => void) | undefined;
20
+ dispose = vi.fn();
21
+
22
+ constructor() {
23
+ this.controller = borderedLoaderMocks.nextController ?? new AbortController();
24
+ borderedLoaderMocks.nextController = undefined;
25
+ this.signal = this.controller.signal;
26
+ }
27
+ },
28
+ }));
29
+
30
+ type CommandOptions = Parameters<ExtensionAPI["registerCommand"]>[1];
31
+ type ShortcutOptions = Parameters<ExtensionAPI["registerShortcut"]>[1];
32
+
33
+ function commandContext(options: { hasUI?: boolean; cwd?: string } = {}) {
34
+ const notify = vi.fn();
35
+ const setStatus = vi.fn();
36
+ const setWorkingIndicator = vi.fn();
37
+ const setWorkingMessage = vi.fn();
38
+ const setWidget = vi.fn();
39
+ const hasUI = options.hasUI ?? true;
40
+ const custom = vi.fn(async (factory: Parameters<ExtensionCommandContext["ui"]["custom"]>[0]) => {
41
+ if (!hasUI) return undefined;
42
+ let component: { dispose?: () => void } | undefined;
43
+ const value = await new Promise<unknown>((resolve) => {
44
+ component = factory(
45
+ { requestRender: vi.fn() } as unknown as Parameters<typeof factory>[0],
46
+ { fg: (_name: string, text: string) => text } as unknown as Parameters<typeof factory>[1],
47
+ undefined as unknown as Parameters<typeof factory>[2],
48
+ resolve,
49
+ ) as { dispose?: () => void };
50
+ });
51
+ component?.dispose?.();
52
+ return value;
53
+ });
54
+ const ctx = {
55
+ ui: { notify, setStatus, setWorkingIndicator, setWorkingMessage, setWidget, custom },
56
+ cwd: options.cwd ?? "/repo",
57
+ hasUI,
58
+ } as unknown as ExtensionCommandContext;
59
+
60
+ return { ctx, notify, setStatus, setWorkingIndicator, setWorkingMessage, setWidget, custom };
61
+ }
62
+
63
+ function registerCommands(runtime: SymphonyRuntime, overrides: Partial<ExtensionAPI> = {}) {
64
+ const commands = new Map<string, CommandOptions>();
65
+ const shortcuts = new Map<string, ShortcutOptions>();
66
+ const appendEntry = vi.fn();
67
+ const pi = {
68
+ registerCommand: (name: string, options: CommandOptions) => commands.set(name, options),
69
+ registerShortcut: (shortcut: string, options: ShortcutOptions) => shortcuts.set(shortcut, options),
70
+ appendEntry,
71
+ ...overrides,
72
+ } as unknown as ExtensionAPI;
73
+
74
+ registerSymphonyCommands(pi, runtime);
75
+
76
+ return { commands, shortcuts, appendEntry };
77
+ }
78
+
79
+ function lastKnownState(baseUrl: string): LastKnownSymphonyState {
80
+ return {
81
+ baseUrl,
82
+ runningCount: 1,
83
+ retryCount: 0,
84
+ blockedCount: 0,
85
+ completedCount: 2,
86
+ pollingChecking: false,
87
+ nextPollInMs: 1000,
88
+ updatedAt: "2026-05-14T00:00:01.000Z",
89
+ };
90
+ }
91
+
92
+ describe("setSymphonyStatus", () => {
93
+ it("uses the runtime attachment state", () => {
94
+ const runtime = new SymphonyRuntime();
95
+ const { ctx, setStatus } = commandContext();
96
+
97
+ setSymphonyStatus(ctx, runtime);
98
+ runtime.state.attachedBaseUrl = "http://127.0.0.1:8080";
99
+ setSymphonyStatus(ctx, runtime);
100
+
101
+ expect(setStatus).toHaveBeenNthCalledWith(1, "symphony", "symphony detached");
102
+ expect(setStatus).toHaveBeenNthCalledWith(2, "symphony", "symphony http://127.0.0.1:8080");
103
+ });
104
+ });
105
+
106
+ describe("symphony commands", () => {
107
+ it("registers console keyboard shortcuts", async () => {
108
+ const runtime = new SymphonyRuntime();
109
+ const { shortcuts } = registerCommands(runtime);
110
+ const shortcutSpy = vi.spyOn(consoleModule, "handleActiveConsoleShortcut").mockResolvedValue(undefined);
111
+ const { ctx } = commandContext();
112
+
113
+ expect([...shortcuts.keys()]).toEqual(expect.arrayContaining([
114
+ "ctrl+shift+up",
115
+ "ctrl+shift+down",
116
+ "ctrl+shift+r",
117
+ "ctrl+shift+t",
118
+ "ctrl+shift+e",
119
+ "ctrl+shift+i",
120
+ "ctrl+shift+q",
121
+ ]));
122
+ expect(shortcuts.get("ctrl+shift+down")?.description).toContain("Select next Symphony console item");
123
+ expect(shortcuts.has("ctrl+shift+s")).toBe(false);
124
+ expect(shortcuts.get("ctrl+shift+t")?.description).toBe("Steer the selected Symphony console worker");
125
+ expect(shortcuts.get("ctrl+shift+e")?.description).toBe("Respond to the selected Symphony console escalation");
126
+
127
+ await shortcuts.get("ctrl+shift+t")?.handler(ctx);
128
+ await shortcuts.get("ctrl+shift+e")?.handler(ctx);
129
+
130
+ expect(shortcutSpy).toHaveBeenCalledWith("steer", ctx);
131
+ expect(shortcutSpy).toHaveBeenCalledWith("respondEscalation", ctx);
132
+ shortcutSpy.mockRestore();
133
+ });
134
+
135
+ it("includes console key guidance in help text", async () => {
136
+ const runtime = new SymphonyRuntime();
137
+ const { commands } = registerCommands(runtime);
138
+ const { ctx, notify } = commandContext();
139
+ const help = commands.get("symphony:help");
140
+ if (!help) throw new Error("expected help command");
141
+
142
+ await help.handler("", ctx);
143
+
144
+ expect(notify).toHaveBeenCalledWith(expect.stringContaining("Console keys:"), "info");
145
+ expect(notify).toHaveBeenCalledWith(expect.stringContaining("s steer selected running worker"), "info");
146
+ expect(notify).toHaveBeenCalledWith(expect.stringContaining("e respond to selected escalation"), "info");
147
+ });
148
+
149
+ it("requests a manual refresh from the command", async () => {
150
+ const runtime = new SymphonyRuntime();
151
+ runtime.state.attachedBaseUrl = "http://127.0.0.1:8080";
152
+ runtime.requestRefresh = vi.fn(async () => {
153
+ runtime.state.lastKnownState = lastKnownState("http://127.0.0.1:8080");
154
+ return {} as Awaited<ReturnType<SymphonyRuntime["requestRefresh"]>>;
155
+ }) as SymphonyRuntime["requestRefresh"];
156
+
157
+ const { commands, appendEntry } = registerCommands(runtime);
158
+ const { ctx, notify } = commandContext();
159
+ const refresh = commands.get("symphony:refresh");
160
+ if (!refresh) throw new Error("expected refresh command");
161
+
162
+ await refresh.handler("", ctx);
163
+
164
+ expect(runtime.requestRefresh).toHaveBeenCalledOnce();
165
+ expect(appendEntry).toHaveBeenCalledWith("symphony-extension-state", expect.objectContaining({ lastKnownState: runtime.state.lastKnownState }));
166
+ expect(notify).toHaveBeenCalledWith("Symphony refresh requested; running 1, retry 0, blocked 0, completed 2", "info");
167
+ });
168
+
169
+ it("sends a steer instruction from the command", async () => {
170
+ const runtime = new SymphonyRuntime();
171
+ runtime.steerWorker = vi.fn(async () => ({ ok: true, issueId: "issue-123", issueIdentifier: "SIM-123", delivered: true, instructionPreview: "Use auth" })) as SymphonyRuntime["steerWorker"];
172
+
173
+ const { commands, appendEntry } = registerCommands(runtime);
174
+ const { ctx, notify } = commandContext();
175
+ const steer = commands.get("symphony:steer");
176
+ if (!steer) throw new Error("expected steer command");
177
+
178
+ await steer.handler("SIM-123 Use auth", ctx);
179
+
180
+ expect(runtime.steerWorker).toHaveBeenCalledWith("SIM-123", "Use auth", expect.any(AbortSignal));
181
+ expect(appendEntry).toHaveBeenCalled();
182
+ expect(notify).toHaveBeenCalledWith("Steer delivered to SIM-123: Use auth", "info");
183
+ });
184
+
185
+ it("attaches to the owned Symphony server when no URL is provided", async () => {
186
+ const baseUrl = "http://127.0.0.1:8080";
187
+ const runtime = new SymphonyRuntime();
188
+ runtime.state.ownedProcess = {
189
+ pid: 123,
190
+ command: "symphony --no-tui",
191
+ cwd: "/repo",
192
+ baseUrl,
193
+ startedAt: "2026-05-14T00:00:00.000Z",
194
+ };
195
+ runtime.attach = vi.fn(async (url: string) => {
196
+ runtime.state.attachedBaseUrl = url;
197
+ runtime.state.lastKnownState = lastKnownState(url);
198
+ return {};
199
+ }) as unknown as SymphonyRuntime["attach"];
200
+
201
+ const { commands, appendEntry } = registerCommands(runtime);
202
+ const { ctx, notify, setStatus } = commandContext();
203
+ const attach = commands.get("symphony:attach");
204
+ if (!attach) throw new Error("expected attach command");
205
+
206
+ await attach.handler("", ctx);
207
+
208
+ expect(runtime.attach).toHaveBeenCalledWith(baseUrl);
209
+ expect(appendEntry).toHaveBeenCalledWith("symphony-extension-state", expect.objectContaining({ attachedBaseUrl: baseUrl }));
210
+ expect(setStatus).toHaveBeenCalledWith("symphony", `symphony ${baseUrl}`);
211
+ expect(notify).toHaveBeenCalledWith(`Attached to Symphony at ${baseUrl}`, "info");
212
+ });
213
+
214
+ it("detaches without stopping an owned Symphony server", async () => {
215
+ const baseUrl = "http://127.0.0.1:8080";
216
+ const runtime = new SymphonyRuntime();
217
+ runtime.state.attachedBaseUrl = baseUrl;
218
+ runtime.state.ownedProcess = {
219
+ pid: 123,
220
+ command: "symphony --no-tui",
221
+ cwd: "/repo",
222
+ baseUrl,
223
+ startedAt: "2026-05-14T00:00:00.000Z",
224
+ };
225
+ runtime.state.lastKnownState = lastKnownState(baseUrl);
226
+ runtime.client = {} as SymphonyRuntime["client"];
227
+ runtime.processManager.stopOwned = vi.fn() as unknown as SymphonyRuntime["processManager"]["stopOwned"];
228
+
229
+ const { commands, appendEntry } = registerCommands(runtime);
230
+ const { ctx, notify, setStatus, setWidget } = commandContext();
231
+ const detach = commands.get("symphony:detach");
232
+ if (!detach) throw new Error("expected detach command");
233
+
234
+ await detach.handler("", ctx);
235
+
236
+ expect(runtime.processManager.stopOwned).not.toHaveBeenCalled();
237
+ expect(runtime.state.ownedProcess?.pid).toBe(123);
238
+ expect(runtime.state.attachedBaseUrl).toBeUndefined();
239
+ expect(runtime.client).toBeUndefined();
240
+ expect(runtime.state.lastKnownState).toBeUndefined();
241
+ expect(setWidget).toHaveBeenCalledWith("symphony-console", undefined);
242
+ expect(appendEntry).toHaveBeenCalledWith(
243
+ "symphony-extension-state",
244
+ expect.objectContaining({
245
+ attachedBaseUrl: undefined,
246
+ ownedProcess: expect.objectContaining({ pid: 123 }),
247
+ lastKnownState: undefined,
248
+ }),
249
+ );
250
+ expect(setStatus).toHaveBeenCalledWith("symphony", "symphony detached");
251
+ expect(notify).toHaveBeenCalledWith(`Detached from Symphony at ${baseUrl}.`, "info");
252
+ });
253
+
254
+ it("reports when detach is requested without an attachment", async () => {
255
+ const runtime = new SymphonyRuntime();
256
+ const { commands } = registerCommands(runtime);
257
+ const { ctx, notify } = commandContext();
258
+ const detach = commands.get("symphony:detach");
259
+ if (!detach) throw new Error("expected detach command");
260
+
261
+ await detach.handler("", ctx);
262
+
263
+ expect(notify).toHaveBeenCalledWith("No Symphony instance is attached.", "info");
264
+ });
265
+
266
+ it("shows a helpful attach error when no URL or owned server is available", async () => {
267
+ const runtime = new SymphonyRuntime();
268
+ runtime.attach = vi.fn() as unknown as SymphonyRuntime["attach"];
269
+
270
+ const { commands } = registerCommands(runtime);
271
+ const { ctx, notify } = commandContext();
272
+ const attach = commands.get("symphony:attach");
273
+ if (!attach) throw new Error("expected attach command");
274
+
275
+ await attach.handler("", ctx);
276
+
277
+ expect(runtime.attach).not.toHaveBeenCalled();
278
+ expect(notify).toHaveBeenCalledWith(expect.stringContaining("No Symphony URL provided and no Pi-owned Symphony server is running"), "error");
279
+ expect(notify).toHaveBeenCalledWith(expect.stringContaining("Use /symphony:start or /symphony:attach <url>"), "error");
280
+ });
281
+
282
+ it("restricts command attach URLs to loopback hosts before attaching", async () => {
283
+ const runtime = new SymphonyRuntime();
284
+ runtime.attach = vi.fn(async (baseUrl: string) => {
285
+ runtime.state.attachedBaseUrl = baseUrl;
286
+ runtime.state.lastKnownState = lastKnownState(baseUrl);
287
+ return {};
288
+ }) as unknown as SymphonyRuntime["attach"];
289
+
290
+ const { commands, appendEntry } = registerCommands(runtime);
291
+ const { ctx, notify, setStatus } = commandContext();
292
+ const attach = commands.get("symphony:attach");
293
+ if (!attach) throw new Error("expected attach command");
294
+
295
+ await attach.handler("http://example.com:8080", ctx);
296
+
297
+ expect(runtime.attach).not.toHaveBeenCalled();
298
+ expect(notify).toHaveBeenCalledWith(expect.stringContaining("loopback host"), "error");
299
+
300
+ await attach.handler("http://localhost:8080", ctx);
301
+
302
+ expect(runtime.attach).toHaveBeenCalledWith("http://localhost:8080");
303
+ expect(appendEntry).toHaveBeenCalledWith(
304
+ "symphony-extension-state",
305
+ expect.objectContaining({ attachedBaseUrl: "http://localhost:8080" }),
306
+ );
307
+ expect(setStatus).toHaveBeenCalledWith("symphony", "symphony http://localhost:8080");
308
+ expect(notify).toHaveBeenCalledWith("Attached to Symphony at http://localhost:8080", "info");
309
+ });
310
+
311
+ it("cleans up an owned process when start is cancelled during attach", async () => {
312
+ const dir = await mkdtemp(join(tmpdir(), "pi-symphony-start-"));
313
+ await mkdir(join(dir, ".symphony"));
314
+ await writeFile(join(dir, ".symphony", "WORKFLOW.md"), "---\n---\n", "utf8");
315
+ const baseUrl = "http://127.0.0.1:8080";
316
+ const controller = new AbortController();
317
+ borderedLoaderMocks.nextController = controller;
318
+ const runtime = new SymphonyRuntime();
319
+ runtime.resolveBinary = vi.fn(async () => "symphony") as SymphonyRuntime["resolveBinary"];
320
+ runtime.processManager = {
321
+ start: vi.fn(async (_options) => {
322
+ runtime.state.ownedProcess = {
323
+ pid: 123,
324
+ command: "symphony --no-tui",
325
+ cwd: "/repo",
326
+ baseUrl,
327
+ startedAt: "2026-05-14T00:00:00.000Z",
328
+ };
329
+ controller.abort();
330
+ return { baseUrl, owned: true, pid: 123 };
331
+ }),
332
+ stopOwned: vi.fn(async () => {
333
+ runtime.state.ownedProcess = undefined;
334
+ }),
335
+ } as unknown as SymphonyRuntime["processManager"];
336
+ runtime.attach = vi.fn(async (_baseUrl: string, signal?: AbortSignal) => {
337
+ expect(signal?.aborted).toBe(true);
338
+ throw new DOMException("This operation was aborted", "AbortError");
339
+ }) as unknown as SymphonyRuntime["attach"];
340
+ const clearAttachmentIfBaseUrl = vi.spyOn(runtime, "clearAttachmentIfBaseUrl");
341
+
342
+ const { commands, appendEntry } = registerCommands(runtime);
343
+ const { ctx, custom, setStatus } = commandContext({ cwd: dir });
344
+ const start = commands.get("symphony:start");
345
+ if (!start) throw new Error("expected start command");
346
+
347
+ await start.handler("", ctx);
348
+
349
+ expect(custom).toHaveBeenCalledOnce();
350
+ expect(setStatus).toHaveBeenNthCalledWith(1, "symphony", "Starting Symphony...");
351
+ expect(setStatus).toHaveBeenLastCalledWith("symphony", "symphony detached");
352
+ expect(runtime.attach).toHaveBeenCalledWith(baseUrl, controller.signal);
353
+ expect(runtime.processManager.stopOwned).toHaveBeenCalledOnce();
354
+ expect(clearAttachmentIfBaseUrl).toHaveBeenCalledWith(baseUrl);
355
+ expect(appendEntry).toHaveBeenCalledWith("symphony-extension-state", expect.objectContaining({ ownedProcess: undefined, attachedBaseUrl: undefined }));
356
+ });
357
+
358
+ it("persists cleanup when start is cancelled after attach", async () => {
359
+ const dir = await mkdtemp(join(tmpdir(), "pi-symphony-start-"));
360
+ await mkdir(join(dir, ".symphony"));
361
+ await writeFile(join(dir, ".symphony", "WORKFLOW.md"), "---\n---\n", "utf8");
362
+ const baseUrl = "http://127.0.0.1:8080";
363
+ const controller = new AbortController();
364
+ borderedLoaderMocks.nextController = controller;
365
+ const runtime = new SymphonyRuntime();
366
+ runtime.resolveBinary = vi.fn(async () => "symphony") as SymphonyRuntime["resolveBinary"];
367
+ runtime.processManager = {
368
+ start: vi.fn(async () => {
369
+ runtime.state.ownedProcess = {
370
+ pid: 123,
371
+ command: "symphony --no-tui",
372
+ cwd: "/repo",
373
+ baseUrl,
374
+ startedAt: "2026-05-14T00:00:00.000Z",
375
+ };
376
+ return { baseUrl, owned: true, pid: 123 };
377
+ }),
378
+ stopOwned: vi.fn(async () => {
379
+ runtime.state.ownedProcess = undefined;
380
+ }),
381
+ } as unknown as SymphonyRuntime["processManager"];
382
+ runtime.attach = vi.fn(async () => {
383
+ runtime.state.attachedBaseUrl = baseUrl;
384
+ controller.abort();
385
+ return {};
386
+ }) as unknown as SymphonyRuntime["attach"];
387
+
388
+ const { commands, appendEntry } = registerCommands(runtime);
389
+ const { ctx, setStatus } = commandContext({ cwd: dir });
390
+ const start = commands.get("symphony:start");
391
+ if (!start) throw new Error("expected start command");
392
+
393
+ await start.handler("", ctx);
394
+
395
+ expect(runtime.processManager.stopOwned).toHaveBeenCalledOnce();
396
+ expect(appendEntry).toHaveBeenCalledWith("symphony-extension-state", expect.objectContaining({ ownedProcess: undefined, attachedBaseUrl: undefined }));
397
+ expect(setStatus).toHaveBeenLastCalledWith("symphony", "symphony detached");
398
+ });
399
+
400
+ it("shows inline progress while attaching", async () => {
401
+ const runtime = new SymphonyRuntime();
402
+ runtime.attach = vi.fn(async (baseUrl: string) => {
403
+ runtime.state.attachedBaseUrl = baseUrl;
404
+ runtime.state.lastKnownState = lastKnownState(baseUrl);
405
+ return {};
406
+ }) as unknown as SymphonyRuntime["attach"];
407
+
408
+ const { commands } = registerCommands(runtime);
409
+ const { ctx, setStatus, setWorkingIndicator, setWorkingMessage } = commandContext();
410
+ const attach = commands.get("symphony:attach");
411
+ if (!attach) throw new Error("expected attach command");
412
+
413
+ await attach.handler("http://localhost:8080", ctx);
414
+
415
+ expect(setWorkingMessage).toHaveBeenNthCalledWith(1, "Attaching to Symphony...");
416
+ expect(setStatus).toHaveBeenNthCalledWith(1, "symphony", "Attaching to Symphony...");
417
+ expect(setWorkingIndicator).toHaveBeenLastCalledWith();
418
+ expect(setWorkingMessage).toHaveBeenLastCalledWith();
419
+ expect(setStatus).toHaveBeenLastCalledWith("symphony", "symphony http://localhost:8080");
420
+ });
421
+
422
+ it("shows inline progress while refreshing", async () => {
423
+ const runtime = new SymphonyRuntime();
424
+ runtime.state.attachedBaseUrl = "http://127.0.0.1:8080";
425
+ runtime.requestRefresh = vi.fn(async () => {
426
+ runtime.state.lastKnownState = lastKnownState("http://127.0.0.1:8080");
427
+ return {} as Awaited<ReturnType<SymphonyRuntime["requestRefresh"]>>;
428
+ }) as SymphonyRuntime["requestRefresh"];
429
+
430
+ const { commands } = registerCommands(runtime);
431
+ const { ctx, setStatus, setWorkingMessage } = commandContext();
432
+ const refresh = commands.get("symphony:refresh");
433
+ if (!refresh) throw new Error("expected refresh command");
434
+
435
+ await refresh.handler("", ctx);
436
+
437
+ expect(setWorkingMessage).toHaveBeenNthCalledWith(1, "Refreshing Symphony...");
438
+ expect(setStatus).toHaveBeenNthCalledWith(1, "symphony", "Refreshing Symphony...");
439
+ expect(setStatus).toHaveBeenLastCalledWith("symphony", "symphony http://127.0.0.1:8080");
440
+ });
441
+
442
+ it("shows inline progress while steering", async () => {
443
+ const runtime = new SymphonyRuntime();
444
+ runtime.state.attachedBaseUrl = "http://127.0.0.1:8080";
445
+ runtime.steerWorker = vi.fn(async () => ({ ok: true, issueId: "issue-123", issueIdentifier: "SIM-123", delivered: true, instructionPreview: "Use auth" })) as SymphonyRuntime["steerWorker"];
446
+
447
+ const { commands } = registerCommands(runtime);
448
+ const { ctx, setStatus, setWorkingMessage } = commandContext();
449
+ const steer = commands.get("symphony:steer");
450
+ if (!steer) throw new Error("expected steer command");
451
+
452
+ await steer.handler("SIM-123 Use auth", ctx);
453
+
454
+ expect(setWorkingMessage).toHaveBeenNthCalledWith(1, "Sending steer instruction...");
455
+ expect(setStatus).toHaveBeenNthCalledWith(1, "symphony", "Sending steer instruction...");
456
+ expect(setStatus).toHaveBeenLastCalledWith("symphony", "symphony http://127.0.0.1:8080");
457
+ });
458
+
459
+ it("uses a blocking loader for init", async () => {
460
+ const runtime = new SymphonyRuntime();
461
+ runtime.resolveBinary = vi.fn(async () => "symphony") as SymphonyRuntime["resolveBinary"];
462
+ const exec = vi.fn(async (_binary: string, _args: string[], _options: unknown) => ({ code: 0, stdout: "init ok", stderr: "", killed: false }));
463
+ const { commands } = registerCommands(runtime, { exec } as Partial<ExtensionAPI>);
464
+ const { ctx, custom, setStatus, notify } = commandContext();
465
+ const init = commands.get("symphony:init");
466
+ if (!init) throw new Error("expected init command");
467
+
468
+ await init.handler("--force", ctx);
469
+
470
+ expect(custom).toHaveBeenCalledOnce();
471
+ expect(setStatus).toHaveBeenNthCalledWith(1, "symphony", "Initializing Symphony...");
472
+ expect(setStatus).toHaveBeenLastCalledWith("symphony", "symphony detached");
473
+ expect(exec).toHaveBeenCalledWith("symphony", ["init", "--force"], { cwd: "/repo", signal: expect.any(AbortSignal) });
474
+ expect(notify).toHaveBeenCalledWith("init ok", "info");
475
+ });
476
+
477
+ it("uses a blocking loader for doctor", async () => {
478
+ const runtime = new SymphonyRuntime();
479
+ runtime.resolveBinary = vi.fn(async () => "symphony") as SymphonyRuntime["resolveBinary"];
480
+ const exec = vi.fn(async (_binary: string, _args: string[], _options: unknown) => ({ code: 0, stdout: "doctor ok", stderr: "", killed: false }));
481
+ const { commands } = registerCommands(runtime, { exec } as Partial<ExtensionAPI>);
482
+
483
+ const { ctx, custom, setStatus } = commandContext();
484
+ const doctor = commands.get("symphony:doctor");
485
+ if (!doctor) throw new Error("expected doctor command");
486
+
487
+ await doctor.handler("", ctx);
488
+
489
+ expect(custom).toHaveBeenCalledOnce();
490
+ expect(setStatus).toHaveBeenNthCalledWith(1, "symphony", "Running Symphony doctor...");
491
+ expect(setStatus).toHaveBeenLastCalledWith("symphony", "symphony detached");
492
+ expect(exec).toHaveBeenCalledWith("symphony", ["doctor"], { cwd: "/repo", signal: expect.any(AbortSignal) });
493
+ });
494
+
495
+ it("falls back to root WORKFLOW.md when start omits a workflow and project-home workflow is absent", async () => {
496
+ const dir = await mkdtemp(join(tmpdir(), "pi-symphony-start-"));
497
+ await writeFile(join(dir, "WORKFLOW.md"), "---\n---\n", "utf8");
498
+ const baseUrl = "http://127.0.0.1:8080";
499
+ const runtime = new SymphonyRuntime();
500
+ runtime.resolveBinary = vi.fn(async () => "symphony") as SymphonyRuntime["resolveBinary"];
501
+ runtime.processManager = {
502
+ start: vi.fn(async () => ({ baseUrl, owned: true, pid: 123 })),
503
+ } as unknown as SymphonyRuntime["processManager"];
504
+ runtime.attach = vi.fn(async () => {
505
+ runtime.state.attachedBaseUrl = baseUrl;
506
+ runtime.state.lastKnownState = lastKnownState(baseUrl);
507
+ return {};
508
+ }) as unknown as SymphonyRuntime["attach"];
509
+
510
+ const { commands } = registerCommands(runtime);
511
+ const { ctx } = commandContext({ cwd: dir });
512
+ const start = commands.get("symphony:start");
513
+ if (!start) throw new Error("expected start command");
514
+
515
+ await start.handler("", ctx);
516
+
517
+ expect(runtime.processManager.start).toHaveBeenCalledWith(expect.objectContaining({ workflow: "WORKFLOW.md" }));
518
+ });
519
+
520
+ it("uses .symphony/WORKFLOW.md when start omits a workflow", async () => {
521
+ const dir = await mkdtemp(join(tmpdir(), "pi-symphony-start-"));
522
+ await mkdir(join(dir, ".symphony"));
523
+ await writeFile(join(dir, ".symphony", "WORKFLOW.md"), "---\n---\n", "utf8");
524
+ const baseUrl = "http://127.0.0.1:8080";
525
+ const runtime = new SymphonyRuntime();
526
+ runtime.resolveBinary = vi.fn(async () => "symphony") as SymphonyRuntime["resolveBinary"];
527
+ runtime.processManager = {
528
+ start: vi.fn(async () => ({ baseUrl, owned: true, pid: 123 })),
529
+ } as unknown as SymphonyRuntime["processManager"];
530
+ runtime.attach = vi.fn(async () => {
531
+ runtime.state.attachedBaseUrl = baseUrl;
532
+ runtime.state.lastKnownState = lastKnownState(baseUrl);
533
+ return {};
534
+ }) as unknown as SymphonyRuntime["attach"];
535
+
536
+ const { commands } = registerCommands(runtime);
537
+ const { ctx } = commandContext({ cwd: dir });
538
+ const start = commands.get("symphony:start");
539
+ if (!start) throw new Error("expected start command");
540
+
541
+ await start.handler("", ctx);
542
+
543
+ expect(runtime.processManager.start).toHaveBeenCalledWith(expect.objectContaining({ workflow: ".symphony/WORKFLOW.md" }));
544
+ });
545
+
546
+ it("shows a helpful error when start omits a missing default workflow", async () => {
547
+ const dir = await mkdtemp(join(tmpdir(), "pi-symphony-start-"));
548
+ const runtime = new SymphonyRuntime();
549
+ runtime.resolveBinary = vi.fn(async () => "symphony") as SymphonyRuntime["resolveBinary"];
550
+ runtime.processManager.start = vi.fn() as unknown as SymphonyRuntime["processManager"]["start"];
551
+
552
+ const { commands } = registerCommands(runtime);
553
+ const { ctx, notify } = commandContext({ cwd: dir });
554
+ const start = commands.get("symphony:start");
555
+ if (!start) throw new Error("expected start command");
556
+
557
+ await start.handler("", ctx);
558
+
559
+ expect(runtime.processManager.start).not.toHaveBeenCalled();
560
+ expect(notify).toHaveBeenCalledWith(expect.stringContaining("Symphony workflow file not found: .symphony/WORKFLOW.md"), "error");
561
+ expect(notify).toHaveBeenCalledWith(expect.stringContaining("Run /symphony:init first or pass a workflow path to /symphony:start <workflow>."), "error");
562
+ });
563
+
564
+ it("uses an explicit start workflow instead of the default", async () => {
565
+ const dir = await mkdtemp(join(tmpdir(), "pi-symphony-start-"));
566
+ await writeFile(join(dir, "custom-WORKFLOW.md"), "---\n---\n", "utf8");
567
+ const baseUrl = "http://127.0.0.1:8080";
568
+ const runtime = new SymphonyRuntime();
569
+ runtime.resolveBinary = vi.fn(async () => "symphony") as SymphonyRuntime["resolveBinary"];
570
+ runtime.processManager = {
571
+ start: vi.fn(async () => ({ baseUrl, owned: true, pid: 123 })),
572
+ } as unknown as SymphonyRuntime["processManager"];
573
+ runtime.attach = vi.fn(async () => {
574
+ runtime.state.attachedBaseUrl = baseUrl;
575
+ runtime.state.lastKnownState = lastKnownState(baseUrl);
576
+ return {};
577
+ }) as unknown as SymphonyRuntime["attach"];
578
+
579
+ const { commands } = registerCommands(runtime);
580
+ const { ctx } = commandContext({ cwd: dir });
581
+ const start = commands.get("symphony:start");
582
+ if (!start) throw new Error("expected start command");
583
+
584
+ await start.handler("custom-WORKFLOW.md", ctx);
585
+
586
+ expect(runtime.processManager.start).toHaveBeenCalledWith(expect.objectContaining({ workflow: "custom-WORKFLOW.md" }));
587
+ });
588
+
589
+ it("runs loader-backed commands without custom when UI is unavailable", async () => {
590
+ const runtime = new SymphonyRuntime();
591
+ runtime.resolveBinary = vi.fn(async () => "symphony") as SymphonyRuntime["resolveBinary"];
592
+ const exec = vi.fn(async (_binary: string, _args: string[], _options: unknown) => ({ code: 0, stdout: "doctor ok", stderr: "", killed: false }));
593
+ const { commands } = registerCommands(runtime, { exec } as Partial<ExtensionAPI>);
594
+ const { ctx, custom, setStatus, notify } = commandContext({ hasUI: false });
595
+ const doctor = commands.get("symphony:doctor");
596
+ if (!doctor) throw new Error("expected doctor command");
597
+
598
+ await doctor.handler("", ctx);
599
+
600
+ expect(custom).not.toHaveBeenCalled();
601
+ expect(exec).toHaveBeenCalledWith("symphony", ["doctor"], { cwd: "/repo", signal: expect.any(AbortSignal) });
602
+ expect(setStatus).toHaveBeenNthCalledWith(1, "symphony", "Running Symphony doctor...");
603
+ expect(setStatus).toHaveBeenLastCalledWith("symphony", "symphony detached");
604
+ expect(notify).toHaveBeenCalledWith("doctor ok", "info");
605
+ });
606
+
607
+ it("clears an owned attachment and updates status after stop", async () => {
608
+ const baseUrl = "http://127.0.0.1:8080";
609
+ const runtime = new SymphonyRuntime();
610
+ runtime.state.attachedBaseUrl = baseUrl;
611
+ runtime.state.ownedProcess = {
612
+ pid: 123,
613
+ command: "symphony --no-tui",
614
+ cwd: "/repo",
615
+ baseUrl,
616
+ startedAt: "2026-05-14T00:00:00.000Z",
617
+ };
618
+ runtime.state.lastKnownState = lastKnownState(baseUrl);
619
+ runtime.client = {} as SymphonyRuntime["client"];
620
+ runtime.processManager = {
621
+ stopOwned: vi.fn(async () => {
622
+ runtime.state.ownedProcess = undefined;
623
+ }),
624
+ } as unknown as SymphonyRuntime["processManager"];
625
+
626
+ const { commands, appendEntry } = registerCommands(runtime);
627
+ const { ctx, notify, setStatus, setWorkingMessage } = commandContext();
628
+ const stop = commands.get("symphony:stop");
629
+ if (!stop) throw new Error("expected stop command");
630
+
631
+ await stop.handler("", ctx);
632
+
633
+ expect(setWorkingMessage).toHaveBeenNthCalledWith(1, "Stopping Symphony...");
634
+ expect(setStatus).toHaveBeenNthCalledWith(1, "symphony", "Stopping Symphony...");
635
+ expect(runtime.processManager.stopOwned).toHaveBeenCalledOnce();
636
+ expect(runtime.state.attachedBaseUrl).toBeUndefined();
637
+ expect(runtime.client).toBeUndefined();
638
+ expect(runtime.state.lastKnownState).toBeUndefined();
639
+ expect(appendEntry).toHaveBeenCalledWith(
640
+ "symphony-extension-state",
641
+ expect.objectContaining({
642
+ attachedBaseUrl: undefined,
643
+ ownedProcess: undefined,
644
+ lastKnownState: undefined,
645
+ }),
646
+ );
647
+ expect(setStatus).toHaveBeenCalledWith("symphony", "symphony detached");
648
+ expect(notify).toHaveBeenCalledWith("Stopped owned Symphony process", "info");
649
+ });
650
+
651
+ it("keeps an external attachment when stopping a different owned server", async () => {
652
+ const runtime = new SymphonyRuntime();
653
+ runtime.state.attachedBaseUrl = "http://127.0.0.1:8081";
654
+ runtime.state.ownedProcess = {
655
+ pid: 123,
656
+ command: "symphony --no-tui",
657
+ cwd: "/repo",
658
+ baseUrl: "http://127.0.0.1:8080",
659
+ startedAt: "2026-05-14T00:00:00.000Z",
660
+ };
661
+ runtime.state.lastKnownState = lastKnownState(runtime.state.attachedBaseUrl);
662
+ runtime.processManager = {
663
+ stopOwned: vi.fn(async () => {
664
+ runtime.state.ownedProcess = undefined;
665
+ }),
666
+ } as unknown as SymphonyRuntime["processManager"];
667
+
668
+ const { commands } = registerCommands(runtime);
669
+ const { ctx, setStatus } = commandContext();
670
+ const stop = commands.get("symphony:stop");
671
+ if (!stop) throw new Error("expected stop command");
672
+
673
+ await stop.handler("", ctx);
674
+
675
+ expect(runtime.state.attachedBaseUrl).toBe("http://127.0.0.1:8081");
676
+ expect(runtime.state.lastKnownState).toEqual(lastKnownState("http://127.0.0.1:8081"));
677
+ expect(setStatus).toHaveBeenCalledWith("symphony", "symphony http://127.0.0.1:8081");
678
+ });
679
+ });