🐛 fix(heterogeneous-agent): surface Codex terminal errors and trace CLI output (#14166)

* 🐛 fix(heterogeneous-agent): surface Codex terminal errors and trace CLI output

- Map Codex `error` / `turn.failed` events to terminal error events
- Filter noisy WARN blocks from Codex stderr when reporting exit errors
- Persist CLI stdin/stdout/stderr to .heerogeneous-tracing/ in dev mode

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(heterogeneous-agent): skip trace when cwd is missing

`mkdir(dir, { recursive: true })` would otherwise materialize a stale or
typo'd cwd from scratch, swallowing the configuration error and running
the agent in an unintended empty directory. Probe `cwd` first and bail
out of trace setup so spawn() surfaces the real failure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arvin Xu
2026-04-25 19:16:36 +08:00
committed by GitHub
parent 774e29e400
commit 66c25cce4b
6 changed files with 652 additions and 43 deletions
@@ -1,7 +1,7 @@
import type { ChildProcess } from 'node:child_process';
import { spawn } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
import { access, appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { Readable, Writable } from 'node:stream';
@@ -51,6 +51,21 @@ const CODEX_RESUME_CWD_MISMATCH_PATTERNS = [
/** Directory under appStoragePath for caching downloaded files */
const FILE_CACHE_DIR = 'heteroAgent/files';
const CLI_TRACE_DIR = '.heerogeneous-tracing';
const IMAGE_EXTENSIONS_BY_MIME = {
'image/gif': '.gif',
'image/jpg': '.jpg',
'image/jpeg': '.jpg',
'image/pjpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'image/x-png': '.png',
} as const satisfies Record<string, string>;
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const CODEX_STDERR_STATUS_LINE = 'Reading prompt from stdin...';
const CODEX_WARN_LOG_PATTERN = /^\d{4}-\d{2}-\d{2}T\S+\s+WARN\s+/;
const CODEX_LOG_PATTERN = /^\d{4}-\d{2}-\d{2}T\S+\s+(?:DEBUG|ERROR|INFO|TRACE|WARN)\s+/;
const CLI_ERROR_LINE_PATTERN = /^(?:error:|Error:|Usage:)/;
// ─── IPC types ───
@@ -120,6 +135,11 @@ interface AgentSession {
type SessionErrorPayload = HeterogeneousAgentSessionError | string;
interface CliTraceSession {
dir: string;
writeQueue: Promise<void>;
}
/**
* External Agent Controller — manages external agent CLI processes via Electron IPC.
*
@@ -306,6 +326,49 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
return error instanceof Error ? error.message : String(error);
}
private getRelevantCodexStderr(stderr: string): string {
const keptLines: string[] = [];
let droppingWarnBlock = false;
for (const line of stderr.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed === CODEX_STDERR_STATUS_LINE) {
continue;
}
if (CODEX_WARN_LOG_PATTERN.test(trimmed)) {
droppingWarnBlock = true;
continue;
}
if (CODEX_LOG_PATTERN.test(trimmed)) {
droppingWarnBlock = false;
keptLines.push(line);
continue;
}
if (droppingWarnBlock && !CLI_ERROR_LINE_PATTERN.test(trimmed)) {
continue;
}
droppingWarnBlock = false;
keptLines.push(line);
}
return keptLines.join('\n').trim();
}
private getExitErrorMessage(
code: number | null,
session: AgentSession,
stderrOutput: string,
): string {
const relevantStderr =
session.agentType === 'codex' ? this.getRelevantCodexStderr(stderrOutput) : stderrOutput;
return relevantStderr || `Agent exited with code ${code}`;
}
private async getSpawnPreflightError(
session: AgentSession,
): Promise<HeterogeneousAgentSessionError | undefined> {
@@ -332,6 +395,168 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
return cliMissingError;
}
private get shouldTraceCliOutput(): boolean {
return process.env.NODE_ENV !== 'test' && !electronApp.isPackaged;
}
private formatTraceTimestamp(date: Date): string {
const pad = (value: number) => value.toString().padStart(2, '0');
return [
date.getFullYear(),
pad(date.getMonth() + 1),
pad(date.getDate()),
'-',
pad(date.getHours()),
pad(date.getMinutes()),
pad(date.getSeconds()),
].join('');
}
private sanitizeTracePathSegment(value: string): string {
const sanitized = value
.replaceAll(path.sep, '-')
.replaceAll(/[^\w.-]+/g, '-')
.replaceAll(/^-+|-+$/g, '')
.slice(0, 80);
return sanitized || 'unknown';
}
private getAttachmentTraceSummary(image: HeterogeneousAgentImageAttachment) {
let urlKind = 'unknown';
try {
urlKind = new URL(image.url).protocol.replace(/:$/, '') || urlKind;
} catch {
urlKind = image.url.startsWith('data:') ? 'data' : 'unknown';
}
return {
id: image.id,
urlKind,
};
}
private async createCliTraceSession({
cliArgs,
cwd,
imageList,
session,
stdinPayload,
}: {
cliArgs: string[];
cwd: string;
imageList: HeterogeneousAgentImageAttachment[];
session: AgentSession;
stdinPayload?: string;
}): Promise<CliTraceSession | undefined> {
if (!this.shouldTraceCliOutput) return;
// Don't materialize the cwd via mkdir — if the caller passed a stale or
// typo'd path, we want spawn() to fail loudly instead of silently running
// the agent in an empty auto-created directory.
try {
await access(cwd);
} catch {
return;
}
const createdAt = new Date();
const rootDir = path.join(cwd, CLI_TRACE_DIR);
const agentDir = path.join(rootDir, this.sanitizeTracePathSegment(session.agentType));
const traceId = `${this.formatTraceTimestamp(createdAt)}-${this.sanitizeTracePathSegment(
session.sessionId,
)}`;
const dir = path.join(agentDir, traceId);
try {
await mkdir(dir, { recursive: true });
await writeFile(path.join(rootDir, '.last-live-trace'), `${dir}\n`);
await writeFile(path.join(dir, 'stdout.jsonl'), '');
await writeFile(path.join(dir, 'stderr.log'), '');
if (stdinPayload !== undefined) {
await writeFile(path.join(dir, 'stdin.txt'), '');
}
await writeFile(
path.join(dir, 'meta.json'),
`${JSON.stringify(
{
agentSessionId: session.agentSessionId,
agentType: session.agentType,
args: cliArgs,
attachments: imageList.map((image) => this.getAttachmentTraceSummary(image)),
command: session.command,
createdAt: createdAt.toISOString(),
cwd,
envKeys: session.env ? Object.keys(session.env).sort() : [],
resumeSessionId: session.resumeSessionId,
sessionId: session.sessionId,
stdinBytes: stdinPayload === undefined ? 0 : Buffer.byteLength(stdinPayload),
stdinFile: stdinPayload === undefined ? undefined : 'stdin.txt',
stderrFile: 'stderr.log',
stdoutFile: 'stdout.jsonl',
},
null,
2,
)}\n`,
);
return { dir, writeQueue: Promise.resolve() };
} catch (error) {
logger.warn('Failed to initialize CLI trace directory:', error);
}
}
private queueCliTraceWrite(
trace: CliTraceSession | undefined,
write: () => Promise<void>,
): Promise<void> | undefined {
if (!trace) return;
trace.writeQueue = trace.writeQueue.then(write).catch((error) => {
logger.warn('Failed to write CLI trace file:', error);
});
return trace.writeQueue;
}
private appendCliTraceFile(
trace: CliTraceSession | undefined,
fileName: string,
data: Buffer | string,
): Promise<void> | undefined {
if (!trace) return;
const filePath = path.join(trace.dir, fileName);
return this.queueCliTraceWrite(trace, () => appendFile(filePath, data));
}
private writeCliTraceFile(
trace: CliTraceSession | undefined,
fileName: string,
data: string,
): Promise<void> | undefined {
if (!trace) return;
const filePath = path.join(trace.dir, fileName);
return this.queueCliTraceWrite(trace, () => writeFile(filePath, data));
}
private writeCliTraceJson(
trace: CliTraceSession | undefined,
fileName: string,
payload: unknown,
): Promise<void> | undefined {
return this.writeCliTraceFile(trace, fileName, `${JSON.stringify(payload, null, 2)}\n`);
}
private async flushCliTrace(trace: CliTraceSession | undefined): Promise<void> {
await trace?.writeQueue;
}
// ─── Broadcast ───
private broadcast<T>(channel: string, data: T) {
@@ -401,26 +626,42 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
return { buffer, mimeType };
}
private normalizeMimeType(mimeType: string): string {
return mimeType.split(';')[0]?.trim().toLowerCase() || '';
}
private guessImageExtensionByBuffer(buffer: Buffer): string | undefined {
if (buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) return '.png';
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return '.jpg';
const gifSignature = buffer.subarray(0, 6).toString('ascii');
if (gifSignature === 'GIF87a' || gifSignature === 'GIF89a') return '.gif';
if (
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return '.webp';
}
}
private guessImageExtension(
mimeType: string,
image: HeterogeneousAgentImageAttachment,
buffer: Buffer,
): string | undefined {
const knownByMime: Record<string, string> = {
'image/gif': '.gif',
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
};
if (knownByMime[mimeType]) return knownByMime[mimeType];
const knownByMime = IMAGE_EXTENSIONS_BY_MIME[this.normalizeMimeType(mimeType)];
if (knownByMime) return knownByMime;
try {
const pathname = new URL(image.url).pathname;
const ext = path.extname(pathname);
return ext || undefined;
const ext = path.extname(pathname).toLowerCase();
if (ext) return ext === '.jpeg' ? '.jpg' : ext;
} catch {
return undefined;
// Fall through to byte sniffing below.
}
return this.guessImageExtensionByBuffer(buffer);
}
/**
@@ -430,7 +671,11 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
private async resolveCliImagePath(image: HeterogeneousAgentImageAttachment): Promise<string> {
const { buffer, mimeType } = await this.resolveImage(image);
const cacheKey = this.getImageCacheKey(image.id);
const ext = this.guessImageExtension(mimeType, image) || '';
const ext = this.guessImageExtension(mimeType, image, buffer);
if (!ext) {
throw new Error(`Unsupported image type for ${image.id}`);
}
const filePath = path.join(this.fileCacheDir, `${cacheKey}${ext}`);
try {
@@ -446,18 +691,31 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
private async resolveCliImagePaths(
imageList: HeterogeneousAgentImageAttachment[] = [],
): Promise<string[]> {
const resolved = await Promise.all(
imageList.map(async (image) => {
try {
return await this.resolveCliImagePath(image);
} catch (err) {
logger.error(`Failed to materialize image ${image.id} for CLI:`, err);
return undefined;
}
}),
const results = await Promise.allSettled(
imageList.map((image) => this.resolveCliImagePath(image)),
);
return resolved.filter(Boolean) as string[];
const imagePaths: string[] = [];
const failures: string[] = [];
for (const [index, result] of results.entries()) {
const imageId = imageList[index]?.id ?? `image-${index + 1}`;
if (result.status === 'fulfilled') {
imagePaths.push(result.value);
continue;
}
const message = this.getErrorMessage(result.reason) || 'Unknown error';
logger.error(`Failed to materialize image ${imageId} for CLI:`, result.reason);
failures.push(`${imageId}: ${message}`);
}
if (failures.length > 0) {
throw new Error(`Failed to attach image(s) to CLI: ${failures.join('; ')}`);
}
return imagePaths;
}
/**
@@ -551,14 +809,20 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
resumeSessionId: session.agentSessionId,
});
const useStdin = spawnPlan.stdinPayload !== undefined;
const cliArgs = spawnPlan.args;
// Fall back to the user's Desktop so the process never inherits
// the Electron parent's cwd (which is `/` when launched from Finder).
const cwd = session.cwd || electronApp.getPath('desktop');
const traceSession = await this.createCliTraceSession({
cliArgs,
cwd,
imageList: params.imageList ?? [],
session,
stdinPayload: spawnPlan.stdinPayload,
});
return new Promise<void>((resolve, reject) => {
const cliArgs = spawnPlan.args;
// Fall back to the user's Desktop so the process never inherits
// the Electron parent's cwd (which is `/` when launched from Finder).
const cwd = session.cwd || electronApp.getPath('desktop');
logger.info('Spawning agent:', session.command, cliArgs.join(' '), `(cwd: ${cwd})`);
// `detached: true` on Unix puts the child in a new process group so we
@@ -580,6 +844,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
// In stdin mode, write the prepared payload and close stdin.
if (useStdin && spawnPlan.stdinPayload !== undefined && proc.stdin) {
void this.writeCliTraceFile(traceSession, 'stdin.txt', spawnPlan.stdinPayload);
const stdin = proc.stdin as Writable;
stdin.write(spawnPlan.stdinPayload, () => {
stdin.end();
@@ -618,6 +883,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
// Stream stdout events as raw provider payloads to Renderer.
const stdout = proc.stdout as Readable;
stdout.on('data', (chunk: Buffer) => {
void this.appendCliTraceFile(traceSession, 'stdout.jsonl', chunk);
broadcastParsedOutputs(streamProcessor.push(chunk));
});
stdout.on('end', () => {
@@ -628,11 +894,17 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
const stderrChunks: string[] = [];
const stderr = proc.stderr as Readable;
stderr.on('data', (chunk: Buffer) => {
void this.appendCliTraceFile(traceSession, 'stderr.log', chunk);
stderrChunks.push(chunk.toString('utf8'));
});
proc.on('error', (err) => {
logger.error('Agent process error:', err);
void this.writeCliTraceJson(traceSession, 'process-error.json', {
message: err.message,
name: err.name,
});
void this.flushCliTrace(traceSession);
const sessionError = this.getSessionErrorPayload(err, session);
this.broadcast('heteroAgentSessionError', {
error: sessionError,
@@ -642,7 +914,14 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
});
proc.on('exit', (code, signal) => {
void stdoutBroadcastQueue.finally(() => {
void stdoutBroadcastQueue.finally(async () => {
void this.writeCliTraceJson(traceSession, 'exit.json', {
code,
finishedAt: new Date().toISOString(),
signal,
});
await this.flushCliTrace(traceSession);
logger.info('Agent process exited:', { code, sessionId: session.sessionId, signal });
session.process = undefined;
@@ -662,7 +941,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
resolve();
} else {
const stderrOutput = stderrChunks.join('').trim();
const errorMsg = stderrOutput || `Agent exited with code ${code}`;
const errorMsg = this.getExitErrorMessage(code, session, stderrOutput);
const sessionError = this.getSessionErrorPayload(errorMsg, session);
this.broadcast('heteroAgentSessionError', {
error: sessionError,
@@ -15,6 +15,7 @@ vi.mock('electron', () => ({
BrowserWindow: { getAllWindows: () => [] },
app: {
getPath: vi.fn((name: string) => (name === 'desktop' ? FAKE_DESKTOP_PATH : `/fake/${name}`)),
isPackaged: false,
on: vi.fn(),
},
ipcMain: { handle: vi.fn() },
@@ -56,9 +57,11 @@ vi.mock('node:child_process', async (importOriginal) => {
*/
const createFakeProc = ({
exitCode = 0,
stderrLines = [],
stdoutLines = [],
}: {
exitCode?: number;
stderrLines?: string[];
stdoutLines?: string[];
} = {}) => {
const proc = new EventEmitter() as any;
@@ -86,6 +89,9 @@ const createFakeProc = ({
for (const line of stdoutLines) {
stdout.write(line);
}
for (const line of stderrLines) {
stderr.write(line);
}
stdout.end();
stderr.end();
proc.emit('exit', exitCode);
@@ -381,8 +387,9 @@ describe('HeterogeneousAgentCtr', () => {
expect(command).toBe('codex');
expect(cliArgs).not.toContain(prompt);
expect(cliArgs).toEqual(
expect.arrayContaining(['exec', '--json', '--skip-git-repo-check', '--full-auto', '-']),
expect.arrayContaining(['exec', '--json', '--skip-git-repo-check', '--full-auto']),
);
expect(cliArgs).not.toContain('-');
expect(writes).toEqual([prompt]);
});
@@ -398,8 +405,11 @@ describe('HeterogeneousAgentCtr', () => {
const imagePaths = getFlagValues(cliArgs, '--image');
expect(cliArgs).not.toContain('describe these screenshots');
expect(cliArgs).not.toContain('-');
expect(cliArgs.filter((arg) => arg === '--image')).toHaveLength(2);
expect(imagePaths).toHaveLength(2);
expect(imagePaths).not.toContain('-');
expect(cliArgs.at(-1)).toBe(imagePaths[1]);
expect(imagePaths[0]).toMatch(/\.png$/);
expect(imagePaths[1]).toMatch(/\.jpg$/);
expect(
@@ -413,22 +423,94 @@ describe('HeterogeneousAgentCtr', () => {
expect(writes).toEqual(['describe these screenshots']);
});
it('skips images that fail to materialize and still forwards the remaining --image args', async () => {
it('normalizes parameterized image MIME types before choosing the CLI file extension', async () => {
const imageList = [
{ id: 'image-with-params', url: 'data:image/png;charset=utf-8;base64,UE5HX1RFU1Q=' },
];
const { cliArgs } = await runSendPrompt('describe this screenshot', {}, [], { imageList });
const imagePaths = getFlagValues(cliArgs, '--image');
expect(imagePaths).toHaveLength(1);
expect(imagePaths[0]).toMatch(/\.png$/);
await expect(readFile(imagePaths[0], 'utf8')).resolves.toBe('PNG_TEST');
});
it('sniffs image bytes when MIME and URL do not expose a usable extension', async () => {
const pngBytes = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.from('PNG_TEST'),
]);
const imageList = [
{
id: 'image-octet',
url: `data:application/octet-stream;base64,${pngBytes.toString('base64')}`,
},
];
const { cliArgs } = await runSendPrompt('describe this screenshot', {}, [], { imageList });
const imagePaths = getFlagValues(cliArgs, '--image');
expect(imagePaths).toHaveLength(1);
expect(imagePaths[0]).toMatch(/\.png$/);
await expect(readFile(imagePaths[0])).resolves.toEqual(pngBytes);
});
it('fails before spawning Codex when any image cannot be materialized', async () => {
const imageList = [
{ id: 'good-image', url: 'data:image/png;base64,VkFMSURfSU1BR0U=' },
{ id: 'bad-image', url: 'bad://broken-image' },
];
const { cliArgs, writes } = await runSendPrompt('inspect the valid screenshot only', {}, [], {
imageList,
const { proc } = createFakeProc();
nextFakeProc = proc;
const ctr = new HeterogeneousAgentCtr({
appStoragePath,
storeManager: { get: vi.fn() },
} as any);
const { sessionId } = await ctr.startSession({
agentType: 'codex',
command: 'codex',
});
const imagePaths = getFlagValues(cliArgs, '--image');
await expect(
ctr.sendPrompt({
imageList,
prompt: 'inspect the screenshots',
sessionId,
}),
).rejects.toThrow('Failed to attach image(s) to CLI');
expect(spawnCalls).toHaveLength(0);
});
expect(cliArgs.filter((arg) => arg === '--image')).toHaveLength(1);
expect(imagePaths).toHaveLength(1);
expect(imagePaths[0]).toMatch(/\.png$/);
await expect(readFile(imagePaths[0], 'utf8')).resolves.toBe('VALID_IMAGE');
expect(writes).toEqual(['inspect the valid screenshot only']);
it('does not surface Codex stderr status and warn logs as the terminal error', async () => {
const { proc } = createFakeProc({
exitCode: 1,
stderrLines: [
'Reading prompt from stdin...\n',
'2026-04-25T09:24:08.165782Z WARN codex_core::session_startup_prewarm: startup websocket prewarm setup failed\n',
'<html>\n',
' <body>challenge page</body>\n',
'</html>\n',
],
stdoutLines: [
`${JSON.stringify({ thread_id: 'thread_codex_123', type: 'thread.started' })}\n`,
`${JSON.stringify({ type: 'turn.started' })}\n`,
`${JSON.stringify({ message: 'real Codex JSONL error', type: 'error' })}\n`,
],
});
nextFakeProc = proc;
const ctr = new HeterogeneousAgentCtr({
appStoragePath,
storeManager: { get: vi.fn() },
} as any);
const { sessionId } = await ctr.startSession({
agentType: 'codex',
command: 'codex',
});
await expect(ctr.sendPrompt({ prompt: 'hello', sessionId })).rejects.toThrow(
'Agent exited with code 1',
);
});
it('uses codex exec resume syntax when continuing an existing thread', async () => {
@@ -437,9 +519,73 @@ describe('HeterogeneousAgentCtr', () => {
expect(cliArgs.slice(0, 2)).toEqual(['exec', 'resume']);
expect(cliArgs).toContain('thread_abc');
expect(cliArgs).not.toContain('--resume');
expect(cliArgs.at(-2)).toBe('thread_abc');
expect(cliArgs.at(-1)).toBe('-');
});
it('writes raw CLI streams to a dev trace directory grouped by agent type', async () => {
const originalNodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'development';
try {
const prompt = 'trace this run';
const rawLine = `${JSON.stringify({
thread_id: 'thread_codex_trace',
type: 'thread.started',
})}\n`;
const { sessionId } = await runSendPrompt(prompt, { cwd: appStoragePath }, [rawLine], {
imageList: [{ id: 'image-1', url: 'data:image/png;base64,UE5HX1RFU1Q=' }],
});
const traceRoot = path.join(appStoragePath, '.heerogeneous-tracing');
const agentTraceRoot = path.join(traceRoot, 'codex');
const traceDirs = await readdir(agentTraceRoot);
expect(traceDirs).toHaveLength(1);
const traceDir = path.join(agentTraceRoot, traceDirs[0]);
await expect(readFile(path.join(traceRoot, '.last-live-trace'), 'utf8')).resolves.toBe(
`${traceDir}\n`,
);
await expect(readFile(path.join(traceDir, 'stdin.txt'), 'utf8')).resolves.toBe(prompt);
await expect(readFile(path.join(traceDir, 'stdout.jsonl'), 'utf8')).resolves.toBe(rawLine);
await expect(readFile(path.join(traceDir, 'stderr.log'), 'utf8')).resolves.toBe('');
await expect(readFile(path.join(traceDir, 'exit.json'), 'utf8')).resolves.toContain(
'"code": 0',
);
const meta = JSON.parse(await readFile(path.join(traceDir, 'meta.json'), 'utf8'));
expect(meta).toMatchObject({
agentType: 'codex',
command: 'codex',
cwd: appStoragePath,
sessionId,
stdinBytes: Buffer.byteLength(prompt),
stdoutFile: 'stdout.jsonl',
});
expect(meta.args).not.toContain('-');
expect(meta.attachments).toEqual([{ id: 'image-1', urlKind: 'data' }]);
} finally {
process.env.NODE_ENV = originalNodeEnv;
}
});
it('skips trace creation (and never auto-creates the cwd) when the cwd is missing', async () => {
const originalNodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'development';
const missingCwd = path.join(appStoragePath, 'does-not-exist');
try {
await runSendPrompt('trace this run', { cwd: missingCwd });
await expect(access(missingCwd)).rejects.toThrow();
} finally {
process.env.NODE_ENV = originalNodeEnv;
}
});
it('captures the Codex thread id from json output for later resume', async () => {
const { ctr, sessionId } = await runSendPrompt('hello', {}, [
`${JSON.stringify({ thread_id: 'thread_codex_123', type: 'thread.started' })}\n`,
@@ -21,7 +21,7 @@ const buildCodexOptionArgs = async ({
const imageArgs = imagePaths.flatMap((filePath) => ['--image', filePath]);
const autoExecutionArgs = hasAnyFlag(args, CODEX_AUTO_EXECUTION_FLAGS) ? [] : ['--full-auto'];
return [...CODEX_REQUIRED_ARGS, ...autoExecutionArgs, ...imageArgs, ...args];
return [...CODEX_REQUIRED_ARGS, ...autoExecutionArgs, ...args, ...imageArgs];
};
export const codexDriver: HeterogeneousAgentDriver = {
@@ -37,7 +37,7 @@ export const codexDriver: HeterogeneousAgentDriver = {
return {
args: resumeSessionId
? ['exec', 'resume', ...optionArgs, resumeSessionId, '-']
: ['exec', ...optionArgs, '-'],
: ['exec', ...optionArgs],
stdinPayload: prompt,
};
},
@@ -48,6 +48,48 @@ describe('CodexAdapter', () => {
});
});
it('emits terminal errors from Codex JSONL error events', () => {
const adapter = new CodexAdapter();
const rawMessage = JSON.stringify({
error: {
message:
"The 'gpt-5.5' model requires a newer version of Codex. Please upgrade to the latest app or CLI and try again.",
type: 'invalid_request_error',
},
status: 400,
type: 'error',
});
adapter.adapt({ type: 'turn.started' });
const events = adapter.adapt({
message: rawMessage,
type: 'error',
});
expect(events.map((event) => event.type)).toEqual(['stream_end', 'error']);
expect(events[1].data).toMatchObject({
agentType: 'codex',
clearEchoedContent: true,
message:
"The 'gpt-5.5' model requires a newer version of Codex. Please upgrade to the latest app or CLI and try again.",
stderr: rawMessage,
});
});
it('deduplicates the following turn.failed after a Codex JSONL error event', () => {
const adapter = new CodexAdapter();
adapter.adapt({ type: 'turn.started' });
adapter.adapt({ message: 'first error', type: 'error' });
expect(
adapter.adapt({
error: { message: 'first error' },
type: 'turn.failed',
}),
).toEqual([]);
});
it('emits a new-step boundary when a second turn starts', () => {
const adapter = new CodexAdapter();
@@ -2,6 +2,7 @@ import type {
AgentCLIPreset,
AgentEventAdapter,
HeterogeneousAgentEvent,
HeterogeneousTerminalErrorData,
StepCompleteData,
ToolCallPayload,
ToolResultData,
@@ -331,6 +332,56 @@ const getEventModel = (raw: any): string | undefined => {
return candidates.find((candidate): candidate is string => typeof candidate === 'string');
};
const getStringValue = (value: unknown): string | undefined =>
typeof value === 'string' && value.trim() ? value : undefined;
const parseMaybeJsonError = (value: string): string => {
try {
const parsed = JSON.parse(value);
return (
getStringValue(parsed?.error?.message) ||
getStringValue(parsed?.message) ||
getStringValue(parsed?.error) ||
value
);
} catch {
return value;
}
};
const getCodexTerminalErrorMessage = (raw: any): string => {
const rawMessage =
getStringValue(raw?.message) ||
getStringValue(raw?.error?.message) ||
getStringValue(raw?.error) ||
getStringValue(raw?.result);
if (rawMessage) return parseMaybeJsonError(rawMessage);
if (raw?.error && typeof raw.error === 'object') {
return (
getStringValue(raw.error.message) ||
getStringValue(raw.error.type) ||
JSON.stringify(raw.error)
);
}
return 'Codex execution failed';
};
const getCodexTerminalErrorStderr = (raw: any): string | undefined => {
const rawMessage =
getStringValue(raw?.message) ||
getStringValue(raw?.error?.message) ||
getStringValue(raw?.error) ||
getStringValue(raw?.result);
return (
rawMessage ||
(raw?.error && typeof raw.error === 'object' ? JSON.stringify(raw.error) : undefined)
);
};
export const codexPreset: AgentCLIPreset = {
baseArgs: ['exec', '--json', '--skip-git-repo-check', '--full-auto'],
promptMode: 'stdin',
@@ -348,6 +399,7 @@ export class CodexAdapter implements AgentEventAdapter {
private stepToolCallIds = new Set<string>();
private started = false;
private stepIndex = 0;
private terminalErrorEmitted = false;
adapt(raw: any): HeterogeneousAgentEvent[] {
if (!raw || typeof raw !== 'object') return [];
@@ -367,6 +419,10 @@ export class CodexAdapter implements AgentEventAdapter {
case 'turn.completed': {
return this.handleTurnCompleted(raw);
}
case 'error':
case 'turn.failed': {
return this.handleTerminalError(raw);
}
case 'item.started': {
return this.handleItemStarted(raw.item);
}
@@ -408,6 +464,25 @@ export class CodexAdapter implements AgentEventAdapter {
return [this.makeEvent('step_complete', data)];
}
private handleTerminalError(raw: any): HeterogeneousAgentEvent[] {
if (this.terminalErrorEmitted) return [];
this.terminalErrorEmitted = true;
const data: HeterogeneousTerminalErrorData = {
agentType: CODEX_IDENTIFIER,
clearEchoedContent: true,
message: getCodexTerminalErrorMessage(raw),
stderr: getCodexTerminalErrorStderr(raw),
};
const events: HeterogeneousAgentEvent[] = this.started
? [this.makeEvent('stream_end', {})]
: [];
events.push(this.makeEvent('error', data));
return events;
}
private handleSessionConfigured(raw: any): HeterogeneousAgentEvent[] {
const model = getEventModel(raw);
if (model) this.currentModel = model;
@@ -977,6 +977,73 @@ describe('heterogeneousAgentExecutor DB persistence', () => {
);
});
it('should prefer Codex JSONL terminal errors over stderr status session errors', async () => {
const codexModelError =
"The 'gpt-5.5' model requires a newer version of Codex. Please upgrade to the latest app or CLI and try again.";
const rawCodexError = JSON.stringify({
error: {
message: codexModelError,
type: 'invalid_request_error',
},
status: 400,
type: 'error',
});
const store = createMockStore();
const get = vi.fn(() => store);
let resolveSendPrompt: () => void;
mockSendPrompt.mockReturnValue(
new Promise<void>((r) => {
resolveSendPrompt = r;
}),
);
const executorPromise = executeHeterogeneousAgent(get, {
...defaultParams,
heterogeneousProvider: { command: 'codex', type: 'codex' as const },
});
await flush();
ipc.emitRawLine('ipc-sess-1', codexThreadStarted());
ipc.emitRawLine('ipc-sess-1', codexTurnStarted());
ipc.emitRawLine('ipc-sess-1', { message: rawCodexError, type: 'error' });
ipc.emitRawLine('ipc-sess-1', {
error: { message: rawCodexError },
type: 'turn.failed',
});
ipc.emitError('ipc-sess-1', 'Agent exited with code 1');
await flush();
resolveSendPrompt!();
await executorPromise.catch(() => {});
await flush();
expect(mockUpdateMessageError).toHaveBeenCalledWith(
'ast-initial',
{
body: expect.objectContaining({
agentType: 'codex',
clearEchoedContent: true,
message: codexModelError,
stderr: rawCodexError,
}),
message: codexModelError,
type: 'AgentRuntimeError',
},
expect.any(Object),
);
expect(mockUpdateMessageError).not.toHaveBeenCalledWith(
'ast-initial',
expect.objectContaining({ message: 'Reading prompt from stdin...' }),
expect.any(Object),
);
expect(mockUpdateMessageError).not.toHaveBeenCalledWith(
'ast-initial',
expect.objectContaining({ message: 'Agent exited with code 1' }),
expect.any(Object),
);
});
it('should persist and dispatch structured cli-not-found errors when sendPrompt rejects', async () => {
const store = createMockStore();
const get = vi.fn(() => store);