@fusionkit/cli 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.
Files changed (61) hide show
  1. package/dist/cli.d.ts +8 -0
  2. package/dist/cli.js +34 -0
  3. package/dist/commands/ensemble-gateway.d.ts +2 -0
  4. package/dist/commands/ensemble-gateway.js +114 -0
  5. package/dist/commands/ensemble-records.d.ts +33 -0
  6. package/dist/commands/ensemble-records.js +207 -0
  7. package/dist/commands/ensemble.d.ts +2 -0
  8. package/dist/commands/ensemble.js +254 -0
  9. package/dist/commands/fusion.d.ts +2 -0
  10. package/dist/commands/fusion.js +112 -0
  11. package/dist/commands/init.d.ts +2 -0
  12. package/dist/commands/init.js +24 -0
  13. package/dist/commands/lifecycle.d.ts +2 -0
  14. package/dist/commands/lifecycle.js +124 -0
  15. package/dist/commands/local.d.ts +2 -0
  16. package/dist/commands/local.js +25 -0
  17. package/dist/commands/plane.d.ts +2 -0
  18. package/dist/commands/plane.js +30 -0
  19. package/dist/commands/run.d.ts +2 -0
  20. package/dist/commands/run.js +149 -0
  21. package/dist/commands/runner.d.ts +2 -0
  22. package/dist/commands/runner.js +33 -0
  23. package/dist/commands/secrets.d.ts +2 -0
  24. package/dist/commands/secrets.js +21 -0
  25. package/dist/config.d.ts +30 -0
  26. package/dist/config.js +69 -0
  27. package/dist/fusion-quickstart.d.ts +182 -0
  28. package/dist/fusion-quickstart.js +673 -0
  29. package/dist/gateway.d.ts +63 -0
  30. package/dist/gateway.js +304 -0
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.js +28 -0
  33. package/dist/local.d.ts +40 -0
  34. package/dist/local.js +144 -0
  35. package/dist/render.d.ts +7 -0
  36. package/dist/render.js +131 -0
  37. package/dist/shared/errors.d.ts +6 -0
  38. package/dist/shared/errors.js +9 -0
  39. package/dist/shared/options.d.ts +24 -0
  40. package/dist/shared/options.js +106 -0
  41. package/dist/shared/plane.d.ts +13 -0
  42. package/dist/shared/plane.js +46 -0
  43. package/dist/shared/preflight.d.ts +15 -0
  44. package/dist/shared/preflight.js +48 -0
  45. package/dist/shared/proc.d.ts +41 -0
  46. package/dist/shared/proc.js +122 -0
  47. package/dist/test/cli.test.d.ts +1 -0
  48. package/dist/test/cli.test.js +867 -0
  49. package/dist/test/e2e.test.d.ts +1 -0
  50. package/dist/test/e2e.test.js +250 -0
  51. package/dist/test/fusion-quickstart.test.d.ts +1 -0
  52. package/dist/test/fusion-quickstart.test.js +189 -0
  53. package/dist/test/gateway-e2e.test.d.ts +1 -0
  54. package/dist/test/gateway-e2e.test.js +606 -0
  55. package/dist/test/handoff.test.d.ts +1 -0
  56. package/dist/test/handoff.test.js +212 -0
  57. package/dist/test/local.test.d.ts +1 -0
  58. package/dist/test/local.test.js +39 -0
  59. package/dist/test/proc.test.d.ts +1 -0
  60. package/dist/test/proc.test.js +22 -0
  61. package/package.json +48 -0
