fix(codex): align local transcript message projection

Use semantic events as the visible text source and preserve completed plans as ordered plan proposal cards.
This commit is contained in:
weishu
2026-07-12 14:33:37 +08:00
parent 942a1dfff8
commit 8782b8a110
6 changed files with 313 additions and 82 deletions
+68 -24
View File
@@ -32,49 +32,93 @@ describe('convertCodexEvent', () => {
expect(result?.userMessage).toBe('hello user');
});
it('converts response_item user messages', () => {
it('converts completed plan items into proposed plan messages', () => {
const result = convertCodexEvent({
type: 'event_msg',
payload: {
type: 'item_completed',
turn_id: 'turn-1',
item: { type: 'Plan', id: 'plan-1', text: '## Plan\n\n1. Inspect\n2. Implement' }
}
});
expect(result?.message).toMatchObject({
type: 'proposed_plan',
plan: '## Plan\n\n1. Inspect\n2. Implement',
id: 'plan-1',
turnId: 'turn-1'
});
});
it('ignores empty completed plan items', () => {
const result = convertCodexEvent({
type: 'event_msg',
payload: {
type: 'item_completed',
turn_id: 'turn-1',
item: { type: 'Plan', id: 'plan-1', text: ' ' }
}
});
expect(result).toBeNull();
});
it('ignores completed plan items without a turn id', () => {
const result = convertCodexEvent({
type: 'event_msg',
payload: {
type: 'item_completed',
item: { type: 'Plan', id: 'plan-1', text: '## Plan' }
}
});
expect(result).toBeNull();
});
it.each(['task_complete', 'turn_aborted', 'task_failed'])('converts %s into a turn boundary', (type) => {
const result = convertCodexEvent({
type: 'event_msg',
payload: { type, turn_id: 'turn-1' }
});
expect(result).toEqual({ finishedTurnId: 'turn-1' });
});
it.each([
['user text', {
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'hello from response_item user' }]
}
});
expect(result).toEqual({
userMessage: 'hello from response_item user',
userActivity: true
});
});
it('marks image-only response_item messages as user activity', () => {
const result = convertCodexEvent({
}],
['user image', {
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_image', image_url: 'data:image/png;base64,abc' }]
}
});
expect(result).toEqual({ userActivity: true });
});
it('converts response_item assistant messages', () => {
const result = convertCodexEvent({
}],
['assistant text', {
type: 'response_item',
payload: {
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'hello from response_item assistant' }]
}
});
expect(result?.message).toMatchObject({
type: 'message',
message: 'hello from response_item assistant'
});
}],
['injected user context', {
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: '# AGENTS.md\n<environment_context>hidden context</environment_context>' }]
}
}]
])('ignores %s response_item messages', (_name, event) => {
expect(convertCodexEvent(event)).toBeNull();
});
it('converts reasoning events', () => {
+30 -44
View File
@@ -14,6 +14,11 @@ export type CodexMessage = {
type: 'message';
message: string;
id: string;
} | {
type: 'proposed_plan';
plan: string;
id: string;
turnId: string;
} | {
type: 'reasoning';
message: string;
@@ -43,6 +48,7 @@ export type CodexConversionResult = {
message?: CodexMessage;
userMessage?: string;
userActivity?: true;
finishedTurnId?: string;
};
function asRecord(value: unknown): Record<string, unknown> | null {
@@ -56,30 +62,6 @@ function asString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null;
}
function extractCodexText(value: unknown): string {
if (typeof value === 'string') {
return value.trim();
}
if (Array.isArray(value)) {
return value
.map((item) => {
const record = asRecord(item);
if (record?.type === 'input_text' && typeof record.text === 'string') return record.text;
if (record?.type === 'output_text' && typeof record.text === 'string') return record.text;
if (record?.type === 'text' && typeof record.text === 'string') return record.text;
return null;
})
.filter((part): part is string => Boolean(part))
.join(' ')
.trim();
}
const record = asRecord(value);
if (record?.type === 'input_text' && typeof record.text === 'string') return record.text.trim();
if (record?.type === 'output_text' && typeof record.text === 'string') return record.text.trim();
if (record?.type === 'text' && typeof record.text === 'string') return record.text.trim();
return '';
}
function parseArguments(value: unknown): unknown {
if (typeof value !== 'string') {
return value;
@@ -167,6 +149,29 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu
};
}
if (eventType === 'item_completed') {
const item = asRecord(payloadRecord.item);
const itemType = asString(item?.type)?.toLowerCase();
const message = itemType === 'plan' ? asString(item?.text) : null;
const turnId = asString(payloadRecord.turn_id);
if (!message || message.trim().length === 0 || !turnId) {
return null;
}
return {
message: {
type: 'proposed_plan',
plan: message,
id: asString(item?.id) ?? randomUUID(),
turnId
}
};
}
if (eventType === 'task_complete' || eventType === 'turn_aborted' || eventType === 'task_failed') {
const turnId = asString(payloadRecord.turn_id);
return turnId ? { finishedTurnId: turnId } : null;
}
if (eventType === 'agent_reasoning') {
const message = asString(payloadRecord.text) ?? asString(payloadRecord.message);
if (!message) {
@@ -218,26 +223,7 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu
}
if (itemType === 'message') {
const role = asString(payloadRecord.role);
const text = extractCodexText(payloadRecord.content);
if (role === 'user') {
return {
userActivity: true,
...(text ? { userMessage: text } : {})
};
}
if (role === 'assistant') {
if (!text) {
return null;
}
return {
message: {
type: 'message',
message: text,
id: randomUUID()
}
};
}
// Response messages are model conversation state; event_msg carries visible chat.
return null;
}
@@ -89,11 +89,11 @@ describe('codexTranscriptLocator', () => {
await appendFile(transcriptPath, `${JSON.stringify({
timestamp: new Date().toISOString(),
type: 'response_item',
type: 'event_msg',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_image', image_url: 'data:image/png;base64,abc' }]
type: 'user_message',
message: '',
images: ['data:image/png;base64,abc']
}
})}\n`);
await wait(100);