doatlas-2 / artifacts /api-server /src /llm /__tests__ /codex-cli.test.ts
Iostream-Li's picture
Add files using upload-large-folder tool
6d1fe92 verified
Raw
History Blame Contribute Delete
5.27 kB
/**
* 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<StreamChunk>,
): Promise<StreamChunk[]> {
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/,
);
});