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');
});