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

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

View File

@@ -0,0 +1,197 @@
# cc-web WebSocket 替换为 SSE / WebTransport 评估报告
日期2026-09-14
## 结论
当前项目不适合直接把 WebSocket 整体替换成 SSE 或 WebTransport。
推荐保留 WebSocket 作为默认双向控制通道,同时增加“认证后的 SSE 下行通道 + HTTP
命令接口”的渐进式方案。这样可以先降低代理对 WebSocket 的依赖,并保留现有协议和
回退能力。WebTransport 适合后续独立 PoC不建议作为近期生产替换目标。
| 方案 | 当前项目可行性 | 适合的范围 | 主要结论 |
|---|---:|---|---|
| 继续使用 WebSocket | 高 | 现有全部功能 | 成本最低,协议已覆盖双向消息、审批和实时输出 |
| SSE + HTTP 命令 | 高(渐进式) | 服务端事件下行、流式输出 | 推荐;需要事件总线、重放和认证改造 |
| 纯 SSE 替换 | 中低 | 只读监控或单向推送 | 不匹配当前大量客户端命令和交互式请求 |
| WebTransport | 中低(近期),高(专用场景) | 高并发低延迟、可靠流+不可靠数据报 | 基础设施和 Node 服务端生态成本过高 |
## 当前 WebSocket 的职责
项目的 WebSocket 并非只用来传输模型文本,而是整个浏览器会话的双向 RPC 通道。
- `server.js:9330-9575` 创建 `/ws``WebSocketServer`,在首帧完成密码或 token
认证,然后按 JSON `type` 分发命令。
- 客户端在 `public/app.js:7781-7870` 建立连接、解析 JSON、指数退避重连并在
`public/app.js:7191-7204` 等位置通过同一连接发送命令。
- 普通 Claude/Codex 运行时在 `lib/agent-runtime.js:359-590` 产生
`text_delta``content_blocks``tool_start/update/end``usage` 等事件Codex
App 在 `lib/codex-app-runtime.js:437` 复用相同下行抽象。
- 服务端还按会话查看关系发送事件(`server.js:6835-6851`),并向所有认证客户端
广播任务看板和后台完成事件(`server.js:6512-6529``server.js:6827-6831`)。
- `activeProcesses``activeCodexAppTurns``wsSessionMap`
`server.js:1420-1503`)把运行中的进程、当前会话和连接绑定在内存中。断线时
`handleDisconnect``server.js:11397-11425`)解绑连接,但进程继续运行,重连后再
恢复查看。
- 客户端和服务端都有心跳:服务端 WebSocket ping 在 `server.js:9578-9604`,客户端
应用层 heartbeat 在 `public/app.js:7720-7775`。这也是当前反向代理长连接稳定性的
一部分。
因此,替换传输层必须保留以下语义:实时增量、工具调用生命周期、审批/引导输入的
双向往返、会话切换与恢复、跨会话广播、断线重连、重复命令保护和后台任务通知。
## SSE 评估
### 可行性
SSE 很适合承载本项目的服务端下行事件。`text_delta`、工具状态、`done`、会话列表、
任务看板和提示事件都可以编码为带 `event``id``data` 的 SSE 帧。浏览器原生
`EventSource` 自带自动重连,服务端可用注释心跳保持连接。
但 SSE 只能由服务器向浏览器推送。当前客户端发送的 `message``abort`、会话管理、
设置、任务看板查询、审批响应和引导输入响应必须迁移到 HTTP `POST`/`PATCH`/`DELETE`
命令接口,或继续由 WebSocket 承担。这意味着“纯 SSE”不是小改动“SSE 下行 + HTTP
命令”才是可行的替代架构。
### 优点
- 基于普通 HTTPNginx、Caddy、云负载均衡和审计工具更容易接入。
- 浏览器 API 简单,断线重连和 `Last-Event-ID` 已有标准语义。
- 事件天然是文本 JSON与当前 `wsSend(JSON.stringify(data))` 的消息模型接近。
- 单向输出的代码边界清晰,适合模型流式文本、工具进度和只读监控。
- 不需要 UDP/443、QUIC、HTTP/3 或新的服务端运行时。
### 缺点和改造点
- 不能承载现有客户端到服务端的命令;需要新建命令路由、请求 ID、错误响应和幂等控制。
- 原生 `EventSource` 不能设置自定义 `Authorization` 头。当前 token 放在首个 WebSocket
JSON 帧中,迁移时应优先改为安全 Cookie配套 CSRF 防护),或使用 `fetch` 流式读取;
把 token 放 URL 会进入代理日志、历史记录和监控标签,不建议。
- 当前仅保存在会话 JSON 和运行态文件中的状态不足以重放每个增量。必须为 SSE 事件分配
单调 `id`,并增加短期事件缓冲或按 `sessionId` 重发快照,否则网络抖动时会丢字、丢工具状态。
- HTTP/1.1 下浏览器对同源并发连接数有限HTTP/2 可改善连接复用,但反向代理必须关闭
响应缓冲并提高读超时。Nginx 通常需要 `proxy_buffering off``X-Accel-Buffering: no`
和足够大的 `proxy_read_timeout`
- 一条 SSE 流是有序可靠字节流;慢客户端会形成反压,需要限制队列、丢弃可重建事件或
断开慢连接,不能无限堆积内存。
- 水平扩展时SSE 客户端和运行进程仍绑定单个 Node 实例;需要粘性会话或 Redis/NATS
等发布订阅与事件重放层。当前项目没有这层基础设施。
### 对当前项目的评分
| 指标 | SSE 下行 + HTTP 命令 |
|---|---:|
| 代码复用 | 7/10 |
| 浏览器兼容 | 9/10 |
| 代理/部署 | 8/10 |
| 双向交互适配 | 6/10 |
| 断线恢复 | 需新增 6/10 |
| 近期落地建议 | 推荐灰度 |
## WebTransport 评估
### 可行性
WebTransport 基于 HTTP/3/QUIC同时提供可靠的双向流和可丢失的数据报理论上可以
较完整地承接当前 WebSocket 的双向 JSON 协议。普通命令、审批和模型文本应使用可靠
双向流;只有明确允许丢失的高频状态才考虑数据报。
当前项目的 Node `http.createServer` + `ws` 结构没有现成 WebTransport 入口。引入后需要
HTTP/3/QUIC 服务端库或独立网关、证书和连接管理;现有 `/ws` 的升级、心跳、认证、
反向代理配置和运维监控均不能直接复用。
### 优点
- 原生双向通信,命令和事件不必拆成两套协议。
- QUIC 在多条流之间避免 TCP 层队头阻塞;建立连接和网络切换体验可能更好。
- 可按场景选择可靠流或低延迟数据报,适合未来高频协作光标、实时遥测等功能。
- 连接由 HTTP/3 承载,具备现代传输层的多路复用能力。
### 缺点和风险
- 浏览器必须处于安全上下文,服务端和代理必须支持 HTTP/3/QUICUDP/443、防火墙、
云负载均衡和企业网络放行都成为部署前置条件。
- Node 核心当前没有与 `ws` 同等成熟、可直接替换的稳定高层 WebTransport 服务端 API
需要评估第三方库或独立网关的维护状态、内存安全和协议兼容性。
- 现有 Nginx/HTTP 反向代理配置按 HTTP/1.1 WebSocket 编写,不能假设能透明转发
WebTransport需要逐个验证 HTTP/3 终止点、QUIC 到后端的转发方式和超时策略。
- 数据报不保证送达、顺序或不重复不能承载文本增量、审批、abort、会话切换等关键
消息;可靠流仍需实现应用层消息边界、背压、重连和幂等。
- 连接迁移、连接 ID、TLS、HTTP/3 日志和指标与当前 WebSocket 运维经验不同,故障排查
成本明显更高。
- Safari、旧版浏览器、企业代理和受限网络的可用性需要实测生产仍需 WebSocket/SSE
回退;这会带来三套客户端和协议测试矩阵。
### 对当前项目的评分
| 指标 | WebTransport |
|---|---:|
| 代码复用 | 5/10 |
| 浏览器兼容 | 5/10需目标用户实测 |
| 代理/部署 | 3/10 |
| 双向交互适配 | 8/10 |
| 低延迟/多路复用潜力 | 9/10 |
| 近期落地建议 | 不推荐直接替换 |
## 改造规模与风险
| 领域 | SSE 方案 | WebTransport 方案 |
|---|---|---|
| 服务端 | 抽象 `wsSend` 为事件发布器;新增 SSE 连接、命令 API、事件 ID/重放 | 替换连接层、增加 HTTP/3/QUIC 服务端和可靠流协议 |
| 前端 | `EventSource`/fetch 流读取;把 `send()` 改为 HTTP 命令;保留统一消息处理器 | 新建 WebTransport 客户端、流帧协议、能力探测和多级回退 |
| 认证 | Cookie+CSRF 或 fetch 自定义头;处理连接失效 | QUIC 握手后应用认证、连接恢复和 token 轮换 |
| 恢复 | `Last-Event-ID`、事件缓冲、会话快照 | 连接迁移、流重建、消息幂等与快照 |
| 部署 | 代理关闭缓冲、长超时HTTP/2 优先 | HTTP/3、UDP/443、证书、网关、监控和防火墙 |
| 测试 | 事件顺序、重放、慢客户端、代理超时、CSRF | 可靠/不可靠流、丢包、网络切换、浏览器和代理矩阵 |
| 估算 | 24 人周做灰度骨架48 人周完成替换 | 612 人周 PoC生产化通常更久取决于网关和网络 |
上述估算不包含新增 Redis/NATS、HTTP/3 网关或大规模压测;多实例部署会增加工作量。
## 推荐实施路线
1. 先把 `wsSend``sendSessionEventToViewers`、全局广播和运行时 `sendRuntime` 收敛到
一个传输无关的事件发布接口,统一事件名、`sessionId``requestId`、时间戳和递增
`eventId`。保留现有 WebSocket 适配器,确保这一步行为不变。
2. 增加受保护的 `/api/events` SSE 端点,只接入只读会话列表、任务事件和运行时下行事件。
对每个连接限制队列,发送注释心跳,并支持 `Last-Event-ID` 后按会话重发快照。
3. 以 Cookie 或 `fetch` 流读取解决认证,不把长期 token 放入 URL命令端点使用
`requestId` 和幂等键,返回 202 后由 SSE 回传结果。先迁移 `message``abort`、会话
切换和审批响应,再迁移设置与任务看板命令。
4. 对比 WebSocket 与 SSE 的首字延迟、完整回合延迟、断线恢复丢事件数、慢客户端内存、
代理超时和移动网络表现。通过特性开关按用户或会话灰度WebSocket 保留为回退。
5. 只有在所有命令均有 HTTP 等价物、事件重放和多实例路由验证通过后,才考虑下线默认
WebSocket机器人和内部集成需单独迁移不能只看浏览器 UI。
6. 如确有 QUIC 需求,再建立独立 WebTransport PoC先验证目标浏览器、TLS/HTTP3 网关、
UDP 网络、可靠流重连和监控,再决定是否替代 SSE/WS。
## 最终建议
对 cc-web 当前“模型流式输出 + 交互式审批 + 会话控制 + 单 Node 进程状态绑定”的
形态,优先级应为:
1. **近期:继续 WebSocket先做传输无关事件层和协议整理。**
2. **中期SSE 下行 + HTTP 命令灰度,逐步减少对 WebSocket 的依赖。**
3. **长期:只有在确认 HTTP/3/QUIC 基础设施和用户浏览器覆盖后,才评估 WebTransport。**
直接替换为 SSE 会把双向协议问题转移到大量 HTTP 命令和恢复逻辑;直接替换为
WebTransport 则会同时引入协议、网关、网络和运维风险。混合渐进式路线能以较小范围验证
收益,并保留可回退路径。
## 本地依据与限制
- 依据当前工作树源码和 `home-cc-web` codebase-memory 索引索引状态ready节点
8138、边 19178完成未修改现有源码。
- 评估假设仍是单 Node/PM2 实例、文件会话存储和现有反向代理形态;如果部署已经具备
HTTP/3 网关、共享消息总线或强制 Cookie 会话WebTransport/SSE 的成本会下降。
- 本次只读核对发现 `ccweb` 的 PM2 进程约 21 分钟前被外部定时单元重启,重启计数为
61环境中带有 `TRIGGER_UNIT=ccweb-restart-final-1789391174.timer`;该重启不是本次
评估触发的。
## 参考标准
- WHATWG Server-sent events<https://html.spec.whatwg.org/multipage/server-sent-events.html>
- MDN Server-sent events<https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events>
- RFC 9297 WebTransport<https://www.rfc-editor.org/rfc/rfc9297.html>
- RFC 9298 WebTransport over HTTP/3<https://www.rfc-editor.org/rfc/rfc9298.html>
- MDN WebTransport API<https://developer.mozilla.org/en-US/docs/Web/API/WebTransport>

