mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
🐛 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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user