Fix Codex SSH sessions showing stderr prefix in responses

When Codex reads prompts from stdin, it outputs "Reading prompt from
stdin..." followed by the actual response to stderr. This caused the
response to appear in red STDERR blocks in the UI.

Fix: Detect and strip the "Reading prompt from stdin..." prefix for
Codex sessions, then emit the actual response content as regular data
instead of stderr.

Added StderrHandler tests covering:
- SSH informational message filtering
- Codex stdin response extraction
- Buffer accumulation
This commit is contained in:
Pedram Amini
2026-02-04 18:25:44 -06:00
parent 60fc0fc5da
commit a5ce8377eb
2 changed files with 214 additions and 0 deletions

View File

@@ -0,0 +1,197 @@
/**
* Tests for src/main/process-manager/handlers/StderrHandler.ts
*
* Covers stderr handling including:
* - SSH informational message filtering
* - Codex stdin mode response extraction
* - Error detection from stderr
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { EventEmitter } from 'events';
// ── Mocks ──────────────────────────────────────────────────────────────────
vi.mock('../../../../main/utils/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock('../../../../main/utils/terminalFilter', () => ({
stripAllAnsiCodes: vi.fn((str: string) => str),
}));
vi.mock('../../../../main/parsers/error-patterns', () => ({
matchSshErrorPattern: vi.fn(() => null),
}));
vi.mock('../../../../main/process-manager/utils/bufferUtils', () => ({
appendToBuffer: vi.fn((buf: string, data: string) => buf + data),
}));
// ── Imports (after mocks) ──────────────────────────────────────────────────
import { StderrHandler } from '../../../../main/process-manager/handlers/StderrHandler';
import type { ManagedProcess } from '../../../../main/process-manager/types';
// ── Helpers ────────────────────────────────────────────────────────────────
function createMockProcess(overrides: Partial<ManagedProcess> = {}): ManagedProcess {
return {
sessionId: 'test-session',
toolType: 'claude-code',
cwd: '/tmp',
pid: 1234,
isTerminal: false,
startTime: Date.now(),
stderrBuffer: '',
errorEmitted: false,
...overrides,
} as ManagedProcess;
}
function createTestContext(processOverrides: Partial<ManagedProcess> = {}) {
const processes = new Map<string, ManagedProcess>();
const emitter = new EventEmitter();
const sessionId = 'test-session';
const proc = createMockProcess({ sessionId, ...processOverrides });
processes.set(sessionId, proc);
const handler = new StderrHandler({ processes, emitter });
return { processes, emitter, handler, sessionId, proc };
}
// ── Tests ──────────────────────────────────────────────────────────────────
describe('StderrHandler', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('SSH informational message filtering', () => {
it('should suppress "Pseudo-terminal will not be allocated" message', () => {
const { handler, emitter, sessionId } = createTestContext();
const stderrSpy = vi.fn();
emitter.on('stderr', stderrSpy);
handler.handleData(sessionId, 'Pseudo-terminal will not be allocated because stdin is not a terminal.');
expect(stderrSpy).not.toHaveBeenCalled();
});
it('should suppress SSH known hosts warning', () => {
const { handler, emitter, sessionId } = createTestContext();
const stderrSpy = vi.fn();
emitter.on('stderr', stderrSpy);
handler.handleData(sessionId, 'Warning: Permanently added "example.com" to the list of known hosts.');
expect(stderrSpy).not.toHaveBeenCalled();
});
it('should emit other stderr messages normally', () => {
const { handler, emitter, sessionId } = createTestContext();
const stderrSpy = vi.fn();
emitter.on('stderr', stderrSpy);
handler.handleData(sessionId, 'Some error message');
expect(stderrSpy).toHaveBeenCalledWith(sessionId, 'Some error message');
});
});
describe('Codex stdin mode response extraction', () => {
it('should extract response from "Reading prompt from stdin..." prefix', () => {
const { handler, emitter, sessionId } = createTestContext({
toolType: 'codex',
});
const dataSpy = vi.fn();
const stderrSpy = vi.fn();
emitter.on('data', dataSpy);
emitter.on('stderr', stderrSpy);
handler.handleData(sessionId, 'Reading prompt from stdin...Hello! How can I help you?');
// Should emit as data, not stderr
expect(dataSpy).toHaveBeenCalledWith(sessionId, 'Hello! How can I help you?');
expect(stderrSpy).not.toHaveBeenCalled();
});
it('should handle "Reading prompt from stdin..." with no content after', () => {
const { handler, emitter, sessionId } = createTestContext({
toolType: 'codex',
});
const dataSpy = vi.fn();
const stderrSpy = vi.fn();
emitter.on('data', dataSpy);
emitter.on('stderr', stderrSpy);
handler.handleData(sessionId, 'Reading prompt from stdin...');
// No actual content to emit
expect(dataSpy).not.toHaveBeenCalled();
expect(stderrSpy).not.toHaveBeenCalled();
});
it('should NOT filter "Reading prompt from stdin..." for non-Codex agents', () => {
const { handler, emitter, sessionId } = createTestContext({
toolType: 'claude-code',
});
const stderrSpy = vi.fn();
emitter.on('stderr', stderrSpy);
handler.handleData(sessionId, 'Reading prompt from stdin...some message');
// Should emit as stderr for non-Codex agents
expect(stderrSpy).toHaveBeenCalledWith(sessionId, 'Reading prompt from stdin...some message');
});
});
describe('empty and whitespace handling', () => {
it('should not emit empty stderr', () => {
const { handler, emitter, sessionId } = createTestContext();
const stderrSpy = vi.fn();
emitter.on('stderr', stderrSpy);
handler.handleData(sessionId, '');
expect(stderrSpy).not.toHaveBeenCalled();
});
it('should not emit whitespace-only stderr', () => {
const { handler, emitter, sessionId } = createTestContext();
const stderrSpy = vi.fn();
emitter.on('stderr', stderrSpy);
handler.handleData(sessionId, ' \n\t ');
expect(stderrSpy).not.toHaveBeenCalled();
});
});
describe('stderr buffer accumulation', () => {
it('should accumulate stderr in buffer', () => {
const { handler, sessionId, proc } = createTestContext();
handler.handleData(sessionId, 'Error 1\n');
handler.handleData(sessionId, 'Error 2\n');
// Buffer should contain both errors
expect(proc.stderrBuffer).toContain('Error 1');
expect(proc.stderrBuffer).toContain('Error 2');
});
});
});

View File

@@ -99,6 +99,23 @@ export class StderrHandler {
return;
}
// Filter out Codex informational prefix when reading from stdin
// Codex outputs "Reading prompt from stdin..." followed by the response to stderr
// when operating in stdin mode. Strip this prefix to avoid displaying it.
if (toolType === 'codex' && cleanedStderr.startsWith('Reading prompt from stdin...')) {
const actualContent = cleanedStderr.replace(/^Reading prompt from stdin\.\.\./, '').trim();
if (actualContent) {
// The actual response content should be emitted as regular data, not stderr
// since it's the agent's response, not an error
logger.debug('[ProcessManager] Codex stdin response extracted from stderr', 'ProcessManager', {
sessionId,
contentPreview: actualContent.substring(0, 100),
});
this.emitter.emit('data', sessionId, actualContent);
}
return;
}
// Emit to separate 'stderr' event for AI processes
this.emitter.emit('stderr', sessionId, cleanedStderr);
}