196 lines
12 KiB
JavaScript
196 lines
12 KiB
JavaScript
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');
|
||
});
|