@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.
- package/README.md +117 -0
- package/package.json +48 -0
- package/scripts/wave3-mock-server.mjs +249 -0
- package/src/attach-url-policy.test.ts +20 -0
- package/src/attach-url-policy.ts +24 -0
- package/src/binary-resolver.test.ts +114 -0
- package/src/binary-resolver.ts +115 -0
- package/src/command-args.test.ts +33 -0
- package/src/command-args.ts +58 -0
- package/src/commands.test.ts +679 -0
- package/src/commands.ts +218 -0
- package/src/console-model.test.ts +252 -0
- package/src/console-model.ts +234 -0
- package/src/console.test.ts +873 -0
- package/src/console.ts +552 -0
- package/src/errors.ts +33 -0
- package/src/event-stream.test.ts +145 -0
- package/src/event-stream.ts +107 -0
- package/src/http-client.test.ts +580 -0
- package/src/http-client.ts +856 -0
- package/src/index.test.ts +80 -0
- package/src/index.ts +27 -0
- package/src/package-smoke.test.ts +24 -0
- package/src/process-manager.test.ts +232 -0
- package/src/process-manager.ts +290 -0
- package/src/progress.test.ts +175 -0
- package/src/progress.ts +75 -0
- package/src/runtime-helpers.ts +11 -0
- package/src/runtime.test.ts +237 -0
- package/src/runtime.ts +119 -0
- package/src/state.test.ts +173 -0
- package/src/state.ts +146 -0
- package/src/tools.test.ts +359 -0
- package/src/tools.ts +201 -0
- package/src/wave3-mock-server.test.ts +83 -0
- package/src/workflow-resolver.ts +40 -0
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
import { createServer, type Server } from "node:http";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { SymphonyHttpClient } from "./http-client.ts";
|
|
4
|
+
import { SymphonyExtensionError } from "./errors.ts";
|
|
5
|
+
|
|
6
|
+
let server: Server | undefined;
|
|
7
|
+
|
|
8
|
+
async function serve(handler: (req: { method?: string; url?: string }, body: string) => { status: number; body: unknown; contentType?: string }): Promise<string> {
|
|
9
|
+
server = createServer((req, res) => {
|
|
10
|
+
let body = "";
|
|
11
|
+
req.on("data", (chunk) => (body += String(chunk)));
|
|
12
|
+
req.on("end", () => {
|
|
13
|
+
const response = handler(req, body);
|
|
14
|
+
res.statusCode = response.status;
|
|
15
|
+
res.setHeader("content-type", response.contentType ?? "application/json");
|
|
16
|
+
res.end(typeof response.body === "string" ? response.body : JSON.stringify(response.body));
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
await new Promise<void>((resolve) => server!.listen(0, "127.0.0.1", resolve));
|
|
20
|
+
const address = server.address();
|
|
21
|
+
if (!address || typeof address === "string") throw new Error("expected TCP address");
|
|
22
|
+
return `http://127.0.0.1:${address.port}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function validState(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
26
|
+
return {
|
|
27
|
+
tracker_project_url: "https://github.com/gannonh/kata/projects/1",
|
|
28
|
+
running: {
|
|
29
|
+
one: {
|
|
30
|
+
issue_id: "issue-1",
|
|
31
|
+
issue_identifier: "KAT-1",
|
|
32
|
+
workspace_path: "/tmp/symphony/issue-1",
|
|
33
|
+
started_at: "2026-05-14T12:00:00Z",
|
|
34
|
+
status: "running",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
retry_queue: [
|
|
38
|
+
{
|
|
39
|
+
issue_id: "issue-2",
|
|
40
|
+
identifier: "KAT-2",
|
|
41
|
+
attempt: 2,
|
|
42
|
+
due_in_ms: 1500,
|
|
43
|
+
error: "rate limited",
|
|
44
|
+
worker_host: "host-1",
|
|
45
|
+
workspace_path: "/tmp/symphony/issue-2",
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
blocked: [],
|
|
49
|
+
completed: [{ issue_id: "issue-3", identifier: "KAT-3", title: "Done", completed_at: null, issue_url: null }],
|
|
50
|
+
polling: { checking: false, next_poll_in_ms: 1000, poll_interval_ms: 30000, poll_count: 2 },
|
|
51
|
+
shared_context: { total_entries: 0, entries_by_scope: {}, oldest_entry_at: null, newest_entry_at: null },
|
|
52
|
+
supervisor: { active: true, steers_issued: 0, conflicts_detected: 0, patterns_detected: 0, escalations_created: 0 },
|
|
53
|
+
codex_totals: { input_tokens: 0, output_tokens: 0, total_tokens: 0, event_count: 0, seconds_running: 0 },
|
|
54
|
+
codex_rate_limits: null,
|
|
55
|
+
...overrides,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
afterEach(async () => {
|
|
60
|
+
vi.unstubAllGlobals();
|
|
61
|
+
if (!server) return;
|
|
62
|
+
await new Promise<void>((resolve, reject) => server!.close((error) => (error ? reject(error) : resolve())));
|
|
63
|
+
server = undefined;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("SymphonyHttpClient", () => {
|
|
67
|
+
it("fetches state and summarizes health", async () => {
|
|
68
|
+
const baseUrl = await serve((req) => {
|
|
69
|
+
expect(req.url).toBe("/api/v1/state");
|
|
70
|
+
return {
|
|
71
|
+
status: 200,
|
|
72
|
+
body: validState(),
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const client = new SymphonyHttpClient(baseUrl);
|
|
77
|
+
const state = await client.getState();
|
|
78
|
+
expect(state.tracker_project_url).toBe("https://github.com/gannonh/kata/projects/1");
|
|
79
|
+
expect(client.toHealthSummary(state).runningCount).toBe(1);
|
|
80
|
+
expect(client.toHealthSummary(state).retryCount).toBe(1);
|
|
81
|
+
expect(client.toHealthSummary(state).completedCount).toBe(1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("normalizes base URL path and query before requesting state", async () => {
|
|
85
|
+
const baseUrl = await serve((req) => {
|
|
86
|
+
expect(req.url).toBe("/dashboard/api/v1/state");
|
|
87
|
+
return { status: 200, body: validState() };
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const client = new SymphonyHttpClient(`${baseUrl}/dashboard///?debug=true#panel`);
|
|
91
|
+
expect(client.baseUrl).toBe(`${baseUrl}/dashboard`);
|
|
92
|
+
await expect(client.verify()).resolves.toMatchObject({ running: expect.any(Object) });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("normalizes Symphony API error envelopes", async () => {
|
|
96
|
+
const baseUrl = await serve(() => ({
|
|
97
|
+
status: 409,
|
|
98
|
+
body: { error: { code: "no_active_session", message: "issue has no active RPC session", status: 409 } },
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
const client = new SymphonyHttpClient(baseUrl);
|
|
102
|
+
await expect(client.getState()).rejects.toMatchObject({
|
|
103
|
+
kind: "api_error",
|
|
104
|
+
message: "issue has no active RPC session",
|
|
105
|
+
} satisfies Partial<SymphonyExtensionError>);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("fetches typed Wave 3 state entries", async () => {
|
|
109
|
+
const baseUrl = await serve((req) => {
|
|
110
|
+
expect(req.url).toBe("/api/v1/state");
|
|
111
|
+
return {
|
|
112
|
+
status: 200,
|
|
113
|
+
body: validState({
|
|
114
|
+
retry_queue: [
|
|
115
|
+
{
|
|
116
|
+
issue_id: "issue-retry-1",
|
|
117
|
+
identifier: "KAT-10",
|
|
118
|
+
attempt: 3,
|
|
119
|
+
due_in_ms: 2500,
|
|
120
|
+
error: null,
|
|
121
|
+
worker_host: "worker-a",
|
|
122
|
+
workspace_path: "/tmp/kata/KAT-10",
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
blocked: [
|
|
126
|
+
{
|
|
127
|
+
issue_id: "issue-blocked-1",
|
|
128
|
+
identifier: "KAT-11",
|
|
129
|
+
title: "Blocked issue",
|
|
130
|
+
state: "blocked",
|
|
131
|
+
blocker_identifiers: ["KAT-9"],
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
pending_escalations: [
|
|
135
|
+
{
|
|
136
|
+
request_id: "esc-1",
|
|
137
|
+
issue_id: "issue-pending-1",
|
|
138
|
+
issue_identifier: "KAT-12",
|
|
139
|
+
method: "request_approval",
|
|
140
|
+
preview: "Approve deploy",
|
|
141
|
+
created_at: "2026-05-14T12:00:00Z",
|
|
142
|
+
timeout_ms: 60000,
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
completed: [
|
|
146
|
+
{
|
|
147
|
+
issue_id: "issue-completed-1",
|
|
148
|
+
identifier: "KAT-13",
|
|
149
|
+
title: "Completed issue",
|
|
150
|
+
completed_at: "2026-05-14T13:00:00Z",
|
|
151
|
+
issue_url: "https://github.com/gannonh/kata/issues/13",
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
}),
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const client = new SymphonyHttpClient(baseUrl);
|
|
159
|
+
const state = await client.getState();
|
|
160
|
+
|
|
161
|
+
expect(state.retry_queue?.[0]).toMatchObject({ issue_id: "issue-retry-1", identifier: "KAT-10", attempt: 3, due_in_ms: 2500 });
|
|
162
|
+
expect(state.blocked?.[0]).toMatchObject({ issue_id: "issue-blocked-1", identifier: "KAT-11", title: "Blocked issue", blocker_identifiers: ["KAT-9"] });
|
|
163
|
+
expect(state.pending_escalations?.[0]).toMatchObject({ request_id: "esc-1", issue_identifier: "KAT-12", timeout_ms: 60000 });
|
|
164
|
+
expect(state.completed?.[0]).toMatchObject({ issue_id: "issue-completed-1", identifier: "KAT-13", title: "Completed issue" });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("fetches pending escalations", async () => {
|
|
168
|
+
const pending = [
|
|
169
|
+
{
|
|
170
|
+
request_id: "esc-1",
|
|
171
|
+
issue_id: "issue-1",
|
|
172
|
+
issue_identifier: "KAT-1",
|
|
173
|
+
method: "request_approval",
|
|
174
|
+
preview: "Approve deploy",
|
|
175
|
+
created_at: "2026-05-14T12:00:00Z",
|
|
176
|
+
timeout_ms: 60000,
|
|
177
|
+
},
|
|
178
|
+
];
|
|
179
|
+
const baseUrl = await serve((req) => {
|
|
180
|
+
expect(req.method).toBe("GET");
|
|
181
|
+
expect(req.url).toBe("/api/v1/escalations");
|
|
182
|
+
return { status: 200, body: { pending } };
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const client = new SymphonyHttpClient(baseUrl);
|
|
186
|
+
|
|
187
|
+
await expect(client.getEscalations()).resolves.toEqual({ pending });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("responds to a pending escalation", async () => {
|
|
191
|
+
const baseUrl = await serve((req, body) => {
|
|
192
|
+
expect(req.method).toBe("POST");
|
|
193
|
+
expect(req.url).toBe("/api/v1/escalations/esc-1/respond");
|
|
194
|
+
expect(JSON.parse(body)).toEqual({ response: { approved: true }, responder_id: "pi-dashboard" });
|
|
195
|
+
return { status: 200, body: { ok: true } };
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const client = new SymphonyHttpClient(baseUrl);
|
|
199
|
+
|
|
200
|
+
await expect(client.respondEscalation("esc-1", { approved: true })).resolves.toEqual({ ok: true });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it.each([
|
|
204
|
+
["missing", 404, "escalation_not_found"],
|
|
205
|
+
["resolved", 409, "escalation_already_resolved"],
|
|
206
|
+
])("normalizes simple escalation API errors: %s", async (_name, status, error) => {
|
|
207
|
+
const baseUrl = await serve(() => ({ status, body: { error } }));
|
|
208
|
+
const client = new SymphonyHttpClient(baseUrl);
|
|
209
|
+
|
|
210
|
+
await expect(client.getEscalations()).rejects.toMatchObject({
|
|
211
|
+
kind: "api_error",
|
|
212
|
+
message: error,
|
|
213
|
+
details: expect.objectContaining({ code: error, status }),
|
|
214
|
+
} satisfies Partial<SymphonyExtensionError>);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("fetches shared context with an encoded scope filter", async () => {
|
|
218
|
+
const entries = [
|
|
219
|
+
{
|
|
220
|
+
id: "ctx-1",
|
|
221
|
+
author_issue: "SIM-123",
|
|
222
|
+
scope: { type: "milestone", value: "M001" },
|
|
223
|
+
content: "Decision: use the existing auth module",
|
|
224
|
+
created_at: "2026-05-17T12:00:00Z",
|
|
225
|
+
ttl_ms: 3600000,
|
|
226
|
+
},
|
|
227
|
+
];
|
|
228
|
+
const summary = {
|
|
229
|
+
total_entries: 1,
|
|
230
|
+
entries_by_scope: { "milestone:M001": 1 },
|
|
231
|
+
oldest_entry_at: "2026-05-17T12:00:00Z",
|
|
232
|
+
newest_entry_at: "2026-05-17T12:00:00Z",
|
|
233
|
+
};
|
|
234
|
+
const baseUrl = await serve((req) => {
|
|
235
|
+
expect(req.method).toBe("GET");
|
|
236
|
+
expect(req.url).toBe("/api/v1/context?scope=milestone%3AM001");
|
|
237
|
+
return { status: 200, body: { entries, summary } };
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const client = new SymphonyHttpClient(baseUrl);
|
|
241
|
+
|
|
242
|
+
await expect(client.getContext("milestone:M001")).resolves.toEqual({ entries, summary });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it.each([
|
|
246
|
+
["getContext", (client: SymphonyHttpClient) => client.getContext(" ")],
|
|
247
|
+
["deleteContext", (client: SymphonyHttpClient) => client.deleteContext("")],
|
|
248
|
+
])("rejects empty shared context scope before sending %s", async (_name, call) => {
|
|
249
|
+
const fetchMock = vi.fn();
|
|
250
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
251
|
+
const client = new SymphonyHttpClient("http://127.0.0.1:8080");
|
|
252
|
+
|
|
253
|
+
await expect(call(client)).rejects.toThrow("Shared context scope must not be empty");
|
|
254
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("creates shared context entries", async () => {
|
|
258
|
+
const baseUrl = await serve((req, body) => {
|
|
259
|
+
expect(req.method).toBe("POST");
|
|
260
|
+
expect(req.url).toBe("/api/v1/context");
|
|
261
|
+
expect(JSON.parse(body)).toEqual({
|
|
262
|
+
author_issue: "SIM-123",
|
|
263
|
+
scope: "project",
|
|
264
|
+
content: "Decision: keep context in the extension package",
|
|
265
|
+
ttl_ms: 60000,
|
|
266
|
+
});
|
|
267
|
+
return { status: 201, body: { id: "ctx-2", created_at: "2026-05-17T12:01:00Z" } };
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const client = new SymphonyHttpClient(baseUrl);
|
|
271
|
+
|
|
272
|
+
await expect(
|
|
273
|
+
client.createContext({
|
|
274
|
+
authorIssue: "SIM-123",
|
|
275
|
+
scope: "project",
|
|
276
|
+
content: "Decision: keep context in the extension package",
|
|
277
|
+
ttlMs: 60000,
|
|
278
|
+
}),
|
|
279
|
+
).resolves.toEqual({ id: "ctx-2", created_at: "2026-05-17T12:01:00Z" });
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("deletes one shared context entry by id", async () => {
|
|
283
|
+
const baseUrl = await serve((req) => {
|
|
284
|
+
expect(req.method).toBe("DELETE");
|
|
285
|
+
expect(req.url).toBe("/api/v1/context/ctx-1");
|
|
286
|
+
return { status: 200, body: { deleted: 1 } };
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const client = new SymphonyHttpClient(baseUrl);
|
|
290
|
+
|
|
291
|
+
await expect(client.deleteContextEntry("ctx-1")).resolves.toEqual({ deleted: 1 });
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("clears shared context by scope", async () => {
|
|
295
|
+
const baseUrl = await serve((req) => {
|
|
296
|
+
expect(req.method).toBe("DELETE");
|
|
297
|
+
expect(req.url).toBe("/api/v1/context?scope=label%3Abackend");
|
|
298
|
+
return { status: 200, body: { deleted: 2 } };
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const client = new SymphonyHttpClient(baseUrl);
|
|
302
|
+
|
|
303
|
+
await expect(client.deleteContext("label:backend")).resolves.toEqual({ deleted: 2 });
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("fetches typed Wave 4 diagnostics from state", async () => {
|
|
307
|
+
const baseUrl = await serve(() => ({
|
|
308
|
+
status: 200,
|
|
309
|
+
body: validState({
|
|
310
|
+
shared_context: {
|
|
311
|
+
total_entries: 2,
|
|
312
|
+
entries_by_scope: { project: 1, "milestone:M001": 1 },
|
|
313
|
+
oldest_entry_at: "2026-05-17T12:00:00Z",
|
|
314
|
+
newest_entry_at: "2026-05-17T12:05:00Z",
|
|
315
|
+
},
|
|
316
|
+
supervisor: {
|
|
317
|
+
active: true,
|
|
318
|
+
steers_issued: 3,
|
|
319
|
+
conflicts_detected: 1,
|
|
320
|
+
patterns_detected: 2,
|
|
321
|
+
escalations_created: 4,
|
|
322
|
+
},
|
|
323
|
+
codex_totals: {
|
|
324
|
+
input_tokens: 1000,
|
|
325
|
+
output_tokens: 500,
|
|
326
|
+
total_tokens: 1500,
|
|
327
|
+
event_count: 12,
|
|
328
|
+
seconds_running: 90,
|
|
329
|
+
},
|
|
330
|
+
codex_rate_limits: {
|
|
331
|
+
requests: { remaining: 80, limit: 100, reset_seconds: 120 },
|
|
332
|
+
},
|
|
333
|
+
polling: {
|
|
334
|
+
checking: true,
|
|
335
|
+
next_poll_in_ms: 2500,
|
|
336
|
+
poll_interval_ms: 30000,
|
|
337
|
+
poll_count: 7,
|
|
338
|
+
last_poll_at: "2026-05-17T12:05:00Z",
|
|
339
|
+
},
|
|
340
|
+
}),
|
|
341
|
+
}));
|
|
342
|
+
|
|
343
|
+
const client = new SymphonyHttpClient(baseUrl);
|
|
344
|
+
const state = await client.getState();
|
|
345
|
+
|
|
346
|
+
expect(state.shared_context).toMatchObject({ total_entries: 2 });
|
|
347
|
+
expect(state.supervisor).toMatchObject({ active: true, steers_issued: 3 });
|
|
348
|
+
expect(state.codex_totals).toMatchObject({ total_tokens: 1500, event_count: 12 });
|
|
349
|
+
expect(state.codex_rate_limits).toMatchObject({ requests: { remaining: 80, limit: 100 } });
|
|
350
|
+
expect(state.polling?.poll_count).toBe(7);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it.each([
|
|
354
|
+
["shared_context.total_entries", validState({ shared_context: { entries_by_scope: {}, oldest_entry_at: null, newest_entry_at: null } })],
|
|
355
|
+
["shared_context.entries_by_scope", validState({ shared_context: { total_entries: 1, entries_by_scope: [], oldest_entry_at: null, newest_entry_at: null } })],
|
|
356
|
+
["shared_context.oldest_entry_at", validState({ shared_context: { total_entries: 1, entries_by_scope: {}, newest_entry_at: null } })],
|
|
357
|
+
["shared_context.newest_entry_at", validState({ shared_context: { total_entries: 1, entries_by_scope: {}, oldest_entry_at: null } })],
|
|
358
|
+
["supervisor.steers_issued", validState({ supervisor: { active: true, conflicts_detected: 0, patterns_detected: 0, escalations_created: 0 } })],
|
|
359
|
+
["codex_totals.total_tokens", validState({ codex_totals: { input_tokens: 1, output_tokens: 2, event_count: 3, seconds_running: 4 } })],
|
|
360
|
+
])("rejects malformed Wave 4 state field: %s", async (field, body) => {
|
|
361
|
+
const baseUrl = await serve(() => ({ status: 200, body }));
|
|
362
|
+
const client = new SymphonyHttpClient(baseUrl);
|
|
363
|
+
|
|
364
|
+
await expect(client.getState()).rejects.toMatchObject({
|
|
365
|
+
kind: "non_symphony_response",
|
|
366
|
+
details: expect.objectContaining({ field }),
|
|
367
|
+
} satisfies Partial<SymphonyExtensionError>);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it.each([
|
|
371
|
+
["retry_queue.0.issue_id", validState({ retry_queue: [{ identifier: "KAT-2", attempt: 1, due_in_ms: 1000 }] })],
|
|
372
|
+
[
|
|
373
|
+
"blocked.0.title",
|
|
374
|
+
validState({
|
|
375
|
+
blocked: [{ issue_id: "issue-2", identifier: "KAT-2", state: "blocked", blocker_identifiers: [] }],
|
|
376
|
+
}),
|
|
377
|
+
],
|
|
378
|
+
[
|
|
379
|
+
"pending_escalations.0.issue_id",
|
|
380
|
+
validState({
|
|
381
|
+
pending_escalations: [
|
|
382
|
+
{
|
|
383
|
+
request_id: "esc-1",
|
|
384
|
+
issue_identifier: "KAT-2",
|
|
385
|
+
method: "request_approval",
|
|
386
|
+
preview: "Approve deploy",
|
|
387
|
+
created_at: "2026-05-14T12:00:00Z",
|
|
388
|
+
timeout_ms: 60000,
|
|
389
|
+
},
|
|
390
|
+
],
|
|
391
|
+
}),
|
|
392
|
+
],
|
|
393
|
+
["completed.0.title", validState({ completed: [{ issue_id: "issue-3", identifier: "KAT-3" }] })],
|
|
394
|
+
])("rejects malformed Wave 3 state entry: %s", async (field, body) => {
|
|
395
|
+
const baseUrl = await serve(() => ({ status: 200, body }));
|
|
396
|
+
const client = new SymphonyHttpClient(baseUrl);
|
|
397
|
+
|
|
398
|
+
await expect(client.getState()).rejects.toMatchObject({
|
|
399
|
+
kind: "non_symphony_response",
|
|
400
|
+
details: expect.objectContaining({ field }),
|
|
401
|
+
} satisfies Partial<SymphonyExtensionError>);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("requests a Symphony poll refresh", async () => {
|
|
405
|
+
const baseUrl = await serve((req) => {
|
|
406
|
+
expect(req.method).toBe("POST");
|
|
407
|
+
expect(req.url).toBe("/api/v1/refresh");
|
|
408
|
+
return { status: 202, body: { queued: true, coalesced: false, pending_requests: 1 } };
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
const client = new SymphonyHttpClient(baseUrl);
|
|
412
|
+
|
|
413
|
+
await expect(client.refresh()).resolves.toEqual({ queued: true, coalesced: false, pendingRequests: 1 });
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("sends a steer instruction for a running issue", async () => {
|
|
417
|
+
const baseUrl = await serve((req, body) => {
|
|
418
|
+
expect(req.method).toBe("POST");
|
|
419
|
+
expect(req.url).toBe("/api/v1/steer");
|
|
420
|
+
expect(JSON.parse(body)).toEqual({ issue_identifier: "SIM-123", instruction: "Use the existing auth module" });
|
|
421
|
+
return {
|
|
422
|
+
status: 200,
|
|
423
|
+
body: {
|
|
424
|
+
ok: true,
|
|
425
|
+
issue_id: "issue-123",
|
|
426
|
+
issue_identifier: "SIM-123",
|
|
427
|
+
delivered: true,
|
|
428
|
+
instruction_preview: "Use the existing auth module",
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const client = new SymphonyHttpClient(baseUrl);
|
|
434
|
+
|
|
435
|
+
await expect(client.steer("SIM-123", "Use the existing auth module")).resolves.toEqual({
|
|
436
|
+
ok: true,
|
|
437
|
+
issueId: "issue-123",
|
|
438
|
+
issueIdentifier: "SIM-123",
|
|
439
|
+
delivered: true,
|
|
440
|
+
instructionPreview: "Use the existing auth module",
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("rejects malformed refresh responses", async () => {
|
|
445
|
+
const baseUrl = await serve(() => ({ status: 202, body: { queued: true } }));
|
|
446
|
+
const client = new SymphonyHttpClient(baseUrl);
|
|
447
|
+
|
|
448
|
+
await expect(client.refresh()).rejects.toMatchObject({
|
|
449
|
+
kind: "non_symphony_response",
|
|
450
|
+
message: "Response did not look like Symphony refresh response",
|
|
451
|
+
} satisfies Partial<SymphonyExtensionError>);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("normalizes steer API errors", async () => {
|
|
455
|
+
const baseUrl = await serve(() => ({
|
|
456
|
+
status: 404,
|
|
457
|
+
body: { error: { code: "issue_not_running", message: "issue is not running", status: 404 } },
|
|
458
|
+
}));
|
|
459
|
+
const client = new SymphonyHttpClient(baseUrl);
|
|
460
|
+
|
|
461
|
+
await expect(client.steer("SIM-404", "check logs")).rejects.toMatchObject({
|
|
462
|
+
kind: "api_error",
|
|
463
|
+
message: "issue is not running",
|
|
464
|
+
details: expect.objectContaining({ code: "issue_not_running", status: 404 }),
|
|
465
|
+
} satisfies Partial<SymphonyExtensionError>);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("normalizes non-OK null JSON responses", async () => {
|
|
469
|
+
const baseUrl = await serve(() => ({ status: 500, body: null }));
|
|
470
|
+
const client = new SymphonyHttpClient(baseUrl);
|
|
471
|
+
|
|
472
|
+
await expect(client.getState()).rejects.toMatchObject({
|
|
473
|
+
kind: "non_symphony_response",
|
|
474
|
+
message: "Symphony HTTP API returned an unexpected error response",
|
|
475
|
+
details: expect.objectContaining({ status: 500, body: null }),
|
|
476
|
+
} satisfies Partial<SymphonyExtensionError>);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it.each([
|
|
480
|
+
["array", []],
|
|
481
|
+
["empty object", {}],
|
|
482
|
+
["unrelated object", { ok: true }],
|
|
483
|
+
])("rejects invalid state shape: %s", async (_name, body) => {
|
|
484
|
+
const baseUrl = await serve(() => ({ status: 200, body }));
|
|
485
|
+
const client = new SymphonyHttpClient(baseUrl);
|
|
486
|
+
|
|
487
|
+
await expect(client.verify()).rejects.toMatchObject({
|
|
488
|
+
kind: "non_symphony_response",
|
|
489
|
+
message: "Response did not look like Symphony state",
|
|
490
|
+
} satisfies Partial<SymphonyExtensionError>);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it.each([
|
|
494
|
+
["running", validState({ running: [] }), "running"],
|
|
495
|
+
["running entry", validState({ running: { one: { issue_identifier: "KAT-1" } } }), "running.one.issue_id"],
|
|
496
|
+
["retry_queue", validState({ retry_queue: {} }), "retry_queue"],
|
|
497
|
+
["polling.next_poll_in_ms", validState({ polling: { checking: false, next_poll_in_ms: "1000", poll_interval_ms: 30000 } }), "polling.next_poll_in_ms"],
|
|
498
|
+
])("rejects malformed state field: %s", async (_name, body, field) => {
|
|
499
|
+
const baseUrl = await serve(() => ({ status: 200, body }));
|
|
500
|
+
const client = new SymphonyHttpClient(baseUrl);
|
|
501
|
+
|
|
502
|
+
await expect(client.getState()).rejects.toMatchObject({
|
|
503
|
+
kind: "non_symphony_response",
|
|
504
|
+
details: expect.objectContaining({ field }),
|
|
505
|
+
} satisfies Partial<SymphonyExtensionError>);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("rejects state missing blocked", async () => {
|
|
509
|
+
const stateMissingBlocked = validState();
|
|
510
|
+
delete stateMissingBlocked.blocked;
|
|
511
|
+
const baseUrl = await serve(() => ({ status: 200, body: stateMissingBlocked }));
|
|
512
|
+
const client = new SymphonyHttpClient(baseUrl);
|
|
513
|
+
|
|
514
|
+
await expect(client.getState()).rejects.toMatchObject({
|
|
515
|
+
kind: "non_symphony_response",
|
|
516
|
+
details: expect.objectContaining({ missingFields: ["blocked"] }),
|
|
517
|
+
} satisfies Partial<SymphonyExtensionError>);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("rejects invalid JSON", async () => {
|
|
521
|
+
const baseUrl = await serve(() => ({ status: 200, body: "not-json", contentType: "application/json" }));
|
|
522
|
+
const client = new SymphonyHttpClient(baseUrl);
|
|
523
|
+
await expect(client.getState()).rejects.toMatchObject({ kind: "invalid_json" } satisfies Partial<SymphonyExtensionError>);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("normalizes unreachable fetch failures", async () => {
|
|
527
|
+
const fetchStub: typeof fetch = async () => {
|
|
528
|
+
throw new Error("connect ECONNREFUSED");
|
|
529
|
+
};
|
|
530
|
+
vi.stubGlobal("fetch", fetchStub);
|
|
531
|
+
|
|
532
|
+
const client = new SymphonyHttpClient("http://127.0.0.1:65535");
|
|
533
|
+
await expect(client.getState()).rejects.toMatchObject({
|
|
534
|
+
kind: "attach_unreachable",
|
|
535
|
+
details: expect.objectContaining({ cause: "connect ECONNREFUSED" }),
|
|
536
|
+
} satisfies Partial<SymphonyExtensionError>);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it("preserves fetch abort cancellation", async () => {
|
|
540
|
+
const controller = new AbortController();
|
|
541
|
+
controller.abort();
|
|
542
|
+
|
|
543
|
+
const client = new SymphonyHttpClient("http://127.0.0.1:65535");
|
|
544
|
+
await expect(client.getState(controller.signal)).rejects.toMatchObject({ name: "AbortError" });
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it("preserves body read abort cancellation", async () => {
|
|
548
|
+
const fetchStub: typeof fetch = async () =>
|
|
549
|
+
({
|
|
550
|
+
ok: true,
|
|
551
|
+
status: 200,
|
|
552
|
+
text: async () => {
|
|
553
|
+
throw new DOMException("The operation was aborted.", "AbortError");
|
|
554
|
+
},
|
|
555
|
+
}) as unknown as Response;
|
|
556
|
+
vi.stubGlobal("fetch", fetchStub);
|
|
557
|
+
|
|
558
|
+
const client = new SymphonyHttpClient("http://127.0.0.1:65535");
|
|
559
|
+
await expect(client.getState(new AbortController().signal)).rejects.toMatchObject({ name: "AbortError" });
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it("normalizes non-abort body read failures", async () => {
|
|
563
|
+
const fetchStub: typeof fetch = async () =>
|
|
564
|
+
({
|
|
565
|
+
ok: true,
|
|
566
|
+
status: 200,
|
|
567
|
+
text: async () => {
|
|
568
|
+
throw new Error("terminated");
|
|
569
|
+
},
|
|
570
|
+
}) as unknown as Response;
|
|
571
|
+
vi.stubGlobal("fetch", fetchStub);
|
|
572
|
+
|
|
573
|
+
const client = new SymphonyHttpClient("http://127.0.0.1:65535");
|
|
574
|
+
await expect(client.getState()).rejects.toMatchObject({
|
|
575
|
+
kind: "non_symphony_response",
|
|
576
|
+
message: "Could not read Symphony HTTP API response body",
|
|
577
|
+
details: expect.objectContaining({ status: 200, cause: "terminated" }),
|
|
578
|
+
} satisfies Partial<SymphonyExtensionError>);
|
|
579
|
+
});
|
|
580
|
+
});
|