fix(codex): preserve transcript final answer ordering

This commit is contained in:
weishu
2026-08-10 10:36:34 +08:00
parent 190293ce9e
commit c2cc73159c
6 changed files with 886 additions and 99 deletions
+376 -10
View File
@@ -1,5 +1,14 @@
import { describe, expect, it } from 'vitest';
import { convertCodexEvent, createCodexEventConverter } from './codexEventConverter';
import {
convertCodexEvent,
createCodexEventConverter,
type CodexConversionAction,
type CodexMessage
} from './codexEventConverter';
function getAgentMessages(actions: CodexConversionAction[]): CodexMessage[] {
return actions.flatMap((action) => action.type === 'agent-message' ? [action.message] : []);
}
describe('convertCodexEvent', () => {
it('extracts session_meta id', () => {
@@ -247,14 +256,14 @@ describe('convertCodexEvent', () => {
it('deduplicates legacy and 0.147 semantic messages against response items', () => {
const convert = createCodexEventConverter();
convert({
expect(convert({
type: 'turn_context',
payload: { turn_id: 'turn-1' }
});
expect(convert({
})).toEqual([]);
expect(getAgentMessages(convert({
type: 'event_msg',
payload: { type: 'agent_message', phase: 'commentary', message: 'legacy commentary' }
})?.messages?.[0]).toMatchObject({ message: 'legacy commentary' });
}))[0]).toMatchObject({ message: 'legacy commentary' });
expect(convert({
type: 'response_item',
payload: {
@@ -265,9 +274,9 @@ describe('convertCodexEvent', () => {
content: [{ type: 'output_text', text: 'legacy commentary' }],
internal_chat_message_metadata_passthrough: { turn_id: 'turn-1' }
}
})).toBeNull();
})).toEqual([]);
expect(convert({
expect(getAgentMessages(convert({
type: 'event_msg',
payload: {
type: 'item_completed',
@@ -279,7 +288,7 @@ describe('convertCodexEvent', () => {
content: [{ type: 'Text', text: 'new commentary' }]
}
}
})?.messages?.[0]).toMatchObject({ message: 'new commentary' });
}))[0]).toMatchObject({ message: 'new commentary' });
expect(convert({
type: 'response_item',
payload: {
@@ -290,7 +299,7 @@ describe('convertCodexEvent', () => {
content: [{ type: 'output_text', text: 'new commentary' }],
internal_chat_message_metadata_passthrough: { turn_id: 'turn-1' }
}
})).toBeNull();
})).toEqual([]);
expect(convert({
type: 'response_item',
@@ -302,7 +311,364 @@ describe('convertCodexEvent', () => {
content: [{ type: 'output_text', text: 'response-only final' }],
internal_chat_message_metadata_passthrough: { turn_id: 'turn-1' }
}
})?.messages?.[0]).toMatchObject({ message: 'response-only final' });
})).toEqual([]);
const completed = convert({
type: 'event_msg',
payload: { type: 'task_complete', turn_id: 'turn-1' }
});
expect(getAgentMessages(completed)).toEqual([
expect.objectContaining({ message: 'response-only final' })
]);
expect(completed.at(-1)).toEqual({ type: 'turn-finished', turnId: 'turn-1' });
});
it('flushes older finals before emitting a later semantic mirror', () => {
const convert = createCodexEventConverter();
convert({
type: 'turn_context',
payload: { turn_id: 'turn-1' }
});
expect(convert({
type: 'response_item',
payload: {
type: 'message',
id: 'final-a',
role: 'assistant',
phase: 'final_answer',
content: [{ type: 'output_text', text: 'visible final A' }],
internal_chat_message_metadata_passthrough: { turn_id: 'turn-1' }
}
})).toEqual([]);
expect(convert({
type: 'response_item',
payload: {
type: 'message',
id: 'final-b',
role: 'assistant',
phase: 'final_answer',
content: [{ type: 'output_text', text: 'visible final B' }],
internal_chat_message_metadata_passthrough: { turn_id: 'turn-1' }
}
})).toEqual([]);
const mirrored = convert({
type: 'event_msg',
payload: {
type: 'item_completed',
turn_id: 'turn-1',
item: {
type: 'AgentMessage',
id: 'final-b',
phase: 'final_answer',
content: [{ type: 'Text', text: 'visible final B' }]
}
}
});
expect(getAgentMessages(mirrored)).toEqual([{
type: 'message',
message: 'visible final A',
id: 'final-a'
}, {
type: 'message',
message: 'visible final B',
id: 'final-b'
}]);
expect(convert({
type: 'event_msg',
payload: { type: 'task_complete', turn_id: 'turn-1' }
})).toEqual([{ type: 'turn-finished', turnId: 'turn-1' }]);
});
it('does not register a semantic message after pairing it with a buffered response', () => {
const convert = createCodexEventConverter();
convert({ type: 'turn_context', payload: { turn_id: 'turn-1' } });
convert({
type: 'response_item',
payload: {
type: 'message',
id: 'paired-final',
role: 'assistant',
phase: 'final_answer',
content: [{ type: 'output_text', text: 'same visible text' }],
internal_chat_message_metadata_passthrough: { turn_id: 'turn-1' }
}
});
expect(getAgentMessages(convert({
type: 'event_msg',
payload: {
type: 'item_completed',
turn_id: 'turn-1',
item: {
type: 'AgentMessage',
id: 'paired-final',
phase: 'final_answer',
content: [{ type: 'Text', text: 'same visible text' }]
}
}
}))).toEqual([{
type: 'message',
message: 'same visible text',
id: 'paired-final'
}]);
expect(convert({
type: 'response_item',
payload: {
type: 'message',
id: 'response-only-final',
role: 'assistant',
phase: 'final_answer',
content: [{ type: 'output_text', text: 'same visible text' }],
internal_chat_message_metadata_passthrough: { turn_id: 'turn-1' }
}
})).toEqual([]);
expect(getAgentMessages(convert({
type: 'event_msg',
payload: { type: 'task_complete', turn_id: 'turn-1' }
}))).toEqual([{
type: 'message',
message: 'same visible text',
id: 'response-only-final'
}]);
});
it('finalizes a response-only answer when the event stream ends', () => {
const convert = createCodexEventConverter();
convert({ type: 'turn_context', payload: { turn_id: 'turn-1' } });
expect(convert({
type: 'response_item',
payload: {
type: 'message',
id: 'final-at-eof',
role: 'assistant',
phase: 'final_answer',
content: [{ type: 'output_text', text: 'visible answer at EOF' }],
internal_chat_message_metadata_passthrough: { turn_id: 'turn-1' }
}
})).toEqual([]);
expect(getAgentMessages(convert.finalize())).toEqual([{
type: 'message',
message: 'visible answer at EOF',
id: 'final-at-eof'
}]);
expect(convert.finalize()).toEqual([]);
});
it('flushes a pending final before subsequent user activity', () => {
const convert = createCodexEventConverter();
convert({ type: 'turn_context', payload: { turn_id: 'turn-1' } });
expect(convert({
type: 'response_item',
payload: {
type: 'message',
id: 'visible-final',
role: 'assistant',
phase: 'final_answer',
content: [{ type: 'output_text', text: 'visible final answer' }],
internal_chat_message_metadata_passthrough: { turn_id: 'turn-1' }
}
})).toEqual([]);
expect(convert({
type: 'event_msg',
payload: { type: 'user_message', message: 'queued follow-up' }
})).toEqual([{
type: 'agent-message',
turnId: 'turn-1',
message: {
type: 'message',
message: 'visible final answer',
id: 'visible-final'
}
}, {
type: 'user-message',
message: 'queued follow-up'
}]);
});
it('flushes a pending final before subsequent tool activity', () => {
const convert = createCodexEventConverter();
convert({ type: 'turn_context', payload: { turn_id: 'turn-1' } });
convert({
type: 'response_item',
payload: {
type: 'message',
id: 'visible-final',
role: 'assistant',
phase: 'final_answer',
content: [{ type: 'output_text', text: 'visible final answer' }],
internal_chat_message_metadata_passthrough: { turn_id: 'turn-1' }
}
});
expect(getAgentMessages(convert({
type: 'response_item',
payload: {
type: 'function_call',
name: 'ReadFile',
call_id: 'call-read',
arguments: '{"path":"README.md"}'
}
}))).toEqual([{
type: 'message',
message: 'visible final answer',
id: 'visible-final'
}, expect.objectContaining({
type: 'tool-call',
name: 'ReadFile',
callId: 'call-read'
})]);
});
it('drops only a confirmed compaction summary and flushes earlier finals', () => {
const convert = createCodexEventConverter();
convert({ type: 'turn_context', payload: { turn_id: 'turn-1' } });
convert({
type: 'response_item',
payload: {
type: 'message',
id: 'earlier-visible-final',
role: 'assistant',
phase: 'final_answer',
content: [{ type: 'output_text', text: 'earlier visible final answer' }],
internal_chat_message_metadata_passthrough: { turn_id: 'turn-1' }
}
});
convert({
type: 'response_item',
payload: {
type: 'message',
id: 'compaction-summary',
role: 'assistant',
phase: 'final_answer',
content: [{ type: 'output_text', text: 'internal context summary for the next model' }],
internal_chat_message_metadata_passthrough: { turn_id: 'turn-1' }
}
});
expect(getAgentMessages(convert({
type: 'compacted',
payload: {
message: 'Compaction checkpoint prefix\ninternal context summary for the next model'
}
}))).toEqual([{
type: 'message',
message: 'earlier visible final answer',
id: 'earlier-visible-final'
}]);
expect(convert({
type: 'event_msg',
payload: { type: 'task_complete', turn_id: 'turn-1' }
})).toEqual([{ type: 'turn-finished', turnId: 'turn-1' }]);
});
it('matches an indented compaction summary after the checkpoint prefix', () => {
const convert = createCodexEventConverter();
convert({ type: 'turn_context', payload: { turn_id: 'turn-1' } });
convert({
type: 'response_item',
payload: {
type: 'message',
id: 'indented-compaction-summary',
role: 'assistant',
phase: 'final_answer',
content: [{ type: 'output_text', text: ' internal context summary' }],
internal_chat_message_metadata_passthrough: { turn_id: 'turn-1' }
}
});
expect(convert({
type: 'compacted',
payload: { message: 'Compaction checkpoint prefix\n internal context summary' }
})).toEqual([]);
expect(convert({
type: 'event_msg',
payload: { type: 'task_complete', turn_id: 'turn-1' }
})).toEqual([{ type: 'turn-finished', turnId: 'turn-1' }]);
});
it('does not let token counts confirm a pending final', () => {
const convert = createCodexEventConverter();
convert({ type: 'turn_context', payload: { turn_id: 'turn-1' } });
convert({
type: 'response_item',
payload: {
type: 'message',
id: 'visible-final',
role: 'assistant',
phase: 'final_answer',
content: [{ type: 'output_text', text: 'visible final answer' }],
internal_chat_message_metadata_passthrough: { turn_id: 'turn-1' }
}
});
const tokenCount = convert({
type: 'event_msg',
payload: { type: 'token_count', info: { total_token_usage: {} } }
});
expect(getAgentMessages(tokenCount)).toEqual([
expect.objectContaining({ type: 'token_count' })
]);
const completed = convert({
type: 'event_msg',
payload: { type: 'task_complete', turn_id: 'turn-1' }
});
expect(getAgentMessages(completed)).toEqual([{
type: 'message',
message: 'visible final answer',
id: 'visible-final'
}]);
});
it('keeps a pending visible final when remote compaction has no raw summary item', () => {
const convert = createCodexEventConverter();
convert({
type: 'turn_context',
payload: { turn_id: 'turn-1' }
});
expect(convert({
type: 'response_item',
payload: {
type: 'message',
id: 'visible-before-remote-compaction',
role: 'assistant',
phase: 'final_answer',
content: [{ type: 'output_text', text: 'visible before remote compaction' }],
internal_chat_message_metadata_passthrough: { turn_id: 'turn-1' }
}
})).toEqual([]);
expect(convert({
type: 'compacted',
payload: {
message: '',
replacement_history: [{
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'remote checkpoint summary' }]
}]
}
})).toEqual([]);
const completed = convert({
type: 'event_msg',
payload: { type: 'task_complete', turn_id: 'turn-1' }
});
expect(getAgentMessages(completed)).toEqual([{
type: 'message',
message: 'visible before remote compaction',
id: 'visible-before-remote-compaction'
}]);
expect(completed.at(-1)).toEqual({ type: 'turn-finished', turnId: 'turn-1' });
});
it('converts reasoning events', () => {
+223 -16
View File
@@ -47,7 +47,7 @@ export type CodexMessage = {
is_error?: boolean;
};
export type CodexConversionResult = {
export type CodexEventProjection = {
sessionId?: string;
turnId?: string;
messages?: CodexMessage[];
@@ -56,6 +56,28 @@ export type CodexConversionResult = {
finishedTurnId?: string;
};
export type CodexConversionAction = {
type: 'session-found';
sessionId: string;
} | {
type: 'user-message';
message: string;
} | {
type: 'user-activity';
} | {
type: 'agent-message';
message: CodexMessage;
turnId?: string;
} | {
type: 'turn-finished';
turnId: string;
};
export interface CodexEventConverter {
(rawEvent: unknown): CodexConversionAction[];
finalize: () => CodexConversionAction[];
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object') {
return null;
@@ -150,6 +172,15 @@ type AssistantMessageProjection = {
source: 'semantic' | 'response';
text: string;
turnId: string | null;
itemId: string | null;
};
type PendingResponseFinal = {
key: string;
text: string;
itemId: string | null;
turnId: string | null;
messages: CodexMessage[];
};
function extractEventTurnId(event: CodexSessionEvent): string | null {
@@ -173,7 +204,8 @@ function extractAssistantMessageProjection(
return {
source: 'semantic',
text,
turnId: extractEventTurnId(event) ?? currentTurnId
turnId: extractEventTurnId(event) ?? currentTurnId,
itemId: asString(payload.id)
};
}
@@ -185,7 +217,8 @@ function extractAssistantMessageProjection(
return {
source: 'semantic',
text,
turnId: extractEventTurnId(event) ?? currentTurnId
turnId: extractEventTurnId(event) ?? currentTurnId,
itemId: asString(item?.id)
};
}
@@ -195,7 +228,8 @@ function extractAssistantMessageProjection(
return {
source: 'response',
text,
turnId: extractEventTurnId(event) ?? currentTurnId
turnId: extractEventTurnId(event) ?? currentTurnId,
itemId: asString(payload.id)
};
}
@@ -214,50 +248,223 @@ function rememberProjection(counts: Map<string, number>, key: string): void {
counts.set(key, (counts.get(key) ?? 0) + 1);
}
function isFinalAnswerResponse(event: CodexSessionEvent): boolean {
const payload = asRecord(event.payload);
return event.type === 'response_item'
&& payload?.type === 'message'
&& payload.role === 'assistant'
&& normalizeItemType(payload.phase) === 'finalanswer';
}
function findPendingResponseFinalIndex(
pending: PendingResponseFinal[],
projection: AssistantMessageProjection,
key: string
): number {
if (projection.itemId) {
const itemIndex = pending.findIndex((entry) => entry.itemId === projection.itemId);
if (itemIndex !== -1) return itemIndex;
}
return pending.findIndex((entry) => entry.key === key);
}
function normalizeCompactionText(value: string): string {
return value.replace(/\r\n/g, '\n').trim();
}
function checkpointEndsWithSummary(checkpointMessage: string, summaryText: string): boolean {
if (checkpointMessage === summaryText) return true;
if (!checkpointMessage.endsWith(summaryText)) return false;
const prefix = checkpointMessage.slice(0, -summaryText.length);
return /\n[\t ]*$/.test(prefix);
}
function findCompactionSummaryIndex(
pending: PendingResponseFinal[],
payload: Record<string, unknown> | null
): number {
const rawCheckpointMessage = asString(payload?.message);
const checkpointMessage = rawCheckpointMessage ? normalizeCompactionText(rawCheckpointMessage) : '';
if (!checkpointMessage) return -1;
for (let index = pending.length - 1; index >= 0; index -= 1) {
const text = normalizeCompactionText(pending[index]?.text ?? '');
if (text && checkpointEndsWithSummary(checkpointMessage, text)) {
return index;
}
}
return -1;
}
function convertProjectionToActions(
projection: CodexEventProjection | null,
fallbackTurnId: string | null = null
): CodexConversionAction[] {
if (!projection) return [];
const actions: CodexConversionAction[] = [];
if (projection.sessionId) {
actions.push({ type: 'session-found', sessionId: projection.sessionId });
}
if (projection.userMessage) {
actions.push({ type: 'user-message', message: projection.userMessage });
} else if (projection.userActivity) {
actions.push({ type: 'user-activity' });
}
const turnId = projection.turnId ?? fallbackTurnId;
for (const message of projection.messages ?? []) {
actions.push({
type: 'agent-message',
message,
...(turnId ? { turnId } : {})
});
}
if (projection.finishedTurnId) {
actions.push({ type: 'turn-finished', turnId: projection.finishedTurnId });
}
return actions;
}
function confirmsPendingFinalVisibility(actions: CodexConversionAction[]): boolean {
return actions.some((action) => (
action.type === 'user-message'
|| action.type === 'user-activity'
|| (action.type === 'agent-message' && action.message.type !== 'token_count')
));
}
/**
* Transcript chat text is duplicated across Codex's semantic events and raw
* response items. Keep both as compatible sources, but project each visible
* assistant message once. Some Codex versions only persist final answers as
* response_item messages, while 0.147+ replaced legacy agent_message events
* with item_completed AgentMessage records.
* Project transcript records into one ordered action stream. Raw final-answer
* response items are ambiguous: they can be visible fallback messages or
* internal compaction summaries. Hold them in arrival order until a semantic
* mirror, subsequent visible activity, compaction checkpoint, or turn boundary
* resolves them. Later actions must never overtake an older pending final.
*/
export function createCodexEventConverter(): (rawEvent: unknown) => CodexConversionResult | null {
export function createCodexEventConverter(): CodexEventConverter {
let currentTurnId: string | null = null;
const unmatchedSemanticMessages = new Map<string, number>();
const unmatchedResponseMessages = new Map<string, number>();
const pendingResponseFinals: PendingResponseFinal[] = [];
return (rawEvent: unknown): CodexConversionResult | null => {
const emitPendingFinals = (entries: PendingResponseFinal[]): CodexConversionAction[] => {
const actions: CodexConversionAction[] = [];
for (const entry of entries) {
rememberProjection(unmatchedResponseMessages, entry.key);
for (const message of entry.messages) {
actions.push({
type: 'agent-message',
message,
...(entry.turnId ? { turnId: entry.turnId } : {})
});
}
}
return actions;
};
const drainPendingPrefix = (count: number): CodexConversionAction[] => (
emitPendingFinals(pendingResponseFinals.splice(0, count))
);
const drainAllPendingFinals = (): CodexConversionAction[] => (
drainPendingPrefix(pendingResponseFinals.length)
);
const convert = (rawEvent: unknown): CodexConversionAction[] => {
const parsed = CodexSessionEventSchema.safeParse(rawEvent);
if (!parsed.success) return null;
if (!parsed.success) return [];
if (parsed.data.type === 'session_meta') {
currentTurnId = null;
unmatchedSemanticMessages.clear();
unmatchedResponseMessages.clear();
pendingResponseFinals.length = 0;
}
currentTurnId = extractEventTurnId(parsed.data) ?? currentTurnId;
const projection = extractAssistantMessageProjection(parsed.data, currentTurnId);
const converted = convertCodexEvent(parsed.data);
if (!projection || !converted) return converted;
const convertedActions = convertProjectionToActions(converted, projection?.turnId ?? null);
const payload = asRecord(parsed.data.payload);
if (parsed.data.type === 'compacted') {
// Local compaction stores its raw assistant summary at the end of
// checkpoint.message. Remote compaction leaves message empty and
// stores the summary only in replacement_history, so adjacency is
// insufficient evidence that the latest pending final is internal.
const summaryIndex = findCompactionSummaryIndex(pendingResponseFinals, payload);
if (summaryIndex === -1) return convertedActions;
const earlierVisibleFinals = drainPendingPrefix(summaryIndex);
pendingResponseFinals.shift();
return [...earlierVisibleFinals, ...convertedActions];
}
const eventType = parsed.data.type === 'event_msg' ? asString(payload?.type) : null;
if (eventType === 'task_complete') {
return [...drainAllPendingFinals(), ...convertedActions];
}
if (eventType === 'turn_aborted' || eventType === 'task_failed') {
pendingResponseFinals.length = 0;
return convertedActions;
}
if (!projection || !converted) {
return confirmsPendingFinalVisibility(convertedActions)
? [...drainAllPendingFinals(), ...convertedActions]
: convertedActions;
}
const key = `${projection.turnId ?? ''}\u0000${projection.text}`;
const pendingIndex = projection.source === 'semantic'
? findPendingResponseFinalIndex(pendingResponseFinals, projection, key)
: -1;
if (pendingIndex !== -1) {
const earlierVisibleFinals = drainPendingPrefix(pendingIndex);
pendingResponseFinals.shift();
return [...earlierVisibleFinals, ...convertedActions];
}
if (projection.source === 'response' && isFinalAnswerResponse(parsed.data)) {
if (consumeProjection(unmatchedSemanticMessages, key)) {
return [];
}
// A response-only final answer is ambiguous until the next
// boundary: task_complete confirms visible output, while compacted
// identifies an internal context summary. Hold it briefly.
pendingResponseFinals.push({
key,
text: projection.text,
itemId: projection.itemId,
turnId: projection.turnId,
messages: converted.messages ?? []
});
return [];
}
const opposite = projection.source === 'semantic'
? unmatchedResponseMessages
: unmatchedSemanticMessages;
if (consumeProjection(opposite, key)) {
return null;
return [];
}
const earlierVisibleFinals = drainAllPendingFinals();
rememberProjection(
projection.source === 'semantic' ? unmatchedSemanticMessages : unmatchedResponseMessages,
key
);
return converted;
return [...earlierVisibleFinals, ...convertedActions];
};
return Object.assign(convert, {
finalize: drainAllPendingFinals
});
}
export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | null {
export function convertCodexEvent(rawEvent: unknown): CodexEventProjection | null {
const parsed = CodexSessionEventSchema.safeParse(rawEvent);
if (!parsed.success) {
return null;
@@ -105,9 +105,13 @@ describe('codexSessionScanner', () => {
);
const replayFlags: boolean[] = [];
let replayCompleteCount = 0;
scanner = await createCodexSessionScanner({
transcriptPath,
replayExistingHistory: true,
onReplayComplete: () => {
replayCompleteCount += 1;
},
onEvent: (event, context) => {
events.push(event);
replayFlags.push(context.replayedHistory);
@@ -119,6 +123,7 @@ describe('codexSessionScanner', () => {
expect(events[0]?.type).toBe('session_meta');
expect(events[1]?.payload).toEqual({ type: 'agent_message', message: 'old' });
expect(replayFlags).toEqual([true, true]);
expect(replayCompleteCount).toBe(1);
await appendFile(
transcriptPath,
@@ -126,6 +131,7 @@ describe('codexSessionScanner', () => {
);
await scanner.flush();
expect(replayFlags).toEqual([true, true, false]);
expect(replayCompleteCount).toBe(1);
});
it('reports session id from the transcript metadata', async () => {
@@ -7,6 +7,7 @@ interface CodexSessionScannerOptions {
transcriptPath: string | null;
onEvent: (event: CodexSessionEvent, context: { replayedHistory: boolean }) => void;
onSessionId?: (sessionId: string) => void;
onReplayComplete?: () => void;
replayExistingHistory?: boolean;
}
@@ -37,6 +38,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner<CodexSessionEvent> {
private transcriptPath: string | null;
private readonly onEvent: (event: CodexSessionEvent, context: { replayedHistory: boolean }) => void;
private readonly onSessionId?: (sessionId: string) => void;
private readonly onReplayComplete?: () => void;
private readonly fileEpochByPath = new Map<string, number>();
private readonly fileStateByPath = new Map<string, {
device: number;
@@ -53,6 +55,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner<CodexSessionEvent> {
this.transcriptPath = opts.transcriptPath;
this.onEvent = opts.onEvent;
this.onSessionId = opts.onSessionId;
this.onReplayComplete = opts.onReplayComplete;
this.replayExistingHistoryOnNextAttach = opts.replayExistingHistory ?? false;
}
@@ -101,6 +104,9 @@ class CodexSessionScannerImpl extends BaseSessionScanner<CodexSessionEvent> {
} finally {
this.replayingExistingHistory = false;
}
if (replayedHistory) {
this.onReplayComplete?.();
}
if (stats.newCount > 0) {
logger.debug(`[codex-session-scanner] ${stats.newCount} new events from ${stats.filePath}`);
}