修复会话切换气泡丢失并重新打包

This commit is contained in:
shiyue
2026-09-14 22:03:23 +08:00
parent b1cd820819
commit a1e66c08f1
6 changed files with 558 additions and 26 deletions

View File

@@ -1631,6 +1631,10 @@ function assertCcwebMcpRecoveryContract() {
const runtimeSource = fs.readFileSync(path.join(REPO_DIR, 'lib', 'agent-runtime.js'), 'utf8');
const mockSource = fs.readFileSync(MOCK_CODEX_APP_SERVER, 'utf8');
const startTurnSource = extractFunctionSource(serverSource, 'startCodexAppTurn');
const statusSource = extractFunctionSource(serverSource, 'codexAppMcpStatusForThread');
const readinessSource = extractFunctionSource(serverSource, 'waitForCodexAppMcpReadyStatus');
const preflightSource = extractFunctionSource(serverSource, 'ensureCodexAppMcpReadyForThread');
const inventorySource = extractFunctionSource(serverSource, 'loadCodexAppMcpInventory');
assert(
serverSource.includes('CC_WEB_CODEX_APP_MCP_STARTUP_TIMEOUT_SEC')
&& serverSource.includes('CODEX_APP_MCP_STARTUP_TIMEOUT_SEC,')
@@ -1639,8 +1643,13 @@ function assertCcwebMcpRecoveryContract() {
&& serverSource.includes('queryCodexAppMcpInventory')
&& serverSource.includes('inventory_thread_mismatch')
&& startTurnSource.includes('ensureCodexAppMcpReadyForThread')
&& readinessSource.includes("status === 'ready'")
&& statusSource.includes('statusThreadId !== normalizedThreadId')
&& preflightSource.includes('waitForCodexAppMcpReadyStatus')
&& !preflightSource.includes('waitForCodexAppMcpInventory')
&& inventorySource.includes('return []')
&& !serverSource.includes('allowThreadMismatch: true'),
'ccweb MCP recovery should expose configurable windows, strict thread routing, and inventory verification'
'ccweb MCP recovery should gate turns on current-thread startup status and degrade inventory lookup safely'
);
assert(
serverSource.includes('codex_app_mcp_reload_timeout')
@@ -1663,6 +1672,19 @@ function assertCcwebMcpRecoveryContract() {
);
}
function assertCodexAppMcpReadinessContract() {
const source = fs.readFileSync(SERVER_PATH, 'utf8');
const workerClientSource = fs.readFileSync(path.join(REPO_DIR, 'lib', 'codex-app-worker-client.js'), 'utf8');
const statusSource = extractFunctionSource(source, 'codexAppMcpStatusForThread');
const readinessSource = extractFunctionSource(source, 'waitForCodexAppMcpReadyStatus');
const inventorySource = extractFunctionSource(source, 'loadCodexAppMcpInventory');
assert(readinessSource.includes("status === 'ready'"), 'Codex App first turn should wait for current-thread ccweb readiness');
assert(readinessSource.includes("status === 'failed'") && readinessSource.includes("status === 'cancelled'"), 'Codex App readiness should surface terminal MCP startup failures');
assert(statusSource.includes('statusThreadId !== normalizedThreadId'), 'Codex App readiness must reject stale status from another thread');
assert(inventorySource.includes('return []'), 'Composer MCP inventory failure should degrade to local suggestions');
assert(workerClientSource.includes("sendWorker('start', {}, 120000)"), 'Codex App worker start should cover initialize and best-effort capability probes');
}
function assertFrontendSubagentCardMetadataContract() {
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
@@ -3599,6 +3621,14 @@ function assertSessionSwitchResilienceContract() {
/case 'resume_session_result':[\s\S]*?if \(!msg\.isRunning && currentSessionId && msg\.sessionId === currentSessionId\) \{[\s\S]*?finishGenerating\(msg\.sessionId \|\| currentSessionId\);[\s\S]*?\}[\s\S]*?break;/.test(frontendSource),
'Frontend idle resume result should finish generation state for the current session'
);
assert(
/case 'resume_session_result':[\s\S]*?reconcileRenderedSessionMessages\(msg\.messages/.test(frontendSource),
'Frontend resume should idempotently restore persisted bubbles after a reconnect'
);
assert(
/preserveStreaming[\s\S]*?reconcileRenderedSessionMessages\(snapshot\.messages/.test(frontendSource),
'Frontend running-session snapshots should reconcile persisted bubbles instead of skipping history'
);
assert(frontendSource.includes('recoverCurrent: true'), 'Frontend fallback load_session should preserve the current running view');
const visibilityStart = frontendSource.indexOf("document.addEventListener('visibilitychange'");
const visibilityEnd = visibilityStart >= 0 ? frontendSource.indexOf("if (!authToken)", visibilityStart) : -1;
@@ -3665,6 +3695,15 @@ function assertSessionSwitchResilienceContract() {
'Server auth_result should advertise client heartbeat support'
);
assert(serverSource.includes('function handleResumeSession'), 'Server should implement lightweight running-session resume');
assert(
/function handleResumeSession[\s\S]*?historyTotal[\s\S]*?recentMessages/.test(serverSource),
'Server resume result should carry recent history metadata for reconnect recovery'
);
const resumeSource = extractFunctionSource(serverSource, 'handleResumeSession');
assert(
resumeSource.indexOf("type: 'resume_session_result'") < resumeSource.indexOf('attachActiveRuntimeToWs(ws, sessionId, msg)'),
'Server resume should deliver persisted history before the streaming bubble'
);
assert(serverSource.includes('function attachActiveRuntimeToWs'), 'Server should share runtime re-attach logic without sending session_info first');
assert(/case 'abort':\s*handleAbort\(ws, msg\);/.test(serverSource), 'Server should pass abort request metadata to handleAbort');
assert(/function handleAbort\(ws, msg = \{\}\)/.test(serverSource), 'Server handleAbort should accept the abort request payload');
@@ -3708,6 +3747,7 @@ function assertSessionRenderEpochRaceContract() {
let loadedHistorySessionId = 'session-a';
let activeSessionLoad = null;
let currentSessionId = 'session-a';
let pendingMissingSessionConfirmation = null;
let closedCollabAgentIds = new Set();
let collabAgentStateCache = new Map();
let collabAgentIdsByToolUseId = new Map();
@@ -3743,6 +3783,7 @@ function assertSessionRenderEpochRaceContract() {
activeSessionLoad = sessionId ? { sessionId, overlayReleased: false } : null;
}
function requestSessionLoad() {}
function clearPendingMissingSessionConfirmation() {}
function collectClosedCollabAgentIds() { return new Set(); }
function clearUserMessageIndex() {}
function buildWelcomeMarkup() { return '<p>welcome</p>'; }
@@ -3934,6 +3975,103 @@ function assertSessionRequestIdRaceContract() {
assert(failures.length === 0, failures.join('; '));
}
function assertSessionListMissingCurrentResilienceContract() {
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
const followOutputSource = extractFunctionSource(frontendSource, 'followOutputIfNeeded');
assert(
!followOutputSource.includes('function reconcileRenderedSessionMessages'),
'Historical message reconciliation must remain top-level and callable during session recovery'
);
const clearSource = extractFunctionSource(frontendSource, 'clearPendingMissingSessionConfirmation');
const scheduleSource = extractFunctionSource(frontendSource, 'scheduleMissingSessionConfirmation');
const reconcileSource = extractFunctionSource(frontendSource, 'reconcileCurrentSessionListSnapshot');
assert(frontendSource.includes('SESSION_LIST_MISSING_CONFIRM_MS'), 'Frontend should define a confirmation window for a missing current session');
assert(clearSource && scheduleSource && reconcileSource, 'Frontend should isolate missing-current-session confirmation helpers');
const api = new Function(`
const SESSION_LIST_MISSING_CONFIRM_MS = 10;
let currentSessionId = 'session-a';
let currentAgent = 'codexapp';
let sessions = [
{ id: 'session-a', title: '当前会话' },
{ id: 'session-b', title: '另一个会话' },
];
let activeSessionLoad = null;
let pendingSessionSwitchRequest = null;
let pendingSessionResumeRequest = null;
let pendingMissingSessionConfirmation = null;
let resetCount = 0;
const sent = [];
const timers = [];
function setTimeout(callback) {
const timer = { callback, active: true };
timers.push(timer);
return timer;
}
function clearTimeout(timer) {
if (timer) timer.active = false;
}
function send(payload) { sent.push(payload); }
function resetChatView() {
resetCount += 1;
currentSessionId = null;
}
${clearSource}
${scheduleSource}
${reconcileSource}
return {
snapshot(nextSessions) {
const result = reconcileCurrentSessionListSnapshot(nextSessions);
sessions = result;
return result;
},
setCurrent(sessionId) { currentSessionId = sessionId; },
setSessions(value) { sessions = value; },
resetCount: () => resetCount,
sentCount: () => sent.length,
pendingCount: () => pendingMissingSessionConfirmation?.missingCount || 0,
flushTimers() {
const pending = timers.splice(0);
pending.filter((timer) => timer.active).forEach((timer) => {
timer.active = false;
timer.callback();
});
},
};
`)();
const firstMissing = api.snapshot([{ id: 'session-b', title: '另一个会话' }]);
assert(firstMissing.some((session) => session.id === 'session-a'), 'A transiently incomplete session_list must retain the last known current session metadata');
assert(api.resetCount() === 0, 'The first missing session_list must not reset the chat view');
assert(api.sentCount() === 1 && api.pendingCount() === 1, 'The first missing session_list must schedule one retry confirmation');
api.flushTimers();
assert(api.resetCount() === 0, 'The retry confirmation window must not reset after only one missing snapshot');
api.snapshot([{ id: 'session-a', title: '当前会话' }, { id: 'session-b', title: '另一个会话' }]);
api.flushTimers();
assert(api.resetCount() === 0 && api.pendingCount() === 0, 'A recovered session_list must cancel the stale reset timer');
api.setCurrent('session-a');
api.setSessions([{ id: 'session-a', title: '当前会话' }, { id: 'session-b', title: '另一个会话' }]);
api.snapshot([{ id: 'session-b', title: '另一个会话' }]);
api.snapshot([{ id: 'session-b', title: '另一个会话' }]);
api.flushTimers();
assert(api.resetCount() === 1, 'A continuously missing current session must reset only after the second confirmation');
const sessionListStart = frontendSource.indexOf("case 'session_list':");
const sessionListEnd = frontendSource.indexOf("case 'session_search_results':", sessionListStart);
const sessionListHandler = sessionListStart >= 0 && sessionListEnd > sessionListStart
? frontendSource.slice(sessionListStart, sessionListEnd)
: '';
assert(sessionListHandler && !/currentSessionId[\s\S]*?resetChatView\(currentAgent\)/.test(sessionListHandler),
'session_list must not immediately clear the current chat view when the current id is absent');
const serverListSource = extractFunctionSource(serverSource, 'sendSessionList');
assert(serverListSource.includes('session_list_load_failed') && !serverListSource.includes('sessions: []'),
'Server session-list read failures must not broadcast an empty list that looks like deletion');
}
function assertBlockingFinishRafRequestRaceContract() {
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const isBlockingSessionLoadSource = extractFunctionSource(frontendSource, 'isBlockingSessionLoad');
@@ -4250,6 +4388,7 @@ function assertSessionSwitchCacheBehaviorContract() {
]);
let currentSessionId = 'session-b';
let activeSessionLoad = null;
let pendingMissingSessionConfirmation = null;
const loads = [];
const shown = [];
const taskBoardViewState = { open: false };
@@ -4281,6 +4420,7 @@ function assertSessionSwitchCacheBehaviorContract() {
function closeUserOutlinePanel() {}
function closeCcwebPromptOutlinePanel() {}
function beginSessionSwitch(sessionId) { loads.push(sessionId); }
function clearPendingMissingSessionConfirmation() {}
function showCachedSession(sessionId) { shown.push(sessionId); return true; }
${openSource}
return {
@@ -4544,6 +4684,7 @@ function assertSessionSwitchRaceContract() {
const checks = [
['render epoch behavior', assertSessionRenderEpochRaceContract],
['frontend requestId behavior', assertSessionRequestIdRaceContract],
['missing current session_list behavior', assertSessionListMissingCurrentResilienceContract],
['blocking finish RAF request behavior', assertBlockingFinishRafRequestRaceContract],
['recoverCurrent history merge behavior', assertRecoverCurrentHistoryMergeContract],
['partial session snapshot cache behavior', assertPartialSessionSnapshotCacheContract],
@@ -7046,6 +7187,11 @@ async function main() {
console.log('Codex App branch fork regression checks passed.');
return;
}
if (regressionTarget === 'codexapp-mcp-readiness') {
assertCodexAppMcpReadinessContract();
console.log('Codex App MCP readiness regression checks passed.');
return;
}
if (regressionTarget === 'goal-mode-title') {
assertGoalModeTitleContract();
console.log('Goal mode/title regression checks passed.');
@@ -7068,6 +7214,7 @@ async function main() {
assertMockCodexAppPromptUserNotTextTriggered();
assertFrontendMcpReloadContract();
assertCcwebMcpRecoveryContract();
assertCodexAppMcpReadinessContract();
assertPlanListProgressContract();
assertFrontendSubagentCardMetadataContract();
assertCodexAppRuntimeSubAgentActivityContract();