Files
cc-web/scripts/session-history-unit.js
shiyue 69b6db0f5b 修复完整会话历史与长气泡交互并更新 CentOS 7 发布包
提交当前工作区全部源码、回归脚本、任务记录及重新构建的 Bun baseline 发布包。发布单文件 MCP 初始化及前端资源一致性验证通过。
2026-10-10 23:40:16 +08:00

207 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

#!/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 { createServerHarness } = require('./session-history-store-unit');
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('快照头部的跨对话回传气泡不阻断 native 边界', () => {
const head = {
id: 'reply:head', role: 'assistant', ccwebDisplayOnly: true, timestamp: '2026-09-20T09:00:00.000Z',
content: '线程「子对话」已返回消息:先按规范核对。', crossConversation: { reply: true, replyToRequestId: 'head' },
};
const result = api.mergeNativeHistoryWithSnapshot({}, [head, snapshot[0], snapshot[65]], native);
assert.equal(result.confirmed, true);
assert.equal(result.nativePrefixCount, 415);
assert.deepEqual(result.messages.slice(-3).map(m => m.id), ['reply:head', 'ccweb-turn', 'architecture']);
assert(!result.messages.some(m => m.id === 'native-482'));
});
check('本地用户回显可以跳过,无法对齐的普通消息仍然放弃确认', () => {
const echo = { id: 'user-echo', role: 'user', content: '继续吧', timestamp: '2026-09-20T09:30:00.000Z' };
const skipped = api.mergeNativeHistoryWithSnapshot({}, [echo, snapshot[0]], native);
assert.equal(skipped.confirmed, true);
assert.equal(skipped.nativePrefixCount, 415);
assert.deepEqual(skipped.messages.slice(-2).map(m => m.id), ['user-echo', 'ccweb-turn']);
const orphan = { id: 'orphan', role: 'assistant', content: '没有 native 对应的助手消息' };
assert.equal(api.mergeNativeHistoryWithSnapshot({}, [orphan, snapshot[0]], native).confirmed, false);
});
check('native 前缀同 ID 片段合并、同轮不同片段各自保留', () => {
const fragments = [
{ id: 'frag-1', role: 'assistant', content: '', toolCalls: [{ name: 'grep', id: 'tool-1' }], nativeThreadId: 'thread', turnId: 'turn-frag' },
{ id: 'frag-1', role: 'assistant', content: '同一轮正文', toolCalls: [{ name: 'run', id: 'tool-2' }], nativeThreadId: 'thread', turnId: 'turn-frag' },
{ id: 'frag-2', role: 'assistant', content: '同轮另一段正文', nativeThreadId: 'thread', turnId: 'turn-frag' },
{ id: 'native-turn', role: 'assistant', nativeThreadId: 'thread', turnId: 'turn-boundary', content: '原生首条片段' },
];
const result = api.mergeNativeHistoryWithSnapshot({}, [snapshot[0], snapshot[65]], fragments);
assert.equal(result.confirmed, true);
assert.equal(result.nativePrefixCount, 3);
assert.deepEqual(result.messages.map(m => m.id), ['frag-1', 'frag-2', 'ccweb-turn', 'architecture']);
assert.equal(result.messages[0].content, '同一轮正文');
assert.deepEqual(result.messages[0].toolCalls.map(t => t.id), ['tool-1', 'tool-2']);
});
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() {}
const sessionHistoryStore = { state() { return null; } };
${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 persistenceAdapter = createServerHarness(dir);
const testApi = new Function('fs', 'path', 'crypto', 'SESSIONS_DIR', 'nativeMessages', 'persistenceAdapter', `
${pureSource}
${extract('isPersistenceNoticeMessage')}
let running = true;
let response;
const SESSION_LOAD_MAX_BYTES = 10000000;
const sessionHistoryCache = new Map();
const sessionSearchIndex = { scheduleUpsert() {} };
const sessionHistoryStore = persistenceAdapter.sessionHistoryStore;
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 persistenceAdapter.loadSession(id); }
function saveSession(session) { return persistenceAdapter.saveSession(session); }
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, persistenceAdapter);
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 = createServerHarness(dir).loadSession(id);
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} 项`);