Files
cc-web/scripts/session-history-unit.js
shiyue 5e9bda7984 修复历史合并跳过快照专有气泡并按 ID 合并片段
- 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)。
2026-09-21 10:58:59 +08:00

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