Skip to content
Merged
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
79 changes: 69 additions & 10 deletions src/llm/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,57 @@ export function resolveCliBinary(
return DEFAULT_BINARIES[provider];
}

function toUtf8String(value: string | Buffer): string {
return typeof value === "string" ? value : value.toString("utf8");
}

function formatErrorMessageWithStderr(
message: string,
stderrText: string,
separator: ": " | "\n" = ": ",
): string {
const trimmedStderr = stderrText.trim();
if (!trimmedStderr || message.includes(trimmedStderr)) return message;
return `${message}${separator}${trimmedStderr}`;
}

function formatTimeoutLabel(timeoutMs: number): string {
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
if (timeoutMs % 60_000 === 0) return `${Math.floor(timeoutMs / 60_000)}m`;
if (timeoutMs % 1000 === 0) return `${Math.floor(timeoutMs / 1000)}s`;
return `${Math.floor(timeoutMs)}ms`;
}
return "unknown time";
}

function getExecErrorCodeText(error: NodeJS.ErrnoException): string {
if (typeof error.code === "string") return error.code;
if (Buffer.isBuffer(error.code)) return toUtf8String(error.code);
if (typeof error.code === "number") return String(error.code);
return "";
}

function isExecTimeoutError(error: NodeJS.ErrnoException): boolean {
if (getExecErrorCodeText(error).toUpperCase() === "ETIMEDOUT") return true;
const withSignal = error as NodeJS.ErrnoException & {
killed?: boolean;
signal?: NodeJS.Signals | null;
};
return withSignal.killed === true && withSignal.signal === "SIGTERM";
}

function getExecErrorMessage(error: NodeJS.ErrnoException): string {
return typeof error.message === "string" && error.message.trim().length > 0
? error.message.trim()
: "CLI command failed";
}

function getExecCommand(error: NodeJS.ErrnoException, cmd: string, args: string[]): string {
return typeof error.cmd === "string" && error.cmd.trim().length > 0
? error.cmd.trim()
: [cmd, ...args].join(" ");
}

async function execCliWithInput({
execFileImpl,
cmd,
Expand Down Expand Up @@ -112,19 +163,27 @@ async function execCliWithInput({
maxBuffer: 50 * 1024 * 1024,
},
(error, stdout, stderr) => {
const stderrText = toUtf8String(stderr);
if (error) {
const stderrText =
typeof stderr === "string" ? stderr : (stderr as Buffer).toString("utf8");
const message = stderrText.trim()
? `${error.message}: ${stderrText.trim()}`
: error.message;
reject(new Error(message, { cause: error }));
if (isExecTimeoutError(error)) {
const timeoutMessage =
`CLI command timed out after ${formatTimeoutLabel(timeoutMs)}: ${getExecCommand(error, cmd, args)}. ` +
"Increase --timeout (e.g. 5m).";
reject(
new Error(formatErrorMessageWithStderr(timeoutMessage, stderrText, "\n"), {
cause: error,
}),
);
return;
}
reject(
new Error(formatErrorMessageWithStderr(getExecErrorMessage(error), stderrText), {
cause: error,
}),
);
return;
}
const stdoutText =
typeof stdout === "string" ? stdout : (stdout as Buffer).toString("utf8");
const stderrText =
typeof stderr === "string" ? stderr : (stderr as Buffer).toString("utf8");
const stdoutText = toUtf8String(stdout);
resolve({ stdout: stdoutText, stderr: stderrText });
},
);
Expand Down
54 changes: 54 additions & 0 deletions tests/llm.cli.more-branches-2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,60 @@ describe("llm/cli more branches", () => {
).rejects.toThrow(/boom: stderr details/i);
});

it("does not duplicate stderr when exec error message already includes stderr", async () => {
const error = await runCliModel({
provider: "gemini",
prompt: "hi",
model: "m",
allowTools: false,
timeoutMs: 1000,
env: {},
config: null,
execFileImpl: (_cmd, _args, _opts, cb) => {
const stderrText = "stderr details";
const error = Object.assign(new Error(`Command failed: gemini\n${stderrText}`), {
code: 1,
});
cb(error as unknown as NodeJS.ErrnoException, "", stderrText);
return { stdin: { write() {}, end() {} } } as unknown as ChildProcess;
},
}).catch((error: unknown) => error as Error);

expect(error.message).toContain("stderr details");
const occurrences = error.message.match(/stderr details/gi)?.length ?? 0;
expect(occurrences).toBe(1);
});

it("formats timeout errors with duration and hint", async () => {
const error = await runCliModel({
provider: "gemini",
prompt: "hi",
model: "m",
allowTools: false,
timeoutMs: 2000,
env: {},
config: null,
execFileImpl: (_cmd, _args, _opts, cb) => {
const timeoutError = Object.assign(new Error("Command failed: gemini --prompt hi"), {
code: "ETIMEDOUT",
cmd: "gemini --prompt hi",
killed: true,
signal: "SIGTERM",
});
cb(
timeoutError as unknown as NodeJS.ErrnoException,
"",
"Reading prompt from stdin...",
);
return { stdin: { write() {}, end() {} } } as unknown as ChildProcess;
},
}).catch((error: unknown) => error as Error);

expect(error.message).toContain("timed out after 2s");
expect(error.message).toContain("Increase --timeout");
expect(error.message).toContain("Reading prompt from stdin...");
});

it("codex: uses last-message file when present, otherwise stdout fallback", async () => {
// file present
const resultFile = await runCliModel({
Expand Down