chore: rebuild CentOS7 release package

This commit is contained in:
shiyue
2026-07-03 08:53:37 +08:00
parent d816ae28b9
commit faf6adceb7
7 changed files with 499 additions and 82 deletions

View File

@@ -1,5 +1,5 @@
{ {
"version": 1, "version": 1,
"updatedAt": "2026-06-30T15:42:26.421Z", "updatedAt": "2026-07-02T06:02:53.126Z",
"replies": [] "replies": []
} }

View File

@@ -23,6 +23,32 @@ function createAgentRuntime(deps) {
getRuntimeSessionId, getRuntimeSessionId,
} = deps; } = 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) { function tomlString(value) {
return JSON.stringify(String(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`) ? (/\n\s*$/.test(currentText) ? `\n${createAgentMessageDivider()}\n\n` : `\n\n${createAgentMessageDivider()}\n\n`)
: ''; : '';
const chunk = separator + nextText; const chunk = separator + nextText;
entry.fullText += chunk; entry.fullText = appendCappedText(entry.fullText || '', chunk);
return chunk; return chunk;
} }
@@ -383,7 +409,7 @@ function createAgentRuntime(deps) {
for (const block of content) { for (const block of content) {
if (block.type === 'text' && block.text) { 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 }); sendRuntime(entry, sessionId, { type: 'text_delta', text: block.text });
} else if (block.type === 'tool_use') { } else if (block.type === 'tool_use') {
const toolInput = sanitizeToolInput(block.name, block.input); const toolInput = sanitizeToolInput(block.name, block.input);

View File

@@ -2,7 +2,7 @@
(function () { (function () {
'use strict'; '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 WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
const RENDER_DEBOUNCE = 100; const RENDER_DEBOUNCE = 100;
const COMPOSER_SUGGESTION_DEBOUNCE = 120; const COMPOSER_SUGGESTION_DEBOUNCE = 120;
@@ -76,6 +76,8 @@
const OLD_SESSION_COLLAPSE_DAYS = 7; const OLD_SESSION_COLLAPSE_DAYS = 7;
const OLD_SESSION_COLLAPSE_MS = OLD_SESSION_COLLAPSE_DAYS * 24 * 60 * 60 * 1000; const OLD_SESSION_COLLAPSE_MS = OLD_SESSION_COLLAPSE_DAYS * 24 * 60 * 60 * 1000;
const SESSION_LOAD_OVERLAY_TIMEOUT_MS = 12_000; 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 = [ const MODEL_OPTIONS = [
{ value: 'opus', label: 'Opus', desc: '最强大1M 上下文' }, { value: 'opus', label: 'Opus', desc: '最强大1M 上下文' },
@@ -183,6 +185,8 @@
let loadedHistorySessionId = null; let loadedHistorySessionId = null;
let activeSessionLoad = null; let activeSessionLoad = null;
let sessionLoadOverlayTimer = null; let sessionLoadOverlayTimer = null;
let sessionLoadRequestTimer = null;
let sessionResumeFallbackTimer = null;
let sidebarSwipe = null; let sidebarSwipe = null;
let activeComposerToken = null; let activeComposerToken = null;
let composerSuggestionTimer = null; let composerSuggestionTimer = null;
@@ -202,6 +206,7 @@
let codexAppApprovalModal = null; let codexAppApprovalModal = null;
let pendingNewSessionRequest = null; let pendingNewSessionRequest = null;
let pendingSessionSwitchRequest = null; let pendingSessionSwitchRequest = null;
let pendingSessionResumeRequest = null;
let sessionSwitchRequestSeq = 0; let sessionSwitchRequestSeq = 0;
let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1'; let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1';
let pendingInitialSessionLoad = false; let pendingInitialSessionLoad = false;
@@ -4126,7 +4131,11 @@
if (fileBrowserState && (fileBrowserState.sessionId !== snapshot.sessionId || (snapshot.cwd && fileBrowserState.rootPath !== snapshot.cwd))) { if (fileBrowserState && (fileBrowserState.sessionId !== snapshot.sessionId || (snapshot.cwd && fileBrowserState.rootPath !== snapshot.cwd))) {
closeFileBrowser(); 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) { if (isGenerating && !preserveStreaming) {
isGenerating = false; isGenerating = false;
generatingSessionId = null; generatingSessionId = null;
@@ -4220,6 +4229,59 @@
sessionLoadOverlayTimer = null; 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 } = {}) { function releaseSessionLoadingOverlay({ keepActiveLoad = true, allowRetry = false } = {}) {
clearSessionLoadOverlayTimer(); clearSessionLoadOverlayTimer();
document.body.classList.remove('session-loading-active'); document.body.classList.remove('session-loading-active');
@@ -4236,10 +4298,19 @@
function setSessionLoading(sessionId, options = {}) { function setSessionLoading(sessionId, options = {}) {
clearSessionLoadOverlayTimer(); clearSessionLoadOverlayTimer();
clearSessionLoadRequestTimer();
const loading = !!sessionId; const loading = !!sessionId;
const blocking = options.blocking !== false; const blocking = options.blocking !== false;
const requestId = loading ? (options.requestId || createSessionSwitchRequestId(sessionId)) : ''; 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); const showOverlay = !!(loading && blocking);
if (showOverlay) { if (showOverlay) {
document.body.classList.add('session-loading-active'); document.body.classList.add('session-loading-active');
@@ -4315,6 +4386,7 @@
blocking: options.blocking !== false, blocking: options.blocking !== false,
label: options.label || '', label: options.label || '',
requestId: options.requestId || activeSessionLoad?.requestId || createSessionSwitchRequestId(sessionId), requestId: options.requestId || activeSessionLoad?.requestId || createSessionSwitchRequestId(sessionId),
recoverCurrent: options.recoverCurrent === true,
}; };
if (ws && ws.readyState === 1 && wsAuthenticated) { if (ws && ws.readyState === 1 && wsAuthenticated) {
flushPendingSessionSwitch(); flushPendingSessionSwitch();
@@ -4323,9 +4395,42 @@
if (!ws || ws.readyState > 1) connect(); 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() { function flushPendingSessionSwitch() {
if (!pendingSessionSwitchRequest) return; if (!pendingSessionSwitchRequest) return false;
if (!ws || ws.readyState !== 1 || !wsAuthenticated) return; if (!ws || ws.readyState !== 1 || !wsAuthenticated) return false;
const request = pendingSessionSwitchRequest; const request = pendingSessionSwitchRequest;
pendingSessionSwitchRequest = null; pendingSessionSwitchRequest = null;
if (!activeSessionLoad) { if (!activeSessionLoad) {
@@ -4333,9 +4438,20 @@
blocking: request.blocking, blocking: request.blocking,
label: request.label || undefined, label: request.label || undefined,
requestId: request.requestId, requestId: request.requestId,
recoverCurrent: request.recoverCurrent === true,
}); });
} }
ws.send(JSON.stringify({ type: 'load_session', sessionId: request.sessionId, requestId: request.requestId })); 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) { function showCachedSession(sessionId) {
@@ -4883,9 +4999,15 @@
if (activeSessionLoad?.sessionId && !isPageUnloading) { if (activeSessionLoad?.sessionId && !isPageUnloading) {
pendingSessionSwitchRequest = { pendingSessionSwitchRequest = {
sessionId: activeSessionLoad.sessionId, sessionId: activeSessionLoad.sessionId,
blocking: activeSessionLoad.blocking, blocking: false,
label: sessionLoadingLabel?.textContent || '', label: sessionLoadingLabel?.textContent || '',
requestId: activeSessionLoad.requestId || createSessionSwitchRequestId(activeSessionLoad.sessionId), requestId: activeSessionLoad.requestId || createSessionSwitchRequestId(activeSessionLoad.sessionId),
recoverCurrent: activeSessionLoad.recoverCurrent === true,
};
} else if (currentSessionId && (isGenerating || currentSessionRunning) && !isPageUnloading) {
pendingSessionResumeRequest = {
sessionId: currentSessionId,
requestId: createSessionSwitchRequestId(currentSessionId),
}; };
} }
clearSessionLoading(); clearSessionLoading();
@@ -4934,7 +5056,18 @@
document.dispatchEvent(new CustomEvent('cc-web-auth-restored')); document.dispatchEvent(new CustomEvent('cc-web-auth-restored'));
loginOverlay.hidden = true; loginOverlay.hidden = true;
app.hidden = false; 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' }); send({ type: 'get_codex_config' });
// Check if must change password // Check if must change password
if (msg.mustChangePassword) { if (msg.mustChangePassword) {
@@ -4944,6 +5077,8 @@
} }
} else { } else {
pendingSessionSwitchRequest = null; pendingSessionSwitchRequest = null;
pendingSessionResumeRequest = null;
clearSessionResumeFallbackTimer();
clearSessionLoading(); clearSessionLoading();
authToken = null; authToken = null;
wsAuthenticated = false; wsAuthenticated = false;
@@ -5007,7 +5142,7 @@
const canSwitchToSessionInfo = matchesActiveLoad const canSwitchToSessionInfo = matchesActiveLoad
|| matchesPendingNewSession || matchesPendingNewSession
|| msg.sessionId === currentSessionId || msg.sessionId === currentSessionId
|| (!currentSessionId && !activeLoad && !pendingNewSession) || (!messageRequestId && !currentSessionId && !activeLoad && !pendingNewSession)
|| (!messageRequestId && !activeLoad && !pendingNewSession); || (!messageRequestId && !activeLoad && !pendingNewSession);
mergeSessionListSnapshot(snapshot); mergeSessionListSnapshot(snapshot);
if (matchesActiveLoad) { if (matchesActiveLoad) {
@@ -5041,6 +5176,10 @@
break; break;
case 'session_history_chunk': 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) { if (msg.sessionId === currentSessionId && loadedHistorySessionId === msg.sessionId) {
const blocking = isBlockingSessionLoad(msg.sessionId); const blocking = isBlockingSessionLoad(msg.sessionId);
if (activeSessionLoad?.sessionId === msg.sessionId && activeSessionLoad.snapshot) { if (activeSessionLoad?.sessionId === msg.sessionId && activeSessionLoad.snapshot) {
@@ -5274,10 +5413,11 @@
case 'resume_generating': case 'resume_generating':
if (!isCurrentSessionEvent(msg)) break; if (!isCurrentSessionEvent(msg)) break;
clearPendingSessionResumeRequest(msg.sessionId || currentSessionId, msg.requestId);
// Server has an active process for this session — resume streaming // Server has an active process for this session — resume streaming
setCurrentSessionRunningState(true); setCurrentSessionRunningState(true);
if (!isGenerating || !document.getElementById('streaming-msg')) { if (!isGenerating || !document.getElementById('streaming-msg')) {
startGenerating(msg.sessionId || currentSessionId); startGenerating(msg.sessionId || currentSessionId, { follow: isNearBottom() });
} else { } else {
updateGenerationControls(); updateGenerationControls();
toolGroupCount = 0; toolGroupCount = 0;
@@ -5312,8 +5452,24 @@
} }
break; break;
case 'error': case 'resume_session_result':
if (!isCurrentSessionEvent(msg)) break; 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) { if (msg.code === 'new_session_cwd_missing' && pendingNewSessionRequest?.cwd) {
const request = pendingNewSessionRequest; const request = pendingNewSessionRequest;
pendingNewSessionRequest = null; pendingNewSessionRequest = null;
@@ -5401,8 +5557,12 @@
showToast(`${msg.title}」任务完成`, msg.sessionId); showToast(`${msg.title}」任务完成`, msg.sessionId);
showBrowserNotification(msg.title); showBrowserNotification(msg.title);
if (msg.sessionId === currentSessionId) { 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 { } else {
send({ type: 'list_sessions' }); send({ type: 'list_sessions' });
} }
@@ -5435,9 +5595,10 @@
} }
// --- Generating State --- // --- Generating State ---
function startGenerating(sessionId = currentSessionId) { function startGenerating(sessionId = currentSessionId, options = {}) {
const targetSessionId = sessionId || currentSessionId || null; const targetSessionId = sessionId || currentSessionId || null;
if (targetSessionId && currentSessionId && targetSessionId !== currentSessionId) return false; if (targetSessionId && currentSessionId && targetSessionId !== currentSessionId) return false;
const shouldFollow = options.follow !== false;
isGenerating = true; isGenerating = true;
generatingSessionId = targetSessionId; generatingSessionId = targetSessionId;
setCurrentSessionRunningState(true); setCurrentSessionRunningState(true);
@@ -5469,7 +5630,11 @@
bubble.appendChild(toolsDiv); bubble.appendChild(toolsDiv);
syncAssistantLastSectionButton(msgEl); syncAssistantLastSectionButton(msgEl);
messagesDiv.appendChild(msgEl); messagesDiv.appendChild(msgEl);
scrollToBottom(); if (shouldFollow) {
scrollToBottom();
} else {
updateScrollbar();
}
return true; return true;
} }
@@ -10210,20 +10375,21 @@
rememberPw.checked = true; rememberPw.checked = true;
} }
// Visibility change: re-sync state when user returns to tab (critical for mobile) // 页签切回只做轻量状态同步:不要因为 visible 事件整屏重载当前会话,
// 否则 renderMessages() 会重建气泡并把滚动条拉回底部。
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible') return; if (document.visibilityState !== 'visible') return;
if (!ws || ws.readyState > 1) { if (!ws || ws.readyState > 1) {
// WS is dead, force reconnect // WS is dead, force reconnect
reconnectAttempts = 0; reconnectAttempts = 0;
connect(); connect();
} else if (ws.readyState === 1 && currentSessionId) { } else if (ws.readyState === 1 && wsAuthenticated) {
// Preserve active streaming UI when returning to foreground. if (currentSessionId && !activeSessionLoad && (isGenerating || currentSessionRunning)) {
if (isGenerating || currentSessionRunning) { requestSessionResume(currentSessionId, {
send({ type: 'load_session', sessionId: currentSessionId }); requestId: createSessionSwitchRequestId(currentSessionId),
} else { });
beginSessionSwitch(currentSessionId, { blocking: false, force: true });
} }
send({ type: 'list_sessions' });
} }
}); });

View File

@@ -173,6 +173,6 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.9.1/mermaid.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.9.1/mermaid.min.js"></script>
<script src="app.js?v=20260629-ccweb-prompt-dark-theme"></script> <script src="app.js?v=20260702-visible-no-rerender"></script>
</body> </body>
</html> </html>

View File

@@ -614,6 +614,102 @@ function assertFrontendMcpReloadContract() {
assert(source.includes('MCP 启动失败'), 'Frontend should expose a failed startup toast'); 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() { async function main() {
assertFrontendGenerationControlsContract(); assertFrontendGenerationControlsContract();
assertFrontendComposerMcpContract(); assertFrontendComposerMcpContract();
@@ -621,6 +717,7 @@ async function main() {
assertFrontendMarkdownLinkContract(); assertFrontendMarkdownLinkContract();
assertMockCodexAppPromptUserNotTextTriggered(); assertMockCodexAppPromptUserNotTextTriggered();
assertFrontendMcpReloadContract(); assertFrontendMcpReloadContract();
assertSessionSwitchResilienceContract();
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-')); const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-'));
const configDir = path.join(tempRoot, 'config'); const configDir = path.join(tempRoot, 'config');
@@ -756,6 +853,13 @@ async function main() {
const { ws, messages, token } = await connectWs(port, password); const { ws, messages, token } = await connectWs(port, password);
await nextMessage(messages, ws, (msg) => msg.type === 'session_list'); 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'); const pickerRoot = path.join(homeDir, 'picker-root');
mkdirp(path.join(pickerRoot, 'alpha')); 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'); assert(codexAppImportItemAfter?.alreadyImported === true, 'Codex App import listing should mark codexAppThreadId as imported');
ws.send(JSON.stringify({ type: 'delete_session', sessionId: importedCodexApp.sessionId })); 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(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'); assert(fs.existsSync(codexAppImportFixture.rolloutPath), 'Deleting Codex App imported session should keep rollout history for recovery');

229
server.js
View File

@@ -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_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_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_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_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 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 }); 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 === // === Utility Functions ===
function wsSend(ws, data) { 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) { function sanitizeId(id) {
@@ -3508,6 +3530,21 @@ function sanitizeMessagesForPersist(messages, limits = {}) {
return output; 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 = {}) { function sanitizeSessionForPersist(session, limits = {}) {
const output = {}; const output = {};
const skipKeys = new Set([ const skipKeys = new Set([
@@ -3755,7 +3792,7 @@ function sessionModelLabel(session) {
} }
function splitHistoryMessages(messages, options = {}) { function splitHistoryMessages(messages, options = {}) {
const list = Array.isArray(messages) ? messages : []; const list = sanitizeMessagesForTransport(messages);
if (list.length <= INITIAL_HISTORY_COUNT) { if (list.length <= INITIAL_HISTORY_COUNT) {
return { recentMessages: list, olderChunks: [], historyRemaining: 0, historyBuffered: list.length }; return { recentMessages: list, olderChunks: [], historyRemaining: 0, historyBuffered: list.length };
} }
@@ -6147,6 +6184,7 @@ const server = http.createServer((req, res) => {
// === WebSocket Server === // === WebSocket Server ===
const wss = new WebSocketServer({ noServer: true }); const wss = new WebSocketServer({ noServer: true });
const WS_HEARTBEAT_INTERVAL_MS = 30000; 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) => { server.on('upgrade', (req, socket, head) => {
let pathname = ''; let pathname = '';
@@ -6184,14 +6222,17 @@ wss.on('connection', (ws, req) => {
const wsId = crypto.randomBytes(4).toString('hex'); // short id for log correlation const wsId = crypto.randomBytes(4).toString('hex'); // short id for log correlation
const wsConnectTime = new Date().toISOString(); const wsConnectTime = new Date().toISOString();
ws.isAlive = true; ws.isAlive = true;
ws._ccWebMissedPongs = 0;
ws._ccWebId = wsId; ws._ccWebId = wsId;
markWsActivity(ws);
plog('INFO', 'ws_connect', { wsId }); plog('INFO', 'ws_connect', { wsId });
ws.on('pong', () => { ws.on('pong', () => {
ws.isAlive = true; markWsActivity(ws);
}); });
ws.on('message', (raw) => { ws.on('message', (raw) => {
markWsActivity(ws);
let msg; let msg;
try { try {
msg = JSON.parse(raw); msg = JSON.parse(raw);
@@ -6244,6 +6285,9 @@ wss.on('connection', (ws, req) => {
case 'load_session': case 'load_session':
handleLoadSession(ws, msg); handleLoadSession(ws, msg);
break; break;
case 'resume_session':
handleResumeSession(ws, msg);
break;
case 'load_history_page': case 'load_history_page':
handleLoadHistoryPage(ws, msg); handleLoadHistoryPage(ws, msg);
break; break;
@@ -6339,19 +6383,36 @@ wss.on('connection', (ws, req) => {
// WebSocket 心跳:避免反向代理因空闲连接关闭 /ws。 // WebSocket 心跳:避免反向代理因空闲连接关闭 /ws。
const wsHeartbeatTimer = setInterval(() => { const wsHeartbeatTimer = setInterval(() => {
const now = Date.now();
for (const client of wss.clients) { for (const client of wss.clients) {
if (client.readyState !== 1) continue;
if (client.isAlive === false) { if (client.isAlive === false) {
client.terminate(); const lastActivityAt = Number(client._ccWebLastActivityAt || 0);
continue; 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; client.isAlive = false;
if (client.readyState === 1) { try {
try { client.ping();
client.ping(); } catch (err) {
} catch (err) { plog('WARN', 'ws_ping_failed', { wsId: client._ccWebId || null, error: err.message });
plog('WARN', 'ws_ping_failed', { wsId: client._ccWebId || null, error: err.message }); client.terminate();
client.terminate(); continue;
}
} }
} }
}, WS_HEARTBEAT_INTERVAL_MS); }, WS_HEARTBEAT_INTERVAL_MS);
@@ -7191,7 +7252,7 @@ function createPersistentConversationSession(args = {}, options = {}) {
function buildSessionInfoPayload(session) { function buildSessionInfoPayload(session) {
const waitState = crossConversationWaitState(session.id); const waitState = crossConversationWaitState(session.id);
const messages = session.messages || []; const messages = sanitizeMessagesForTransport(session.messages || []);
return { return {
type: 'session_info', type: 'session_info',
sessionId: session.id, sessionId: session.id,
@@ -7268,9 +7329,14 @@ function handleLoadHistoryPage(ws, msg = {}) {
const sessionId = sanitizeId(msg.sessionId || ''); const sessionId = sanitizeId(msg.sessionId || '');
const session = loadSession(sessionId); const session = loadSession(sessionId);
if (!session) { 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 requestedBefore = Number.parseInt(String(msg.before || ''), 10);
const before = Number.isFinite(requestedBefore) const before = Number.isFinite(requestedBefore)
? Math.max(0, Math.min(list.length, 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) { function handleLoadSession(ws, msg) {
const sessionId = sanitizeId(typeof msg === 'string' ? msg : msg?.sessionId); const sessionId = sanitizeId(typeof msg === 'string' ? msg : msg?.sessionId);
reconcilePendingCrossConversationReplies(); reconcilePendingCrossConversationReplies();
const session = loadSession(sessionId); const session = loadSession(sessionId);
if (!session) { 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); flushPendingCrossConversationReplies(sessionId);
const refreshedSession = loadSession(sessionId) || session; const refreshedSession = loadSession(sessionId) || session;
@@ -7367,44 +7514,8 @@ function handleLoadSession(ws, msg) {
}); });
} }
// Resume streaming if process is still active // Resume streaming if process is still active.
if (activeProcesses.has(sessionId)) { attachActiveRuntimeToWs(ws, 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...' });
}
} }
function sqlQuote(value) { function sqlQuote(value) {
@@ -9940,10 +10051,11 @@ function handleImportNativeSession(ws, msg) {
}; };
saveSession(session); saveSession(session);
wsSessionMap.set(ws, id); wsSessionMap.set(ws, id);
const transportMessages = sanitizeMessagesForTransport(session.messages);
wsSend(ws, attachClientRequestId({ wsSend(ws, attachClientRequestId({
type: 'session_info', type: 'session_info',
sessionId: id, sessionId: id,
messages: session.messages, messages: transportMessages,
title: session.title, title: session.title,
pinnedAt: session.pinnedAt || null, pinnedAt: session.pinnedAt || null,
mode: session.permissionMode, mode: session.permissionMode,
@@ -9953,6 +10065,8 @@ function handleImportNativeSession(ws, msg) {
totalCost: session.totalCost || 0, totalCost: session.totalCost || 0,
totalUsage: session.totalUsage || null, totalUsage: session.totalUsage || null,
updated: session.updated, updated: session.updated,
historyTotal: transportMessages.length,
historyBaseIndex: 0,
hasUnread: false, hasUnread: false,
historyPending: false, historyPending: false,
isRunning: false, isRunning: false,
@@ -10109,10 +10223,11 @@ function handleImportCodexSession(ws, msg) {
saveSession(session); saveSession(session);
wsSessionMap.set(ws, id); wsSessionMap.set(ws, id);
const transportMessages = sanitizeMessagesForTransport(session.messages);
wsSend(ws, attachClientRequestId({ wsSend(ws, attachClientRequestId({
type: 'session_info', type: 'session_info',
sessionId: id, sessionId: id,
messages: session.messages, messages: transportMessages,
title: session.title, title: session.title,
pinnedAt: session.pinnedAt || null, pinnedAt: session.pinnedAt || null,
mode: session.permissionMode, mode: session.permissionMode,
@@ -10122,6 +10237,8 @@ function handleImportCodexSession(ws, msg) {
totalCost: session.totalCost || 0, totalCost: session.totalCost || 0,
totalUsage: session.totalUsage || null, totalUsage: session.totalUsage || null,
updated: session.updated, updated: session.updated,
historyTotal: transportMessages.length,
historyBaseIndex: 0,
hasUnread: false, hasUnread: false,
historyPending: false, historyPending: false,
isRunning: false, isRunning: false,