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

482 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

#!/usr/bin/env node
'use strict';
// 该回归直接调用生产运行时 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 };