@@ -0,0 +1,606 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import { createServer } from "node:http";
4
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import readline from "node:readline";
8
+ import { PassThrough } from "node:stream";
9
+ import { fileURLToPath } from "node:url";
10
+ import { after, before, test } from "node:test";
11
+ import { FUSION_REPORT_HEADER, FUSION_RUN_ID_HEADER, runAcpAgent, runFrontDoorAcceptance } from "@fusionkit/model-gateway";
12
+ import { buildAcpRunner, startConfiguredGateway } from "../gateway.js";
13
+ /**
14
+ * Comprehensive front-door e2e. Exercises the real chain end to end:
15
+ *
16
+ * native front door (Codex Responses / Claude Messages / Cursorkit Chat / ACP)
17
+ * -> real Fusion Harness Gateway
18
+ * -> real runUnifiedHarnessE2E
19
+ * -> two panel models, each in its own git worktree
20
+ * -> a real command harness that patches code and runs the failing test
21
+ * -> FusionKit-backed judge synthesis
22
+ * -> native-shaped response
23
+ *
24
+ * Assertions read the on-disk unified report and the candidate patch artifacts
25
+ * so we validate genuine per-model isolation, patch/test/tool evidence, and
26
+ * judge synthesis — not just a stubbed sentinel.
27
+ */
28
+ const SENTINEL = "FUSION_OK";
29
+ async function readBody(req) {
30
+ const chunks = [];
31
+ for await (const chunk of req)
32
+ chunks.push(chunk);
33
+ return Buffer.concat(chunks);
34
+ }
35
+ async function closeServer(server) {
36
+ await new Promise((resolve, reject) => {
37
+ server.close((error) => (error ? reject(error) : resolve()));
38
+ });
39
+ }
40
+ /**
41
+ * A realistic local OpenAI-compatible model backend that stands in for
42
+ * FusionKit. The judge synthesizer calls `/v1/chat/completions`; we echo the
43
+ * candidate evidence count so the synthesized answer is grounded in the run.
44
+ */
45
+ async function startModelBackend() {
46
+ let judgeCalls = 0;
47
+ const server = createServer((req, res) => {
48
+ void (async () => {
49
+ const path = new URL(req.url ?? "/", "http://localhost").pathname;
50
+ if (req.method === "GET" && path === "/v1/models") {
51
+ res.writeHead(200, { "content-type": "application/json" });
52
+ res.end(JSON.stringify({ object: "list", data: [{ id: "local-fusion", object: "model" }] }));
53
+ return;
54
+ }
55
+ if (req.method === "POST" && path === "/v1/chat/completions") {
56
+ const body = JSON.parse((await readBody(req)).toString("utf8"));
57
+ const isJudge = (body.messages ?? []).some((message) => message.role === "system" &&
58
+ typeof message.content === "string" &&
59
+ message.content.includes("synthesize coding harness candidate evidence"));
60
+ if (isJudge)
61
+ judgeCalls += 1;
62
+ const content = `${SENTINEL}: synthesized calculator fix from ${body.model ?? "model"}`;
63
+ res.writeHead(200, { "content-type": "application/json" });
64
+ res.end(JSON.stringify({
65
+ id: "chatcmpl_e2e",
66
+ model: body.model ?? "local-fusion",
67
+ choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
68
+ usage: { prompt_tokens: 8, completion_tokens: 8, total_tokens: 16 }
69
+ }));
70
+ return;
71
+ }
72
+ res.writeHead(404).end();
73
+ })().catch((error) => {
74
+ res.writeHead(500, { "content-type": "application/json" });
75
+ res.end(JSON.stringify({ error: String(error) }));
76
+ });
77
+ });
78
+ await new Promise((resolve, reject) => {
79
+ server.once("error", reject);
80
+ server.listen(0, "127.0.0.1", () => {
81
+ server.off("error", reject);
82
+ resolve();
83
+ });
84
+ });
85
+ const address = server.address();
86
+ assert.ok(typeof address === "object" && address !== null);
87
+ return {
88
+ url: `http://127.0.0.1:${address.port}`,
89
+ judgeCallCount: () => judgeCalls,
90
+ close: () => closeServer(server)
91
+ };
92
+ }
93
+ /**
94
+ * A repo with a real bug: `add` subtracts. `solve.js` applies the fix, runs the
95
+ * failing unit test (which throws unless the fix is correct), and records the
96
+ * harness-injected model id — proving per-candidate environment isolation.
97
+ */
98
+ function makeCodingRepo() {
99
+ const root = mkdtempSync(join(tmpdir(), "gateway-e2e-"));
100
+ const repo = join(root, "repo");
101
+ mkdirSync(repo);
102
+ spawnSync("git", ["init", "--quiet", "--initial-branch=main"], { cwd: repo });
103
+ spawnSync("git", ["config", "user.email", "e2e@warrant.local"], { cwd: repo });
104
+ spawnSync("git", ["config", "user.name", "warrant-e2e"], { cwd: repo });
105
+ writeFileSync(join(repo, "calculator.js"), "exports.add = (left, right) => left - right;\n");
106
+ writeFileSync(join(repo, "calculator.test.js"), [
107
+ "const assert = require('node:assert/strict');",
108
+ "const { add } = require('./calculator.js');",
109
+ "assert.equal(add(2, 3), 5);",
110
+ "console.log('TEST_OK');",
111
+ ""
112
+ ].join("\n"));
113
+ writeFileSync(join(repo, "solve.js"), [
114
+ "const fs = require('node:fs');",
115
+ "fs.writeFileSync('calculator.js', 'exports.add = (left, right) => left + right;\\n');",
116
+ "require('./calculator.test.js');",
117
+ "fs.writeFileSync('SOLVED_BY.txt', `${process.env.HARNESS_MODEL_ID}\\n`);",
118
+ "console.log('SOLVE_OK');",
119
+ ""
120
+ ].join("\n"));
121
+ spawnSync("git", ["add", "-A"], { cwd: repo });
122
+ spawnSync("git", ["commit", "--quiet", "-m", "failing calculator fixture"], { cwd: repo });
123
+ return { root, repo, cleanup: () => rmSync(root, { recursive: true, force: true }) };
124
+ }
125
+ function readPatch(uri) {
126
+ return readFileSync(fileURLToPath(uri), "utf8");
127
+ }
128
+ let backend;
129
+ let gateway;
130
+ let fixture;
131
+ let config;
132
+ before(async () => {
133
+ backend = await startModelBackend();
134
+ fixture = makeCodingRepo();
135
+ config = {
136
+ fusionBackendUrl: backend.url,
137
+ repo: fixture.repo,
138
+ outputRoot: join(fixture.root, "gateway-runs"),
139
+ harnesses: ["command"],
140
+ models: [
141
+ { id: "alpha", model: "fusion-alpha" },
142
+ { id: "beta", model: "fusion-beta" }
143
+ ],
144
+ command: "node solve.js",
145
+ judgeModel: "fusion-judge",
146
+ timeoutMs: 60_000
147
+ };
148
+ gateway = await startConfiguredGateway({ config, host: "127.0.0.1", port: 0 });
149
+ });
150
+ after(async () => {
151
+ await gateway.close();
152
+ await backend.close();
153
+ fixture.cleanup();
154
+ });
155
+ test("Codex Responses front door drives the full multi-model panel with real patch/test/judge evidence", async () => {
156
+ const response = await fetch(`${gateway.url()}/v1/responses`, {
157
+ method: "POST",
158
+ headers: { "content-type": "application/json" },
159
+ body: JSON.stringify({
160
+ model: "fusion-panel",
161
+ instructions: "Fix the add() bug so the test passes.",
162
+ input: [
163
+ {
164
+ role: "user",
165
+ content: [{ type: "input_text", text: "calculator.test.js fails; make it pass." }]
166
+ }
167
+ ]
168
+ })
169
+ });
170
+ assert.equal(response.status, 200);
171
+ assert.equal(response.headers.get(FUSION_RUN_ID_HEADER)?.startsWith("gateway_"), true);
172
+ // Native Codex Responses shape carrying the judge's synthesized answer.
173
+ const body = (await response.json());
174
+ assert.equal(body.object, "response");
175
+ assert.match(body.output[0]?.content[0]?.text ?? "", new RegExp(SENTINEL));
176
+ // Full provenance is pointed to by the report header.
177
+ const reportPath = response.headers.get(FUSION_REPORT_HEADER);
178
+ assert.ok(reportPath, "expected x-fusion-report header");
179
+ const report = JSON.parse(readFileSync(reportPath, "utf8"));
180
+ const ensemble = report.results.find((row) => row.harness === "command")?.ensemble;
181
+ assert.ok(ensemble, "expected a command ensemble result");
182
+ // Two panel models -> two candidates -> two distinct worktrees.
183
+ assert.equal(ensemble.candidates.length, 2);
184
+ const worktrees = new Set(ensemble.candidates.map((candidate) => candidate.worktree_path));
185
+ assert.equal(worktrees.size, 2, "each model must run in its own worktree");
186
+ assert.deepEqual(ensemble.candidates.map((candidate) => candidate.metadata?.model_id).sort(), ["alpha", "beta"]);
187
+ // Every candidate succeeded and produced a real patch that applies the fix
188
+ // and is attributed to that candidate's injected model id.
189
+ for (const candidate of ensemble.candidates) {
190
+ assert.equal(candidate.status, "succeeded");
191
+ const patch = candidate.artifacts?.find((artifact) => artifact.kind === "patch");
192
+ assert.ok(patch?.uri, `candidate ${candidate.candidate_id} must have a patch artifact`);
193
+ const patchText = readPatch(patch.uri);
194
+ assert.match(patchText, /left \+ right/);
195
+ assert.match(patchText, new RegExp(candidate.metadata?.model_id ?? "model"));
196
+ }
197
+ // Real tool-execution evidence and judge synthesis.
198
+ assert.ok(ensemble.toolRecords.length >= 2);
199
+ assert.ok(ensemble.toolRecords.every((record) => record.status === "succeeded"));
200
+ assert.equal(ensemble.judgeSynthesisRecord?.status, "succeeded");
201
+ assert.match(ensemble.judgeSynthesisRecord?.final_output ?? "", new RegExp(SENTINEL));
202
+ });
203
+ test("Claude Messages front door returns native Anthropic shape backed by a real run", async () => {
204
+ const response = await fetch(`${gateway.url()}/v1/messages`, {
205
+ method: "POST",
206
+ headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" },
207
+ body: JSON.stringify({
208
+ model: "fusion-panel",
209
+ max_tokens: 512,
210
+ messages: [{ role: "user", content: "Fix calculator add() so the test passes." }]
211
+ })
212
+ });
213
+ assert.equal(response.status, 200);
214
+ const body = (await response.json());
215
+ assert.equal(body.type, "message");
216
+ assert.equal(body.role, "assistant");
217
+ assert.match(body.content[0]?.text ?? "", new RegExp(SENTINEL));
218
+ const reportPath = response.headers.get(FUSION_REPORT_HEADER);
219
+ assert.ok(reportPath);
220
+ const report = JSON.parse(readFileSync(reportPath, "utf8"));
221
+ const ensemble = report.results[0]?.ensemble;
222
+ assert.equal(ensemble?.candidates.length, 2);
223
+ assert.equal(ensemble?.judgeSynthesisRecord?.status, "succeeded");
224
+ });
225
+ test("Cursorkit chat front door returns native chat-completion shape backed by a real run", async () => {
226
+ const response = await fetch(`${gateway.url()}/v1/chat/completions`, {
227
+ method: "POST",
228
+ headers: { "content-type": "application/json" },
229
+ body: JSON.stringify({
230
+ model: "fusion-panel",
231
+ messages: [{ role: "user", content: "Fix calculator add() so the test passes." }]
232
+ })
233
+ });
234
+ assert.equal(response.status, 200);
235
+ const body = (await response.json());
236
+ assert.equal(body.object, "chat.completion");
237
+ assert.equal(body.choices[0]?.message.role, "assistant");
238
+ assert.match(body.choices[0]?.message.content ?? "", new RegExp(SENTINEL));
239
+ });
240
+ function sseEvents(raw) {
241
+ const events = [];
242
+ for (const block of raw.split("\n\n")) {
243
+ let event;
244
+ const dataLines = [];
245
+ for (const line of block.split("\n")) {
246
+ if (line.startsWith("event:"))
247
+ event = line.slice(6).trim();
248
+ else if (line.startsWith("data:"))
249
+ dataLines.push(line.slice(5).trim());
250
+ }
251
+ if (dataLines.length === 0)
252
+ continue;
253
+ const payload = dataLines.join("\n");
254
+ if (payload === "[DONE]") {
255
+ events.push({ event, data: "[DONE]" });
256
+ continue;
257
+ }
258
+ try {
259
+ events.push({ event, data: JSON.parse(payload) });
260
+ }
261
+ catch {
262
+ // ignore partial frames
263
+ }
264
+ }
265
+ return events;
266
+ }
267
+ test("Codex Responses streaming emits a response.completed SSE sequence carrying the synthesized answer", async () => {
268
+ const response = await fetch(`${gateway.url()}/v1/responses`, {
269
+ method: "POST",
270
+ headers: { "content-type": "application/json" },
271
+ body: JSON.stringify({
272
+ model: "fusion-panel",
273
+ stream: true,
274
+ input: [{ role: "user", content: [{ type: "input_text", text: "Fix add() and report." }] }]
275
+ })
276
+ });
277
+ assert.equal(response.status, 200);
278
+ assert.match(response.headers.get("content-type") ?? "", /text\/event-stream/);
279
+ const events = sseEvents(await response.text());
280
+ const types = events
281
+ .map((event) => (typeof event.data === "object" && event.data !== null ? event.data.type : undefined))
282
+ .filter((value) => typeof value === "string");
283
+ assert.ok(types.includes("response.created"), "must open with response.created");
284
+ assert.ok(types.includes("response.output_text.delta"), "must stream output text deltas");
285
+ assert.ok(types.includes("response.completed"), "must close with response.completed");
286
+ const completed = events.find((event) => typeof event.data === "object" && event.data !== null && event.data.type === "response.completed")?.data;
287
+ assert.match(completed?.response?.output?.[0]?.content?.[0]?.text ?? "", new RegExp(SENTINEL));
288
+ });
289
+ test("Anthropic Messages streaming emits message_stop SSE carrying the synthesized answer", async () => {
290
+ const response = await fetch(`${gateway.url()}/v1/messages`, {
291
+ method: "POST",
292
+ headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" },
293
+ body: JSON.stringify({
294
+ model: "fusion-panel",
295
+ stream: true,
296
+ max_tokens: 256,
297
+ messages: [{ role: "user", content: "Fix calculator add() and report." }]
298
+ })
299
+ });
300
+ assert.equal(response.status, 200);
301
+ assert.match(response.headers.get("content-type") ?? "", /text\/event-stream/);
302
+ const events = sseEvents(await response.text());
303
+ const types = events
304
+ .map((event) => (typeof event.data === "object" && event.data !== null ? event.data.type : undefined))
305
+ .filter((value) => typeof value === "string");
306
+ assert.ok(types.includes("message_start"), "must open with message_start");
307
+ assert.ok(types.includes("message_stop"), "must close with message_stop");
308
+ const text = events
309
+ .filter((event) => typeof event.data === "object" && event.data !== null && event.data.type === "content_block_delta")
310
+ .map((event) => (event.data.delta?.text ?? ""))
311
+ .join("");
312
+ assert.match(text, new RegExp(SENTINEL));
313
+ });
314
+ test("OpenAI chat streaming emits chat.completion.chunk frames carrying the synthesized answer", async () => {
315
+ const response = await fetch(`${gateway.url()}/v1/chat/completions`, {
316
+ method: "POST",
317
+ headers: { "content-type": "application/json" },
318
+ body: JSON.stringify({
319
+ model: "fusion-panel",
320
+ stream: true,
321
+ messages: [{ role: "user", content: "Fix calculator add() and report." }]
322
+ })
323
+ });
324
+ assert.equal(response.status, 200);
325
+ assert.match(response.headers.get("content-type") ?? "", /text\/event-stream/);
326
+ const events = sseEvents(await response.text());
327
+ assert.ok(events.some((event) => event.data === "[DONE]"), "must terminate with [DONE]");
328
+ const text = events
329
+ .filter((event) => typeof event.data === "object" && event.data !== null && event.data.object === "chat.completion.chunk")
330
+ .map((event) => (event.data.choices?.[0]?.delta?.content ?? ""))
331
+ .join("");
332
+ assert.match(text, new RegExp(SENTINEL));
333
+ });
334
+ test("generic ACP session lifecycle drives the real runner and streams the synthesized answer", async () => {
335
+ const input = new PassThrough();
336
+ const output = new PassThrough();
337
+ let raw = "";
338
+ output.on("data", (chunk) => {
339
+ raw += chunk.toString("utf8");
340
+ });
341
+ const done = runAcpAgent({ runner: buildAcpRunner(config), input, output });
342
+ const write = (message) => {
343
+ input.write(`${JSON.stringify(message)}\n`);
344
+ };
345
+ write({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } });
346
+ write({ jsonrpc: "2.0", id: 2, method: "session/new", params: { cwd: fixture.repo, mcpServers: [] } });
347
+ write({
348
+ jsonrpc: "2.0",
349
+ id: 3,
350
+ method: "session/prompt",
351
+ params: { sessionId: "sess_1", prompt: [{ type: "text", text: "Fix calculator add()." }] }
352
+ });
353
+ input.end();
354
+ await done;
355
+ const messages = raw
356
+ .split("\n")
357
+ .filter((line) => line.trim().length > 0)
358
+ .map((line) => JSON.parse(line));
359
+ const update = messages.find((message) => message.method === "session/update");
360
+ const updateText = update?.params?.update?.content
361
+ ?.text ?? "";
362
+ assert.match(updateText, new RegExp(SENTINEL));
363
+ const promptResult = messages.find((message) => message.id === 3)?.result;
364
+ assert.equal(promptResult?.stopReason, "end_turn");
365
+ assert.equal(promptResult?._meta.status, "succeeded");
366
+ assert.ok(promptResult?._meta.evidence.includes("judge_synthesis"));
367
+ assert.ok(promptResult?._meta.evidence.includes("tool_execution"));
368
+ });
369
+ test("unified acceptance suite passes every reachable front door against the real gateway", async () => {
370
+ const report = await runFrontDoorAcceptance({
371
+ gatewayUrl: gateway.url(),
372
+ sentinel: SENTINEL,
373
+ acpRunner: buildAcpRunner(config)
374
+ });
375
+ const statusOf = (id) => report.front_doors.find((door) => door.id === id)?.status;
376
+ const evidenceOf = (id) => report.front_doors.find((door) => door.id === id)?.evidence ?? [];
377
+ for (const id of ["codex-responses", "claude-messages", "openai-chat", "generic-acp"]) {
378
+ assert.equal(statusOf(id), "passed", `${id} should pass`);
379
+ assert.ok(evidenceOf(id).includes("sentinel"), `${id} should carry sentinel evidence`);
380
+ }
381
+ assert.ok(evidenceOf("codex-responses").includes("judge_synthesis"));
382
+ assert.ok(evidenceOf("codex-responses").includes("patch_artifact"));
383
+ // External-dependency front doors are explicitly blocked, never silently passed.
384
+ assert.equal(statusOf("codex-acp"), "blocked");
385
+ assert.equal(statusOf("claude-acp"), "blocked");
386
+ assert.equal(statusOf("cursor-acp"), "blocked");
387
+ assert.ok(backend.judgeCallCount() >= 4, "judge synthesis must hit the model backend per front door");
388
+ });
389
+ const LIVE_CLAUDE = process.env.WARRANT_GATEWAY_LIVE_CLAUDE === "1" ? false : "set WARRANT_GATEWAY_LIVE_CLAUDE=1 with a working claude CLI";
390
+ test("live: real Claude Code CLI drives the gateway fusion run and receives the synthesized answer", { skip: LIVE_CLAUDE }, async () => {
391
+ // A dedicated single-model gateway keeps the live run light: each Claude
392
+ // model call triggers one real unified harness run (worktree + command +
393
+ // judge) on this gateway and the synthesized answer is returned to Claude.
394
+ const liveGateway = await startConfiguredGateway({
395
+ config: { ...config, models: [{ id: "claude-panel", model: "fusion-claude" }] },
396
+ host: "127.0.0.1",
397
+ port: 0
398
+ });
399
+ try {
400
+ const result = await new Promise((resolve) => {
401
+ const child = spawn("claude", ["-p", "Report the final calculator fix result.", "--output-format", "text"], {
402
+ env: {
403
+ ...process.env,
404
+ // Claude Code appends `/v1/messages`, so the base URL is the
405
+ // gateway root without a `/v1` suffix.
406
+ ANTHROPIC_BASE_URL: liveGateway.url(),
407
+ ANTHROPIC_AUTH_TOKEN: "local-gateway"
408
+ },
409
+ stdio: ["ignore", "pipe", "pipe"]
410
+ });
411
+ let stdout = "";
412
+ let stderr = "";
413
+ child.stdout.on("data", (chunk) => {
414
+ stdout += chunk.toString("utf8");
415
+ });
416
+ child.stderr.on("data", (chunk) => {
417
+ stderr += chunk.toString("utf8");
418
+ });
419
+ const timer = setTimeout(() => child.kill("SIGTERM"), 120_000);
420
+ child.on("exit", (code) => {
421
+ clearTimeout(timer);
422
+ resolve({ code: code ?? 1, stdout, stderr });
423
+ });
424
+ });
425
+ assert.equal(result.code, 0, result.stderr);
426
+ assert.match(result.stdout, new RegExp(SENTINEL));
427
+ }
428
+ finally {
429
+ await liveGateway.close();
430
+ }
431
+ });
432
+ const LIVE_CODEX = process.env.WARRANT_GATEWAY_LIVE_CODEX === "1" ? false : "set WARRANT_GATEWAY_LIVE_CODEX=1 with a working codex CLI";
433
+ test("live: real Codex CLI drives the gateway fusion run and receives the synthesized answer", { skip: LIVE_CODEX }, async () => {
434
+ // Codex streams `/v1/responses`; the gateway must emit the Responses SSE
435
+ // sequence ending in response.completed for Codex to accept the answer.
436
+ const liveGateway = await startConfiguredGateway({
437
+ config: { ...config, models: [{ id: "codex-panel", model: "fusion-codex" }] },
438
+ host: "127.0.0.1",
439
+ port: 0
440
+ });
441
+ const codexHome = mkdtempSync(join(tmpdir(), "gateway-live-codex-"));
442
+ writeFileSync(join(codexHome, "config.toml"), [
443
+ 'model = "fusion-panel"',
444
+ 'model_provider = "fusion-gateway"',
445
+ 'approval_policy = "never"',
446
+ 'sandbox_mode = "read-only"',
447
+ "",
448
+ "[model_providers.fusion-gateway]",
449
+ 'name = "Fusion Harness Gateway"',
450
+ // Codex appends `/responses`, so the provider base URL ends in `/v1`.
451
+ `base_url = "${liveGateway.url()}/v1"`,
452
+ 'wire_api = "responses"',
453
+ "requires_openai_auth = false",
454
+ ""
455
+ ].join("\n"));
456
+ try {
457
+ const result = await new Promise((resolve) => {
458
+ const child = spawn("codex", ["exec", "--json", "--skip-git-repo-check", "Report the final calculator fix result."], {
459
+ cwd: fixture.repo,
460
+ env: { ...process.env, CODEX_HOME: codexHome },
461
+ stdio: ["ignore", "pipe", "pipe"]
462
+ });
463
+ let stdout = "";
464
+ let stderr = "";
465
+ child.stdout.on("data", (chunk) => {
466
+ stdout += chunk.toString("utf8");
467
+ });
468
+ child.stderr.on("data", (chunk) => {
469
+ stderr += chunk.toString("utf8");
470
+ });
471
+ const timer = setTimeout(() => child.kill("SIGTERM"), 120_000);
472
+ child.on("exit", (code) => {
473
+ clearTimeout(timer);
474
+ resolve({ code: code ?? 1, stdout, stderr });
475
+ });
476
+ });
477
+ assert.equal(result.code, 0, result.stderr);
478
+ assert.match(result.stdout, new RegExp(SENTINEL));
479
+ }
480
+ finally {
481
+ await liveGateway.close();
482
+ rmSync(codexHome, { recursive: true, force: true });
483
+ }
484
+ });
485
+ // Drives the real cursor-agent CLI in ACP mode through the real Cursorkit
486
+ // bridge, whose local model backend is pointed at this gateway. Requires a
487
+ // logged-in cursor-agent and a built Cursorkit checkout (WARRANT_CURSORKIT_DIR).
488
+ const CURSORKIT_DIR = process.env.WARRANT_CURSORKIT_DIR;
489
+ const LIVE_CURSOR = process.env.WARRANT_GATEWAY_LIVE_CURSOR === "1" && CURSORKIT_DIR !== undefined
490
+ ? false
491
+ : "set WARRANT_GATEWAY_LIVE_CURSOR=1 and WARRANT_CURSORKIT_DIR=<built cursorkit checkout> with a logged-in cursor-agent";
492
+ test("live: real cursor-agent (ACP) drives the Cursorkit bridge into the gateway fusion run", { skip: LIVE_CURSOR }, async () => {
493
+ const cursorkitDir = CURSORKIT_DIR;
494
+ const liveGateway = await startConfiguredGateway({
495
+ config: { ...config, models: [{ id: "cursor-panel", model: "fusion-cursor" }] },
496
+ host: "127.0.0.1",
497
+ port: 0
498
+ });
499
+ const bridgePort = 9700 + Math.floor(Math.random() * 250);
500
+ let bridgeOut = "";
501
+ const scrubbed = {};
502
+ for (const key of Object.keys(process.env)) {
503
+ if (key.startsWith("BRIDGE_") || key.startsWith("MODEL_") || key.startsWith("E2E_") || key.startsWith("CURSOR_UPSTREAM")) {
504
+ scrubbed[key] = undefined;
505
+ }
506
+ }
507
+ const bridgeEnv = {};
508
+ for (const [key, value] of Object.entries({ ...process.env, ...scrubbed })) {
509
+ if (value !== undefined)
510
+ bridgeEnv[key] = value;
511
+ }
512
+ Object.assign(bridgeEnv, {
513
+ BRIDGE_PORT: String(bridgePort),
514
+ BRIDGE_ROUTE_INVENTORY: "true",
515
+ CURSOR_UPSTREAM_BASE_URL: "https://api2.cursor.sh",
516
+ MODEL_BASE_URL: `${liveGateway.url()}/v1`,
517
+ MODEL_API_KEY: "local",
518
+ MODEL_NAME: "local-fusion",
519
+ MODEL_PROVIDER_MODEL: "fusion-panel",
520
+ MODEL_CONTEXT_TOKEN_LIMIT: "128000"
521
+ });
522
+ const bridge = spawn(process.execPath, ["dist/src/cli.js", "serve"], {
523
+ cwd: cursorkitDir,
524
+ env: bridgeEnv,
525
+ stdio: ["ignore", "pipe", "pipe"]
526
+ });
527
+ bridge.stdout.on("data", (chunk) => {
528
+ bridgeOut += chunk.toString("utf8");
529
+ });
530
+ bridge.stderr.on("data", (chunk) => {
531
+ bridgeOut += chunk.toString("utf8");
532
+ });
533
+ try {
534
+ const deadline = Date.now() + 15_000;
535
+ while (!/bridge listening/.test(bridgeOut) && Date.now() < deadline) {
536
+ await new Promise((resolve) => setTimeout(resolve, 250));
537
+ }
538
+ assert.match(bridgeOut, /bridge listening/, "Cursorkit bridge must start");
539
+ const acp = spawn("cursor-agent", ["--endpoint", `http://127.0.0.1:${bridgePort}`, "--model", "local-fusion", "--mode", "ask", "acp"], { cwd: fixture.repo, stdio: ["pipe", "pipe", "pipe"] });
540
+ let acpText = "";
541
+ let nextId = 1;
542
+ const pending = new Map();
543
+ const rl = readline.createInterface({ input: acp.stdout });
544
+ const send = (method, params) => {
545
+ const id = nextId++;
546
+ acp.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
547
+ return new Promise((resolve, reject) => pending.set(id, { resolve, reject }));
548
+ };
549
+ rl.on("line", (line) => {
550
+ let message;
551
+ try {
552
+ message = JSON.parse(line);
553
+ }
554
+ catch {
555
+ return;
556
+ }
557
+ if (message.id !== undefined && message.method === undefined) {
558
+ const waiter = pending.get(Number(message.id));
559
+ if (waiter === undefined)
560
+ return;
561
+ pending.delete(Number(message.id));
562
+ if (message.error !== undefined)
563
+ waiter.reject(message.error);
564
+ else
565
+ waiter.resolve(message.result);
566
+ return;
567
+ }
568
+ if (message.method !== undefined) {
569
+ if (message.method === "session/update")
570
+ acpText += JSON.stringify(message.params);
571
+ if (message.id !== undefined) {
572
+ acp.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { outcome: { outcome: "skipped", reason: "harness" } } })}\n`);
573
+ }
574
+ }
575
+ });
576
+ const withTimeout = (promise, ms) => Promise.race([
577
+ promise,
578
+ new Promise((_resolve, reject) => setTimeout(() => reject(new Error("ACP step timed out")), ms))
579
+ ]);
580
+ try {
581
+ await withTimeout(send("initialize", {
582
+ protocolVersion: 1,
583
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
584
+ clientInfo: { name: "warrant-live", version: "0.1.0" }
585
+ }), 60_000);
586
+ await withTimeout(send("authenticate", { methodId: "cursor_login" }), 60_000);
587
+ const session = (await withTimeout(send("session/new", { cwd: fixture.repo, mcpServers: [] }), 60_000));
588
+ const sessionId = session.sessionId ?? session.session?.id;
589
+ assert.ok(sessionId, "cursor-agent must create an ACP session");
590
+ await withTimeout(send("session/prompt", {
591
+ sessionId,
592
+ prompt: [{ type: "text", text: "Fix calculator add() so the test passes, then report the result." }]
593
+ }), 60_000);
594
+ }
595
+ finally {
596
+ rl.close();
597
+ acp.kill("SIGTERM");
598
+ }
599
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
600
+ assert.match(acpText, new RegExp(SENTINEL), "fusion-synthesized answer must reach cursor-agent via session/update");
601
+ }
602
+ finally {
603
+ bridge.kill("SIGTERM");
604
+ await liveGateway.close();
605
+ }
606
+ });
@@ -0,0 +1 @@
1
+ export {};