/** * Unit coverage for the codex-cli adapter. Uses a tiny shell script as * the "local helper" so we exercise the real spawn/stdin/stdout/JSONL * pipeline without depending on any external binary. * * Run with: pnpm --filter @workspace/api-server test:codex-cli */ import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdtempSync, writeFileSync, chmodSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; process.env.DATABASE_URL ??= "postgres://test:test@127.0.0.1:5432/test"; import type { StreamChunk } from "../types"; const { codexCliAdapter } = await import("../codex-cli"); function makeFakeBin(body: string): string { const dir = mkdtempSync(join(tmpdir(), "codex-cli-test-")); const path = join(dir, "fake-codex.sh"); writeFileSync(path, `#!/usr/bin/env bash\nset -eu\n${body}\n`, "utf8"); chmodSync(path, 0o755); return path; } async function collect( iter: AsyncIterable, ): Promise { const out: StreamChunk[] = []; for await (const c of iter) out.push(c); return out; } test("codex-cli adapter streams text + tool call + usage + done", async () => { const bin = makeFakeBin(` cat >/dev/null printf '%s\\n' '{"type":"text_delta","text":"Hello "}' printf '%s\\n' '{"type":"text_delta","text":"world"}' printf '%s\\n' '{"type":"tool_call","id":"t1","name":"read_file","arguments":{"path":"a.ts"}}' printf '%s\\n' '{"type":"usage","input_tokens":5,"output_tokens":2}' printf '%s\\n' '{"type":"done","stop_reason":"end_turn"}' `); process.env["CODEX_CLI_BIN"] = bin; delete process.env["CODEX_CLI_ARGS"]; const chunks = await collect( codexCliAdapter.stream({ model: "codex-cli", messages: [{ role: "user", content: "hi" }], }), ); const text = chunks .filter((c) => c.type === "text_delta") .map((c) => c.text) .join(""); assert.equal(text, "Hello world"); const tool = chunks.find((c) => c.type === "tool_call"); assert.ok(tool, "expected a tool_call chunk"); assert.equal(tool!.toolName, "read_file"); assert.equal(tool!.toolCallId, "t1"); assert.deepEqual(tool!.toolArguments, { path: "a.ts" }); const usage = chunks.find((c) => c.type === "usage"); assert.ok(usage, "expected usage chunk"); assert.equal(usage!.inputTokens, 5); assert.equal(usage!.outputTokens, 2); const done = chunks.find((c) => c.type === "done"); assert.ok(done, "expected done chunk"); }); test("codex-cli adapter surfaces non-zero exit with stderr tail and code", async () => { const bin = makeFakeBin(` cat >/dev/null echo "boom: missing model gpt-9000" 1>&2 exit 7 `); process.env["CODEX_CLI_BIN"] = bin; const chunks = await collect( codexCliAdapter.stream({ model: "codex-cli", messages: [{ role: "user", content: "hi" }], }), ); const err = chunks.find((c) => c.type === "error"); assert.ok(err, "expected error chunk"); assert.match(err!.message || "", /exited with code 7/); // stderr tail must be included so self-hosters can see what failed. assert.match(err!.message || "", /missing model gpt-9000/); assert.equal(err!.providerErrorCode, "codex_cli_exited"); // gateway always closes with a done event after error const done = chunks.find((c) => c.type === "done"); assert.ok(done); }); test("codex-cli adapter surfaces ENOENT spawn failure with a friendly code", async () => { process.env["CODEX_CLI_BIN"] = "/definitely/does/not/exist/codex-binary-xyz"; const chunks = await collect( codexCliAdapter.stream({ model: "codex-cli", messages: [{ role: "user", content: "hi" }], }), ); const err = chunks.find((c) => c.type === "error"); assert.ok(err, "expected error chunk"); assert.match(err!.message || "", /not found/i); assert.match(err!.message || "", /codex-binary-xyz/); assert.equal(err!.providerErrorCode, "codex_cli_not_found"); const done = chunks.find((c) => c.type === "done"); assert.ok(done); }); test("codex-cli adapter surfaces EACCES (non-executable) with a friendly code", async () => { // Write a file but do NOT chmod +x it, so spawn() returns EACCES. const dir = mkdtempSync(join(tmpdir(), "codex-cli-test-noexec-")); const path = join(dir, "not-executable.sh"); writeFileSync(path, "#!/usr/bin/env bash\nexit 0\n", "utf8"); // Explicitly strip the executable bit (writeFileSync default is 0o666 // anyway, but be defensive on systems with permissive umasks). chmodSync(path, 0o644); process.env["CODEX_CLI_BIN"] = path; const chunks = await collect( codexCliAdapter.stream({ model: "codex-cli", messages: [{ role: "user", content: "hi" }], }), ); const err = chunks.find((c) => c.type === "error"); assert.ok(err, "expected error chunk"); assert.equal(err!.providerErrorCode, "codex_cli_permission_denied"); assert.match(err!.message || "", /permission denied/i); }); test("codex-cli adapter throws when CODEX_CLI_BIN is unset", async () => { delete process.env["CODEX_CLI_BIN"]; await assert.rejects( async () => collect( codexCliAdapter.stream({ model: "codex-cli", messages: [{ role: "user", content: "hi" }], }), ), /CODEX_CLI_BIN/, ); });