修复会话切换气泡丢失并重新打包
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user