View File

@@ -160,7 +160,9 @@ function createCodexAppWorkerClient(options = {}) {
async function start() {
await configureIfNeeded();
const result = await sendWorker('start', {}, 30000);
// worker 内部还要完成 initialize 和两个 best-effort 能力探测;外层
// 30 秒预算会在冷启动时提前结束,留下一个仍在初始化的 app-server。
const result = await sendWorker('start', {}, 120000);
appServerRunning = true;
return result;
}

View File

@@ -99,6 +99,8 @@
const SESSION_LOAD_OVERLAY_TIMEOUT_MS = 12_000;
const SESSION_LOAD_REQUEST_TIMEOUT_MS = 45_000;
const SESSION_RESUME_FALLBACK_MS = 1_500;
// session_list 可能在重连/原子写入窗口中暂时缺少当前会话;不要把一次快照抖动当成删除。
const SESSION_LIST_MISSING_CONFIRM_MS = 1_500;
const WS_CLIENT_HEARTBEAT_INTERVAL_MS = 15_000;
const WS_CLIENT_HEARTBEAT_TIMEOUT_MS = 10_000;
@@ -296,6 +298,7 @@
let pendingNewSessionRequest = null;
let pendingSessionSwitchRequest = null;
let pendingSessionResumeRequest = null;
let pendingMissingSessionConfirmation = null;
let sessionSwitchRequestSeq = 0;
let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1';
let pendingInitialSessionLoad = false;
@@ -6510,6 +6513,71 @@
updateGenerationControls();
}
function clearPendingMissingSessionConfirmation(sessionId = null) {
const pending = pendingMissingSessionConfirmation;
if (!pending) return;
if (sessionId && pending.sessionId !== sessionId) return;
if (pending.timer) clearTimeout(pending.timer);
pendingMissingSessionConfirmation = null;
}
function scheduleMissingSessionConfirmation(sessionId) {
if (!sessionId) return;
const existing = pendingMissingSessionConfirmation;
if (existing?.sessionId === sessionId) {
existing.missingCount += 1;
return;
}
clearPendingMissingSessionConfirmation();
const pending = {
sessionId,
missingCount: 1,
timer: null,
};
const confirmMissingSession = () => {
if (pendingMissingSessionConfirmation !== pending) return;
if (currentSessionId !== sessionId) {
clearPendingMissingSessionConfirmation(sessionId);
return;
}
// 当前会话正在等待明确的加载/重连响应时,不能把等待中的视图清掉。
const hasPendingLoad = !!(
activeSessionLoad?.sessionId === sessionId
|| pendingSessionSwitchRequest?.sessionId === sessionId
|| pendingSessionResumeRequest?.sessionId === sessionId
);
if (pending.missingCount < 2 || hasPendingLoad) {
pending.timer = setTimeout(confirmMissingSession, SESSION_LIST_MISSING_CONFIRM_MS);
return;
}
pendingMissingSessionConfirmation = null;
resetChatView(currentAgent);
};
pendingMissingSessionConfirmation = pending;
pending.timer = setTimeout(confirmMissingSession, SESSION_LIST_MISSING_CONFIRM_MS);
// 主动再取一次列表,只有连续两次都缺少当前会话才允许清空视图。
send({ type: 'list_sessions' });
}
function reconcileCurrentSessionListSnapshot(nextSessions) {
const normalized = Array.isArray(nextSessions) ? nextSessions : [];
const sessionId = currentSessionId;
if (!sessionId) {
clearPendingMissingSessionConfirmation();
return normalized;
}
if (normalized.some((session) => session.id === sessionId)) {
clearPendingMissingSessionConfirmation(sessionId);
return normalized;
}
const previousCurrent = sessions.find((session) => session.id === sessionId) || null;
scheduleMissingSessionConfirmation(sessionId);
if (!previousCurrent) return normalized;
// 保留上一次确认过的当前会话元数据,避免侧栏高亮和聊天视图同时闪退。
return [previousCurrent, ...normalized.filter((session) => session.id !== sessionId)];
}
function closeAgentMenu() {
if (!chatAgentMenu) return;
chatAgentMenu.hidden = true;
@@ -6525,6 +6593,7 @@
}
function resetChatView(agent) {
clearPendingMissingSessionConfirmation();
setCurrentAgent(agent);
closeUserOutlinePanel();
closeCcwebPromptOutlinePanel();
@@ -6597,6 +6666,8 @@
resetHistoryLoadState(snapshot.sessionId);
resetOutlineHistoryState(snapshot.sessionId);
}
// session_info 是当前会话的明确存在证明,取消 session_list 缺失确认窗口。
clearPendingMissingSessionConfirmation();
currentSessionId = snapshot.sessionId;
syncHistoryLoadControl(snapshot);
syncOutlineHistoryIndex(snapshot);
@@ -6638,6 +6709,12 @@
startGenerating(snapshot.sessionId);
}
} else {
// 运行中重连/重复 load 时仍需把服务端持久化历史与现有流式 DOM 对账;
// 仅保留 streaming-msg 会导致用户气泡在第一次切换时消失。
reconcileRenderedSessionMessages(snapshot.messages || [], {
baseIndex: snapshot.historyBaseIndex || 0,
preserveScroll: true,
});
generatingSessionId = snapshot.sessionId;
}
highlightActiveSession();
@@ -7031,6 +7108,9 @@
function beginSessionSwitch(sessionId, options = {}) {
if (!sessionId) return;
if (currentSessionId && currentSessionId !== sessionId) {
clearPendingMissingSessionConfirmation(currentSessionId);
}
const blocking = options.blocking !== false;
const force = options.force === true;
if (!force && activeSessionLoad?.sessionId === sessionId && !activeSessionLoad.overlayReleased) return;
@@ -7167,7 +7247,8 @@
});
return;
}
if (!options.force && sessionId === currentSessionId && !activeSessionLoad) return;
const isPendingMissingCurrent = pendingMissingSessionConfirmation?.sessionId === sessionId;
if (!options.force && sessionId === currentSessionId && !activeSessionLoad && !isPendingMissingCurrent) return;
const disposition = getSessionCacheDisposition(sessionId);
if (disposition === 'strong') {
@@ -7185,7 +7266,7 @@
}
beginSessionSwitch(sessionId, {
blocking: options.blocking !== false,
force: options.force === true,
force: options.force === true || isPendingMissingCurrent,
label: options.label,
targetMessageIndex: options.targetMessageIndex,
});
@@ -7929,7 +8010,9 @@
break;
case 'session_list':
sessions = Array.isArray(msg.sessions) ? msg.sessions.map(normalizeSessionSnapshot) : [];
sessions = reconcileCurrentSessionListSnapshot(
Array.isArray(msg.sessions) ? msg.sessions.map(normalizeSessionSnapshot) : [],
);
reconcileSessionCacheWithSessions();
renderSessionList();
if (currentSessionId) {
@@ -7939,9 +8022,6 @@
pendingInitialSessionLoad = false;
initialSessionListHandled = true;
syncViewForAgent(currentAgent, { preserveCurrent: false, loadLast: true });
} else if (currentSessionId && !getSessionMeta(currentSessionId)) {
initialSessionListHandled = true;
resetChatView(currentAgent);
} else {
initialSessionListHandled = true;
}
@@ -8457,6 +8537,21 @@
case 'resume_session_result':
if (!isCurrentSessionEvent(msg)) break;
clearPendingSessionResumeRequest(msg.sessionId || currentSessionId, msg.requestId);
if (msg.sessionId === currentSessionId && Array.isArray(msg.messages) && msg.messages.length > 0) {
const renderedMessages = messagesDiv.querySelectorAll('[data-session-message="true"]').length;
const historyTotal = Number.isFinite(Number(msg.historyTotal))
? Math.max(0, Number(msg.historyTotal))
: msg.messages.length;
if (renderedMessages < historyTotal) {
reconcileRenderedSessionMessages(msg.messages, {
preserveScroll: false,
baseIndex: Number.isFinite(Number(msg.historyBaseIndex))
? Number(msg.historyBaseIndex)
: Math.max(0, historyTotal - msg.messages.length),
});
loadedHistorySessionId = msg.sessionId;
}
}
setCurrentSessionRunningState(!!msg.isRunning);
if (!msg.isRunning && currentSessionId && msg.sessionId === currentSessionId) {
finishGenerating(msg.sessionId || currentSessionId);
@@ -11487,6 +11582,37 @@
}
}
function reconcileRenderedSessionMessages(messages, options = {}) {
if (!Array.isArray(messages) || messages.length === 0) return;
const baseIndex = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0;
const existing = new Map();
messagesDiv.querySelectorAll('[data-session-message="true"][data-message-index]').forEach((element) => {
const index = Number(element.dataset.messageIndex);
if (Number.isFinite(index)) existing.set(index, element);
});
const beforeHeight = messagesDiv.scrollHeight;
const beforeScrollTop = messagesDiv.scrollTop;
const stream = document.getElementById('streaming-msg');
let inserted = 0;
messages.forEach((message, offset) => {
const messageIndex = baseIndex + offset;
if (existing.has(messageIndex)) return;
const element = buildMsgElement(message, messageIndex);
const next = Array.from(existing.entries())
.filter(([index]) => index > messageIndex)
.sort(([left], [right]) => left - right)[0]?.[1];
messagesDiv.insertBefore(element, next || stream || null);
existing.set(messageIndex, element);
inserted += 1;
});
if (inserted === 0) return;
updateUserOutlinePanel();
if (options.preserveScroll !== false) {
messagesDiv.scrollTop = beforeScrollTop + (messagesDiv.scrollHeight - beforeHeight);
}
updateScrollbar();
}
// --- Custom Scrollbar ---
const scrollbarEl = document.getElementById('custom-scrollbar');
const thumbEl = document.getElementById('custom-scrollbar-thumb');

