Files
cc-web/scripts/codex-rollouts-unit.js

196 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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', '<environment_context>运行上下文</environment_context>'),
...pair,
message('assistant', '开始检查'),
item('function_call', { call_id: 'tool-one', name: 'read_file', arguments: '{}' }),
message('user', '<system_reminder>系统注入</system_reminder>'),
item('function_call_output', { call_id: 'tool-one', output: '内容' }),
message('assistant', '检查完成'),
event('task_complete', { turn_id: 'turn-real' }),
message('user', '<environment_context>下一轮上下文</environment_context>'),
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');
});