From f49fde707995e3e876891f36d9c7c71c3d837fab Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Thu, 13 Aug 2026 06:10:06 +0200 Subject: [PATCH] feat(agent): detect listed session parse failures --- .../__tests__/adapters/CodexAdapter.test.ts | 82 +++++++++++ .../src/adapters/AgentAdapter.ts | 18 +++ .../src/adapters/CodexAdapter.ts | 132 +++++++++++------- packages/agent-manager/src/index.ts | 2 + .../cli/src/__tests__/commands/agent.test.ts | 131 ++++++++++++++++- packages/cli/src/commands/agent.ts | 70 ++++++++++ 6 files changed, 385 insertions(+), 50 deletions(-) diff --git a/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts index 7bc591c9..1cb939db 100644 --- a/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts @@ -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; diff --git a/packages/agent-manager/src/adapters/AgentAdapter.ts b/packages/agent-manager/src/adapters/AgentAdapter.ts index 2444d644..f1a39794 100644 --- a/packages/agent-manager/src/adapters/AgentAdapter.ts +++ b/packages/agent-manager/src/adapters/AgentAdapter.ts @@ -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 * diff --git a/packages/agent-manager/src/adapters/CodexAdapter.ts b/packages/agent-manager/src/adapters/CodexAdapter.ts index 28c1629f..529c021e 100644 --- a/packages/agent-manager/src/adapters/CodexAdapter.ts +++ b/packages/agent-manager/src/adapters/CodexAdapter.ts @@ -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'; @@ -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; @@ -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 = @@ -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, @@ -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(); 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); @@ -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 { @@ -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; diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index ced400ec..312c49b8 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -16,6 +16,8 @@ export type { ConversationMessage, SessionSummary, ListSessionsOptions, + ParserHealthProvider, + SessionParserHealth, } from './adapters/AgentAdapter.js'; export { TerminalFocusManager, TerminalType } from './terminal/TerminalFocusManager.js'; diff --git a/packages/cli/src/__tests__/commands/agent.test.ts b/packages/cli/src/__tests__/commands/agent.test.ts index a45e8cf1..cc4ee8fb 100644 --- a/packages/cli/src/__tests__/commands/agent.test.ts +++ b/packages/cli/src/__tests__/commands/agent.test.ts @@ -43,8 +43,9 @@ const mockSelect: any = vi.fn(); const mockTtyWriterSend = vi.fn<(location: any, message: string) => Promise>().mockResolvedValue(undefined); const mockKillAgent = vi.fn<(...args: any[]) => Promise>(); -const { mockEnableDebug, mockDebugLogger } = vi.hoisted(() => ({ +const { mockEnableDebug, mockCreateLogger, mockDebugLogger } = vi.hoisted(() => ({ mockEnableDebug: vi.fn(), + mockCreateLogger: vi.fn(), mockDebugLogger: vi.fn(), })); let restoreStdin: (() => void) | undefined; @@ -150,7 +151,10 @@ vi.mock('../../util/terminal-ui.js', () => ({ vi.mock('../../util/debug.js', () => ({ enableDebug: () => mockEnableDebug(), - createLogger: () => mockDebugLogger, + createLogger: (namespace: string) => { + mockCreateLogger(namespace); + return mockDebugLogger; + }, })); vi.mock('../../services/agent/agent.service.js', async (importOriginal) => { @@ -347,6 +351,129 @@ describe('agent command', () => { expect(ui.table).not.toHaveBeenCalled(); }); + it('logs parser failures for listed agents with list --debug', async () => { + const providerAdapter = { + getParserHealth: vi.fn().mockReturnValue([ + { + adapterType: 'codex', + sessionFilePath: '/tmp/codex/sessions/2026/08/13/session.jsonl', + totalRecords: 4, + parsedMessages: 0, + parseErrors: 1, + healthy: false, + warning: 'session has 4 record(s), 1 JSON parse error(s), and 0 parsed messages', + }, + ]), + }; + mockManager.listAgents.mockResolvedValue([ + { + name: 'repo-codex', + type: 'codex', + status: AgentStatus.RUNNING, + summary: 'Working', + lastActive: new Date('2026-02-26T10:00:00.000Z'), + pid: 123, + sessionFilePath: '/tmp/codex/sessions/2026/08/13/session.jsonl', + }, + ]); + mockManager.getAdapter.mockImplementation((type: string) => type === 'codex' ? providerAdapter : undefined); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'list', '--debug']); + + expect(mockEnableDebug).toHaveBeenCalledTimes(1); + expect(mockCreateLogger).toHaveBeenCalledWith('agent:list'); + expect(providerAdapter.getParserHealth).toHaveBeenCalledWith(['/tmp/codex/sessions/2026/08/13/session.jsonl']); + expect(mockDebugLogger).toHaveBeenCalledWith('listed agents=%d parser failures=%d', 1, 1); + expect(mockDebugLogger).toHaveBeenCalledWith( + '%s', + expect.stringContaining('adapter=codex agent=repo-codex session=session.jsonl records=4 messages=0 parseErrors=1'), + ); + expect(mockDebugLogger).toHaveBeenCalledWith('%s', expect.stringContaining('warning=session has 4 record(s), 1 JSON parse error(s), and 0 parsed messages')); + expect(stderrSpy).not.toHaveBeenCalledWith(expect.stringContaining('parser diagnostics')); + }); + + it('does not warn in normal mode when messages parse', async () => { + const providerAdapter = { + getParserHealth: vi.fn().mockReturnValue([ + { + adapterType: 'codex', + sessionFilePath: '/tmp/current.jsonl', + totalRecords: 4, + parsedMessages: 2, + parseErrors: 1, + healthy: true, + }, + ]), + }; + mockManager.listAgents.mockResolvedValue([ + { + name: 'repo-codex', + type: 'codex', + status: AgentStatus.RUNNING, + summary: 'Working', + lastActive: new Date('2026-02-26T10:00:00.000Z'), + pid: 123, + sessionFilePath: '/tmp/current.jsonl', + }, + ]); + mockManager.getAdapter.mockImplementation((type: string) => type === 'codex' ? providerAdapter : undefined); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'list']); + + expect(providerAdapter.getParserHealth).toHaveBeenCalledWith(['/tmp/current.jsonl']); + expect(stderrSpy).not.toHaveBeenCalledWith(expect.stringContaining('could not be parsed')); + }); + + it('warns concisely in normal mode for parser failures on listed agents only', async () => { + const providerAdapter = { + getParserHealth: vi.fn().mockReturnValue([ + { + adapterType: 'codex', + sessionFilePath: '/tmp/current.jsonl', + totalRecords: 2, + parsedMessages: 0, + parseErrors: 0, + healthy: false, + warning: 'session has 2 record(s) but 0 parsed messages', + }, + ]), + }; + mockManager.listAgents.mockResolvedValue([ + { + name: 'repo-codex', + type: 'codex', + status: AgentStatus.RUNNING, + summary: 'Working', + lastActive: new Date('2026-02-26T10:00:00.000Z'), + pid: 123, + sessionFilePath: '/tmp/current.jsonl', + }, + { + name: 'repo-claude', + type: 'claude', + status: AgentStatus.RUNNING, + summary: 'Working', + lastActive: new Date('2026-02-26T10:00:00.000Z'), + pid: 124, + sessionFilePath: '/tmp/claude.jsonl', + }, + ]); + mockManager.getAdapter.mockImplementation((type: string) => type === 'codex' ? providerAdapter : {}); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'list']); + + expect(mockEnableDebug).not.toHaveBeenCalled(); + expect(mockCreateLogger).not.toHaveBeenCalled(); + expect(providerAdapter.getParserHealth).toHaveBeenCalledWith(['/tmp/current.jsonl']); + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Warning: 1 listed agent session(s) could not be parsed.')); + }); + it('renders table and waiting summary for list', async () => { vi.spyOn(Date, 'now').mockReturnValue(new Date('2026-02-26T10:00:00.000Z').getTime()); mockManager.listAgents.mockResolvedValue([ diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index c7cd9b40..90f35476 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -27,7 +27,9 @@ import { type AgentInfo, type AgentType, type ConversationMessage, + type ParserHealthProvider, type SessionSummary, + type SessionParserHealth, } from '@ai-devkit/agent-manager'; import { ui } from '../util/terminal-ui.js'; import { withErrorHandler } from '../util/errors.js'; @@ -201,6 +203,69 @@ function writeWaitStatus(message: string): void { process.stderr.write(`${message.replace(ANSI_ESCAPE_PATTERN, '')}\n`); } +function isParserHealthProvider(value: unknown): value is ParserHealthProvider { + return Boolean( + value && + typeof value === 'object' && + 'getParserHealth' in value && + typeof (value as { getParserHealth?: unknown }).getParserHealth === 'function', + ); +} + +function reportAgentListParserHealth(manager: AgentManager, agents: AgentInfo[], debug: boolean): void { + const health = getListedAgentParserHealth(manager, agents); + const failures = health.filter((item) => !item.healthy); + + if (debug) { + const logger = createLogger('agent:list'); + logger('listed agents=%d parser failures=%d', agents.length, failures.length); + for (const item of failures) { + logger('%s', formatParserHealthDebugLine(item, agents)); + } + return; + } + + if (failures.length > 0) { + process.stderr.write( + `Warning: ${failures.length} listed agent session(s) could not be parsed. Run "ai-devkit agent list --debug" for details.\n`, + ); + } +} + +function getListedAgentParserHealth(manager: AgentManager, agents: AgentInfo[]): SessionParserHealth[] { + const byType = new Map(); + + for (const agent of agents) { + if (!agent.sessionFilePath) continue; + const current = byType.get(agent.type) ?? []; + current.push(agent.sessionFilePath); + byType.set(agent.type, current); + } + + const health: SessionParserHealth[] = []; + for (const [type, filePaths] of byType) { + const adapter = manager.getAdapter(type); + if (!isParserHealthProvider(adapter)) continue; + health.push(...adapter.getParserHealth(Array.from(new Set(filePaths)))); + } + + return health; +} + +function formatParserHealthDebugLine(item: SessionParserHealth, agents: AgentInfo[]): string { + const agentName = agents.find((agent) => agent.sessionFilePath === item.sessionFilePath)?.name ?? ''; + const sessionPath = item.sessionFilePath ? path.basename(item.sessionFilePath) : ''; + return [ + `adapter=${item.adapterType}`, + `agent=${agentName}`, + `session=${sessionPath}`, + `records=${item.totalRecords}`, + `messages=${item.parsedMessages}`, + `parseErrors=${item.parseErrors}`, + `warning=${item.warning ?? 'parse failed'}`, + ].join(' '); +} + function readStdin(): Promise { return new Promise((resolve, reject) => { let input = ''; @@ -338,10 +403,15 @@ export function registerAgentCommand(program: Command): void { .command('list') .description('List all running AI agents') .option('-j, --json', 'Output as JSON') + .option('--debug', 'Enable debug logging') .action(withErrorHandler('list agents', async (options) => { + if (options.debug) { + enableDebug(); + } const manager = createAgentManager(); const agents = await manager.listAgents(); const printAgents = await createPrintAgentService().store.list(); + reportAgentListParserHealth(manager, agents, Boolean(options.debug)); if (options.json) { console.log(JSON.stringify([...agents, ...printAgents], null, 2));