修复历史边界与流式消息去重并更新发布包

This commit is contained in:
shiyue
2026-09-21 00:38:51 +08:00
parent db04e088bc
commit f8f5ef3a0b
14 changed files with 1733 additions and 423 deletions

View File

@@ -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/\`:前端静态资源

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

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

View 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、分页、重连、流式身份绑定与完成去重');

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

View File

@@ -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'

View 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} 项`);