| |
| |
| |
| |
| |
| |
| |
| 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/); |
| |
| assert.match(err!.message || "", /missing model gpt-9000/); |
| assert.equal(err!.providerErrorCode, "codex_cli_exited"); |
| |
| 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 () => { |
| |
| 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"); |
| |
| |
| 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/, |
| ); |
| }); |
|
|