const assert = require('node:assert/strict'); const { test } = require('node:test'); const { createCodexRolloutStore } = require('../lib/codex-rollouts'); const store = createCodexRolloutStore({ codexSessionsDir: '/tmp/cc-web-rollout-unit-unused', sessionsDir: '/tmp/cc-web-rollout-unit-unused', normalizeSession: (value) => value, sanitizeToolInput: (_name, value) => value, }); const timestamp = '2026-09-21T10:00:00.000Z'; const event = (type, extra = {}) => ({ type: 'event_msg', timestamp, payload: { type, ...extra } }); const item = (type, extra = {}) => ({ type: 'response_item', timestamp, payload: { type, ...extra } }); const message = (role, text, extra = {}) => item('message', { role, content: [{ type: role === 'user' ? 'input_text' : 'output_text', text }], ...extra, }); const parse = (...entries) => store.parseCodexRolloutLines([ { type: 'session_meta', payload: { id: 'thread-unit' } }, ...entries, ].map((entry) => JSON.stringify(entry))); test('turn ID 支持 metadata 和 turn_context,按明确优先级读取', () => { assert.equal(store.getRolloutTurnId({ turn_id: 'entry', payload: { turn_id: 'payload', internal_chat_message_metadata_passthrough: { turn_id: 'metadata' }, } }), 'payload'); assert.equal(store.getRolloutTurnId({ turn_id: 'entry', payload: { internal_chat_message_metadata_passthrough: { turn_id: 'metadata' }, } }), 'metadata'); assert.equal(store.getRolloutTurnId({ turn_id: 'entry' }), 'entry'); assert.equal(store.getRolloutTurnId({ payload: { turn_context: { id: 'nested-context' } } }), 'nested-context'); assert.equal(store.getRolloutTurnId({ type: 'turn_context', payload: { id: 'context' } }), 'context'); }); test('同一 metadata turn 的几十个文本、重复开始事件和各类工具结果只生成一个助手气泡', () => { const entries = [event('task_started', { turn_id: 'turn-many' }), event('user_message', { message: '检查页面' })]; for (let index = 0; index < 40; index += 1) { entries.push(message('assistant', `片段 ${index}`, { internal_chat_message_metadata_passthrough: { turn_id: 'turn-many' }, })); if (index === 20) entries.push(event('task_started', { turn_id: 'turn-many' })); } for (const type of ['function_call', 'custom_tool_call', 'mcp_tool_call']) { entries.push(item(type, { call_id: type, name: `${type}_name`, arguments: '{}', turn_id: 'turn-many' })); entries.push(item(`${type}_output`, { call_id: type, output: `${type} 完成`, turn_id: 'turn-many' })); } entries.push(event('turn_completed', { turn_id: 'turn-many' })); const result = parse(...entries); assert.deepEqual(result.messages.map((entry) => entry.role), ['user', 'assistant']); const assistant = result.messages[1]; assert.equal(assistant.content.split('\n\n').length, 40); assert.equal(assistant.turnId, 'turn-many'); assert.equal(assistant.nativeThreadId, 'thread-unit'); assert.equal(assistant.nativeTurnKey, 'native:thread-unit:turn-many'); assert.equal(assistant.toolCalls.length, 3); assert(assistant.toolCalls.every((tool) => tool.done && tool.result.endsWith('完成'))); assert.equal(parse(...entries).messages[1].id, assistant.id); }); test('只有 response_item 用户消息的旧 rollout 保持用户和回复的原始位置', () => { const result = parse( message('user', '第一问'), message('assistant', '第一答'), message('user', '第二问'), message('assistant', '第二答'), ); assert.deepEqual(result.messages.map((entry) => entry.content), ['第一问', '第一答', '第二问', '第二答']); assert.notEqual(result.messages[1].id, result.messages[3].id); assert.notEqual(result.messages[1].nativeTurnKey, result.messages[3].nativeTurnKey); }); test('event_msg 与 response_item 用户双写只保留一条,并保留原消息 ID 和客户端 ID', () => { for (const reversed of [false, true]) { const pair = [ event('user_message', { message: '同一问题', client_user_message_id: 'client-1' }), message('user', '同一问题', { id: 'original-user', clientMessageId: 'client-1' }), ]; if (reversed) pair.reverse(); const result = parse(event('task_started', { turn_id: 'turn-pair' }), ...pair, message('assistant', '回答', { id: 'original-assistant' })); assert.equal(result.messages.length, 2); assert.equal(result.messages[0].id, 'original-user'); assert.equal(result.messages[0].clientMessageId, 'client-1'); assert.equal(result.messages[1].id, 'original-assistant'); assert.equal(result.messages[1].turnId, 'turn-pair'); } }); test('有权威事件时忽略 AGENTS 和 environment 注入,双写先后两种顺序保持原消息位置', () => { for (const reversed of [false, true]) { const pair = [event('user_message', { message: '检查页面' }), message('user', '检查页面', { id: 'original-user' })]; if (reversed) pair.reverse(); const result = parse( message('user', '# AGENTS.md instructions for /home/cc-web'), event('task_started', { turn_id: 'turn-real' }), message('user', '运行上下文'), ...pair, message('assistant', '开始检查'), item('function_call', { call_id: 'tool-one', name: 'read_file', arguments: '{}' }), message('user', '系统注入'), item('function_call_output', { call_id: 'tool-one', output: '内容' }), message('assistant', '检查完成'), event('task_complete', { turn_id: 'turn-real' }), message('user', '下一轮上下文'), event('task_started', { turn_id: 'turn-next' }), event('user_message', { message: '下一问', id: 'next-user' }), message('assistant', '下一答'), ); assert.deepEqual(result.messages.map((entry) => entry.content), ['检查页面', '开始检查\n\n检查完成', '下一问', '下一答']); assert.equal(result.messages[0].id, 'original-user'); assert.equal(result.messages[1].toolCalls.length, 1); assert.equal(result.messages[1].toolCalls[0].result, '内容'); assert.equal(result.meta.title, '检查页面'); } }); test('内部跨会话回传不生成用户气泡,也不会切开同一助手 turn', () => { const internal = '来自「来源对话」对话(ID: 00000000-0000-0000-0000-000000000001)的消息:处理完毕'; const result = parse( event('task_started', { turn_id: 'turn-cross' }), event('user_message', { message: '真正的问题' }), message('user', '真正的问题'), message('assistant', '前半'), event('user_message', { message: internal }), message('user', internal), message('assistant', '后半'), event('task_complete', { turn_id: 'turn-cross' }), ); assert.deepEqual(result.messages.map((entry) => entry.content), ['真正的问题', '前半\n\n后半']); assert.equal(result.meta.sourceConversationId, '00000000-0000-0000-0000-000000000001'); assert.equal(result.messages[1].nativeTurnSegment, undefined); }); test('自动继续的子对话回传包装不生成用户气泡或拆轮,同时保留目标来源', () => { const returned = '子对话回传已返回,但已返回不等于已完成。请先检查返回内容是否完整满足原始请求,再决定继续推进、补问目标对话或向用户汇报。\n\nrequestId:request-unit\n目标对话:「实现会话」(ID: 00000000-0000-0000-0000-000000000002)\n\n原始请求:\n检查并修复历史顺序\n\n返回正文:\n修复完成,测试通过。'; const result = parse( event('task_started', { turn_id: 'turn-return' }), event('user_message', { message: '请处理问题' }), message('assistant', '等待实现反馈'), event('user_message', { message: returned }), message('user', returned), message('assistant', '反馈已核对'), ); assert.deepEqual(result.messages.map((entry) => entry.content), ['请处理问题', '等待实现反馈\n\n反馈已核对']); assert.equal(result.messages[1].nativeTurnSegment, undefined); assert.equal(result.meta.sourceConversationId, '00000000-0000-0000-0000-000000000002'); assert.equal(result.meta.sourceConversationTitle, '实现会话'); }); test('普通用户提到子对话回传或不完整包装仍作为用户消息保留', () => { const texts = [ '请解释子对话回传已返回,但已返回不等于已完成。是什么意思。', '子对话回传已返回,但已返回不等于已完成。请帮我确认这句话是否正确。', '子对话回传已返回,但已返回不等于已完成。请先检查返回内容是否完整满足原始请求,再决定继续推进、补问目标对话或向用户汇报。', ]; const result = parse(...texts.map((text) => event('user_message', { message: text }))); assert.deepEqual(result.messages.map((entry) => entry.content), texts); }); test('turn_context 的新轮 ID 和明确完成事件正确划分助手轮次', () => { const result = parse( { type: 'turn_context', payload: { id: 'turn-one' } }, message('assistant', '第一轮'), { type: 'turn_context', payload: { turn_id: 'turn-two' } }, message('assistant', '第二轮'), event('turn_completed', { turn_id: 'turn-one' }), message('assistant', '第二轮继续'), event('turn_completed', { turn_id: 'turn-two' }), message('assistant', '第三轮', { internal_chat_message_metadata_passthrough: { turn_id: 'turn-three' } }), ); assert.deepEqual(result.messages.map((entry) => entry.content), ['第一轮', '第二轮\n\n第二轮继续', '第三轮']); assert.deepEqual(result.messages.map((entry) => entry.turnId), ['turn-one', 'turn-two', 'turn-three']); assert.equal(new Set(result.messages.map((entry) => entry.id)).size, 3); }); test('同轮真实追加用户输入保留邻接关系,前后助手段有不同稳定键', () => { const entries = [ event('task_started', { turn_id: 'turn-steer' }), event('user_message', { message: '开始', id: 'user-start' }), message('assistant', '当前进展'), event('user_message', { message: '补充要求', id: 'user-steer' }), message('user', '补充要求', { id: 'user-steer-copy' }), message('assistant', '按补充要求继续'), ]; const result = parse(...entries); assert.deepEqual(result.messages.map((entry) => entry.content), ['开始', '当前进展', '补充要求', '按补充要求继续']); assert.equal(result.messages[1].nativeTurnSegment, 'initial'); assert(result.messages[3].nativeTurnSegment); assert.equal(result.messages[1].turnId, result.messages[3].turnId); assert.notEqual(result.messages[1].id, result.messages[3].id); assert.notEqual(result.messages[1].nativeTurnKey, result.messages[3].nativeTurnKey); assert.deepEqual(parse(...entries).messages.map((entry) => entry.id), result.messages.map((entry) => entry.id)); }); test('两个相同来源的重复文本仍作为真实用户输入保留', () => { const result = parse( event('task_started', { turn_id: 'turn-repeat' }), event('user_message', { message: '继续', id: 'first' }), message('assistant', '进展'), event('user_message', { message: '继续', id: 'second' }), message('assistant', '完成'), ); assert.equal(result.messages.filter((entry) => entry.role === 'user').length, 2); }); test('迟到的 turn_context 补充身份时不会把已开始的同轮回复拆开', () => { const result = parse( message('assistant', '前半'), { type: 'turn_context', payload: { id: 'turn-late' } }, message('assistant', '后半'), ); assert.equal(result.messages.length, 1); assert.equal(result.messages[0].content, '前半\n\n后半'); assert.equal(result.messages[0].turnId, 'turn-late'); });