修复完整会话历史与长气泡交互并更新 CentOS 7 发布包
提交当前工作区全部源码、回归脚本、任务记录及重新构建的 Bun baseline 发布包。发布单文件 MCP 初始化及前端资源一致性验证通过。
This commit is contained in:
114
scripts/assistant-bubble-controls-unit.js
Normal file
114
scripts/assistant-bubble-controls-unit.js
Normal file
@@ -0,0 +1,114 @@
|
||||
#!/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 depth = 0, parentheses = 0, signatureEnd = start;
|
||||
for (let i = source.indexOf('(', start); i < source.length; i += 1) {
|
||||
if (source[i] === '(') parentheses += 1;
|
||||
if (source[i] === ')' && --parentheses === 0) { signatureEnd = i; break; }
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
|
||||
const names = ['getAssistantReplySections', 'getAssistantReplySource', 'getAssistantLastReplyText', 'shouldCollapseAssistantReplies'];
|
||||
const api = new Function(`
|
||||
const ASSISTANT_REPLY_COLLAPSE_CHARS = 6000;
|
||||
const ASSISTANT_REPLY_COLLAPSE_BLOCKS = 24;
|
||||
${names.map(extractFunction).join('\n')}
|
||||
return { ${names.join(',')} };
|
||||
`)();
|
||||
const DIVIDER = '<div class="agent-message-divider" data-divider-time="10:20:30"><span>10:20:30</span></div>';
|
||||
const FIRST = '# 第一条回复\n\n' + '前文'.repeat(4000);
|
||||
const LAST = '# 最后一条回复\n\n第一自然段。\n\n第二自然段。\n\n```js\nconst value = 1;\n```';
|
||||
let passed = 0;
|
||||
function test(description, fn) { fn(); passed += 1; console.log('通过:' + description); }
|
||||
|
||||
test('末段按原生回复分界,保留 Markdown、多自然段与代码', () => {
|
||||
const content = FIRST + '\n\n' + DIVIDER + '\n\n' + LAST;
|
||||
const message = { __assistantContent: content };
|
||||
assert.deepEqual(api.getAssistantReplySections(content), [FIRST, LAST]);
|
||||
assert.equal(api.getAssistantLastReplyText(message), LAST);
|
||||
assert(!api.getAssistantLastReplyText(message).includes('10:20:30'));
|
||||
assert(!api.getAssistantLastReplyText(message).includes('前文'));
|
||||
});
|
||||
|
||||
test('无原生分界的单条回复复制全文,长正文也不会按最后 p 截断', () => {
|
||||
assert.equal(api.getAssistantLastReplyText({ __assistantContent: LAST }), LAST);
|
||||
assert.equal(api.shouldCollapseAssistantReplies([FIRST], 30), true);
|
||||
});
|
||||
|
||||
test('复制保留 Markdown 首行缩进和行末空格,不改变代码及换行语义', () => {
|
||||
const indented = ' const value = 1;\n return value; ';
|
||||
assert.equal(api.getAssistantLastReplyText({ __assistantContent: indented }), indented);
|
||||
assert.equal(api.getAssistantLastReplyText({ __assistantContent: FIRST + '\n\n' + DIVIDER + '\n\n' + indented + '\n\n' }), indented);
|
||||
});
|
||||
|
||||
test('代码围栏内展示分界 HTML 不应误判成原生回复边界', () => {
|
||||
const code = '```html\n' + DIVIDER + '\n```\n\n说明仍属于同一回复。';
|
||||
assert.deepEqual(api.getAssistantReplySections(code), [code]);
|
||||
assert.deepEqual(api.getAssistantReplySections(code + '\n' + DIVIDER + '\n' + LAST), [code, LAST]);
|
||||
const tilde = '~~~~html\n' + DIVIDER + '\n~~~~\n段落';
|
||||
assert.deepEqual(api.getAssistantReplySections(tilde), [tilde]);
|
||||
});
|
||||
|
||||
test('分界 HTML 尚未传完时复制上一条正文,不夹带时间或半截标签', () => {
|
||||
for (const partial of ['<div class="agent-message-divider', '<div class="agent-message-divider" data-divider-time="10:20',
|
||||
'<div class="agent-message-divider" data-divider-time="10:20:30"><span>10:20:30</span>']) {
|
||||
assert.equal(api.getAssistantLastReplyText({ __assistantContent: LAST + '\n\n' + partial }), LAST);
|
||||
}
|
||||
});
|
||||
|
||||
test('历史、流式及文本块采用完整原始内容,复制不掺工具或按钮', () => {
|
||||
assert.equal(api.getAssistantLastReplyText({ __sessionMessage: { content: FIRST + '\n' + DIVIDER + '\n' + LAST } }), LAST);
|
||||
assert.equal(api.getAssistantLastReplyText({ __assistantContent: [
|
||||
{ type: 'text', text: FIRST + '\n' + DIVIDER + '\n' + LAST },
|
||||
{ type: 'tool_result', text: '不该复制的工具结果' },
|
||||
{ type: 'image', alt: '不该复制的图片元数据' },
|
||||
] }), LAST);
|
||||
const stream = { __assistantContent: FIRST + '\n' + DIVIDER + '\n' + LAST };
|
||||
stream.__assistantRepliesExpanded = true;
|
||||
assert.equal(api.getAssistantLastReplyText(stream), LAST);
|
||||
stream.__assistantContent += '\n\n最新流式追加';
|
||||
assert.equal(api.getAssistantLastReplyText(stream), LAST + '\n\n最新流式追加');
|
||||
});
|
||||
|
||||
test('短消息无折叠入口;达到长度或块数后仅折较早原生回复', () => {
|
||||
assert.equal(api.shouldCollapseAssistantReplies(['短前文', '短末段'], 2), false);
|
||||
assert.equal(api.shouldCollapseAssistantReplies([FIRST, LAST], 2), true);
|
||||
assert.equal(api.shouldCollapseAssistantReplies(['短前文', '短末段'], 24), true);
|
||||
assert.equal(api.shouldCollapseAssistantReplies([], 50), false);
|
||||
});
|
||||
|
||||
test('定位先展开裁剪的单条长回复,再读取末段坐标;多回复收起状态保持', () => {
|
||||
const locate = new Function('initiallyClipped', `
|
||||
let clipped = initiallyClipped;
|
||||
const events = [];
|
||||
const message = { classList: { contains: (name) => name === 'assistant-single-collapsed' && clipped } };
|
||||
const bubble = { closest: () => message, querySelector: () => ({ click() { clipped = false; events.push('展开'); } }) };
|
||||
const scope = { classList: { remove() {} }, getBoundingClientRect: () => ({ top: 100 }) };
|
||||
const getAssistantLastSectionTarget = () => { events.push(clipped ? '读取隐藏末段' : '读取可见末段'); return { node: {}, offset: 0, scope }; };
|
||||
const getRangeRectFromTextPosition = () => ({ top: 100 });
|
||||
const messagesDiv = { scrollTop: 0, getBoundingClientRect: () => ({ top: 0 }), scrollTo: (value) => events.push(value.top) };
|
||||
const ASSISTANT_LAST_SECTION_SCROLL_OFFSET = 12;
|
||||
const ASSISTANT_LAST_SECTION_FOCUS_CLASS = 'focus';
|
||||
const requestAnimationFrame = () => {};
|
||||
const updateScrollbar = () => {};
|
||||
${extractFunction('scrollAssistantBubbleToLastSection')}
|
||||
return { result: scrollAssistantBubbleToLastSection(bubble), clipped, events };
|
||||
`);
|
||||
assert.deepEqual(locate(true), { result: true, clipped: false, events: ['展开', '读取可见末段', 88] });
|
||||
assert.deepEqual(locate(false), { result: true, clipped: false, events: ['读取可见末段', 88] });
|
||||
});
|
||||
|
||||
console.log(JSON.stringify({ passed, source: 'public/app.js 原函数', collapseChars: 6000, collapseBlocks: 24 }));
|
||||
762
scripts/codexapp-retry-history-unit.js
Normal file
762
scripts/codexapp-retry-history-unit.js
Normal file
@@ -0,0 +1,762 @@
|
||||
#!/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 { inspect } = require('node:util');
|
||||
const vm = require('node:vm');
|
||||
const { createServerHarness } = require('./session-history-store-unit');
|
||||
const { createCodexAppServerClient } = require('../lib/codex-app-server-client');
|
||||
const { LIFECYCLE_EVENT_TYPES: TASK_BOARD_LIFECYCLE_EVENTS } = require('../lib/task-board-lifecycle');
|
||||
const { CLASSIFICATION_EVENT_TYPES: TASK_STATUS_CLASSIFICATION_EVENTS } = require('../lib/task-board-classifier');
|
||||
|
||||
const REPO_DIR = path.join(__dirname, '..');
|
||||
const baselineIndex = process.argv.indexOf('--baseline');
|
||||
const sourcePath = baselineIndex >= 0 ? process.argv[baselineIndex + 1] : path.join(REPO_DIR, 'server.js');
|
||||
assert(sourcePath, '--baseline 需要提供修复前工作区 server.js 快照路径');
|
||||
const serverSource = fs.readFileSync(sourcePath, 'utf8');
|
||||
const runtimePath = baselineIndex >= 0 && fs.existsSync(path.join(path.dirname(sourcePath), 'ccweb-streaming-retry-baseline-runtime.js'))
|
||||
? path.join(path.dirname(sourcePath), 'ccweb-streaming-retry-baseline-runtime.js')
|
||||
: path.join(REPO_DIR, 'lib/codex-app-runtime.js');
|
||||
const { createCodexAppRuntime } = require(runtimePath);
|
||||
|
||||
function extractFunction(name, optional = false) {
|
||||
const match = new RegExp(`(?:^|\\n)(?:async )?function ${name}\\(`).exec(serverSource);
|
||||
if (!match && optional) return '';
|
||||
assert(match, `缺少生产函数 ${name}`);
|
||||
const start = match.index + (serverSource[match.index] === '\n' ? 1 : 0);
|
||||
const closingLine = /\n\}/g;
|
||||
closingLine.lastIndex = start;
|
||||
let end;
|
||||
while ((end = closingLine.exec(serverSource))) {
|
||||
const candidate = serverSource.slice(start, end.index + 2);
|
||||
try {
|
||||
new vm.Script(`(${candidate})`, { filename: sourcePath });
|
||||
return candidate;
|
||||
} catch (error) {
|
||||
if (!(error instanceof SyntaxError)) throw error;
|
||||
}
|
||||
}
|
||||
throw new Error(`${name} 函数体不完整`);
|
||||
}
|
||||
|
||||
function extractConst(name) {
|
||||
const match = serverSource.match(new RegExp(`^const ${name} = .*;$`, 'm'));
|
||||
assert(match, `缺少生产常量 ${name}`);
|
||||
return match[0];
|
||||
}
|
||||
|
||||
function createApi(dir, options = {}) {
|
||||
const storeApi = createServerHarness(dir);
|
||||
const sent = [];
|
||||
const logs = [];
|
||||
const starts = [];
|
||||
const runtimeRef = { current: null };
|
||||
const timers = new Map();
|
||||
const maps = Object.fromEntries([
|
||||
'activeProcesses', 'activeCodexAppTurns', 'activeCodexAppGoalCommands', 'activeCodexAppCompactions',
|
||||
'codexAppGoalStates', 'pendingCompactRetries', 'pendingCodexCapacityRetries', 'retainedCodexAppTurns',
|
||||
'retiredCodexAppRuntimeTurns', 'wsSessionMap', 'ccwebMcpChildThreads',
|
||||
].map(name => [name, new Map()]));
|
||||
const realSaveTurn = storeApi.sessionHistoryStore.saveTurn.bind(storeApi.sessionHistoryStore);
|
||||
storeApi.sessionHistoryStore.saveTurn = (...args) => {
|
||||
if (options.failTurnSave) { const error = new Error('夹具:完整运行态保存失败'); error.code = 'ENOSPC'; throw error; }
|
||||
return realSaveTurn(...args);
|
||||
};
|
||||
const requiredNames = [
|
||||
'readPositiveIntEnv', 'sanitizeId', 'runDir', 'writeFileAtomicSync', 'cleanRunDir',
|
||||
'textByteLength', 'truncateTextValue', 'sanitizePersistValue', 'sanitizeToolCallsForPersist',
|
||||
'mapToPairs', 'codexAppStatePath', 'codexAppTurnKey', 'serializeCodexAppEntry',
|
||||
'buildCodexAppStateJson', 'writeCodexAppTurnState', 'persistCodexAppTurnState',
|
||||
'cleanupCodexAppTurnState', 'loadCodexAppTurnState', 'hasCodexAppTurnMessage',
|
||||
'appendCodexAppRecoveryNotice', 'recoverCodexAppTurnState',
|
||||
'hasRuntimeOutput', 'retryTailText', 'retryPreviewValue', 'buildCodexRetryToolSummary',
|
||||
'shouldUseCodexAppContinuationRetry', 'buildCodexAppContinuationRetryText',
|
||||
'isCodexTransientCapacityError', 'isCodexTransientConnectionError', 'isCodexTransientRetryableError',
|
||||
'codexTransientRetryDelayMs', 'cancelCodexCapacityRetry', 'shouldRetryCodexTransientFailure',
|
||||
'scheduleCodexCapacityRetry', 'stableMessageHash', 'messageReplyToRequestId', 'ensureStableMessageId',
|
||||
'handleMessage', 'handleCodexAppMessage', 'handleCodexAppTurnComplete', 'handleCodexAppTurnFailure',
|
||||
'attachActiveRuntimeToWs', 'handleResumeSession', 'isSessionRunning',
|
||||
'bindAbortSessionToWs', 'handleCodexAppAbortSession', 'handleAbort',
|
||||
'codexAppRuntimeThreadId', 'codexAppRuntimeTurnId', 'findCodexAppEntryByRuntime',
|
||||
'isCodexAppAdoptableRuntimeMethod', 'adoptCodexAppUnroutedTurn', 'findCodexAppRouteByRuntime',
|
||||
'handleCodexAppNotification', 'handleCodexAppServerExit',
|
||||
];
|
||||
const constNames = [
|
||||
'CODEX_APP_STATE_FULL_TEXT_MAX_CHARS', 'CODEX_APP_STATE_MAP_VALUE_MAX_CHARS', 'CODEX_APP_STATE_MAX_MAP_ENTRIES',
|
||||
'CODEX_APP_STATE_MAX_BYTES', 'CODEX_APP_STATE_LOAD_MAX_BYTES', 'CODEX_APP_STATE_FILE',
|
||||
'CODEX_APP_STATE_FLUSH_DELAY_MS', 'SESSION_MESSAGE_CONTENT_MAX_CHARS',
|
||||
'SESSION_TOOL_INPUT_MAX_CHARS', 'SESSION_TOOL_RESULT_MAX_CHARS', 'SESSION_MAX_TOOL_CALLS_PER_MESSAGE',
|
||||
'PERSIST_TRUNCATED_TEXT',
|
||||
];
|
||||
const optionalNames = [
|
||||
'ensureCodexAppAssistantMessageId', 'buildCodexAppResumePayload', 'retirePendingCodexCapacityRetry',
|
||||
'retireCodexAppRuntimeTurn', 'codexAppAssistantMessageIdForEntry',
|
||||
];
|
||||
const api = new Function('deps', `
|
||||
const { fs, path, crypto, dir, storeApi, sent, logs, starts, timers, maps, options, runtimeRef,
|
||||
TASK_BOARD_LIFECYCLE_EVENTS, TASK_STATUS_CLASSIFICATION_EVENTS } = deps;
|
||||
const SESSIONS_DIR = dir;
|
||||
const INITIAL_HISTORY_COUNT = 12;
|
||||
const DEFAULT_CODEX_CONFIG = { retry: { intervalSeconds: 1 } };
|
||||
const { activeProcesses, activeCodexAppTurns, activeCodexAppGoalCommands, activeCodexAppCompactions,
|
||||
codexAppGoalStates, pendingCompactRetries, pendingCodexCapacityRetries, retainedCodexAppTurns,
|
||||
retiredCodexAppRuntimeTurns, wsSessionMap, ccwebMcpChildThreads } = maps;
|
||||
const sessionHistoryStore = storeApi.sessionHistoryStore;
|
||||
const loadSession = id => options.failSessionLoad ? null : storeApi.loadSession(id);
|
||||
const saveSession = session => options.failSessionSave ? false : storeApi.saveSession(session);
|
||||
const wsSend = (ws, payload) => sent.push({ ...payload, _ws: ws });
|
||||
const plog = (level, event, data) => logs.push({ level, event, ...data });
|
||||
const setTimeout = (callback, delay) => {
|
||||
const timer = { callback, delay, unref() {} }; timers.set(timer, timer); return timer;
|
||||
};
|
||||
const clearTimeout = timer => timers.delete(timer);
|
||||
const getCodexRetryConfig = () => ({ mode: 'limited', maxAttempts: 3, intervalSeconds: 1, ...options.retryConfig });
|
||||
const crossConversationReplyRequestIds = () => [];
|
||||
const resolveMessageAttachments = () => [];
|
||||
const resolveComposerDecorators = text => ({ runtimeText: text, mentions: [] });
|
||||
const attachClientRequestId = (payload, source) => ({ ...payload, requestId: source?.requestId });
|
||||
const isCodexAppGoalActive = () => false;
|
||||
const isCodexAppSession = session => session?.agent === 'codexapp';
|
||||
const getSessionAgent = session => session?.agent || 'claude';
|
||||
const getRuntimeSessionId = session => session?.codexAppThreadId || null;
|
||||
const getDefaultSessionCwd = () => dir;
|
||||
const normalizeAgent = agent => agent || 'codexapp';
|
||||
const getDefaultCodexModel = () => 'fixture-model';
|
||||
const normalizeSession = session => session;
|
||||
const normalizeGiteaSource = value => value || null;
|
||||
const reconcilePendingCrossConversationReplies = () => false;
|
||||
const flushPendingCrossConversationReplies = () => false;
|
||||
const detachWsFromActiveRuntimes = () => {};
|
||||
const sendSessionList = () => {};
|
||||
const broadcastSessionList = () => {};
|
||||
const dispatchTaskBoardLifecycle = () => Promise.resolve(null);
|
||||
const enqueueTaskStatusClassification = () => Promise.resolve(null);
|
||||
const resolvePendingCodexAppUserInputsForSession = () => {};
|
||||
const resolvePendingCodexAppApprovalsForSession = () => {};
|
||||
const resolvePendingCodexAppElicitationsForSession = () => {};
|
||||
const formatRuntimeError = (agent, raw) => String(raw || '');
|
||||
const completeCrossConversationReply = () => false;
|
||||
const recoverCcwebMcpChildThreadsFromPersistedToolCalls = () => {};
|
||||
const setRuntimeSessionId = (session, threadId) => { session.codexAppThreadId = threadId; };
|
||||
const resolveSessionHistory = storeApi.resolveSessionHistory;
|
||||
const sanitizeMessageForTransport = value => value;
|
||||
const sanitizeMessagesForTransport = value => value;
|
||||
const wss = { clients: [] };
|
||||
const buildNotifyContent = async () => ({ title: '', content: '' });
|
||||
const sendNotification = () => {};
|
||||
const giteaWorkflowWaiters = new Map();
|
||||
const settleGiteaWorkflowTurn = () => Promise.resolve(null);
|
||||
let codexAppClient = options.runtimeClient || null;
|
||||
let codexAppClientSignature = 'fixture-signature';
|
||||
const pauseCodexAppGoalForAbort = () => false;
|
||||
const cancelCodexAppGoalCommand = () => false;
|
||||
const cancelCodexAppCompaction = () => false;
|
||||
const codexAppInputFromMessage = text => text ? [{ type: 'text', text }] : [];
|
||||
const normalizeCodexAppThreadId = value => String(value || '').trim();
|
||||
const getCodexAppClient = () => ({ client: options.runtimeClient });
|
||||
const codexAppThreadParams = () => ({});
|
||||
const ensureCodexAppMcpReadyForThread = async () => {};
|
||||
const codexAppTurnParams = (session, input, threadId, clientUserMessageId) => ({ input, threadId, clientUserMessageId });
|
||||
const codexAppRuntime = { processCodexAppNotification: (...args) => runtimeRef.current.processCodexAppNotification(...args) };
|
||||
const handleCodexAppGoalNotification = () => false;
|
||||
const handleCodexAppMcpStartupStatusNotification = () => false;
|
||||
const shouldLogCodexAppUnroutedNotification = () => false;
|
||||
const syncCcwebMcpChildAgentsFromCollabItem = () => {};
|
||||
const processCcwebMcpChildNotification = () => ({ changed: false });
|
||||
const sendCcwebMcpChildAgentUpdate = () => {};
|
||||
const findViewingSessionWs = id => [...wsSessionMap].find(([, sessionId]) => sessionId === id)?.[0] || null;
|
||||
const findCodexAppSessionByThreadId = threadId => {
|
||||
for (const id of new Set(wsSessionMap.values())) {
|
||||
const session = loadSession(id);
|
||||
if (session?.codexAppThreadId === threadId) return { sessionId: id, session };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
// 仅替换真正的模型边界;调度器、hideInHistory 与新 entry 的交接全部执行生产函数。
|
||||
let startCodexAppTurn = async (sessionId, input) => {
|
||||
const entry = activeCodexAppTurns.get(sessionId);
|
||||
starts.push({ sessionId, input });
|
||||
if (options.failStart) throw new Error('夹具:线程启动失败');
|
||||
if (options.startTurn) { await options.startTurn(sessionId, input, entry); return; }
|
||||
entry.turnId = 'fixture-turn-' + starts.length;
|
||||
persistCodexAppTurnState(sessionId, entry, { immediate: true });
|
||||
};
|
||||
${requiredNames.map(name => extractFunction(name)).join('\n')}
|
||||
${optionalNames.map(name => extractFunction(name, true)).join('\n')}
|
||||
${constNames.map(name => extractConst(name)).join('\n')}
|
||||
if (options.realStart) startCodexAppTurn = (${extractFunction('startCodexAppTurn')});
|
||||
return {
|
||||
handleMessage, handleCodexAppMessage, handleCodexAppTurnComplete, handleCodexAppTurnFailure,
|
||||
scheduleCodexCapacityRetry, cancelCodexCapacityRetry, attachActiveRuntimeToWs, handleResumeSession,
|
||||
handleAbort, writeCodexAppTurnState, loadCodexAppTurnState, persistCodexAppTurnState,
|
||||
recoverCodexAppTurnState, codexAppTurnKey, isSessionRunning, findCodexAppEntryByRuntime,
|
||||
handleCodexAppNotification, handleCodexAppServerExit,
|
||||
};
|
||||
`)({ fs, path, crypto, dir, storeApi, sent, logs, starts, timers, maps, options, runtimeRef,
|
||||
TASK_BOARD_LIFECYCLE_EVENTS, TASK_STATUS_CLASSIFICATION_EVENTS });
|
||||
const runtime = createCodexAppRuntime({ wsSend: (ws, payload) => sent.push({ ...payload, _ws: ws }),
|
||||
loadSession: storeApi.loadSession, saveSession: storeApi.saveSession });
|
||||
runtimeRef.current = runtime;
|
||||
async function fireRetry(sessionId) {
|
||||
const retry = maps.pendingCodexCapacityRetries.get(sessionId);
|
||||
assert(retry?.timer, '应已安排自动重试计时器');
|
||||
timers.delete(retry.timer);
|
||||
retry.timer.callback();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
return { ...api, ...maps, runtime, storeApi, sent, logs, starts, timers, fireRetry, options };
|
||||
}
|
||||
|
||||
const ERROR = 'Our servers are currently overloaded. Please try again later.';
|
||||
const A = 'A正文首段唯一\n' + '甲'.repeat(220000) + '\nA正文尾段唯一';
|
||||
const B = 'B正文唯一\n' + '乙'.repeat(15000) + '\nB正文结束唯一';
|
||||
const C = 'C正文唯一\n' + '丙'.repeat(15000) + '\nC正文结束唯一';
|
||||
|
||||
function seed(api, id = 'retry-history', text = '继续长任务') {
|
||||
const session = { id, title: '重试历史夹具', agent: 'codexapp', codexAppThreadId: 'fixture-thread',
|
||||
model: 'fixture-model', permissionMode: 'yolo', cwd: '/fixture', messages: [] };
|
||||
assert.equal(api.storeApi.saveSession(session), true);
|
||||
const ws = { readyState: 1, label: 'initial' };
|
||||
assert.equal(api.handleMessage(ws, { type: 'message', sessionId: id, agent: 'codexapp',
|
||||
clientMessageId: 'original-user', text, mode: 'yolo' }).ok, true);
|
||||
return { id, ws };
|
||||
}
|
||||
|
||||
function emit(api, id, text, label, reusedId = false) {
|
||||
const entry = api.activeCodexAppTurns.get(id);
|
||||
assert(entry, '应有活动 entry');
|
||||
const base = { threadId: entry.threadId, turnId: entry.turnId };
|
||||
const agentId = reusedId ? 'agent-fixed' : 'agent-' + label;
|
||||
const toolId = reusedId ? 'tool-fixed' : 'tool-' + label;
|
||||
for (let offset = 0; offset < text.length; offset += 8192) {
|
||||
api.runtime.processCodexAppNotification(entry, { method: 'item/agentMessage/delta',
|
||||
params: { ...base, itemId: agentId, delta: text.slice(offset, offset + 8192) } }, id);
|
||||
}
|
||||
api.runtime.processCodexAppNotification(entry, { method: 'item/completed',
|
||||
params: { ...base, item: { id: agentId, type: 'agentMessage', text } } }, id);
|
||||
api.runtime.processCodexAppNotification(entry, { method: 'item/started',
|
||||
params: { ...base, item: { id: toolId, type: 'commandExecution',
|
||||
command: 'fixture-' + label + '-' + '输入'.repeat(10000), status: 'inProgress' } } }, id);
|
||||
api.runtime.processCodexAppNotification(entry, { method: 'item/commandExecution/outputDelta',
|
||||
params: { ...base, itemId: toolId, delta: '工具' + label + '结果' + '输出'.repeat(20000) } }, id);
|
||||
api.writeCodexAppTurnState(id, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function assertToolOrder(tools, labels) {
|
||||
assert.equal(tools.length, labels.length, '工具调用数量不能缩水或重复');
|
||||
labels.forEach((label, index) => assert(tools[index].result.includes('工具' + label + '结果'), '工具结果必须按开轮顺序保留'));
|
||||
assert.equal(new Set(tools.map(tool => tool.id)).size, labels.length, '跨轮同原生 ID 必须独立保存');
|
||||
}
|
||||
|
||||
function assertOnceInOrder(text, parts) {
|
||||
let end = -1;
|
||||
for (const part of parts) {
|
||||
const at = text.indexOf(part);
|
||||
assert(at > end, '正文应完整保留并按 A → B → C 排序');
|
||||
assert.equal(text.indexOf(part, at + part.length), -1, '同一段正文不能重复');
|
||||
end = at + part.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
const failures = [];
|
||||
async function check(name, callback) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-retry-history-'));
|
||||
try {
|
||||
await callback(dir);
|
||||
passed += 1;
|
||||
console.log(`通过:${name}`);
|
||||
} catch (error) {
|
||||
failures.push(name);
|
||||
console.error(`失败:${name}:${inspect({ name: error.name, message: error.message.slice(0, 600),
|
||||
stack: error.stack?.split('\n').filter(line => /^\s+at /.test(line)).slice(0, 3),
|
||||
actual: error.actual, expected: error.expected,
|
||||
}, { depth: 1, maxArrayLength: 3, maxStringLength: 160 })}`);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (baselineIndex < 0 && !process.argv.includes('--only-handoff')) {
|
||||
for (const startOutcome of ['response', 'timeout', 'exit']) {
|
||||
const label = startOutcome === 'response'
|
||||
? '取消启动时原生通知先于 RPC 返回,旧 A 不得进入或结束新 B'
|
||||
: startOutcome === 'timeout'
|
||||
? '取消启动 RPC 超时后保留门禁,晚到 ID 仅中断 A 且 B 正常完成'
|
||||
: '取消启动未知 ID 时 app-server 明确退出,提交 A 前文并释放 B 门禁';
|
||||
await check(label, async dir => {
|
||||
const deferred = () => {
|
||||
let resolve, reject;
|
||||
const promise = new Promise((done, fail) => { resolve = done; reject = fail; });
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
const aResponse = deferred();
|
||||
const bResponse = deferred();
|
||||
const nativeStarts = [];
|
||||
const nativeInterrupts = [];
|
||||
let clientRunning = true;
|
||||
const runtimeClient = {
|
||||
start: async () => { clientRunning = true; },
|
||||
isRunning: () => clientRunning,
|
||||
request: async (method, params) => {
|
||||
if (method === 'thread/resume') return { thread: { id: params.threadId } };
|
||||
if (method === 'turn/interrupt') { nativeInterrupts.push(params); return {}; }
|
||||
assert.equal(method, 'turn/start', '夹具只替换线程 RPC 边界');
|
||||
nativeStarts.push(params);
|
||||
return nativeStarts.length === 1 ? aResponse.promise : bResponse.promise;
|
||||
},
|
||||
};
|
||||
const api = createApi(dir, { realStart: true, runtimeClient });
|
||||
const pump = async () => { for (let count = 0; count < 20; count += 1) await Promise.resolve(); };
|
||||
// 路由、增量累积、完整运行态提交和完成函数均来自生产代码。
|
||||
const receive = notification => {
|
||||
const routed = api.findCodexAppEntryByRuntime(notification.params);
|
||||
api.handleCodexAppNotification(notification);
|
||||
return !!routed;
|
||||
};
|
||||
const { id, ws } = seed(api, 'cancel-start-race', '启动任务 A');
|
||||
await pump();
|
||||
assert.equal(nativeStarts.length, 1, 'A 的真实 turn/start RPC 应已发出但尚未返回');
|
||||
assert.equal(api.activeCodexAppTurns.get(id).turnId, null);
|
||||
if (startOutcome === 'exit') {
|
||||
// 延续任务在新原生 ID 确认前已经拥有上轮正文,退出也不能丢弃它。
|
||||
const carriedEntry = api.activeCodexAppTurns.get(id);
|
||||
carriedEntry.fullText = '已经产生的 A 完整前文';
|
||||
api.persistCodexAppTurnState(id, carriedEntry, { immediate: true });
|
||||
}
|
||||
api.handleAbort(ws, { sessionId: id });
|
||||
const nextMessage = { sessionId: id, agent: 'codexapp', clientMessageId: 'race-b-user', text: '新任务 B' };
|
||||
const beforeIdentity = api.handleMessage(ws, nextMessage);
|
||||
await pump();
|
||||
assert.equal(beforeIdentity.ok, false, '旧 A 身份未知时必须阻止同线程 B 开始');
|
||||
assert.equal(nativeStarts.length, 1, '取消启动的 A 获取精确身份前不能发第二个 turn/start');
|
||||
if (startOutcome !== 'response') {
|
||||
aResponse.reject(new Error('夹具:turn/start RPC request timed out'));
|
||||
await pump();
|
||||
assert.equal(api.activeCodexAppTurns.get(id)?.turnId, null, 'RPC 超时不代表旧 A 没有启动,未知 ID 门禁应保留');
|
||||
assert.equal(api.handleMessage(ws, nextMessage).ok, false, 'RPC 超时后 B 仍不能绕过未知身份门禁');
|
||||
}
|
||||
if (startOutcome === 'exit') {
|
||||
clientRunning = false;
|
||||
api.handleCodexAppServerExit('fixture-signature', { code: 1, stderr: '夹具:app-server 已明确退出' });
|
||||
assert.equal(api.activeCodexAppTurns.has(id), false, 'app-server 明确退出后取消 A 不应永久占用门禁');
|
||||
assert.equal(createServerHarness(dir).loadSession(id).messages.find(message => message.role === 'assistant')?.content,
|
||||
'已经产生的 A 完整前文', '释放门禁前须提交 A 既有正文');
|
||||
assert.equal(nativeInterrupts.length, 0, '已退出的进程无需请求未知 turn interrupt');
|
||||
} else {
|
||||
receive({ method: 'turn/started', params: { threadId: 'fixture-thread', turn: { id: 'native-A', status: 'inProgress' } } });
|
||||
receive({ method: 'item/agentMessage/delta', params: { threadId: 'fixture-thread', turnId: 'native-A', itemId: 'agent-A', delta: '旧 A 的迟到正文' } });
|
||||
if (startOutcome === 'response') aResponse.resolve({ turn: { id: 'native-A' } });
|
||||
await pump();
|
||||
assert(nativeInterrupts.some(params => params.turnId === 'native-A'), '旧 A 身份确认后必须按精确回合身份中断');
|
||||
}
|
||||
assert.equal(api.handleMessage(ws, nextMessage).ok, true, '旧 A 身份处理后 B 可以正常开始');
|
||||
await pump();
|
||||
assert.equal(nativeStarts.length, 2);
|
||||
const bEntry = api.activeCodexAppTurns.get(id);
|
||||
receive({ method: 'turn/started', params: { threadId: 'fixture-thread', turn: { id: 'native-B', status: 'inProgress' } } });
|
||||
const bText = 'B 第一段完整正文\n' + '乙'.repeat(9000) + '\nB 最后一段完整正文';
|
||||
receive({ method: 'item/agentMessage/delta', params: { threadId: 'fixture-thread', turnId: 'native-B', itemId: 'agent-B', delta: bText } });
|
||||
if (startOutcome !== 'exit') {
|
||||
assert.equal(receive({ method: 'turn/completed', params: { threadId: 'fixture-thread', turn: { id: 'native-A', status: 'interrupted' } } }), false);
|
||||
}
|
||||
assert.strictEqual(api.activeCodexAppTurns.get(id), bEntry, '旧 A 的迟到完成不能结束 B');
|
||||
assert.equal(bEntry.fullText, bText, 'B 正文不能混入旧 A 通知');
|
||||
bResponse.resolve({ turn: { id: 'native-B' } });
|
||||
await pump();
|
||||
receive({ method: 'item/completed', params: { threadId: 'fixture-thread', turnId: 'native-B', item: { id: 'agent-B', type: 'agentMessage', text: bText } } });
|
||||
receive({ method: 'turn/completed', params: { threadId: 'fixture-thread', turn: { id: 'native-B', status: 'completed' } } });
|
||||
const session = createServerHarness(dir).loadSession(id);
|
||||
const replies = session.messages.filter(message => message.role === 'assistant' && message.content.includes('B 第一段完整正文'));
|
||||
assert.equal(replies.length, 1, 'B 完成后只能归档一次');
|
||||
assert.equal(replies[0].content, bText, 'B 完成与存储重开后前后文完整');
|
||||
assert.equal(session.messages.filter(message => message.role === 'user' && message.id === 'race-b-user').length, 1);
|
||||
assert.equal(api.activeCodexAppTurns.has(id), false);
|
||||
if (startOutcome !== 'exit') {
|
||||
assert.equal(receive({ method: 'turn/completed', params: { threadId: 'fixture-thread', turn: { id: 'native-A', status: 'interrupted' } } }), false);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (process.argv.includes('--only-start-race')) return;
|
||||
}
|
||||
|
||||
await check('真实自动重试开轮必须继承超过提示和快照上限的 A 正文及工具映射', async dir => {
|
||||
const api = createApi(dir);
|
||||
const { id } = seed(api);
|
||||
const original = emit(api, id, A, 'A');
|
||||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||||
await api.fireRetry(id);
|
||||
const resumed = api.activeCodexAppTurns.get(id);
|
||||
assert(resumed?.fullText.includes(A), '真实 schedule → handleMessage → handleCodexAppMessage 丢失了重试前 A 正文');
|
||||
assert.deepEqual(resumed.toolCalls, original.toolCalls, '工具调用不得丢失或只剩预览');
|
||||
assert.deepEqual(Array.from(resumed.toolOutputDeltas), Array.from(original.toolOutputDeltas));
|
||||
assert.deepEqual(Array.from(resumed.agentMessageItems), Array.from(original.agentMessageItems));
|
||||
assert.equal(resumed.codexRetry.attempt, 1);
|
||||
assert.equal(api.storeApi.loadSession(id).messages.filter(message => message.role === 'user').length, 1);
|
||||
});
|
||||
if (process.argv.includes('--only-handoff')) return;
|
||||
|
||||
await check('A → B → C 两次重试后最终助手归档重开仍各保留一次且用户不重复', async dir => {
|
||||
const api = createApi(dir);
|
||||
const { id } = seed(api);
|
||||
emit(api, id, A, 'A');
|
||||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||||
await api.fireRetry(id);
|
||||
emit(api, id, B, 'B');
|
||||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||||
await api.fireRetry(id);
|
||||
const entry = emit(api, id, C, 'C');
|
||||
assert.equal(entry.codexRetry.attempt, 2, '重试次数必须单调增长');
|
||||
api.handleCodexAppTurnComplete(id);
|
||||
const reloaded = createServerHarness(dir).loadSession(id);
|
||||
const assistants = reloaded.messages.filter(message => message.role === 'assistant');
|
||||
assert.equal(assistants.length, 1, '一个逻辑任务只保存一个累积助手气泡');
|
||||
assertOnceInOrder(assistants[0].content, [A, B, C]);
|
||||
assertToolOrder(assistants[0].toolCalls, ['A', 'B', 'C']);
|
||||
for (const tool of assistants[0].toolCalls) assert(tool.result.length > 32768, '最终工具结果必须完整');
|
||||
assert.equal(reloaded.messages.filter(message => message.role === 'user').length, 1);
|
||||
assert.equal(api.storeApi.sessionHistoryStore.loadTurn(id), null, '完成提交后才清理完整运行态');
|
||||
});
|
||||
|
||||
await check('等待重试期间及续跑启动后重连均提供全部已流出的正文与工具', async dir => {
|
||||
const api = createApi(dir);
|
||||
const { id } = seed(api);
|
||||
const first = emit(api, id, A, 'A');
|
||||
api.attachActiveRuntimeToWs({ readyState: 1, label: 'before-error' }, id);
|
||||
const originalId = api.sent.findLast(message => message.type === 'resume_generating')?.assistantMessageId;
|
||||
assert(originalId, '累积气泡必须有稳定身份');
|
||||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||||
assert.equal(api.isSessionRunning(id), true, '等待重试仍属于运行中的逻辑任务');
|
||||
api.handleResumeSession({ readyState: 1, label: 'wait-reconnect' }, { sessionId: id, requestId: 'waiting' });
|
||||
let resume = api.sent.findLast(message => message.type === 'resume_generating');
|
||||
assert.equal(resume?.text, first.fullText, '等待期刷新/重连不能清空或截断正文');
|
||||
assert.deepEqual(resume.toolCalls, first.toolCalls);
|
||||
assert.equal(resume.assistantMessageId, originalId, '等待期必须复用同一累积气泡身份');
|
||||
await api.fireRetry(id);
|
||||
emit(api, id, B, 'B');
|
||||
assert.equal(api.attachActiveRuntimeToWs({ readyState: 1, label: 'running-reconnect' }, id), true);
|
||||
resume = api.sent.findLast(message => message.type === 'resume_generating');
|
||||
assertOnceInOrder(resume.text, [A, B]);
|
||||
assertToolOrder(resume.toolCalls, ['A', 'B']);
|
||||
assert.equal(resume.assistantMessageId, originalId, '新重试 turn 必须复用累积气泡身份');
|
||||
api.handleCodexAppTurnComplete(id);
|
||||
const assistant = createServerHarness(dir).loadSession(id).messages.find(message => message.role === 'assistant');
|
||||
assert.equal(assistant.id, originalId, '归档及重连必须指向同一个助手气泡');
|
||||
});
|
||||
|
||||
await check('等待期完整运行态存储重开保留 A 正文及两个映射', async dir => {
|
||||
const api = createApi(dir);
|
||||
const { id } = seed(api);
|
||||
const entry = emit(api, id, A, 'A');
|
||||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||||
const state = createServerHarness(dir).sessionHistoryStore.loadTurn(id);
|
||||
assert.equal(state?.fullText, A, '安排重试前不能删除唯一完整运行态');
|
||||
assert.deepEqual(state.toolCalls, entry.toolCalls);
|
||||
assert.deepEqual(state.toolOutputDeltas, Array.from(entry.toolOutputDeltas));
|
||||
assert.deepEqual(state.agentMessageItems, Array.from(entry.agentMessageItems));
|
||||
});
|
||||
|
||||
await check('重试耗尽保存所有已产生正文,且不会再安排新轮', async dir => {
|
||||
const api = createApi(dir, { retryConfig: { maxAttempts: 1 } });
|
||||
const { id } = seed(api);
|
||||
emit(api, id, A, 'A');
|
||||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||||
await api.fireRetry(id);
|
||||
emit(api, id, B, 'B');
|
||||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||||
assert.equal(api.pendingCodexCapacityRetries.has(id), false);
|
||||
assert.equal(api.activeCodexAppTurns.has(id), false);
|
||||
const assistant = createServerHarness(dir).loadSession(id).messages.find(message => message.role === 'assistant');
|
||||
assertOnceInOrder(assistant?.content || '', [A, B]);
|
||||
});
|
||||
|
||||
await check('用户取消等待中的重试仍提交已产生正文', async dir => {
|
||||
const api = createApi(dir);
|
||||
const { id, ws } = seed(api);
|
||||
emit(api, id, A, 'A');
|
||||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||||
api.handleAbort(ws, { sessionId: id });
|
||||
assert.equal(api.pendingCodexCapacityRetries.has(id), false);
|
||||
const assistant = createServerHarness(dir).loadSession(id).messages.find(message => message.role === 'assistant');
|
||||
assert.equal(assistant?.content, A, '取消等待不得删除尚未归档正文');
|
||||
});
|
||||
|
||||
await check('重试前完整运行态保存失败时保留内存与上次落盘正文', async dir => {
|
||||
const options = {};
|
||||
const api = createApi(dir, options);
|
||||
const { id } = seed(api);
|
||||
const entry = emit(api, id, A, 'A');
|
||||
entry.fullText += '\n最新未落盘正文';
|
||||
options.failTurnSave = true;
|
||||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||||
const state = createServerHarness(dir).sessionHistoryStore.loadTurn(id);
|
||||
assert.equal(state?.fullText, A, '保存失败不能清理此前完整运行态');
|
||||
const retained = api.activeCodexAppTurns.get(id) || api.pendingCodexCapacityRetries.get(id)?.entry || api.retainedCodexAppTurns.get(id);
|
||||
assert(retained?.fullText.includes('最新未落盘正文'), '保存失败不能丢弃唯一内存正文');
|
||||
});
|
||||
|
||||
await check('续跑启动失败仍归档此前正文', async dir => {
|
||||
const options = {};
|
||||
const api = createApi(dir, options);
|
||||
const { id } = seed(api);
|
||||
emit(api, id, A, 'A');
|
||||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||||
options.failStart = true;
|
||||
await api.fireRetry(id);
|
||||
const assistant = createServerHarness(dir).loadSession(id).messages.find(message => message.role === 'assistant');
|
||||
assert.equal(assistant?.content, A);
|
||||
});
|
||||
|
||||
await check('最终会话保存失败仍留下可重开的 A/B/C 完整运行态', async dir => {
|
||||
const options = {};
|
||||
const api = createApi(dir, options);
|
||||
const { id } = seed(api);
|
||||
for (const [text, label] of [[A, 'A'], [B, 'B']]) {
|
||||
emit(api, id, text, label);
|
||||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||||
await api.fireRetry(id);
|
||||
}
|
||||
emit(api, id, C, 'C');
|
||||
options.failSessionSave = true;
|
||||
api.handleCodexAppTurnComplete(id);
|
||||
const state = createServerHarness(dir).sessionHistoryStore.loadTurn(id);
|
||||
assertOnceInOrder(state?.fullText || '', [A, B, C]);
|
||||
assertToolOrder(state.toolCalls, ['A', 'B', 'C']);
|
||||
assert.equal(api.sent.findLast(message => message.type === 'done')?.historySaved, false);
|
||||
});
|
||||
|
||||
await check('普通新用户轮次从空正文开始而不继承上一气泡', async dir => {
|
||||
const api = createApi(dir);
|
||||
const { id, ws } = seed(api);
|
||||
emit(api, id, A, 'A');
|
||||
api.handleCodexAppTurnComplete(id);
|
||||
assert.equal(api.handleMessage(ws, { sessionId: id, clientMessageId: 'next-user', text: '新任务' }).ok, true);
|
||||
const next = api.activeCodexAppTurns.get(id);
|
||||
assert.equal(next.fullText, '');
|
||||
assert.deepEqual(next.toolCalls, []);
|
||||
assert.equal(next.toolOutputDeltas.size, 0);
|
||||
assert.equal(next.agentMessageItems.size, 0);
|
||||
});
|
||||
|
||||
await check('跨轮复用原生 item ID 不会覆盖工具或误拼已完成助手消息', async dir => {
|
||||
const api = createApi(dir);
|
||||
const { id } = seed(api);
|
||||
for (const [text, label] of [[A, 'A'], [B, 'B']]) {
|
||||
emit(api, id, text, label, true);
|
||||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||||
await api.fireRetry(id);
|
||||
}
|
||||
const entry = emit(api, id, C, 'C', true);
|
||||
assertOnceInOrder(entry.fullText, [A, B, C]);
|
||||
assertToolOrder(entry.toolCalls, ['A', 'B', 'C']);
|
||||
assert.equal(entry.agentMessageItems.size, 3);
|
||||
assert.equal(entry.toolOutputDeltas.size, 3);
|
||||
api.handleCodexAppTurnComplete(id);
|
||||
const assistant = createServerHarness(dir).loadSession(id).messages.find(message => message.role === 'assistant');
|
||||
assertOnceInOrder(assistant.content, [A, B, C]);
|
||||
assertToolOrder(assistant.toolCalls, ['A', 'B', 'C']);
|
||||
});
|
||||
|
||||
await check('未完成消息的空白前缀及完整映射跨重试落盘且交接不丢', async dir => {
|
||||
const api = createApi(dir);
|
||||
const { id } = seed(api);
|
||||
const oldEntry = emit(api, id, A, 'A');
|
||||
api.runtime.processCodexAppNotification(oldEntry, { method: 'item/agentMessage/delta',
|
||||
params: { threadId: oldEntry.threadId, turnId: oldEntry.turnId, itemId: 'pending-prefix', delta: '\n ' } }, id);
|
||||
assert.equal(oldEntry.agentMessagePendingPrefixes.size, 1);
|
||||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||||
const state = createServerHarness(dir).sessionHistoryStore.loadTurn(id);
|
||||
assert.deepEqual(state?.agentMessagePendingPrefixes, Array.from(oldEntry.agentMessagePendingPrefixes));
|
||||
await api.fireRetry(id);
|
||||
const resumed = api.activeCodexAppTurns.get(id);
|
||||
assert.deepEqual(Array.from(resumed.agentMessagePendingPrefixes), Array.from(oldEntry.agentMessagePendingPrefixes));
|
||||
assert.deepEqual(Array.from(resumed.agentMessageItems), Array.from(oldEntry.agentMessageItems));
|
||||
});
|
||||
|
||||
await check('最终保存失败后重连能看完整正文,存储恢复前不得启动新用户轮', async dir => {
|
||||
const options = {};
|
||||
const api = createApi(dir, options);
|
||||
const { id, ws } = seed(api);
|
||||
const output = emit(api, id, A, 'A');
|
||||
output.fullText += '\n最新完成段';
|
||||
options.failSessionSave = true;
|
||||
options.failTurnSave = true;
|
||||
api.handleCodexAppTurnComplete(id);
|
||||
assert(api.retainedCodexAppTurns.get(id)?.fullText.includes('最新完成段'), '双保存失败仍须保留最新内存正文');
|
||||
api.attachActiveRuntimeToWs({ readyState: 1 }, id);
|
||||
assert.equal(api.sent.findLast(message => message.type === 'resume_generating')?.text, output.fullText);
|
||||
assert.equal(api.sent.findLast(message => message.type === 'done')?.historySaved, false);
|
||||
assert.equal(api.handleMessage(ws, { sessionId: id, clientMessageId: 'next-user', text: '下一任务' }).ok, false);
|
||||
assert.equal(api.starts.length, 1);
|
||||
options.failSessionSave = false;
|
||||
options.failTurnSave = false;
|
||||
assert.equal(api.handleMessage(ws, { sessionId: id, clientMessageId: 'next-user', text: '下一任务' }).ok, true);
|
||||
const reloaded = createServerHarness(dir).loadSession(id);
|
||||
assert.equal(reloaded.messages.find(message => message.role === 'assistant')?.content, output.fullText);
|
||||
assert.equal(reloaded.messages.filter(message => message.role === 'user').length, 2);
|
||||
assert.equal(api.activeCodexAppTurns.get(id).fullText, '');
|
||||
});
|
||||
|
||||
await check('等待期计时触发时会话暂不可读仍保留 A 的完整运行态及内存', async dir => {
|
||||
const options = {};
|
||||
const api = createApi(dir, options);
|
||||
const { id } = seed(api);
|
||||
emit(api, id, A, 'A');
|
||||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||||
options.failSessionLoad = true;
|
||||
await api.fireRetry(id);
|
||||
assert.equal(createServerHarness(dir).sessionHistoryStore.loadTurn(id)?.fullText, A);
|
||||
const held = api.retainedCodexAppTurns.get(id) || api.pendingCodexCapacityRetries.get(id)?.entry;
|
||||
assert.equal(held?.fullText, A);
|
||||
assert.equal(api.starts.length, 1, '会话暂时不可读时不能启动空白新线程');
|
||||
});
|
||||
|
||||
await check('等待期收到新用户任务先提交 A,旧重试取消且新气泡从空开始', async dir => {
|
||||
const api = createApi(dir);
|
||||
const { id, ws } = seed(api);
|
||||
emit(api, id, A, 'A');
|
||||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||||
const oldTimer = api.pendingCodexCapacityRetries.get(id)?.timer;
|
||||
assert(oldTimer);
|
||||
assert.equal(api.handleMessage(ws, { sessionId: id, text: '新用户任务', clientMessageId: 'new-during-wait' }).ok, true);
|
||||
assert.equal(api.pendingCodexCapacityRetries.has(id), false);
|
||||
assert.equal(api.activeCodexAppTurns.get(id).fullText, '');
|
||||
const reloaded = createServerHarness(dir).loadSession(id);
|
||||
assert.deepEqual(reloaded.messages.map(message => message.role), ['user', 'assistant', 'user']);
|
||||
assert.equal(reloaded.messages[1].content, A);
|
||||
oldTimer.callback();
|
||||
await Promise.resolve();
|
||||
assert.equal(api.starts.length, 2, '已取消的计时器不能再启动旧任务');
|
||||
});
|
||||
|
||||
await check('超过映射及工具预览数量的 105 项完整状态跨重试与归档保留', async dir => {
|
||||
const api = createApi(dir);
|
||||
const { id } = seed(api);
|
||||
const first = api.activeCodexAppTurns.get(id);
|
||||
const base = { threadId: first.threadId, turnId: first.turnId };
|
||||
for (let index = 0; index < 105; index += 1) {
|
||||
api.runtime.processCodexAppNotification(first, { method: 'item/agentMessage/delta',
|
||||
params: { ...base, itemId: 'agent-many-' + index, delta: '正文段落-' + index } }, id);
|
||||
api.runtime.processCodexAppNotification(first, { method: 'item/started',
|
||||
params: { ...base, item: { id: 'tool-many-' + index, type: 'commandExecution', command: 'fixture-' + index } } }, id);
|
||||
api.runtime.processCodexAppNotification(first, { method: 'item/commandExecution/outputDelta',
|
||||
params: { ...base, itemId: 'tool-many-' + index, delta: '输出-' + index } }, id);
|
||||
}
|
||||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||||
const state = createServerHarness(dir).sessionHistoryStore.loadTurn(id);
|
||||
assert.equal(state?.toolCalls.length, 105);
|
||||
assert.equal(state.toolOutputDeltas.length, 105);
|
||||
assert.equal(state.agentMessageItems.length, 105);
|
||||
await api.fireRetry(id);
|
||||
const resumed = api.activeCodexAppTurns.get(id);
|
||||
assert.equal(resumed.toolCalls.length, 105);
|
||||
assert.equal(resumed.toolOutputDeltas.size, 105);
|
||||
assert.equal(resumed.agentMessageItems.size, 105);
|
||||
api.attachActiveRuntimeToWs({ readyState: 1 }, id);
|
||||
assert.equal(api.sent.findLast(message => message.type === 'resume_generating').toolCalls.length, 105);
|
||||
api.handleCodexAppTurnComplete(id);
|
||||
const assistant = createServerHarness(dir).loadSession(id).messages.find(message => message.role === 'assistant');
|
||||
assert.equal(assistant.toolCalls.length, 105);
|
||||
assert.equal(assistant.content, first.fullText);
|
||||
});
|
||||
|
||||
await check('等待期进程重开从完整运行态恢复 A/B,一次归档且重复恢复不再追加', async dir => {
|
||||
const api = createApi(dir);
|
||||
const { id } = seed(api);
|
||||
emit(api, id, A, 'A');
|
||||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||||
await api.fireRetry(id);
|
||||
emit(api, id, B, 'B');
|
||||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||||
const savedState = createServerHarness(dir).sessionHistoryStore.loadTurn(id);
|
||||
assertOnceInOrder(savedState?.fullText || '', [A, B]);
|
||||
const reopened = createApi(dir);
|
||||
assert.equal(reopened.recoverCodexAppTurnState(id), true);
|
||||
let session = createServerHarness(dir).loadSession(id);
|
||||
let assistants = session.messages.filter(message => message.role === 'assistant');
|
||||
assert.equal(assistants.length, 1);
|
||||
assertOnceInOrder(assistants[0].content, [A, B]);
|
||||
assertToolOrder(assistants[0].toolCalls, ['A', 'B']);
|
||||
assert.equal(assistants[0].id, savedState.assistantMessageId);
|
||||
assert.equal(assistants[0].interrupted, true);
|
||||
assert.equal(session.messages.filter(message => message.role === 'user').length, 1);
|
||||
// 模拟运行态清理失败后下次重启再次读到同一状态,恢复入口必须幂等。
|
||||
reopened.storeApi.sessionHistoryStore.saveTurn(id, savedState);
|
||||
assert.equal(reopened.recoverCodexAppTurnState(id), true);
|
||||
session = createServerHarness(dir).loadSession(id);
|
||||
assistants = session.messages.filter(message => message.role === 'assistant');
|
||||
assert.equal(assistants.length, 1);
|
||||
assert.equal(reopened.storeApi.sessionHistoryStore.loadTurn(id), null);
|
||||
});
|
||||
|
||||
if (baselineIndex >= 0 || process.argv.includes('--skip-mock')) return;
|
||||
await check('真实 mock 协议三轮续接:A/B/C 正文、复用 ID 工具及用户归档完整', async dir => {
|
||||
const options = {};
|
||||
const api = createApi(dir, options);
|
||||
const client = createCodexAppServerClient({ command: process.execPath,
|
||||
args: [path.join(__dirname, 'mock-codex-app-server.js'), 'app-server'], cwd: dir,
|
||||
env: { ...process.env, CODEX_HOME: path.join(dir, 'codex-home') },
|
||||
onNotification: notification => {
|
||||
const entry = api.activeCodexAppTurns.get('mock-history');
|
||||
if (!entry) return;
|
||||
const result = api.runtime.processCodexAppNotification(entry, notification, 'mock-history');
|
||||
if (result.done) api.handleCodexAppTurnComplete('mock-history');
|
||||
},
|
||||
});
|
||||
const waitFor = async predicate => {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (!predicate()) {
|
||||
assert(Date.now() < deadline, '隔离 mock 事件等待超过 5 秒');
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
};
|
||||
try {
|
||||
await client.start();
|
||||
await client.request('thread/resume', { threadId: 'fixture-thread' });
|
||||
options.startTurn = async (sessionId, input) => {
|
||||
await client.request('turn/start', { threadId: 'fixture-thread', input });
|
||||
};
|
||||
const { id } = seed(api, 'mock-history', 'codexapp retry full history');
|
||||
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
||||
await waitFor(() => api.pendingCodexCapacityRetries.has(id));
|
||||
assert.equal(api.pendingCodexCapacityRetries.get(id).attempts, attempt);
|
||||
api.handleResumeSession({ readyState: 1 }, { sessionId: id });
|
||||
const resume = api.sent.findLast(message => message.type === 'resume_generating');
|
||||
assert(resume.text.includes('retry-history-A-begin'));
|
||||
await api.fireRetry(id);
|
||||
}
|
||||
await waitFor(() => !api.activeCodexAppTurns.has(id) && !api.pendingCodexCapacityRetries.has(id));
|
||||
const reloaded = createServerHarness(dir).loadSession(id);
|
||||
const assistant = reloaded.messages.find(message => message.role === 'assistant');
|
||||
for (const label of ['A', 'B', 'C']) {
|
||||
assert.equal(assistant.content.split(`retry-history-${label}-begin`).length, 2);
|
||||
assert.equal(assistant.content.split(`retry-history-${label}-end`).length, 2);
|
||||
}
|
||||
assert.equal(assistant.toolCalls.length, 3);
|
||||
assert.equal(new Set(assistant.toolCalls.map(tool => tool.id)).size, 3);
|
||||
['A', 'B', 'C'].forEach((label, index) => {
|
||||
assert(assistant.toolCalls[index].result.startsWith(`tool-${label}-full-result`));
|
||||
assert(assistant.toolCalls[index].result.length > 32768);
|
||||
});
|
||||
assert.equal(reloaded.messages.filter(message => message.role === 'user').length, 1);
|
||||
} finally {
|
||||
await client.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
run().then(() => {
|
||||
if (failures.length) {
|
||||
console.error(`运行中气泡续接回归失败:${failures.length} 项;通过 ${passed} 项`);
|
||||
process.exitCode = 1;
|
||||
} else console.log(`运行中气泡续接回归通过:${passed} 项`);
|
||||
}).catch(error => { console.error(error); process.exitCode = 1; });
|
||||
347
scripts/codexapp-steer-recovery-unit.js
Normal file
347
scripts/codexapp-steer-recovery-unit.js
Normal file
@@ -0,0 +1,347 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
// 直接读取服务端函数,避免单测中的业务副本与实际实现逐渐偏离。
|
||||
const serverPath = path.join(__dirname, '..', 'server.js');
|
||||
const serverSource = fs.readFileSync(serverPath, 'utf8');
|
||||
const functionNames = [
|
||||
'codexAppAssistantMessageIdForEntry',
|
||||
'ensureCodexAppAssistantMessageId',
|
||||
'buildCodexAppResumePayload',
|
||||
'retireCodexAppRuntimeTurn',
|
||||
'handleMessage',
|
||||
'handleCodexAppMessage',
|
||||
'handleCodexAppTurnComplete',
|
||||
'isCodexAppNoActiveTurnError',
|
||||
'rollbackPersistedCodexAppSteerMessage',
|
||||
'reorderStaleCodexAppTurnMessages',
|
||||
'handleCodexAppSteerMessage',
|
||||
];
|
||||
|
||||
function extractFunction(name) {
|
||||
const match = new RegExp(`^function ${name}\\(`, 'm').exec(serverSource);
|
||||
assert.ok(match, `服务端应存在实际函数:${name}`);
|
||||
const closingLine = /\n\}/g;
|
||||
closingLine.lastIndex = match.index;
|
||||
let end;
|
||||
while ((end = closingLine.exec(serverSource))) {
|
||||
const candidate = serverSource.slice(match.index, end.index + 2);
|
||||
try {
|
||||
// 编译成单一表达式,防止误带相邻函数或全局初始化代码。
|
||||
new vm.Script(`(${candidate})`, { filename: serverPath });
|
||||
return candidate;
|
||||
} catch (error) {
|
||||
if (!(error instanceof SyntaxError)) throw error;
|
||||
}
|
||||
}
|
||||
assert.fail(`无法完整提取服务端函数:${name}`);
|
||||
}
|
||||
|
||||
const sourceFunctions = functionNames.map(extractFunction).join('\n');
|
||||
const copy = (value) => JSON.parse(JSON.stringify(value));
|
||||
const noop = () => {};
|
||||
const sessionId = 'steer-recovery-review';
|
||||
const answerId = 'form-answer-review';
|
||||
const answerText = '表单答案:保留这条消息';
|
||||
const oldOutput = '上一轮完整输出,不能被新轮覆盖。';
|
||||
const noActiveErrors = [
|
||||
'no active turn to steer',
|
||||
'expected active turn id `old-turn-review`: no active turn',
|
||||
];
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function createHarness({ running = true, failedSaveAttempts = [], steerError = null } = {}) {
|
||||
const ws = {};
|
||||
const timers = new Map();
|
||||
const events = [];
|
||||
const requests = [];
|
||||
const starts = [];
|
||||
const saves = [];
|
||||
const stateWrites = [];
|
||||
const failedSaves = new Set(failedSaveAttempts);
|
||||
const requestResult = deferred();
|
||||
const oldEntry = {
|
||||
ws,
|
||||
threadId: 'thread-review',
|
||||
turnId: 'old-turn-review',
|
||||
fullText: oldOutput,
|
||||
toolCalls: [],
|
||||
mcpContext: { sourceSessionId: sessionId },
|
||||
startedAt: '2026-10-10T00:00:00.000Z',
|
||||
};
|
||||
let storedSession = {
|
||||
id: sessionId,
|
||||
agent: 'codexapp',
|
||||
title: '恢复插入单测',
|
||||
cwd: '/tmp/ccweb-steer-recovery-unit',
|
||||
permissionMode: 'yolo',
|
||||
codexAppThreadId: 'thread-review',
|
||||
messages: [{
|
||||
id: 'original-question',
|
||||
role: 'user',
|
||||
content: '上一轮问题',
|
||||
timestamp: '2026-10-10T00:00:00.000Z',
|
||||
}],
|
||||
};
|
||||
let retainedState = copy(oldEntry);
|
||||
const writeState = (entry) => {
|
||||
retainedState = copy(entry);
|
||||
stateWrites.push(copy(entry));
|
||||
return true;
|
||||
};
|
||||
const context = {
|
||||
crypto, Date, Promise, Map,
|
||||
activeCodexAppTurns: new Map([[sessionId, oldEntry]]),
|
||||
activeCodexAppGoalCommands: new Map(),
|
||||
activeCodexAppCompactions: new Map(),
|
||||
codexAppGoalStates: new Map(),
|
||||
activeProcesses: new Map(),
|
||||
pendingCodexCapacityRetries: new Map(),
|
||||
retainedCodexAppTurns: new Map(),
|
||||
retiredCodexAppRuntimeTurns: new Map(),
|
||||
giteaWorkflowWaiters: new Map(),
|
||||
wsSessionMap: new Map(),
|
||||
// 手动推进轮询定时器,不等待真实时间,也不向磁盘写会话。
|
||||
setTimeout(callback, delay) {
|
||||
const token = { unref: noop };
|
||||
timers.set(token, { callback, delay });
|
||||
return token;
|
||||
},
|
||||
clearTimeout: (token) => timers.delete(token),
|
||||
loadSession: (id) => id === sessionId ? copy(storedSession) : null,
|
||||
saveSession(session) {
|
||||
saves.push(copy(session));
|
||||
if (failedSaves.has(saves.length)) return false;
|
||||
storedSession = copy(session);
|
||||
return true;
|
||||
},
|
||||
writeCodexAppTurnState: (id, entry) => writeState(entry),
|
||||
persistCodexAppTurnState: (id, entry) => writeState(entry),
|
||||
cleanupCodexAppTurnState(id, entry) {
|
||||
entry.codexAppStateCleaned = true;
|
||||
retainedState = null;
|
||||
},
|
||||
codexAppClient: {
|
||||
isRunning: () => running,
|
||||
request(method, params, timeout) {
|
||||
requests.push({ method, params: copy(params), timeout });
|
||||
return steerError ? Promise.reject(new Error(steerError)) : requestResult.promise;
|
||||
},
|
||||
},
|
||||
startCodexAppTurn(id, input) {
|
||||
starts.push({ sessionId: id, input: copy(input) });
|
||||
return Promise.resolve();
|
||||
},
|
||||
sanitizeId: (id) => id,
|
||||
isCodexAppSession: (session) => session?.agent === 'codexapp',
|
||||
getRuntimeSessionId: (session) => session.codexAppThreadId,
|
||||
getDefaultSessionCwd: () => '/tmp/ccweb-steer-recovery-unit',
|
||||
getSessionAgent: (session) => session.agent,
|
||||
normalizeSession: noop,
|
||||
normalizeGiteaSource: () => null,
|
||||
resolveMessageAttachments: (attachments) => copy(attachments),
|
||||
resolveComposerDecorators: (text) => ({ runtimeText: text, mentions: [] }),
|
||||
codexAppInputFromMessage: (text, attachments) => [
|
||||
...(text.trim() ? [{ type: 'text', text }] : []),
|
||||
...attachments.map((attachment) => ({ type: 'image', id: attachment.id })),
|
||||
],
|
||||
attachClientRequestId: (payload) => payload,
|
||||
detachWsFromActiveRuntimes: noop,
|
||||
registerCrossConversationReplyRequest: noop,
|
||||
unregisterCrossConversationReplyRequest: noop,
|
||||
deletePendingCrossConversationReply: noop,
|
||||
resolvePendingCodexAppUserInputsForSession: noop,
|
||||
resolvePendingCodexAppApprovalsForSession: noop,
|
||||
resolvePendingCodexAppElicitationsForSession: noop,
|
||||
formatRuntimeError: (agent, text) => String(text),
|
||||
codexAppTurnKey: (id, entry) => `${entry.threadId}:${entry.turnId}`,
|
||||
shouldRetryCodexTransientFailure: () => false,
|
||||
cancelCodexCapacityRetry: noop,
|
||||
hasCodexAppTurnMessage: (session, key) => session.messages.some((message) => message.codexAppTurnKey === key),
|
||||
ensureStableMessageId: (message) => ({ ...message, id: crypto.randomUUID() }),
|
||||
isCodexAppGoalActive: () => false,
|
||||
dispatchTaskBoardLifecycle: noop,
|
||||
TASK_BOARD_LIFECYCLE_EVENTS: {
|
||||
USER_MESSAGE_RECEIVED: 'user_message_received',
|
||||
TURN_FAILED: 'turn_failed',
|
||||
TURN_COMPLETED: 'turn_completed',
|
||||
},
|
||||
TASK_STATUS_CLASSIFICATION_EVENTS: {
|
||||
USER_MESSAGE_RECEIVED: 'user_message_received',
|
||||
TURN_COMPLETED: 'turn_completed',
|
||||
},
|
||||
enqueueTaskStatusClassification: () => Promise.resolve(),
|
||||
crossConversationReplyRequestIds: () => [],
|
||||
reconcilePendingCrossConversationReplies: noop,
|
||||
flushPendingCrossConversationReplies: noop,
|
||||
sanitizeMessageForTransport: copy,
|
||||
resolveSessionHistory: (session) => session,
|
||||
plog: noop,
|
||||
sendSessionList: noop,
|
||||
previewInlineText: (text) => text,
|
||||
wsSend: (target, payload) => events.push(copy(payload)),
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(sourceFunctions, context, { filename: serverPath, timeout: 1000 });
|
||||
|
||||
return {
|
||||
context, oldEntry, events, requests, starts, saves, stateWrites, requestResult,
|
||||
session: () => copy(storedSession),
|
||||
state: () => retainedState && copy(retainedState),
|
||||
send(attachments = []) {
|
||||
return context.handleCodexAppSteerMessage(ws, {
|
||||
sessionId, clientMessageId: answerId, text: answerText, attachments,
|
||||
}, { runtimeText: answerText });
|
||||
},
|
||||
complete: () => context.handleCodexAppTurnComplete(sessionId),
|
||||
setRunning: (value) => { running = value; },
|
||||
async poll() {
|
||||
const waiting = [...timers.entries()].filter(([, timer]) => timer.delay === 50);
|
||||
assert.equal(waiting.length, 1, '等待 ready 时应只有一个待执行的轮询');
|
||||
const [token, timer] = waiting[0];
|
||||
timers.delete(token);
|
||||
timer.callback();
|
||||
await new Promise(setImmediate);
|
||||
},
|
||||
drain: () => new Promise(setImmediate),
|
||||
};
|
||||
}
|
||||
|
||||
function answers(harness) {
|
||||
return harness.session().messages.filter((message) => message.id === answerId);
|
||||
}
|
||||
|
||||
function assertSaveFailure(harness) {
|
||||
assert.equal(harness.starts.length, 0, '保存失败不能启动新轮');
|
||||
assert.equal(harness.stateWrites.filter((entry) => entry.fullText === '').length, 0, '保存失败不能写入新轮的空运行态');
|
||||
assert.ok(harness.events.some((event) => event.type === 'error' && event.code === 'session_save_failed'), '保存失败应返回明确的可重试错误');
|
||||
assert.equal(harness.events.filter((event) => event.type === 'codex_app_steer_status').at(-1)?.status, 'failed', '保存失败不能把答案确认成发送成功');
|
||||
}
|
||||
|
||||
async function testWaitingTurnEnded() {
|
||||
const harness = createHarness({ running: false });
|
||||
const attachment = { id: 'answer-image', filename: '答案图片.png', mime: 'image/png', kind: 'image' };
|
||||
assert.equal(harness.send([attachment]).ok, true, '等待中的插入请求应被接收');
|
||||
assert.equal(answers(harness).length, 0, '等待 ready 时不能预先重复保存答案');
|
||||
harness.complete();
|
||||
harness.setRunning(true);
|
||||
await harness.poll();
|
||||
|
||||
assert.equal(harness.requests.length, 0, '旧轮已结束时应走普通消息分发');
|
||||
assert.equal(harness.starts.length, 1, '旧轮结束后应只启动一轮普通发送');
|
||||
assert.equal(answers(harness).length, 1, '重分发后答案应只保存一次');
|
||||
assert.deepEqual(answers(harness)[0].attachments, [attachment], '转新轮必须保留答案附件');
|
||||
assert.deepEqual(harness.session().messages.map((message) => message.role), ['user', 'assistant', 'user'], '历史应先保存旧轮输出,再保存新答案');
|
||||
assert.ok(harness.events.some((event) => event.type === 'codex_app_steer_status' && event.status === 'sent'), '转新轮后应确认同一条答案发送成功');
|
||||
}
|
||||
|
||||
async function testWaitingTurnReplaced() {
|
||||
const harness = createHarness({ running: false });
|
||||
harness.send();
|
||||
harness.complete();
|
||||
const replacement = { ...harness.oldEntry, turnId: 'replacement-turn-review', fullText: '', historySaved: undefined };
|
||||
harness.context.activeCodexAppTurns.set(sessionId, replacement);
|
||||
harness.setRunning(true);
|
||||
await harness.poll();
|
||||
|
||||
assert.equal(harness.requests.length, 1, '已有新 entry 时应向当前轮发送一次插入请求');
|
||||
assert.equal(harness.requests[0].method, 'turn/steer', '已有活动轮应保留插入语义');
|
||||
assert.equal(harness.requests[0].params.expectedTurnId, replacement.turnId, '重新分发必须使用新 entry 的 turn id');
|
||||
assert.equal(harness.starts.length, 0, '已有新 entry 时不能额外创建新轮');
|
||||
assert.equal(answers(harness).length, 1, '重新分发到新 entry 时答案不能重复保存');
|
||||
harness.requestResult.resolve({});
|
||||
await harness.drain();
|
||||
assert.ok(harness.events.some((event) => event.type === 'codex_app_steer_status' && event.status === 'inserted'), '当前轮插入成功后应确认答案');
|
||||
}
|
||||
|
||||
async function testEndedTurnSaveFailed() {
|
||||
const harness = createHarness({ failedSaveAttempts: [2], steerError: noActiveErrors[1] });
|
||||
harness.send();
|
||||
await harness.drain();
|
||||
|
||||
assertSaveFailure(harness);
|
||||
assert.equal(harness.oldEntry.historySaved, false, '旧轮保存失败应记录明确状态');
|
||||
assert.equal(harness.state()?.fullText, oldOutput, '旧轮完整输出运行态必须保留');
|
||||
assert.equal(harness.state()?.turnStatus, 'completed', '保留的运行态应标记旧轮已经结束');
|
||||
assert.equal(answers(harness).length, 1, '旧轮输出保存失败时不能删除已保存的答案');
|
||||
}
|
||||
|
||||
async function testCompletedFirstReorderSaveFailed() {
|
||||
const harness = createHarness({ failedSaveAttempts: [3] });
|
||||
harness.send();
|
||||
await harness.drain();
|
||||
assert.equal(harness.requests.length, 1, '应先发出等待响应的 steer 请求');
|
||||
harness.complete();
|
||||
assert.equal(harness.oldEntry.historySaved, true, '先到的 completion 应成功保存旧轮输出');
|
||||
harness.requestResult.reject(new Error(noActiveErrors[0]));
|
||||
await harness.drain();
|
||||
|
||||
assertSaveFailure(harness);
|
||||
assert.equal(harness.saves.length, 3, '应真实尝试保存旧轮输出与答案的重排顺序');
|
||||
assert.equal(answers(harness).length, 1, '历史重排保存失败时应保留一次答案');
|
||||
assert.deepEqual(harness.session().messages.map((message) => message.role), ['user', 'user', 'assistant'], '保存失败时不能误认为重排已经提交');
|
||||
}
|
||||
|
||||
async function testErrorClassification() {
|
||||
const harness = createHarness();
|
||||
for (const message of noActiveErrors) {
|
||||
assert.equal(harness.context.isCodexAppNoActiveTurnError(new Error(message)), true, `应识别真实的无活动轮错误:${message}`);
|
||||
}
|
||||
const otherErrors = [
|
||||
'expected active turn id `old-turn-review`: active turn id `different-turn`',
|
||||
'network connection reset',
|
||||
'network error while checking no active turn to steer',
|
||||
];
|
||||
for (const message of otherErrors) {
|
||||
assert.equal(harness.context.isCodexAppNoActiveTurnError(new Error(message)), false, `不能把其他错误误判成结束:${message}`);
|
||||
const failedHarness = createHarness({ steerError: message });
|
||||
failedHarness.send();
|
||||
await failedHarness.drain();
|
||||
assert.equal(failedHarness.starts.length, 0, `真实错误不能自动开新轮:${message}`);
|
||||
assert.equal(failedHarness.context.activeCodexAppTurns.get(sessionId), failedHarness.oldEntry, '真实错误不能提前收敛仍活动的旧轮');
|
||||
assert.equal(answers(failedHarness).length, 0, '未被运行时接收的答案应回滚,避免重试时重复保存');
|
||||
assert.ok(failedHarness.events.some((event) => event.type === 'error' && event.code === 'codexapp_steer_failed'), '真实错误应保留插入失败语义');
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const tests = [
|
||||
['等待 ready 时旧轮结束后普通发送', testWaitingTurnEnded],
|
||||
['等待 ready 时已有新 entry 按当前轮插入', testWaitingTurnReplaced],
|
||||
['无活动轮但旧轮输出保存失败时保留运行态', testEndedTurnSaveFailed],
|
||||
['completion 先到而重排保存失败时保留答案', testCompletedFirstReorderSaveFailed],
|
||||
['无活动轮错误与真实错误区分', testErrorClassification],
|
||||
];
|
||||
// 独立脚本异常挂起时也会在 60 秒以内退出。
|
||||
const deadline = setTimeout(() => {
|
||||
console.error('codexapp-steer-recovery-unit:单测超过 45 秒,已终止。');
|
||||
process.exit(1);
|
||||
}, 45000);
|
||||
try {
|
||||
for (const [name, test] of tests) {
|
||||
await test();
|
||||
console.log(`通过:${name}`);
|
||||
}
|
||||
console.log(`codexapp-steer-recovery-unit:${tests.length} 项全部通过。`);
|
||||
} finally {
|
||||
clearTimeout(deadline);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.stack || error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -203,6 +203,10 @@ class FakeDocument {
|
||||
querySelector(selector) {
|
||||
return this.root.querySelector(selector);
|
||||
}
|
||||
|
||||
getElementById() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createHarness() {
|
||||
@@ -222,6 +226,8 @@ function createHarness() {
|
||||
'commitCodexAppSteerMessage',
|
||||
'registerCodexAppSteerSessionMessage',
|
||||
'retryCodexAppSteerMessage',
|
||||
'completeCodexAppSteerMessage',
|
||||
'reconcileCodexAppSteerSessionMessage',
|
||||
'deleteCodexAppSteerMessage',
|
||||
'setCodexAppSteerStatusElement',
|
||||
'updateCodexAppSteerMessage',
|
||||
@@ -236,17 +242,26 @@ function createHarness() {
|
||||
let isGenerating = true;
|
||||
let currentSessionRunning = true;
|
||||
let currentSessionMessageCount = 4;
|
||||
let startGeneratingCalls = 0;
|
||||
let sendError = null;
|
||||
let ws = { readyState: 1, send(payload) { sent.push(JSON.parse(payload)); } };
|
||||
function cssEscape(value) { return String(value || '').replace(/"/g, '\\\\"'); }
|
||||
function isCodexAppAgent(agent) { return agent === 'codexapp'; }
|
||||
function send(data) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(data)); }
|
||||
function startGenerating() { startGeneratingCalls += 1; }
|
||||
function send(data) {
|
||||
if (sendError) throw new Error(sendError);
|
||||
if (ws && ws.readyState === 1) ws.send(JSON.stringify(data));
|
||||
}
|
||||
function appendError(message) { errors.push(String(message || '')); }
|
||||
function updateUserOutlinePanel() { outlineUpdates += 1; }
|
||||
function updateScrollbar() { scrollbarUpdates += 1; }
|
||||
function scrollToBottom() {}
|
||||
function markSessionMessageElement(element, index) {
|
||||
function mergeHistoryChunkIntoCachedSnapshot() {}
|
||||
function markSessionMessageElement(element, index, message) {
|
||||
element.dataset.sessionMessage = 'true';
|
||||
element.dataset.messageIndex = String(index);
|
||||
if (message && message.id) element.dataset.messageKey = String(message.id);
|
||||
if (message) element.__sessionMessage = message;
|
||||
}
|
||||
${functions}
|
||||
function makeMessage(id) {
|
||||
@@ -267,6 +282,7 @@ function createHarness() {
|
||||
updateCodexAppSteerMessage,
|
||||
setCodexAppSteerStatusElement,
|
||||
retryCodexAppSteerMessage,
|
||||
reconcileCodexAppSteerSessionMessage,
|
||||
deleteCodexAppSteerMessage,
|
||||
records: codexAppSteerRecords,
|
||||
index: userMessageIndex,
|
||||
@@ -276,9 +292,11 @@ function createHarness() {
|
||||
get outlineUpdates() { return outlineUpdates; },
|
||||
get scrollbarUpdates() { return scrollbarUpdates; },
|
||||
get currentSessionMessageCount() { return currentSessionMessageCount; },
|
||||
get startGeneratingCalls() { return startGeneratingCalls; },
|
||||
setSession(id) { currentSessionId = id; },
|
||||
setGenerating(value) { isGenerating = value; currentSessionRunning = value; },
|
||||
setWsReady(value) { ws.readyState = value; },
|
||||
setSendError(value) { sendError = value; },
|
||||
});
|
||||
`;
|
||||
return vm.runInNewContext(script, { document, messagesDiv, sent, errors, outlineUpdates, scrollbarUpdates });
|
||||
@@ -304,7 +322,7 @@ function assertFailedActionsContract() {
|
||||
assert(retry && remove, 'failed 状态应同时渲染重试和删除按钮');
|
||||
assert.strictEqual(retry.textContent, '重试');
|
||||
assert.strictEqual(remove.textContent, '删除');
|
||||
assert.strictEqual(retry.getAttribute('aria-label'), '重试插入此消息');
|
||||
assert.strictEqual(retry.getAttribute('aria-label'), '重新发送此消息');
|
||||
assert.strictEqual(remove.getAttribute('aria-label'), '删除失败插入项');
|
||||
|
||||
retry.click();
|
||||
@@ -318,6 +336,7 @@ function assertFailedActionsContract() {
|
||||
mode: 'plan',
|
||||
agent: 'codexapp',
|
||||
clientMessageId: 'retry-target',
|
||||
codexAppSteer: true,
|
||||
});
|
||||
assert.strictEqual(element.getAttribute('aria-busy'), 'true', 'pending 状态应暴露 aria-busy=true');
|
||||
assert.strictEqual(element.querySelector('.codex-steer-actions'), null, 'pending 状态不应展示失败操作');
|
||||
@@ -329,6 +348,7 @@ function assertFailedActionsContract() {
|
||||
assert.strictEqual(element.dataset.sessionMessage, 'true', '成功后才提交消息索引');
|
||||
assert.strictEqual(element.dataset.messageIndex, '4', '成功提交应使用当前会话消息序号');
|
||||
assert.strictEqual(harness.currentSessionMessageCount, 5, '成功提交只应递增一次');
|
||||
assert.strictEqual(element.dataset.messageKey, 'retry-target', '已插入消息也要保留稳定键,避免历史回显重复');
|
||||
harness.updateCodexAppSteerMessage('retry-target', 'inserted', '已插入');
|
||||
assert.strictEqual(harness.currentSessionMessageCount, 5, '重复 inserted 不应重复递增');
|
||||
}
|
||||
@@ -383,12 +403,54 @@ function assertDeleteAndBlockedRetryContract() {
|
||||
blocked.querySelector('.codex-steer-retry').click();
|
||||
assert.strictEqual(harness.sent.length, 0, '会话不匹配时不能误发重试');
|
||||
assert(blocked.querySelector('.codex-steer-actions'), '会话不匹配时应保持失败态');
|
||||
}
|
||||
|
||||
harness.setSession('session-a');
|
||||
function assertFinishedTurnRetryAsNewTurnContract() {
|
||||
const harness = createHarness();
|
||||
const element = harness.makeMessage('finished-turn-target');
|
||||
harness.registerCodexAppSteerRecord('finished-turn-target', {
|
||||
element,
|
||||
text: '结束后重试',
|
||||
attachments: [{ id: 'img-finished', filename: 'finished.png' }],
|
||||
sessionId: 'session-a',
|
||||
mode: 'plan',
|
||||
agent: 'codexapp',
|
||||
});
|
||||
harness.updateCodexAppSteerMessage('finished-turn-target', 'failed', '插入失败');
|
||||
|
||||
// 上一轮已经结束:重试应把这条消息当作新一轮对话直接发送。
|
||||
harness.setGenerating(false);
|
||||
blocked.querySelector('.codex-steer-retry').click();
|
||||
assert.strictEqual(harness.sent.length, 0, 'turn 已结束时不能误发重试');
|
||||
assert(blocked.querySelector('.codex-steer-actions'), 'turn 已结束时应保持失败态');
|
||||
const retry = element.querySelector('.codex-steer-retry');
|
||||
assert(retry, '已结束会话的失败项仍应展示重试按钮');
|
||||
retry.click();
|
||||
retry.click();
|
||||
|
||||
assert.strictEqual(harness.sent.length, 1, '已结束会话重试应发送一条普通消息');
|
||||
assert.deepStrictEqual(JSON.parse(JSON.stringify(harness.sent[0])), {
|
||||
type: 'message',
|
||||
text: '结束后重试',
|
||||
attachments: [{ id: 'img-finished', filename: 'finished.png' }],
|
||||
sessionId: 'session-a',
|
||||
mode: 'plan',
|
||||
agent: 'codexapp',
|
||||
clientMessageId: 'finished-turn-target',
|
||||
codexAppSteer: true,
|
||||
});
|
||||
assert.strictEqual(harness.startGeneratingCalls, 1, '已结束会话重试应开启新一轮生成');
|
||||
assert.strictEqual(harness.currentSessionMessageCount, 4, '未收到发送确认前不能把临时卡片计入历史');
|
||||
assert.strictEqual(element.querySelector('.codex-steer-status').textContent, '重新发送中...', '重试应明确显示重新发送状态');
|
||||
assert.strictEqual(harness.updateCodexAppSteerMessage('finished-turn-target', 'sent', '已转为新对话'), true);
|
||||
assert.strictEqual(element.querySelector('.codex-steer-actions'), null, '转新对话后不应保留失败操作区');
|
||||
assert.strictEqual(element.querySelector('.codex-steer-status'), null, '转新对话后不应保留插入状态行');
|
||||
assert.strictEqual(element.classList.contains('codex-steer-failed'), false, '转新对话后不应保留插入失败样式');
|
||||
assert.strictEqual(element.dataset.sessionMessage, 'true', '转新对话后应把消息提交为普通会话消息');
|
||||
assert.strictEqual(element.dataset.messageIndex, '4', '转新对话后应占用当前消息序号');
|
||||
assert.strictEqual(element.dataset.messageKey, 'finished-turn-target', '转新对话后应写入稳定的消息键');
|
||||
assert.strictEqual(harness.currentSessionMessageCount, 5, '转新对话只应递增一次消息计数');
|
||||
assert.strictEqual(harness.records.has('finished-turn-target'), false, '成功确认后应清理临时记录');
|
||||
harness.updateCodexAppSteerMessage('finished-turn-target', 'sent', '已转为新对话');
|
||||
assert.strictEqual(harness.currentSessionMessageCount, 5, '重复发送回执不能增加计数');
|
||||
assert.strictEqual(harness.errors.length, 0, '转新对话后不应再提示无法重试');
|
||||
}
|
||||
|
||||
function assertProgrammaticSessionMessageRetryContract() {
|
||||
@@ -417,6 +479,7 @@ function assertProgrammaticSessionMessageRetryContract() {
|
||||
mode: 'yolo',
|
||||
agent: 'codexapp',
|
||||
clientMessageId: 'programmatic-target',
|
||||
codexAppSteer: true,
|
||||
});
|
||||
harness.updateCodexAppSteerMessage('programmatic-target', 'inserted', '已插入');
|
||||
assert.strictEqual(element.dataset.sessionMessage, 'true', '程序化临时项成功后才提交消息索引');
|
||||
@@ -424,6 +487,50 @@ function assertProgrammaticSessionMessageRetryContract() {
|
||||
assert.strictEqual(harness.currentSessionMessageCount, 5, '程序化临时项成功后只递增一次消息计数');
|
||||
}
|
||||
|
||||
function assertRetryFailureAndNormalEchoContract() {
|
||||
const harness = createHarness();
|
||||
const element = harness.makeMessage('normal-echo-target');
|
||||
harness.registerCodexAppSteerRecord('normal-echo-target', {
|
||||
element, text: '表单答案', sessionId: 'session-a',
|
||||
attachments: [{ id: 'img-retry', filename: 'retry.png' }],
|
||||
});
|
||||
harness.updateCodexAppSteerMessage('normal-echo-target', 'failed', '插入失败');
|
||||
harness.setGenerating(false);
|
||||
harness.setSendError('连接发送失败');
|
||||
assert.strictEqual(harness.retryCodexAppSteerMessage('normal-echo-target'), false);
|
||||
assert(element.querySelector('.codex-steer-retry'), '发送异常后仍应允许重试');
|
||||
assert.strictEqual(harness.currentSessionMessageCount, 4, '发送异常不能提交消息序号');
|
||||
harness.setSendError(null);
|
||||
assert.strictEqual(harness.retryCodexAppSteerMessage('normal-echo-target'), true);
|
||||
harness.updateCodexAppSteerMessage('normal-echo-target', 'failed', '保存失败');
|
||||
assert(element.querySelector('.codex-steer-retry'), '服务端保存失败后仍应允许再次重试');
|
||||
assert.strictEqual(harness.retryCodexAppSteerMessage('normal-echo-target'), true);
|
||||
|
||||
const echoedMessage = {
|
||||
id: 'normal-echo-target', role: 'user', content: '表单答案',
|
||||
attachments: [{ id: 'img-retry', filename: 'retry.png' }],
|
||||
timestamp: '2026-10-10T11:00:00.000Z',
|
||||
};
|
||||
assert.strictEqual(harness.reconcileCodexAppSteerSessionMessage('other-session', echoedMessage), false);
|
||||
assert.strictEqual(harness.reconcileCodexAppSteerSessionMessage('session-a', echoedMessage), true);
|
||||
assert.strictEqual(harness.messagesDiv.children.length, 1, '普通发送回显应确认原卡片,不能追加重复气泡');
|
||||
assert.strictEqual(harness.currentSessionMessageCount, 5, '普通发送回显只提交一次消息序号');
|
||||
assert.strictEqual(element.querySelector('.codex-steer-status'), null, '普通发送回显后应移除插入状态');
|
||||
assert.strictEqual(harness.updateCodexAppSteerMessage('normal-echo-target', 'sent', '已转为新对话'), false);
|
||||
assert.strictEqual(harness.currentSessionMessageCount, 5, '普通回显之后的回执不能再提交一次');
|
||||
}
|
||||
|
||||
function assertCompletedFirstMessageIndexContract() {
|
||||
const harness = createHarness();
|
||||
const element = harness.makeMessage('completed-first-index');
|
||||
harness.registerCodexAppSteerRecord('completed-first-index', {
|
||||
element, text: '完成先到时重试', sessionId: 'session-a',
|
||||
});
|
||||
harness.updateCodexAppSteerMessage('completed-first-index', 'sent', '已转为新对话', 3);
|
||||
assert.strictEqual(element.dataset.messageIndex, '3', '旧轮重排后应使用服务端确认的真实下标');
|
||||
assert.strictEqual(harness.currentSessionMessageCount, 4, '旧轮完成已推进计数时,确认消息不能再重复递增');
|
||||
}
|
||||
|
||||
function assertStaticContracts() {
|
||||
const appSource = fs.readFileSync(APP_PATH, 'utf8');
|
||||
const styleSource = fs.readFileSync(STYLE_PATH, 'utf8');
|
||||
@@ -439,5 +546,8 @@ assertStaticContracts();
|
||||
assertFailedActionsContract();
|
||||
assertDeleteAndBlockedRetryContract();
|
||||
assertProgrammaticSessionMessageRetryContract();
|
||||
assertFinishedTurnRetryAsNewTurnContract();
|
||||
assertRetryFailureAndNormalEchoContract();
|
||||
assertCompletedFirstMessageIndexContract();
|
||||
|
||||
console.log('Failed insert card frontend unit checks passed.');
|
||||
|
||||
@@ -48,9 +48,13 @@ async function waitFor(predicate, description, timeout = 12000) {
|
||||
}
|
||||
async function stopChild(child) {
|
||||
if (!child || child.exitCode !== null || child.signalCode) return;
|
||||
const exited = new Promise((resolve) => child.once('exit', resolve));
|
||||
child.kill('SIGTERM');
|
||||
await Promise.race([new Promise((resolve) => child.once('exit', resolve)), sleep(1000)]);
|
||||
if (child.exitCode === null && !child.signalCode) child.kill('SIGKILL');
|
||||
await Promise.race([exited, sleep(1000)]);
|
||||
if (child.exitCode === null && !child.signalCode) {
|
||||
child.kill('SIGKILL');
|
||||
await exited;
|
||||
}
|
||||
}
|
||||
|
||||
class FirefoxDriver {
|
||||
@@ -168,7 +172,11 @@ Module._extensions['.js'] = (module, filename) => {
|
||||
}, 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 };
|
||||
return {
|
||||
home, sessions, rollout, preload, mock, completeFlag, snapshotCount: snapshot.length,
|
||||
historyIds: [...prefix, ...snapshot.slice(-4)].map((message) => message.id),
|
||||
nativeCount: store.parseCodexRolloutLines(rows.map((row) => JSON.stringify(row))).messages.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function startRelay(port, targetPort, evidence) {
|
||||
@@ -192,8 +200,13 @@ async function startRelay(port, targetPort, evidence) {
|
||||
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,
|
||||
type: msg.type, historySource: msg.historySource, historyTotal: msg.historyTotal,
|
||||
historyAvailable: msg.historyAvailable, isRunning: msg.isRunning,
|
||||
ids: msg.messages?.map((message) => message.id),
|
||||
messages: msg.messages?.map((message, index) => ({
|
||||
id: message.id,
|
||||
index: Number.isFinite(Number(msg.historyBaseIndex)) ? Number(msg.historyBaseIndex) + index : null,
|
||||
})),
|
||||
});
|
||||
} catch {}
|
||||
if (client.readyState === WebSocket.OPEN) client.send(data, { binary: false });
|
||||
@@ -265,16 +278,23 @@ async function main() {
|
||||
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;
|
||||
let rolloutHidden = false;
|
||||
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_INTERNAL_MCP_TOKEN: MCP_TOKEN,
|
||||
CC_WEB_CONFIG_DIR: path.join(root, 'config'), CC_WEB_SESSIONS_DIR: fixture.sessions, CC_WEB_LOGS_DIR: path.join(root, 'logs'),
|
||||
CODEX_HOME: path.join(fixture.home, '.codex'),
|
||||
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);
|
||||
const launchServer = (logName = 'server.log') => {
|
||||
const child = 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_INTERNAL_MCP_TOKEN: MCP_TOKEN,
|
||||
CC_WEB_CONFIG_DIR: path.join(root, 'config'), CC_WEB_SESSIONS_DIR: fixture.sessions, CC_WEB_LOGS_DIR: path.join(root, 'logs'),
|
||||
CODEX_HOME: path.join(fixture.home, '.codex'),
|
||||
CODEX_PATH: fixture.mock, CLAUDE_PATH: path.join(REPO, 'scripts/mock-claude.js'), CC_WEB_TASK_BOARD_ENABLED: '0',
|
||||
}, stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const log = fs.createWriteStream(path.join(root, logName));
|
||||
child.stdout.pipe(log); child.stderr.pipe(log);
|
||||
child.once('exit', () => log.end());
|
||||
return child;
|
||||
};
|
||||
server = launchServer();
|
||||
await waitFor(async () => (await fetch(`http://127.0.0.1:${serverPort}/`)).ok, '隔离服务启动失败');
|
||||
const mcpResponse = await fetch(`http://127.0.0.1:${serverPort}/api/internal/mcp/stream?sourceSessionId=${SESSION_ID}`, {
|
||||
method: 'POST',
|
||||
@@ -301,6 +321,106 @@ async function main() {
|
||||
}, '登录或会话列表未就绪');
|
||||
await driver.execute(`document.querySelector('.session-item').click();`);
|
||||
evidence.stages.loaded = await capture(driver, 'loaded', root, false);
|
||||
const archiveStart = evidence.protocol.length;
|
||||
fs.renameSync(fixture.rollout, `${fixture.rollout}.isolated-hidden`);
|
||||
rolloutHidden = true;
|
||||
await stopChild(server);
|
||||
server = launchServer('server-restarted.log');
|
||||
await waitFor(async () => {
|
||||
assert.equal(server.exitCode, null, '隔离服务重启后已退出');
|
||||
return (await fetch(`http://127.0.0.1:${serverPort}/`)).ok;
|
||||
}, '归档验证服务重启失败');
|
||||
await driver.refresh();
|
||||
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.archivedAfterRestart = await capture(driver, 'archived-after-restart', root, false);
|
||||
const archivedBeforePaging = await driver.execute(`return {
|
||||
ids: [...document.querySelectorAll('#messages > .msg')].map((node) => node.dataset.messageKey).filter(Boolean),
|
||||
indices: [...document.querySelectorAll('#messages > .msg')]
|
||||
.map((node) => Number(node.dataset.messageIndex))
|
||||
.filter((value) => Number.isFinite(value)),
|
||||
notice: document.querySelector('#history-load-more')?.textContent || '',
|
||||
};`);
|
||||
await driver.execute(`const box=document.querySelector('#messages'); box.scrollTop=0;`);
|
||||
await waitFor(async () => {
|
||||
const state = await driver.execute(`return {
|
||||
earliest: [...document.querySelectorAll('#messages .msg')].some((node) => node.textContent.includes('早期问题 0')),
|
||||
canLoad: !!document.querySelector('.history-load-more-button:not([disabled])'),
|
||||
};`);
|
||||
if (state.earliest) return true;
|
||||
if (state.canLoad) {
|
||||
await driver.execute(`document.querySelector('.history-load-more-button:not([disabled])').click();`);
|
||||
}
|
||||
return false;
|
||||
}, '原始记录移开并重启后,最早消息无法从自身归档读回', 30000);
|
||||
const archived = await driver.execute(`return {
|
||||
ids: [...document.querySelectorAll('#messages > .msg')].map((node) => node.dataset.messageKey).filter(Boolean),
|
||||
indices: [...document.querySelectorAll('#messages > .msg')]
|
||||
.map((node) => Number(node.dataset.messageIndex))
|
||||
.filter((value) => Number.isFinite(value)),
|
||||
notice: document.querySelector('#history-load-more')?.textContent || '',
|
||||
};`);
|
||||
const archiveProtocol = evidence.protocol.slice(archiveStart);
|
||||
const archiveInfo = archiveProtocol.filter((message) => message.type === 'session_info');
|
||||
const archiveChunks = archiveProtocol.filter((message) => message.type === 'session_history_chunk');
|
||||
const archiveMessages = archiveProtocol.flatMap((message) => message.messages || []);
|
||||
const archiveIds = new Set(archiveMessages.map((message) => message.id).filter(Boolean));
|
||||
const archiveIndices = new Set(archiveMessages
|
||||
.map((message) => message.index)
|
||||
.filter((index) => Number.isFinite(index)));
|
||||
assert(archiveInfo.length > 0 && archiveInfo.every((message) => message.historySource === 'archive'),
|
||||
'归档刷新后的 session_info 必须全部来自 archive');
|
||||
assert(archiveInfo.every((message) => message.historyTotal === 148 && message.historyAvailable === true),
|
||||
'归档响应必须提供完整总数,且 historyAvailable=true');
|
||||
assert(archiveChunks.length > 0 && archiveChunks.every((message) => message.historySource === 'archive'),
|
||||
'归档分页必须全部来自 archive');
|
||||
assert(archiveChunks.every((message) => message.historyTotal === 148 && message.historyAvailable === true),
|
||||
'归档分页总数或可用状态不一致');
|
||||
assert.deepEqual(
|
||||
[...archiveIndices].sort((left, right) => left - right),
|
||||
Array.from({ length: 148 }, (_, index) => index),
|
||||
'归档协议消息下标必须连续覆盖 0 到 147',
|
||||
);
|
||||
assert.equal(archiveIds.size, 148, `归档协议应覆盖 148 个稳定 ID,实际 ${archiveIds.size}`);
|
||||
assert.equal(archived.ids.length, 148, `归档消息总数应为 148,实际 ${archived.ids.length}`);
|
||||
assert.deepEqual(archived.ids, fixture.historyIds, '归档分页必须保留夹具全部消息稳定 ID 及顺序');
|
||||
assert.deepEqual(
|
||||
archived.indices.slice().sort((left, right) => left - right),
|
||||
Array.from({ length: archived.indices.length }, (_, index) => index),
|
||||
'归档可见消息下标必须从 0 连续覆盖到 147',
|
||||
);
|
||||
assert.deepEqual(
|
||||
archivedBeforePaging.indices.slice().sort((left, right) => left - right),
|
||||
Array.from({ length: archivedBeforePaging.indices.length }, (_, index) => index + 136),
|
||||
'归档首屏必须从消息下标 136 开始',
|
||||
);
|
||||
assert.equal(new Set(archived.ids).size, 148, '归档必须保留 148 个唯一稳定 ID');
|
||||
assert(!archived.notice.includes('无法恢复') && !archived.notice.includes('待核实'),
|
||||
'完整归档读完后不应保留来源不可用或待核实提示');
|
||||
await driver.execute(`document.querySelector('#messages').scrollTop=0;`);
|
||||
await waitFor(() => driver.execute(`const node=[...document.querySelectorAll('#messages > .msg')]
|
||||
.find((message) => message.dataset.messageKey === arguments[0]);
|
||||
if (!node) return false;
|
||||
const box=node.getBoundingClientRect(), style=getComputedStyle(node);
|
||||
return node.textContent.includes('早期问题 0') && box.top >= 0
|
||||
&& box.bottom <= innerHeight && box.height > 0 && Number(style.opacity) > 0.9;`,
|
||||
[fixture.historyIds[0]]), '归档最早问题没有实际显示在视口');
|
||||
await driver.screenshot(path.join(root, 'archived-paged.png'));
|
||||
evidence.stages.archivedPaged = archived;
|
||||
evidence.archived = {
|
||||
beforePaging: archivedBeforePaging,
|
||||
afterPaging: archived,
|
||||
archiveInfoCount: archiveInfo.length,
|
||||
archiveChunkCount: archiveChunks.length,
|
||||
protocolStableIdCount: archiveIds.size,
|
||||
protocolIndexCount: archiveIndices.size,
|
||||
pagedScreenshot: path.join(root, 'archived-paged.png'),
|
||||
};
|
||||
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();
|
||||
@@ -323,10 +443,14 @@ async function main() {
|
||||
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);
|
||||
await stopChild(gecko);
|
||||
await stopChild(server);
|
||||
if (rolloutHidden && fs.existsSync(`${fixture.rollout}.isolated-hidden`)) {
|
||||
fs.renameSync(`${fixture.rollout}.isolated-hidden`, fixture.rollout);
|
||||
}
|
||||
fs.writeFileSync(path.join(root, 'evidence.json'), JSON.stringify(evidence, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,13 +26,20 @@ function extractFunction(name) {
|
||||
|
||||
// 仅模拟浏览器节点操作,消息身份、对账、缓存合并及重连分支均执行 app.js 原函数。
|
||||
class Element {
|
||||
constructor() {
|
||||
constructor(tagName = '') {
|
||||
this.tagName = tagName;
|
||||
this.dataset = {};
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
this.scrollTop = 0;
|
||||
this.id = '';
|
||||
this.classList = { contains: (name) => this.message?.role === name };
|
||||
this.classes = new Set();
|
||||
this.listeners = new Map();
|
||||
this.classList = {
|
||||
contains: (name) => this.classes.has(name) || this.message?.role === name,
|
||||
add: (name) => this.classes.add(name),
|
||||
remove: (name) => this.classes.delete(name),
|
||||
};
|
||||
}
|
||||
get scrollHeight() { return this.children.length * 50; }
|
||||
get nextSibling() {
|
||||
@@ -40,9 +47,11 @@ class Element {
|
||||
return this.parentNode.children[this.parentNode.children.indexOf(this) + 1] || null;
|
||||
}
|
||||
set innerHTML(value) {
|
||||
this.html = value;
|
||||
this.children.forEach((child) => { child.parentNode = null; });
|
||||
this.children = [];
|
||||
}
|
||||
get innerHTML() { return this.html || ''; }
|
||||
insertBefore(node, anchor) {
|
||||
if (node.fragment) {
|
||||
[...node.children].forEach((child) => this.insertBefore(child, anchor));
|
||||
@@ -57,6 +66,8 @@ class Element {
|
||||
return node;
|
||||
}
|
||||
appendChild(node) { return this.insertBefore(node, null); }
|
||||
addEventListener(type, listener) { this.listeners.set(type, listener); }
|
||||
click() { if (!this.disabled) this.listeners.get('click')?.(); }
|
||||
removeAttribute(name) { if (name === 'id') this.id = ''; }
|
||||
remove() {
|
||||
if (!this.parentNode) return;
|
||||
@@ -71,6 +82,7 @@ class Element {
|
||||
}
|
||||
querySelectorAll(selector) {
|
||||
return this.children.filter((child) => {
|
||||
if (selector.includes('.user') && child.message?.role !== 'user') return false;
|
||||
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;
|
||||
@@ -190,6 +202,241 @@ function message(id, role = 'assistant', content = id) {
|
||||
return { id, role, content, timestamp: '2026-09-21T00:00:00.000Z' };
|
||||
}
|
||||
|
||||
function createPaginationHarness() {
|
||||
const rendering = createHarness();
|
||||
const names = [
|
||||
'createOutlineHistoryState', 'resetOutlineHistoryState', 'buildOutlineHistoryEntry',
|
||||
'mergeOutlineHistoryMessages', 'syncOutlineHistoryIndex', 'normalizeOutlineTitleHistory',
|
||||
'formatUserOutlineDate', 'buildUserOutlineItems', 'buildUserOutlineTimelineItems',
|
||||
'updateUserOutlinePanel', 'resetHistoryLoadState', 'renderHistoryLoadControl',
|
||||
'syncHistoryLoadControl', 'requestOlderHistory', 'requestOutlineHistoryPage',
|
||||
];
|
||||
const historyStart = source.indexOf("case 'session_history_chunk':");
|
||||
const historyEnd = source.indexOf("case 'session_message':", historyStart);
|
||||
const errorStart = source.indexOf("case 'error':", historyStart);
|
||||
const errorEnd = source.indexOf('const matchesActiveLoadError =', errorStart);
|
||||
assert(historyStart >= 0 && historyEnd > historyStart && errorStart >= 0 && errorEnd > errorStart);
|
||||
return new Function('Element', 'rendering', `
|
||||
let currentSessionId = 'running-session';
|
||||
const loadedHistorySessionId = currentSessionId;
|
||||
let currentHistoryState = {};
|
||||
let currentOutlineHistoryState = createOutlineHistoryState(currentSessionId);
|
||||
let activeHistoryPageRequest = null;
|
||||
let activeOutlineHistoryRequest = null;
|
||||
let activeOutlineTargetRequest = null;
|
||||
let historyPageRequestSeq = 0;
|
||||
let outlineHistoryRequestSeq = 0;
|
||||
const activeSessionLoad = null;
|
||||
let ws = { readyState: 1 };
|
||||
let wsAuthenticated = true;
|
||||
const historyLoadMore = new Element();
|
||||
const userOutlinePanel = new Element();
|
||||
const userOutlineBtn = new Element();
|
||||
const messagesDiv = rendering.messagesDiv;
|
||||
const userMessageIndex = new Map();
|
||||
const currentOutlineTitleHistory = [];
|
||||
const requests = [];
|
||||
const document = { createElement(tagName) { return new Element(tagName); } };
|
||||
function send(request) { requests.push(request); }
|
||||
function shortMessagePreview(value, length) { return String(value).slice(0, length); }
|
||||
function escapeHtml(value) { return String(value); }
|
||||
function isBlockingSessionLoad() { return false; }
|
||||
function scheduleAdvancedSearchJump() {}
|
||||
function prependHistoryMessages(...args) { rendering.prependHistoryMessages(...args); }
|
||||
function mergeHistoryChunkIntoCachedSnapshot(...args) { rendering.mergeHistoryChunkIntoCachedSnapshot(...args); }
|
||||
${names.map(extractFunction).join('\n')}
|
||||
return {
|
||||
requests, historyLoadMore, userOutlinePanel, userOutlineBtn,
|
||||
requestOlderHistory, requestOutlineHistoryPage,
|
||||
reset() {
|
||||
resetHistoryLoadState(currentSessionId);
|
||||
resetOutlineHistoryState(currentSessionId);
|
||||
updateUserOutlinePanel();
|
||||
},
|
||||
ids: rendering.ids,
|
||||
outlineItems: buildUserOutlineItems,
|
||||
state() { return { history: currentHistoryState, outline: currentOutlineHistoryState,
|
||||
pageRequest: activeHistoryPageRequest, outlineRequest: activeOutlineHistoryRequest }; },
|
||||
connect(authenticated = true, readyState = 1, connected = true) {
|
||||
wsAuthenticated = authenticated;
|
||||
ws = connected ? { readyState } : null;
|
||||
},
|
||||
select(sessionId) { currentSessionId = sessionId; },
|
||||
init(snapshot) {
|
||||
resetHistoryLoadState();
|
||||
resetOutlineHistoryState();
|
||||
rendering.buffers.set(snapshot.sessionId, { ...snapshot, messages: [...snapshot.messages] });
|
||||
rendering.renderMessages(snapshot.messages, { immediate: true, baseIndex: snapshot.historyBaseIndex });
|
||||
syncHistoryLoadControl(snapshot);
|
||||
syncOutlineHistoryIndex(snapshot);
|
||||
updateUserOutlinePanel();
|
||||
},
|
||||
receive(msg) {
|
||||
switch (msg.type) {
|
||||
${source.slice(historyStart, historyEnd)}
|
||||
${source.slice(errorStart, errorEnd)}
|
||||
break;
|
||||
}
|
||||
},
|
||||
};
|
||||
`)(Element, rendering);
|
||||
}
|
||||
|
||||
const savedMessages = Array.from({ length: 57 }, (_, index) =>
|
||||
message(`saved-${index}`, index < 45 && index % 2 === 0 ? 'user' : 'assistant'));
|
||||
function savedSnapshot(available = false, cursor = 45) {
|
||||
return {
|
||||
sessionId: 'running-session', messages: savedMessages.slice(cursor),
|
||||
historyBaseIndex: cursor, historyCursor: cursor, historyTotal: savedMessages.length,
|
||||
historyAvailable: available,
|
||||
};
|
||||
}
|
||||
function historyChunk(request, cursor) {
|
||||
return {
|
||||
type: 'session_history_chunk', sessionId: request.sessionId, requestId: request.requestId,
|
||||
messages: savedMessages.slice(cursor, request.before), historyBaseIndex: cursor,
|
||||
historyCursor: cursor, historyTotal: savedMessages.length, historyAvailable: false, remaining: 0,
|
||||
};
|
||||
}
|
||||
function historyButtons(api) {
|
||||
return api.historyLoadMore.children.filter((node) => node.tagName === 'button');
|
||||
}
|
||||
function historyCopy(api) {
|
||||
return api.historyLoadMore.children.filter((node) => node.tagName === 'span')
|
||||
.map((node) => node.textContent).join(' ');
|
||||
}
|
||||
|
||||
{
|
||||
const api = createPaginationHarness();
|
||||
api.init(savedSnapshot());
|
||||
api.requestOlderHistory();
|
||||
assert.equal(api.requests.length, 1, '原始来源不可用时仍须请求已保存的45条正文');
|
||||
assert.match(historyCopy(api), /45 条已保存的更早消息/);
|
||||
assert.equal(historyButtons(api)[0].disabled, true, '请求期间保留 loading 和按钮禁用');
|
||||
assert.equal(historyButtons(api)[0].textContent, '正在加载…');
|
||||
api.requestOlderHistory();
|
||||
assert.equal(api.requests.length, 1, '重复点击不能重复请求');
|
||||
const first = api.requests[0];
|
||||
api.receive({ ...historyChunk(first, 33), requestId: '过期请求' });
|
||||
api.receive({ ...historyChunk(first, 33), sessionId: '其他会话' });
|
||||
assert.equal(api.ids().length, 12, '错误会话和过期请求不能插入正文');
|
||||
while (api.state().pageRequest) {
|
||||
const request = api.state().pageRequest;
|
||||
const response = historyChunk(request, Math.max(0, request.before - 12));
|
||||
api.receive(response);
|
||||
api.receive(response);
|
||||
if (api.state().history.cursor > 0) historyButtons(api)[0].click();
|
||||
}
|
||||
assert.deepEqual(api.requests.map((request) => request.before), [45, 33, 21, 9]);
|
||||
assert.deepEqual(api.ids(), savedMessages.map((item) => item.id), '57条已保存正文保持顺序且不重复');
|
||||
assert.equal(api.state().history.available, false, '来源状态不能伪装成完整恢复');
|
||||
assert.equal(api.historyLoadMore.hidden, false, '读完已保存消息仍须保留更早记录待核实提示');
|
||||
assert.equal(historyButtons(api).length, 0, 'cursor=0时不能保留加载按钮');
|
||||
assert.doesNotMatch(historyCopy(api), /无法恢复/);
|
||||
}
|
||||
|
||||
{
|
||||
const api = createPaginationHarness();
|
||||
api.init(savedSnapshot());
|
||||
assert.equal(api.outlineItems().length, 0, '首屏没有用户提问,用于验证目录入口');
|
||||
assert.equal(api.userOutlineBtn.disabled, false, '有已保存旧消息时目录按钮不能因来源状态而禁用');
|
||||
api.requestOutlineHistoryPage();
|
||||
assert.equal(api.requests.length, 1, '原始来源不可用时仍须索引已保存的45条');
|
||||
assert.match(api.userOutlinePanel.innerHTML, /正在加载/);
|
||||
api.requestOutlineHistoryPage();
|
||||
assert.equal(api.requests.length, 1, '目录索引请求必须去重');
|
||||
const first = api.requests[0];
|
||||
api.receive({ ...historyChunk(first, 33), requestId: '过期目录请求' });
|
||||
api.receive({ ...historyChunk(first, 33), sessionId: '其他会话' });
|
||||
assert.equal(api.outlineItems().length, 0, '错误会话和过期响应不能污染目录');
|
||||
while (api.state().outlineRequest) {
|
||||
const request = api.state().outlineRequest;
|
||||
const response = historyChunk(request, Math.max(0, request.before - 12));
|
||||
api.receive(response);
|
||||
api.receive(response);
|
||||
}
|
||||
assert.deepEqual(api.requests.map((request) => request.before), [45, 33, 21, 9]);
|
||||
assert(api.requests.every((request) => request.purpose === 'outline'));
|
||||
assert.deepEqual(api.outlineItems().map((item) => item.id),
|
||||
savedMessages.filter((item) => item.role === 'user').map((item) => item.id),
|
||||
'目录自动完成全部用户提问索引,顺序正确且无重复');
|
||||
assert.equal(api.state().outline.complete, true);
|
||||
assert.equal(api.state().outline.available, false);
|
||||
assert.match(api.userOutlinePanel.innerHTML, /更早的记录尚待核实/);
|
||||
assert.doesNotMatch(api.userOutlinePanel.innerHTML, /无法恢复/);
|
||||
}
|
||||
|
||||
for (const outline of [false, true]) {
|
||||
const api = createPaginationHarness();
|
||||
api.init(savedSnapshot());
|
||||
const requestPage = () => outline ? api.requestOutlineHistoryPage() : api.requestOlderHistory();
|
||||
const state = () => outline ? api.state().outline : api.state().history;
|
||||
const active = () => outline ? api.state().outlineRequest : api.state().pageRequest;
|
||||
api.connect(false);
|
||||
requestPage();
|
||||
api.connect(true, 0);
|
||||
requestPage();
|
||||
api.connect(true, 1, false);
|
||||
requestPage();
|
||||
api.connect();
|
||||
api.select('其他会话');
|
||||
requestPage();
|
||||
assert.equal(api.requests.length, 0, '保留认证、连接状态和会话隔离门禁');
|
||||
api.select('running-session');
|
||||
requestPage();
|
||||
assert.equal(api.requests.length, 1);
|
||||
const failedRequest = active();
|
||||
api.receive({ type: 'error', sessionId: '其他会话', requestId: failedRequest.requestId, message: '不得生效' });
|
||||
assert.equal(state().loading, true, '其他会话错误不能取消本次加载');
|
||||
api.receive({ type: 'error', sessionId: failedRequest.sessionId, requestId: failedRequest.requestId, message: '读取失败,请重试' });
|
||||
assert.equal(active(), null);
|
||||
assert.equal(state().loading, false);
|
||||
assert.equal(state().cursor, 45, '失败后保留游标供重试');
|
||||
if (outline) {
|
||||
assert.match(api.userOutlinePanel.innerHTML, /data-outline-retry/);
|
||||
assert.equal(api.userOutlineBtn.disabled, false);
|
||||
requestPage();
|
||||
} else {
|
||||
assert.equal(historyButtons(api)[0].disabled, false);
|
||||
assert.equal(historyButtons(api)[0].textContent, '重试');
|
||||
historyButtons(api)[0].click();
|
||||
}
|
||||
assert.equal(api.requests.length, 2, '失败后能够重试已有记录');
|
||||
assert.notEqual(active().requestId, failedRequest.requestId);
|
||||
assert.equal(state().error, '');
|
||||
assert.equal(state().loading, true);
|
||||
}
|
||||
|
||||
for (const available of [false, true]) {
|
||||
const api = createPaginationHarness();
|
||||
api.init(savedSnapshot(available, 0));
|
||||
api.requestOlderHistory();
|
||||
api.requestOutlineHistoryPage();
|
||||
assert.equal(api.requests.length, 0, 'cursor=0时两类请求都必须停止');
|
||||
assert.equal(historyButtons(api).length, 0);
|
||||
assert.equal(api.historyLoadMore.hidden, available, '完整历史读完隐藏控件;来源待核实则保留提示');
|
||||
if (available) {
|
||||
assert.doesNotMatch(api.userOutlinePanel.innerHTML, /待核实/);
|
||||
} else {
|
||||
assert.match(historyCopy(api), /更早的记录尚待核实/);
|
||||
assert.match(api.userOutlinePanel.innerHTML, /更早的记录尚待核实/);
|
||||
assert.doesNotMatch(historyCopy(api), /不完整|未保存|无法恢复/);
|
||||
assert.doesNotMatch(api.userOutlinePanel.innerHTML, /不完整|未保存|无法恢复/);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const api = createPaginationHarness();
|
||||
api.reset();
|
||||
assert.equal(api.historyLoadMore.hidden, true, '收到历史元数据前不能误报更早记录待核实');
|
||||
assert.doesNotMatch(api.userOutlinePanel.innerHTML, /待核实/);
|
||||
assert.equal(api.userOutlineBtn.disabled, true, '没有已保存消息或待加载记录时保持空目录禁用');
|
||||
api.init(savedSnapshot(true));
|
||||
assert.equal(historyButtons(api)[0].disabled, false);
|
||||
assert.doesNotMatch(historyCopy(api), /待核实/);
|
||||
assert.doesNotMatch(api.userOutlinePanel.innerHTML, /待核实/);
|
||||
}
|
||||
|
||||
{
|
||||
const api = createHarness();
|
||||
assert.equal(api.getStableMessageKey({ role: 'assistant', turnId: 'turn-1', content: '初稿' }),
|
||||
@@ -343,4 +590,4 @@ for (const cached of [false, true]) {
|
||||
assert.equal(api.count(), 4);
|
||||
}
|
||||
|
||||
console.log('前端历史单测通过:稳定 ID、分页、重连、流式身份绑定与完成去重');
|
||||
console.log('前端历史单测通过:已保存历史分页、目录索引、来源提示、认证隔离与重试、稳定 ID、重连和流式去重');
|
||||
|
||||
1009
scripts/history-runtime-completion-unit.js
Normal file
1009
scripts/history-runtime-completion-unit.js
Normal file
File diff suppressed because it is too large
Load Diff
481
scripts/history-runtime-unit.js
Normal file
481
scripts/history-runtime-unit.js
Normal file
@@ -0,0 +1,481 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// 该回归直接调用生产运行时 factory / 事件入口,确认完整采集不再被预览限额裁剪。
|
||||
// WebSocket 仅为内存接收器;本脚本不读写真实会话,也不启动外部进程。
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const Module = require('node:module');
|
||||
const path = require('node:path');
|
||||
const { createAgentRuntime } = require('../lib/agent-runtime');
|
||||
const { createCodexAppRuntime } = require('../lib/codex-app-runtime');
|
||||
|
||||
const REPO_DIR = path.join(__dirname, '..');
|
||||
const SERVER_PATH = path.join(REPO_DIR, 'server.js');
|
||||
const PERSIST_TRUNCATED_TEXT = '[cc-web: 内容过大,已截断以保护服务稳定性]';
|
||||
const APP_TRUNCATED_TEXT = '[cc-web: 内容过长,已截断以保护服务稳定性]';
|
||||
const EXPECTED_BASELINE_FAILURES = [
|
||||
'Claude user/tool_result 长结果完整采集',
|
||||
'Codex CLI 正文与 JSON 内容块混合采集',
|
||||
];
|
||||
const invalidEventHandler = () => {
|
||||
throw new Error('运行时完整采集回归不应调用 saveSession');
|
||||
};
|
||||
|
||||
function createRuntimeHarness(options = {}) {
|
||||
const sent = [];
|
||||
const ws = {};
|
||||
const previewApi = buildProductionPreviewApi();
|
||||
const wsSend = (target, payload) => previewApi.sendRuntimePreview(target, payload);
|
||||
const agentRuntimeFactory = options.createAgentRuntime || createAgentRuntime;
|
||||
const codexAppRuntimeFactory = options.createCodexAppRuntime || createCodexAppRuntime;
|
||||
const cliRuntime = agentRuntimeFactory({
|
||||
processEnv: {},
|
||||
wsSend,
|
||||
sanitizeToolInput: previewApi.sanitizeToolInput,
|
||||
loadSession: () => null,
|
||||
saveSession: invalidEventHandler,
|
||||
});
|
||||
const codexAppRuntime = codexAppRuntimeFactory({
|
||||
wsSend,
|
||||
loadSession: () => null,
|
||||
saveSession: invalidEventHandler,
|
||||
});
|
||||
return { cliRuntime, codexAppRuntime, previewApi, ws, sent };
|
||||
}
|
||||
|
||||
function entry(agent, ws) {
|
||||
return { agent, ws, fullText: '', toolCalls: [] };
|
||||
}
|
||||
|
||||
function extractFunctionSource(source, name) {
|
||||
const start = source.indexOf(`function ${name}(`);
|
||||
assert(start >= 0, `server.js 应定义 ${name}`);
|
||||
let parenDepth = 0;
|
||||
let signatureEnd = -1;
|
||||
for (let index = start; index < source.length; index += 1) {
|
||||
if (source[index] === '(') parenDepth += 1;
|
||||
if (source[index] === ')') {
|
||||
parenDepth -= 1;
|
||||
if (parenDepth === 0) {
|
||||
signatureEnd = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert(signatureEnd > start, `${name} 应有完整签名`);
|
||||
const open = source.indexOf('{', signatureEnd);
|
||||
assert(open > start, `${name} 应有函数体`);
|
||||
let depth = 0;
|
||||
for (let index = open; index < source.length; index += 1) {
|
||||
if (source[index] === '{') depth += 1;
|
||||
if (source[index] === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return source.slice(start, index + 1);
|
||||
}
|
||||
}
|
||||
throw new Error(`${name} 函数体不完整`);
|
||||
}
|
||||
|
||||
function extractConstSource(source, name) {
|
||||
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const match = source.match(new RegExp(`^const ${escapedName} = .*;$`, 'm'));
|
||||
assert(match, `server.js 应定义 ${name}`);
|
||||
return match[0];
|
||||
}
|
||||
|
||||
function buildProductionPreviewApi() {
|
||||
const source = fs.readFileSync(SERVER_PATH, 'utf8');
|
||||
const functionNames = [
|
||||
'readPositiveIntEnv',
|
||||
'truncateTextValue',
|
||||
'sanitizePersistValue',
|
||||
'sanitizeToolCallsForPersist',
|
||||
'sanitizeMessageForPersist',
|
||||
'sanitizeMessageForTransport',
|
||||
'safeJsonParse',
|
||||
'sanitizeToolInput',
|
||||
'sendRuntimePreview',
|
||||
];
|
||||
const constantNames = [
|
||||
'SESSION_MESSAGE_CONTENT_MAX_CHARS',
|
||||
'SESSION_TOOL_INPUT_MAX_CHARS',
|
||||
'SESSION_TOOL_RESULT_MAX_CHARS',
|
||||
'SESSION_MAX_TOOL_CALLS_PER_MESSAGE',
|
||||
'SESSION_TRANSPORT_MESSAGE_CONTENT_MAX_CHARS',
|
||||
'SESSION_TRANSPORT_TOOL_INPUT_MAX_CHARS',
|
||||
'SESSION_TRANSPORT_TOOL_RESULT_MAX_CHARS',
|
||||
'SESSION_TRANSPORT_MAX_TOOL_CALLS_PER_MESSAGE',
|
||||
'PERSIST_TRUNCATED_TEXT',
|
||||
];
|
||||
return new Function('wsSend', `
|
||||
const process = { env: {} };
|
||||
${functionNames.map((name) => extractFunctionSource(source, name)).join('\n')}
|
||||
${constantNames.map((name) => extractConstSource(source, name)).join('\n')}
|
||||
return { sendRuntimePreview, sanitizeMessageForTransport, sanitizeToolInput };
|
||||
`)((target, payload) => target.received.push(payload));
|
||||
}
|
||||
|
||||
function assertPreviewStringIsBounded(value, maxChars, label) {
|
||||
assert.equal(typeof value, 'string', `${label}预览应保留字符串形状`);
|
||||
assert(value.length <= maxChars, `${label}预览不应超过 ${maxChars} 个字符`);
|
||||
assert(
|
||||
value.includes(PERSIST_TRUNCATED_TEXT) || value.includes(APP_TRUNCATED_TEXT),
|
||||
`${label}预览应标记被省略的内容,不能伪装成完整 record`
|
||||
);
|
||||
}
|
||||
|
||||
function assertPreviewIsBounded(value, maxChars, label) {
|
||||
assert(value !== null && typeof value === 'object', `${label}预览应是对象`);
|
||||
assertPreviewStringIsBounded(value.prompt, maxChars, `${label}正文`);
|
||||
assert(value.rows.length <= 81, `${label}数组预览应保持有界`);
|
||||
assert(
|
||||
value.rows.some((row) => typeof row?.__truncated === 'string'),
|
||||
`${label}数组预览应标注省略项目数`
|
||||
);
|
||||
}
|
||||
|
||||
function loadGitHeadFactory(relativePath, exportName) {
|
||||
const source = execFileSync('git', ['show', `HEAD:${relativePath}`], {
|
||||
cwd: REPO_DIR,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const filename = path.join(REPO_DIR, relativePath);
|
||||
const loadedModule = new Module(filename, module);
|
||||
loadedModule.filename = filename;
|
||||
loadedModule.paths = Module._nodeModulePaths(path.dirname(filename));
|
||||
loadedModule._compile(source, filename);
|
||||
assert.equal(typeof loadedModule.exports[exportName], 'function', `HEAD:${relativePath} 应导出 ${exportName}`);
|
||||
return loadedModule.exports[exportName];
|
||||
}
|
||||
|
||||
function assertRuntimeHistoryContract(options = {}) {
|
||||
const { cliRuntime, codexAppRuntime, previewApi, ws, sent } = createRuntimeHarness(options);
|
||||
ws.received = sent;
|
||||
const longText = '长正文'.repeat(350000) + '[正文尾部]';
|
||||
const longToolInput = '工具参数'.repeat(6000) + '[参数尾部]';
|
||||
const failures = [];
|
||||
const passed = [];
|
||||
const check = (name, callback) => {
|
||||
try {
|
||||
callback();
|
||||
passed.push(name);
|
||||
console.log(`通过:${name}`);
|
||||
} catch (error) {
|
||||
failures.push(`${name}:${error.message}`);
|
||||
console.error(`失败:${name}:${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const claudeEntry = entry('claude', ws);
|
||||
check('Claude CLI 长正文完整采集', () => {
|
||||
cliRuntime.processRuntimeEvent(claudeEntry, {
|
||||
type: 'assistant',
|
||||
message: { content: [{ type: 'text', text: longText }] },
|
||||
}, 'fixture-full');
|
||||
assert.equal(claudeEntry.fullText, longText, 'Claude 1,050,006 字符正文应原样进入生产 runtime 记录');
|
||||
assert(!claudeEntry.fullText.includes(PERSIST_TRUNCATED_TEXT), 'Claude 完整正文不应包含裁剪标记');
|
||||
});
|
||||
|
||||
const codexEntry = entry('codex', ws);
|
||||
check('Codex CLI 长正文完整采集', () => {
|
||||
cliRuntime.processRuntimeEvent(codexEntry, {
|
||||
type: 'item.completed',
|
||||
item: { id: 'codex-message', type: 'agent_message', text: longText },
|
||||
}, 'fixture-full');
|
||||
assert.equal(codexEntry.fullText, longText, 'Codex 1,050,006 字符正文应原样进入生产 runtime 记录');
|
||||
assert(!codexEntry.fullText.includes(PERSIST_TRUNCATED_TEXT), 'Codex 完整正文不应包含裁剪标记');
|
||||
});
|
||||
|
||||
const codexAppEntry = entry('codexapp', ws);
|
||||
const appTextStartIndex = sent.length;
|
||||
check('Codex App 长正文完整采集', () => {
|
||||
codexAppRuntime.processCodexAppNotification(codexAppEntry, {
|
||||
method: 'item/agentMessage/delta',
|
||||
params: { itemId: 'codexapp-message', delta: longText },
|
||||
}, 'fixture-full');
|
||||
assert.equal(codexAppEntry.fullText, longText, 'Codex App 1,050,006 字符增量应原样进入生产记录');
|
||||
assert(
|
||||
!codexAppEntry.fullText.includes(PERSIST_TRUNCATED_TEXT),
|
||||
'Codex App 完整正文不应包含裁剪标记'
|
||||
);
|
||||
});
|
||||
|
||||
const fullToolResult = '完整工具结果'.repeat(4200) + '[结果尾部]';
|
||||
const claudeToolEntry = entry('claude', ws);
|
||||
cliRuntime.processRuntimeEvent(claudeToolEntry, {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{
|
||||
type: 'tool_use',
|
||||
id: 'claude-tool',
|
||||
name: 'Read',
|
||||
input: JSON.stringify({ path: '/fixture.txt', prompt: longToolInput }),
|
||||
}],
|
||||
},
|
||||
}, 'fixture-tool');
|
||||
check('Claude CLI JSON 字符串工具参数使用生产解析并完整保留', () => {
|
||||
const parsedInput = claudeToolEntry.toolCalls.find((tool) => tool.id === 'claude-tool')?.input;
|
||||
assert.equal(parsedInput?.path, '/fixture.txt', 'Claude 工具 JSON 输入应由生产 sanitizer 解析为对象');
|
||||
assert.equal(parsedInput?.prompt, longToolInput, '生产输入解析不应裁剪工具参数长字符串');
|
||||
});
|
||||
|
||||
check('Claude user/tool_result 长结果完整采集', () => {
|
||||
cliRuntime.processRuntimeEvent(claudeToolEntry, {
|
||||
type: 'user',
|
||||
message: {
|
||||
content: [{ type: 'tool_result', tool_use_id: 'claude-tool', content: fullToolResult }],
|
||||
},
|
||||
}, 'fixture-tool');
|
||||
const claudeTool = claudeToolEntry.toolCalls.find((tool) => tool.id === 'claude-tool');
|
||||
assert.equal(claudeTool?.done, true, 'Claude user/tool_result 应结束对应工具调用');
|
||||
assert.equal(claudeTool?.result, fullToolResult, 'Claude 完整工具结果应在用户事件中进入生产记录');
|
||||
});
|
||||
|
||||
const codexToolEntry = entry('codex', ws);
|
||||
check('Codex CLI 长工具结果完整采集', () => {
|
||||
cliRuntime.processRuntimeEvent(codexToolEntry, {
|
||||
type: 'item.completed',
|
||||
item: {
|
||||
id: 'codex-tool',
|
||||
type: 'command_execution',
|
||||
command: 'printf fixture',
|
||||
status: 'completed',
|
||||
aggregated_output: fullToolResult,
|
||||
},
|
||||
}, 'fixture-tool');
|
||||
assert.equal(
|
||||
codexToolEntry.toolCalls.find((tool) => tool.id === 'codex-tool')?.result,
|
||||
fullToolResult,
|
||||
'Codex CLI 完整聚合输出应在生产记录中原样保留'
|
||||
);
|
||||
});
|
||||
|
||||
const codexLongInput = 'Codex 命令参数'.repeat(5000) + '[参数尾部]';
|
||||
const codexInputEntry = entry('codex', ws);
|
||||
check('Codex CLI 长工具输入完整采集', () => {
|
||||
cliRuntime.processRuntimeEvent(codexInputEntry, {
|
||||
type: 'item.started',
|
||||
item: {
|
||||
id: 'codex-long-command',
|
||||
type: 'command_execution',
|
||||
command: codexLongInput,
|
||||
},
|
||||
}, 'fixture-tool-input');
|
||||
assert.equal(
|
||||
codexInputEntry.toolCalls[0]?.input?.command,
|
||||
codexLongInput,
|
||||
'Codex CLI 完整工具输入不应受传输预览上限影响'
|
||||
);
|
||||
});
|
||||
|
||||
const longAppToolResult = '工具返回'.repeat(12000) + '[工具尾部]';
|
||||
const codexAppToolEntry = entry('codexapp', ws);
|
||||
const largeTool = {
|
||||
id: 'codexapp-large-tool',
|
||||
type: 'mcpToolCall',
|
||||
server: 'fixture',
|
||||
tool: 'long_payload',
|
||||
arguments: {
|
||||
prompt: longToolInput,
|
||||
rows: Array.from({ length: 150 }, (_, index) => ({ index, values: [index, index + 1] })),
|
||||
},
|
||||
result: { content: [{ type: 'text', text: longAppToolResult }] },
|
||||
};
|
||||
check('Codex App 超限工具输入、结果与数组完整采集', () => {
|
||||
codexAppRuntime.processCodexAppNotification(codexAppToolEntry, {
|
||||
method: 'item/completed',
|
||||
params: { item: largeTool },
|
||||
}, 'fixture-tool');
|
||||
assert.deepEqual(
|
||||
codexAppToolEntry.toolCalls[0]?.input.arguments,
|
||||
largeTool.arguments,
|
||||
'Codex App 完整输入对象、长字符串和大数组都应在 entry 原样保留'
|
||||
);
|
||||
assert.equal(
|
||||
codexAppToolEntry.toolCalls[0]?.result,
|
||||
longAppToolResult,
|
||||
'Codex App 完整工具结果应原样序列化'
|
||||
);
|
||||
const largeToolInputPreview = sent.find((payload) => payload.toolUseId === 'codexapp-large-tool' && payload.type === 'tool_start');
|
||||
const largeToolResultPreview = sent.find((payload) => payload.toolUseId === 'codexapp-large-tool' && payload.type === 'tool_end');
|
||||
assert(largeToolInputPreview, 'Codex App 启动的大工具调用应产生真实 WebSocket 输入预览');
|
||||
assert(largeToolResultPreview, 'Codex App 完成的大工具调用应产生真实 WebSocket 结果预览');
|
||||
assertPreviewIsBounded(largeToolInputPreview.input.arguments, 8 * 1024, 'Codex App 工具输入');
|
||||
assertPreviewStringIsBounded(largeToolResultPreview.result, 16 * 1024, 'Codex App 工具结果');
|
||||
});
|
||||
|
||||
const manyToolsEntry = entry('codexapp', ws);
|
||||
const toolLimit = 40;
|
||||
const toolCount = 130;
|
||||
check(`超出 WebSocket 预览条数上限 ${toolLimit} 的 ${toolCount} 条工具仍完整采集`, () => {
|
||||
for (let index = 0; index < toolCount; index += 1) {
|
||||
codexAppRuntime.processCodexAppNotification(manyToolsEntry, {
|
||||
method: 'item/completed',
|
||||
params: {
|
||||
item: {
|
||||
id: `codexapp-tool-${index}`,
|
||||
type: 'mcpToolCall',
|
||||
server: 'fixture',
|
||||
tool: 'short_payload',
|
||||
arguments: { index, text: `输入-${index}` },
|
||||
result: { content: [{ type: 'text', text: `结果-${index}` }] },
|
||||
},
|
||||
},
|
||||
}, 'fixture-many-tools');
|
||||
}
|
||||
assert.equal(
|
||||
manyToolsEntry.toolCalls.length,
|
||||
toolCount,
|
||||
`超过传输预览条数上限 ${toolLimit} 的 ${toolCount} 条工具都应保留在 entry`
|
||||
);
|
||||
assert.equal(manyToolsEntry.toolCalls[0]?.id, 'codexapp-tool-0', '工具数组应保留首项及原始顺序');
|
||||
assert.equal(
|
||||
manyToolsEntry.toolCalls[toolCount - 1]?.result,
|
||||
`结果-${toolCount - 1}`,
|
||||
'工具数组应保留最后一项完整结果'
|
||||
);
|
||||
assert.equal(
|
||||
sent.filter((payload) => payload.type === 'tool_end' && /^codexapp-tool-/.test(payload.toolUseId)).length,
|
||||
toolCount,
|
||||
'每条工具事件均应发送生产预览,完整采集不可让工具数超限跳过事件'
|
||||
);
|
||||
});
|
||||
|
||||
const mixedCodexEntry = entry('codex', ws);
|
||||
check('Codex CLI 正文与 JSON 内容块混合采集', () => {
|
||||
const mixedParts = [
|
||||
['mixed-1', '开头正文'],
|
||||
['mixed-2', JSON.stringify([{ type: 'text', text: '内容块中正文' }])],
|
||||
['mixed-3', '末尾正文'],
|
||||
];
|
||||
for (const [itemId, text] of mixedParts) {
|
||||
cliRuntime.processRuntimeEvent(mixedCodexEntry, {
|
||||
type: 'item.completed',
|
||||
item: { id: itemId, type: 'agent_message', text },
|
||||
}, 'fixture-mixed');
|
||||
}
|
||||
assert(mixedCodexEntry.fullText.includes('开头正文'), 'Codex CLI 完整正文应保留 JSON 内容块之前的文本');
|
||||
assert(mixedCodexEntry.fullText.includes('末尾正文'), 'Codex CLI 完整正文应保留 JSON 内容块之后的文本');
|
||||
assert(
|
||||
mixedCodexEntry.contentBlocks?.some((block) => block.text === '内容块中正文'),
|
||||
'Codex CLI 正文数组应保留完整的 JSON 内容块'
|
||||
);
|
||||
const consumerSelectedContent = mixedCodexEntry.contentBlocks || mixedCodexEntry.fullText;
|
||||
const selectedText = typeof consumerSelectedContent === 'string'
|
||||
? consumerSelectedContent
|
||||
: JSON.stringify(consumerSelectedContent);
|
||||
assert(
|
||||
selectedText.includes('开头正文') && selectedText.includes('末尾正文'),
|
||||
'Codex CLI 混合记录按生产完成路径选择 contentBlocks || fullText 时应不丢两端文本'
|
||||
);
|
||||
});
|
||||
|
||||
const completedAppEntry = entry('codexapp', ws);
|
||||
check('Codex App 增量与完成事件补尾且不重复', () => {
|
||||
codexAppRuntime.processCodexAppNotification(completedAppEntry, {
|
||||
method: 'item/agentMessage/delta',
|
||||
params: { itemId: 'codexapp-finished-message', delta: '流式正文' },
|
||||
}, 'fixture-complete');
|
||||
codexAppRuntime.processCodexAppNotification(completedAppEntry, {
|
||||
method: 'item/completed',
|
||||
params: {
|
||||
item: {
|
||||
id: 'codexapp-finished-message',
|
||||
type: 'agentMessage',
|
||||
text: '流式正文与完成事件补尾',
|
||||
},
|
||||
},
|
||||
}, 'fixture-complete');
|
||||
assert.equal(
|
||||
completedAppEntry.fullText,
|
||||
'流式正文与完成事件补尾',
|
||||
'完成事件只应将尚未收到的尾部追加到完整正文'
|
||||
);
|
||||
});
|
||||
|
||||
check('生产 WebSocket 工具预览有界且 Codex App 正文分片完整', () => {
|
||||
const cliWireText = sent.find((payload) => payload.type === 'text_delta' && payload.text?.includes('长正文'));
|
||||
assert(cliWireText, 'CLI 长正文应发出真实 WebSocket 增量');
|
||||
assertPreviewStringIsBounded(cliWireText.text, 16 * 1024, 'CLI 文本增量');
|
||||
const claudeWireResult = sent.find((payload) => payload.type === 'tool_end' && payload.toolUseId === 'claude-tool');
|
||||
if (claudeWireResult) assertPreviewStringIsBounded(claudeWireResult.result, 16 * 1024, 'Claude 工具结果');
|
||||
else assert(failures.includes('Claude user/tool_result 长结果完整采集'), 'Claude 用户工具结果缺少 tool_end 时应由对应事件断言报告');
|
||||
const appChunks = sent.slice(appTextStartIndex).filter((payload) => (
|
||||
payload.type === 'text_delta'
|
||||
&& payload.sessionId === 'fixture-full'
|
||||
));
|
||||
assert(appChunks.length > 1, 'Codex App 大增量应分片发送');
|
||||
assert(appChunks.every((payload) => payload.text.length <= 16 * 1024), 'Codex App 每片正文保持传输大小上限');
|
||||
assert.equal(appChunks.map((payload) => payload.text).join(''), longText, 'Codex App 所有分片拼接后完整保留正文');
|
||||
});
|
||||
|
||||
check('完成及历史正文读取保留 130 条工具和完整输入结果', () => {
|
||||
// 历史与 done 读取完整记录;实时 tool_start/tool_end 的预览上限另有断言。
|
||||
const completeTools = manyToolsEntry.toolCalls.map((tool, index) => index === 65
|
||||
? { ...tool, input: largeTool.arguments, result: longAppToolResult }
|
||||
: tool);
|
||||
const transportMessage = previewApi.sanitizeMessageForTransport({
|
||||
role: 'assistant',
|
||||
content: longText,
|
||||
toolCalls: completeTools,
|
||||
});
|
||||
assert.equal(transportMessage.content, longText, '历史与完成是正文读取入口,必须保留完整正文供折叠和复制');
|
||||
assert.equal(transportMessage.toolCalls.length, toolCount, '完成和历史不可用工具预览条数上限替代完整列表');
|
||||
assert.deepEqual(transportMessage.toolCalls, completeTools, '完整工具数量、顺序、输入对象和结果不得缩水');
|
||||
assert.equal(transportMessage.toolCalls[65].input.prompt, longToolInput, '完成及历史工具输入长字符串完整读回');
|
||||
assert.equal(transportMessage.toolCalls[65].result, longAppToolResult, '完成及历史工具结果长字符串完整读回');
|
||||
assert(!transportMessage.toolCalls.some(tool => tool.id === 'ccweb-toolcalls-truncated'), '完整读取不得注入工具省略标记');
|
||||
});
|
||||
|
||||
if (failures.length > 0 && !options.returnFailures) {
|
||||
throw new Error(`运行时完整采集回归发现 ${failures.length} 个失败:\n${failures.map((message) => `- ${message}`).join('\n')}`);
|
||||
}
|
||||
return {
|
||||
checkedTextChars: longText.length,
|
||||
checkedToolInputChars: longToolInput.length,
|
||||
checkedToolResultChars: longAppToolResult.length,
|
||||
checkedToolCalls: toolCount,
|
||||
webSocketPreviews: sent.length,
|
||||
passed,
|
||||
failures,
|
||||
};
|
||||
}
|
||||
|
||||
function runBaselineHead() {
|
||||
const result = assertRuntimeHistoryContract({
|
||||
createAgentRuntime: loadGitHeadFactory('lib/agent-runtime.js', 'createAgentRuntime'),
|
||||
createCodexAppRuntime: loadGitHeadFactory('lib/codex-app-runtime.js', 'createCodexAppRuntime'),
|
||||
returnFailures: true,
|
||||
});
|
||||
const failedNames = new Set(result.failures.map((failure) => failure.split(':', 1)[0]));
|
||||
const missingProofs = EXPECTED_BASELINE_FAILURES.filter((name) => !failedNames.has(name));
|
||||
assert.equal(
|
||||
missingProofs.length,
|
||||
0,
|
||||
`HEAD 基线未复现预期缺陷:${missingProofs.join('、') || '未知'}`
|
||||
);
|
||||
console.log(`通过:HEAD 旧版已复现预期失败(${JSON.stringify(result.failures)})`);
|
||||
}
|
||||
|
||||
function run() {
|
||||
if (process.argv.includes('--baseline-head')) {
|
||||
runBaselineHead();
|
||||
return;
|
||||
}
|
||||
const result = assertRuntimeHistoryContract();
|
||||
console.log(`通过:生产 runtime 完整采集与有界传输(${JSON.stringify({
|
||||
checkedTextChars: result.checkedTextChars,
|
||||
checkedToolInputChars: result.checkedToolInputChars,
|
||||
checkedToolResultChars: result.checkedToolResultChars,
|
||||
checkedToolCalls: result.checkedToolCalls,
|
||||
webSocketPreviews: result.webSocketPreviews,
|
||||
passed: result.passed.length,
|
||||
})})`);
|
||||
}
|
||||
|
||||
if (require.main === module) run();
|
||||
|
||||
module.exports = { assertRuntimeHistoryContract };
|
||||
@@ -792,6 +792,40 @@ function emitPartialCapacityOutput(thread, turnId) {
|
||||
});
|
||||
}
|
||||
|
||||
// 多次延续重试夹具:正文首段超过提示及快照上限,每轮故意复用原生 item ID。
|
||||
function emitRetryHistoryPart(thread, turnId, label) {
|
||||
const padding = label === 'A' ? '甲'.repeat(220000) : '乙'.repeat(15000);
|
||||
const text = `retry-history-${label}-begin\n${padding}\nretry-history-${label}-end`;
|
||||
for (let offset = 0; offset < text.length; offset += 8192) {
|
||||
send({
|
||||
method: 'item/agentMessage/delta',
|
||||
params: { threadId: thread.id, turnId, itemId: 'retry-history-agent', delta: text.slice(offset, offset + 8192) },
|
||||
});
|
||||
}
|
||||
send({
|
||||
method: 'item/completed',
|
||||
params: { threadId: thread.id, turnId, item: { id: 'retry-history-agent', type: 'agentMessage', text } },
|
||||
});
|
||||
send({
|
||||
method: 'item/started',
|
||||
params: { threadId: thread.id, turnId, item: { id: 'retry-history-tool', type: 'commandExecution',
|
||||
command: `fixture-${label}`, status: 'inProgress' } },
|
||||
});
|
||||
send({
|
||||
method: 'item/commandExecution/outputDelta',
|
||||
params: { threadId: thread.id, turnId, itemId: 'retry-history-tool', delta: `tool-${label}-full-result\n${'输出'.repeat(20000)}` },
|
||||
});
|
||||
if (label !== 'C') {
|
||||
emitCapacityError(thread, turnId);
|
||||
return;
|
||||
}
|
||||
send({
|
||||
method: 'turn/completed',
|
||||
params: { threadId: thread.id, turn: { id: turnId, status: 'completed', items: [] } },
|
||||
});
|
||||
thread.activeTurnId = null;
|
||||
}
|
||||
|
||||
function completeGuidedInputTurn(thread, turnId) {
|
||||
requestClient('item/tool/requestUserInput', {
|
||||
threadId: thread.id,
|
||||
@@ -924,6 +958,20 @@ function startTurn(params) {
|
||||
return { turn: { id: turnId, status: 'running', items: [] } };
|
||||
}
|
||||
|
||||
if (/^codexapp late completion first$/i.test(text)) {
|
||||
// 保持 turn 处于运行中,直到后续 steer 到达时才结束,用于复现“发送瞬间 turn 结束”的竞态。
|
||||
send({
|
||||
method: 'item/agentMessage/delta',
|
||||
params: {
|
||||
threadId: thread.id,
|
||||
turnId,
|
||||
itemId: 'agent-msg',
|
||||
delta: 'late completion fixture ready\n',
|
||||
},
|
||||
});
|
||||
return { turn: { id: turnId, status: 'running', items: [] } };
|
||||
}
|
||||
|
||||
if (/^codexapp expected turn mismatch first$/i.test(text)) {
|
||||
send({
|
||||
method: 'item/agentMessage/delta',
|
||||
@@ -953,6 +1001,14 @@ function startTurn(params) {
|
||||
}
|
||||
}
|
||||
|
||||
if (/codexapp retry full history/i.test(text)) {
|
||||
const retryKey = retryScenarioKey(text, 'codexapp retry full history');
|
||||
const attempts = (thread.capacityRetryAttempts.get(retryKey) || 0) + 1;
|
||||
thread.capacityRetryAttempts.set(retryKey, attempts);
|
||||
emitRetryHistoryPart(thread, turnId, attempts === 1 ? 'A' : attempts === 2 ? 'B' : 'C');
|
||||
return { turn: { id: turnId, status: 'running', items: [] } };
|
||||
}
|
||||
|
||||
if (/codexapp capacity retry/i.test(text)) {
|
||||
const retryKey = retryScenarioKey(text, 'codexapp capacity retry');
|
||||
const attempts = (thread.capacityRetryAttempts.get(retryKey) || 0) + 1;
|
||||
@@ -1028,6 +1084,21 @@ function startTurn(params) {
|
||||
return { turn: { id: turnId, status: 'running', items: [] } };
|
||||
}
|
||||
|
||||
if (text === 'slow codexapp prompt') {
|
||||
// 连续插入回归由最后一条 steer 决定完成时机,不依赖保存耗时小于定时窗口。
|
||||
thread.heldSteerFixture = { turnId, text };
|
||||
send({
|
||||
method: 'item/agentMessage/delta',
|
||||
params: {
|
||||
threadId: thread.id,
|
||||
turnId,
|
||||
itemId: 'agent-msg',
|
||||
delta: 'active steer fixture ready\n',
|
||||
},
|
||||
});
|
||||
return { turn: { id: turnId, status: 'running', items: [] } };
|
||||
}
|
||||
|
||||
const delay = /recover/i.test(text) ? 5000 : /slow/i.test(text) ? 900 : 80;
|
||||
if (/recover/i.test(text)) {
|
||||
send({
|
||||
@@ -1078,11 +1149,33 @@ function interruptTurn(params) {
|
||||
|
||||
function steerTurn(params) {
|
||||
const thread = ensureThread(params.threadId, params);
|
||||
if (!thread.activeTurnId) {
|
||||
const text = textFromInput(params.input);
|
||||
if (/^codexapp retry after completed(?:\n|$)/.test(text) && thread.activeTurnId) {
|
||||
return { error: { code: -32603, message: 'temporary steer rejection' } };
|
||||
}
|
||||
if (/codexapp steer after completion/i.test(text) && thread.activeTurnId) {
|
||||
// 模拟 steer 到达时 turn 恰好结束:先发出 turn/completed,再返回“无活动 turn”。
|
||||
completeTurn(thread, thread.activeTurnId, 'codexapp late completion first');
|
||||
const expectedTurnId = typeof params.expectedTurnId === 'string' ? params.expectedTurnId.trim() : '';
|
||||
return {
|
||||
error: {
|
||||
code: -32001,
|
||||
message: 'no active turn to steer',
|
||||
message: expectedTurnId
|
||||
? `expected active turn id \`${expectedTurnId}\`: no active turn`
|
||||
: 'no active turn to steer',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (!thread.activeTurnId) {
|
||||
// 对齐真实 app-server:带 expectedTurnId 时返回 expected active turn id 形式,
|
||||
// 不带期望 id 时才返回 no active turn to steer。
|
||||
const expectedTurnId = typeof params.expectedTurnId === 'string' ? params.expectedTurnId.trim() : '';
|
||||
return {
|
||||
error: {
|
||||
code: -32001,
|
||||
message: expectedTurnId
|
||||
? `expected active turn id \`${expectedTurnId}\`: no active turn`
|
||||
: 'no active turn to steer',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1094,7 +1187,6 @@ function steerTurn(params) {
|
||||
},
|
||||
};
|
||||
}
|
||||
const text = textFromInput(params.input);
|
||||
if (text) thread.steers.push(text);
|
||||
send({
|
||||
method: 'item/agentMessage/delta',
|
||||
@@ -1105,6 +1197,11 @@ function steerTurn(params) {
|
||||
delta: `\n[steer accepted: ${text}]`,
|
||||
},
|
||||
});
|
||||
const held = thread.heldSteerFixture;
|
||||
if (held?.turnId === thread.activeTurnId && text === 'runtime steer insert') {
|
||||
thread.heldSteerFixture = null;
|
||||
thread.timer = setTimeout(() => completeTurn(thread, held.turnId, held.text), 80);
|
||||
}
|
||||
return { result: {} };
|
||||
}
|
||||
|
||||
|
||||
@@ -3051,6 +3051,13 @@ function assertSidebarTitleRefreshStormContract() {
|
||||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const createItemSource = extractFunctionSource(frontendSource, 'createSessionListItem');
|
||||
const renderListSource = extractFunctionSource(frontendSource, 'renderSessionList');
|
||||
const dependencySource = [
|
||||
'sessionPassesMcpVisibilityFilter',
|
||||
'syncMcpSessionVisibilityToggle',
|
||||
'normalizeGiteaSource',
|
||||
'giteaResourceLabel',
|
||||
]
|
||||
.map((name) => extractFunctionSource(frontendSource, name)).join('\n');
|
||||
const optionalSources = [
|
||||
'buildSessionListStructureSignature',
|
||||
'refreshSessionListRelativeTimes',
|
||||
@@ -3196,6 +3203,7 @@ function assertSidebarTitleRefreshStormContract() {
|
||||
let sessions = [];
|
||||
let currentSessionId = 's1';
|
||||
let sessionSearchQuery = '';
|
||||
let showMcpSessions = true;
|
||||
let lastSessionListStructureSignature = '';
|
||||
let collapseOlderSessions = false;
|
||||
let failOldSessionLoadMoreOnce = false;
|
||||
@@ -3206,6 +3214,7 @@ function assertSidebarTitleRefreshStormContract() {
|
||||
const pendingNotesByTarget = new Map();
|
||||
const queuedMessagesByTarget = new Map();
|
||||
const localStorage = { removeItem() {}, setItem() {}, getItem() { return null; } };
|
||||
const mcpSessionVisibilityToggle = new MiniElement('button');
|
||||
const sessionList = new MiniElement('div');
|
||||
sessionList.clearCount = 0;
|
||||
Object.defineProperty(sessionList, 'innerHTML', {
|
||||
@@ -3319,6 +3328,7 @@ function assertSidebarTitleRefreshStormContract() {
|
||||
function startEditSessionTitle() {}
|
||||
|
||||
${optionalSources}
|
||||
${dependencySource}
|
||||
${createItemSource}
|
||||
${renderListSource}
|
||||
|
||||
@@ -3904,10 +3914,14 @@ function assertSessionSwitchResilienceContract() {
|
||||
'Server wsSend should guard JSON serialization failures'
|
||||
);
|
||||
assert(
|
||||
runtimeSource.includes('CC_WEB_RUNTIME_FULL_TEXT_MAX_CHARS') &&
|
||||
runtimeSource.includes('function appendCappedText') &&
|
||||
runtimeSource.includes('entry.fullText = appendCappedText'),
|
||||
'Classic runtime should cap accumulated fullText in memory'
|
||||
runtimeSource.includes('function appendFullText') &&
|
||||
runtimeSource.includes('entry.fullText = appendFullText'),
|
||||
'Classic runtime should retain complete fullText in the production record'
|
||||
);
|
||||
assert(
|
||||
!runtimeSource.includes('CC_WEB_RUNTIME_FULL_TEXT_MAX_CHARS') &&
|
||||
!runtimeSource.includes('function appendCappedText'),
|
||||
'Classic runtime must not discard the only complete record before archive persistence'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4734,7 +4748,7 @@ function assertServerSessionHistoryRequestIdContract() {
|
||||
}
|
||||
|
||||
function assertHistoryRecallContract() {
|
||||
for (const script of ['session-history-unit.js', 'codex-rollouts-unit.js', 'history-frontend-unit.js']) {
|
||||
for (const script of ['session-history-store-unit.js', 'session-history-unit.js', 'history-runtime-unit.js', 'history-runtime-completion-unit.js', 'codex-rollouts-unit.js', 'history-frontend-unit.js', 'codexapp-retry-history-unit.js', 'streaming-retry-frontend-unit.js', 'assistant-bubble-controls-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}`);
|
||||
}
|
||||
@@ -4772,7 +4786,7 @@ function assertHistoryRecallContract() {
|
||||
/function buildUserOutlineItems[\s\S]*?currentOutlineHistoryState\.entries/.test(frontendSource),
|
||||
'User-message locator should merge indexed history instead of reading only rendered DOM');
|
||||
assert(
|
||||
/userOutlineBtn\.disabled\s*=\s*!historyState\.loading[\s\S]*?historyState\.cursor\s*> 0[\s\S]*?historyState\.available/.test(frontendSource),
|
||||
/userOutlineBtn\.disabled\s*=\s*historyState\.cursor\s*<=\s*0\s*&&\s*!historyStatus/.test(frontendSource),
|
||||
'Locator button should remain openable when only older user messages are available');
|
||||
assert(
|
||||
/messagesDiv\.innerHTML = '';\s*if \(historyLoadMore\) messagesDiv\.appendChild\(historyLoadMore\)/.test(frontendSource),
|
||||
@@ -4978,6 +4992,20 @@ function assertCodexAppStaleRunningRecoveryContract() {
|
||||
steerBlock.includes('mcpContext: entry.mcpContext || options.mcpContext || {}'),
|
||||
'Stale steer recovery should prefer the original active entry MCP context'
|
||||
);
|
||||
const noActiveTurnBlock = extractFunctionSource(serverSource, 'isCodexAppNoActiveTurnError');
|
||||
const matchesNoActiveTurn = new Function(`${noActiveTurnBlock}; return isCodexAppNoActiveTurnError;`)();
|
||||
assert(
|
||||
matchesNoActiveTurn(new Error('no active turn to steer')),
|
||||
'Legacy no-active-turn text should trigger stale steer recovery'
|
||||
);
|
||||
assert(
|
||||
matchesNoActiveTurn(new Error('expected active turn id `turn-1`: no active turn')),
|
||||
'Real app-server expected-active-turn text should trigger stale steer recovery'
|
||||
);
|
||||
assert(
|
||||
!matchesNoActiveTurn(new Error('expectedTurnId does not match active turn')),
|
||||
'Expected-turn mismatch must not be treated as a finished turn'
|
||||
);
|
||||
}
|
||||
|
||||
function assertCodexAppGoalLifecycleContract() {
|
||||
@@ -5378,7 +5406,7 @@ async function runCodexAppStaleRunningRegression(options = {}) {
|
||||
msg.type === 'codex_app_steer_status' &&
|
||||
msg.sessionId === sessionId &&
|
||||
msg.clientMessageId === 'regression-stale-running-follow-up' &&
|
||||
msg.status === 'inserted'
|
||||
msg.status === 'sent'
|
||||
), 5000);
|
||||
assert(!/失败/.test(recoveredStatus.message || ''), 'Recovered stale steer should update the UI as a non-failure');
|
||||
const recoveredHint = await nextMessage(messages, ws, (msg) => (
|
||||
@@ -5428,9 +5456,256 @@ async function runCodexAppStaleRunningRegression(options = {}) {
|
||||
assert(staleAssistantIndex < followUpUserIndex && followUpUserIndex < recoveredAssistantIndex, 'Recovered history should keep stale assistant before follow-up user before recovered assistant');
|
||||
assert(!fs.existsSync(path.join(sessionsDir, `${sessionId}-run`)), 'Recovered follow-up should clean the Codex App run directory after completion');
|
||||
|
||||
// 竞态场景:发送引导消息的瞬间旧 turn 刚好结束(turn/completed 先到、steer 失败后到),
|
||||
// 服务端应恢复旧输出顺序并自动开启新一轮对话。
|
||||
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: homeDir, mode: 'yolo' }));
|
||||
const lateSession = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === homeDir && msg.sessionId !== sessionId
|
||||
));
|
||||
ws.send(JSON.stringify({
|
||||
type: 'message',
|
||||
text: 'codexapp late completion first',
|
||||
sessionId: lateSession.sessionId,
|
||||
mode: 'yolo',
|
||||
agent: 'codexapp',
|
||||
}));
|
||||
await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'text_delta' && msg.sessionId === lateSession.sessionId && /late completion fixture ready/.test(msg.text || '')
|
||||
), 5000);
|
||||
await sleep(150);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'message',
|
||||
text: 'codexapp steer after completion',
|
||||
sessionId: lateSession.sessionId,
|
||||
mode: 'yolo',
|
||||
agent: 'codexapp',
|
||||
clientMessageId: 'regression-steer-after-completion',
|
||||
}));
|
||||
const latePending = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'codex_app_steer_status' &&
|
||||
msg.sessionId === lateSession.sessionId &&
|
||||
msg.clientMessageId === 'regression-steer-after-completion' &&
|
||||
msg.status === 'pending'
|
||||
), 5000);
|
||||
assert(latePending.status === 'pending', 'Steer-after-completion should first enter pending status');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === lateSession.sessionId, 5000);
|
||||
const lateRecovered = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'codex_app_steer_status' &&
|
||||
msg.sessionId === lateSession.sessionId &&
|
||||
msg.clientMessageId === 'regression-steer-after-completion' &&
|
||||
msg.status === 'sent'
|
||||
), 5000);
|
||||
assert(/已转为新对话/.test(lateRecovered.message || ''), 'Steer-after-completion should recover as a replacement turn');
|
||||
await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'resume_generating' && msg.sessionId === lateSession.sessionId
|
||||
), 5000);
|
||||
const lateHint = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'system_message' && msg.sessionId === lateSession.sessionId && /已自动开始新一轮对话/.test(msg.message || '')
|
||||
), 5000);
|
||||
assert(lateHint.transient === true, 'Steer-after-completion hint should be transient');
|
||||
await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'text_delta' && msg.sessionId === lateSession.sessionId && /Codex App mock handled: codexapp steer after completion/.test(msg.text || '')
|
||||
), 5000);
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === lateSession.sessionId, 5000);
|
||||
assert(
|
||||
!messages.some((msg) => msg.code === 'codexapp_steer_failed' && msg.sessionId === lateSession.sessionId),
|
||||
'Steer-after-completion should not emit codexapp_steer_failed'
|
||||
);
|
||||
const lateStored = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${lateSession.sessionId}.json`), 'utf8'));
|
||||
assert(
|
||||
lateStored.messages.filter((message) => message.role === 'user' && message.content === 'codexapp steer after completion').length === 1,
|
||||
'Steer-after-completion user message should persist exactly once'
|
||||
);
|
||||
const lateOldAssistantIndex = lateStored.messages.findIndex((message) => message.role === 'assistant' && /Codex App mock handled: codexapp late completion first/.test(String(message.content || '')));
|
||||
const lateUserIndex = lateStored.messages.findIndex((message) => message.role === 'user' && message.id === 'regression-steer-after-completion');
|
||||
const lateNewAssistantIndex = lateStored.messages.findIndex((message) => message.role === 'assistant' && /Codex App mock handled: codexapp steer after completion/.test(String(message.content || '')));
|
||||
assert(
|
||||
lateOldAssistantIndex >= 0 && lateUserIndex >= 0 && lateNewAssistantIndex >= 0,
|
||||
'Steer-after-completion should persist both turns and the steer message'
|
||||
);
|
||||
assert(
|
||||
lateOldAssistantIndex < lateUserIndex && lateUserIndex < lateNewAssistantIndex,
|
||||
'Steer-after-completion should keep old assistant before steer user before replacement assistant'
|
||||
);
|
||||
const lateResumeIndex = receivedMessages.findIndex((msg) => msg.type === 'resume_generating' && msg.sessionId === lateSession.sessionId);
|
||||
const lateDeltaIndex = receivedMessages.findIndex((msg) => msg.type === 'text_delta' && msg.sessionId === lateSession.sessionId && /Codex App mock handled: codexapp steer after completion/.test(msg.text || ''));
|
||||
assert(
|
||||
lateResumeIndex >= 0 && lateResumeIndex < lateDeltaIndex,
|
||||
'Steer-after-completion should restore generating UI before its replacement output'
|
||||
);
|
||||
|
||||
// 表单答案同样应在旧 turn 已结束时自动转新对话,而不是停留在“插入失败”。
|
||||
const promptInternalToken = 'StaleRunningMcp!234';
|
||||
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: homeDir, mode: 'yolo' }));
|
||||
const promptSession = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === homeDir
|
||||
&& msg.sessionId !== sessionId && msg.sessionId !== lateSession.sessionId
|
||||
));
|
||||
ws.send(JSON.stringify({
|
||||
type: 'message',
|
||||
text: 'codexapp stale running first',
|
||||
sessionId: promptSession.sessionId,
|
||||
mode: 'yolo',
|
||||
agent: 'codexapp',
|
||||
}));
|
||||
await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'text_delta' && msg.sessionId === promptSession.sessionId && /Codex App stale turn output/.test(msg.text || '')
|
||||
), 5000);
|
||||
await sleep(150);
|
||||
const promptUserResult = await callInternalMcp(port, promptInternalToken, {
|
||||
tool: 'ccweb_prompt_user',
|
||||
sourceSessionId: promptSession.sessionId,
|
||||
sourceHopCount: 0,
|
||||
args: {
|
||||
title: 'Stale 表单',
|
||||
questions: [
|
||||
{
|
||||
id: 'stale_choice',
|
||||
title: '继续方式',
|
||||
question: '在旧 turn 已结束时提交表单。',
|
||||
required: true,
|
||||
options: [
|
||||
{ id: 'continue', label: '继续', answerText: '请继续处理 stale 表单。' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
assert(promptUserResult.status === 200 && promptUserResult.body?.ok, `Stale prompt user should render: ${JSON.stringify(promptUserResult.body)}`);
|
||||
const promptRendered = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_message' &&
|
||||
msg.sessionId === promptSession.sessionId &&
|
||||
msg.message?.ccwebPrompt?.id === promptUserResult.body.promptId
|
||||
), 5000);
|
||||
assert(promptRendered.message.ccwebPrompt.status === 'pending', 'Stale prompt should start pending');
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ccweb_prompt_user_response',
|
||||
sessionId: promptSession.sessionId,
|
||||
promptId: promptUserResult.body.promptId,
|
||||
answers: {
|
||||
stale_choice: {
|
||||
selectedOptionIds: ['continue'],
|
||||
answerText: '请继续处理 stale 表单。',
|
||||
},
|
||||
},
|
||||
}));
|
||||
const promptRemoved = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'ccweb_prompt_user_remove' &&
|
||||
msg.sessionId === promptSession.sessionId &&
|
||||
msg.promptId === promptUserResult.body.promptId
|
||||
), 5000);
|
||||
assert(promptRemoved.prompt?.status === 'submitted', 'Stale prompt should be marked submitted');
|
||||
const promptSteerBubble = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_message' &&
|
||||
msg.sessionId === promptSession.sessionId &&
|
||||
msg.message?.role === 'user' &&
|
||||
msg.message?.codexAppSteerStatus === 'pending'
|
||||
), 5000);
|
||||
assert(/请继续处理 stale 表单/.test(promptSteerBubble.message.content || ''), 'Stale prompt answer bubble should carry the submitted answer');
|
||||
const promptRecovered = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'codex_app_steer_status' &&
|
||||
msg.sessionId === promptSession.sessionId &&
|
||||
msg.status === 'sent' && /已转为新对话/.test(msg.message || '')
|
||||
), 5000);
|
||||
assert(promptRecovered.status === 'sent', 'Stale prompt answer should recover as a replacement turn');
|
||||
await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'resume_generating' && msg.sessionId === promptSession.sessionId
|
||||
), 5000);
|
||||
await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'text_delta' && msg.sessionId === promptSession.sessionId && /请继续处理 stale 表单/.test(msg.text || '')
|
||||
), 5000);
|
||||
// 降级收敛旧 entry 时也会发一个 done,这里要等带新答案输出的那个完成回执。
|
||||
await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'done' && msg.sessionId === promptSession.sessionId &&
|
||||
/请继续处理 stale 表单/.test(String(msg.assistantMessage?.content || ''))
|
||||
), 5000);
|
||||
const promptStored = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${promptSession.sessionId}.json`), 'utf8'));
|
||||
const promptAnswerUsers = promptStored.messages.filter((message) => (
|
||||
message.role === 'user' && /请继续处理 stale 表单/.test(String(message.content || ''))
|
||||
));
|
||||
assert(promptAnswerUsers.length === 1, 'Stale prompt answer should persist exactly once');
|
||||
assert(
|
||||
!promptStored.messages.some((message) => message.ccwebPrompt?.id === promptUserResult.body.promptId),
|
||||
'Submitted stale prompt should be removed from history'
|
||||
);
|
||||
const promptStaleAssistantIndex = promptStored.messages.findIndex((message) => (
|
||||
message.role === 'assistant' && /Codex App stale turn output/.test(String(message.content || ''))
|
||||
));
|
||||
const promptAnswerUserIndex = promptStored.messages.findIndex((message) => (
|
||||
message.role === 'user' && /请继续处理 stale 表单/.test(String(message.content || ''))
|
||||
));
|
||||
const promptNewAssistantIndex = promptStored.messages.findIndex((message) => (
|
||||
message.role === 'assistant' && /请继续处理 stale 表单/.test(String(message.content || ''))
|
||||
));
|
||||
assert(
|
||||
promptStaleAssistantIndex >= 0 && promptAnswerUserIndex >= 0 && promptNewAssistantIndex >= 0,
|
||||
'Stale prompt recovery should persist old output, answer message and replacement output'
|
||||
);
|
||||
assert(
|
||||
promptStaleAssistantIndex < promptAnswerUserIndex && promptAnswerUserIndex < promptNewAssistantIndex,
|
||||
'Stale prompt recovery should keep old assistant before answer user before replacement assistant'
|
||||
);
|
||||
|
||||
// 插入确实失败后,旧轮结束,再用同一条消息与附件重试:普通发送必须有确认回执。
|
||||
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: homeDir, mode: 'yolo' }));
|
||||
const retrySession = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === homeDir
|
||||
&& ![sessionId, lateSession.sessionId, promptSession.sessionId].includes(msg.sessionId)
|
||||
));
|
||||
ws.send(JSON.stringify({
|
||||
type: 'message', text: 'codexapp slow retry first', sessionId: retrySession.sessionId,
|
||||
mode: 'yolo', agent: 'codexapp',
|
||||
}));
|
||||
await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_list' && msg.sessions.some((session) => session.id === retrySession.sessionId && session.isRunning)
|
||||
), 5000);
|
||||
const retryAttachment = await uploadAttachment(port, token, {
|
||||
filename: 'finished-retry.png', mime: 'image/png', data: Buffer.from('finished-retry-image'),
|
||||
});
|
||||
const retryPayload = {
|
||||
type: 'message', text: 'codexapp retry after completed', sessionId: retrySession.sessionId,
|
||||
mode: 'plan', agent: 'codexapp', clientMessageId: 'regression-finished-steer-retry',
|
||||
codexAppSteer: true, attachments: [retryAttachment],
|
||||
};
|
||||
ws.send(JSON.stringify(retryPayload));
|
||||
await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'codex_app_steer_status' && msg.sessionId === retrySession.sessionId
|
||||
&& msg.clientMessageId === retryPayload.clientMessageId && msg.status === 'failed'
|
||||
), 5000);
|
||||
await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'error' && msg.sessionId === retrySession.sessionId && msg.code === 'codexapp_steer_failed'
|
||||
), 5000);
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === retrySession.sessionId, 5000);
|
||||
const beforeRetry = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${retrySession.sessionId}.json`), 'utf8'));
|
||||
assert(!beforeRetry.messages.some((message) => message.id === retryPayload.clientMessageId), 'Failed steer should not leave a persisted retry message');
|
||||
|
||||
const retryStartIndex = receivedMessages.length;
|
||||
ws.send(JSON.stringify(retryPayload));
|
||||
const retryEcho = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_message' && msg.sessionId === retrySession.sessionId && msg.message?.id === retryPayload.clientMessageId
|
||||
), 5000);
|
||||
assert(!retryEcho.message.codexAppSteerStatus, 'Finished-turn retry should echo a normal user message');
|
||||
await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'codex_app_steer_status' && msg.sessionId === retrySession.sessionId
|
||||
&& msg.clientMessageId === retryPayload.clientMessageId && msg.status === 'sent'
|
||||
), 5000);
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'resume_generating' && msg.sessionId === retrySession.sessionId, 5000);
|
||||
const retryDelta = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'text_delta' && msg.sessionId === retrySession.sessionId && /codexapp retry after completed/.test(msg.text || '')
|
||||
), 5000);
|
||||
assert(storedAttachmentImageMarker(retryAttachment).test(retryDelta.text || ''), 'Retry must deliver the original image to the new turn');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === retrySession.sessionId, 5000);
|
||||
const afterRetry = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${retrySession.sessionId}.json`), 'utf8'));
|
||||
const retriedUsers = afterRetry.messages.filter((message) => message.role === 'user' && message.id === retryPayload.clientMessageId);
|
||||
assert(retriedUsers.length === 1, 'Finished-turn retry should persist the original stable message ID exactly once');
|
||||
assertPersistedMessageAttachment(retriedUsers[0], retryAttachment.filename, 'Finished-turn retry message');
|
||||
assert(afterRetry.permissionMode === 'plan', 'Finished-turn retry should preserve the original mode');
|
||||
assert(!receivedMessages.slice(retryStartIndex).some((msg) => msg.type === 'error' && msg.sessionId === retrySession.sessionId), 'Successful retry should not report another insertion failure');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: homeDir, mode: 'yolo' }));
|
||||
const mismatchSession = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === homeDir && msg.sessionId !== sessionId
|
||||
msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === homeDir
|
||||
&& ![sessionId, lateSession.sessionId, promptSession.sessionId, retrySession.sessionId].includes(msg.sessionId)
|
||||
));
|
||||
ws.send(JSON.stringify({
|
||||
type: 'message',
|
||||
@@ -6130,7 +6405,9 @@ function assertCodexAppChildToolRoutingContract() {
|
||||
assert(!JSON.parse(oldTool.result).agentsStates?.['missing-route-thread'], 'Unknown spawn tool ids must not contaminate older child cards');
|
||||
|
||||
const syncSource = extractFunctionSource(source, 'syncCcwebMcpChildAgentsFromCollabItem');
|
||||
const syncApi = new Function(`
|
||||
const { createCodexAppRuntime } = require(path.join(REPO_DIR, 'lib', 'codex-app-runtime'));
|
||||
const codexAppRuntime = createCodexAppRuntime();
|
||||
const syncApi = new Function('codexAppRuntime', `
|
||||
const ccwebMcpChildThreads = new Map();
|
||||
const sent = [];
|
||||
const path = { basename: (value) => String(value || '').split('/').filter(Boolean).pop() || '' };
|
||||
@@ -6156,7 +6433,7 @@ function assertCodexAppChildToolRoutingContract() {
|
||||
sync: syncCcwebMcpChildAgentsFromCollabItem,
|
||||
sent,
|
||||
};
|
||||
`)();
|
||||
`)(codexAppRuntime);
|
||||
syncApi.setChild('current-plan-thread', {
|
||||
threadId: 'current-plan-thread',
|
||||
parentSessionId: session.id,
|
||||
@@ -6222,7 +6499,7 @@ function assertCodexAppUnroutedNotificationRoutingContract() {
|
||||
);
|
||||
assert(source.includes('function updateSessionRuntimeThreadIndex'), 'Server should centralize session thread index maintenance');
|
||||
assert(
|
||||
/function saveSession\(session\)[\s\S]*?updateSessionRuntimeThreadIndex\(session\)/.test(source),
|
||||
extractFunctionSource(source, 'saveSession').includes('updateSessionRuntimeThreadIndex(session)'),
|
||||
'Saving a session should refresh the runtime thread index'
|
||||
);
|
||||
assert(
|
||||
@@ -9524,7 +9801,9 @@ async function main() {
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: 'slow codexapp prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexAppSession.sessionId && s.isRunning));
|
||||
await sleep(500);
|
||||
await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /active steer fixture ready/.test(msg.text || '')
|
||||
));
|
||||
ws.send(JSON.stringify({
|
||||
type: 'message',
|
||||
text: '/runtime/report',
|
||||
@@ -9902,7 +10181,7 @@ async function main() {
|
||||
try {
|
||||
const { ws, messages } = await connectWs(recoveryPort, password);
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((session) => session.id === oversizedRecoverySessionId));
|
||||
assert(!fs.existsSync(oversizedRecoveryStateDir), 'Oversized Codex App recovery state directory should be cleaned without parsing the state');
|
||||
assert(fs.existsSync(oversizedRecoveryStateDir), 'Oversized Codex App recovery state directory should be retained as the only recovery evidence');
|
||||
ws.send(JSON.stringify({ type: 'load_session', sessionId: oversizedRecoverySessionId }));
|
||||
const oversizedSessionInfo = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.sessionId === oversizedRecoverySessionId);
|
||||
assert((oversizedSessionInfo.messages || []).some((message) => (
|
||||
|
||||
550
scripts/session-history-store-unit.js
Normal file
550
scripts/session-history-store-unit.js
Normal file
@@ -0,0 +1,550 @@
|
||||
#!/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',
|
||||
'saveSession', 'sessionHistoryCacheKey', 'cacheSessionHistory', 'loadNativeSessionHistory',
|
||||
'resolveSessionHistory',
|
||||
'listSessionFiles', 'refreshSessionSnapshotCache', 'prepareNativeHistoryImport',
|
||||
'handleSlashCommand', 'handleDeleteSession', 'findCcwebPromptMessage', 'removeCcwebPromptMessage',
|
||||
'rollbackPersistedCodexAppSteerMessage', 'handleLoadHistoryPage',
|
||||
];
|
||||
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 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 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, saveSession, resolveSessionHistory, listSessionFiles, refreshSessionSnapshotCache,
|
||||
prepareNativeHistoryImport, handleSlashCommand, handleDeleteSession, removeCcwebPromptMessage,
|
||||
rollbackPersistedCodexAppSteerMessage, handleLoadHistoryPage, responses, logs, sessionHistoryStore,
|
||||
};
|
||||
`)(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('可信旧修订并发追加和插入保留锚点顺序以及特殊 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.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}.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);
|
||||
assert.equal(createServerHarness(dir).resolveSessionHistory(createServerHarness(dir).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);
|
||||
});
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`完整历史回归失败:${failures.length} 项,已通过 ${checks} 项`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(`完整历史持久化回归通过:${checks} 项`);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) runTests();
|
||||
module.exports = { createServerHarness };
|
||||
@@ -6,6 +6,7 @@ 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}(`);
|
||||
@@ -140,6 +141,7 @@ check('三个历史出口使用同一 resolver,异常边界保持当前快照'
|
||||
${extract('isPersistenceNoticeMessage')}
|
||||
function loadNativeSessionHistory() { return { messages: ${JSON.stringify(native)} }; }
|
||||
function plog() {}
|
||||
const sessionHistoryStore = { state() { return null; } };
|
||||
${extract('resolveSessionHistory')}
|
||||
return resolveSessionHistory;
|
||||
`)(crypto);
|
||||
@@ -157,7 +159,8 @@ check('迁移只在 idle 执行并精确保留备份,写后可以重新加载'
|
||||
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', `
|
||||
const persistenceAdapter = createServerHarness(dir);
|
||||
const testApi = new Function('fs', 'path', 'crypto', 'SESSIONS_DIR', 'nativeMessages', 'persistenceAdapter', `
|
||||
${pureSource}
|
||||
${extract('isPersistenceNoticeMessage')}
|
||||
let running = true;
|
||||
@@ -165,6 +168,7 @@ check('迁移只在 idle 执行并精确保留备份,写后可以重新加载'
|
||||
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'); }
|
||||
@@ -172,7 +176,8 @@ check('迁移只在 idle 执行并精确保留备份,写后可以重新加载'
|
||||
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 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; }
|
||||
@@ -181,7 +186,7 @@ check('迁移只在 idle 执行并精确保留备份,写后可以重新加载'
|
||||
${extract('writeFileAtomicSync')}
|
||||
${extract('handleMigrateSessionHistory')}
|
||||
return { run(id, apply) { handleMigrateSessionHistory(null, { sessionId: id, apply }); return response; }, idle() { running = false; } };
|
||||
`)(fs, path, crypto, dir, native);
|
||||
`)(fs, path, crypto, dir, native, persistenceAdapter);
|
||||
assert.equal(testApi.run(id, true).code, 'session_running');
|
||||
assert.equal(fs.readFileSync(filePath, 'utf8'), original);
|
||||
testApi.idle();
|
||||
@@ -191,7 +196,7 @@ check('迁移只在 idle 执行并精确保留备份,写后可以重新加载'
|
||||
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'));
|
||||
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));
|
||||
|
||||
498
scripts/streaming-retry-browser-check.js
Normal file
498
scripts/streaming-retry-browser-check.js
Normal file
@@ -0,0 +1,498 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// 真实浏览器与隔离服务验证同一任务的重试气泡;所有会话、日志和门控文件均位于临时目录。
|
||||
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 crypto = require('node:crypto');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { WebSocket, WebSocketServer } = require('ws');
|
||||
|
||||
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 SESSION = '12222222-3333-4444-8555-666666666666';
|
||||
const OTHER = '12222222-3333-4444-8555-777777777777';
|
||||
const THREAD = '12222222-3333-4444-8555-888888888888';
|
||||
const PASSWORD = 'StreamingRetryBrowserFixture!234';
|
||||
const QUESTION = 'streaming browser retry fixture';
|
||||
const BROWSER_ASSETS = [
|
||||
{ url: 'https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js', file: 'marked.min.js' },
|
||||
{ url: 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js', file: 'highlight.min.js' },
|
||||
{ url: 'https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.9.1/mermaid.min.js', file: 'mermaid.min.js' },
|
||||
{ url: 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css', file: 'atom-one-dark.min.css' },
|
||||
];
|
||||
const PARTS = {
|
||||
A: '第一段最早正文\n\n' + '甲'.repeat(128000) + '\n\n第一段完整末尾\n',
|
||||
B: '\n第二段续跑正文\n' + '乙'.repeat(4000) + '\n第二段完整末尾\n',
|
||||
C: '\n第三段续跑正文\n\n最后回复的第一自然段。\n\n- 第一条完整列表内容\n- 第二条完整列表内容\n\n```js\nconst retained = true;\n```\n\n```html\n<div class="agent-message-divider">代码中的假分界</div>\n```\n\n' + '丙'.repeat(4000) + '\n\n最后回复的第二自然段。\n\n第三段完整末尾\n',
|
||||
};
|
||||
const MARKERS = {
|
||||
A: ['第一段最早正文', '第一段完整末尾'],
|
||||
B: ['第二段续跑正文', '第二段完整末尾'],
|
||||
C: ['第三段续跑正文', '第三段完整末尾'],
|
||||
};
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function sourceFingerprints() {
|
||||
return Object.fromEntries(['server.js', 'lib/agent-runtime.js', 'lib/codex-app-runtime.js', 'lib/session-history-store.js',
|
||||
'public/app.js', 'public/style.css', 'scripts/mock-codex-app-server.js', 'scripts/streaming-retry-browser-check.js'].map((file) => [
|
||||
file, crypto.createHash('sha256').update(fs.readFileSync(path.join(REPO, file))).digest('hex'),
|
||||
]));
|
||||
}
|
||||
|
||||
async function freePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => { const port = server.address().port; server.close(() => resolve(port)); });
|
||||
});
|
||||
}
|
||||
async function waitFor(predicate, description, timeout = 8000) {
|
||||
const start = Date.now();
|
||||
let lastError;
|
||||
while (Date.now() - start < timeout) {
|
||||
try { const value = await predicate(); if (value) return value; } catch (error) { lastError = error; }
|
||||
await sleep(60);
|
||||
}
|
||||
throw new Error(`${description}${lastError ? ':' + lastError.message : ''}`);
|
||||
}
|
||||
async function stopChild(child) {
|
||||
if (!child || child.exitCode !== null || child.signalCode) return;
|
||||
const exited = new Promise((resolve) => child.once('exit', resolve));
|
||||
child.kill('SIGTERM');
|
||||
await Promise.race([exited, sleep(1000)]);
|
||||
if (child.exitCode === null && !child.signalCode) { child.kill('SIGKILL'); await exited; }
|
||||
}
|
||||
|
||||
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', pageLoadStrategy: 'eager', 'moz:firefoxOptions': { binary: FIREFOX, args: ['-headless'], prefs: {
|
||||
'app.update.auto': false, 'browser.newtabpage.enabled': false, 'browser.startup.page': 0,
|
||||
'browser.tabs.remote.autostart': false, 'dom.ipc.processCount': 1, 'dom.ipc.processPrelaunch.enabled': false,
|
||||
'fission.autostart': false, 'gfx.webrender.software': true, 'browser.shell.checkDefaultBrowser': false,
|
||||
} },
|
||||
} } });
|
||||
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 pressKey(selector, key) {
|
||||
await this.execute('document.querySelector(arguments[0]).focus();', [selector]);
|
||||
const value = key === 'Enter' ? '\uE007' : ' ';
|
||||
await this.request('POST', `/session/${this.sessionId}/actions`, { actions: [{
|
||||
type: 'key', id: 'keyboard', actions: [{ type: 'keyDown', value }, { type: 'keyUp', value }],
|
||||
}] });
|
||||
await this.request('DELETE', `/session/${this.sessionId}/actions`);
|
||||
}
|
||||
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}`); }
|
||||
}
|
||||
|
||||
class ChromiumDriver {
|
||||
constructor() { this.sessionId = 'chromium'; }
|
||||
async start() {
|
||||
const { chromium } = require(process.env.CC_WEB_BROWSER_PLAYWRIGHT || '/home/hdzx/node_modules/playwright');
|
||||
this.browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
|
||||
this.page = await this.browser.newPage({ viewport: { width: 1280, height: 1050 } });
|
||||
return { browserName: 'chromium', browserVersion: this.browser.version() };
|
||||
}
|
||||
execute(script, args = []) {
|
||||
return this.page.evaluate(({ script, args }) => new Function(script).apply(null, args), { script, args });
|
||||
}
|
||||
navigate(url) { return this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 8000 }); }
|
||||
refresh() { return this.page.reload({ waitUntil: 'domcontentloaded', timeout: 8000 }); }
|
||||
pressKey(selector, key) { return this.page.locator(selector).press(key); }
|
||||
screenshot(file) { return this.page.screenshot({ path: file }); }
|
||||
request(_method, endpoint, body) {
|
||||
if (endpoint.endsWith('/window/rect')) return this.page.setViewportSize({ width: body.width, height: body.height });
|
||||
throw new Error('Chromium 夹具不支持 ' + endpoint);
|
||||
}
|
||||
async close() { await this.browser?.close(); }
|
||||
}
|
||||
|
||||
function createFixture(root) {
|
||||
const home = path.join(root, 'fixture-home'), sessions = path.join(root, 'sessions');
|
||||
for (const folder of [home, sessions, path.join(home, '.codex', 'sessions'), path.join(root, 'config'), path.join(root, 'logs')]) fs.mkdirSync(folder, { recursive: true });
|
||||
const metadata = { agent: 'codexapp', cwd: root, created: '2026-10-10T01:00:00.000Z', updated: '2026-10-10T01:00:00.000Z', permissionMode: 'yolo' };
|
||||
fs.writeFileSync(path.join(sessions, SESSION + '.json'), JSON.stringify({ ...metadata, id: SESSION,
|
||||
title: '重试气泡浏览器夹具', codexAppThreadId: THREAD, messages: [],
|
||||
}));
|
||||
fs.writeFileSync(path.join(sessions, OTHER + '.json'), JSON.stringify({ ...metadata, id: OTHER,
|
||||
title: '切换隔离目标', messages: [{ id: 'other-user', role: 'user', content: '另一个会话的问题' },
|
||||
{ id: 'other-answer', role: 'assistant', content: '另一个会话的回答' }],
|
||||
}));
|
||||
fs.writeFileSync(path.join(root, 'config', 'codex.json'), JSON.stringify({ mode: 'local', retry: { mode: 'limited', intervalSeconds: 4, maxAttempts: 4 } }));
|
||||
const gates = ['retry-first', 'retry-second', 'complete-turn'].map((name) => path.join(root, name));
|
||||
const preload = path.join(root, 'isolated-home.cjs');
|
||||
const baseline = process.env.CC_WEB_STREAMING_BROWSER_BASELINE_SERVER || '';
|
||||
if (baseline) assert(fs.existsSync(baseline), '指定的基线源码不存在');
|
||||
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(${JSON.stringify(baseline)} || filename, 'utf8');
|
||||
const needle = "const CODEX_SESSIONS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.codex', 'sessions');";
|
||||
if (!source.includes(needle)) throw new Error('原生历史目录注入点已变更');
|
||||
module._compile(source.replace(needle, 'const CODEX_SESSIONS_DIR = ' + ${JSON.stringify(JSON.stringify(path.join(home, '.codex', 'sessions')))} + ';'), filename);
|
||||
};
|
||||
`);
|
||||
const mock = path.join(root, 'mock-codex.js');
|
||||
const originalMock = fs.readFileSync(path.join(REPO, 'scripts/mock-codex-app-server.js'), 'utf8');
|
||||
const needle = " const delay = /recover/i.test(text) ? 5000 : /slow/i.test(text) ? 900 : 80;";
|
||||
assert(originalMock.includes(needle), 'mock 默认启动分支已变更');
|
||||
const replacement = `
|
||||
if (/streaming browser retry fixture/i.test(text)) {
|
||||
const attempt = (thread.browserRetryAttempt || 0) + 1;
|
||||
thread.browserRetryAttempt = attempt;
|
||||
const label = attempt === 1 ? 'A' : attempt === 2 ? 'B' : 'C';
|
||||
const output = ${JSON.stringify(PARTS)}[label];
|
||||
for (let offset = 0; offset < output.length; offset += 8192) {
|
||||
send({method:'item/agentMessage/delta',params:{threadId:thread.id,turnId,itemId:'browser-reused-message',delta:output.slice(offset,offset+8192)}});
|
||||
}
|
||||
send({method:'item/completed',params:{threadId:thread.id,turnId,item:{id:'browser-reused-message',type:'agentMessage',text:output}}});
|
||||
const item = {id:'browser-reused-tool',type:'commandExecution',command:'echo browser-tool-'+label,status:'inProgress'};
|
||||
send({method:'item/started',params:{threadId:thread.id,turnId,item}});
|
||||
send({method:'item/commandExecution/outputDelta',params:{threadId:thread.id,turnId,itemId:item.id,delta:'browser-tool-'+label+'-result'}});
|
||||
send({method:'item/completed',params:{threadId:thread.id,turnId,item:{...item,status:'completed',exitCode:0,aggregatedOutput:'browser-tool-'+label+'-result'}}});
|
||||
thread.timer = setInterval(() => {
|
||||
if (!require('fs').existsSync(${JSON.stringify(gates)}[Math.min(attempt,3)-1])) return;
|
||||
clearInterval(thread.timer); thread.timer = null;
|
||||
if (attempt <= 2) { emitCapacityError(thread,turnId); }
|
||||
else {
|
||||
send({method:'turn/completed',params:{threadId:thread.id,turn:{id:turnId,status:'completed',items:[]}}});
|
||||
thread.activeTurnId = null;
|
||||
}
|
||||
},40);
|
||||
return {turn:{id:turnId,status:'running',items:[]}};
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(mock, originalMock.replace(needle, replacement + needle)
|
||||
.replace("path.join(__dirname, 'mock-codex.js')", JSON.stringify(path.join(REPO, 'scripts/mock-codex.js'))), { mode: 0o755 });
|
||||
return { home, sessions, preload, mock, gates, baseline };
|
||||
}
|
||||
|
||||
async function startRelay(port, targetPort, evidence) {
|
||||
const assetDir = process.env.CC_WEB_STREAMING_BROWSER_ASSET_DIR || '';
|
||||
if (assetDir) {
|
||||
// 使用与 index.html 相同版本的公开库字节,隔离测试无需等待外部 CDN。
|
||||
evidence.assets = BROWSER_ASSETS.map((asset) => {
|
||||
const content = fs.readFileSync(path.join(assetDir, asset.file));
|
||||
assert(content.length > 0, '本地浏览器库不能为空:' + asset.file);
|
||||
return { ...asset, bytes: content.length, sha256: crypto.createHash('sha256').update(content).digest('hex') };
|
||||
});
|
||||
}
|
||||
const server = http.createServer((request, response) => {
|
||||
const asset = assetDir && BROWSER_ASSETS.find((item) => request.url === '/__streaming_fixture_assets/' + item.file);
|
||||
if (asset) {
|
||||
response.writeHead(200, { 'content-type': asset.file.endsWith('.css') ? 'text/css' : 'text/javascript' });
|
||||
response.end(fs.readFileSync(path.join(assetDir, asset.file)));
|
||||
return;
|
||||
}
|
||||
const upstream = http.request({ hostname: '127.0.0.1', port: targetPort, path: request.url, method: request.method,
|
||||
headers: { ...request.headers, ...(assetDir ? { 'accept-encoding': 'identity' } : {}) },
|
||||
}, (result) => {
|
||||
if (assetDir && result.headers['content-type']?.includes('text/html')) {
|
||||
const chunks = [];
|
||||
result.on('data', (chunk) => chunks.push(chunk));
|
||||
result.on('end', () => {
|
||||
let html = Buffer.concat(chunks).toString('utf8');
|
||||
BROWSER_ASSETS.forEach((item) => { html = html.replaceAll(item.url, '/__streaming_fixture_assets/' + item.file); });
|
||||
response.writeHead(result.statusCode, { ...result.headers, 'content-length': Buffer.byteLength(html) });
|
||||
response.end(html);
|
||||
});
|
||||
return;
|
||||
}
|
||||
response.writeHead(result.statusCode, result.headers); result.pipe(response);
|
||||
});
|
||||
upstream.on('error', () => response.destroy()); request.pipe(upstream);
|
||||
});
|
||||
const sockets = new Set(), wsServer = new WebSocketServer({ noServer: true });
|
||||
let connections = 0;
|
||||
server.on('upgrade', (request, socket, head) => wsServer.handleUpgrade(request, socket, head, (client) => {
|
||||
connections += 1; sockets.add(client);
|
||||
const upstream = new WebSocket(`ws://127.0.0.1:${targetPort}/ws`), 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 (['resume_generating', 'done', 'system_message'].includes(msg.type)) evidence.protocol.push({
|
||||
type: msg.type, sessionId: msg.sessionId, assistantMessageId: msg.assistantMessageId || msg.assistantMessage?.id,
|
||||
textChars: (msg.text || msg.assistantMessage?.content || '').length, retryPending: msg.retryPending === true,
|
||||
isRunning: msg.isRunning, message: msg.message, tools: (msg.toolCalls || msg.assistantMessage?.toolCalls || []).length,
|
||||
});
|
||||
} 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) => server.listen(port, '127.0.0.1', resolve));
|
||||
return {
|
||||
count: () => connections,
|
||||
disconnect() { for (const socket of sockets) socket.terminate(); },
|
||||
async close() { for (const socket of sockets) socket.terminate(); wsServer.close(); server.closeAllConnections(); await new Promise((resolve) => server.close(resolve)); },
|
||||
};
|
||||
}
|
||||
|
||||
async function capture(driver, stage, labels, evidence, generating = true) {
|
||||
const result = await waitFor(async () => {
|
||||
const state = await driver.execute(`return {
|
||||
current:document.querySelector('#chat-session-id-btn')?.title || '',
|
||||
streamCount:document.querySelectorAll('#streaming-msg').length,
|
||||
bubbles:[...document.querySelectorAll('#messages > .msg.assistant')].map(node => ({
|
||||
text:node.querySelector('.msg-text')?.textContent || node.querySelector('.msg-bubble')?.textContent || '',
|
||||
key:node.dataset.messageKey || '',streaming:node.id==='streaming-msg',
|
||||
tools:[...node.querySelectorAll('.tool-call')].map(tool => tool.textContent),
|
||||
})),
|
||||
users:[...document.querySelectorAll('#messages > .msg.user')].map(node => node.textContent),
|
||||
};`);
|
||||
const bubble = state.bubbles.find((node) => node.text.includes(MARKERS.A[0]));
|
||||
if (!bubble) return null;
|
||||
for (const label of labels) {
|
||||
for (const marker of MARKERS[label]) if (!bubble.text.includes(marker)) return null;
|
||||
if (!bubble.tools.some((text) => text.includes('browser-tool-' + label + '-result'))) return null;
|
||||
}
|
||||
return { ...state, bubble };
|
||||
}, `${stage}:气泡前文或完整尾段丢失`);
|
||||
assert.equal(result.streamCount, generating ? 1 : 0, `${stage}:流式气泡数量错误`);
|
||||
assert.equal(result.bubbles.length, 1, `${stage}:同一逻辑任务只能显示一个助手气泡`);
|
||||
assert.equal(result.users.filter((text) => text.includes(QUESTION)).length, 1, `${stage}:用户问题重复或消失`);
|
||||
let previousIndex = -1;
|
||||
for (const label of labels) {
|
||||
for (const marker of MARKERS[label]) {
|
||||
assert.equal(result.bubble.text.split(marker).length - 1, 1, `${stage}:正文重复:${marker}`);
|
||||
const index = result.bubble.text.indexOf(marker);
|
||||
assert(index > previousIndex, `${stage}:正文顺序错乱`); previousIndex = index;
|
||||
}
|
||||
const first = result.bubble.text.indexOf(MARKERS[label][0]) + MARKERS[label][0].length;
|
||||
const last = result.bubble.text.indexOf(MARKERS[label][1]);
|
||||
assert(last - first >= PARTS[label].length - 30, `${stage}:正文中段被裁剪`);
|
||||
}
|
||||
assert.equal(result.bubble.tools.length, labels.length, `${stage}:工具气泡丢失或重复`);
|
||||
for (const label of labels) assert(result.bubble.tools.some((text) => text.includes('browser-tool-' + label + '-result')), `${stage}:工具结果 ${label} 丢失`);
|
||||
const compact = {
|
||||
textChars: result.bubble.text.length, key: result.bubble.key, streamCount: result.streamCount,
|
||||
toolCount: result.bubble.tools.length, labels, textSha256: crypto.createHash('sha256').update(result.bubble.text).digest('hex'),
|
||||
};
|
||||
evidence.stages[stage] = compact;
|
||||
console.log(JSON.stringify({ stage, ...compact }));
|
||||
return compact;
|
||||
}
|
||||
|
||||
async function selectSession(driver, sessionId) {
|
||||
await driver.execute(`const node=document.querySelector('.session-item[data-id="'+arguments[0]+'"]');
|
||||
if(!node)throw new Error('会话列表未出现隔离夹具');node.click();`, [sessionId]);
|
||||
}
|
||||
|
||||
async function checkControls(driver, stage, labels, expanded = false, mobile = false) {
|
||||
const controls = await driver.execute(`const msg=document.querySelector('#messages > .msg.assistant');
|
||||
const toggle=msg?.querySelector('.msg-replies-toggle'),copy=msg?.querySelector('.msg-copy-reply-btn');
|
||||
const content=msg?.querySelector('.assistant-reply-content'),box=content?.getBoundingClientRect();
|
||||
return {toggle:!!toggle,copy:!!copy,expanded:toggle?.getAttribute('aria-expanded'),
|
||||
multiCollapsed:msg?.classList.contains('assistant-history-collapsed'),singleCollapsed:msg?.classList.contains('assistant-single-collapsed'),
|
||||
textChars:content?.textContent.length || msg?.querySelector('.msg-bubble')?.textContent.length,
|
||||
contentHeight:box?.height,contentMaxHeight:content?getComputedStyle(content).maxHeight:'',
|
||||
early:[...msg.querySelectorAll('.assistant-earlier-reply')].map(node=>({text:node.textContent,visible:node.getClientRects().length>0})),
|
||||
dividerCount:msg.querySelectorAll('.agent-message-divider').length,
|
||||
fencedDivider:!![...msg.querySelectorAll('pre code')].find(node=>node.textContent.includes('代码中的假分界')),
|
||||
actionButtons:[...msg.querySelectorAll('.msg-action-row > button')].filter(node=>!node.hidden).map(node=>({
|
||||
className:node.className,top:node.getBoundingClientRect().top,row:node.parentElement.className})),
|
||||
copyWidth:copy?.getBoundingClientRect().width,copyHeight:copy?.getBoundingClientRect().height,
|
||||
toggleHeight:toggle?.getBoundingClientRect().height,
|
||||
width:innerWidth,scrollWidth:document.documentElement.scrollWidth};`);
|
||||
assert(controls.toggle && controls.copy, `${stage}:折叠或复制入口丢失`);
|
||||
assert.equal(controls.expanded, expanded ? 'true' : 'false', `${stage}:折叠 aria 状态错误`);
|
||||
assert.equal(controls.dividerCount, labels.length - 1, `${stage}:代码围栏内的 HTML 不得生成原生分界`);
|
||||
if (labels.includes('C')) assert(controls.fencedDivider, `${stage}:代码中的原始 HTML 应完整显示`);
|
||||
const actionClasses = controls.actionButtons.map((button) => button.className);
|
||||
assert(actionClasses.includes('msg-copy-reply-btn') && actionClasses.includes('msg-last-section-btn'), `${stage}:复制和定位控件应在同一工具行`);
|
||||
if (stage.startsWith('completed') || mobile) assert(actionClasses.includes('msg-branch-btn'), `${stage}:完成气泡的分支控件应保留`);
|
||||
assert(controls.actionButtons.every((button) => Math.abs(button.top - controls.actionButtons[0].top) < 1), `${stage}:复制、定位和分支应同排`);
|
||||
if (labels.length === 1 && !expanded) {
|
||||
assert(controls.singleCollapsed, `${stage}:无 divider 的长单回复应有受控折叠`);
|
||||
assert(controls.contentHeight <= 421 && controls.textChars > 128000, `${stage}:折叠不得删正文`);
|
||||
} else if (labels.length > 1) {
|
||||
assert.equal(controls.multiCollapsed, !expanded, `${stage}:多原生回复折叠状态错误`);
|
||||
assert(controls.early.some((node) => node.text.includes(MARKERS.A[0])), `${stage}:第一段仍须存在于 DOM`);
|
||||
assert(controls.early.every((node) => node.visible === expanded), `${stage}:早期正文可见状态错误`);
|
||||
}
|
||||
if (mobile) {
|
||||
assert(controls.copyWidth >= 44 && controls.copyHeight >= 44 && controls.toggleHeight >= 44, `${stage}:手机触控面积不足`);
|
||||
assert(controls.scrollWidth <= controls.width + 1, `${stage}:控件导致手机水平溢出`);
|
||||
}
|
||||
await driver.execute(`window.__browserCopiedText='';
|
||||
Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async(value)=>{window.__browserCopiedText=value;}}});
|
||||
document.execCommand=(name)=>{if(name==='copy'){window.__browserCopiedText=document.activeElement?.value||'';return true;}return false;};
|
||||
document.querySelector('#messages > .msg.assistant .msg-copy-reply-btn').click();`);
|
||||
const copied = await waitFor(() => driver.execute('return window.__browserCopiedText'), `${stage}:复制没有写入剪贴板`);
|
||||
assert.equal(copied, PARTS[labels[labels.length - 1]].trim(), `${stage}:复制应包含完整最新原生回复 Markdown`);
|
||||
assert(!copied.includes('browser-tool-'), `${stage}:复制混入工具结果`);
|
||||
return { ...controls, early: controls.early.map((node) => ({ textChars: node.text.length, visible: node.visible })), copiedChars: copied.length };
|
||||
}
|
||||
|
||||
async function toggleControls(driver, key) {
|
||||
if (key) await driver.pressKey('#messages > .msg.assistant .msg-replies-toggle', key);
|
||||
else await driver.execute(`document.querySelector('#messages > .msg.assistant .msg-replies-toggle').click();`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const chromium = process.env.CC_WEB_STREAMING_BROWSER_ENGINE === 'chromium';
|
||||
if (!chromium) assert(fs.existsSync(FIREFOX) && fs.existsSync(GECKODRIVER), '需要本机 Firefox 与 geckodriver');
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-streaming-retry-browser-'));
|
||||
const fixture = createFixture(root);
|
||||
const evidence = { root, fixture, protocol: [], stages: {}, sourceFingerprints: sourceFingerprints(),
|
||||
kind: '真实 server.js / 浏览器 / 门控 A-B-C / 隔离存储' };
|
||||
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_HOME: path.join(fixture.home, '.codex'),
|
||||
CODEX_PATH: fixture.mock, CLAUDE_PATH: path.join(REPO, 'scripts/mock-claude.js'), CC_WEB_TASK_BOARD_ENABLED: '0',
|
||||
CC_WEB_SESSION_MESSAGE_CONTENT_MAX_CHARS: '4096',
|
||||
}, 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, '隔离服务启动失败');
|
||||
console.log('隔离服务就绪:' + root);
|
||||
relay = await startRelay(relayPort, serverPort, evidence);
|
||||
if (!chromium) {
|
||||
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 = chromium ? new ChromiumDriver() : new FirefoxDriver(driverPort);
|
||||
console.log('开始启动浏览器:' + (chromium ? 'Chromium' : 'Firefox'));
|
||||
evidence.browser = await driver.start();
|
||||
console.log('浏览器会话已就绪');
|
||||
await driver.navigate(`http://127.0.0.1:${relayPort}/`);
|
||||
await waitFor(() => driver.execute(`return document.querySelector('#login-password')?.getClientRects().length > 0`), '登录表单未就绪');
|
||||
// 只提交一次,避免认证刷新尚未结束时再次提交造成连续导航。
|
||||
await driver.execute(`document.querySelector('#login-password').value=arguments[0];document.querySelector('#login-form').requestSubmit();`, [PASSWORD]);
|
||||
await waitFor(() => driver.execute(`return !document.querySelector('#app').hidden && !!document.querySelector('.session-item')`), '登录或会话列表未就绪');
|
||||
await selectSession(driver, SESSION);
|
||||
await waitFor(() => driver.execute(`return document.querySelector('#chat-session-id-btn')?.title.includes(arguments[0])`, [SESSION]), '目标会话未加载');
|
||||
await driver.execute(`const input=document.querySelector('#msg-input');input.value=arguments[0];input.dispatchEvent(new Event('input',{bubbles:true}));`, [QUESTION]);
|
||||
await waitFor(() => driver.execute(`return !document.querySelector('#send-btn').disabled`), '初次请求发送控件未就绪');
|
||||
await driver.execute(`document.querySelector('#send-btn').click();`);
|
||||
const first = await capture(driver, 'initial-A', ['A'], evidence);
|
||||
evidence.controls = { initialSingle: await checkControls(driver, 'initial-A', ['A']) };
|
||||
await driver.execute(`document.querySelector('#messages > .msg.assistant .msg-last-section-btn').click();`);
|
||||
evidence.singleReplyLocate = await waitFor(() => driver.execute(`
|
||||
const msg=document.querySelector('#messages > .msg.assistant');
|
||||
const last=[...msg.querySelectorAll('p')].find(node=>node.textContent.trim()===arguments[0]);
|
||||
const rect=last?.getBoundingClientRect(),box=document.querySelector('#messages').getBoundingClientRect();
|
||||
if(!rect || msg.querySelector('.msg-replies-toggle').getAttribute('aria-expanded')!=='true')return null;
|
||||
return rect.top>=box.top&&rect.bottom<=box.bottom?{visible:true,expanded:true,top:rect.top,bottom:rect.bottom}:null;`, [MARKERS.A[1]]), '单条长回复的定位必须先展开并显示裁剪区末段');
|
||||
await toggleControls(driver);
|
||||
await toggleControls(driver, 'Enter');
|
||||
evidence.controls.initialSingleExpanded = await checkControls(driver, 'initial-A-expanded', ['A'], true);
|
||||
evidence.controls.initialSingleExpanded.keyboard = 'Enter';
|
||||
await toggleControls(driver);
|
||||
|
||||
await selectSession(driver, OTHER);
|
||||
await waitFor(() => driver.execute(`return document.querySelector('#messages').textContent.includes('另一个会话的回答')`), '隔离会话未加载');
|
||||
assert.equal(await driver.execute(`return document.querySelector('#messages').textContent.includes('第一段最早正文')`), false, '长任务正文串入其他会话');
|
||||
assert.equal(await driver.execute(`return !!document.querySelector('.msg-replies-toggle')`), false, '短历史回复不应显示折叠入口');
|
||||
await selectSession(driver, SESSION);
|
||||
await capture(driver, 'switched-back-A', ['A'], evidence);
|
||||
|
||||
fs.writeFileSync(fixture.gates[0], 'retry\n');
|
||||
await waitFor(() => evidence.protocol.some((msg) => msg.type === 'resume_generating' && msg.retryPending), '等待重试未下发完整运行态');
|
||||
await driver.refresh();
|
||||
await capture(driver, 'retry-wait-refreshed-A', ['A'], evidence);
|
||||
const beforeReconnect = relay.count(); relay.disconnect();
|
||||
await waitFor(() => relay.count() > beforeReconnect, '等待重试期间 WebSocket 未重连');
|
||||
await capture(driver, 'retry-wait-reconnected-A', ['A'], evidence);
|
||||
const second = await capture(driver, 'continued-AB', ['A', 'B'], evidence);
|
||||
assert.equal(second.key, first.key, '重试不能更换逻辑助手消息 ID');
|
||||
evidence.controls.multiple = await checkControls(driver, 'continued-AB', ['A', 'B']);
|
||||
await toggleControls(driver, 'Space');
|
||||
evidence.controls.multipleExpanded = await checkControls(driver, 'continued-AB-expanded', ['A', 'B'], true);
|
||||
evidence.controls.multipleExpanded.keyboard = 'Space';
|
||||
await toggleControls(driver);
|
||||
|
||||
fs.writeFileSync(fixture.gates[1], 'retry\n');
|
||||
const third = await capture(driver, 'continued-ABC', ['A', 'B', 'C'], evidence);
|
||||
assert.equal(third.key, first.key);
|
||||
fs.writeFileSync(fixture.gates[2], 'done\n');
|
||||
await waitFor(() => driver.execute(`return !document.querySelector('#streaming-msg')`), '完成后没有收束流式气泡');
|
||||
await capture(driver, 'completed-ABC', ['A', 'B', 'C'], evidence, false);
|
||||
await driver.refresh();
|
||||
const completed = await capture(driver, 'completed-refreshed-ABC', ['A', 'B', 'C'], evidence, false);
|
||||
assert.equal(completed.key, first.key);
|
||||
evidence.controls.completed = await checkControls(driver, 'completed-refreshed-ABC', ['A', 'B', 'C']);
|
||||
await driver.execute(`document.querySelector('#messages > .msg.assistant .msg-replies-toggle').scrollIntoView({block:'start'});`);
|
||||
await driver.screenshot(path.join(root, 'desktop-collapsed-top.png'));
|
||||
await driver.execute(`const box=document.querySelector('#messages');box.scrollTop=box.scrollHeight;`);
|
||||
await driver.screenshot(path.join(root, 'desktop-latest-reply.png'));
|
||||
await toggleControls(driver);
|
||||
evidence.controls.completedExpanded = await checkControls(driver, 'completed-ABC-expanded', ['A', 'B', 'C'], true);
|
||||
|
||||
await driver.execute(`const box=document.querySelector('#messages');box.scrollTop=0;`);
|
||||
await waitFor(() => driver.execute(`const node=[...document.querySelectorAll('#messages > .msg.assistant .msg-bubble')].find(node=>node.textContent.includes('第一段最早正文'));
|
||||
if(!node)return false;const rect=node.getBoundingClientRect();return rect.top>=0&&rect.top<innerHeight;`), '最早正文没有实际显示在视口');
|
||||
await driver.screenshot(path.join(root, 'completed-earliest-text.png'));
|
||||
await toggleControls(driver);
|
||||
await driver.request('POST', `/session/${driver.sessionId}/window/rect`, { width: 390, height: 844 });
|
||||
evidence.controls.mobile = await checkControls(driver, 'mobile-ABC', ['A', 'B', 'C'], false, true);
|
||||
for (const theme of ['editorial', 'nocturne']) {
|
||||
await driver.execute(`document.documentElement.dataset.theme=arguments[0];`, [theme]);
|
||||
await checkControls(driver, 'mobile-' + theme, ['A', 'B', 'C'], false, true);
|
||||
}
|
||||
await driver.execute(`document.querySelector('#messages > .msg.assistant .msg-replies-toggle').scrollIntoView({block:'start'});`);
|
||||
await driver.screenshot(path.join(root, 'mobile-collapsed-top.png'));
|
||||
await driver.execute(`const box=document.querySelector('#messages');box.scrollTop=box.scrollHeight;`);
|
||||
await driver.screenshot(path.join(root, 'mobile-latest-reply.png'));
|
||||
await driver.execute(`const input=document.querySelector('#msg-input');input.value='下一用户轮次';input.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#send-btn').click();`);
|
||||
await waitFor(() => driver.execute(`return [...document.querySelectorAll('#messages > .msg.assistant')].some(node=>node.textContent.includes('下一用户轮次'))`), '下一用户轮次未完成');
|
||||
const next = await driver.execute(`return [...document.querySelectorAll('#messages > .msg.assistant')].map(node=>node.textContent)`);
|
||||
assert.equal(next.length, 2, '新问题应有独立助手气泡');
|
||||
assert(!next[1].includes(MARKERS.A[0]), '新用户轮次错误继承上轮正文');
|
||||
evidence.nextUserTurn = { assistantBubbles: next.length, preservedPrevious: next[0].includes(MARKERS.A[0]) };
|
||||
evidence.ok = true;
|
||||
console.log(JSON.stringify({ ok: true, root, browser: evidence.browser.browserVersion, stages: Object.keys(evidence.stages) }));
|
||||
} 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 {
|
||||
if (driver) await driver.close().catch(() => {});
|
||||
if (relay) await relay.close();
|
||||
await stopChild(gecko); await stopChild(server);
|
||||
evidence.sourceFingerprintsAfter = sourceFingerprints();
|
||||
evidence.sourcesChangedDuringRun = Object.keys(evidence.sourceFingerprints)
|
||||
.filter((file) => evidence.sourceFingerprints[file] !== evidence.sourceFingerprintsAfter[file]);
|
||||
fs.writeFileSync(path.join(root, 'evidence.json'), JSON.stringify(evidence, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
293
scripts/streaming-retry-frontend-unit.js
Normal file
293
scripts/streaming-retry-frontend-unit.js
Normal file
@@ -0,0 +1,293 @@
|
||||
#!/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}`);
|
||||
}
|
||||
|
||||
function extractCases(start, end) {
|
||||
const first = source.indexOf(`case '${start}':`);
|
||||
const last = source.indexOf(`case '${end}':`, first);
|
||||
assert(first >= 0 && last > first, `缺少事件分支 ${start} → ${end}`);
|
||||
return source.slice(first, last);
|
||||
}
|
||||
|
||||
// 节点仅模拟 DOM 容器;生成、恢复、历史对账和完成逻辑直接执行生产函数。
|
||||
class Element {
|
||||
constructor(className = '') {
|
||||
this.className = className;
|
||||
this.id = '';
|
||||
this.dataset = {};
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
this.scrollTop = 0;
|
||||
this.classList = { contains: (value) => this.className.split(' ').includes(value) };
|
||||
}
|
||||
get scrollHeight() { return this.children.length * 50; }
|
||||
get nextSibling() { return this.parentNode?.children[this.parentNode.children.indexOf(this) + 1] || null; }
|
||||
set innerHTML(value) {
|
||||
this.html = value;
|
||||
this.children.forEach((child) => { child.parentNode = null; });
|
||||
this.children = [];
|
||||
}
|
||||
get innerHTML() { return this.html || ''; }
|
||||
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;
|
||||
this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1);
|
||||
this.parentNode = null;
|
||||
}
|
||||
replaceWith(node) { if (this.parentNode) { this.parentNode.insertBefore(node, this); this.remove(); } }
|
||||
matches(selector) {
|
||||
if (selector.startsWith('.')) return this.classList.contains(selector.slice(1));
|
||||
if (selector.startsWith('#')) return this.id === selector.slice(1);
|
||||
if (selector.includes('[data-session-message="true"]') && this.dataset.sessionMessage !== 'true') return false;
|
||||
if (selector.includes('[data-message-key]') && !this.dataset.messageKey) return false;
|
||||
return selector.startsWith('[data-');
|
||||
}
|
||||
querySelectorAll(selector) {
|
||||
return this.children.flatMap((child) => [child, ...child.querySelectorAll('*')])
|
||||
.filter((child) => selector === '*' || child.matches(selector));
|
||||
}
|
||||
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
|
||||
}
|
||||
|
||||
function createHarness() {
|
||||
const names = [
|
||||
'stableMessageHash', 'getStableMessageKey', 'getMessageRenderFingerprint',
|
||||
'mergeIndexedSessionMessages', 'dedupeSessionMessages', 'markSessionMessageElement',
|
||||
'findRenderedSessionMessageByKey', 'renderMessages', 'reconcileRenderedSessionMessages',
|
||||
'mergeHistoryChunkIntoCachedSnapshot', 'applySessionSnapshot',
|
||||
'isCurrentSessionEvent', 'ensureGeneratingForEvent', 'startGenerating',
|
||||
'bindStreamingAssistantMessage', 'commitCompletedAssistantMessage', 'finishGenerating', 'flushRender',
|
||||
];
|
||||
return new Function('Element', `
|
||||
const messagesDiv = new Element();
|
||||
const document = {
|
||||
getElementById(id) { return messagesDiv.querySelector('#' + id); },
|
||||
querySelector(selector) {
|
||||
if (selector.startsWith('#streaming-msg ')) {
|
||||
return document.getElementById('streaming-msg')?.querySelector(selector.slice(15)) || null;
|
||||
}
|
||||
return messagesDiv.querySelector(selector);
|
||||
},
|
||||
createElement() { return new Element(); },
|
||||
createDocumentFragment() { const node = new Element(); node.fragment = true; return node; },
|
||||
};
|
||||
const window = { pendingContentBlocks: [] };
|
||||
const activeToolCalls = new Map(), activeTodoCallTargets = new Map();
|
||||
let currentSessionId = 'task-session', currentSessionMessageCount = 0;
|
||||
let currentSessionRunning = false, isGenerating = false, generatingSessionId = null, pendingText = '';
|
||||
let toolGroupCount = 0, hasGrouped = false, renderEpoch = 0, currentCwd = '/tmp/streaming-test';
|
||||
let currentAgent = 'codexapp', currentMode = 'yolo', currentModel = '', loadedHistorySessionId = null;
|
||||
let currentOutlineTitleHistory = [], closedCollabAgentIds = new Set();
|
||||
let collabAgentStateCache = new Map(), collabAgentIdsByToolUseId = new Map(), closedCollabAgentIdsByToolUseId = new Map();
|
||||
const historyLoadMore = null, fileBrowserState = null, PRIMARY_UI_AGENT = 'codexapp', MODE_LABELS = {};
|
||||
const sessionCache = new Map(), buffers = new Map(), chatTitle = {}, msgInput = { focus() {} };
|
||||
function createMsgElement(role) { const node = new Element('msg ' + role); node.appendChild(new Element('msg-bubble')); return node; }
|
||||
function buildMsgElement(message, index) {
|
||||
const node = createMsgElement(message.role);
|
||||
node.message = JSON.parse(JSON.stringify(message));
|
||||
node.querySelector('.msg-bubble').innerHTML = message.content;
|
||||
markSessionMessageElement(node, index, message);
|
||||
return node;
|
||||
}
|
||||
function appendToolCall(id, name, input, done, kind, meta) {
|
||||
const node = new Element('tool-call'); node.dataset.toolUseId = id;
|
||||
node.tool = { id, name, input, done, kind, meta };
|
||||
document.getElementById('streaming-msg')?.querySelector('.msg-tools')?.appendChild(node);
|
||||
}
|
||||
function updateToolCall(id, result, done) {
|
||||
const node = messagesDiv.querySelectorAll('.tool-call').find((node) => node.dataset.toolUseId === id);
|
||||
if (node) Object.assign(node.tool, { result, done });
|
||||
}
|
||||
function isEmptyReasoningTool() { return false; }
|
||||
function renderToolCallsWithMergedCollab(tools) { return tools; }
|
||||
function setRenderedMarkdown(node, text) { node.innerHTML = text; }
|
||||
function scheduleRender() { flushRender(); }
|
||||
function setCurrentSessionRunningState(value) { currentSessionRunning = value; }
|
||||
function normalizeAgent(value) { return value || 'codexapp'; }
|
||||
function isPrimaryUiAgent() { return true; }
|
||||
function setCurrentAgent(value) { currentAgent = value; }
|
||||
function normalizeOutlineTitleHistory(value) { return value || []; }
|
||||
function collectClosedCollabAgentIds() { return new Set(); }
|
||||
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); }
|
||||
function isNearBottom() { return true; }
|
||||
${[
|
||||
'resetChatView', 'renderSessionList', 'closeFileBrowser', 'updateGenerationControls',
|
||||
'resetHistoryLoadState', 'resetOutlineHistoryState', 'clearPendingMissingSessionConfirmation',
|
||||
'syncHistoryLoadControl', 'syncOutlineHistoryIndex', 'syncTaskTrackingControl', 'setLastSessionForAgent',
|
||||
'updateSessionIdBadge', 'migratePendingNotesToSession', 'migrateQueuedMessagesToSession',
|
||||
'setStatsDisplay', 'closeUserOutlinePanel', 'closeCcwebPromptOutlinePanel', 'updateCwdBadge',
|
||||
'scheduleAdvancedSearchJump', 'highlightActiveSession', 'closeSidebar', 'showToast',
|
||||
'updateNoteModeUI', 'renderPendingNotes', 'scrollToBottom', 'updateScrollbar',
|
||||
'syncAssistantLastSectionButton', 'syncAssistantBranchButton', 'clearUserMessageIndex',
|
||||
'updateUserOutlinePanel', 'clearPendingSessionResumeRequest', 'scheduleQueuedMessageDrain',
|
||||
'followOutputIfNeeded',
|
||||
].map((name) => 'function ' + name + '() {}').join('\n')}
|
||||
${names.map(extractFunction).join('\n')}
|
||||
function receive(msg) {
|
||||
switch (msg.type) {
|
||||
${extractCases('text_delta', 'content_blocks')}
|
||||
${extractCases('tool_start', 'cost')}
|
||||
${extractCases('done', 'system_message')}
|
||||
${extractCases('resume_generating', 'error')}
|
||||
}
|
||||
}
|
||||
return {
|
||||
receive, startGenerating, messagesDiv, sessionCache,
|
||||
snapshot(snapshot, preserve = false) {
|
||||
sessionCache.set(snapshot.sessionId, { snapshot });
|
||||
applySessionSnapshot(snapshot, { preserveStreaming: preserve, immediate: true });
|
||||
},
|
||||
stream() { return document.getElementById('streaming-msg'); },
|
||||
state() { return { currentSessionId, currentSessionMessageCount, isGenerating,
|
||||
generatingSessionId, pendingText, currentSessionRunning, tools: [...activeToolCalls.entries()] }; },
|
||||
};
|
||||
`)(Element);
|
||||
}
|
||||
|
||||
const SESSION = 'task-session';
|
||||
const FIRST = '第一段不能丢失\n' + '已产生的长正文。'.repeat(16000) + '\n第一段末尾\n';
|
||||
const SECOND = '\n第二次续跑正文\n';
|
||||
const THIRD = '\n第三次续跑正文\n';
|
||||
const MESSAGE_ID = 'logical-task-assistant';
|
||||
const tool = (id, result = id) => ({ id, name: 'exec_command', input: { cmd: id }, result, done: true });
|
||||
const question = { id: 'question', role: 'user', content: '长任务', timestamp: '2026-10-10T01:00:00.000Z' };
|
||||
const snapshot = (sessionId = SESSION, isRunning = true, messages = [question]) => ({
|
||||
sessionId, agent: 'codexapp', isRunning, messages, historyBaseIndex: 0, historyTotal: messages.length,
|
||||
historyCursor: 0,
|
||||
});
|
||||
const resume = (text, tools = [], extra = {}) => ({
|
||||
type: 'resume_generating', sessionId: SESSION, assistantMessageId: MESSAGE_ID,
|
||||
turnId: 'runtime-turn-1', text, toolCalls: tools, ...extra,
|
||||
});
|
||||
let passed = 0;
|
||||
function test(description, run) { run(); passed += 1; console.log('通过:' + description); }
|
||||
|
||||
test('多次延续重试复用一个气泡,完整 snapshot 覆盖正文和工具且不重复', () => {
|
||||
const api = createHarness();
|
||||
api.snapshot(snapshot());
|
||||
api.receive(resume(''));
|
||||
const stream = api.stream();
|
||||
api.receive({ type: 'text_delta', sessionId: SESSION, text: FIRST });
|
||||
api.receive({ type: 'tool_start', sessionId: SESSION, toolUseId: 'first', name: 'exec_command', input: { cmd: 'first' } });
|
||||
api.receive({ type: 'tool_end', sessionId: SESSION, toolUseId: 'first', result: 'first' });
|
||||
for (const text of [FIRST, FIRST + SECOND, FIRST + SECOND + THIRD]) {
|
||||
const tools = text.includes(SECOND) ? [tool('first'), tool('second')] : [tool('first')];
|
||||
api.receive(resume(text, tools, { retryPending: true, isRunning: true, turnId: 'runtime-turn-2' }));
|
||||
api.receive(resume(text, tools, { turnId: 'runtime-turn-3' }));
|
||||
assert.strictEqual(api.stream(), stream, '同一逻辑重试不得另建气泡');
|
||||
assert.equal(api.state().pendingText, text);
|
||||
assert.equal(stream.querySelector('.msg-text').innerHTML, text, '长正文前缀必须仍在 DOM');
|
||||
assert.equal(api.messagesDiv.querySelectorAll('#streaming-msg').length, 1);
|
||||
assert.deepEqual(api.state().tools.map(([id]) => id), tools.map((value) => value.id));
|
||||
assert.equal(stream.querySelectorAll('.tool-call').length, tools.length);
|
||||
}
|
||||
assert.equal(stream.dataset.messageKey, MESSAGE_ID);
|
||||
api.receive(resume('服务端规范化后的完整正文', [tool('replacement')]));
|
||||
assert.equal(api.state().pendingText, '服务端规范化后的完整正文', 'snapshot 必须权威替换而非盲目拼接');
|
||||
assert.equal(stream.querySelector('.msg-text').innerHTML, '服务端规范化后的完整正文');
|
||||
assert.deepEqual(api.state().tools.map(([id]) => id), ['replacement']);
|
||||
});
|
||||
|
||||
test('运行中的历史重复快照保留气泡,切换会话后从完整恢复协议重建', () => {
|
||||
const api = createHarness();
|
||||
api.snapshot(snapshot());
|
||||
api.receive(resume(FIRST + SECOND, [tool('first')]));
|
||||
const stream = api.stream();
|
||||
api.snapshot(snapshot(), true);
|
||||
assert.strictEqual(api.stream(), stream);
|
||||
assert.equal(api.state().pendingText, FIRST + SECOND);
|
||||
api.snapshot(snapshot('another-session', false, [{ ...question, id: 'other-question' }]));
|
||||
assert.equal(api.state().pendingText, '');
|
||||
assert.equal(api.stream(), null);
|
||||
api.receive(resume('不该串入其他会话'));
|
||||
assert.equal(api.stream(), null, '后台会话协议不得污染当前视图');
|
||||
api.snapshot(snapshot());
|
||||
api.receive(resume(FIRST + SECOND, [tool('first')]));
|
||||
assert.equal(api.state().pendingText, FIRST + SECOND);
|
||||
assert.equal(api.stream().querySelector('.msg-text').innerHTML, FIRST + SECOND);
|
||||
});
|
||||
|
||||
test('刷新后的空客户端及断线重连都用完整正文和工具恢复', () => {
|
||||
for (const existingStream of [false, true]) {
|
||||
const api = createHarness();
|
||||
api.snapshot(snapshot());
|
||||
if (existingStream) api.receive(resume('客户端断线前的过期预览', [tool('outdated')]));
|
||||
api.receive(resume(FIRST + SECOND + THIRD, [tool('first'), tool('second')]));
|
||||
assert.equal(api.state().pendingText, FIRST + SECOND + THIRD);
|
||||
assert.equal(api.stream().querySelector('.msg-text').innerHTML, FIRST + SECOND + THIRD);
|
||||
assert.deepEqual(api.state().tools.map(([id]) => id), ['first', 'second']);
|
||||
assert.equal(api.messagesDiv.querySelectorAll('#streaming-msg').length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('最终 done 采用完整规范消息,重复完成不重复提交,下一用户轮次清空', () => {
|
||||
const api = createHarness();
|
||||
api.snapshot(snapshot());
|
||||
api.receive(resume('客户端尚不完整的输出', [tool('outdated')]));
|
||||
const final = { id: MESSAGE_ID, role: 'assistant', content: FIRST + SECOND + THIRD, toolCalls: [tool('first'), tool('second')] };
|
||||
const done = { type: 'done', sessionId: SESSION, assistantMessage: final, assistantMessageIndex: 1 };
|
||||
api.receive(done);
|
||||
api.receive(done);
|
||||
assert.equal(api.stream(), null);
|
||||
assert.equal(api.messagesDiv.children.length, 2);
|
||||
assert.deepEqual(api.messagesDiv.children[1].message, final);
|
||||
assert.equal(api.state().currentSessionMessageCount, 2);
|
||||
assert.equal(api.sessionCache.get(SESSION).snapshot.messages[1].content, final.content);
|
||||
api.startGenerating(SESSION);
|
||||
assert.equal(api.state().pendingText, '');
|
||||
assert.equal(api.state().tools.length, 0);
|
||||
api.receive({ type: 'text_delta', sessionId: SESSION, text: '新问题的新回答' });
|
||||
assert.equal(api.stream().querySelector('.msg-text').innerHTML, '新问题的新回答');
|
||||
assert(!api.stream().querySelector('.msg-text').innerHTML.includes('第一段不能丢失'));
|
||||
});
|
||||
|
||||
test('停止运行但保存失败的完整运行态可显示,done 后停止生成且保留正文', () => {
|
||||
const api = createHarness();
|
||||
api.snapshot(snapshot());
|
||||
api.receive(resume(FIRST + SECOND, [tool('first')], { isRunning: false, historySaved: false }));
|
||||
const stream = api.stream();
|
||||
api.receive({ type: 'done', sessionId: SESSION, historySaved: false });
|
||||
assert.equal(api.state().isGenerating, false);
|
||||
assert.equal(api.state().currentSessionRunning, false);
|
||||
assert.equal(api.stream(), null);
|
||||
assert.equal(stream.querySelector('.msg-text').innerHTML, FIRST + SECOND);
|
||||
assert.equal(stream.querySelectorAll('.tool-call').length, 1);
|
||||
});
|
||||
|
||||
console.log(JSON.stringify({ passed, fullTextChars: (FIRST + SECOND + THIRD).length, source: 'public/app.js 原函数与事件分支' }));
|
||||
Reference in New Issue
Block a user