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

294 lines
16 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';
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 原函数与事件分支' }));