diff --git a/config/cross-conversation-replies.json b/config/cross-conversation-replies.json index 6e823b1..210cacd 100644 --- a/config/cross-conversation-replies.json +++ b/config/cross-conversation-replies.json @@ -1,5 +1,5 @@ { "version": 1, - "updatedAt": "2026-06-30T15:42:26.421Z", + "updatedAt": "2026-07-02T06:02:53.126Z", "replies": [] } \ No newline at end of file diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz index 0e2c3e1..557e9d8 100644 Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ diff --git a/lib/agent-runtime.js b/lib/agent-runtime.js index f955b7f..16d1c23 100644 --- a/lib/agent-runtime.js +++ b/lib/agent-runtime.js @@ -23,6 +23,32 @@ function createAgentRuntime(deps) { getRuntimeSessionId, } = deps; + function readRuntimePositiveIntEnv(name, fallback, options = {}) { + const raw = Number.parseInt(String(processEnv?.[name] || ''), 10); + const min = Number.isFinite(options.min) ? options.min : 1; + const max = Number.isFinite(options.max) ? options.max : Number.MAX_SAFE_INTEGER; + if (!Number.isFinite(raw) || raw <= 0) return fallback; + return Math.max(min, Math.min(max, raw)); + } + + const RUNTIME_FULL_TEXT_MAX_CHARS = readRuntimePositiveIntEnv( + 'CC_WEB_RUNTIME_FULL_TEXT_MAX_CHARS', + 256 * 1024, + { min: 4096 }, + ); + const RUNTIME_TRUNCATED_HEAD = '[cc-web: 前文过长,已保留尾部以保护服务稳定性]\n'; + + function keepTail(value, maxLen) { + const text = String(value || ''); + if (!Number.isFinite(maxLen) || maxLen <= 0 || text.length <= maxLen) return text; + const keep = Math.max(0, maxLen - RUNTIME_TRUNCATED_HEAD.length); + return `${RUNTIME_TRUNCATED_HEAD}${text.slice(-keep)}`; + } + + function appendCappedText(current, addition, maxLen = RUNTIME_FULL_TEXT_MAX_CHARS) { + return keepTail(`${String(current || '')}${String(addition || '')}`, maxLen); + } + function tomlString(value) { return JSON.stringify(String(value || '')); } @@ -330,7 +356,7 @@ function createAgentRuntime(deps) { ? (/\n\s*$/.test(currentText) ? `\n${createAgentMessageDivider()}\n\n` : `\n\n${createAgentMessageDivider()}\n\n`) : ''; const chunk = separator + nextText; - entry.fullText += chunk; + entry.fullText = appendCappedText(entry.fullText || '', chunk); return chunk; } @@ -383,7 +409,7 @@ function createAgentRuntime(deps) { for (const block of content) { if (block.type === 'text' && block.text) { - entry.fullText += block.text; + entry.fullText = appendCappedText(entry.fullText || '', block.text); sendRuntime(entry, sessionId, { type: 'text_delta', text: block.text }); } else if (block.type === 'tool_use') { const toolInput = sanitizeToolInput(block.name, block.input); diff --git a/public/app.js b/public/app.js index 24859aa..abfd051 100644 --- a/public/app.js +++ b/public/app.js @@ -2,7 +2,7 @@ (function () { 'use strict'; - const ASSET_VERSION = '20260629-ccweb-prompt-dark-theme'; + const ASSET_VERSION = '20260702-visible-no-rerender'; const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`; const RENDER_DEBOUNCE = 100; const COMPOSER_SUGGESTION_DEBOUNCE = 120; @@ -76,6 +76,8 @@ const OLD_SESSION_COLLAPSE_DAYS = 7; const OLD_SESSION_COLLAPSE_MS = OLD_SESSION_COLLAPSE_DAYS * 24 * 60 * 60 * 1000; const SESSION_LOAD_OVERLAY_TIMEOUT_MS = 12_000; + const SESSION_LOAD_REQUEST_TIMEOUT_MS = 45_000; + const SESSION_RESUME_FALLBACK_MS = 1_500; const MODEL_OPTIONS = [ { value: 'opus', label: 'Opus', desc: '最强大,1M 上下文' }, @@ -183,6 +185,8 @@ let loadedHistorySessionId = null; let activeSessionLoad = null; let sessionLoadOverlayTimer = null; + let sessionLoadRequestTimer = null; + let sessionResumeFallbackTimer = null; let sidebarSwipe = null; let activeComposerToken = null; let composerSuggestionTimer = null; @@ -202,6 +206,7 @@ let codexAppApprovalModal = null; let pendingNewSessionRequest = null; let pendingSessionSwitchRequest = null; + let pendingSessionResumeRequest = null; let sessionSwitchRequestSeq = 0; let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1'; let pendingInitialSessionLoad = false; @@ -4126,7 +4131,11 @@ if (fileBrowserState && (fileBrowserState.sessionId !== snapshot.sessionId || (snapshot.cwd && fileBrowserState.rootPath !== snapshot.cwd))) { closeFileBrowser(); } - const preserveStreaming = !!(options.preserveStreaming && isGenerating && snapshot.sessionId === currentSessionId && snapshot.isRunning); + const hasStreamingElement = !!document.getElementById('streaming-msg'); + const preserveStreaming = !!(options.preserveStreaming && + snapshot.sessionId === currentSessionId && + snapshot.isRunning && + (isGenerating || currentSessionRunning || hasStreamingElement)); if (isGenerating && !preserveStreaming) { isGenerating = false; generatingSessionId = null; @@ -4220,6 +4229,59 @@ sessionLoadOverlayTimer = null; } + function clearSessionLoadRequestTimer() { + if (!sessionLoadRequestTimer) return; + clearTimeout(sessionLoadRequestTimer); + sessionLoadRequestTimer = null; + } + + function clearSessionResumeFallbackTimer() { + if (!sessionResumeFallbackTimer) return; + clearTimeout(sessionResumeFallbackTimer); + sessionResumeFallbackTimer = null; + } + + function clearPendingSessionSwitchRequest(sessionId, requestId) { + if (!pendingSessionSwitchRequest) return; + if (sessionId && pendingSessionSwitchRequest.sessionId !== sessionId) return; + if (requestId && pendingSessionSwitchRequest.requestId !== requestId) return; + pendingSessionSwitchRequest = null; + } + + function clearPendingSessionResumeRequest(sessionId, requestId) { + if (!pendingSessionResumeRequest) return; + if (sessionId && pendingSessionResumeRequest.sessionId !== sessionId) return; + if (requestId && pendingSessionResumeRequest.requestId !== requestId) return; + pendingSessionResumeRequest = null; + clearSessionResumeFallbackTimer(); + } + + function notifySessionLoadTimeout(sessionId) { + const meta = sessionId ? getSessionMeta(sessionId) : null; + const title = meta?.title ? `“${meta.title}”` : '所选会话'; + appendError(`${title} 加载超时,已取消本次切换。若服务刚重启或网络恢复后,可重新点击该会话。`, { + transient: true, + autoDismissMs: 9000, + preserveScroll: false, + }); + } + + function scheduleSessionLoadRequestTimeout(sessionId, requestId) { + clearSessionLoadRequestTimer(); + if (!sessionId || !requestId) return; + sessionLoadRequestTimer = setTimeout(() => { + sessionLoadRequestTimer = null; + if (!activeSessionLoad || + activeSessionLoad.sessionId !== sessionId || + activeSessionLoad.requestId !== requestId) { + return; + } + clearPendingSessionSwitchRequest(sessionId, requestId); + clearSessionLoading(sessionId); + notifySessionLoadTimeout(sessionId); + }, SESSION_LOAD_REQUEST_TIMEOUT_MS); + } + function releaseSessionLoadingOverlay({ keepActiveLoad = true, allowRetry = false } = {}) { clearSessionLoadOverlayTimer(); document.body.classList.remove('session-loading-active'); @@ -4236,10 +4298,19 @@ function setSessionLoading(sessionId, options = {}) { clearSessionLoadOverlayTimer(); + clearSessionLoadRequestTimer(); const loading = !!sessionId; const blocking = options.blocking !== false; const requestId = loading ? (options.requestId || createSessionSwitchRequestId(sessionId)) : ''; - activeSessionLoad = loading ? { sessionId, blocking, snapshot: null, requestId, overlayReleased: false } : null; + activeSessionLoad = loading ? { + sessionId, + blocking, + snapshot: null, + requestId, + overlayReleased: false, + recoverCurrent: options.recoverCurrent === true, + } : null; + if (loading) scheduleSessionLoadRequestTimeout(sessionId, requestId); const showOverlay = !!(loading && blocking); if (showOverlay) { document.body.classList.add('session-loading-active'); @@ -4315,6 +4386,7 @@ blocking: options.blocking !== false, label: options.label || '', requestId: options.requestId || activeSessionLoad?.requestId || createSessionSwitchRequestId(sessionId), + recoverCurrent: options.recoverCurrent === true, }; if (ws && ws.readyState === 1 && wsAuthenticated) { flushPendingSessionSwitch(); @@ -4323,9 +4395,42 @@ if (!ws || ws.readyState > 1) connect(); } + function requestSessionResume(sessionId, options = {}) { + if (!sessionId) return; + pendingSessionResumeRequest = { + sessionId, + requestId: options.requestId || createSessionSwitchRequestId(sessionId), + }; + if (ws && ws.readyState === 1 && wsAuthenticated) { + flushPendingSessionResume(); + return; + } + if (!ws || ws.readyState > 1) connect(); + } + + function scheduleSessionResumeFallback(sessionId, requestId) { + clearSessionResumeFallbackTimer(); + if (!sessionId || !requestId) return; + sessionResumeFallbackTimer = setTimeout(() => { + sessionResumeFallbackTimer = null; + if (!pendingSessionResumeRequest || + pendingSessionResumeRequest.sessionId !== sessionId || + pendingSessionResumeRequest.requestId !== requestId) { + return; + } + pendingSessionResumeRequest = null; + requestSessionLoad(sessionId, { + blocking: false, + label: '正在恢复运行输出…', + requestId: createSessionSwitchRequestId(sessionId), + recoverCurrent: true, + }); + }, SESSION_RESUME_FALLBACK_MS); + } + function flushPendingSessionSwitch() { - if (!pendingSessionSwitchRequest) return; - if (!ws || ws.readyState !== 1 || !wsAuthenticated) return; + if (!pendingSessionSwitchRequest) return false; + if (!ws || ws.readyState !== 1 || !wsAuthenticated) return false; const request = pendingSessionSwitchRequest; pendingSessionSwitchRequest = null; if (!activeSessionLoad) { @@ -4333,9 +4438,20 @@ blocking: request.blocking, label: request.label || undefined, requestId: request.requestId, + recoverCurrent: request.recoverCurrent === true, }); } ws.send(JSON.stringify({ type: 'load_session', sessionId: request.sessionId, requestId: request.requestId })); + return true; + } + + function flushPendingSessionResume() { + if (!pendingSessionResumeRequest) return false; + if (!ws || ws.readyState !== 1 || !wsAuthenticated) return false; + const request = pendingSessionResumeRequest; + ws.send(JSON.stringify({ type: 'resume_session', sessionId: request.sessionId, requestId: request.requestId })); + scheduleSessionResumeFallback(request.sessionId, request.requestId); + return true; } function showCachedSession(sessionId) { @@ -4883,9 +4999,15 @@ if (activeSessionLoad?.sessionId && !isPageUnloading) { pendingSessionSwitchRequest = { sessionId: activeSessionLoad.sessionId, - blocking: activeSessionLoad.blocking, + blocking: false, label: sessionLoadingLabel?.textContent || '', requestId: activeSessionLoad.requestId || createSessionSwitchRequestId(activeSessionLoad.sessionId), + recoverCurrent: activeSessionLoad.recoverCurrent === true, + }; + } else if (currentSessionId && (isGenerating || currentSessionRunning) && !isPageUnloading) { + pendingSessionResumeRequest = { + sessionId: currentSessionId, + requestId: createSessionSwitchRequestId(currentSessionId), }; } clearSessionLoading(); @@ -4934,7 +5056,18 @@ document.dispatchEvent(new CustomEvent('cc-web-auth-restored')); loginOverlay.hidden = true; app.hidden = false; - flushPendingSessionSwitch(); + const flushedSessionSwitch = flushPendingSessionSwitch(); + const flushedSessionResume = flushedSessionSwitch ? false : flushPendingSessionResume(); + if (!flushedSessionSwitch && + !flushedSessionResume && + !pendingSessionSwitchRequest && + !pendingSessionResumeRequest && + currentSessionId && + (isGenerating || currentSessionRunning)) { + requestSessionResume(currentSessionId, { + requestId: createSessionSwitchRequestId(currentSessionId), + }); + } send({ type: 'get_codex_config' }); // Check if must change password if (msg.mustChangePassword) { @@ -4944,6 +5077,8 @@ } } else { pendingSessionSwitchRequest = null; + pendingSessionResumeRequest = null; + clearSessionResumeFallbackTimer(); clearSessionLoading(); authToken = null; wsAuthenticated = false; @@ -5007,7 +5142,7 @@ const canSwitchToSessionInfo = matchesActiveLoad || matchesPendingNewSession || msg.sessionId === currentSessionId - || (!currentSessionId && !activeLoad && !pendingNewSession) + || (!messageRequestId && !currentSessionId && !activeLoad && !pendingNewSession) || (!messageRequestId && !activeLoad && !pendingNewSession); mergeSessionListSnapshot(snapshot); if (matchesActiveLoad) { @@ -5041,6 +5176,10 @@ break; case 'session_history_chunk': + if (activeSessionLoad?.recoverCurrent && activeSessionLoad.sessionId === msg.sessionId) { + if (!msg.remaining) finalizeLoadedSession(msg.sessionId); + break; + } if (msg.sessionId === currentSessionId && loadedHistorySessionId === msg.sessionId) { const blocking = isBlockingSessionLoad(msg.sessionId); if (activeSessionLoad?.sessionId === msg.sessionId && activeSessionLoad.snapshot) { @@ -5274,10 +5413,11 @@ case 'resume_generating': if (!isCurrentSessionEvent(msg)) break; + clearPendingSessionResumeRequest(msg.sessionId || currentSessionId, msg.requestId); // Server has an active process for this session — resume streaming setCurrentSessionRunningState(true); if (!isGenerating || !document.getElementById('streaming-msg')) { - startGenerating(msg.sessionId || currentSessionId); + startGenerating(msg.sessionId || currentSessionId, { follow: isNearBottom() }); } else { updateGenerationControls(); toolGroupCount = 0; @@ -5312,8 +5452,24 @@ } break; - case 'error': + case 'resume_session_result': if (!isCurrentSessionEvent(msg)) break; + clearPendingSessionResumeRequest(msg.sessionId || currentSessionId, msg.requestId); + setCurrentSessionRunningState(!!msg.isRunning); + if (!msg.isRunning && currentSessionId && msg.sessionId === currentSessionId) { + updateGenerationControls(); + } + break; + + case 'error': + const errorRequestId = String(msg.requestId || ''); + const matchesActiveLoadError = !!(activeSessionLoad && + (!msg.sessionId || msg.sessionId === activeSessionLoad.sessionId) && + (!errorRequestId || errorRequestId === activeSessionLoad.requestId)); + if (!matchesActiveLoadError && !isCurrentSessionEvent(msg)) break; + if (matchesActiveLoadError) { + clearPendingSessionSwitchRequest(activeSessionLoad.sessionId, activeSessionLoad.requestId); + } if (msg.code === 'new_session_cwd_missing' && pendingNewSessionRequest?.cwd) { const request = pendingNewSessionRequest; pendingNewSessionRequest = null; @@ -5401,8 +5557,12 @@ showToast(`「${msg.title}」任务完成`, msg.sessionId); showBrowserNotification(msg.title); if (msg.sessionId === currentSessionId) { - // Reload current session to show completed response - openSession(msg.sessionId, { forceSync: true, blocking: false }); + // 当前用户如果正在向上翻历史,不要自动整屏重绘并把滚动条拽回底部。 + if (isNearBottom()) { + openSession(msg.sessionId, { forceSync: true, blocking: false }); + } else { + send({ type: 'list_sessions' }); + } } else { send({ type: 'list_sessions' }); } @@ -5435,9 +5595,10 @@ } // --- Generating State --- - function startGenerating(sessionId = currentSessionId) { + function startGenerating(sessionId = currentSessionId, options = {}) { const targetSessionId = sessionId || currentSessionId || null; if (targetSessionId && currentSessionId && targetSessionId !== currentSessionId) return false; + const shouldFollow = options.follow !== false; isGenerating = true; generatingSessionId = targetSessionId; setCurrentSessionRunningState(true); @@ -5469,7 +5630,11 @@ bubble.appendChild(toolsDiv); syncAssistantLastSectionButton(msgEl); messagesDiv.appendChild(msgEl); - scrollToBottom(); + if (shouldFollow) { + scrollToBottom(); + } else { + updateScrollbar(); + } return true; } @@ -10210,20 +10375,21 @@ rememberPw.checked = true; } - // Visibility change: re-sync state when user returns to tab (critical for mobile) + // 页签切回只做轻量状态同步:不要因为 visible 事件整屏重载当前会话, + // 否则 renderMessages() 会重建气泡并把滚动条拉回底部。 document.addEventListener('visibilitychange', () => { if (document.visibilityState !== 'visible') return; if (!ws || ws.readyState > 1) { // WS is dead, force reconnect reconnectAttempts = 0; connect(); - } else if (ws.readyState === 1 && currentSessionId) { - // Preserve active streaming UI when returning to foreground. - if (isGenerating || currentSessionRunning) { - send({ type: 'load_session', sessionId: currentSessionId }); - } else { - beginSessionSwitch(currentSessionId, { blocking: false, force: true }); + } else if (ws.readyState === 1 && wsAuthenticated) { + if (currentSessionId && !activeSessionLoad && (isGenerating || currentSessionRunning)) { + requestSessionResume(currentSessionId, { + requestId: createSessionSwitchRequestId(currentSessionId), + }); } + send({ type: 'list_sessions' }); } }); diff --git a/public/index.html b/public/index.html index 8cb545a..ee8d86a 100644 --- a/public/index.html +++ b/public/index.html @@ -173,6 +173,6 @@ - + diff --git a/scripts/regression.js b/scripts/regression.js index 7f21c5c..30f49d8 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -614,6 +614,102 @@ function assertFrontendMcpReloadContract() { assert(source.includes('MCP 启动失败'), 'Frontend should expose a failed startup toast'); } +function assertSessionSwitchResilienceContract() { + const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); + const serverSource = fs.readFileSync(SERVER_PATH, 'utf8'); + const runtimeSource = fs.readFileSync(path.join(REPO_DIR, 'lib', 'agent-runtime.js'), 'utf8'); + + assert(frontendSource.includes('SESSION_LOAD_REQUEST_TIMEOUT_MS'), 'Frontend should define a hard timeout for session load requests'); + assert(frontendSource.includes('sessionLoadRequestTimer'), 'Frontend should track the session load request timeout timer'); + assert(frontendSource.includes('function clearPendingSessionSwitchRequest'), 'Frontend should be able to cancel stale pending session switch requests'); + assert(frontendSource.includes('function scheduleSessionLoadRequestTimeout'), 'Frontend should schedule cancellation for stuck load_session requests'); + assert(frontendSource.includes('pendingSessionResumeRequest'), 'Frontend should track lightweight running-session resume requests'); + assert(frontendSource.includes('SESSION_RESUME_FALLBACK_MS'), 'Frontend should keep a compatibility fallback for servers without resume_session'); + assert(frontendSource.includes('function requestSessionResume'), 'Frontend should request running-session resume without full history reload'); + assert(frontendSource.includes("type: 'resume_session'"), 'Frontend should use resume_session for reconnecting running conversations'); + assert(frontendSource.includes("case 'resume_session_result':"), 'Frontend should handle lightweight resume results'); + 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; + const visibilitySource = visibilityStart >= 0 && visibilityEnd > visibilityStart + ? frontendSource.slice(visibilityStart, visibilityEnd) + : ''; + assert(visibilitySource.includes('requestSessionResume(currentSessionId'), 'Visibility restore should use lightweight resume for running conversations'); + assert(visibilitySource.includes("send({ type: 'list_sessions' });"), 'Visibility restore should only refresh session list for idle conversations'); + assert(!visibilitySource.includes("type: 'load_session'"), 'Visibility restore must not force load_session and rerender the current conversation'); + assert(!visibilitySource.includes('beginSessionSwitch('), 'Visibility restore must not force a session switch and scroll to bottom'); + assert( + /else if \(currentSessionId && \(isGenerating \|\| currentSessionRunning\)[\s\S]*?pendingSessionResumeRequest\s*=\s*\{/.test(frontendSource), + 'Frontend should queue a lightweight resume request for running conversations when WS closes' + ); + assert( + /const flushedSessionResume = flushedSessionSwitch \? false : flushPendingSessionResume\(\);[\s\S]*?requestSessionResume\(currentSessionId/.test(frontendSource), + 'Frontend should resume the current running session after auth without forcing load_session' + ); + assert( + /case 'background_done':[\s\S]*?if \(isNearBottom\(\)\)[\s\S]*?openSession\(msg\.sessionId,\s*\{ forceSync: true, blocking: false \}\)[\s\S]*?send\(\{ type: 'list_sessions' \}\)/.test(frontendSource), + 'Frontend should not auto-rerender the current session on background_done while the user is reading history' + ); + assert( + /function startGenerating\(sessionId = currentSessionId, options = \{\}\)[\s\S]*?const shouldFollow = options\.follow !== false[\s\S]*?if \(shouldFollow\)/.test(frontendSource), + 'Frontend resume_generating should be able to create a streaming bubble without forcing scroll-to-bottom' + ); + assert( + /const preserveStreaming = !!\(options\.preserveStreaming[\s\S]*?\(isGenerating \|\| currentSessionRunning \|\| hasStreamingElement\)\)/.test(frontendSource), + 'Frontend should preserve the current running conversation DOM even when isGenerating was stale' + ); + assert( + /case 'session_history_chunk':[\s\S]*?activeSessionLoad\?\.recoverCurrent[\s\S]*?break;/.test(frontendSource), + 'Frontend should ignore history chunks from recovery fallback to avoid duplicating/redrawing bubbles' + ); + assert( + /function flushPendingSessionSwitch\(\)[\s\S]*?return false[\s\S]*?return true/.test(frontendSource), + 'Frontend flushPendingSessionSwitch should report whether it sent a load_session request' + ); + assert( + /pendingSessionSwitchRequest\s*=\s*\{[\s\S]*?blocking:\s*false[\s\S]*?requestId:\s*activeSessionLoad\.requestId/.test(frontendSource), + 'Frontend should retry load_session after WS close without re-blocking the UI' + ); + assert( + frontendSource.includes('!messageRequestId && !currentSessionId && !activeLoad && !pendingNewSession'), + 'Frontend should not let late requestId-bearing session_info switch an idle/welcome view' + ); + assert( + frontendSource.includes('matchesActiveLoadError') && frontendSource.includes('errorRequestId === activeSessionLoad.requestId'), + 'Frontend should clear active session load errors by requestId' + ); + + assert(serverSource.includes('SESSION_TRANSPORT_MESSAGE_CONTENT_MAX_CHARS'), 'Server should cap session message size for WebSocket transport'); + assert(serverSource.includes("case 'resume_session':"), 'Server should accept lightweight resume_session requests'); + assert(serverSource.includes('function handleResumeSession'), 'Server should implement lightweight running-session resume'); + assert(serverSource.includes('function attachActiveRuntimeToWs'), 'Server should share runtime re-attach logic without sending session_info first'); + assert(serverSource.includes('WS_HEARTBEAT_MAX_MISSES'), 'Server should tolerate missed WebSocket pongs before terminating'); + assert(serverSource.includes('function markWsActivity'), 'Server should mark WebSocket activity on send/message/pong'); + assert( + serverSource.includes('markWsActivity(ws);') && serverSource.includes('markWsActivity(ws);'), + 'Server should call markWsActivity from WebSocket send and message paths' + ); + assert( + /hasRecentActivity[\s\S]*?_ccWebMissedPongs[\s\S]*?ws_heartbeat_terminate/.test(serverSource), + 'Server heartbeat should consider recent activity and log before terminating stale sockets' + ); + assert(serverSource.includes('function sanitizeMessagesForTransport'), 'Server should sanitize session messages before WebSocket transport'); + assert( + /function splitHistoryMessages\(messages[\s\S]*?const list = sanitizeMessagesForTransport\(messages\)/.test(serverSource), + 'Server history split should operate on transport-sanitized messages' + ); + assert( + /function wsSend\(ws, data\)[\s\S]*?try\s*\{[\s\S]*?JSON\.stringify\(data\)[\s\S]*?catch/.test(serverSource), + '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' + ); +} + async function main() { assertFrontendGenerationControlsContract(); assertFrontendComposerMcpContract(); @@ -621,6 +717,7 @@ async function main() { assertFrontendMarkdownLinkContract(); assertMockCodexAppPromptUserNotTextTriggered(); assertFrontendMcpReloadContract(); + assertSessionSwitchResilienceContract(); const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-')); const configDir = path.join(tempRoot, 'config'); @@ -756,6 +853,13 @@ async function main() { const { ws, messages, token } = await connectWs(port, password); await nextMessage(messages, ws, (msg) => msg.type === 'session_list'); + ws.send(JSON.stringify({ type: 'load_session', sessionId: 'missing-session', requestId: 'reg-missing-session' })); + const missingSessionLoad = await nextMessage(messages, ws, (msg) => ( + msg.type === 'error' && + msg.code === 'session_not_found' && + msg.sessionId === 'missing-session' + )); + assert(missingSessionLoad.requestId === 'reg-missing-session', 'Missing load_session error should echo requestId for frontend cleanup'); const pickerRoot = path.join(homeDir, 'picker-root'); mkdirp(path.join(pickerRoot, 'alpha')); @@ -1977,7 +2081,11 @@ async function main() { assert(codexAppImportItemAfter?.alreadyImported === true, 'Codex App import listing should mark codexAppThreadId as imported'); ws.send(JSON.stringify({ type: 'delete_session', sessionId: importedCodexApp.sessionId })); - await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && !msg.sessions.some((s) => s.id === importedCodexApp.sessionId)); + await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_list' && + !msg.sessions.some((s) => s.id === importedCodexApp.sessionId) && + !fs.existsSync(importedCodexAppPath) + )); assert(!fs.existsSync(importedCodexAppPath), 'Deleting Codex App imported session did not remove cc-web session JSON'); assert(fs.existsSync(codexAppImportFixture.rolloutPath), 'Deleting Codex App imported session should keep rollout history for recovery'); diff --git a/server.js b/server.js index 4604e21..7075a64 100644 --- a/server.js +++ b/server.js @@ -105,6 +105,10 @@ const SESSION_MESSAGE_CONTENT_MAX_CHARS = readPositiveIntEnv('CC_WEB_SESSION_MES const SESSION_TOOL_INPUT_MAX_CHARS = readPositiveIntEnv('CC_WEB_SESSION_TOOL_INPUT_MAX_CHARS', 16 * 1024, { min: 1024 }); const SESSION_TOOL_RESULT_MAX_CHARS = readPositiveIntEnv('CC_WEB_SESSION_TOOL_RESULT_MAX_CHARS', 32 * 1024, { min: 1024 }); const SESSION_MAX_TOOL_CALLS_PER_MESSAGE = readPositiveIntEnv('CC_WEB_SESSION_MAX_TOOL_CALLS_PER_MESSAGE', 80, { min: 1, max: 1000 }); +const SESSION_TRANSPORT_MESSAGE_CONTENT_MAX_CHARS = readPositiveIntEnv('CC_WEB_SESSION_TRANSPORT_MESSAGE_CONTENT_MAX_CHARS', 64 * 1024, { min: 4096 }); +const SESSION_TRANSPORT_TOOL_INPUT_MAX_CHARS = readPositiveIntEnv('CC_WEB_SESSION_TRANSPORT_TOOL_INPUT_MAX_CHARS', 8 * 1024, { min: 1024 }); +const SESSION_TRANSPORT_TOOL_RESULT_MAX_CHARS = readPositiveIntEnv('CC_WEB_SESSION_TRANSPORT_TOOL_RESULT_MAX_CHARS', 16 * 1024, { min: 1024 }); +const SESSION_TRANSPORT_MAX_TOOL_CALLS_PER_MESSAGE = readPositiveIntEnv('CC_WEB_SESSION_TRANSPORT_MAX_TOOL_CALLS_PER_MESSAGE', 40, { min: 1, max: 1000 }); const HISTORY_PREFETCH_CHUNKS = readPositiveIntEnv('CC_WEB_HISTORY_PREFETCH_CHUNKS', 3, { min: 0, max: 20 }); const HISTORY_MAX_CHUNKS_PER_LOAD = readPositiveIntEnv('CC_WEB_HISTORY_MAX_CHUNKS_PER_LOAD', 8, { min: 1, max: 100 }); const CODEX_APP_STATE_MAX_BYTES = readPositiveIntEnv('CC_WEB_CODEX_APP_STATE_MAX_BYTES', 2 * 1024 * 1024, { min: 128 * 1024 }); @@ -1276,7 +1280,25 @@ const MIME_TYPES = { // === Utility Functions === function wsSend(ws, data) { - if (ws && ws.readyState === 1) ws.send(JSON.stringify(data)); + if (!ws || ws.readyState !== 1) return; + try { + ws.send(JSON.stringify(data)); + markWsActivity(ws); + } catch (err) { + plog('WARN', 'ws_send_failed', { + wsId: ws._ccWebId || null, + type: data?.type || null, + sessionId: data?.sessionId ? String(data.sessionId).slice(0, 8) : null, + error: err?.message || String(err || ''), + }); + } +} + +function markWsActivity(ws) { + if (!ws) return; + ws.isAlive = true; + ws._ccWebLastActivityAt = Date.now(); + ws._ccWebMissedPongs = 0; } function sanitizeId(id) { @@ -3508,6 +3530,21 @@ function sanitizeMessagesForPersist(messages, limits = {}) { return output; } +function sanitizeMessageForTransport(message) { + return sanitizeMessageForPersist(message, { + contentMaxChars: SESSION_TRANSPORT_MESSAGE_CONTENT_MAX_CHARS, + toolInputMaxChars: SESSION_TRANSPORT_TOOL_INPUT_MAX_CHARS, + toolResultMaxChars: SESSION_TRANSPORT_TOOL_RESULT_MAX_CHARS, + maxToolCalls: SESSION_TRANSPORT_MAX_TOOL_CALLS_PER_MESSAGE, + metaMaxChars: SESSION_TRANSPORT_MESSAGE_CONTENT_MAX_CHARS, + }); +} + +function sanitizeMessagesForTransport(messages) { + const list = Array.isArray(messages) ? messages : []; + return list.map((message) => sanitizeMessageForTransport(message)); +} + function sanitizeSessionForPersist(session, limits = {}) { const output = {}; const skipKeys = new Set([ @@ -3755,7 +3792,7 @@ function sessionModelLabel(session) { } function splitHistoryMessages(messages, options = {}) { - const list = Array.isArray(messages) ? messages : []; + const list = sanitizeMessagesForTransport(messages); if (list.length <= INITIAL_HISTORY_COUNT) { return { recentMessages: list, olderChunks: [], historyRemaining: 0, historyBuffered: list.length }; } @@ -6147,6 +6184,7 @@ const server = http.createServer((req, res) => { // === WebSocket Server === const wss = new WebSocketServer({ noServer: true }); const WS_HEARTBEAT_INTERVAL_MS = 30000; +const WS_HEARTBEAT_MAX_MISSES = readPositiveIntEnv('CC_WEB_WS_HEARTBEAT_MAX_MISSES', 4, { min: 2, max: 20 }); server.on('upgrade', (req, socket, head) => { let pathname = ''; @@ -6184,14 +6222,17 @@ wss.on('connection', (ws, req) => { const wsId = crypto.randomBytes(4).toString('hex'); // short id for log correlation const wsConnectTime = new Date().toISOString(); ws.isAlive = true; + ws._ccWebMissedPongs = 0; ws._ccWebId = wsId; + markWsActivity(ws); plog('INFO', 'ws_connect', { wsId }); ws.on('pong', () => { - ws.isAlive = true; + markWsActivity(ws); }); ws.on('message', (raw) => { + markWsActivity(ws); let msg; try { msg = JSON.parse(raw); @@ -6244,6 +6285,9 @@ wss.on('connection', (ws, req) => { case 'load_session': handleLoadSession(ws, msg); break; + case 'resume_session': + handleResumeSession(ws, msg); + break; case 'load_history_page': handleLoadHistoryPage(ws, msg); break; @@ -6339,19 +6383,36 @@ wss.on('connection', (ws, req) => { // WebSocket 心跳:避免反向代理因空闲连接关闭 /ws。 const wsHeartbeatTimer = setInterval(() => { + const now = Date.now(); for (const client of wss.clients) { + if (client.readyState !== 1) continue; if (client.isAlive === false) { - client.terminate(); - continue; + const lastActivityAt = Number(client._ccWebLastActivityAt || 0); + const hasRecentActivity = lastActivityAt > 0 && now - lastActivityAt < WS_HEARTBEAT_INTERVAL_MS * 2; + if (hasRecentActivity) { + client._ccWebMissedPongs = 0; + } else { + client._ccWebMissedPongs = Number(client._ccWebMissedPongs || 0) + 1; + } + if (client._ccWebMissedPongs >= WS_HEARTBEAT_MAX_MISSES) { + plog('WARN', 'ws_heartbeat_terminate', { + wsId: client._ccWebId || null, + missedPongs: client._ccWebMissedPongs, + lastActivityAgeMs: lastActivityAt ? now - lastActivityAt : null, + }); + client.terminate(); + continue; + } + } else { + client._ccWebMissedPongs = 0; } client.isAlive = false; - if (client.readyState === 1) { - try { - client.ping(); - } catch (err) { - plog('WARN', 'ws_ping_failed', { wsId: client._ccWebId || null, error: err.message }); - client.terminate(); - } + try { + client.ping(); + } catch (err) { + plog('WARN', 'ws_ping_failed', { wsId: client._ccWebId || null, error: err.message }); + client.terminate(); + continue; } } }, WS_HEARTBEAT_INTERVAL_MS); @@ -7191,7 +7252,7 @@ function createPersistentConversationSession(args = {}, options = {}) { function buildSessionInfoPayload(session) { const waitState = crossConversationWaitState(session.id); - const messages = session.messages || []; + const messages = sanitizeMessagesForTransport(session.messages || []); return { type: 'session_info', sessionId: session.id, @@ -7268,9 +7329,14 @@ function handleLoadHistoryPage(ws, msg = {}) { const sessionId = sanitizeId(msg.sessionId || ''); const session = loadSession(sessionId); if (!session) { - return wsSend(ws, { type: 'error', message: 'Session not found' }); + return wsSend(ws, attachClientRequestId({ + type: 'error', + code: 'session_not_found', + sessionId, + message: 'Session not found', + }, msg)); } - const list = Array.isArray(session.messages) ? session.messages : []; + const list = sanitizeMessagesForTransport(session.messages); const requestedBefore = Number.parseInt(String(msg.before || ''), 10); const before = Number.isFinite(requestedBefore) ? Math.max(0, Math.min(list.length, requestedBefore)) @@ -7288,12 +7354,93 @@ function handleLoadHistoryPage(ws, msg = {}) { }); } +function attachActiveRuntimeToWs(ws, sessionId, source = {}) { + if (activeProcesses.has(sessionId)) { + const entry = activeProcesses.get(sessionId); + entry.ws = ws; + entry.wsDisconnectTime = null; // clear disconnect marker + plog('INFO', 'ws_resume_attach', { + sessionId: sessionId.slice(0, 8), + pid: entry.pid, + responseLen: (entry.fullText || '').length, + }); + wsSend(ws, attachClientRequestId({ + type: 'resume_generating', + sessionId, + text: truncateTextValue(entry.fullText || '', SESSION_MESSAGE_CONTENT_MAX_CHARS), + toolCalls: sanitizeToolCallsForPersist(entry.toolCalls || []), + }, source)); + return true; + } + + if (activeCodexAppTurns.has(sessionId)) { + const entry = activeCodexAppTurns.get(sessionId); + entry.ws = ws; + entry.wsDisconnectTime = null; + plog('INFO', 'codex_app_ws_resume_attach', { + sessionId: sessionId.slice(0, 8), + threadId: entry.threadId || null, + turnId: entry.turnId || null, + responseLen: (entry.fullText || '').length, + }); + wsSend(ws, attachClientRequestId({ + type: 'resume_generating', + sessionId, + text: truncateTextValue(entry.fullText || '', SESSION_MESSAGE_CONTENT_MAX_CHARS), + toolCalls: sanitizeToolCallsForPersist(entry.toolCalls || []), + }, source)); + return true; + } + + if (activeCodexAppGoalCommands.has(sessionId)) { + const entry = activeCodexAppGoalCommands.get(sessionId); + entry.ws = ws; + entry.wsDisconnectTime = null; + wsSend(ws, attachClientRequestId({ + type: 'system_message', + sessionId, + message: '正在同步 Goal...', + }, source)); + return true; + } + + return false; +} + +function handleResumeSession(ws, msg = {}) { + const sessionId = sanitizeId(msg.sessionId || ''); + const session = loadSession(sessionId); + if (!session) { + return wsSend(ws, attachClientRequestId({ + type: 'error', + code: 'session_not_found', + sessionId, + message: 'Session not found', + }, msg)); + } + + detachWsFromActiveRuntimes(ws); + wsSessionMap.set(ws, sessionId); + const attached = attachActiveRuntimeToWs(ws, sessionId, msg); + wsSend(ws, attachClientRequestId({ + type: 'resume_session_result', + sessionId, + isRunning: isSessionRunning(sessionId), + attached, + }, msg)); +} + function handleLoadSession(ws, msg) { const sessionId = sanitizeId(typeof msg === 'string' ? msg : msg?.sessionId); reconcilePendingCrossConversationReplies(); const session = loadSession(sessionId); if (!session) { - return wsSend(ws, { type: 'error', message: 'Session not found' }); + return wsSend(ws, attachClientRequestId({ + type: 'error', + code: 'session_not_found', + sessionId, + message: 'Session not found', + }, msg)); } flushPendingCrossConversationReplies(sessionId); const refreshedSession = loadSession(sessionId) || session; @@ -7367,44 +7514,8 @@ function handleLoadSession(ws, msg) { }); } - // Resume streaming if process is still active - if (activeProcesses.has(sessionId)) { - const entry = activeProcesses.get(sessionId); - entry.ws = ws; - entry.wsDisconnectTime = null; // clear disconnect marker - plog('INFO', 'ws_resume_attach', { - sessionId: sessionId.slice(0, 8), - pid: entry.pid, - responseLen: (entry.fullText || '').length, - }); - wsSend(ws, { - type: 'resume_generating', - sessionId, - text: truncateTextValue(entry.fullText || '', SESSION_MESSAGE_CONTENT_MAX_CHARS), - toolCalls: sanitizeToolCallsForPersist(entry.toolCalls || []), - }); - } else if (activeCodexAppTurns.has(sessionId)) { - const entry = activeCodexAppTurns.get(sessionId); - entry.ws = ws; - entry.wsDisconnectTime = null; - plog('INFO', 'codex_app_ws_resume_attach', { - sessionId: sessionId.slice(0, 8), - threadId: entry.threadId || null, - turnId: entry.turnId || null, - responseLen: (entry.fullText || '').length, - }); - wsSend(ws, { - type: 'resume_generating', - sessionId, - text: truncateTextValue(entry.fullText || '', SESSION_MESSAGE_CONTENT_MAX_CHARS), - toolCalls: sanitizeToolCallsForPersist(entry.toolCalls || []), - }); - } else if (activeCodexAppGoalCommands.has(sessionId)) { - const entry = activeCodexAppGoalCommands.get(sessionId); - entry.ws = ws; - entry.wsDisconnectTime = null; - wsSend(ws, { type: 'system_message', sessionId, message: '正在同步 Goal...' }); - } + // Resume streaming if process is still active. + attachActiveRuntimeToWs(ws, sessionId); } function sqlQuote(value) { @@ -9940,10 +10051,11 @@ function handleImportNativeSession(ws, msg) { }; saveSession(session); wsSessionMap.set(ws, id); + const transportMessages = sanitizeMessagesForTransport(session.messages); wsSend(ws, attachClientRequestId({ type: 'session_info', sessionId: id, - messages: session.messages, + messages: transportMessages, title: session.title, pinnedAt: session.pinnedAt || null, mode: session.permissionMode, @@ -9953,6 +10065,8 @@ function handleImportNativeSession(ws, msg) { totalCost: session.totalCost || 0, totalUsage: session.totalUsage || null, updated: session.updated, + historyTotal: transportMessages.length, + historyBaseIndex: 0, hasUnread: false, historyPending: false, isRunning: false, @@ -10109,10 +10223,11 @@ function handleImportCodexSession(ws, msg) { saveSession(session); wsSessionMap.set(ws, id); + const transportMessages = sanitizeMessagesForTransport(session.messages); wsSend(ws, attachClientRequestId({ type: 'session_info', sessionId: id, - messages: session.messages, + messages: transportMessages, title: session.title, pinnedAt: session.pinnedAt || null, mode: session.permissionMode, @@ -10122,6 +10237,8 @@ function handleImportCodexSession(ws, msg) { totalCost: session.totalCost || 0, totalUsage: session.totalUsage || null, updated: session.updated, + historyTotal: transportMessages.length, + historyBaseIndex: 0, hasUnread: false, historyPending: false, isRunning: false,