chore: rebuild CentOS7 release package
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updatedAt": "2026-06-30T15:42:26.421Z",
|
||||
"updatedAt": "2026-07-02T06:02:53.126Z",
|
||||
"replies": []
|
||||
}
|
||||
Binary file not shown.
@@ -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);
|
||||
|
||||
208
public/app.js
208
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' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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/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="app.js?v=20260629-ccweb-prompt-dark-theme"></script>
|
||||
<script src="app.js?v=20260702-visible-no-rerender"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
229
server.js
229
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,
|
||||
|
||||
Reference in New Issue
Block a user