修复历史边界与流式消息去重并更新发布包
This commit is contained in:
@@ -114,6 +114,19 @@ ${runCommand}
|
||||
|
||||
如果不设置上述变量,默认命令名仍是 \`claude\` 和 \`codex\`。
|
||||
|
||||
## 迁移旧会话历史
|
||||
|
||||
先启动新版服务,再在会话 idle 时预览迁移:
|
||||
|
||||
\`\`\`bash
|
||||
CC_WEB_PASSWORD='登录密码' ${runCommand} --migrate-session-history <会话ID>
|
||||
CC_WEB_PASSWORD='登录密码' ${runCommand} --migrate-session-history <会话ID> --apply
|
||||
\`\`\`
|
||||
|
||||
默认连接本机 8002 端口,可用 \`PORT\` 或 \`CC_WEB_SERVER_URL=ws://127.0.0.1:8002/ws\` 指定服务。
|
||||
只有 \`--apply\` 执行写入;服务端拒绝迁移运行中的会话或无法确认历史边界的快照。
|
||||
原 JSON 保存在 \`sessions/_history-backups/\`,写入后重新加载;无需另装 Node.js。
|
||||
|
||||
## 目录说明
|
||||
|
||||
- \`public/\`:前端静态资源
|
||||
|
||||
195
scripts/codex-rollouts-unit.js
Normal file
195
scripts/codex-rollouts-unit.js
Normal file
@@ -0,0 +1,195 @@
|
||||
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');
|
||||
});
|
||||
297
scripts/history-browser-check.js
Normal file
297
scripts/history-browser-check.js
Normal file
@@ -0,0 +1,297 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// 使用隔离会话、原生 rollout 和 mock 运行时,在真实 Firefox 中验证历史恢复。
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const http = require('node:http');
|
||||
const net = require('node:net');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { WebSocket, WebSocketServer } = require('ws');
|
||||
const { createCodexRolloutStore } = require('../lib/codex-rollouts');
|
||||
|
||||
const REPO = path.resolve(__dirname, '..');
|
||||
const FIREFOX = process.env.CC_WEB_BROWSER_FIREFOX || '/snap/firefox/current/usr/lib/firefox/firefox';
|
||||
const GECKODRIVER = process.env.CC_WEB_BROWSER_GECKODRIVER || '/snap/firefox/current/usr/lib/firefox/geckodriver';
|
||||
const QUESTION = '没参考其他的页面么,WMSA51 是你这么写的么?';
|
||||
const ARCHITECTURE = '先按项目规范核对现有页面、任务票和负责会话,再把样式返工交回原实现负责人。';
|
||||
const BOUNDARY = '这不是单纯的样式问题,而是 T-003 的页面职责和流程边界做错了。';
|
||||
const STREAM = '当前正在生成的消息:正在核对页面职责与流程边界。';
|
||||
const SESSION_ID = '11111111-2222-4333-8444-555555555555';
|
||||
const THREAD_ID = '11111111-2222-4333-8444-666666666666';
|
||||
const PASSWORD = 'HistoryBrowserFixture!234';
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
async function freePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const port = server.address().port;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
async function waitFor(predicate, description, timeout = 12000) {
|
||||
const start = Date.now();
|
||||
let lastError;
|
||||
while (Date.now() - start < timeout) {
|
||||
try { const result = await predicate(); if (result) return result; } catch (error) { lastError = error; }
|
||||
await sleep(80);
|
||||
}
|
||||
throw new Error(`${description}${lastError ? `:${lastError.message}` : ''}`);
|
||||
}
|
||||
async function stopChild(child) {
|
||||
if (!child || child.exitCode !== null || child.signalCode) return;
|
||||
child.kill('SIGTERM');
|
||||
await Promise.race([new Promise((resolve) => child.once('exit', resolve)), sleep(1000)]);
|
||||
if (child.exitCode === null && !child.signalCode) child.kill('SIGKILL');
|
||||
}
|
||||
|
||||
class FirefoxDriver {
|
||||
constructor(port) { this.port = port; this.sessionId = ''; }
|
||||
async request(method, endpoint, body) {
|
||||
const response = await fetch(`http://127.0.0.1:${this.port}${endpoint}`, {
|
||||
method, headers: body === undefined ? {} : { 'content-type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok || payload.value?.error) throw new Error(JSON.stringify(payload.value || payload));
|
||||
return payload.value;
|
||||
}
|
||||
async start() {
|
||||
const value = await this.request('POST', '/session', { capabilities: { alwaysMatch: {
|
||||
browserName: 'firefox', 'moz:firefoxOptions': { binary: FIREFOX, args: ['-headless'] },
|
||||
} } });
|
||||
this.sessionId = value.sessionId;
|
||||
await this.request('POST', `/session/${this.sessionId}/window/rect`, { width: 1280, height: 1050 });
|
||||
return value.capabilities;
|
||||
}
|
||||
execute(script, args = []) { return this.request('POST', `/session/${this.sessionId}/execute/sync`, { script, args }); }
|
||||
navigate(url) { return this.request('POST', `/session/${this.sessionId}/url`, { url }); }
|
||||
refresh() { return this.request('POST', `/session/${this.sessionId}/refresh`, {}); }
|
||||
async screenshot(file) {
|
||||
const data = await this.request('GET', `/session/${this.sessionId}/screenshot`);
|
||||
fs.writeFileSync(file, Buffer.from(data, 'base64'));
|
||||
}
|
||||
async close() { if (this.sessionId) await this.request('DELETE', `/session/${this.sessionId}`); }
|
||||
}
|
||||
|
||||
function createFixture(root) {
|
||||
const home = path.join(root, 'fixture-home');
|
||||
const sessions = path.join(root, 'sessions');
|
||||
const nativeDir = path.join(home, '.codex', 'sessions', '2026', '09', '21');
|
||||
for (const folder of [sessions, nativeDir, path.join(root, 'config'), path.join(root, 'logs')]) fs.mkdirSync(folder, { recursive: true });
|
||||
const rows = [{ type: 'session_meta', payload: { id: THREAD_ID, cwd: root } }];
|
||||
function turn(index, user, textParts) {
|
||||
const stamp = new Date(Date.UTC(2026, 8, 21, 0, 0, index)).toISOString();
|
||||
const turnId = `browser-turn-${index}`;
|
||||
rows.push({ type: 'event_msg', timestamp: stamp, payload: { type: 'task_started', turn_id: turnId } });
|
||||
rows.push({ type: 'event_msg', timestamp: stamp, payload: { type: 'user_message', message: user, id: `browser-user-${index}` } });
|
||||
textParts.forEach((text) => rows.push({ type: 'response_item', timestamp: stamp, payload: {
|
||||
type: 'message', role: 'assistant', internal_chat_message_metadata_passthrough: { turn_id: turnId },
|
||||
content: [{ type: 'output_text', text }],
|
||||
} }));
|
||||
rows.push({ type: 'event_msg', timestamp: stamp, payload: { type: 'task_complete', turn_id: turnId } });
|
||||
}
|
||||
for (let index = 0; index < 72; index++) turn(index, `早期问题 ${index}`, [`早期回答 ${index}`]);
|
||||
const store = createCodexRolloutStore({ codexSessionsDir: nativeDir, sessionsDir: sessions, normalizeSession: (v) => v, sanitizeToolInput: (_name, v) => v });
|
||||
const prefix = store.parseCodexRolloutLines(rows.map((row) => JSON.stringify(row))).messages;
|
||||
const snapshot = prefix.slice(-64);
|
||||
snapshot.push(
|
||||
{ id: 'snapshot-latest-user', clientMessageId: 'snapshot-latest-user', role: 'user', timestamp: '2026-09-21T01:00:00.000Z', content: QUESTION },
|
||||
{ id: 'snapshot-architecture', role: 'assistant', timestamp: '2026-09-21T01:00:01.000Z', content: ARCHITECTURE },
|
||||
{ id: 'snapshot-boundary', role: 'assistant', timestamp: '2026-09-21T01:00:02.000Z', content: BOUNDARY },
|
||||
{ id: 'snapshot-child-reply', role: 'assistant', timestamp: '2026-09-21T01:00:03.000Z', content: '已按现有 WMSA51 页面完成职责核对。', crossConversation: {
|
||||
reply: true, replyToRequestId: 'fixture-child-request', sourceTitle: '页面实现负责人', sourceSessionId: '77777777-2222-4333-8444-555555555555',
|
||||
} },
|
||||
);
|
||||
turn(73, QUESTION, Array.from({ length: 48 }, (_, index) => `native 不应覆盖当前快照的助手片段 ${index}`));
|
||||
for (let index = 74; index < 242; index++) turn(index, `native 内部输入 ${index}`, [`native 内部回答 ${index}`]);
|
||||
rows.push({ type: 'event_msg', timestamp: '2026-09-21T02:00:00.000Z', payload: {
|
||||
type: 'user_message', message: 'native 内部输入尾部', turn_id: 'browser-native-tail',
|
||||
} });
|
||||
const rollout = path.join(nativeDir, `rollout-2026-09-21T00-00-00-${THREAD_ID}.jsonl`);
|
||||
fs.writeFileSync(rollout, rows.map((row) => JSON.stringify(row)).join('\n') + '\n');
|
||||
const notice = {
|
||||
role: 'system', content: '历史消息过多,cc-web 本地快照只保留最近 68 条……', ccwebPersistenceNotice: true,
|
||||
snapshotMessageCount: 68, snapshotFirstMessageId: snapshot[0].id, snapshotLastMessageId: snapshot.at(-1).id,
|
||||
nativeThreadId: THREAD_ID, truncatedAt: '2026-09-21T01:00:04.000Z',
|
||||
};
|
||||
fs.writeFileSync(path.join(sessions, `${SESSION_ID}.json`), JSON.stringify({
|
||||
id: SESSION_ID, title: '历史恢复浏览器夹具', agent: 'codexapp', codexAppThreadId: THREAD_ID, cwd: root,
|
||||
created: '2026-09-21T00:00:00.000Z', updated: '2026-09-21T01:00:04.000Z', permissionMode: 'yolo',
|
||||
historySnapshotBaseIndex: 80, historySnapshotCount: 68, messages: [notice, ...snapshot],
|
||||
}, null, 2));
|
||||
// 只在隔离子进程中替换 homedir;不覆盖真实 HOME / CODEX_HOME,也不访问生产 rollout。
|
||||
const preload = path.join(root, 'isolated-home.cjs');
|
||||
fs.writeFileSync(preload, `
|
||||
const fs = require('node:fs');
|
||||
const Module = require('node:module');
|
||||
const originalLoader = Module._extensions['.js'];
|
||||
require('node:os').homedir = () => ${JSON.stringify(home)};
|
||||
Module._extensions['.js'] = (module, filename) => {
|
||||
if (filename !== ${JSON.stringify(path.join(REPO, 'server.js'))}) return originalLoader(module, filename);
|
||||
const source = fs.readFileSync(filename, 'utf8');
|
||||
const original = "const CODEX_SESSIONS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.codex', 'sessions');";
|
||||
if (!source.includes(original)) throw new Error('原生历史目录注入点已变更,请更新浏览器夹具');
|
||||
module._compile(source.replace(original, 'const CODEX_SESSIONS_DIR = ' + ${JSON.stringify(JSON.stringify(path.join(home, '.codex', 'sessions')))} + ';'), filename);
|
||||
};
|
||||
`);
|
||||
const mock = path.join(root, 'mock-codex.js');
|
||||
const completeFlag = path.join(root, 'complete-turn');
|
||||
const existingMock = fs.readFileSync(path.join(REPO, 'scripts/mock-codex-app-server.js'), 'utf8');
|
||||
const timerCode = 'thread.timer = setTimeout(() => completeTurn(thread, turnId, text), delay);';
|
||||
assert(existingMock.includes(timerCode), 'mock 运行时启动入口已变更');
|
||||
const browserMock = existingMock.replace(timerCode, `
|
||||
send({method:'item/agentMessage/delta',params:{threadId:thread.id,turnId,itemId:'browser-stream',delta:${JSON.stringify(STREAM)}}});
|
||||
thread.timer = setInterval(() => {
|
||||
if (!require('fs').existsSync(${JSON.stringify(completeFlag)})) return;
|
||||
clearInterval(thread.timer);
|
||||
send({method:'turn/completed',params:{threadId:thread.id,turn:{id:turnId,status:'completed',items:[]}}});
|
||||
thread.activeTurnId = null;
|
||||
thread.timer = null;
|
||||
}, 80);
|
||||
`).replace("path.join(__dirname, 'mock-codex.js')", JSON.stringify(path.join(REPO, 'scripts/mock-codex.js')));
|
||||
fs.writeFileSync(mock, browserMock, { mode: 0o755 });
|
||||
return { home, sessions, rollout, preload, mock, completeFlag, snapshotCount: snapshot.length, nativeCount: store.parseCodexRolloutLines(rows.map((row) => JSON.stringify(row))).messages.length };
|
||||
}
|
||||
|
||||
async function startRelay(port, targetPort, evidence) {
|
||||
const relay = http.createServer((request, response) => {
|
||||
const upstream = http.request({ hostname: '127.0.0.1', port: targetPort, path: request.url, method: request.method, headers: request.headers }, (result) => {
|
||||
response.writeHead(result.statusCode, result.headers); result.pipe(response);
|
||||
});
|
||||
upstream.on('error', () => response.destroy()); request.pipe(upstream);
|
||||
});
|
||||
const sockets = new Set();
|
||||
const wsServer = new WebSocketServer({ noServer: true });
|
||||
let connections = 0;
|
||||
relay.on('upgrade', (request, socket, head) => wsServer.handleUpgrade(request, socket, head, (client) => {
|
||||
connections++;
|
||||
sockets.add(client);
|
||||
const upstream = new WebSocket(`ws://127.0.0.1:${targetPort}/ws`);
|
||||
const pending = [];
|
||||
upstream.on('open', () => { pending.splice(0).forEach((data) => upstream.send(data)); });
|
||||
client.on('message', (data) => { if (upstream.readyState === WebSocket.OPEN) upstream.send(data); else pending.push(data); });
|
||||
upstream.on('message', (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(String(data));
|
||||
if (['session_info', 'resume_session_result', 'session_history_chunk'].includes(msg.type)) evidence.protocol.push({
|
||||
type: msg.type, historySource: msg.historySource, historyTotal: msg.historyTotal, isRunning: msg.isRunning,
|
||||
ids: msg.messages?.map((message) => message.id),
|
||||
});
|
||||
} catch {}
|
||||
if (client.readyState === WebSocket.OPEN) client.send(data, { binary: false });
|
||||
});
|
||||
client.on('close', () => { sockets.delete(client); upstream.terminate(); });
|
||||
upstream.on('close', () => { if (client.readyState === WebSocket.OPEN) client.close(); });
|
||||
upstream.on('error', () => client.terminate());
|
||||
client.on('error', () => upstream.terminate());
|
||||
}));
|
||||
await new Promise((resolve) => relay.listen(port, '127.0.0.1', resolve));
|
||||
return {
|
||||
connectionCount: () => connections,
|
||||
disconnect: () => { for (const socket of sockets) socket.terminate(); },
|
||||
close: async () => { for (const socket of sockets) socket.terminate(); wsServer.close(); relay.closeAllConnections(); await new Promise((resolve) => relay.close(resolve)); },
|
||||
};
|
||||
}
|
||||
|
||||
async function capture(driver, stage, root, requireStream = true) {
|
||||
await waitFor(() => driver.execute(`return [...document.querySelectorAll('#messages .msg')].some(node => node.textContent.includes(arguments[0]))`, [requireStream ? STREAM : QUESTION]), `${stage} 消息未恢复`);
|
||||
const result = await driver.execute(`return {
|
||||
messages: [...document.querySelectorAll('#messages > .msg')].map(node => ({
|
||||
role: node.classList.contains('user') ? 'user' : node.classList.contains('assistant') ? 'assistant' : 'system',
|
||||
id: node.dataset.messageId || '', key: node.dataset.messageKey || '', text: node.innerText,
|
||||
source: node.querySelector('.cross-conversation-label')?.innerText || '', streaming: node.id === 'streaming-msg',
|
||||
})), running: document.querySelector('#chat-runtime-state')?.textContent || '',
|
||||
};`);
|
||||
const find = (text) => result.messages.filter((message) => message.text.includes(text));
|
||||
for (const text of [QUESTION, ARCHITECTURE, BOUNDARY]) assert.equal(find(text).length, 1, `${stage}:消息重复或消失:${text}`);
|
||||
const userIndex = result.messages.findIndex((message) => message.text.includes(QUESTION));
|
||||
assert.equal(result.messages[userIndex].role, 'user');
|
||||
assert(result.messages[userIndex + 1].text.includes(ARCHITECTURE), `${stage}:用户与对应回复失去邻接关系`);
|
||||
assert(result.messages[userIndex + 2].text.includes(BOUNDARY));
|
||||
assert.equal(result.messages[userIndex + 3].role, 'assistant');
|
||||
assert.equal(result.messages[userIndex + 3].source, '来自「页面实现负责人」的回复');
|
||||
assert(!result.messages.some((message) => message.text.includes('native 不应覆盖') || message.text.includes('native 内部输入')));
|
||||
if (requireStream) assert.equal(find(STREAM).length, 1, `${stage}:当前输出重复或丢失`);
|
||||
const keys = result.messages.map((message) => message.key).filter(Boolean);
|
||||
assert.equal(new Set(keys).size, keys.length, `${stage}:稳定 ID 重复渲染`);
|
||||
await driver.execute(`const box=document.querySelector('#messages');box.scrollTop=box.scrollHeight;`);
|
||||
// 等待浏览器提交滚动与消息淡入,截图必须展示真实可见内容。
|
||||
await sleep(500);
|
||||
result.visibleTail = await driver.execute(`return [...document.querySelectorAll('#messages > .msg')].slice(-6).map(node => {
|
||||
const box=node.getBoundingClientRect(), style=getComputedStyle(node);
|
||||
return {text:node.innerText, top:box.top, bottom:box.bottom, height:box.height, opacity:style.opacity, visibility:style.visibility, display:style.display};
|
||||
});`);
|
||||
const visibleQuestion = result.visibleTail.find((message) => message.text.includes(QUESTION));
|
||||
assert(visibleQuestion && visibleQuestion.top >= 0 && visibleQuestion.height > 0 && Number(visibleQuestion.opacity) > 0.9,
|
||||
`${stage}:目标问题没有实际显示在视口`);
|
||||
await driver.screenshot(path.join(root, `${stage}.png`));
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
assert(fs.existsSync(FIREFOX) && fs.existsSync(GECKODRIVER), '需要本机 Firefox 和 geckodriver,可通过 CC_WEB_BROWSER_* 指定');
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-history-browser-'));
|
||||
const fixture = createFixture(root);
|
||||
const evidence = { root, fixture, protocol: [], stages: {}, kind: '真实 server.js / Firefox / 隔离持久化与 native rollout / mock Codex App' };
|
||||
const [serverPort, relayPort, driverPort] = await Promise.all([freePort(), freePort(), freePort()]);
|
||||
let server, gecko, relay, driver;
|
||||
try {
|
||||
server = spawn(process.execPath, ['-r', fixture.preload, path.join(REPO, 'server.js')], {
|
||||
cwd: REPO, env: { ...process.env, PORT: String(serverPort), CC_WEB_PASSWORD: PASSWORD,
|
||||
CC_WEB_CONFIG_DIR: path.join(root, 'config'), CC_WEB_SESSIONS_DIR: fixture.sessions, CC_WEB_LOGS_DIR: path.join(root, 'logs'),
|
||||
CODEX_PATH: fixture.mock, CLAUDE_PATH: path.join(REPO, 'scripts/mock-claude.js'), CC_WEB_TASK_BOARD_ENABLED: '0',
|
||||
}, stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const serverLog = fs.createWriteStream(path.join(root, 'server.log')); server.stdout.pipe(serverLog); server.stderr.pipe(serverLog);
|
||||
await waitFor(async () => (await fetch(`http://127.0.0.1:${serverPort}/`)).ok, '隔离服务启动失败');
|
||||
relay = await startRelay(relayPort, serverPort, evidence);
|
||||
gecko = spawn(GECKODRIVER, ['--port', String(driverPort)], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const geckoLog = fs.createWriteStream(path.join(root, 'geckodriver.log')); gecko.stdout.pipe(geckoLog); gecko.stderr.pipe(geckoLog);
|
||||
await waitFor(async () => (await fetch(`http://127.0.0.1:${driverPort}/status`)).ok, 'geckodriver 启动失败');
|
||||
driver = new FirefoxDriver(driverPort);
|
||||
evidence.browser = await driver.start();
|
||||
await driver.navigate(`http://127.0.0.1:${relayPort}/`);
|
||||
await waitFor(async () => {
|
||||
const ready = await driver.execute(`return !document.querySelector('#app').hidden && !!document.querySelector('.session-item')`);
|
||||
if (ready) return true;
|
||||
await driver.execute(`document.querySelector('#login-password').value=arguments[0];document.querySelector('#login-form').requestSubmit();`, [PASSWORD]);
|
||||
return false;
|
||||
}, '登录或会话列表未就绪');
|
||||
await driver.execute(`document.querySelector('.session-item').click();`);
|
||||
evidence.stages.loaded = await capture(driver, 'loaded', root, false);
|
||||
await driver.execute(`const input=document.querySelector('#msg-input');input.value='继续核对页面职责与流程边界';input.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#send-btn').click();`);
|
||||
evidence.stages.running = await capture(driver, 'running', root);
|
||||
await driver.refresh();
|
||||
evidence.stages.refreshed = await capture(driver, 'refreshed', root);
|
||||
const previousCount = relay.connectionCount();
|
||||
relay.disconnect();
|
||||
await waitFor(() => relay.connectionCount() > previousCount, '真实 WebSocket 断线后没有重连');
|
||||
await waitFor(() => evidence.protocol.some((message) => message.type === 'resume_session_result'), '没有收到恢复会话结果');
|
||||
evidence.stages.reconnected = await capture(driver, 'reconnected', root);
|
||||
fs.writeFileSync(fixture.completeFlag, 'complete\n');
|
||||
await waitFor(() => driver.execute(`return !document.querySelector('#streaming-msg')`), '完成事件后流式气泡未收束');
|
||||
evidence.stages.completed = await capture(driver, 'completed', root);
|
||||
await driver.refresh();
|
||||
evidence.stages.completedRefreshed = await capture(driver, 'completed-refreshed', root);
|
||||
assert(evidence.protocol.some((message) => message.historySource === 'merged'), '没有验证到服务端真实 native 前缀合并');
|
||||
evidence.ok = true;
|
||||
console.log(JSON.stringify({ ok: true, browser: evidence.browser.browserVersion, root, stages: Object.keys(evidence.stages), nativeCount: fixture.nativeCount, snapshotCount: fixture.snapshotCount }, null, 2));
|
||||
} catch (error) {
|
||||
evidence.ok = false; evidence.error = error.stack;
|
||||
if (driver?.sessionId) await driver.screenshot(path.join(root, 'failure.png')).catch(() => {});
|
||||
throw new Error(`${error.message}\n证据:${root}`, { cause: error });
|
||||
} finally {
|
||||
fs.writeFileSync(path.join(root, 'evidence.json'), JSON.stringify(evidence, null, 2));
|
||||
if (driver) await driver.close().catch(() => {});
|
||||
if (relay) await relay.close();
|
||||
await stopChild(gecko); await stopChild(server);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
346
scripts/history-frontend-unit.js
Normal file
346
scripts/history-frontend-unit.js
Normal file
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'public', 'app.js'), 'utf8');
|
||||
|
||||
function extractFunction(name) {
|
||||
const start = source.indexOf(`function ${name}(`);
|
||||
assert(start >= 0, `缺少前端函数 ${name}`);
|
||||
let signatureEnd = start;
|
||||
let parentheses = 0;
|
||||
for (let i = source.indexOf('(', start); i < source.length; i += 1) {
|
||||
if (source[i] === '(') parentheses += 1;
|
||||
if (source[i] === ')' && --parentheses === 0) { signatureEnd = i; break; }
|
||||
}
|
||||
let depth = 0;
|
||||
for (let i = source.indexOf('{', signatureEnd); i < source.length; i += 1) {
|
||||
if (source[i] === '{') depth += 1;
|
||||
if (source[i] === '}' && --depth === 0) return source.slice(start, i + 1);
|
||||
}
|
||||
throw new Error(`无法提取 ${name}`);
|
||||
}
|
||||
|
||||
// 仅模拟浏览器节点操作,消息身份、对账、缓存合并及重连分支均执行 app.js 原函数。
|
||||
class Element {
|
||||
constructor() {
|
||||
this.dataset = {};
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
this.scrollTop = 0;
|
||||
this.id = '';
|
||||
this.classList = { contains: (name) => this.message?.role === name };
|
||||
}
|
||||
get scrollHeight() { return this.children.length * 50; }
|
||||
get nextSibling() {
|
||||
if (!this.parentNode) return null;
|
||||
return this.parentNode.children[this.parentNode.children.indexOf(this) + 1] || null;
|
||||
}
|
||||
set innerHTML(value) {
|
||||
this.children.forEach((child) => { child.parentNode = null; });
|
||||
this.children = [];
|
||||
}
|
||||
insertBefore(node, anchor) {
|
||||
if (node.fragment) {
|
||||
[...node.children].forEach((child) => this.insertBefore(child, anchor));
|
||||
return node;
|
||||
}
|
||||
if (node === anchor) return node;
|
||||
node.remove();
|
||||
const index = anchor ? this.children.indexOf(anchor) : this.children.length;
|
||||
assert(index >= 0, '插入锚点必须仍在消息容器中');
|
||||
this.children.splice(index, 0, node);
|
||||
node.parentNode = this;
|
||||
return node;
|
||||
}
|
||||
appendChild(node) { return this.insertBefore(node, null); }
|
||||
removeAttribute(name) { if (name === 'id') this.id = ''; }
|
||||
remove() {
|
||||
if (!this.parentNode) return;
|
||||
const nodes = this.parentNode.children;
|
||||
nodes.splice(nodes.indexOf(this), 1);
|
||||
this.parentNode = null;
|
||||
}
|
||||
replaceWith(node) {
|
||||
if (!this.parentNode) return;
|
||||
this.parentNode.insertBefore(node, this);
|
||||
this.remove();
|
||||
}
|
||||
querySelectorAll(selector) {
|
||||
return this.children.filter((child) => {
|
||||
if (selector.includes('[data-session-message="true"]') && child.dataset.sessionMessage !== 'true') return false;
|
||||
if (selector.includes('[data-message-key]') && !child.dataset.messageKey) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
querySelector() { return null; }
|
||||
}
|
||||
|
||||
function createHarness() {
|
||||
const names = [
|
||||
'stableMessageHash', 'getStableMessageKey', 'getMessageRenderFingerprint',
|
||||
'mergeIndexedSessionMessages', 'dedupeSessionMessages', 'markSessionMessageElement',
|
||||
'mergeHistoryChunkIntoCachedSnapshot', 'mergeHistoryChunkIntoSessionLoadSnapshot',
|
||||
'findRenderedSessionMessageByKey', 'renderMessages', 'reconcileRenderedSessionMessages',
|
||||
'prependHistoryMessages', 'bindStreamingAssistantMessage', 'commitCompletedAssistantMessage',
|
||||
'finishGenerating',
|
||||
];
|
||||
const resumeStart = source.indexOf("case 'resume_session_result':");
|
||||
const resumeEnd = source.indexOf("case 'error':", resumeStart);
|
||||
const doneStart = source.indexOf("case 'done':");
|
||||
const doneEnd = source.indexOf("case 'system_message':", doneStart);
|
||||
const generatingStart = source.indexOf("case 'resume_generating':");
|
||||
const messagesDiv = new Element();
|
||||
const api = new Function('Element', 'messagesDiv', `
|
||||
let renderEpoch = 0;
|
||||
let closedCollabAgentIds = new Set();
|
||||
let collabAgentStateCache = new Map();
|
||||
let collabAgentIdsByToolUseId = new Map();
|
||||
let closedCollabAgentIdsByToolUseId = new Map();
|
||||
let currentSessionId = 'running-session';
|
||||
let currentSessionMessageCount = 0;
|
||||
let isGenerating = false;
|
||||
let generatingSessionId = null;
|
||||
let pendingText = '';
|
||||
let toolGroupCount = 0;
|
||||
let hasGrouped = false;
|
||||
const currentAgent = 'codexapp';
|
||||
const activeToolCalls = new Map();
|
||||
const activeTodoCallTargets = new Map();
|
||||
const window = { pendingContentBlocks: [] };
|
||||
const msgInput = { focus() {} };
|
||||
let loadedHistorySessionId = '';
|
||||
let currentCwd = '/tmp/history-test';
|
||||
const historyLoadMore = null;
|
||||
const sessionCache = new Map();
|
||||
const buffers = new Map();
|
||||
const timers = [];
|
||||
let outlineUpdates = 0;
|
||||
let scrollbarUpdates = 0;
|
||||
const document = {
|
||||
getElementById(id) { return messagesDiv.children.find((node) => node.id === id) || null; },
|
||||
querySelector() { return null; },
|
||||
createDocumentFragment() { const node = new Element(); node.fragment = true; return node; },
|
||||
};
|
||||
function setTimeout(callback) { timers.push(callback); }
|
||||
function collectClosedCollabAgentIds() { return new Set(); }
|
||||
function clearUserMessageIndex() {}
|
||||
function updateUserOutlinePanel() { outlineUpdates += 1; }
|
||||
function updateScrollbar() { scrollbarUpdates += 1; }
|
||||
function renderPendingNotes() {}
|
||||
function scrollToBottom() {}
|
||||
function syncAssistantBranchButton() {}
|
||||
function isCurrentSessionEvent(msg) { return msg.sessionId === currentSessionId; }
|
||||
function clearPendingSessionResumeRequest() {}
|
||||
function setCurrentSessionRunningState() {}
|
||||
function updateNoteModeUI() {}
|
||||
function migratePendingNotesToSession() {}
|
||||
function migrateQueuedMessagesToSession() {}
|
||||
function scheduleQueuedMessageDrain() {}
|
||||
function syncAssistantLastSectionButton() {}
|
||||
function updateGenerationControls() {}
|
||||
function isNearBottom() { return true; }
|
||||
function flushRender() {
|
||||
const stream = document.getElementById('streaming-msg');
|
||||
if (stream) stream.pendingText = pendingText;
|
||||
}
|
||||
function startGenerating() {
|
||||
isGenerating = true;
|
||||
const stream = new Element();
|
||||
stream.id = 'streaming-msg';
|
||||
stream.dataset.messageKey = 'local-stream';
|
||||
messagesDiv.appendChild(stream);
|
||||
}
|
||||
function getSessionHistoryBuffer(id) { return buffers.get(id) || null; }
|
||||
function isCompleteSessionSnapshot(snapshot) { return snapshot.complete; }
|
||||
function cacheSessionSnapshot(snapshot) { sessionCache.set(snapshot.sessionId, { snapshot }); }
|
||||
function bufferSessionSnapshot(snapshot) { buffers.set(snapshot.sessionId, snapshot); }
|
||||
${names.map(extractFunction).join('\n')}
|
||||
function buildMsgElement(message, index) {
|
||||
const element = new Element();
|
||||
element.message = JSON.parse(JSON.stringify(message));
|
||||
markSessionMessageElement(element, index, message);
|
||||
return element;
|
||||
}
|
||||
function resume(msg) {
|
||||
switch (msg.type) { ${source.slice(resumeStart, resumeEnd)} }
|
||||
}
|
||||
function done(msg) {
|
||||
switch (msg.type) { ${source.slice(doneStart, doneEnd)} }
|
||||
}
|
||||
function resumeGenerating(msg) {
|
||||
switch (msg.type) { ${source.slice(generatingStart, resumeStart)} }
|
||||
}
|
||||
return {
|
||||
${names.join(',')}, resume, done, resumeGenerating, messagesDiv, sessionCache, buffers,
|
||||
setCount(value) { currentSessionMessageCount = value; },
|
||||
count() { return currentSessionMessageCount; },
|
||||
flushTimers() { while (timers.length) timers.shift()(); },
|
||||
updates() { return { outlineUpdates, scrollbarUpdates }; },
|
||||
};
|
||||
`)(Element, messagesDiv);
|
||||
api.ids = () => messagesDiv.children.map((node) => node.message?.id || node.id);
|
||||
return api;
|
||||
}
|
||||
|
||||
function message(id, role = 'assistant', content = id) {
|
||||
return { id, role, content, timestamp: '2026-09-21T00:00:00.000Z' };
|
||||
}
|
||||
|
||||
{
|
||||
const api = createHarness();
|
||||
assert.equal(api.getStableMessageKey({ role: 'assistant', turnId: 'turn-1', content: '初稿' }),
|
||||
api.getStableMessageKey({ role: 'assistant', turnId: 'turn-1', content: '完成' }));
|
||||
assert.notEqual(api.getStableMessageKey({ role: 'user', turnId: 'turn-1' }),
|
||||
api.getStableMessageKey({ role: 'assistant', turnId: 'turn-1' }));
|
||||
assert.equal(api.getStableMessageKey({ id: '已保存', turnId: 'turn-1' }), '已保存');
|
||||
const before = message('reply');
|
||||
assert.notEqual(api.getMessageRenderFingerprint(before), api.getMessageRenderFingerprint({ ...before, toolCalls: [{ result: '完成' }] }));
|
||||
assert.notEqual(api.getMessageRenderFingerprint(before), api.getMessageRenderFingerprint({ ...before, crossConversation: { sourceTitle: '子对话' } }));
|
||||
}
|
||||
|
||||
for (const cached of [false, true]) {
|
||||
const api = createHarness();
|
||||
const snapshot = { sessionId: 'history', messages: [message('user', 'user'), message('assistant')], historyBaseIndex: 7, historyTotal: 10 };
|
||||
api.buffers.set('history', snapshot);
|
||||
const merge = (messages, baseIndex) => cached
|
||||
? api.mergeHistoryChunkIntoCachedSnapshot('history', messages, { baseIndex, total: 10, cursor: 3 })
|
||||
: api.mergeHistoryChunkIntoSessionLoadSnapshot(snapshot, messages, { baseIndex, total: 10 });
|
||||
merge([message('older')], 7);
|
||||
assert.deepEqual(snapshot.messages.map((item) => item.id), ['user', 'older', 'assistant'], '相同下标不能吞掉不同 ID');
|
||||
merge([message('older')], 7);
|
||||
assert.equal(snapshot.messages.length, 3, '重复分页不能重复插入');
|
||||
assert.equal(snapshot.historyMessagePositions.assistant, 8, '碰撞后的再次分页不能挤动原下标');
|
||||
const updated = { ...message('assistant', 'assistant', '新正文'), toolCalls: [{ result: '工具结果' }], crossConversation: { reply: true, sourceTitle: '负责人' } };
|
||||
merge([updated], 3);
|
||||
assert.deepEqual(snapshot.messages.map((item) => item.id), ['assistant', 'user', 'older'], '同 ID 的新位置必须生效');
|
||||
assert.deepEqual(snapshot.messages[0], updated, '同 ID 的正文、工具和来源必须更新');
|
||||
assert.equal(snapshot.historyBaseIndex, 3);
|
||||
}
|
||||
|
||||
{
|
||||
const api = createHarness();
|
||||
api.renderMessages([message('user', 'user'), message('assistant'), message('assistant', 'assistant', '最终回复')], { immediate: true, baseIndex: 7 });
|
||||
assert.deepEqual(api.ids(), ['user', 'assistant'], '初始渲染按稳定 ID 去重');
|
||||
assert.equal(api.messagesDiv.children[1].message.content, '最终回复');
|
||||
api.prependHistoryMessages([message('older')], { baseIndex: 7 });
|
||||
assert.deepEqual(api.ids(), ['user', 'older', 'assistant'], '同下标的分页消息不得丢失');
|
||||
api.prependHistoryMessages([message('older')], { baseIndex: 7 });
|
||||
assert.equal(api.messagesDiv.children.length, 3);
|
||||
const updated = { ...message('assistant', 'assistant', '最终回复'), toolCalls: [{ id: 'tool', result: '已更新' }], crossConversation: { reply: true, sourceTitle: '负责人' } };
|
||||
const counts = api.updates();
|
||||
api.prependHistoryMessages([updated], { baseIndex: 3 });
|
||||
assert.deepEqual(api.ids(), ['assistant', 'user', 'older'], '同 ID 跨下标移动必须重排 DOM');
|
||||
assert.deepEqual(api.messagesDiv.children[0].message, updated, '正文相同时也更新工具和来源');
|
||||
assert.equal(api.messagesDiv.children[0].dataset.messageIndex, '3');
|
||||
assert(api.updates().outlineUpdates > counts.outlineUpdates, '只更新消息也需要刷新目录');
|
||||
}
|
||||
|
||||
{
|
||||
const api = createHarness();
|
||||
const initial = Array.from({ length: 35 }, (_, index) => message(`batch-${index}`));
|
||||
api.renderMessages(initial);
|
||||
const latest = { ...initial[3], content: '重连期间更新的内容', toolCalls: [{ result: '新结果' }] };
|
||||
api.reconcileRenderedSessionMessages([latest], { baseIndex: 3 });
|
||||
api.flushTimers();
|
||||
assert.deepEqual(api.ids(), initial.map((item) => item.id), '延迟渲染与重连对账交错不能重复插入');
|
||||
assert.equal(api.messagesDiv.children[3].message.content, latest.content, '延迟批次不能把更新恢复成旧内容');
|
||||
}
|
||||
|
||||
{
|
||||
const api = createHarness();
|
||||
const user = message('question', 'user', '没参考其他的页面么,WMSA51 是你这么写的么?');
|
||||
const architecture = message('architecture', 'assistant', '页面职责和流程边界需要返工');
|
||||
const reply = { ...message('reply'), crossConversation: { reply: true, sourceTitle: '旧来源' } };
|
||||
api.renderMessages([architecture, reply], { immediate: true, baseIndex: 20 });
|
||||
const stream = new Element();
|
||||
stream.id = 'streaming-msg';
|
||||
stream.pendingText = '当前正在生成的消息';
|
||||
api.messagesDiv.appendChild(stream);
|
||||
const refreshedReply = { ...reply, crossConversation: { reply: true, sourceTitle: '子对话负责人' }, toolCalls: [{ result: '验收完成' }] };
|
||||
const snapshot = { sessionId: 'running-session', messages: [architecture, reply], historyBaseIndex: 20, historyTotal: 23 };
|
||||
api.buffers.set('running-session', snapshot);
|
||||
const resume = {
|
||||
type: 'resume_session_result', sessionId: 'running-session', isRunning: true,
|
||||
historyBaseIndex: 20, historyTotal: 23, messages: [user, architecture, refreshedReply],
|
||||
};
|
||||
api.resume(resume);
|
||||
api.resume(resume);
|
||||
assert.deepEqual(api.ids(), ['question', 'architecture', 'reply', 'streaming-msg']);
|
||||
assert.strictEqual(api.messagesDiv.children.at(-1), stream, '运行中重连必须保留原流式节点');
|
||||
assert.equal(stream.pendingText, '当前正在生成的消息');
|
||||
assert.equal(api.messagesDiv.children[2].message.crossConversation.sourceTitle, '子对话负责人');
|
||||
const changed = { ...refreshedReply, content: '回传内容更新', toolCalls: [{ result: '新结果' }] };
|
||||
api.resume({ ...resume, messages: [user, architecture, changed] });
|
||||
assert.equal(api.messagesDiv.children[2].message.content, '回传内容更新', '同数量同 ID 的重连也必须更新');
|
||||
assert.equal(snapshot.messages.find((item) => item.id === 'reply').toolCalls[0].result, '新结果');
|
||||
assert.equal(api.messagesDiv.children[0].message.role, 'user');
|
||||
assert.equal(api.messagesDiv.children[2].message.role, 'assistant');
|
||||
}
|
||||
|
||||
{
|
||||
const api = createHarness();
|
||||
const question = message('question', 'user');
|
||||
const completed = { ...message('turn-reply'), toolCalls: [{ id: 'tool', result: '最终结果' }] };
|
||||
api.renderMessages([question], { immediate: true });
|
||||
api.setCount(1);
|
||||
api.resumeGenerating({ type: 'resume_generating', sessionId: 'running-session', assistantMessageId: completed.id, turnId: 'turn-1', text: '生成中的回复' });
|
||||
const stream = api.messagesDiv.children.at(-1);
|
||||
assert.equal(stream.dataset.messageKey, completed.id, '恢复流式状态必须绑定持久化助手 ID');
|
||||
assert.equal(stream.dataset.turnId, 'turn-1');
|
||||
api.done({ type: 'done', sessionId: 'running-session', assistantMessage: completed, assistantMessageIndex: 1 });
|
||||
assert.deepEqual(api.ids(), ['question', 'turn-reply']);
|
||||
assert.deepEqual(api.messagesDiv.children[1].message, completed, '完成事件必须采用规范化助手消息');
|
||||
assert.equal(api.count(), 2);
|
||||
api.done({ type: 'done', sessionId: 'running-session', assistantMessage: completed, assistantMessageIndex: 1 });
|
||||
assert.deepEqual(api.ids(), ['question', 'turn-reply'], '重复完成事件不能重复增加助手');
|
||||
assert.equal(api.count(), 2, '重复完成事件不能增加消息计数');
|
||||
api.resume({ type: 'resume_session_result', sessionId: 'running-session', isRunning: false, messages: [question, completed], historyBaseIndex: 0, historyTotal: 2 });
|
||||
assert.deepEqual(api.ids(), ['question', 'turn-reply'], '完成后的重连不得再次插入助手');
|
||||
}
|
||||
|
||||
{
|
||||
const api = createHarness();
|
||||
const question = message('question', 'user');
|
||||
const completed = message('turn-reply', 'assistant', '快照中的最终回复');
|
||||
api.renderMessages([question, completed], { immediate: true });
|
||||
api.setCount(2);
|
||||
api.resumeGenerating({ type: 'resume_generating', sessionId: 'running-session', assistantMessageId: completed.id, turnId: 'turn-2', text: '仍在生成' });
|
||||
const stream = api.messagesDiv.children.at(-1);
|
||||
assert.deepEqual(api.ids(), ['question', 'streaming-msg'], '相同助手 ID 的历史气泡与 stream 只能显示一份');
|
||||
api.resume({ type: 'resume_session_result', sessionId: 'running-session', isRunning: true, messages: [question, completed], historyBaseIndex: 0, historyTotal: 2 });
|
||||
assert.deepEqual(api.ids(), ['question', 'streaming-msg'], '流式过程中重复快照不能再建立同 ID 历史气泡');
|
||||
assert.strictEqual(api.messagesDiv.children.at(-1), stream, '对账仍须保留运行中的原节点');
|
||||
api.resume({ type: 'resume_session_result', sessionId: 'running-session', isRunning: false, messages: [question, completed], historyBaseIndex: 0, historyTotal: 2 });
|
||||
assert.deepEqual(api.ids(), ['question', 'turn-reply'], '停止运行的恢复结果须把 stream 转成同 ID 最终助手');
|
||||
assert.equal(api.count(), 2);
|
||||
}
|
||||
|
||||
{
|
||||
const api = createHarness();
|
||||
const completed = message('already-saved');
|
||||
api.renderMessages([completed], { immediate: true });
|
||||
api.setCount(1);
|
||||
api.resumeGenerating({ type: 'resume_generating', sessionId: 'running-session', text: '尚未绑定 ID 的流式输出' });
|
||||
assert.equal(api.messagesDiv.children.length, 2);
|
||||
api.done({ type: 'done', sessionId: 'running-session', assistantMessage: { ...completed, content: '规范化最终回复' }, assistantMessageIndex: 0 });
|
||||
assert.deepEqual(api.ids(), ['already-saved'], '完成事件需移除与已保存消息重复的 stream');
|
||||
assert.equal(api.messagesDiv.children[0].message.content, '规范化最终回复');
|
||||
assert.equal(api.count(), 1);
|
||||
}
|
||||
|
||||
{
|
||||
const api = createHarness();
|
||||
const completed = message('without-stream');
|
||||
api.done({ type: 'done', sessionId: 'running-session', assistantMessage: completed, assistantMessageIndex: 3 });
|
||||
assert.deepEqual(api.ids(), ['without-stream'], '丢失流式节点时完成事件仍可补回最终消息');
|
||||
assert.equal(api.count(), 4);
|
||||
api.done({ type: 'done', sessionId: 'running-session', assistantMessage: { ...completed, content: '最终内容更新' }, assistantMessageIndex: 3 });
|
||||
assert.equal(api.messagesDiv.children[0].message.content, '最终内容更新');
|
||||
assert.equal(api.count(), 4);
|
||||
}
|
||||
|
||||
console.log('前端历史单测通过:稳定 ID、分页、重连、流式身份绑定与完成去重');
|
||||
37
scripts/migrate-session-history.js
Normal file
37
scripts/migrate-session-history.js
Normal file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// 默认预览;--apply 才会让服务端在 idle 状态下备份并原子迁移。
|
||||
// CC_WEB_PASSWORD=... node scripts/migrate-session-history.js <会话 ID> [--apply]
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const sessionId = process.argv.slice(2).find((arg) => !arg.startsWith('--'));
|
||||
if (!sessionId || !/^[0-9a-f-]{36}$/i.test(sessionId)) {
|
||||
console.error('用法:node scripts/migrate-session-history.js <会话 ID> [--apply]');
|
||||
process.exit(1);
|
||||
}
|
||||
const serverUrl = process.env.CC_WEB_SERVER_URL || `ws://127.0.0.1:${process.env.PORT || '8002'}/ws`;
|
||||
const ws = new WebSocket(serverUrl);
|
||||
const timer = setTimeout(() => finish({ ok: false, code: 'timeout' }), 15000);
|
||||
let finished = false;
|
||||
function finish(result) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(timer);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
process.exitCode = result.ok ? 0 : 1;
|
||||
ws.terminate();
|
||||
}
|
||||
ws.on('open', () => ws.send(JSON.stringify({ type: 'auth', password: process.env.CC_WEB_PASSWORD || '' })));
|
||||
ws.on('error', (error) => finish({ ok: false, code: 'connection_failed', message: error.message }));
|
||||
ws.on('close', () => { if (!finished) finish({ ok: false, code: 'connection_closed' }); });
|
||||
ws.on('message', (raw) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(String(raw)); } catch { return; }
|
||||
if (msg.type === 'auth_result') {
|
||||
if (!msg.success) return finish({ ok: false, code: 'authentication_failed' });
|
||||
ws.send(JSON.stringify({ type: 'migrate_session_history', sessionId, apply: process.argv.includes('--apply') }));
|
||||
} else if (msg.type === 'migrate_session_history_result' || msg.type === 'error') {
|
||||
finish(msg);
|
||||
}
|
||||
});
|
||||
@@ -2818,6 +2818,7 @@ function assertSessionProjectSnapshotContract() {
|
||||
return new Date(b.updated || 0) - new Date(a.updated || 0);
|
||||
}
|
||||
${normalizeGiteaSourceSource}
|
||||
${['stableMessageHash', 'getStableMessageKey', 'mergeIndexedSessionMessages'].map((name) => extractFunctionSource(frontendSource, name)).join('\n')}
|
||||
${mergeSource}
|
||||
return {
|
||||
mergeSessionListSnapshot,
|
||||
@@ -3799,6 +3800,9 @@ function assertSessionRenderEpochRaceContract() {
|
||||
function scrollToBottom() {}
|
||||
function updateScrollbar() {}
|
||||
function buildMsgElement(message) { return { id: message.id }; }
|
||||
function getStableMessageKey(message) { return message.id; }
|
||||
function findRenderedSessionMessageByKey(key) { return messagesDiv.nodes.find((node) => node.id === key); }
|
||||
function prependHistoryMessages(messages) { messagesDiv.insertBefore({ __fragment: true, nodes: messages.map(buildMsgElement) }); }
|
||||
${beginSessionSwitchSource}
|
||||
${renderMessagesSource}
|
||||
return {
|
||||
@@ -3929,6 +3933,7 @@ function assertSessionRequestIdRaceContract() {
|
||||
function bufferSessionSnapshot() {}
|
||||
function scheduleAdvancedSearchJump() {}
|
||||
function finalizeLoadedSession(sessionId, requestId) { finalized.push({ sessionId, requestId }); }
|
||||
${['stableMessageHash', 'getStableMessageKey', 'mergeIndexedSessionMessages'].map((name) => extractFunctionSource(frontendSource, name)).join('\n')}
|
||||
${historyMergeSource}
|
||||
function handleHistoryMessage(msg) {
|
||||
switch (msg.type) {
|
||||
@@ -4174,6 +4179,7 @@ function assertRecoverCurrentHistoryMergeContract() {
|
||||
function scheduleAdvancedSearchJump() {}
|
||||
function cacheSessionSnapshot(snapshot) { cached.push(JSON.parse(JSON.stringify(snapshot))); }
|
||||
function finishSessionSwitch(sessionId, requestId) { finished.push({ sessionId, requestId }); }
|
||||
${['stableMessageHash', 'getStableMessageKey', 'mergeIndexedSessionMessages'].map((name) => extractFunctionSource(frontendSource, name)).join('\n')}
|
||||
${historyMergeSource}
|
||||
${finalizeLoadedSessionSource}
|
||||
function handleHistoryMessage(msg) {
|
||||
@@ -4273,6 +4279,7 @@ function assertPartialSessionSnapshotCacheContract() {
|
||||
${bufferSource}
|
||||
${getBufferSource}
|
||||
${cacheSource}
|
||||
${['stableMessageHash', 'getStableMessageKey', 'mergeIndexedSessionMessages'].map((name) => extractFunctionSource(frontendSource, name)).join('\n')}
|
||||
${mergeSource}
|
||||
${dispositionSource}
|
||||
return {
|
||||
@@ -4562,6 +4569,11 @@ function assertServerSessionHistoryRequestIdContract() {
|
||||
}
|
||||
|
||||
function assertHistoryRecallContract() {
|
||||
for (const script of ['session-history-unit.js', 'codex-rollouts-unit.js', 'history-frontend-unit.js']) {
|
||||
const result = spawnSync(process.execPath, [path.join(REPO_DIR, 'scripts', script)], { encoding: 'utf8', timeout: 60000 });
|
||||
assert(result.status === 0, `${script}: ${result.stderr || result.stdout || result.error}`);
|
||||
}
|
||||
|
||||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||||
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
|
||||
@@ -4576,16 +4588,11 @@ function assertHistoryRecallContract() {
|
||||
assert(frontendSource.includes('function requestOlderHistory'), 'Frontend should request older history on demand');
|
||||
assert(frontendSource.includes("type: 'load_history_page'"), 'Frontend should use the paged history WebSocket request');
|
||||
assert(frontendSource.includes('mergeHistoryChunkIntoCachedSnapshot'), 'Frontend should merge history pages into the session cache');
|
||||
assert(
|
||||
/const existingIndexes = new Set\([\s\S]*?data-message-index/.test(frontendSource),
|
||||
'Frontend should deduplicate history messages by stable message index'
|
||||
);
|
||||
assert(frontendSource.includes('function getStableMessageKey'), 'Frontend should use a stable message key for history reconciliation');
|
||||
assert(frontendSource.includes('data-message-key'), 'Message DOM nodes should retain stable message keys');
|
||||
assert(
|
||||
(frontendSource.match(/indexed\.set\(keyed\.get\(key\), message\)/g) || []).length >= 2,
|
||||
'History snapshot merges should update an existing stable ID in place'
|
||||
);
|
||||
assert(!extractFunctionSource(frontendSource, 'reconcileRenderedSessionMessages').includes('if (existing.has(messageIndex)) return'),
|
||||
'Different IDs must not be dropped just because their indexes collide');
|
||||
assert(frontendSource.includes('function mergeIndexedSessionMessages'), 'History pages should share ID-based merging');
|
||||
assert(serverSource.includes('function mergeNativeHistoryWithSnapshot'), 'Server should merge native history into the snapshot prefix');
|
||||
assert(serverSource.includes('historySnapshotBaseIndex'), 'Persisted snapshots should record their logical history base index');
|
||||
assert(frontendSource.includes('currentOutlineHistoryState'),
|
||||
@@ -4606,8 +4613,8 @@ function assertHistoryRecallContract() {
|
||||
/messagesDiv\.innerHTML = '';\s*if \(historyLoadMore\) messagesDiv\.appendChild\(historyLoadMore\)/.test(frontendSource),
|
||||
'Message rerenders should preserve the in-content history control');
|
||||
assert(
|
||||
/messagesDiv\.insertBefore\(frag, historyLoadMore\?\.nextSibling \|\| null\)/.test(frontendSource),
|
||||
'Older message batches should be inserted below the in-content history control');
|
||||
extractFunctionSource(frontendSource, 'prependHistoryMessages').includes('reconcileRenderedSessionMessages'),
|
||||
'Older pages should use stable-ID reconciliation while preserving the in-content history control');
|
||||
const historyHandlerStart = frontendSource.indexOf("case 'session_history_chunk':");
|
||||
const historyHandlerEnd = frontendSource.indexOf("case 'session_message':", historyHandlerStart);
|
||||
const historyHandlerSource = historyHandlerStart >= 0 && historyHandlerEnd > historyHandlerStart
|
||||
@@ -4717,10 +4724,7 @@ function assertHistoryRecallContract() {
|
||||
assert(implicitAssistants.length === 2 && implicitAssistants[0].nativeTurnKey !== implicitAssistants[1].nativeTurnKey,
|
||||
'Rollout parser should assign distinct stable keys to adjacent unmarked turns');
|
||||
|
||||
assert(
|
||||
/existingElement\.dataset\.messageFingerprint[\s\S]*?existingElement\.replaceWith\(replacement\)/.test(frontendSource),
|
||||
'Realtime session messages with an existing stable key should replace changed content instead of being dropped'
|
||||
);
|
||||
assert(frontendSource.includes('getMessageRenderFingerprint'), 'History rendering should fingerprint tool results and source metadata too');
|
||||
|
||||
const historyMergeStart = serverSource.indexOf('function stableMessageHash');
|
||||
const historyMergeEnd = serverSource.indexOf('function sessionHistoryCacheKey', historyMergeStart);
|
||||
@@ -4832,7 +4836,7 @@ function assertCodexAppGoalLifecycleContract() {
|
||||
|
||||
assert(
|
||||
doneBlock.includes('snapshot.isRunning = msg.goalActive === true')
|
||||
&& doneBlock.includes('finishGenerating(msg.sessionId, { keepRunning: msg.goalActive === true })'),
|
||||
&& /finishGenerating\(msg.sessionId,\s*\{\s*keepRunning: msg.goalActive === true/.test(doneBlock),
|
||||
'Frontend done handling should keep current and background Goal sessions running between turns'
|
||||
);
|
||||
assert(
|
||||
@@ -4859,7 +4863,7 @@ function assertCodexAppGoalLifecycleContract() {
|
||||
);
|
||||
assert(
|
||||
completeBlock.includes('const goalActive = isCodexAppGoalActive(sessionId)')
|
||||
&& completeBlock.includes("{ type: 'done', sessionId, costUsd: null, goalActive }")
|
||||
&& completeBlock.includes("type: 'done', sessionId, costUsd: null, goalActive")
|
||||
&& completeBlock.includes('if (goalActive)')
|
||||
&& completeBlock.includes('broadcastSessionList()'),
|
||||
'Server turn completion should report active Goal state without sending final background completion'
|
||||
|
||||
167
scripts/session-history-unit.js
Normal file
167
scripts/session-history-unit.js
Normal file
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'server.js'), 'utf8');
|
||||
function extract(name) {
|
||||
const start = source.indexOf(`function ${name}(`);
|
||||
assert(start >= 0, `缺少 ${name}`);
|
||||
return source.slice(start, source.indexOf('\nfunction ', start + 1));
|
||||
}
|
||||
const pureSource = source.slice(source.indexOf('function stableMessageHash('), source.indexOf('function sessionHistoryCacheKey('));
|
||||
const api = new Function('crypto', `${pureSource}; return { ensureStableMessageId, mergeNativeHistoryWithSnapshot, mergeHistorySegments };`)(crypto);
|
||||
let checks = 0;
|
||||
function check(name, fn) { fn(); checks++; console.log(`通过:${name}`); }
|
||||
const question = '没参考其他的页面么,WMSA51 是你这么写的么?';
|
||||
const notice = { role: 'system', ccwebPersistenceNotice: true, content: '旧截断提示' };
|
||||
const native = Array.from({ length: 483 }, (_, index) => ({ id: `native-${index}`, role: 'assistant', content: `原生历史 ${index}` }));
|
||||
const snapshot = Array.from({ length: 68 }, (_, index) => ({ id: `ccweb-${index}`, role: index % 2 ? 'assistant' : 'user', content: `快照历史 ${index}` }));
|
||||
snapshot[0] = { id: 'ccweb-turn', role: 'assistant', codexAppThreadId: 'thread', codexAppTurnId: 'turn-boundary', content: '权威快照首条' };
|
||||
native[415] = { id: 'native-turn', role: 'assistant', nativeThreadId: 'thread', turnId: 'turn-boundary', content: '原生首条片段' };
|
||||
snapshot[64] = { id: 'client-final', role: 'user', content: question };
|
||||
snapshot[65] = { id: 'architecture', role: 'assistant', content: '先按项目规范核对现有页面、任务票和负责会话。' };
|
||||
snapshot[66] = { id: 'reply', role: 'assistant', content: '子对话回传', replyToRequestId: 'request', crossConversation: { sourceTitle: '原实现负责人' } };
|
||||
snapshot[67] = { id: 'current-user', role: 'user', content: '继续返工' };
|
||||
const session = { codexAppThreadId: 'thread', messages: [notice, ...snapshot], historySnapshotBaseIndex: 900 };
|
||||
check('68 条快照与 483 条原生历史合并后保留权威尾部', () => {
|
||||
const result = api.mergeNativeHistoryWithSnapshot(session, snapshot, native);
|
||||
assert.equal(result.confirmed, true);
|
||||
assert.equal(result.nativePrefixCount, 415);
|
||||
assert.deepEqual(result.messages.slice(-68).map(m => m.id), snapshot.map(m => m.id));
|
||||
const index = result.messages.findIndex(m => m.content === question);
|
||||
assert.equal(result.messages[index + 1].id, 'architecture');
|
||||
assert.equal(result.messages[index + 2].crossConversation.sourceTitle, '原实现负责人');
|
||||
assert.equal(result.messages.at(-1).id, 'current-user');
|
||||
assert(!result.messages.some(m => m.id === 'native-482'));
|
||||
});
|
||||
check('错误数组基线和空快照都不能导入未经确认的 native', () => {
|
||||
assert.equal(api.mergeNativeHistoryWithSnapshot({ historySnapshotBaseIndex: 10 }, [{ role: 'user', content: '不匹配' }], native).confirmed, false);
|
||||
assert.equal(api.mergeNativeHistoryWithSnapshot({}, [], native).confirmed, false);
|
||||
});
|
||||
check('不同来源 ID 可以按完整时间戳和内容摘要确认边界', () => {
|
||||
const boundary = { id: 'local', role: 'user', content: '唯一问题', timestamp: '2026-09-20T10:00:00.000Z' };
|
||||
const nativeBoundary = { ...boundary, id: 'native', timestamp: '2026-09-20T18:00:00+08:00' };
|
||||
const result = api.mergeNativeHistoryWithSnapshot({}, [boundary, snapshot[65]], [native[0], nativeBoundary, native[2]]);
|
||||
assert.equal(result.confirmed, true);
|
||||
assert.deepEqual(result.messages.map(m => m.id), ['native-0', 'local', 'architecture']);
|
||||
assert.equal(api.mergeNativeHistoryWithSnapshot({}, [boundary], [nativeBoundary, nativeBoundary]).confirmed, false);
|
||||
assert.equal(api.mergeNativeHistoryWithSnapshot({}, [boundary], [{ ...nativeBoundary, timestamp: '' }]).confirmed, false);
|
||||
});
|
||||
check('不同线程和分段 turn 不能错误匹配,已有 ID 不被改写', () => {
|
||||
assert.equal(api.mergeNativeHistoryWithSnapshot({}, [snapshot[0]], [{ ...native[415], nativeThreadId: 'other' }]).confirmed, false);
|
||||
assert.equal(api.mergeNativeHistoryWithSnapshot({}, [snapshot[0]], [{ ...native[415], nativeTurnSegment: 'initial' }]).confirmed, false);
|
||||
const normalized = api.ensureStableMessageId(snapshot[0]);
|
||||
assert.equal(normalized.id, snapshot[0].id);
|
||||
assert.equal(normalized.turnId, 'turn-boundary');
|
||||
assert.equal(api.ensureStableMessageId({ role: 'assistant', turnId: 't' }).id, 'history:assistant::t');
|
||||
});
|
||||
check('重复回传按请求标识去重且以快照内容为准', () => {
|
||||
const merged = api.mergeHistorySegments([{ ...snapshot[66], id: 'native-reply', content: '旧回传' }], [snapshot[66], snapshot[66]]);
|
||||
assert.equal(merged.length, 1);
|
||||
assert.equal(merged[0].content, '子对话回传');
|
||||
});
|
||||
|
||||
const persistApi = new Function('crypto', `
|
||||
${pureSource}
|
||||
${extract('isPersistenceNoticeMessage')}
|
||||
const SESSION_PERSIST_MAX_MESSAGES = 45;
|
||||
function sanitizeMessageForPersist(message) { return JSON.parse(JSON.stringify(message)); }
|
||||
function sanitizePersistValue(value) { return value; }
|
||||
function normalizeSession(value) { return value; }
|
||||
${extract('sanitizeMessagesForPersist')}
|
||||
${extract('sanitizeSessionForPersist')}
|
||||
return { sanitizeSessionForPersist };
|
||||
`)(crypto);
|
||||
check('截断提示保存结构化字段,旧基线不覆盖新基线', () => {
|
||||
const persisted = persistApi.sanitizeSessionForPersist(session, { maxMessages: 45 });
|
||||
const metadata = persisted.messages[0];
|
||||
assert.equal(metadata.ccwebPersistenceNotice, true);
|
||||
assert.equal(metadata.snapshotMessageCount, 45);
|
||||
assert.equal(metadata.snapshotFirstMessageId, snapshot[23].id);
|
||||
assert.equal(metadata.snapshotLastMessageId, snapshot[67].id);
|
||||
assert.equal(metadata.nativeThreadId, 'thread');
|
||||
assert(Number.isFinite(Date.parse(metadata.truncatedAt)));
|
||||
assert.equal(persisted.historySnapshotBaseIndex, 923);
|
||||
assert.equal(persisted.historySnapshotCount, 45);
|
||||
const again = persistApi.sanitizeSessionForPersist(persisted, { maxMessages: 45 });
|
||||
assert.deepEqual(again, persisted);
|
||||
assert.equal(api.mergeNativeHistoryWithSnapshot({ ...session, messages: [{ ...notice, snapshotFirstMessageId: 'bad' }, ...snapshot] }, snapshot, native).confirmed, false);
|
||||
});
|
||||
check('结构化提示不占保留消息名额,新消息后仍刷新边界', () => {
|
||||
const persisted = persistApi.sanitizeSessionForPersist({ ...session, messages: [notice, ...snapshot.slice(-45)] }, { maxMessages: 45 });
|
||||
assert.equal(persisted.messages.length, 46);
|
||||
persisted.messages.push({ id: 'new-user', role: 'user', content: '运行中新消息' });
|
||||
const next = persistApi.sanitizeSessionForPersist(persisted, { maxMessages: 45 });
|
||||
assert.equal(next.messages[0].snapshotMessageCount, 45);
|
||||
assert.equal(next.messages[0].snapshotLastMessageId, 'new-user');
|
||||
assert.equal(next.historySnapshotBaseIndex, persisted.historySnapshotBaseIndex + 1);
|
||||
});
|
||||
check('三个历史出口使用同一 resolver,异常边界保持当前快照', () => {
|
||||
const resolver = new Function('crypto', `
|
||||
${pureSource}
|
||||
${extract('isPersistenceNoticeMessage')}
|
||||
function loadNativeSessionHistory() { return { messages: ${JSON.stringify(native)} }; }
|
||||
function plog() {}
|
||||
${extract('resolveSessionHistory')}
|
||||
return resolveSessionHistory;
|
||||
`)(crypto);
|
||||
assert.equal(resolver(session).source, 'merged');
|
||||
const failed = resolver({ messages: [notice, { id: 'new', role: 'user', content: question }], historySnapshotBaseIndex: 45 });
|
||||
assert.equal(failed.available, false);
|
||||
assert.equal(failed.messages[0].content, question);
|
||||
for (const fn of ['buildSessionInfoPayload', 'handleLoadHistoryPage', 'handleResumeSession']) assert(extract(fn).includes('resolveSessionHistory'));
|
||||
});
|
||||
|
||||
check('迁移只在 idle 执行并精确保留备份,写后可以重新加载', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-history-unit-'));
|
||||
try {
|
||||
const id = '35394008-4f0c-43c0-a481-b622d9e6b884';
|
||||
const filePath = path.join(dir, `${id}.json`);
|
||||
const original = JSON.stringify({ ...session, id });
|
||||
fs.writeFileSync(filePath, original);
|
||||
const testApi = new Function('fs', 'path', 'crypto', 'SESSIONS_DIR', 'nativeMessages', `
|
||||
${pureSource}
|
||||
${extract('isPersistenceNoticeMessage')}
|
||||
let running = true;
|
||||
let response;
|
||||
const SESSION_LOAD_MAX_BYTES = 10000000;
|
||||
const sessionHistoryCache = new Map();
|
||||
const sessionSearchIndex = { scheduleUpsert() {} };
|
||||
function scheduleUsageStatisticsUpsert() {}
|
||||
function sanitizeId(id) { return id; }
|
||||
function sessionPath(id) { return path.join(SESSIONS_DIR, id + '.json'); }
|
||||
function isSessionRunning() { return running; }
|
||||
function normalizeSession(value) { return value; }
|
||||
function loadNativeSessionHistory() { return { messages: nativeMessages }; }
|
||||
function textByteLength(value) { return Buffer.byteLength(value); }
|
||||
function loadSession(id) { return JSON.parse(fs.readFileSync(sessionPath(id), 'utf8')); }
|
||||
function sessionHistoryCacheKey(session) { return session.id; }
|
||||
function wsSend(ws, result) { response = result; }
|
||||
function attachClientRequestId(payload) { return payload; }
|
||||
function findViewingSessionWs() { return null; }
|
||||
function plog() {}
|
||||
${extract('writeFileAtomicSync')}
|
||||
${extract('handleMigrateSessionHistory')}
|
||||
return { run(id, apply) { handleMigrateSessionHistory(null, { sessionId: id, apply }); return response; }, idle() { running = false; } };
|
||||
`)(fs, path, crypto, dir, native);
|
||||
assert.equal(testApi.run(id, true).code, 'session_running');
|
||||
assert.equal(fs.readFileSync(filePath, 'utf8'), original);
|
||||
testApi.idle();
|
||||
assert.equal(testApi.run(id, false).preview, true);
|
||||
assert.equal(fs.readFileSync(filePath, 'utf8'), original);
|
||||
const result = testApi.run(id, true);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(fs.readFileSync(result.backupPath, 'utf8'), original);
|
||||
const restored = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
assert.equal(restored.messages.length, 483);
|
||||
assert(!restored.messages.some(m => m.ccwebPersistenceNotice));
|
||||
assert.deepEqual(restored.messages.slice(-68).map(m => m.id), snapshot.map(m => m.id));
|
||||
assert.equal(testApi.run(id, true).changed, false);
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
console.log(`历史服务端回归通过:${checks} 项`);
|
||||
Reference in New Issue
Block a user