修复完整会话历史与长气泡交互并更新 CentOS 7 发布包
提交当前工作区全部源码、回归脚本、任务记录及重新构建的 Bun baseline 发布包。发布单文件 MCP 初始化及前端资源一致性验证通过。
This commit is contained in:
293
scripts/streaming-retry-frontend-unit.js
Normal file
293
scripts/streaming-retry-frontend-unit.js
Normal file
@@ -0,0 +1,293 @@
|
||||
#!/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 原函数与事件分支' }));
|
||||
Reference in New Issue
Block a user