973 lines
52 KiB
JavaScript
973 lines
52 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 { createCodexRolloutStore } = require('../lib/codex-rollouts');
|
||
const serverSource = fs.readFileSync(path.join(__dirname, '..', 'server.js'), 'utf8');
|
||
const storePath = path.join(__dirname, '..', 'lib', 'session-history-store.js');
|
||
const sessionHistoryStoreModule = require(storePath);
|
||
const { inspect } = require('node:util');
|
||
|
||
function extract(name, optional = false) {
|
||
const start = serverSource.indexOf(`function ${name}(`);
|
||
if (start < 0 && optional) return '';
|
||
assert(start >= 0, `缺少生产函数 ${name}`);
|
||
const end = serverSource.indexOf('\nfunction ', start + 1);
|
||
return serverSource.slice(start, end < 0 ? undefined : end);
|
||
}
|
||
|
||
function createServerHarness(sessionsDir, options = {}) {
|
||
const logs = [];
|
||
const filesystem = options.fs || fs;
|
||
const sessionHistoryStore = sessionHistoryStoreModule.createSessionHistoryStore({ sessionsDir, fs: filesystem });
|
||
const rollouts = createCodexRolloutStore({
|
||
codexSessionsDir: options.nativeDir || path.join(sessionsDir, '_native'),
|
||
sessionsDir,
|
||
normalizeSession: value => value,
|
||
sanitizeToolInput: value => value,
|
||
});
|
||
const pureSource = serverSource.slice(serverSource.indexOf('function stableMessageHash('), serverSource.indexOf('function sessionHistoryCacheKey('));
|
||
const names = [
|
||
'isPersistenceNoticeMessage', 'truncateTextValue', 'sanitizePersistValue',
|
||
'sanitizeToolCallsForPersist', 'sanitizeMessageForPersist', 'sanitizeMessagesForPersist',
|
||
'sanitizeSessionForPersist', 'buildSessionJsonForPersist', 'writeFileAtomicSync',
|
||
'safeReadSessionJson', 'loadSession', 'preserveNewerTaskTrackingForSessionSave',
|
||
'findSessionMessage',
|
||
'saveSession', 'sessionHistoryCacheKey', 'cacheSessionHistory', 'loadNativeSessionHistory',
|
||
'resolveSessionHistory',
|
||
'listSessionFiles', 'refreshSessionSnapshotCache', 'prepareNativeHistoryImport',
|
||
'handleSlashCommand', 'handleDeleteSession', 'findCcwebPromptMessage', 'removeCcwebPromptMessage',
|
||
'rollbackPersistedCodexAppSteerMessage', 'handleLoadHistoryPage',
|
||
'buildSessionInfoPayload', 'handleResumeSession',
|
||
];
|
||
const api = new Function('fs', 'path', 'crypto', 'SESSIONS_DIR', 'sessionHistoryStore', 'rollouts', 'options', 'logs', `
|
||
const SESSION_MAX_JSON_BYTES = options.snapshotMaxBytes || 512 * 1024;
|
||
const SESSION_LOAD_MAX_BYTES = 32 * 1024 * 1024;
|
||
const SESSION_PERSIST_MAX_MESSAGES = options.snapshotMaxMessages || 180;
|
||
const SESSION_MESSAGE_CONTENT_MAX_CHARS = 96 * 1024;
|
||
const SESSION_TOOL_INPUT_MAX_CHARS = 16 * 1024;
|
||
const SESSION_TOOL_RESULT_MAX_CHARS = 32 * 1024;
|
||
const SESSION_MAX_TOOL_CALLS_PER_MESSAGE = 80;
|
||
const PERSIST_TRUNCATED_TEXT = '[cc-web: 内容过大,已截断以保护服务稳定性]';
|
||
const SESSION_HISTORY_CACHE_LIMIT = 12;
|
||
const sessionHistoryCache = new Map();
|
||
const sessionSearchIndex = { scheduleUpsert() {}, remove() {} };
|
||
const COMPOSER_COMMANDS = [{ name: '/clear' }];
|
||
const activeProcesses = new Map();
|
||
const activeCodexAppTurns = new Map();
|
||
const retainedCodexAppTurns = new Map();
|
||
const activeCodexAppGoalCommands = new Map();
|
||
const activeCodexAppCompactions = new Map();
|
||
const pendingCodexCapacityRetries = new Map();
|
||
const wsSessionMap = new Map();
|
||
const codexAppGoalStates = new Map();
|
||
const pendingSlashCommands = new Map();
|
||
const pendingCompactRetries = new Map();
|
||
const ccwebMcpChildThreads = new Map();
|
||
const codexAppClient = null;
|
||
const responses = [];
|
||
const INITIAL_HISTORY_COUNT = 12;
|
||
const HISTORY_CHUNK_SIZE = 24;
|
||
function scheduleUsageStatisticsUpsert() {}
|
||
function syncCodexAppGoalStateFromSession() {}
|
||
function updateSessionRuntimeThreadIndex() {}
|
||
function removeSessionRuntimeThreadIndex() {}
|
||
function removeUsageStatisticsSession() {}
|
||
function cancelCodexCapacityRetry() {}
|
||
function cancelCodexAppCompaction() {}
|
||
function deleteCrossConversationRepliesForSession() {}
|
||
function clearRuntimeSessionId(session) { session.claudeSessionId = session.codexThreadId = session.codexAppThreadId = null; }
|
||
function cleanRunDir() {}
|
||
function collectSessionAttachmentIds() { return []; }
|
||
function sendSessionList() {}
|
||
function wsSend(ws, payload) { responses.push(payload); }
|
||
function attachClientRequestId(payload, source) { return { ...payload, requestId: source?.requestId }; }
|
||
function taskTrackingSnapshotForSession() { return null; }
|
||
function crossConversationWaitState() { return {}; }
|
||
function detachWsFromActiveRuntimes() {}
|
||
function isSessionRunning() { return false; }
|
||
function attachActiveRuntimeToWs() {}
|
||
function publicTitleMetadata() { return {}; }
|
||
function sessionModelLabel(session) { return session.model; }
|
||
function isCodexAppSession(session) { return session.agent === 'codexapp'; }
|
||
function normalizeAgent(agent) { return agent; }
|
||
function sanitizeMessagesForTransport(messages) { return messages; }
|
||
function plog(level, event, data) { logs.push({ level, event, ...data }); }
|
||
function sanitizeId(id) { return /^[\\w-]+$/.test(id) ? id : ''; }
|
||
function sessionPath(id) { return path.join(SESSIONS_DIR, sanitizeId(id) + '.json'); }
|
||
function textByteLength(value) { return Buffer.byteLength(value); }
|
||
function normalizeMessageAttachments(value) { return value; }
|
||
function normalizeSession(value) {
|
||
if (value && Array.isArray(value.messages)) value.messages = value.messages.map(ensureStableMessageId);
|
||
return value;
|
||
}
|
||
function isCodexLikeSession(session) { return session.agent === 'codex' || session.agent === 'codexapp'; }
|
||
function getSessionAgent(session) { return session.agent || 'claude'; }
|
||
function getCodexRolloutFiles() { return options.nativeFiles || []; }
|
||
function resolveClaudeSessionLocalMeta() { return null; }
|
||
const parseCodexRolloutFile = rollouts.parseCodexRolloutFile;
|
||
${pureSource}
|
||
${names.map(name => extract(name)).join('\n')}
|
||
return {
|
||
loadSession: (id, loadOptions = {}) => {
|
||
const explicitWindow = ['metadata', 'count', 'window', 'messageId', 'promptId'].some(key => Object.prototype.hasOwnProperty.call(loadOptions, key));
|
||
const full = Object.prototype.hasOwnProperty.call(loadOptions, 'full')
|
||
? loadOptions.full : options.fullHistory !== false && !explicitWindow;
|
||
return loadSession(id, { ...loadOptions, full });
|
||
},
|
||
saveSession, resolveSessionHistory, listSessionFiles, refreshSessionSnapshotCache, findSessionMessage,
|
||
prepareNativeHistoryImport, handleSlashCommand, handleDeleteSession, removeCcwebPromptMessage,
|
||
rollbackPersistedCodexAppSteerMessage, handleLoadHistoryPage, responses, logs, sessionHistoryStore,
|
||
buildSessionInfoPayload, handleResumeSession,
|
||
};
|
||
`)(filesystem, path, crypto, sessionsDir, sessionHistoryStore, rollouts, options, logs);
|
||
return api;
|
||
}
|
||
|
||
let checks = 0;
|
||
const failures = [];
|
||
function check(name, callback) {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-full-history-'));
|
||
try {
|
||
callback(dir);
|
||
checks += 1;
|
||
console.log(`通过:${name}`);
|
||
} catch (error) {
|
||
failures.push(`${name}:${error.message}`);
|
||
console.error(`失败:${name}:${inspect(error, { depth: 1, maxArrayLength: 5, maxStringLength: 500 })}`);
|
||
} finally {
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
}
|
||
|
||
function messages(count) {
|
||
return Array.from({ length: count }, (_, index) => ({
|
||
id: `message-${index}`, role: index % 2 ? 'assistant' : 'user', content: `完整历史 ${index}`,
|
||
timestamp: new Date(1800000000000 + index * 1000).toISOString(),
|
||
}));
|
||
}
|
||
function fixture(id = 'session-a', count = 650) {
|
||
return { id, agent: 'codexapp', codexAppThreadId: 'thread-a', title: '完整会话', messages: messages(count) };
|
||
}
|
||
|
||
function runTests() {
|
||
check('650 条历史在重复保存和存储实例重开后原样读取', dir => {
|
||
let api = createServerHarness(dir);
|
||
const original = fixture();
|
||
assert.equal(api.saveSession(original), true);
|
||
const cached = JSON.parse(fs.readFileSync(path.join(dir, 'session-a.json'), 'utf8'));
|
||
assert(cached.messages.length <= 181, '快照仍应有界');
|
||
assert.equal(api.saveSession(original), true);
|
||
api = createServerHarness(dir);
|
||
const reloaded = api.loadSession(original.id);
|
||
assert.deepEqual(reloaded.messages, original.messages);
|
||
assert.equal(api.resolveSessionHistory(reloaded).available, true);
|
||
});
|
||
|
||
check('可调 500 条快照上限不限制 650 条完整历史', dir => {
|
||
const original = fixture();
|
||
const api = createServerHarness(dir, { snapshotMaxMessages: 500 });
|
||
assert.equal(api.saveSession(original), true);
|
||
assert.deepEqual(createServerHarness(dir).loadSession(original.id).messages, original.messages);
|
||
});
|
||
|
||
check('长正文、工具参数、工具结果、数组和深层元数据完整持久化', dir => {
|
||
const original = fixture('long-content', 1);
|
||
original.messages[0].content = '完整中文'.repeat(35000);
|
||
original.messages[0].toolCalls = Array.from({ length: 125 }, (_, index) => ({
|
||
id: `tool-${index}`, name: 'run', input: '输入'.repeat(12000), result: '输出'.repeat(21000),
|
||
rows: Array.from({ length: 150 }, (_, row) => ({ row, values: [row, row + 1] })),
|
||
}));
|
||
original.metadata = { text: '元数据'.repeat(8000), values: Array.from({ length: 180 }, (_, index) => index), nested: { a: { b: { c: { d: { value: '原值' } } } } } };
|
||
const api = createServerHarness(dir);
|
||
assert.equal(api.saveSession(original), true);
|
||
const reloaded = createServerHarness(dir).loadSession(original.id);
|
||
assert.deepEqual(reloaded.messages, original.messages);
|
||
assert.deepEqual(reloaded.metadata, original.metadata);
|
||
});
|
||
|
||
check('未可信加载的短快照反复保存不能覆盖完整历史或正文', dir => {
|
||
const original = fixture();
|
||
original.messages.at(-1).content = '长正文'.repeat(50000);
|
||
const api = createServerHarness(dir);
|
||
assert.equal(api.saveSession(original), true);
|
||
const preview = JSON.parse(fs.readFileSync(path.join(dir, 'session-a.json'), 'utf8'));
|
||
assert.equal(api.saveSession(preview), false);
|
||
assert.equal(api.saveSession(preview), false);
|
||
assert.deepEqual(createServerHarness(dir).loadSession(original.id).messages, original.messages);
|
||
});
|
||
|
||
check('可信同 ID 更新、删除和 steer 前插入精确保存顺序', dir => {
|
||
const api = createServerHarness(dir);
|
||
assert.equal(api.saveSession(fixture('mutable', 8)), true);
|
||
let loaded = api.loadSession('mutable');
|
||
loaded.messages[1].content = '同 ID 最新正文';
|
||
loaded.messages[1].toolCalls = [{ id: 'same-tool', input: '新输入', result: ['新结果', '尾部'] }];
|
||
loaded.messages.splice(2, 1);
|
||
loaded.messages.splice(3, 0, { id: 'inserted-assistant', role: 'assistant', content: 'steer 前旧轮结果' });
|
||
assert.equal(api.saveSession(loaded), true);
|
||
assert.deepEqual(createServerHarness(dir).loadSession('mutable').messages, loaded.messages);
|
||
loaded = api.loadSession('mutable');
|
||
loaded.messages = [];
|
||
loaded.codexAppThreadId = null;
|
||
assert.equal(api.saveSession(loaded), true);
|
||
assert.deepEqual(createServerHarness(dir).loadSession('mutable').messages, []);
|
||
});
|
||
|
||
check('可信旧修订合并自身更新且保留后续提交的追加和元数据', dir => {
|
||
const api = createServerHarness(dir);
|
||
assert.equal(api.saveSession(fixture('versions', 8)), true);
|
||
const stale = api.loadSession('versions');
|
||
const current = api.loadSession('versions');
|
||
current.messages.push({ id: 'new-committed', role: 'user', content: '已提交追加' });
|
||
current.codexAppGoal = { objective: '后来保存的目标', status: 'in_progress' };
|
||
assert.equal(api.saveSession(current), true);
|
||
stale.messages[0].content = '旧对象修改';
|
||
assert.equal(api.saveSession(stale), true);
|
||
const restored = createServerHarness(dir).loadSession('versions');
|
||
assert.equal(restored.messages[0].content, '旧对象修改');
|
||
assert.equal(restored.messages.at(-1).id, 'new-committed');
|
||
assert.deepEqual(restored.codexAppGoal, current.codexAppGoal);
|
||
assert.deepEqual(stale.messages, restored.messages);
|
||
});
|
||
|
||
check('两个会话档案相互隔离', dir => {
|
||
const api = createServerHarness(dir);
|
||
const left = fixture('left', 220);
|
||
const right = fixture('right', 250);
|
||
right.messages[0].content = '另一会话';
|
||
assert.equal(api.saveSession(left), true);
|
||
assert.equal(api.saveSession(right), true);
|
||
assert.deepEqual(createServerHarness(dir).loadSession('left').messages, left.messages);
|
||
assert.deepEqual(createServerHarness(dir).loadSession('right').messages, right.messages);
|
||
});
|
||
|
||
check('完整归档写入失败保持上次归档和快照原文', dir => {
|
||
let failArchiveRename = false;
|
||
const filesystem = new Proxy(fs, {
|
||
get(target, key) {
|
||
if (key === 'renameSync') return (from, to) => {
|
||
if (failArchiveRename && String(to).includes(`${path.sep}_history${path.sep}`)) throw new Error('模拟归档提交失败');
|
||
return target.renameSync(from, to);
|
||
};
|
||
return target[key];
|
||
},
|
||
});
|
||
const api = createServerHarness(dir, { fs: filesystem });
|
||
assert.equal(api.saveSession(fixture('atomic', 220)), true);
|
||
const loaded = api.loadSession('atomic');
|
||
const oldMessages = JSON.parse(JSON.stringify(loaded.messages));
|
||
const oldSnapshot = fs.readFileSync(path.join(dir, 'atomic.json'), 'utf8');
|
||
loaded.messages.push({ id: 'must-not-commit', role: 'assistant', content: '未提交' });
|
||
failArchiveRename = true;
|
||
assert.equal(api.saveSession(loaded), false);
|
||
assert.equal(fs.readFileSync(path.join(dir, 'atomic.json'), 'utf8'), oldSnapshot);
|
||
assert.deepEqual(createServerHarness(dir).loadSession('atomic').messages, oldMessages);
|
||
});
|
||
|
||
check('完整归档成功而快照失败仍返回成功并恢复最新元数据', dir => {
|
||
let failSnapshotRename = false;
|
||
const filesystem = new Proxy(fs, {
|
||
get(target, key) {
|
||
if (key === 'renameSync') return (from, to) => {
|
||
if (failSnapshotRename && path.dirname(to) === dir) throw new Error('模拟快照缓存失败');
|
||
return target.renameSync(from, to);
|
||
};
|
||
return target[key];
|
||
},
|
||
});
|
||
const api = createServerHarness(dir, { fs: filesystem });
|
||
assert.equal(api.saveSession(fixture('cache-failure', 220)), true);
|
||
const loaded = api.loadSession('cache-failure');
|
||
loaded.title = '归档中的最新标题';
|
||
loaded.codexAppThreadId = 'latest-thread';
|
||
loaded.messages.push({ id: 'committed', role: 'user', content: '已提交' });
|
||
failSnapshotRename = true;
|
||
assert.equal(api.saveSession(loaded), true);
|
||
const reloaded = createServerHarness(dir).loadSession('cache-failure');
|
||
assert.deepEqual(reloaded.messages, loaded.messages);
|
||
assert.equal(reloaded.title, loaded.title);
|
||
assert.equal(reloaded.codexAppThreadId, loaded.codexAppThreadId);
|
||
});
|
||
|
||
check('首次快照失败的孤立档案重开后仍可读取并参与列表且缓存恢复有界', dir => {
|
||
const filesystem = new Proxy(fs, {
|
||
get(target, key) {
|
||
if (key === 'renameSync') return (from, to) => {
|
||
if (path.dirname(to) === dir) throw new Error('模拟首次快照缓存失败');
|
||
return target.renameSync(from, to);
|
||
};
|
||
return target[key];
|
||
},
|
||
});
|
||
const original = fixture('orphan', 650);
|
||
assert.equal(createServerHarness(dir, { fs: filesystem }).saveSession(original), true);
|
||
assert.equal(fs.existsSync(path.join(dir, 'orphan.json')), false);
|
||
const failedCacheApi = createServerHarness(dir, { fs: filesystem });
|
||
assert.deepEqual(failedCacheApi.loadSession('orphan').messages, original.messages);
|
||
assert(failedCacheApi.listSessionFiles().includes('orphan.json'));
|
||
assert(failedCacheApi.refreshSessionSnapshotCache('orphan').session.messages.length <= 181);
|
||
const reopened = createServerHarness(dir);
|
||
assert(reopened.listSessionFiles().includes('orphan.json'));
|
||
assert(reopened.refreshSessionSnapshotCache('orphan').session.messages.length <= 181);
|
||
assert(fs.existsSync(path.join(dir, 'orphan.json')));
|
||
});
|
||
|
||
check('运行态保留目录和同名旧文件不会干扰完整归档与会话列表', dir => {
|
||
const api = createServerHarness(dir);
|
||
assert.equal(api.saveSession(fixture('listed-session', 3)), true);
|
||
api.sessionHistoryStore.saveTurn('listed-session', { sessionId: 'listed-session', agent: 'codexapp' });
|
||
fs.writeFileSync(path.join(dir, '_history', '_turns.json'), '{}');
|
||
assert.deepEqual(api.sessionHistoryStore.listTurnIds(), ['listed-session']);
|
||
assert.deepEqual(api.sessionHistoryStore.listIds(), ['listed-session']);
|
||
assert.deepEqual(api.listSessionFiles(), ['listed-session.json']);
|
||
});
|
||
|
||
check('可信旧修订并发追加和插入保留锚点顺序以及特殊 JSON 键', dir => {
|
||
const api = createServerHarness(dir);
|
||
const original = fixture('concurrent', 4);
|
||
original.messages[1].toolCalls = [{ id: 'tool', input: JSON.parse('{"__proto__":{"old":"原值"},"constructor":"构造键","value":1}') }];
|
||
assert.equal(api.saveSession(original), true);
|
||
const stale = api.loadSession('concurrent');
|
||
const current = api.loadSession('concurrent');
|
||
current.messages[1].toolCalls[0].input.later = '后续值';
|
||
current.messages.push({ id: 'current-tail', role: 'user', content: '后来提交的尾部' });
|
||
assert.equal(api.saveSession(current), true);
|
||
stale.messages[1].toolCalls[0].input.__proto__.updated = '本次值';
|
||
stale.messages.splice(2, 0, { id: 'inserted', role: 'assistant', content: '插入到已知锚点前' });
|
||
stale.messages.push({ id: 'stale-tail', role: 'assistant', content: '旧对象本轮输出' });
|
||
assert.equal(api.saveSession(stale), true);
|
||
const restored = api.loadSession('concurrent');
|
||
assert.deepEqual(restored.messages.map(message => message.id), ['message-0', 'message-1', 'inserted', 'message-2', 'message-3', 'stale-tail', 'current-tail']);
|
||
const input = restored.messages[1].toolCalls[0].input;
|
||
assert.equal(Object.hasOwn(input, '__proto__'), true);
|
||
assert.deepEqual(input.__proto__, { old: '原值', updated: '本次值' });
|
||
assert.equal(input.constructor, '构造键');
|
||
assert.equal(input.later, '后续值');
|
||
});
|
||
|
||
check('旧修订不能复活后来清空的历史或已经删除的气泡', dir => {
|
||
const api = createServerHarness(dir);
|
||
assert.equal(api.saveSession(fixture('clear-race', 4)), true);
|
||
const stale = api.loadSession('clear-race');
|
||
const current = api.loadSession('clear-race');
|
||
current.messages = [];
|
||
current.codexAppThreadId = null;
|
||
api.sessionHistoryStore.markComplete(current, { reset: true });
|
||
assert.equal(api.saveSession(current), true);
|
||
stale.messages[0].content = '旧助手最后写入';
|
||
stale.messages.push({ id: 'old-run-result', role: 'assistant', content: '清空后旧轮才返回的输出' });
|
||
stale.model = '新模型设置';
|
||
assert.equal(api.saveSession(stale), true);
|
||
const restored = api.loadSession('clear-race');
|
||
assert.deepEqual(restored.messages, []);
|
||
assert.equal(restored.codexAppThreadId, null);
|
||
assert.equal(restored.model, '新模型设置');
|
||
});
|
||
|
||
check('流式气泡及工具引用在元数据并发保存后继续接收delta并落盘', dir => {
|
||
const api = createServerHarness(dir);
|
||
assert.equal(api.saveSession(fixture('streaming', 5)), true);
|
||
const streaming = api.loadSession('streaming');
|
||
const assistant = { id: 'streaming-assistant', role: 'assistant', content: '第一段', toolCalls: [{ id: 'streaming-tool', name: 'run', result: '工具第一段' }] };
|
||
const toolCall = assistant.toolCalls[0];
|
||
streaming.messages.push(assistant);
|
||
assert.equal(api.saveSession(streaming), true);
|
||
const metadata = api.loadSession('streaming');
|
||
metadata.codexAppMcpReload = { pending: true };
|
||
assert.equal(api.saveSession(metadata), true);
|
||
assistant.content += '第二段';
|
||
toolCall.result += '第二段';
|
||
assert.equal(api.saveSession(streaming), true);
|
||
assert.equal(streaming.messages.at(-1), assistant);
|
||
assert.equal(streaming.messages.at(-1).toolCalls[0], toolCall);
|
||
assistant.content += '第三段';
|
||
toolCall.result += '第三段';
|
||
assert.equal(api.saveSession(streaming), true);
|
||
const reopened = createServerHarness(dir).loadSession('streaming');
|
||
assert.equal(reopened.messages.at(-1).content, '第一段第二段第三段');
|
||
assert.equal(reopened.messages.at(-1).toolCalls[0].result, '工具第一段第二段第三段');
|
||
assert.deepEqual(reopened.codexAppMcpReload, metadata.codexAppMcpReload);
|
||
});
|
||
|
||
check('完整归档历史按稳定ID去重且同turn不同气泡分别保留', dir => {
|
||
const original = fixture('archive-identities', 1);
|
||
original.messages = [
|
||
{ id: 'duplicate', role: 'assistant', turnId: 'same-turn', content: '旧片段' },
|
||
{ id: 'another-segment', role: 'assistant', turnId: 'same-turn', content: '不同气泡' },
|
||
{ id: 'duplicate', role: 'assistant', turnId: 'same-turn', content: '最新片段' },
|
||
];
|
||
const api = createServerHarness(dir);
|
||
assert.equal(api.saveSession(original), true);
|
||
const result = api.resolveSessionHistory(api.loadSession('archive-identities'));
|
||
assert.deepEqual(result.messages.map(message => message.id), ['duplicate', 'another-segment']);
|
||
assert.equal(result.messages[0].content, '最新片段');
|
||
assert.equal(result.messages[1].content, '不同气泡');
|
||
});
|
||
|
||
check('档案异常禁止回退预览覆盖,删除档案后旧加载对象不能重新创建', dir => {
|
||
const api = createServerHarness(dir);
|
||
assert.equal(api.saveSession(fixture('corrupt', 220)), true);
|
||
const stale = api.loadSession('corrupt');
|
||
const oldSnapshot = fs.readFileSync(path.join(dir, 'corrupt.json'), 'utf8');
|
||
fs.writeFileSync(path.join(dir, '_history', 'corrupt', 'manifest.json'), '{损坏文件');
|
||
assert.equal(api.loadSession('corrupt'), null);
|
||
assert.equal(api.saveSession(stale), false);
|
||
assert.equal(fs.readFileSync(path.join(dir, 'corrupt.json'), 'utf8'), oldSnapshot);
|
||
assert.equal(api.saveSession(fixture('remove', 220)), true);
|
||
const removed = api.loadSession('remove');
|
||
api.sessionHistoryStore.remove('remove');
|
||
fs.unlinkSync(path.join(dir, 'remove.json'));
|
||
assert.equal(api.loadSession('remove'), null);
|
||
assert.equal(api.saveSession(removed), false);
|
||
assert.equal(fs.existsSync(path.join(dir, '_history', 'remove.json')), false);
|
||
});
|
||
|
||
check('旧短预览同 ID 覆盖受拒绝,导入经确认边界保留 cc-web 专有气泡', dir => {
|
||
const api = createServerHarness(dir);
|
||
const original = fixture('import-existing', 10);
|
||
original.messages[0].nativeThreadId = 'thread-a';
|
||
original.messages[0].turnId = 'confirmed-boundary';
|
||
original.messages[0].role = 'assistant';
|
||
original.messages.push({ id: 'ccweb-only', role: 'assistant', ccwebDisplayOnly: true, content: '跨会话回传' });
|
||
assert.equal(api.saveSession(original), true);
|
||
const untrusted = JSON.parse(JSON.stringify(original));
|
||
untrusted.messages.at(-1).content = '短';
|
||
assert.equal(api.saveSession(untrusted), false);
|
||
const native = [{ id: 'earlier', role: 'assistant', content: '更早前缀' }, { ...original.messages[0], id: 'native-boundary' }, { id: 'native-tail', role: 'assistant', content: '原生尾部' }];
|
||
const existing = api.loadSession('import-existing');
|
||
const prepared = api.prepareNativeHistoryImport(existing, { ...original, messages: native });
|
||
assert(prepared);
|
||
assert.equal(api.saveSession(prepared), true);
|
||
const restored = api.loadSession('import-existing');
|
||
assert.equal(restored.messages[0].id, 'earlier');
|
||
assert.equal(restored.messages.at(-1).id, 'ccweb-only');
|
||
assert(!restored.messages.some(message => message.id === 'native-tail'));
|
||
assert.equal(api.prepareNativeHistoryImport(restored, { ...original, messages: [{ id: 'wrong', role: 'assistant', content: '不匹配' }] }), null);
|
||
});
|
||
|
||
check('旧快照的真实 rollout 前缀确认后先归档,移除 rollout 后完整读回', dir => {
|
||
const id = 'legacy';
|
||
const nativeDir = path.join(dir, '_native');
|
||
fs.mkdirSync(nativeDir);
|
||
const rolloutPath = path.join(nativeDir, 'rollout-thread-a.jsonl');
|
||
const entries = [{ type: 'session_meta', payload: { id: 'thread-a', cwd: dir } }];
|
||
for (let index = 0; index < 8; index += 1) {
|
||
entries.push({ timestamp: new Date(1800000000000 + index * 1000).toISOString(), type: 'event_msg', payload: { type: 'user_message', id: `user-${index}`, message: `原生日志 ${index}` } });
|
||
}
|
||
fs.writeFileSync(rolloutPath, entries.map(entry => JSON.stringify(entry)).join('\n'));
|
||
const parsed = createCodexRolloutStore({ codexSessionsDir: nativeDir, sessionsDir: dir, normalizeSession: value => value, sanitizeToolInput: value => value }).parseCodexRolloutFile(rolloutPath);
|
||
assert.equal(parsed.messages.length, 8);
|
||
const old = {
|
||
id, agent: 'codexapp', codexAppThreadId: 'thread-a', historySnapshotBaseIndex: 5,
|
||
messages: [{ role: 'system', ccwebPersistenceNotice: true, content: '旧截断' }, ...parsed.messages.slice(5)],
|
||
};
|
||
fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(old));
|
||
const api = createServerHarness(dir, { nativeDir, nativeFiles: [rolloutPath] });
|
||
const resolved = api.resolveSessionHistory(api.loadSession(id));
|
||
assert.equal(resolved.messages.length, 8);
|
||
assert(fs.existsSync(path.join(dir, '_history', id, 'manifest.json')), '确认前缀后应提交自身档案');
|
||
fs.unlinkSync(rolloutPath);
|
||
const reopened = createServerHarness(dir);
|
||
const reloaded = reopened.loadSession(id);
|
||
assert.deepEqual(reloaded.messages, resolved.messages);
|
||
assert.equal(reopened.resolveSessionHistory(reloaded).available, true);
|
||
});
|
||
|
||
check('原生日志不匹配和不存在时只返回已有旧快照并保留缺口状态', dir => {
|
||
const old = { ...fixture('unconfirmed', 57), messages: [{ ccwebPersistenceNotice: true, role: 'system', content: '旧截断' }, ...messages(57)] };
|
||
fs.writeFileSync(path.join(dir, 'unconfirmed.json'), JSON.stringify(old));
|
||
const api = createServerHarness(dir);
|
||
const loaded = api.loadSession('unconfirmed');
|
||
const history = api.resolveSessionHistory(loaded);
|
||
assert.equal(history.messages.length, 57);
|
||
assert.equal(history.available, false);
|
||
assert.equal(api.saveSession(loaded), true);
|
||
const reopened = createServerHarness(dir);
|
||
assert.equal(reopened.resolveSessionHistory(reopened.loadSession('unconfirmed')).available, false);
|
||
});
|
||
|
||
check('归档提交失败的旧前缀恢复只提供原快照而不宣称恢复成功', dir => {
|
||
const id = 'recover-failure';
|
||
const original = fixture(id, 3);
|
||
original.messages[0].role = 'assistant';
|
||
original.messages[0].turnId = 'boundary';
|
||
original.messages[0].nativeThreadId = 'thread-a';
|
||
const old = { ...original, messages: [{ ccwebPersistenceNotice: true, role: 'system', content: '旧截断' }, ...original.messages] };
|
||
fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(old));
|
||
const nativeDir = path.join(dir, '_native');
|
||
fs.mkdirSync(nativeDir);
|
||
const rolloutPath = path.join(nativeDir, 'rollout-thread-a.jsonl');
|
||
fs.writeFileSync(rolloutPath, [
|
||
{ type: 'session_meta', payload: { id: 'thread-a' } },
|
||
{ type: 'turn_context', payload: { turn_id: 'early' } },
|
||
{ type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: '前缀' }] } },
|
||
{ type: 'turn_context', payload: { turn_id: 'boundary' } },
|
||
{ type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: '边界' }] } },
|
||
].map(entry => JSON.stringify(entry)).join('\n'));
|
||
const filesystem = new Proxy(fs, { get(target, key) {
|
||
if (key === 'renameSync') return (from, to) => {
|
||
if (String(to).includes(`${path.sep}_history${path.sep}`)) throw new Error('归档失败');
|
||
return target.renameSync(from, to);
|
||
};
|
||
return target[key];
|
||
} });
|
||
const api = createServerHarness(dir, { fs: filesystem, nativeFiles: [rolloutPath], nativeDir });
|
||
const result = api.resolveSessionHistory(api.loadSession(id));
|
||
assert.equal(result.available, false);
|
||
assert.equal(result.messages.length, 3);
|
||
assert.equal(fs.existsSync(path.join(dir, '_history', `${id}.json`)), false);
|
||
});
|
||
|
||
check('生产 clear/delete/prompt删除/steer撤回路径保存精确状态且重开不复活', dir => {
|
||
const api = createServerHarness(dir);
|
||
const original = fixture('lifecycle', 230);
|
||
original.messages.push({ id: 'prompt-message', role: 'assistant', content: '引导问题', ccwebPrompt: { id: 'prompt-id' } });
|
||
original.messages.push({ id: 'steer-user', role: 'user', content: '待撤回 steer' });
|
||
assert.equal(api.saveSession(original), true);
|
||
const loaded = api.loadSession('lifecycle');
|
||
assert(api.removeCcwebPromptMessage(loaded, 'prompt-id'));
|
||
assert.equal(api.saveSession(loaded), true);
|
||
assert.equal(api.rollbackPersistedCodexAppSteerMessage('lifecycle', 'steer-user'), true);
|
||
assert.equal(createServerHarness(dir).loadSession('lifecycle').messages.length, 230);
|
||
assert.equal(api.handleSlashCommand(null, '/clear', 'lifecycle', 'codexapp'), true);
|
||
const cleared = createServerHarness(dir).loadSession('lifecycle');
|
||
assert.deepEqual(cleared.messages, []);
|
||
assert.equal(cleared.codexAppThreadId, null);
|
||
assert(!api.responses.some(response => response.type === 'error'));
|
||
api.handleDeleteSession(null, 'lifecycle');
|
||
assert.equal(createServerHarness(dir).loadSession('lifecycle'), null);
|
||
assert.equal(fs.existsSync(path.join(dir, '_history', 'lifecycle.json')), false);
|
||
});
|
||
|
||
check('生产分页出口读取57条旧快照:首屏12以外45条完整有序且不重复', dir => {
|
||
const original = fixture('paging', 57);
|
||
original.messages.unshift({ ccwebPersistenceNotice: true, role: 'system', content: '旧截断提示' });
|
||
fs.writeFileSync(path.join(dir, 'paging.json'), JSON.stringify(original));
|
||
const api = createServerHarness(dir);
|
||
const chunks = [];
|
||
let cursor = 45;
|
||
while (cursor > 0) {
|
||
api.handleLoadHistoryPage(null, { sessionId: 'paging', before: cursor, requestId: `page-${cursor}` });
|
||
const page = api.responses.at(-1);
|
||
assert.equal(page.type, 'session_history_chunk');
|
||
assert.equal(page.historyTotal, 57);
|
||
assert.equal(page.historyAvailable, false);
|
||
chunks.unshift(...page.messages);
|
||
cursor = page.historyCursor;
|
||
}
|
||
assert.deepEqual(chunks.map(message => message.id), original.messages.slice(1, 46).map(message => message.id));
|
||
assert.equal(new Set(chunks.map(message => message.id)).size, 45);
|
||
});
|
||
|
||
check('真实默认加载650条历史只持有180条尾部且显式窗口不走完整加载', dir => {
|
||
const api = createServerHarness(dir, { fullHistory: false });
|
||
const original = fixture('bounded-default', 650);
|
||
assert.equal(api.saveSession(original), true);
|
||
const loaded = api.loadSession(original.id);
|
||
assert.equal(loaded.messages.length, 180);
|
||
assert.equal(loaded.historySnapshotBaseIndex, 470);
|
||
assert.deepEqual(loaded.messages, original.messages.slice(470));
|
||
const state = api.sessionHistoryStore.state(loaded);
|
||
assert.equal(state.partial, true);
|
||
assert.equal(state.start, 470);
|
||
assert.equal(state.total, 650);
|
||
assert.deepEqual(api.loadSession(original.id, { full: true }).messages, original.messages);
|
||
const fullDefault = createServerHarness(dir);
|
||
assert.equal(fullDefault.loadSession(original.id, { count: 7 }).messages.length, 7);
|
||
assert.equal(fullDefault.loadSession(original.id, { metadata: true }).messages.length, 0);
|
||
assert.equal(fullDefault.loadSession(original.id, { window: { start: 470, end: 650 } }).messages.length, 180);
|
||
});
|
||
|
||
check('有界尾部修改删除前插追加精确保存且未加载前缀原样保留', dir => {
|
||
const api = createServerHarness(dir, { fullHistory: false });
|
||
const original = fixture('bounded-edit', 650);
|
||
assert.equal(api.saveSession(original), true);
|
||
const loaded = api.loadSession(original.id);
|
||
loaded.messages[0].content = '尾部同 ID 更新';
|
||
loaded.messages[0].toolCalls = [{ id: 'changed-tool', input: { retained: true }, result: '完整工具结果' }];
|
||
loaded.messages.splice(1, 1);
|
||
loaded.messages.splice(2, 0, { id: 'inserted-before-tail', role: 'assistant', content: '已有气泡前插入' });
|
||
loaded.messages.push({ id: 'new-tail', role: 'user', content: '追加完整内容' });
|
||
const expected = [...original.messages.slice(0, 470), ...loaded.messages];
|
||
assert.equal(api.saveSession(loaded), true);
|
||
const restored = api.loadSession(original.id, { full: true });
|
||
assert.deepEqual(restored.messages, expected);
|
||
assert.deepEqual(restored.messages.slice(0, 470), original.messages.slice(0, 470));
|
||
assert.equal(restored.messages.some(message => message.id === 'message-471'), false);
|
||
});
|
||
|
||
check('中间加载窗口修改删除前插保存同时保留未加载前缀和后缀', dir => {
|
||
const api = createServerHarness(dir, { fullHistory: false });
|
||
const original = fixture('middle-window', 650);
|
||
original.messages[12].ccwebPrompt = { id: 'early-prompt' };
|
||
assert.equal(api.saveSession(original), true);
|
||
const tail = api.loadSession(original.id);
|
||
assert.deepEqual(api.findSessionMessage(tail, message => message?.ccwebPrompt?.id === 'early-prompt'), original.messages[12]);
|
||
const promptWindow = api.loadSession(original.id, { promptId: 'early-prompt' });
|
||
assert.equal(promptWindow.messages.length, 1);
|
||
assert.equal(promptWindow.messages[0].id, 'message-12');
|
||
const messageWindow = api.loadSession(original.id, { messageId: 'message-10' });
|
||
assert.equal(messageWindow.messages.length, 1);
|
||
assert.equal(messageWindow.messages[0].id, 'message-10');
|
||
const loaded = api.loadSession(original.id, { window: { start: 250, end: 255 } });
|
||
assert.equal(loaded.messages.length, 5);
|
||
assert.equal(loaded.historySnapshotBaseIndex, 250);
|
||
loaded.messages[0].content = '中间窗口同 ID 更新';
|
||
loaded.messages.splice(1, 1);
|
||
loaded.messages.splice(2, 0, { id: 'middle-insert', role: 'assistant', content: '中间窗口前插内容' });
|
||
const expected = [...original.messages.slice(0, 250), ...loaded.messages, ...original.messages.slice(255)];
|
||
assert.equal(api.saveSession(loaded), true);
|
||
const restored = api.loadSession(original.id, { full: true });
|
||
assert.deepEqual(restored.messages, expected);
|
||
assert.deepEqual(restored.messages.slice(0, 250), original.messages.slice(0, 250));
|
||
assert.deepEqual(restored.messages.slice(255), original.messages.slice(255));
|
||
assert.equal(restored.messages.some(message => message.id === 'message-251'), false);
|
||
});
|
||
|
||
check('仅元数据加载更新标题及taskTracking不读取或覆盖未加载消息', dir => {
|
||
const api = createServerHarness(dir);
|
||
const original = fixture('metadata-only', 650);
|
||
original.taskTracking = { version: 2, tasks: [{ id: 'task-a', title: '原任务', status: 'pending' }] };
|
||
assert.equal(api.saveSession(original), true);
|
||
const metadata = api.loadSession(original.id, { metadata: true });
|
||
assert.equal(metadata.messages.length, 0);
|
||
assert.equal(api.sessionHistoryStore.state(metadata).partial, true);
|
||
metadata.title = '仅元数据更新的标题';
|
||
metadata.taskTracking = { version: 3, tasks: [{ id: 'task-a', title: '新任务', status: 'completed' }] };
|
||
assert.equal(api.saveSession(metadata), true);
|
||
const restored = api.loadSession(original.id);
|
||
assert.equal(restored.title, metadata.title);
|
||
assert.deepEqual(restored.taskTracking, metadata.taskTracking);
|
||
assert.deepEqual(restored.messages, original.messages);
|
||
});
|
||
|
||
check('同修订任务版本保护只读取修订头且提交后同步活跃对象', dir => {
|
||
let tracking = false;
|
||
let wholeReads = 0;
|
||
const filesystem = new Proxy(fs, {
|
||
get(target, key) {
|
||
if (key === 'readFileSync') return (...args) => {
|
||
if (tracking) wholeReads += 1;
|
||
return target.readFileSync(...args);
|
||
};
|
||
return target[key];
|
||
},
|
||
});
|
||
const api = createServerHarness(dir, { fs: filesystem, fullHistory: false });
|
||
const original = fixture('same-task-version', 650);
|
||
original.taskTracking = { version: 5, statusId: 'waiting', summary: '已提交任务' };
|
||
assert.equal(api.saveSession(original), true);
|
||
const loaded = api.loadSession(original.id);
|
||
const taskReference = loaded.taskTracking;
|
||
loaded.taskTracking.version = 1;
|
||
loaded.taskTracking.statusId = '旧任务';
|
||
tracking = true;
|
||
const committed = api.sessionHistoryStore.save(loaded);
|
||
tracking = false;
|
||
assert.equal(wholeReads, 0, '同修订提交不得重新读取清单或消息正文');
|
||
assert.deepEqual(committed.taskTrackingPreserved, { incomingVersion: 1, persistedVersion: 5 });
|
||
assert.equal(loaded.taskTracking, taskReference, '任务对象仍原位协调');
|
||
assert.deepEqual(loaded.taskTracking, original.taskTracking);
|
||
loaded.taskTracking.summary = '提交后修改';
|
||
const disk = api.loadSession(original.id, { metadata: true });
|
||
assert.equal(disk.taskTracking.summary, '已提交任务', '活跃对象不能污染可信清单');
|
||
assert.equal(api.loadSession(original.id, { full: true }).messages.length, 650);
|
||
});
|
||
|
||
check('旧修订普通保存完整保留更高任务版本与流式消息引用', dir => {
|
||
const api = createServerHarness(dir, { fullHistory: false });
|
||
const original = fixture('stale-task-version', 650);
|
||
original.taskTracking = { version: 2, statusId: 'initial', nested: { value: '原任务' } };
|
||
assert.equal(api.saveSession(original), true);
|
||
const streaming = api.loadSession(original.id);
|
||
const message = streaming.messages.at(-1);
|
||
const metadata = api.loadSession(original.id, { metadata: true });
|
||
metadata.taskTracking = { version: 6, statusId: 'newer', nested: { value: '新任务' } };
|
||
assert.equal(api.saveSession(metadata, { taskBoardMetadata: true }), true);
|
||
streaming.taskTracking = { version: 4, statusId: 'stale', nested: { value: '旧对象修改' } };
|
||
message.content += '|增量';
|
||
assert.equal(api.saveSession(streaming), true);
|
||
assert.deepEqual(streaming.taskTracking, metadata.taskTracking);
|
||
assert.equal(streaming.messages.at(-1), message);
|
||
const restored = api.loadSession(original.id, { full: true });
|
||
assert.equal(restored.messages.length, 650);
|
||
assert.equal(restored.messages.at(-1).content, message.content);
|
||
assert.deepEqual(restored.taskTracking, metadata.taskTracking);
|
||
});
|
||
|
||
check('任务看板显式提交可删除任务状态而普通保存保留已提交版本', dir => {
|
||
const api = createServerHarness(dir);
|
||
const original = fixture('task-metadata-removal', 3);
|
||
original.taskTracking = { version: 3, statusId: 'waiting' };
|
||
assert.equal(api.saveSession(original), true);
|
||
const ordinary = api.loadSession(original.id);
|
||
delete ordinary.taskTracking;
|
||
assert.equal(api.saveSession(ordinary), true);
|
||
assert.deepEqual(ordinary.taskTracking, original.taskTracking);
|
||
const board = api.loadSession(original.id, { metadata: true });
|
||
delete board.taskTracking;
|
||
assert.equal(api.saveSession(board, { taskBoardMetadata: true }), true);
|
||
const restored = api.loadSession(original.id);
|
||
assert.equal(Object.prototype.hasOwnProperty.call(restored, 'taskTracking'), false);
|
||
assert.deepEqual(restored.messages, original.messages);
|
||
});
|
||
|
||
check('任务状态保护遇清单提交失败不提前修改对象或推进基线', dir => {
|
||
let failManifest = false;
|
||
const filesystem = new Proxy(fs, {
|
||
get(target, key) {
|
||
if (key === 'renameSync') return (from, to) => {
|
||
if (failManifest && path.basename(to) === 'manifest.json') throw new Error('模拟任务清单提交失败');
|
||
return target.renameSync(from, to);
|
||
};
|
||
return target[key];
|
||
},
|
||
});
|
||
const api = createServerHarness(dir, { fs: filesystem });
|
||
const original = fixture('task-failed-commit', 4);
|
||
original.taskTracking = { version: 5, statusId: 'saved' };
|
||
assert.equal(api.saveSession(original), true);
|
||
const loaded = api.loadSession(original.id);
|
||
loaded.taskTracking = { version: 1, statusId: 'stale' };
|
||
const pendingTracking = loaded.taskTracking;
|
||
const revision = api.sessionHistoryStore.state(loaded).revision;
|
||
failManifest = true;
|
||
assert.equal(api.saveSession(loaded), false);
|
||
assert.equal(loaded.taskTracking, pendingTracking);
|
||
assert.equal(loaded.taskTracking.version, 1);
|
||
assert.equal(api.sessionHistoryStore.state(loaded).revision, revision);
|
||
failManifest = false;
|
||
assert.equal(api.saveSession(loaded), true);
|
||
assert.deepEqual(loaded.taskTracking, original.taskTracking);
|
||
});
|
||
|
||
check('不同尾部加载范围的旧修订合并修改删除及新增并保留各自顺序', dir => {
|
||
const api = createServerHarness(dir, { fullHistory: false });
|
||
const original = fixture('partial-ranges', 650);
|
||
assert.equal(api.saveSession(original), true);
|
||
const narrow = api.sessionHistoryStore.loadTail(original.id, 5);
|
||
const wide = api.sessionHistoryStore.loadTail(original.id, 30);
|
||
wide.messages[0].content = '较宽窗口的修改';
|
||
wide.messages.splice(wide.messages.findIndex(message => message.id === 'message-640'), 1);
|
||
wide.messages.push({ id: 'wide-new', role: 'user', content: '较宽窗口追加' });
|
||
assert.equal(api.saveSession(wide), true);
|
||
narrow.messages[0].content = '旧修订较窄窗口的修改';
|
||
narrow.messages.splice(1, 0, { id: 'narrow-insert', role: 'assistant', content: '较窄窗口前插' });
|
||
narrow.messages.push({ id: 'narrow-new', role: 'assistant', content: '较窄窗口追加' });
|
||
assert.equal(api.saveSession(narrow), true);
|
||
const restored = api.loadSession(original.id, { full: true });
|
||
const expected = original.messages.filter(message => message.id !== 'message-640').map(message => ({ ...message }));
|
||
expected.find(message => message.id === 'message-620').content = '较宽窗口的修改';
|
||
expected.find(message => message.id === 'message-645').content = '旧修订较窄窗口的修改';
|
||
expected.splice(expected.findIndex(message => message.id === 'message-646'), 0, { id: 'narrow-insert', role: 'assistant', content: '较窄窗口前插' });
|
||
expected.push({ id: 'narrow-new', role: 'assistant', content: '较窄窗口追加' }, { id: 'wide-new', role: 'user', content: '较宽窗口追加' });
|
||
assert.deepEqual(restored.messages, expected);
|
||
assert.deepEqual(narrow.messages, expected.slice(644));
|
||
});
|
||
|
||
check('部分加载明确reset清空全部历史后旧运行对象只保存元数据', dir => {
|
||
const api = createServerHarness(dir, { fullHistory: false });
|
||
const original = fixture('partial-clear', 650);
|
||
assert.equal(api.saveSession(original), true);
|
||
const stale = api.loadSession(original.id);
|
||
const current = api.loadSession(original.id);
|
||
current.messages = [];
|
||
current.codexAppThreadId = null;
|
||
api.sessionHistoryStore.markComplete(current, { reset: true });
|
||
assert.equal(api.saveSession(current), true);
|
||
assert.deepEqual(api.loadSession(original.id, { full: true }).messages, []);
|
||
stale.messages[0].content = '清空后旧轮才返回的输出';
|
||
stale.messages.push({ id: 'stale-output', role: 'assistant', content: '不能复活旧历史' });
|
||
stale.model = '旧对象本次修改的模型';
|
||
assert.equal(api.saveSession(stale), true);
|
||
const restored = api.loadSession(original.id, { full: true });
|
||
assert.deepEqual(restored.messages, []);
|
||
assert.equal(restored.codexAppThreadId, null);
|
||
assert.equal(restored.model, stale.model);
|
||
});
|
||
|
||
check('尾部manifest提交失败不推进基线且重试保留并发元数据与完整前缀', dir => {
|
||
let failManifest = false;
|
||
const filesystem = new Proxy(fs, { get(target, key) {
|
||
if (key === 'renameSync') return (from, to) => {
|
||
if (failManifest && path.basename(to) === 'manifest.json') throw new Error('模拟清单提交失败');
|
||
return target.renameSync(from, to);
|
||
};
|
||
return target[key];
|
||
} });
|
||
const api = createServerHarness(dir, { fullHistory: false, fs: filesystem });
|
||
const original = fixture('partial-retry', 650);
|
||
assert.equal(api.saveSession(original), true);
|
||
const pending = api.loadSession(original.id);
|
||
const baselineRevision = api.sessionHistoryStore.state(pending).revision;
|
||
const oldSnapshot = fs.readFileSync(path.join(dir, `${original.id}.json`), 'utf8');
|
||
pending.messages[0].content = '失败重试后应保存的正文';
|
||
pending.messages.push({ id: 'retry-new', role: 'assistant', content: '只提交一次的新消息' });
|
||
failManifest = true;
|
||
assert.equal(api.saveSession(pending), false);
|
||
assert.equal(api.sessionHistoryStore.state(pending).revision, baselineRevision);
|
||
assert.equal(fs.readFileSync(path.join(dir, `${original.id}.json`), 'utf8'), oldSnapshot);
|
||
assert.deepEqual(api.loadSession(original.id, { full: true }).messages, original.messages);
|
||
failManifest = false;
|
||
const metadata = api.loadSession(original.id, { metadata: true });
|
||
metadata.title = '失败后其他对象提交的标题';
|
||
assert.equal(api.saveSession(metadata), true);
|
||
assert.equal(api.saveSession(pending), true);
|
||
assert.notEqual(api.sessionHistoryStore.state(pending).revision, baselineRevision);
|
||
const restored = api.loadSession(original.id, { full: true });
|
||
assert.equal(restored.title, metadata.title);
|
||
assert.deepEqual(restored.messages.slice(0, 470), original.messages.slice(0, 470));
|
||
assert.equal(restored.messages[470].content, '失败重试后应保存的正文');
|
||
assert.equal(restored.messages.length, 651);
|
||
assert.equal(restored.messages.filter(message => message.id === 'retry-new').length, 1);
|
||
});
|
||
|
||
check('不完整归档分页总量和消息不计入持久化裁剪提示', dir => {
|
||
const api = createServerHarness(dir, { fullHistory: false });
|
||
const original = fixture('incomplete-pages', 57);
|
||
original.messages.unshift({ id: 'persistence-notice', role: 'system', ccwebPersistenceNotice: true, content: '仅显示的旧截断提示' });
|
||
original.historySnapshotBaseIndex = 500;
|
||
const expectedMessages = original.messages.slice(1);
|
||
assert.equal(api.saveSession(original), true);
|
||
const directPage = api.sessionHistoryStore.loadHistoryPage(original.id, 57, 24);
|
||
assert.equal(directPage.total, 57);
|
||
assert.equal(directPage.complete, false);
|
||
const chunks = [];
|
||
let cursor = 57;
|
||
while (cursor > 0) {
|
||
api.handleLoadHistoryPage(null, { sessionId: original.id, before: cursor, requestId: `archive-page-${cursor}` });
|
||
const page = api.responses.at(-1);
|
||
assert.equal(page.type, 'session_history_chunk');
|
||
assert.equal(page.historyTotal, 57);
|
||
assert.equal(page.historyAvailable, false);
|
||
assert.equal(page.messages.some(message => message.ccwebPersistenceNotice === true), false);
|
||
chunks.unshift(...page.messages);
|
||
assert(page.historyCursor < cursor, '分页游标必须前进');
|
||
cursor = page.historyCursor;
|
||
}
|
||
assert.deepEqual(chunks, expectedMessages);
|
||
});
|
||
|
||
check('尾部追加ID撞未加载前缀时保留原始前缀并去重', dir => {
|
||
const api = createServerHarness(dir, { fullHistory: false });
|
||
const original = fixture('partial-collision', 650);
|
||
assert.equal(api.saveSession(original), true);
|
||
const loaded = api.loadSession(original.id);
|
||
loaded.messages.push({ id: 'message-5', role: 'assistant', content: '碰撞内容不能覆盖未知前缀' });
|
||
loaded.messages.push({ id: 'unique-tail', role: 'assistant', content: '真正新增的尾部内容' });
|
||
assert.equal(api.saveSession(loaded), true);
|
||
const restored = api.loadSession(original.id, { full: true });
|
||
assert.deepEqual(restored.messages.slice(0, 650), original.messages);
|
||
assert.equal(restored.messages.length, 651);
|
||
assert.equal(restored.messages.filter(message => message.id === 'message-5').length, 1);
|
||
assert.equal(restored.messages.at(-1).id, 'unique-tail');
|
||
});
|
||
|
||
check('真实归档首屏及恢复仅返回12条并逐页读取650条完整有序历史', dir => {
|
||
const api = createServerHarness(dir, { fullHistory: false });
|
||
const original = fixture('bounded-first-screen', 650);
|
||
assert.equal(api.saveSession(original), true);
|
||
const loaded = api.loadSession(original.id);
|
||
assert.equal(loaded.messages.length, 180);
|
||
const info = api.buildSessionInfoPayload(loaded);
|
||
assert.equal(info.type, 'session_info');
|
||
assert.equal(info.messages.length, 12);
|
||
assert.deepEqual(info.messages, original.messages.slice(638));
|
||
assert.equal(info.historyTotal, 650);
|
||
assert.equal(info.historyBaseIndex, 638);
|
||
assert.equal(info.historyCursor, 638);
|
||
assert.equal(info.historyTruncated, true);
|
||
assert.equal(info.historyAvailable, true);
|
||
api.handleLoadHistoryPage(null, { sessionId: original.id, before: 638, requestId: 'first-earlier-page' });
|
||
const firstPage = api.responses.at(-1);
|
||
assert.equal(firstPage.type, 'session_history_chunk');
|
||
assert.equal(firstPage.messages.length, 24);
|
||
assert.deepEqual(firstPage.messages, original.messages.slice(614, 638));
|
||
assert.equal(firstPage.historyTotal, 650);
|
||
assert.equal(firstPage.historyBaseIndex, 614);
|
||
assert.equal(firstPage.historyCursor, 614);
|
||
const older = [...firstPage.messages];
|
||
let cursor = firstPage.historyCursor;
|
||
while (cursor > 0) {
|
||
api.handleLoadHistoryPage(null, { sessionId: original.id, before: cursor, requestId: `complete-page-${cursor}` });
|
||
const page = api.responses.at(-1);
|
||
assert.equal(page.type, 'session_history_chunk');
|
||
assert.equal(page.historyTotal, 650);
|
||
assert.equal(page.historyAvailable, true);
|
||
assert(page.messages.length <= 24, '每页消息数必须有界');
|
||
assert(page.historyCursor < cursor, '历史游标必须前进');
|
||
older.unshift(...page.messages);
|
||
cursor = page.historyCursor;
|
||
}
|
||
const complete = [...older, ...info.messages];
|
||
assert.deepEqual(complete, original.messages);
|
||
assert.equal(new Set(complete.map(message => message.id)).size, 650);
|
||
api.handleResumeSession({}, { sessionId: original.id, requestId: 'resume-bounded-history' });
|
||
const resumed = api.responses.at(-1);
|
||
assert.equal(resumed.type, 'resume_session_result');
|
||
assert.equal(resumed.messages.length, 12);
|
||
assert.deepEqual(resumed.messages, info.messages);
|
||
assert.equal(resumed.historyTotal, info.historyTotal);
|
||
assert.equal(resumed.historyBaseIndex, info.historyBaseIndex);
|
||
assert.equal(resumed.historyAvailable, true);
|
||
assert.equal(resumed.isRunning, false);
|
||
});
|
||
|
||
check('旧尾部对象协调新增深层元数据后原位修改不污染不可变基线', dir => {
|
||
const api = createServerHarness(dir, { fullHistory: false });
|
||
const original = fixture('partial-nested-baseline', 650);
|
||
assert.equal(api.saveSession(original), true);
|
||
const streaming = api.loadSession(original.id);
|
||
const metadata = api.loadSession(original.id, { metadata: true });
|
||
metadata.nested = { left: 1, right: 1 };
|
||
assert.equal(api.saveSession(metadata), true);
|
||
assert.equal(api.saveSession(streaming), true);
|
||
assert.deepEqual(streaming.nested, { left: 1, right: 1 });
|
||
const laterMetadata = api.loadSession(original.id, { metadata: true });
|
||
laterMetadata.nested.right = 2;
|
||
assert.equal(api.saveSession(laterMetadata), true);
|
||
streaming.nested.left = 2;
|
||
assert.equal(api.saveSession(streaming), true);
|
||
const restored = api.loadSession(original.id, { full: true });
|
||
assert.deepEqual(restored.nested, { left: 2, right: 2 });
|
||
assert.deepEqual(streaming.nested, { left: 2, right: 2 });
|
||
assert.deepEqual(restored.messages, original.messages);
|
||
});
|
||
|
||
check('中间窗口协调并发尾部提交后再次保存仍保留前后缀', dir => {
|
||
const api = createServerHarness(dir, { fullHistory: false });
|
||
const original = fixture('middle-window-rebase', 650);
|
||
assert.equal(api.saveSession(original), true);
|
||
const middle = api.loadSession(original.id, { window: { start: 30, end: 33 } });
|
||
const tail = api.loadSession(original.id);
|
||
const addedTail = { id: 'concurrent-tail', role: 'assistant', content: '另一运行对象追加的尾部' };
|
||
tail.messages.push(addedTail);
|
||
assert.equal(api.saveSession(tail), true);
|
||
middle.messages[0].content = '中间窗口第一次更新';
|
||
middle.messages.splice(1, 1);
|
||
assert.equal(api.saveSession(middle), true);
|
||
assert.deepEqual(middle.messages.map(message => message.id), ['message-30', 'message-32']);
|
||
assert.equal(middle.historySnapshotBaseIndex, 30);
|
||
middle.messages[0].content = '中间窗口第二次更新';
|
||
middle.messages.splice(1, 0, { id: 'middle-second-insert', role: 'assistant', content: '第二次保存前插内容' });
|
||
const expected = [...original.messages.slice(0, 30), ...middle.messages, ...original.messages.slice(33), addedTail];
|
||
assert.equal(api.saveSession(middle), true);
|
||
assert.deepEqual(api.loadSession(original.id, { full: true }).messages, expected);
|
||
});
|
||
|
||
if (failures.length > 0) {
|
||
console.error(`完整历史回归失败:${failures.length} 项,已通过 ${checks} 项`);
|
||
process.exitCode = 1;
|
||
} else {
|
||
console.log(`完整历史持久化回归通过:${checks} 项`);
|
||
}
|
||
}
|
||
|
||
if (require.main === module) runTests();
|
||
module.exports = { createServerHarness };
|