182 lines
5.9 KiB
TypeScript
182 lines
5.9 KiB
TypeScript
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<void>((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<void>((resolve) => opencode.close(() => resolve()));
|
|
}
|
|
});
|
|
|
|
async function handleOpencodeRequest(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
requests: RequestLog[],
|
|
createSessionId: () => string,
|
|
): Promise<void> {
|
|
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<string, unknown> {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
|
|
function readBody(req: IncomingMessage): Promise<string> {
|
|
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<void> {
|
|
return new Promise((resolve, reject) => {
|
|
if (proxy.address()) {
|
|
resolve();
|
|
return;
|
|
}
|
|
proxy.once("listening", () => resolve());
|
|
proxy.once("error", reject);
|
|
});
|
|
}
|
|
|
|
function closeWss(proxy: WebSocketServer): Promise<void> {
|
|
for (const client of proxy.clients) {
|
|
client.terminate();
|
|
}
|
|
return new Promise<void>((resolve) => proxy.close(() => resolve()));
|
|
}
|