View File

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

View File

@@ -1467,6 +1467,7 @@ const CODEX_APP_MCP_RELOAD_TRACK_MS = readPositiveIntEnv(
{ min: 5000, max: 300000 },
);
const CODEX_APP_MCP_INVENTORY_TTL_MS = 3000;
const CODEX_APP_MCP_INVENTORY_FAILURE_TTL_MS = 30000;
const CODEX_APP_MCP_INVENTORY_MAX_PAGES = 10;
const codexAppMcpStartupStatusByServer = new Map();
// threadId -> { fetchedAt, servers }
@@ -3573,12 +3574,56 @@ async function waitForCodexAppMcpInventory(session, options = {}) {
}
}
function codexAppMcpStatusForThread(session, threadId, serverName = CODEX_APP_MCP_DEFAULT_SERVER) {
const normalizedThreadId = normalizeCodexAppThreadId(threadId || '');
if (!normalizedThreadId) return null;
const summary = buildCodexAppMcpStatusSummary(session, { serverName });
const statusThreadId = normalizeCodexAppThreadId(summary?.threadId || '');
if (statusThreadId !== normalizedThreadId) return null;
return summary;
}
async function waitForCodexAppMcpReadyStatus(session, threadId, options = {}) {
const normalizedThreadId = normalizeCodexAppThreadId(threadId || getRuntimeSessionId(session));
const maxWaitMs = Math.max(0, Number(options.maxWaitMs ?? CODEX_APP_MCP_STARTUP_TIMEOUT_SEC * 1000));
const deadline = Date.now() + maxWaitMs;
let lastSummary = null;
while (true) {
lastSummary = codexAppMcpStatusForThread(session, normalizedThreadId);
if (lastSummary?.status === 'ready') {
return { ok: true, code: 'ok', threadId: normalizedThreadId, status: lastSummary };
}
if (lastSummary?.status === 'failed' || lastSummary?.status === 'cancelled') {
return {
ok: false,
code: 'ccweb_startup_failed',
threadId: normalizedThreadId,
status: lastSummary,
error: lastSummary.message || `当前线程 ccweb MCP 启动${lastSummary.status === 'failed' ? '失败' : '已取消'}`,
};
}
if (Date.now() >= deadline) {
return {
ok: false,
code: 'ccweb_startup_timeout',
threadId: normalizedThreadId,
status: lastSummary,
error: `当前线程 ccweb MCP 在 ${Math.ceil(maxWaitMs / 1000)} 秒内未完成启动。`,
};
}
await new Promise((resolve) => setTimeout(resolve, Math.min(250, Math.max(1, deadline - Date.now()))));
}
}
async function loadCodexAppMcpInventory(session) {
if (!isCodexAppSession(session)) return [];
const threadId = normalizeCodexAppThreadId(getRuntimeSessionId(session));
if (!threadId || !codexAppClient?.isRunning()) return [];
const cached = codexAppMcpInventoryByThread.get(threadId);
if (cached && Date.now() - cached.fetchedAt < CODEX_APP_MCP_INVENTORY_TTL_MS) {
const cacheTtl = cached?.failed
? CODEX_APP_MCP_INVENTORY_FAILURE_TTL_MS
: CODEX_APP_MCP_INVENTORY_TTL_MS;
if (cached && Date.now() - cached.fetchedAt < cacheTtl) {
return cached.servers;
}
const existing = codexAppMcpInventoryPending.get(threadId);
@@ -3591,9 +3636,9 @@ async function loadCodexAppMcpInventory(session) {
code: result.code,
error: result.error,
});
const error = new Error(result.error || 'MCP 工具清单查询失败。');
error.code = result.code || 'codexapp_mcp_inventory_failed';
throw error;
// 清单仅用于补充 composer 候选;慢或暂不可用时保留本地配置候选,不能阻断输入框和首轮消息。
codexAppMcpInventoryByThread.set(threadId, { fetchedAt: Date.now(), servers: [], failed: true });
return [];
}
codexAppMcpInventoryByThread.set(threadId, { fetchedAt: Date.now(), servers: result.servers });
return result.servers;
@@ -3613,13 +3658,10 @@ async function ensureCodexAppMcpReadyForThread(session, threadId) {
const cachedSummary = summarizeCodexAppMcpInventory(cached.servers);
if (cachedSummary.ccwebReady) return cached.servers;
}
const result = await waitForCodexAppMcpInventory(session, {
threadId: normalizedThreadId,
requireCcweb: true,
timeoutMs: 5000,
maxWaitMs: CODEX_APP_MCP_STARTUP_TIMEOUT_SEC * 1000,
});
recordCodexAppMcpInventoryState(session, result);
// MCP 启动状态和完整 inventory 都是运行能力探测,不能阻断首轮 turn/start。
// app-server 会在工具真正调用时继续管理 MCP 生命周期;慢的全局 MCP例如
// 远程 playwright不应让当前线程连普通消息也发不出去。
const result = await waitForCodexAppMcpReadyStatus(session, normalizedThreadId, { maxWaitMs: 0 });
if (!result.ok) {
plog('WARN', 'codex_app_mcp_preflight_failed', {
sessionId: session?.id ? session.id.slice(0, 8) : null,
@@ -3627,10 +3669,9 @@ async function ensureCodexAppMcpReadyForThread(session, threadId) {
code: result.code,
error: result.error,
});
throw new Error(result.error || '当前线程 MCP 工具不可用,已停止发送本轮消息。');
return [];
}
codexAppMcpInventoryByThread.set(normalizedThreadId, { fetchedAt: Date.now(), servers: result.servers });
return result.servers;
return codexAppMcpInventoryByThread.get(normalizedThreadId)?.servers || [];
}
function summarizeSkillDependencies(skill) {
@@ -6670,8 +6711,11 @@ function sendSessionList(ws) {
}
sessions.sort(compareSessionsForList);
wsSend(ws, { type: 'session_list', sessions });
} catch {
wsSend(ws, { type: 'session_list', sessions: [] });
} catch (error) {
// 会话目录短暂不可读时不要广播空列表;空列表会让前端误判当前会话已被删除并清空气泡。
plog('WARN', 'session_list_load_failed', {
error: error?.message || String(error || ''),
});
}
}
@@ -11045,13 +11089,29 @@ function handleResumeSession(ws, msg = {}) {
detachWsFromActiveRuntimes(ws);
wsSessionMap.set(ws, sessionId);
const attached = attachActiveRuntimeToWs(ws, sessionId, msg);
// 重连只发送 resume_generating 会让前端无法补回断线期间丢掉的历史气泡;
// 同时带上最近历史,前端可按消息索引幂等补齐而不会重绘整个会话。
const history = typeof resolveSessionHistory === 'function'
? resolveSessionHistory(session)
: { messages: session.messages || [], source: 'snapshot', available: true };
const historyMessages = sanitizeMessagesForTransport(history.messages || []);
const recentMessages = historyMessages.slice(-INITIAL_HISTORY_COUNT);
const hasActiveRuntime = activeProcesses.has(sessionId)
|| activeCodexAppTurns.has(sessionId)
|| activeCodexAppGoalCommands.has(sessionId);
wsSend(ws, attachClientRequestId({
type: 'resume_session_result',
sessionId,
isRunning: isSessionRunning(sessionId),
attached,
attached: hasActiveRuntime,
messages: recentMessages,
historyTotal: historyMessages.length,
historyBaseIndex: Math.max(0, historyMessages.length - recentMessages.length),
historyAvailable: history.available !== false,
historySource: history.source || 'snapshot',
}, msg));
// 先让前端补齐已持久化气泡,再追加流式气泡,避免重连瞬间只看到助手输出。
if (hasActiveRuntime) attachActiveRuntimeToWs(ws, sessionId, msg);
}
function handleLoadSession(ws, msg) {