#!/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 原函数与事件分支' }));