修复完整会话历史与长气泡交互并更新 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,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 原函数与事件分支' }));