- mergeNativeHistoryWithSnapshot 的边界定位改为 locateNativeBoundaryIndex, 允许跳过快照头部只在 cc-web 存在的气泡(ccwebDisplayOnly 回传显示、 无 native 身份的本地用户回显),遇到无法对齐的普通消息仍然放弃确认。 - native 前缀不再按 turn 别名折叠,改为 mergeNativePrefixWithSnapshot 按自身 ID 去重,同 ID 片段用 combineNativeFragments 合并正文与工具调用,避免前端按稳定 ID 去重丢正文。 - session-history-unit 新增 3 条断言,历史服务端回归 12 项通过。 - 重新构建 dist-exe/cc-web-bun-linux-x64-baseline.tar.gz(bun 1.4.2 baseline)。
202 lines
13 KiB
JavaScript
202 lines
13 KiB
JavaScript
#!/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('快照头部的跨对话回传气泡不阻断 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() {}
|
||
${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} 项`);
|