修复完整会话历史与长气泡交互并更新 CentOS 7 发布包

提交当前工作区全部源码、回归脚本、任务记录及重新构建的 Bun baseline 发布包。发布单文件 MCP 初始化及前端资源一致性验证通过。
This commit is contained in:
shiyue
2026-10-10 23:40:16 +08:00
parent 747771a048
commit 69b6db0f5b
71 changed files with 9536 additions and 503 deletions

View 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 };