Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1452,6 +1452,88 @@ describe('CodexAdapter', () => {
});
});

describe('getParserHealth', () => {
let tmpDir: string;
let sessionsDir: string;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-parser-health-'));
sessionsDir = path.join(tmpDir, 'sessions');
(adapter as any).codexSessionsDir = sessionsDir;
});

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

it('reports healthy when messages parse despite non-message records', () => {
const day = path.join(sessionsDir, '2026', '08', '13');
fs.mkdirSync(day, { recursive: true });
const filePath = path.join(day, 'sess-health.jsonl');
fs.writeFileSync(filePath, [
JSON.stringify({ type: 'session_meta', payload: { id: 'sess-health', cwd: '/repo', timestamp: '2026-08-13T10:00:00Z' } }),
JSON.stringify({ type: 'event', timestamp: '2026-08-13T10:00:01Z', payload: { type: 'user_message', message: 'Fix this' } }),
'{not json',
JSON.stringify({ type: 'mystery_record', timestamp: '2026-08-13T10:00:02Z', payload: { type: 'unknown_payload', message: '???' } }),
JSON.stringify({ type: 'event', timestamp: '2026-08-13T10:00:03Z', payload: { type: 'new_tool_payload', message: 'tool data' } }),
JSON.stringify({
type: 'response_item',
timestamp: '2026-08-13T10:00:04Z',
payload: {
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'Done' }],
internal_chat_message_metadata_passthrough: { turn_id: 'turn-1' },
},
}),
JSON.stringify({
type: 'event_msg',
timestamp: '2026-08-13T10:00:04.001Z',
payload: {
type: 'item_completed',
turn_id: 'turn-1',
item: {
type: 'AgentMessage',
content: [{ type: 'Text', text: 'Done' }],
},
},
}),
].join('\n'));

const health = adapter.getParserHealth();

expect(health).toHaveLength(1);
expect(health[0]).toMatchObject({
adapterType: 'codex',
sessionFilePath: filePath,
totalRecords: 7,
parsedMessages: 2,
parseErrors: 1,
healthy: true,
warning: undefined,
});
});

it('flags non-empty session files with zero parsed messages', () => {
const day = path.join(sessionsDir, '2026', '08', '13');
fs.mkdirSync(day, { recursive: true });
fs.writeFileSync(path.join(day, 'sess-empty.jsonl'), [
JSON.stringify({ type: 'session_meta', payload: { id: 'sess-empty', cwd: '/repo', timestamp: '2026-08-13T10:00:00Z' } }),
JSON.stringify({ type: 'event', timestamp: '2026-08-13T10:00:01Z', payload: { type: 'token_count', input: 10 } }),
].join('\n'));

const health = adapter.getParserHealth();

expect(health[0]).toMatchObject({
totalRecords: 2,
parsedMessages: 0,
parseErrors: 0,
healthy: false,
warning: 'session has 2 record(s) but 0 parsed messages',
});
});
});

