Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions src/llm/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,67 @@ type CliRunResult = {
const isNonEmptyString = (value: unknown): value is string =>
typeof value === "string" && value.trim().length > 0;

const CODEX_META_ONLY_OUTPUT_ERROR =
"Codex returned no assistant text; stdout only contained session/meta events.";

const CODEX_FOOTER_LINE_PATTERN = /\bcli\/codex(?:\/\S+)?$/;
const CODEX_TEXT_PAYLOAD_KEYS = [
"result",
"response",
"output",
"message",
"text",
"content",
] as const;

function hasTextPayloadValue(value: unknown): boolean {
if (typeof value === "string") return value.trim().length > 0;
if (Array.isArray(value)) return value.some((entry) => hasTextPayloadValue(entry));
if (!value || typeof value !== "object") return false;
return hasTextPayload(value as Record<string, unknown>);
}

function hasTextPayload(payload: Record<string, unknown>): boolean {
return CODEX_TEXT_PAYLOAD_KEYS.some((key) => hasTextPayloadValue(payload[key]));
}

function parseJsonRecord(line: string): Record<string, unknown> | null {
if (!line.startsWith("{")) return null;
try {
const parsed = JSON.parse(line) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
} catch {
return null;
}
}

function isCodexFooterLine(line: string): boolean {
return line.includes("·") && CODEX_FOOTER_LINE_PATTERN.test(line);
}

function isCodexMetaOnlyOutput(output: string): boolean {
const lines = output
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
if (lines.length === 0) return false;
let sawMeta = false;
for (const line of lines) {
if (isCodexFooterLine(line)) {
sawMeta = true;
continue;
}
const payload = parseJsonRecord(line);
if (!payload) return false;
if (typeof payload.type !== "string" || hasTextPayload(payload)) {
return false;
}
sawMeta = true;
}
return sawMeta;
}

function getCliProviderConfig(
provider: CliProvider,
config: CliConfig | null | undefined,
Expand Down Expand Up @@ -186,6 +247,9 @@ export async function runCliModel({
}
const stdoutText = stdout.trim();
if (stdoutText) {
if (isCodexMetaOnlyOutput(stdoutText)) {
throw new Error(CODEX_META_ONLY_OUTPUT_ERROR);
}
return { text: stdoutText, usage, costUsd };
}
throw new Error("CLI returned empty output");
Expand Down
78 changes: 78 additions & 0 deletions tests/llm.cli.more-branches.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,27 @@
import type { ChildProcess } from "node:child_process";
import { writeFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { runCliModel } from "../src/llm/cli.js";

const CODEX_META_ONLY_STDOUT = [
'{"type":"thread.started","thread_id":"019cd2c2-0645-7312-b7f2-f10a3d41eb5c"}',
"2m 0s · 3.1k words · cli/codex/gpt-5.2",
].join("\n");

const CODEX_STDOUT_WITH_ARRAY_TEXT = [
JSON.stringify({
type: "response.completed",
response: {
output: [
{
content: [{ type: "output_text", text: "assistant text from array payload" }],
},
],
},
}),
"2m 0s · 3.1k words · cli/codex/gpt-5.2",
].join("\n");

describe("llm/cli extra branches", () => {
it("parses the last JSON object when stdout includes a preface", async () => {
const result = await runCliModel({
Expand Down Expand Up @@ -58,4 +78,62 @@ describe("llm/cli extra branches", () => {
expect(result.usage?.completionTokens).toBe(2);
expect(result.usage?.totalTokens).toBe(3);
});

it("throws when Codex last-message is empty and stdout only contains session metadata", async () => {
await expect(
runCliModel({
provider: "codex",
prompt: "hi",
model: "gpt-5.2",
allowTools: false,
timeoutMs: 1000,
env: {},
config: null,
execFileImpl: (_cmd, args, _opts, cb) => {
const outputIndex = args.indexOf("--output-last-message");
const outputPath = outputIndex >= 0 ? args[outputIndex + 1] : null;
if (!outputPath) throw new Error("missing output path");
writeFileSync(outputPath, " ", "utf8");
cb(null, CODEX_META_ONLY_STDOUT, "");
return { stdin: { write() {}, end() {} } } as unknown as ChildProcess;
},
}),
).rejects.toThrow(/stdout only contained session\/meta events/i);
});

it("throws when Codex last-message is missing and stdout only contains session metadata", async () => {
await expect(
runCliModel({
provider: "codex",
prompt: "hi",
model: "gpt-5.2",
allowTools: false,
timeoutMs: 1000,
env: {},
config: null,
execFileImpl: (_cmd, _args, _opts, cb) => {
cb(null, CODEX_META_ONLY_STDOUT, "");
return { stdin: { write() {}, end() {} } } as unknown as ChildProcess;
},
}),
).rejects.toThrow(/stdout only contained session\/meta events/i);
});

it("keeps raw stdout fallback when Codex stdout includes nested array text", async () => {
const result = await runCliModel({
provider: "codex",
prompt: "hi",
model: "gpt-5.2",
allowTools: false,
timeoutMs: 1000,
env: {},
config: null,
execFileImpl: (_cmd, _args, _opts, cb) => {
cb(null, CODEX_STDOUT_WITH_ARRAY_TEXT, "");
return { stdin: { write() {}, end() {} } } as unknown as ChildProcess;
},
});

expect(result.text).toBe(CODEX_STDOUT_WITH_ARRAY_TEXT);
});
});