mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-08 01:43:07 +00:00
fix(codex): preserve transcript final answer ordering
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user