describe('listSessions', () => {
let tmpDir: string;
let sessionsDir: string;
Expand Down
18 changes: 18 additions & 0 deletions packages/agent-manager/src/adapters/AgentAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,24 @@ export interface ListSessionsOptions {
type?: AgentType;
}

export interface SessionParserHealth {
/** Adapter that produced the session file, e.g. codex or claude. */
adapterType: AgentType;

/** Absolute path to the session file inspected by the adapter. */
sessionFilePath: string;

totalRecords: number;
parsedMessages: number;
parseErrors: number;
healthy: boolean;
warning?: string;
}

export interface ParserHealthProvider {
getParserHealth(filePaths?: string[]): SessionParserHealth[];
}

/**
* Agent Adapter Interface
*
Expand Down
132 changes: 84 additions & 48 deletions packages/agent-manager/src/adapters/CodexAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import type {
ConversationMessage,
SessionSummary,
ListSessionsOptions,
ParserHealthProvider,
SessionParserHealth,
} from './AgentAdapter.js';
import { AgentStatus } from './AgentAdapter.js';
import { listAgentProcesses, enrichProcesses } from '../utils/process.js';
Expand Down Expand Up @@ -86,12 +88,31 @@ interface MappingMatchResult {
fallback: ProcessInfo[];
}

export class CodexAdapter implements AgentAdapter {
interface AnalyzedCodexSession {
entries: CodexEventEntry[];
metaEntry?: CodexEventEntry;
messages: ConversationMessage[];
health: SessionParserHealth;
}

export class CodexAdapter implements AgentAdapter, ParserHealthProvider {
readonly type = 'codex' as const;

private static readonly IDLE_THRESHOLD_MINUTES = 5;
/** Include session files around process start day to recover long-lived processes. */
private static readonly PROCESS_START_DAY_WINDOW_DAYS = 1;
private static readonly KNOWN_RECORD_TYPES = new Set(['session_meta', 'event', 'response_item', 'event_msg']);
private static readonly KNOWN_PAYLOAD_TYPES = new Set([
'agent_message',
'agent_reasoning',
'exec_command',
'item_completed',
'message',
'task_complete',
'token_count',
'turn_aborted',
'user_message',
]);

private codexSessionsDir: string;
private sessionMappingPath: string;
Expand Down Expand Up @@ -495,30 +516,13 @@ export class CodexAdapter implements AgentAdapter {
}
}

const allLines = content.trim().split('\n');
if (!allLines[0]) return null;

let metaEntry: CodexEventEntry;
try {
metaEntry = JSON.parse(allLines[0]);
} catch {
return null;
}

if (metaEntry.type !== 'session_meta' || !metaEntry.payload?.id) {
const analyzed = this.analyzeCodexSessionContent(content, filePath, { includeVerboseMessages: false });
const metaEntry = analyzed.metaEntry;
if (!metaEntry || metaEntry.type !== 'session_meta' || !metaEntry.payload?.id) {
return null;
}

const entries: CodexEventEntry[] = [];
for (const line of allLines) {
try {
entries.push(JSON.parse(line));
} catch {
continue;
}
}

const lastEntry = this.findLastEventEntry(entries);
const lastEntry = this.findLastEventEntry(analyzed.entries);
const lastPayloadType = lastEntry ? this.normalizedPayloadType(lastEntry) : undefined;

const lastActive =
Expand All @@ -532,7 +536,7 @@ export class CodexAdapter implements AgentAdapter {
return {
sessionId: metaEntry.payload.id,
projectPath: metaEntry.payload.cwd || '',
summary: this.extractSummary(entries),
summary: this.extractSummary(analyzed.entries),
sessionStart,
lastActive,
lastPayloadType,
Expand Down Expand Up @@ -674,29 +678,56 @@ export class CodexAdapter implements AgentAdapter {
const content = safeReadFile(sessionFilePath);
if (content === undefined) return [];

const lines = content.trim().split('\n');
return this.analyzeCodexSessionContent(content, sessionFilePath, { includeVerboseMessages: verbose }).messages;
}

getParserHealth(filePaths?: string[]): SessionParserHealth[] {
const pathsToInspect = filePaths ?? (isDirectory(this.codexSessionsDir) ? this.collectAllSessionFiles() : []);

return pathsToInspect
.map((filePath) => {
const content = safeReadFile(filePath);
if (content === undefined) return null;
return this.analyzeCodexSessionContent(content, filePath, { includeVerboseMessages: false }).health;
})
.filter((health): health is SessionParserHealth => health !== null);
}

private analyzeCodexSessionContent(
content: string,
filePath: string,
options: { includeVerboseMessages: boolean },
): AnalyzedCodexSession {
const lines = content.trim().split('\n').filter((line) => line.trim().length > 0);
const entries: CodexEventEntry[] = [];
const messages: ConversationMessage[] = [];
let parseErrors = 0;

for (let index = 0; index < lines.length; index++) {
const rawLine = lines[index].trim();
let entry: CodexEventEntry;

for (const line of lines) {
try {
entries.push(JSON.parse(line));
entry = JSON.parse(rawLine) as CodexEventEntry;
} catch {
parseErrors += 1;
continue;
}

entries.push(entry);
}

const responseItemMirrorKeys = new Set<string>();
for (const entry of entries) {
if (entry.type !== 'response_item') continue;

const message = this.toConversationMessage(entry, verbose);
const message = this.toConversationMessage(entry, options.includeVerboseMessages);
const mirrorKey = message ? this.mirroredMessageKey(entry, message) : null;
if (mirrorKey) responseItemMirrorKeys.add(mirrorKey);
}

for (const entry of entries) {
const message = this.toConversationMessage(entry, verbose);
const message = this.toConversationMessage(entry, options.includeVerboseMessages);
if (!message) continue;

const mirrorKey = this.mirroredMessageKey(entry, message);
Expand All @@ -711,7 +742,27 @@ export class CodexAdapter implements AgentAdapter {
messages.push(message);
}

return messages;
const healthy = lines.length === 0 || messages.length > 0;
const warning = healthy
? undefined
: parseErrors > 0
? `session has ${lines.length} record(s), ${parseErrors} JSON parse error(s), and 0 parsed messages`
: `session has ${lines.length} record(s) but 0 parsed messages`;

return {
entries,
metaEntry: entries[0],
messages,
health: {
adapterType: this.type,
sessionFilePath: filePath,
totalRecords: lines.length,
parsedMessages: messages.length,
parseErrors,
healthy,
warning,
},
};
}

private toConversationMessage(entry: CodexEventEntry, verbose: boolean): ConversationMessage | null {
Expand Down Expand Up @@ -856,31 +907,16 @@ export class CodexAdapter implements AgentAdapter {
const content = safeReadFile(filePath);
if (content === undefined) return null;

const allLines = content.trim().split('\n');
if (!allLines[0]) return null;

let metaEntry: CodexEventEntry;
try {
metaEntry = JSON.parse(allLines[0]);
} catch {
return null;
}

if (metaEntry.type !== 'session_meta' || !metaEntry.payload?.id) {
const analyzed = this.analyzeCodexSessionContent(content, filePath, { includeVerboseMessages: false });
const metaEntry = analyzed.metaEntry;
if (!metaEntry || metaEntry.type !== 'session_meta' || !metaEntry.payload?.id) {
return null;
}

let firstUserMessage = '';
let lastTimestamp: Date | null = null;

for (let i = 1; i < allLines.length; i++) {
let entry: CodexEventEntry;
try {
entry = JSON.parse(allLines[i]);
} catch {
continue;
}

for (const entry of analyzed.entries.slice(1)) {
const ts = this.parseTimestamp(entry.timestamp);
if (ts) lastTimestamp = ts;

Expand Down
2 changes: 2 additions & 0 deletions packages/agent-manager/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export type {
ConversationMessage,
SessionSummary,
ListSessionsOptions,
ParserHealthProvider,
SessionParserHealth,
} from './adapters/AgentAdapter.js';

export { TerminalFocusManager, TerminalType } from './terminal/TerminalFocusManager.js';
Expand Down
Loading
Loading