@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,173 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { normalizeBaseUrl, restoreStateFromEntries, STATE_ENTRY_TYPE } from "./state.ts";
3
+
4
+ const ownedProcess = {
5
+ pid: 123,
6
+ command: "symphony",
7
+ cwd: "/repo",
8
+ baseUrl: "http://127.0.0.1:8080",
9
+ startedAt: "2026-05-14T00:00:00.000Z",
10
+ };
11
+
12
+ const lastKnownState = {
13
+ baseUrl: "http://127.0.0.1:8080",
14
+ trackerProjectUrl: "https://github.com/gannonh/kata/issues",
15
+ runningCount: 1,
16
+ retryCount: 2,
17
+ blockedCount: 3,
18
+ completedCount: 4,
19
+ pollingChecking: false,
20
+ nextPollInMs: 5000,
21
+ updatedAt: "2026-05-14T00:00:01.000Z",
22
+ };
23
+
24
+ describe("extension state persistence", () => {
25
+ it("restores the latest snapshot and clears stale optional fields", () => {
26
+ const state = restoreStateFromEntries([
27
+ {
28
+ type: "custom",
29
+ customType: STATE_ENTRY_TYPE,
30
+ data: {
31
+ binaryPath: "/usr/local/bin/symphony",
32
+ attachedBaseUrl: "http://127.0.0.1:8080",
33
+ ownedProcess,
34
+ console: { showDetails: true },
35
+ stopOwnedOnShutdown: false,
36
+ lastKnownState,
37
+ },
38
+ },
39
+ {
40
+ type: "custom",
41
+ customType: STATE_ENTRY_TYPE,
42
+ data: {
43
+ binaryPath: "/opt/symphony",
44
+ console: { showDetails: false },
45
+ stopOwnedOnShutdown: true,
46
+ lastKnownState: {
47
+ baseUrl: "http://127.0.0.1:9090",
48
+ runningCount: 0,
49
+ retryCount: 0,
50
+ blockedCount: 0,
51
+ completedCount: 1,
52
+ pollingChecking: true,
53
+ nextPollInMs: 1000,
54
+ updatedAt: "2026-05-14T00:00:02.000Z",
55
+ },
56
+ },
57
+ },
58
+ ]);
59
+
60
+ expect(state).toEqual({
61
+ binaryPath: "/opt/symphony",
62
+ console: { showDetails: false },
63
+ stopOwnedOnShutdown: true,
64
+ lastKnownState: {
65
+ baseUrl: "http://127.0.0.1:9090",
66
+ runningCount: 0,
67
+ retryCount: 0,
68
+ blockedCount: 0,
69
+ completedCount: 1,
70
+ pollingChecking: true,
71
+ nextPollInMs: 1000,
72
+ updatedAt: "2026-05-14T00:00:02.000Z",
73
+ },
74
+ });
75
+ });
76
+
77
+ it("migrates legacy dashboard details state to console details state", () => {
78
+ const state = restoreStateFromEntries([
79
+ {
80
+ type: "custom",
81
+ customType: STATE_ENTRY_TYPE,
82
+ data: {
83
+ dashboard: { showDetails: false },
84
+ },
85
+ },
86
+ ]);
87
+
88
+ expect(state.console.showDetails).toBe(false);
89
+ });
90
+
91
+ it("ignores invalid nested persisted data", () => {
92
+ const invalidPids = ["123", 0, -1, 1.5];
93
+
94
+ for (const pid of invalidPids) {
95
+ const state = restoreStateFromEntries([
96
+ {
97
+ type: "custom",
98
+ customType: STATE_ENTRY_TYPE,
99
+ data: {
100
+ ownedProcess,
101
+ lastKnownState,
102
+ },
103
+ },
104
+ {
105
+ type: "custom",
106
+ customType: STATE_ENTRY_TYPE,
107
+ data: {
108
+ ownedProcess: { ...ownedProcess, pid },
109
+ console: { showDetails: true },
110
+ stopOwnedOnShutdown: false,
111
+ lastKnownState: { ...lastKnownState, runningCount: "1" },
112
+ },
113
+ },
114
+ ]);
115
+
116
+ expect(state).toEqual({
117
+ console: { showDetails: true },
118
+ stopOwnedOnShutdown: false,
119
+ });
120
+ }
121
+ });
122
+
123
+ it("canonicalizes restored base URLs before storing them", () => {
124
+ const state = restoreStateFromEntries([
125
+ {
126
+ type: "custom",
127
+ customType: STATE_ENTRY_TYPE,
128
+ data: {
129
+ attachedBaseUrl: " HTTP://LOCALHOST:8080/api///?debug=true#panel ",
130
+ ownedProcess: { ...ownedProcess, baseUrl: "http://127.0.0.1:8080///?debug=true#panel" },
131
+ lastKnownState: { ...lastKnownState, baseUrl: "http://127.0.0.1:8080///?debug=true#panel" },
132
+ },
133
+ },
134
+ ]);
135
+
136
+ expect(state.attachedBaseUrl).toBe("http://localhost:8080/api");
137
+ expect(state.ownedProcess?.baseUrl).toBe("http://127.0.0.1:8080");
138
+ expect(state.lastKnownState?.baseUrl).toBe("http://127.0.0.1:8080");
139
+ });
140
+
141
+ it("ignores persisted base URLs with non-http protocols", () => {
142
+ const state = restoreStateFromEntries([
143
+ {
144
+ type: "custom",
145
+ customType: STATE_ENTRY_TYPE,
146
+ data: {
147
+ attachedBaseUrl: "file:///tmp/symphony.sock",
148
+ ownedProcess: { ...ownedProcess, baseUrl: "data:text/plain,symphony" },
149
+ console: { showDetails: true },
150
+ stopOwnedOnShutdown: false,
151
+ lastKnownState: { ...lastKnownState, baseUrl: "ws://127.0.0.1:8080" },
152
+ },
153
+ },
154
+ ]);
155
+
156
+ expect(state).toEqual({
157
+ console: { showDetails: true },
158
+ stopOwnedOnShutdown: false,
159
+ });
160
+ });
161
+ });
162
+
163
+ describe("normalizeBaseUrl", () => {
164
+ it("normalizes http and https URLs", () => {
165
+ expect(normalizeBaseUrl(" http://127.0.0.1:8080/ ")).toBe("http://127.0.0.1:8080");
166
+ expect(normalizeBaseUrl("https://example.com/api///?debug=true#panel")).toBe("https://example.com/api");
167
+ });
168
+
169
+ it("rejects non-http base URLs", () => {
170
+ expect(() => normalizeBaseUrl("file:///tmp/symphony.sock")).toThrow("URL must use http or https");
171
+ expect(() => normalizeBaseUrl("data:text/plain,symphony")).toThrow("URL must use http or https");
172
+ });
173
+ });
package/src/state.ts ADDED
@@ -0,0 +1,146 @@
1
+ export const STATE_ENTRY_TYPE = "symphony-extension-state";
2
+
3
+ export interface OwnedProcessMetadata {
4
+ pid: number;
5
+ command: string;
6
+ cwd: string;
7
+ baseUrl?: string;
8
+ startedAt: string;
9
+ }
10
+
11
+ export interface LastKnownSymphonyState {
12
+ baseUrl: string;
13
+ trackerProjectUrl?: string;
14
+ runningCount: number;
15
+ retryCount: number;
16
+ blockedCount: number;
17
+ completedCount: number;
18
+ pollingChecking: boolean;
19
+ nextPollInMs: number;
20
+ updatedAt: string;
21
+ }
22
+
23
+ export interface ExtensionState {
24
+ binaryPath?: string;
25
+ attachedBaseUrl?: string;
26
+ ownedProcess?: OwnedProcessMetadata;
27
+ console: {
28
+ showDetails: boolean;
29
+ };
30
+ stopOwnedOnShutdown: boolean;
31
+ lastKnownState?: LastKnownSymphonyState;
32
+ }
33
+
34
+ export function createDefaultState(): ExtensionState {
35
+ return {
36
+ console: { showDetails: true },
37
+ stopOwnedOnShutdown: true,
38
+ };
39
+ }
40
+
41
+ export function normalizeBaseUrl(url: string): string {
42
+ const trimmed = url.trim();
43
+ if (!trimmed) throw new Error("URL must not be empty");
44
+ const parsed = new URL(trimmed);
45
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
46
+ throw new Error("URL must use http or https");
47
+ }
48
+ parsed.pathname = parsed.pathname.replace(/\/+$/, "");
49
+ parsed.search = "";
50
+ parsed.hash = "";
51
+ return parsed.toString().replace(/\/$/, "");
52
+ }
53
+
54
+ export function restoreStateFromEntries(entries: Array<{ type?: string; customType?: string; data?: unknown }>): ExtensionState {
55
+ let state = createDefaultState();
56
+ for (const entry of entries) {
57
+ if (entry.type !== "custom" || entry.customType !== STATE_ENTRY_TYPE) continue;
58
+ if (!isRecord(entry.data)) continue;
59
+ state = restoreStateFromSnapshot(entry.data);
60
+ }
61
+ return state;
62
+ }
63
+
64
+ function restoreStateFromSnapshot(data: Record<string, unknown>): ExtensionState {
65
+ const state = createDefaultState();
66
+ if (typeof data.binaryPath === "string") state.binaryPath = data.binaryPath;
67
+ if (isValidBaseUrl(data.attachedBaseUrl)) state.attachedBaseUrl = normalizeBaseUrl(data.attachedBaseUrl);
68
+ if (typeof data.stopOwnedOnShutdown === "boolean") state.stopOwnedOnShutdown = data.stopOwnedOnShutdown;
69
+ if (isRecord(data.console) && typeof data.console.showDetails === "boolean") {
70
+ state.console.showDetails = data.console.showDetails;
71
+ } else if (isRecord(data.dashboard) && typeof data.dashboard.showDetails === "boolean") {
72
+ state.console.showDetails = data.dashboard.showDetails;
73
+ }
74
+ if (isOwnedProcessMetadata(data.ownedProcess)) {
75
+ state.ownedProcess = {
76
+ ...data.ownedProcess,
77
+ baseUrl: data.ownedProcess.baseUrl ? normalizeBaseUrl(data.ownedProcess.baseUrl) : undefined,
78
+ };
79
+ }
80
+ if (isLastKnownSymphonyState(data.lastKnownState)) {
81
+ state.lastKnownState = {
82
+ ...data.lastKnownState,
83
+ baseUrl: normalizeBaseUrl(data.lastKnownState.baseUrl),
84
+ };
85
+ }
86
+ return state;
87
+ }
88
+
89
+ function isRecord(value: unknown): value is Record<string, unknown> {
90
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
91
+ }
92
+
93
+ function isOwnedProcessMetadata(value: unknown): value is OwnedProcessMetadata {
94
+ return (
95
+ isRecord(value) &&
96
+ isPositiveInteger(value.pid) &&
97
+ typeof value.command === "string" &&
98
+ typeof value.cwd === "string" &&
99
+ (value.baseUrl === undefined || isValidBaseUrl(value.baseUrl)) &&
100
+ typeof value.startedAt === "string"
101
+ );
102
+ }
103
+
104
+ function isLastKnownSymphonyState(value: unknown): value is LastKnownSymphonyState {
105
+ return (
106
+ isRecord(value) &&
107
+ isValidBaseUrl(value.baseUrl) &&
108
+ (value.trackerProjectUrl === undefined || typeof value.trackerProjectUrl === "string") &&
109
+ isFiniteNumber(value.runningCount) &&
110
+ isFiniteNumber(value.retryCount) &&
111
+ isFiniteNumber(value.blockedCount) &&
112
+ isFiniteNumber(value.completedCount) &&
113
+ typeof value.pollingChecking === "boolean" &&
114
+ isFiniteNumber(value.nextPollInMs) &&
115
+ typeof value.updatedAt === "string"
116
+ );
117
+ }
118
+
119
+ function isValidBaseUrl(value: unknown): value is string {
120
+ if (typeof value !== "string") return false;
121
+ try {
122
+ normalizeBaseUrl(value);
123
+ return true;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ function isFiniteNumber(value: unknown): value is number {
130
+ return typeof value === "number" && Number.isFinite(value);
131
+ }
132
+
133
+ function isPositiveInteger(value: unknown): value is number {
134
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
135
+ }
136
+
137
+ export function snapshotStateForPersistence(state: ExtensionState): ExtensionState {
138
+ return {
139
+ binaryPath: state.binaryPath,
140
+ attachedBaseUrl: state.attachedBaseUrl,
141
+ ownedProcess: state.ownedProcess,
142
+ console: { ...state.console },
143
+ stopOwnedOnShutdown: state.stopOwnedOnShutdown,
144
+ lastKnownState: state.lastKnownState,
145
+ };
146
+ }
@@ -0,0 +1,359 @@
1
+ import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { AgentToolUpdateCallback, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { registerSymphonyTools } from "./tools.ts";
7
+ import { SymphonyRuntime } from "./runtime.ts";
8
+ import type { LastKnownSymphonyState } from "./state.ts";
9
+
10
+ type RegisteredTool = {
11
+ name: string;
12
+ executionMode?: string;
13
+ execute: (id: string, params: Record<string, unknown>, signal: AbortSignal, update: AgentToolUpdateCallback | undefined, ctx: ExtensionContext) => Promise<unknown>;
14
+ };
15
+
16
+ function toolContext(options: { cwd?: string } = {}) {
17
+ const setStatus = vi.fn();
18
+ const ctx = {
19
+ cwd: options.cwd ?? "/repo",
20
+ hasUI: true,
21
+ ui: { setStatus },
22
+ } as unknown as ExtensionContext;
23
+ return { ctx, setStatus };
24
+ }
25
+
26
+ function registerTools(runtime: SymphonyRuntime, overrides: Partial<ExtensionAPI> = {}) {
27
+ const tools = new Map<string, RegisteredTool>();
28
+ const appendEntry = vi.fn();
29
+ const pi = {
30
+ registerTool: (tool: RegisteredTool) => tools.set(tool.name, tool),
31
+ appendEntry,
32
+ ...overrides,
33
+ } as unknown as ExtensionAPI;
34
+
35
+ registerSymphonyTools(pi, runtime);
36
+
37
+ return { tools, appendEntry };
38
+ }
39
+
40
+ function lastKnownState(baseUrl: string): LastKnownSymphonyState {
41
+ return {
42
+ baseUrl,
43
+ runningCount: 1,
44
+ retryCount: 0,
45
+ blockedCount: 0,
46
+ completedCount: 2,
47
+ pollingChecking: false,
48
+ nextPollInMs: 1000,
49
+ updatedAt: "2026-05-14T00:00:01.000Z",
50
+ };
51
+ }
52
+
53
+ function progressUpdate(text: string) {
54
+ return {
55
+ content: [{ type: "text", text }],
56
+ details: { status: "working" },
57
+ };
58
+ }
59
+
60
+ describe("symphony tools", () => {
61
+ it("registers every Symphony tool for sequential execution", () => {
62
+ const runtime = new SymphonyRuntime();
63
+ const { tools } = registerTools(runtime);
64
+
65
+ expect([...tools.keys()].sort()).toEqual([
66
+ "symphony_attach",
67
+ "symphony_doctor",
68
+ "symphony_help",
69
+ "symphony_init",
70
+ "symphony_refresh",
71
+ "symphony_start",
72
+ "symphony_status",
73
+ "symphony_steer",
74
+ "symphony_stop",
75
+ ]);
76
+ expect([...tools.values()].every((tool) => tool.executionMode === "sequential")).toBe(true);
77
+ });
78
+
79
+ it("reports progress while running init", async () => {
80
+ const runtime = new SymphonyRuntime();
81
+ runtime.resolveBinary = vi.fn(async () => "symphony") as SymphonyRuntime["resolveBinary"];
82
+ const exec = vi.fn(async (_binary: string, _args: string[], _options: unknown) => ({ code: 0, stdout: "init ok", stderr: "", killed: false }));
83
+ const { tools } = registerTools(runtime, { exec } as Partial<ExtensionAPI>);
84
+ const init = tools.get("symphony_init");
85
+ if (!init) throw new Error("expected init tool");
86
+ const signal = new AbortController().signal;
87
+ const update = vi.fn();
88
+
89
+ const result = await init.execute("1", { force: true }, signal, update, toolContext().ctx);
90
+
91
+ expect(update).toHaveBeenCalledWith(progressUpdate("Initializing Symphony..."));
92
+ expect(exec).toHaveBeenCalledWith("symphony", ["init", "--force"], { cwd: "/repo", signal });
93
+ expect(result).toMatchObject({ content: [{ type: "text", text: "init ok" }] });
94
+ });
95
+
96
+ it("reports progress while running doctor", async () => {
97
+ const runtime = new SymphonyRuntime();
98
+ runtime.resolveBinary = vi.fn(async () => "symphony") as SymphonyRuntime["resolveBinary"];
99
+ const exec = vi.fn(async (_binary: string, _args: string[], _options: unknown) => ({ code: 0, stdout: "doctor ok", stderr: "", killed: false }));
100
+ const { tools } = registerTools(runtime, { exec } as Partial<ExtensionAPI>);
101
+ const doctor = tools.get("symphony_doctor");
102
+ if (!doctor) throw new Error("expected doctor tool");
103
+ const signal = new AbortController().signal;
104
+ const update = vi.fn();
105
+
106
+ const result = await doctor.execute("1", { workflow: "WORKFLOW.md" }, signal, update, toolContext().ctx);
107
+
108
+ expect(update).toHaveBeenCalledWith(progressUpdate("Running Symphony doctor..."));
109
+ expect(exec).toHaveBeenCalledWith("symphony", ["doctor", "WORKFLOW.md"], { cwd: "/repo", signal });
110
+ expect(result).toMatchObject({ content: [{ type: "text", text: "doctor ok" }] });
111
+ });
112
+
113
+ it("falls back to root WORKFLOW.md when start omits a workflow and project-home workflow is absent", async () => {
114
+ const dir = await mkdtemp(join(tmpdir(), "pi-symphony-start-tool-"));
115
+ await writeFile(join(dir, "WORKFLOW.md"), "---\n---\n", "utf8");
116
+ const baseUrl = "http://127.0.0.1:8080";
117
+ const runtime = new SymphonyRuntime();
118
+ runtime.resolveBinary = vi.fn(async () => "symphony") as SymphonyRuntime["resolveBinary"];
119
+ runtime.processManager = {
120
+ start: vi.fn(async () => ({ baseUrl, owned: true, pid: 123 })),
121
+ } as unknown as SymphonyRuntime["processManager"];
122
+ runtime.attach = vi.fn(async (_baseUrl: string) => {
123
+ runtime.state.attachedBaseUrl = baseUrl;
124
+ runtime.state.lastKnownState = lastKnownState(baseUrl);
125
+ return {};
126
+ }) as unknown as SymphonyRuntime["attach"];
127
+ const { tools } = registerTools(runtime);
128
+ const start = tools.get("symphony_start");
129
+ if (!start) throw new Error("expected start tool");
130
+ const { ctx } = toolContext({ cwd: dir });
131
+ const signal = new AbortController().signal;
132
+
133
+ await start.execute("1", {}, signal, undefined, ctx);
134
+
135
+ expect(runtime.processManager.start).toHaveBeenCalledWith(expect.objectContaining({ workflow: "WORKFLOW.md" }));
136
+ });
137
+
138
+ it("uses .symphony/WORKFLOW.md when start omits a workflow", async () => {
139
+ const dir = await mkdtemp(join(tmpdir(), "pi-symphony-start-tool-"));
140
+ await mkdir(join(dir, ".symphony"));
141
+ await writeFile(join(dir, ".symphony", "WORKFLOW.md"), "---\n---\n", "utf8");
142
+ const baseUrl = "http://127.0.0.1:8080";
143
+ const runtime = new SymphonyRuntime();
144
+ runtime.resolveBinary = vi.fn(async () => "symphony") as SymphonyRuntime["resolveBinary"];
145
+ runtime.processManager = {
146
+ start: vi.fn(async () => ({ baseUrl, owned: true, pid: 123 })),
147
+ } as unknown as SymphonyRuntime["processManager"];
148
+ runtime.attach = vi.fn(async (_baseUrl: string) => {
149
+ runtime.state.attachedBaseUrl = baseUrl;
150
+ runtime.state.lastKnownState = lastKnownState(baseUrl);
151
+ return {};
152
+ }) as unknown as SymphonyRuntime["attach"];
153
+ const { tools } = registerTools(runtime);
154
+ const start = tools.get("symphony_start");
155
+ if (!start) throw new Error("expected start tool");
156
+ const { ctx } = toolContext({ cwd: dir });
157
+ const signal = new AbortController().signal;
158
+
159
+ await start.execute("1", {}, signal, undefined, ctx);
160
+
161
+ expect(runtime.processManager.start).toHaveBeenCalledWith(expect.objectContaining({ workflow: ".symphony/WORKFLOW.md" }));
162
+ });
163
+
164
+ it("rejects start with a helpful error when the default workflow is missing", async () => {
165
+ const dir = await mkdtemp(join(tmpdir(), "pi-symphony-start-tool-"));
166
+ const runtime = new SymphonyRuntime();
167
+ runtime.resolveBinary = vi.fn(async () => "symphony") as SymphonyRuntime["resolveBinary"];
168
+ runtime.processManager.start = vi.fn() as unknown as SymphonyRuntime["processManager"]["start"];
169
+ const { tools } = registerTools(runtime);
170
+ const start = tools.get("symphony_start");
171
+ if (!start) throw new Error("expected start tool");
172
+
173
+ await expect(start.execute("1", {}, new AbortController().signal, undefined, toolContext({ cwd: dir }).ctx)).rejects.toThrow("Symphony workflow file not found: .symphony/WORKFLOW.md");
174
+ expect(runtime.processManager.start).not.toHaveBeenCalled();
175
+ });
176
+
177
+ it("reports progress while starting Symphony", async () => {
178
+ const dir = await mkdtemp(join(tmpdir(), "pi-symphony-start-tool-"));
179
+ await writeFile(join(dir, "WORKFLOW.md"), "---\n---\n", "utf8");
180
+ const baseUrl = "http://127.0.0.1:8080";
181
+ const runtime = new SymphonyRuntime();
182
+ runtime.resolveBinary = vi.fn(async () => "symphony") as SymphonyRuntime["resolveBinary"];
183
+ runtime.processManager = {
184
+ start: vi.fn(async () => ({ baseUrl, owned: true, pid: 123 })),
185
+ } as unknown as SymphonyRuntime["processManager"];
186
+ runtime.attach = vi.fn(async (_baseUrl: string) => {
187
+ runtime.state.attachedBaseUrl = baseUrl;
188
+ runtime.state.lastKnownState = lastKnownState(baseUrl);
189
+ return {};
190
+ }) as unknown as SymphonyRuntime["attach"];
191
+ const { tools } = registerTools(runtime);
192
+ const start = tools.get("symphony_start");
193
+ if (!start) throw new Error("expected start tool");
194
+ const { ctx, setStatus } = toolContext({ cwd: dir });
195
+ const signal = new AbortController().signal;
196
+ const update = vi.fn();
197
+
198
+ const result = await start.execute("1", { workflow: "WORKFLOW.md" }, signal, update, ctx);
199
+
200
+ expect(update).toHaveBeenCalledWith(progressUpdate("Starting Symphony..."));
201
+ expect(runtime.processManager.start).toHaveBeenCalledWith({ binary: "symphony", cwd: dir, workflow: "WORKFLOW.md", signal });
202
+ expect(runtime.attach).toHaveBeenCalledWith(baseUrl, signal);
203
+ expect(setStatus).toHaveBeenCalledWith("symphony", `symphony ${baseUrl}`);
204
+ expect(result).toMatchObject({ content: [{ type: "text", text: `Symphony started at ${baseUrl}` }] });
205
+ });
206
+
207
+ it("requests a manual refresh from the tool", async () => {
208
+ const events: string[] = [];
209
+ const runtime = new SymphonyRuntime();
210
+ runtime.requestRefresh = vi.fn(async () => {
211
+ events.push("requestRefresh");
212
+ runtime.state.lastKnownState = lastKnownState("http://127.0.0.1:8080");
213
+ return {} as Awaited<ReturnType<SymphonyRuntime["requestRefresh"]>>;
214
+ }) as SymphonyRuntime["requestRefresh"];
215
+ const { tools, appendEntry } = registerTools(runtime);
216
+ const refresh = tools.get("symphony_refresh");
217
+ if (!refresh) throw new Error("expected refresh tool");
218
+ const update = vi.fn(() => events.push("update"));
219
+
220
+ const result = await refresh.execute("1", {}, new AbortController().signal, update, toolContext().ctx);
221
+
222
+ expect(update).toHaveBeenCalledWith(progressUpdate("Refreshing Symphony..."));
223
+ expect(events).toEqual(["update", "requestRefresh"]);
224
+ expect(runtime.requestRefresh).toHaveBeenCalledOnce();
225
+ expect(appendEntry).toHaveBeenCalled();
226
+ expect(result).toMatchObject({
227
+ content: [{ type: "text", text: "Symphony refresh requested" }],
228
+ details: { state: runtime.state.lastKnownState },
229
+ });
230
+ });
231
+
232
+ it("sends a steer instruction from the tool", async () => {
233
+ const events: string[] = [];
234
+ const runtime = new SymphonyRuntime();
235
+ runtime.steerWorker = vi.fn(async () => {
236
+ events.push("steerWorker");
237
+ return { ok: true, issueId: "issue-123", issueIdentifier: "SIM-123", delivered: true, instructionPreview: "Use auth" };
238
+ }) as SymphonyRuntime["steerWorker"];
239
+ const { tools, appendEntry } = registerTools(runtime);
240
+ const steer = tools.get("symphony_steer");
241
+ if (!steer) throw new Error("expected steer tool");
242
+ const update = vi.fn(() => events.push("update"));
243
+
244
+ const result = await steer.execute("1", { issueIdentifier: "SIM-123", instruction: "Use auth" }, new AbortController().signal, update, toolContext().ctx);
245
+
246
+ expect(update).toHaveBeenCalledWith(progressUpdate("Sending steer instruction..."));
247
+ expect(events).toEqual(["update", "steerWorker"]);
248
+ expect(runtime.steerWorker).toHaveBeenCalledWith("SIM-123", "Use auth", expect.any(AbortSignal));
249
+ expect(appendEntry).toHaveBeenCalled();
250
+ expect(result).toMatchObject({
251
+ content: [{ type: "text", text: "Steer delivered to SIM-123: Use auth" }],
252
+ details: { result: expect.objectContaining({ issueIdentifier: "SIM-123" }) },
253
+ });
254
+ });
255
+
256
+ it("attaches to the owned Symphony server when no tool URL is provided", async () => {
257
+ const baseUrl = "http://127.0.0.1:8080";
258
+ const runtime = new SymphonyRuntime();
259
+ runtime.state.ownedProcess = {
260
+ pid: 123,
261
+ command: "symphony --no-tui",
262
+ cwd: "/repo",
263
+ baseUrl,
264
+ startedAt: "2026-05-14T00:00:00.000Z",
265
+ };
266
+ runtime.attach = vi.fn(async (url: string) => {
267
+ runtime.state.attachedBaseUrl = url;
268
+ runtime.state.lastKnownState = lastKnownState(url);
269
+ return {};
270
+ }) as unknown as SymphonyRuntime["attach"];
271
+
272
+ const { tools } = registerTools(runtime);
273
+ const attach = tools.get("symphony_attach");
274
+ if (!attach) throw new Error("expected attach tool");
275
+ const { ctx, setStatus } = toolContext();
276
+ const signal = new AbortController().signal;
277
+
278
+ const result = await attach.execute("1", {}, signal, undefined, ctx);
279
+
280
+ expect(runtime.attach).toHaveBeenCalledWith(baseUrl, signal);
281
+ expect(setStatus).toHaveBeenCalledWith("symphony", `symphony ${baseUrl}`);
282
+ expect(result).toMatchObject({ content: [{ type: "text", text: `Attached to Symphony at ${baseUrl}` }] });
283
+ });
284
+
285
+ it("rejects attach without URL when no owned server is available", async () => {
286
+ const runtime = new SymphonyRuntime();
287
+ runtime.attach = vi.fn() as unknown as SymphonyRuntime["attach"];
288
+
289
+ const { tools } = registerTools(runtime);
290
+ const attach = tools.get("symphony_attach");
291
+ if (!attach) throw new Error("expected attach tool");
292
+
293
+ await expect(attach.execute("1", {}, new AbortController().signal, undefined, toolContext().ctx)).rejects.toThrow("No Symphony URL provided and no Pi-owned Symphony server is running");
294
+ expect(runtime.attach).not.toHaveBeenCalled();
295
+ });
296
+
297
+ it("restricts tool attach URLs to loopback hosts before attaching", async () => {
298
+ const runtime = new SymphonyRuntime();
299
+ runtime.attach = vi.fn(async (baseUrl: string) => {
300
+ runtime.state.attachedBaseUrl = baseUrl;
301
+ runtime.state.lastKnownState = lastKnownState(baseUrl);
302
+ return {};
303
+ }) as unknown as SymphonyRuntime["attach"];
304
+
305
+ const { tools } = registerTools(runtime);
306
+ const attach = tools.get("symphony_attach");
307
+ if (!attach) throw new Error("expected attach tool");
308
+ const { ctx, setStatus } = toolContext();
309
+ const signal = new AbortController().signal;
310
+
311
+ await expect(attach.execute("1", { url: "http://example.com:8080" }, signal, undefined, ctx)).rejects.toThrow("loopback host");
312
+ expect(runtime.attach).not.toHaveBeenCalled();
313
+
314
+ const update = vi.fn();
315
+ await attach.execute("2", { url: "http://localhost:8080" }, signal, update, ctx);
316
+
317
+ expect(update).toHaveBeenCalledWith(progressUpdate("Attaching to Symphony..."));
318
+ expect(runtime.attach).toHaveBeenCalledWith("http://localhost:8080", signal);
319
+ expect(setStatus).toHaveBeenCalledWith("symphony", "symphony http://localhost:8080");
320
+ });
321
+
322
+ it("clears an active owned attachment and updates status after tool stop", async () => {
323
+ const baseUrl = "http://127.0.0.1:8080";
324
+ const runtime = new SymphonyRuntime();
325
+ runtime.state.attachedBaseUrl = baseUrl;
326
+ runtime.state.ownedProcess = {
327
+ pid: 123,
328
+ command: "symphony --no-tui",
329
+ cwd: "/repo",
330
+ baseUrl,
331
+ startedAt: "2026-05-14T00:00:00.000Z",
332
+ };
333
+ runtime.state.lastKnownState = lastKnownState(baseUrl);
334
+ runtime.client = {} as SymphonyRuntime["client"];
335
+ runtime.processManager = {
336
+ stopOwned: vi.fn(async () => {
337
+ runtime.state.ownedProcess = undefined;
338
+ }),
339
+ } as unknown as SymphonyRuntime["processManager"];
340
+
341
+ const { tools, appendEntry } = registerTools(runtime);
342
+ const stop = tools.get("symphony_stop");
343
+ if (!stop) throw new Error("expected stop tool");
344
+ const { ctx, setStatus } = toolContext();
345
+ const update = vi.fn();
346
+
347
+ await stop.execute("1", {}, new AbortController().signal, update, ctx);
348
+
349
+ expect(update).toHaveBeenCalledWith(progressUpdate("Stopping Symphony..."));
350
+ expect(runtime.state.attachedBaseUrl).toBeUndefined();
351
+ expect(runtime.client).toBeUndefined();
352
+ expect(runtime.state.lastKnownState).toBeUndefined();
353
+ expect(appendEntry).toHaveBeenCalledWith(
354
+ "symphony-extension-state",
355
+ expect.objectContaining({ attachedBaseUrl: undefined, ownedProcess: undefined, lastKnownState: undefined }),
356
+ );
357
+ expect(setStatus).toHaveBeenCalledWith("symphony", "symphony detached");
358
+ });
359
+ });