From 66c25cce4bdfb98838f9d0e4eb66287cd8cadeea Mon Sep 17 00:00:00 2001 From: Arvin Xu Date: Sat, 25 Apr 2026 19:16:36 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(heterogeneous-agent):=20surf?= =?UTF-8?q?ace=20Codex=20terminal=20errors=20and=20trace=20CLI=20output=20?= =?UTF-8?q?(#14166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🐛 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) * 🐛 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) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../main/controllers/HeterogeneousAgentCtr.ts | 341 ++++++++++++++++-- .../__tests__/HeterogeneousAgentCtr.test.ts | 166 ++++++++- .../heterogeneousAgent/drivers/codex.ts | 4 +- .../src/adapters/codex.test.ts | 42 +++ .../src/adapters/codex.ts | 75 ++++ .../heterogeneousAgentExecutor.test.ts | 67 ++++ 6 files changed, 652 insertions(+), 43 deletions(-) diff --git a/apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts b/apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts index 96364f7d02..a3b989f71e 100644 --- a/apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts +++ b/apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts @@ -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; +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; +} + /** * 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 { @@ -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 { + 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, + ): Promise | 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 | 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 | 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 | undefined { + return this.writeCliTraceFile(trace, fileName, `${JSON.stringify(payload, null, 2)}\n`); + } + + private async flushCliTrace(trace: CliTraceSession | undefined): Promise { + await trace?.writeQueue; + } + // ─── Broadcast ─── private broadcast(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 = { - '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 { 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 { - 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((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, diff --git a/apps/desktop/src/main/controllers/__tests__/HeterogeneousAgentCtr.test.ts b/apps/desktop/src/main/controllers/__tests__/HeterogeneousAgentCtr.test.ts index b6070b8f1a..c04df38799 100644 --- a/apps/desktop/src/main/controllers/__tests__/HeterogeneousAgentCtr.test.ts +++ b/apps/desktop/src/main/controllers/__tests__/HeterogeneousAgentCtr.test.ts @@ -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', + '\n', + ' challenge page\n', + '\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`, diff --git a/apps/desktop/src/main/modules/heterogeneousAgent/drivers/codex.ts b/apps/desktop/src/main/modules/heterogeneousAgent/drivers/codex.ts index 5faf4455ed..b8c06b3da5 100644 --- a/apps/desktop/src/main/modules/heterogeneousAgent/drivers/codex.ts +++ b/apps/desktop/src/main/modules/heterogeneousAgent/drivers/codex.ts @@ -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, }; }, diff --git a/packages/heterogeneous-agents/src/adapters/codex.test.ts b/packages/heterogeneous-agents/src/adapters/codex.test.ts index a032ecddeb..a0c9ffb457 100644 --- a/packages/heterogeneous-agents/src/adapters/codex.test.ts +++ b/packages/heterogeneous-agents/src/adapters/codex.test.ts @@ -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(); diff --git a/packages/heterogeneous-agents/src/adapters/codex.ts b/packages/heterogeneous-agents/src/adapters/codex.ts index 4e37be0fa0..db21e0484a 100644 --- a/packages/heterogeneous-agents/src/adapters/codex.ts +++ b/packages/heterogeneous-agents/src/adapters/codex.ts @@ -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(); 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; diff --git a/src/store/chat/slices/aiChat/actions/__tests__/heterogeneousAgentExecutor.test.ts b/src/store/chat/slices/aiChat/actions/__tests__/heterogeneousAgentExecutor.test.ts index 0fab298264..403d035a4c 100644 --- a/src/store/chat/slices/aiChat/actions/__tests__/heterogeneousAgentExecutor.test.ts +++ b/src/store/chat/slices/aiChat/actions/__tests__/heterogeneousAgentExecutor.test.ts @@ -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((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);