#!/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 };