import assert from "node:assert/strict"; import test from "node:test"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import type { WebSocketServer } from "ws"; import { formatFailure, startCraftos } from "./harness.js"; import { startOpencodeProxy } from "../src/opencode-proxy.js"; type RequestLog = { method: string; path: string; body?: unknown; }; test("ai CLI commands run through the opencode bridge proxy", async () => { const requests: RequestLog[] = []; let nextSession = 1; const opencode = createServer((req, res) => { void handleOpencodeRequest(req, res, requests, () => { const id = `ses_${nextSession}`; nextSession += 1; return id; }); }); await new Promise((resolve) => opencode.listen(0, "127.0.0.1", resolve)); const opencodeUrl = `http://127.0.0.1:${serverPort(opencode)}`; const proxy = startOpencodeProxy({ host: "127.0.0.1", port: 0, opencodeUrl }); await waitForListening(proxy); const proxyUrl = `ws://127.0.0.1:${wssPort(proxy)}`; const craftos = startCraftos("ai-cli-check.lua", { mountRepo: true, shellArgs: [proxyUrl], timeoutMs: 20_000, }); try { const result = await craftos.done; const output = result.output; assert.equal(result.status, 0, formatFailure("expected craftos to exit cleanly", output)); assert.match(output, /ses_existing {2}Existing title/, formatFailure("expected sessions output", output)); assert.match(output, /\bpong\b/, formatFailure("expected ping reply", output)); assert.match(output, /new reply/, formatFailure("expected ai new reply", output)); assert.match(output, /plain reply/, formatFailure("expected plain ai reply", output)); assert.match(output, /SESSION_AFTER_PING=ses_1/, formatFailure("expected ping session to persist", output)); assert.match(output, /SESSION_AFTER_NEW=ses_2/, formatFailure("expected ai new to replace session", output)); assert.match(output, /SESSION_AFTER_ASK=ses_2/, formatFailure("expected plain ai to reuse new session", output)); assert.deepEqual(requests.map((r) => `${r.method} ${r.path}`), [ "GET /session", "POST /session", "POST /session/ses_1/message", "POST /session", "POST /session/ses_2/message", "POST /session/ses_2/message", ]); assert.match(promptText(requests[2].body), /reply with exactly: pong/); assert.match(promptText(requests[4].body), /User prompt:\nfresh start/); assert.match(promptText(requests[5].body), /User prompt:\ncontinue please/); } finally { craftos.abort(); await craftos.done.catch(() => undefined); await closeWss(proxy); await new Promise((resolve) => opencode.close(() => resolve())); } }); async function handleOpencodeRequest( req: IncomingMessage, res: ServerResponse, requests: RequestLog[], createSessionId: () => string, ): Promise { const bodyText = await readBody(req); const body: unknown = bodyText === "" ? undefined : JSON.parse(bodyText) as unknown; const url = new URL(req.url ?? "/", "http://127.0.0.1"); requests.push({ method: req.method ?? "GET", path: url.pathname + url.search, body }); if (req.method === "GET" && url.pathname === "/session") { respondJson(res, [ { id: "ses_existing", title: "Existing title", time: { updated: 10 } }, ]); return; } if (req.method === "POST" && url.pathname === "/session") { const id = createSessionId(); respondJson(res, { id, title: "cc-ai" }); return; } const messageMatch = url.pathname.match(/^\/session\/([^/]+)\/message$/); if (req.method === "POST" && messageMatch) { respondJson(res, { info: { id: `msg_${requests.length}`, time: { completed: 1 } }, parts: [{ type: "text", text: replyForPrompt(promptText(body)) }], }); return; } respondJson(res, { error: "not found" }, 404); } function replyForPrompt(prompt: string): string { if (prompt.includes("reply with exactly: pong")) { return "pong"; } if (prompt.includes("fresh start")) { return "new reply"; } if (prompt.includes("continue please")) { return "plain reply"; } return `unhandled prompt: ${prompt}`; } function promptText(body: unknown): string { if (!isObject(body) || !Array.isArray(body.parts)) { return ""; } const parts = body.parts as unknown[]; const first = parts[0]; if (!isObject(first) || typeof first.text !== "string") { return ""; } return first.text; } function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null; } function readBody(req: IncomingMessage): Promise { const chunks: Buffer[] = []; req.on("data", (chunk: Buffer) => chunks.push(chunk)); return new Promise((resolve, reject) => { req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); req.on("error", reject); }); } function respondJson(res: ServerResponse, body: unknown, status = 200): void { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify(body)); } function serverPort(server: Server): number { const address = server.address(); if (!address || typeof address === "string") { throw new Error("server has no TCP address"); } return address.port; } function wssPort(proxy: WebSocketServer): number { const address = proxy.address(); if (!address || typeof address === "string") { throw new Error("proxy has no TCP address"); } return address.port; } function waitForListening(proxy: WebSocketServer): Promise { return new Promise((resolve, reject) => { if (proxy.address()) { resolve(); return; } proxy.once("listening", () => resolve()); proxy.once("error", reject); }); } function closeWss(proxy: WebSocketServer): Promise { for (const client of proxy.clients) { client.terminate(); } return new Promise((resolve) => proxy.close(() => resolve())); }