修复完整会话历史与长气泡交互并更新 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

@@ -3051,6 +3051,13 @@ function assertSidebarTitleRefreshStormContract() {
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const createItemSource = extractFunctionSource(frontendSource, 'createSessionListItem');
const renderListSource = extractFunctionSource(frontendSource, 'renderSessionList');
const dependencySource = [
'sessionPassesMcpVisibilityFilter',
'syncMcpSessionVisibilityToggle',
'normalizeGiteaSource',
'giteaResourceLabel',
]
.map((name) => extractFunctionSource(frontendSource, name)).join('\n');
const optionalSources = [
'buildSessionListStructureSignature',
'refreshSessionListRelativeTimes',
@@ -3196,6 +3203,7 @@ function assertSidebarTitleRefreshStormContract() {
let sessions = [];
let currentSessionId = 's1';
let sessionSearchQuery = '';
let showMcpSessions = true;
let lastSessionListStructureSignature = '';
let collapseOlderSessions = false;
let failOldSessionLoadMoreOnce = false;
@@ -3206,6 +3214,7 @@ function assertSidebarTitleRefreshStormContract() {
const pendingNotesByTarget = new Map();
const queuedMessagesByTarget = new Map();
const localStorage = { removeItem() {}, setItem() {}, getItem() { return null; } };
const mcpSessionVisibilityToggle = new MiniElement('button');
const sessionList = new MiniElement('div');
sessionList.clearCount = 0;
Object.defineProperty(sessionList, 'innerHTML', {
@@ -3319,6 +3328,7 @@ function assertSidebarTitleRefreshStormContract() {
function startEditSessionTitle() {}
${optionalSources}
${dependencySource}
${createItemSource}
${renderListSource}
@@ -3904,10 +3914,14 @@ function assertSessionSwitchResilienceContract() {
'Server wsSend should guard JSON serialization failures'
);
assert(
runtimeSource.includes('CC_WEB_RUNTIME_FULL_TEXT_MAX_CHARS') &&
runtimeSource.includes('function appendCappedText') &&
runtimeSource.includes('entry.fullText = appendCappedText'),
'Classic runtime should cap accumulated fullText in memory'
runtimeSource.includes('function appendFullText') &&
runtimeSource.includes('entry.fullText = appendFullText'),
'Classic runtime should retain complete fullText in the production record'
);
assert(
!runtimeSource.includes('CC_WEB_RUNTIME_FULL_TEXT_MAX_CHARS') &&
!runtimeSource.includes('function appendCappedText'),
'Classic runtime must not discard the only complete record before archive persistence'
);
}
@@ -4734,7 +4748,7 @@ function assertServerSessionHistoryRequestIdContract() {
}
function assertHistoryRecallContract() {
for (const script of ['session-history-unit.js', 'codex-rollouts-unit.js', 'history-frontend-unit.js']) {
for (const script of ['session-history-store-unit.js', 'session-history-unit.js', 'history-runtime-unit.js', 'history-runtime-completion-unit.js', 'codex-rollouts-unit.js', 'history-frontend-unit.js', 'codexapp-retry-history-unit.js', 'streaming-retry-frontend-unit.js', 'assistant-bubble-controls-unit.js']) {
const result = spawnSync(process.execPath, [path.join(REPO_DIR, 'scripts', script)], { encoding: 'utf8', timeout: 60000 });
assert(result.status === 0, `${script}: ${result.stderr || result.stdout || result.error}`);
}
@@ -4772,7 +4786,7 @@ function assertHistoryRecallContract() {
/function buildUserOutlineItems[\s\S]*?currentOutlineHistoryState\.entries/.test(frontendSource),
'User-message locator should merge indexed history instead of reading only rendered DOM');
assert(
/userOutlineBtn\.disabled\s*=\s*!historyState\.loading[\s\S]*?historyState\.cursor\s*> 0[\s\S]*?historyState\.available/.test(frontendSource),
/userOutlineBtn\.disabled\s*=\s*historyState\.cursor\s*<=\s*0\s*&&\s*!historyStatus/.test(frontendSource),
'Locator button should remain openable when only older user messages are available');
assert(
/messagesDiv\.innerHTML = '';\s*if \(historyLoadMore\) messagesDiv\.appendChild\(historyLoadMore\)/.test(frontendSource),
@@ -4978,6 +4992,20 @@ function assertCodexAppStaleRunningRecoveryContract() {
steerBlock.includes('mcpContext: entry.mcpContext || options.mcpContext || {}'),
'Stale steer recovery should prefer the original active entry MCP context'
);
const noActiveTurnBlock = extractFunctionSource(serverSource, 'isCodexAppNoActiveTurnError');
const matchesNoActiveTurn = new Function(`${noActiveTurnBlock}; return isCodexAppNoActiveTurnError;`)();
assert(
matchesNoActiveTurn(new Error('no active turn to steer')),
'Legacy no-active-turn text should trigger stale steer recovery'
);
assert(
matchesNoActiveTurn(new Error('expected active turn id `turn-1`: no active turn')),
'Real app-server expected-active-turn text should trigger stale steer recovery'
);
assert(
!matchesNoActiveTurn(new Error('expectedTurnId does not match active turn')),
'Expected-turn mismatch must not be treated as a finished turn'
);
}
function assertCodexAppGoalLifecycleContract() {
@@ -5378,7 +5406,7 @@ async function runCodexAppStaleRunningRegression(options = {}) {
msg.type === 'codex_app_steer_status' &&
msg.sessionId === sessionId &&
msg.clientMessageId === 'regression-stale-running-follow-up' &&
msg.status === 'inserted'
msg.status === 'sent'
), 5000);
assert(!/失败/.test(recoveredStatus.message || ''), 'Recovered stale steer should update the UI as a non-failure');
const recoveredHint = await nextMessage(messages, ws, (msg) => (
@@ -5428,9 +5456,256 @@ async function runCodexAppStaleRunningRegression(options = {}) {
assert(staleAssistantIndex < followUpUserIndex && followUpUserIndex < recoveredAssistantIndex, 'Recovered history should keep stale assistant before follow-up user before recovered assistant');
assert(!fs.existsSync(path.join(sessionsDir, `${sessionId}-run`)), 'Recovered follow-up should clean the Codex App run directory after completion');
// 竞态场景:发送引导消息的瞬间旧 turn 刚好结束(turn/completed 先到、steer 失败后到),
// 服务端应恢复旧输出顺序并自动开启新一轮对话。
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: homeDir, mode: 'yolo' }));
const lateSession = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === homeDir && msg.sessionId !== sessionId
));
ws.send(JSON.stringify({
type: 'message',
text: 'codexapp late completion first',
sessionId: lateSession.sessionId,
mode: 'yolo',
agent: 'codexapp',
}));
await nextMessage(messages, ws, (msg) => (
msg.type === 'text_delta' && msg.sessionId === lateSession.sessionId && /late completion fixture ready/.test(msg.text || '')
), 5000);
await sleep(150);
ws.send(JSON.stringify({
type: 'message',
text: 'codexapp steer after completion',
sessionId: lateSession.sessionId,
mode: 'yolo',
agent: 'codexapp',
clientMessageId: 'regression-steer-after-completion',
}));
const latePending = await nextMessage(messages, ws, (msg) => (
msg.type === 'codex_app_steer_status' &&
msg.sessionId === lateSession.sessionId &&
msg.clientMessageId === 'regression-steer-after-completion' &&
msg.status === 'pending'
), 5000);
assert(latePending.status === 'pending', 'Steer-after-completion should first enter pending status');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === lateSession.sessionId, 5000);
const lateRecovered = await nextMessage(messages, ws, (msg) => (
msg.type === 'codex_app_steer_status' &&
msg.sessionId === lateSession.sessionId &&
msg.clientMessageId === 'regression-steer-after-completion' &&
msg.status === 'sent'
), 5000);
assert(/已转为新对话/.test(lateRecovered.message || ''), 'Steer-after-completion should recover as a replacement turn');
await nextMessage(messages, ws, (msg) => (
msg.type === 'resume_generating' && msg.sessionId === lateSession.sessionId
), 5000);
const lateHint = await nextMessage(messages, ws, (msg) => (
msg.type === 'system_message' && msg.sessionId === lateSession.sessionId && /已自动开始新一轮对话/.test(msg.message || '')
), 5000);
assert(lateHint.transient === true, 'Steer-after-completion hint should be transient');
await nextMessage(messages, ws, (msg) => (
msg.type === 'text_delta' && msg.sessionId === lateSession.sessionId && /Codex App mock handled: codexapp steer after completion/.test(msg.text || '')
), 5000);
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === lateSession.sessionId, 5000);
assert(
!messages.some((msg) => msg.code === 'codexapp_steer_failed' && msg.sessionId === lateSession.sessionId),
'Steer-after-completion should not emit codexapp_steer_failed'
);
const lateStored = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${lateSession.sessionId}.json`), 'utf8'));
assert(
lateStored.messages.filter((message) => message.role === 'user' && message.content === 'codexapp steer after completion').length === 1,
'Steer-after-completion user message should persist exactly once'
);
const lateOldAssistantIndex = lateStored.messages.findIndex((message) => message.role === 'assistant' && /Codex App mock handled: codexapp late completion first/.test(String(message.content || '')));
const lateUserIndex = lateStored.messages.findIndex((message) => message.role === 'user' && message.id === 'regression-steer-after-completion');
const lateNewAssistantIndex = lateStored.messages.findIndex((message) => message.role === 'assistant' && /Codex App mock handled: codexapp steer after completion/.test(String(message.content || '')));
assert(
lateOldAssistantIndex >= 0 && lateUserIndex >= 0 && lateNewAssistantIndex >= 0,
'Steer-after-completion should persist both turns and the steer message'
);
assert(
lateOldAssistantIndex < lateUserIndex && lateUserIndex < lateNewAssistantIndex,
'Steer-after-completion should keep old assistant before steer user before replacement assistant'
);
const lateResumeIndex = receivedMessages.findIndex((msg) => msg.type === 'resume_generating' && msg.sessionId === lateSession.sessionId);
const lateDeltaIndex = receivedMessages.findIndex((msg) => msg.type === 'text_delta' && msg.sessionId === lateSession.sessionId && /Codex App mock handled: codexapp steer after completion/.test(msg.text || ''));
assert(
lateResumeIndex >= 0 && lateResumeIndex < lateDeltaIndex,
'Steer-after-completion should restore generating UI before its replacement output'
);
// 表单答案同样应在旧 turn 已结束时自动转新对话,而不是停留在“插入失败”。
const promptInternalToken = 'StaleRunningMcp!234';
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: homeDir, mode: 'yolo' }));
const promptSession = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === homeDir
&& msg.sessionId !== sessionId && msg.sessionId !== lateSession.sessionId
));
ws.send(JSON.stringify({
type: 'message',
text: 'codexapp stale running first',
sessionId: promptSession.sessionId,
mode: 'yolo',
agent: 'codexapp',
}));
await nextMessage(messages, ws, (msg) => (
msg.type === 'text_delta' && msg.sessionId === promptSession.sessionId && /Codex App stale turn output/.test(msg.text || '')
), 5000);
await sleep(150);
const promptUserResult = await callInternalMcp(port, promptInternalToken, {
tool: 'ccweb_prompt_user',
sourceSessionId: promptSession.sessionId,
sourceHopCount: 0,
args: {
title: 'Stale 表单',
questions: [
{
id: 'stale_choice',
title: '继续方式',
question: '在旧 turn 已结束时提交表单。',
required: true,
options: [
{ id: 'continue', label: '继续', answerText: '请继续处理 stale 表单。' },
],
},
],
},
});
assert(promptUserResult.status === 200 && promptUserResult.body?.ok, `Stale prompt user should render: ${JSON.stringify(promptUserResult.body)}`);
const promptRendered = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_message' &&
msg.sessionId === promptSession.sessionId &&
msg.message?.ccwebPrompt?.id === promptUserResult.body.promptId
), 5000);
assert(promptRendered.message.ccwebPrompt.status === 'pending', 'Stale prompt should start pending');
ws.send(JSON.stringify({
type: 'ccweb_prompt_user_response',
sessionId: promptSession.sessionId,
promptId: promptUserResult.body.promptId,
answers: {
stale_choice: {
selectedOptionIds: ['continue'],
answerText: '请继续处理 stale 表单。',
},
},
}));
const promptRemoved = await nextMessage(messages, ws, (msg) => (
msg.type === 'ccweb_prompt_user_remove' &&
msg.sessionId === promptSession.sessionId &&
msg.promptId === promptUserResult.body.promptId
), 5000);
assert(promptRemoved.prompt?.status === 'submitted', 'Stale prompt should be marked submitted');
const promptSteerBubble = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_message' &&
msg.sessionId === promptSession.sessionId &&
msg.message?.role === 'user' &&
msg.message?.codexAppSteerStatus === 'pending'
), 5000);
assert(/请继续处理 stale 表单/.test(promptSteerBubble.message.content || ''), 'Stale prompt answer bubble should carry the submitted answer');
const promptRecovered = await nextMessage(messages, ws, (msg) => (
msg.type === 'codex_app_steer_status' &&
msg.sessionId === promptSession.sessionId &&
msg.status === 'sent' && /已转为新对话/.test(msg.message || '')
), 5000);
assert(promptRecovered.status === 'sent', 'Stale prompt answer should recover as a replacement turn');
await nextMessage(messages, ws, (msg) => (
msg.type === 'resume_generating' && msg.sessionId === promptSession.sessionId
), 5000);
await nextMessage(messages, ws, (msg) => (
msg.type === 'text_delta' && msg.sessionId === promptSession.sessionId && /请继续处理 stale 表单/.test(msg.text || '')
), 5000);
// 降级收敛旧 entry 时也会发一个 done,这里要等带新答案输出的那个完成回执。
await nextMessage(messages, ws, (msg) => (
msg.type === 'done' && msg.sessionId === promptSession.sessionId &&
/请继续处理 stale 表单/.test(String(msg.assistantMessage?.content || ''))
), 5000);
const promptStored = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${promptSession.sessionId}.json`), 'utf8'));
const promptAnswerUsers = promptStored.messages.filter((message) => (
message.role === 'user' && /请继续处理 stale 表单/.test(String(message.content || ''))
));
assert(promptAnswerUsers.length === 1, 'Stale prompt answer should persist exactly once');
assert(
!promptStored.messages.some((message) => message.ccwebPrompt?.id === promptUserResult.body.promptId),
'Submitted stale prompt should be removed from history'
);
const promptStaleAssistantIndex = promptStored.messages.findIndex((message) => (
message.role === 'assistant' && /Codex App stale turn output/.test(String(message.content || ''))
));
const promptAnswerUserIndex = promptStored.messages.findIndex((message) => (
message.role === 'user' && /请继续处理 stale 表单/.test(String(message.content || ''))
));
const promptNewAssistantIndex = promptStored.messages.findIndex((message) => (
message.role === 'assistant' && /请继续处理 stale 表单/.test(String(message.content || ''))
));
assert(
promptStaleAssistantIndex >= 0 && promptAnswerUserIndex >= 0 && promptNewAssistantIndex >= 0,
'Stale prompt recovery should persist old output, answer message and replacement output'
);
assert(
promptStaleAssistantIndex < promptAnswerUserIndex && promptAnswerUserIndex < promptNewAssistantIndex,
'Stale prompt recovery should keep old assistant before answer user before replacement assistant'
);
// 插入确实失败后,旧轮结束,再用同一条消息与附件重试:普通发送必须有确认回执。
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: homeDir, mode: 'yolo' }));
const retrySession = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === homeDir
&& ![sessionId, lateSession.sessionId, promptSession.sessionId].includes(msg.sessionId)
));
ws.send(JSON.stringify({
type: 'message', text: 'codexapp slow retry first', sessionId: retrySession.sessionId,
mode: 'yolo', agent: 'codexapp',
}));
await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' && msg.sessions.some((session) => session.id === retrySession.sessionId && session.isRunning)
), 5000);
const retryAttachment = await uploadAttachment(port, token, {
filename: 'finished-retry.png', mime: 'image/png', data: Buffer.from('finished-retry-image'),
});
const retryPayload = {
type: 'message', text: 'codexapp retry after completed', sessionId: retrySession.sessionId,
mode: 'plan', agent: 'codexapp', clientMessageId: 'regression-finished-steer-retry',
codexAppSteer: true, attachments: [retryAttachment],
};
ws.send(JSON.stringify(retryPayload));
await nextMessage(messages, ws, (msg) => (
msg.type === 'codex_app_steer_status' && msg.sessionId === retrySession.sessionId
&& msg.clientMessageId === retryPayload.clientMessageId && msg.status === 'failed'
), 5000);
await nextMessage(messages, ws, (msg) => (
msg.type === 'error' && msg.sessionId === retrySession.sessionId && msg.code === 'codexapp_steer_failed'
), 5000);
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === retrySession.sessionId, 5000);
const beforeRetry = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${retrySession.sessionId}.json`), 'utf8'));
assert(!beforeRetry.messages.some((message) => message.id === retryPayload.clientMessageId), 'Failed steer should not leave a persisted retry message');
const retryStartIndex = receivedMessages.length;
ws.send(JSON.stringify(retryPayload));
const retryEcho = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_message' && msg.sessionId === retrySession.sessionId && msg.message?.id === retryPayload.clientMessageId
), 5000);
assert(!retryEcho.message.codexAppSteerStatus, 'Finished-turn retry should echo a normal user message');
await nextMessage(messages, ws, (msg) => (
msg.type === 'codex_app_steer_status' && msg.sessionId === retrySession.sessionId
&& msg.clientMessageId === retryPayload.clientMessageId && msg.status === 'sent'
), 5000);
await nextMessage(messages, ws, (msg) => msg.type === 'resume_generating' && msg.sessionId === retrySession.sessionId, 5000);
const retryDelta = await nextMessage(messages, ws, (msg) => (
msg.type === 'text_delta' && msg.sessionId === retrySession.sessionId && /codexapp retry after completed/.test(msg.text || '')
), 5000);
assert(storedAttachmentImageMarker(retryAttachment).test(retryDelta.text || ''), 'Retry must deliver the original image to the new turn');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === retrySession.sessionId, 5000);
const afterRetry = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${retrySession.sessionId}.json`), 'utf8'));
const retriedUsers = afterRetry.messages.filter((message) => message.role === 'user' && message.id === retryPayload.clientMessageId);
assert(retriedUsers.length === 1, 'Finished-turn retry should persist the original stable message ID exactly once');
assertPersistedMessageAttachment(retriedUsers[0], retryAttachment.filename, 'Finished-turn retry message');
assert(afterRetry.permissionMode === 'plan', 'Finished-turn retry should preserve the original mode');
assert(!receivedMessages.slice(retryStartIndex).some((msg) => msg.type === 'error' && msg.sessionId === retrySession.sessionId), 'Successful retry should not report another insertion failure');
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: homeDir, mode: 'yolo' }));
const mismatchSession = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === homeDir && msg.sessionId !== sessionId
msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === homeDir
&& ![sessionId, lateSession.sessionId, promptSession.sessionId, retrySession.sessionId].includes(msg.sessionId)
));
ws.send(JSON.stringify({
type: 'message',
@@ -6130,7 +6405,9 @@ function assertCodexAppChildToolRoutingContract() {
assert(!JSON.parse(oldTool.result).agentsStates?.['missing-route-thread'], 'Unknown spawn tool ids must not contaminate older child cards');
const syncSource = extractFunctionSource(source, 'syncCcwebMcpChildAgentsFromCollabItem');
const syncApi = new Function(`
const { createCodexAppRuntime } = require(path.join(REPO_DIR, 'lib', 'codex-app-runtime'));
const codexAppRuntime = createCodexAppRuntime();
const syncApi = new Function('codexAppRuntime', `
const ccwebMcpChildThreads = new Map();
const sent = [];
const path = { basename: (value) => String(value || '').split('/').filter(Boolean).pop() || '' };
@@ -6156,7 +6433,7 @@ function assertCodexAppChildToolRoutingContract() {
sync: syncCcwebMcpChildAgentsFromCollabItem,
sent,
};
`)();
`)(codexAppRuntime);
syncApi.setChild('current-plan-thread', {
threadId: 'current-plan-thread',
parentSessionId: session.id,
@@ -6222,7 +6499,7 @@ function assertCodexAppUnroutedNotificationRoutingContract() {
);
assert(source.includes('function updateSessionRuntimeThreadIndex'), 'Server should centralize session thread index maintenance');
assert(
/function saveSession\(session\)[\s\S]*?updateSessionRuntimeThreadIndex\(session\)/.test(source),
extractFunctionSource(source, 'saveSession').includes('updateSessionRuntimeThreadIndex(session)'),
'Saving a session should refresh the runtime thread index'
);
assert(
@@ -9524,7 +9801,9 @@ async function main() {
ws.send(JSON.stringify({ type: 'message', text: 'slow codexapp prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexAppSession.sessionId && s.isRunning));
await sleep(500);
await nextMessage(messages, ws, (msg) => (
msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /active steer fixture ready/.test(msg.text || '')
));
ws.send(JSON.stringify({
type: 'message',
text: '/runtime/report',
@@ -9902,7 +10181,7 @@ async function main() {
try {
const { ws, messages } = await connectWs(recoveryPort, password);
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((session) => session.id === oversizedRecoverySessionId));
assert(!fs.existsSync(oversizedRecoveryStateDir), 'Oversized Codex App recovery state directory should be cleaned without parsing the state');
assert(fs.existsSync(oversizedRecoveryStateDir), 'Oversized Codex App recovery state directory should be retained as the only recovery evidence');
ws.send(JSON.stringify({ type: 'load_session', sessionId: oversizedRecoverySessionId }));
const oversizedSessionInfo = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.sessionId === oversizedRecoverySessionId);
assert((oversizedSessionInfo.messages || []).some((message) => (