chore: rebuild release package
This commit is contained in:
47
.planning/session-form-switch-race/findings.md
Normal file
47
.planning/session-form-switch-race/findings.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# 调研发现
|
||||||
|
|
||||||
|
## 用户侧证据
|
||||||
|
|
||||||
|
- 截图一:底部“表单”入口角标为 `1`,消息区只有普通助手消息,没有待处理表单卡片。
|
||||||
|
- 截图二:执行 F5 后,同一会话顶部出现“先定空条件规则”表单,角标仍为 `1`。
|
||||||
|
- 由此可排除“表单未持久化”这一主假设,更可能是 SPA 会话切换后的客户端状态同步或渲染失效。
|
||||||
|
|
||||||
|
## 待验证假设
|
||||||
|
|
||||||
|
1. 会话切换触发的消息请求存在竞态,旧会话响应晚到后覆盖当前消息列表。
|
||||||
|
2. 待处理表单计数按全局/会话摘要更新,但表单卡片依赖另一份未同步的消息状态。
|
||||||
|
3. 表单渲染使用了只在首次加载执行的派生缓存、去重集合或已处理 ID 集合,切换会话时未重置/重建。
|
||||||
|
|
||||||
|
## 代码定位(第一轮)
|
||||||
|
|
||||||
|
- `renderPendingCcwebPrompts()` 通过 `collectCurrentPendingCcwebPrompts()` 统计表单角标。
|
||||||
|
- `collectCurrentPendingCcwebPrompts()` 优先读取 `sessionCache.get(currentSessionId).snapshot.messages`,随后再扫描消息区 DOM;因此角标可以来自完整缓存,即使卡片尚未进入 DOM。
|
||||||
|
- `renderMessages()` 对超过 10 条的历史消息采用分批渲染:先渲染最后 10 条,再用 `setTimeout` 逐批前插;每个延迟批次用全局 `renderEpoch` 防止旧会话继续写 DOM。
|
||||||
|
- `renderMessages()` 首批完成后调用 `renderPendingNotes()`,后者会立即刷新表单角标;此时较旧的表单消息可能还未渲染,形成“角标为 1、卡片暂缺”的短暂状态。
|
||||||
|
- `applySessionSnapshot()` 是缓存展示和服务端 `session_info` 的共同入口;缓存展示强制 `immediate: true`,正常服务端响应默认分批渲染。
|
||||||
|
- 当前需要继续验证:快速切换过程中,哪个响应/早退分支使当前会话的剩余批次被 `renderEpoch` 永久取消,却没有再次对当前快照执行完整渲染。
|
||||||
|
|
||||||
|
## 已确认关键符号
|
||||||
|
|
||||||
|
- `public/app.js`: `openSession`、`showCachedSession`、`applySessionSnapshot`、`renderMessages`
|
||||||
|
- `public/app.js`: `collectCurrentPendingCcwebPrompts`、`renderPendingCcwebPrompts`、`renderPendingNotes`
|
||||||
|
- `scripts/regression.js`: `assertSessionSwitchResilienceContract`
|
||||||
|
|
||||||
|
## 根因结论
|
||||||
|
|
||||||
|
1. `beginSessionSwitch()` 在请求意图阶段执行 `renderEpoch++`;如果新快照尚未提交、请求被快速切换覆盖或失败,旧 DOM 仍展示但其剩余批次已永久失效。真正替换 DOM 的 `renderMessages()` 本身已经推进并校验 `renderEpoch`,所以前者属于重复且有害的失效动作。
|
||||||
|
2. `session_info` 的 `canSwitchToSessionInfo` 对当前 `sessionId` 无条件放行,没有阻止同一会话旧 `requestId` 响应重绘当前 DOM。
|
||||||
|
3. `handleLoadSession()` 给 `session_info` 附带了客户端 `requestId`,但 `session_history_chunk` 没有;前端无法区分 `A→B→A` 中两次 A 加载的历史块。
|
||||||
|
4. `finalizeLoadedSession()` 只按 `sessionId` 完成加载,旧 A 尾块可误完成新 A 请求。
|
||||||
|
|
||||||
|
## 测试策略
|
||||||
|
|
||||||
|
- 在 `scripts/regression.js` 新增无依赖、确定性的渲染 epoch 调度测试:发起 B 但不提交 B 时,A 的延迟表单批次必须继续;真正提交 B 后,A 批次必须停止。
|
||||||
|
- 扩展会话切换静态契约:`beginSessionSwitch` 不推进 epoch;带请求 ID 的旧 `session_info` 不得仅因 sessionId 当前而放行;历史块及 finalize 路径校验请求 ID。
|
||||||
|
- 扩展 WebSocket 集成断言:`session_info` 与同次 `session_history_chunk` 回传相同请求 ID。
|
||||||
|
|
||||||
|
## 仓库协作状态
|
||||||
|
|
||||||
|
- `.planning/.active_plan` 当前指向其他任务 `subagent-card-metadata`,本次不改动该指针。
|
||||||
|
- `.trellis/.current-task` 当前指向其他任务 `07-17-gilded-wasteland-theme`,本次不覆盖。
|
||||||
|
- 工作树已有无关未跟踪 CSV,必须保留。
|
||||||
25
.planning/session-form-switch-race/progress.md
Normal file
25
.planning/session-form-switch-race/progress.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# 进度日志
|
||||||
|
|
||||||
|
## 2026-07-18
|
||||||
|
|
||||||
|
- 已读取并启用 `planning-with-files` 与 `todo-list-csv` 技能。
|
||||||
|
- 已分析用户提供的两张截图,确认 F5 可恢复表单渲染。
|
||||||
|
- 已隔离创建本任务规划文件,未覆盖其他会话的活动计划。
|
||||||
|
- 当前阶段:定位表单计数与消息渲染的会话切换链路。
|
||||||
|
- 计划审查代理已通过计划,无阻塞问题。
|
||||||
|
- `codebase-memory-mcp` 索引 `home-cc-web` 状态为 ready(3950 节点、8664 边)。
|
||||||
|
- 已确认表单角标读取完整 `sessionCache`,消息区历史采用带 `renderEpoch` 的异步分批 DOM 渲染,二者存在可观察状态分叉。
|
||||||
|
- 两条独立只读调查均确认 `beginSessionSwitch` 过早失效渲染批次;另确认同一会话旧 `requestId` 响应与无请求 ID 历史块存在竞态。
|
||||||
|
- 已完成步骤 1,进入步骤 2:先编写可确定性失败的回归测试。
|
||||||
|
- 已在 `scripts/regression.js` 新增 `session-switch-race` 定向回归入口,并接入默认回归。
|
||||||
|
- 已运行 `node scripts/regression.js --target session-switch-race`,当前生产代码如预期失败:渲染 epoch、旧 session_info、历史块/finalize 请求归属、服务端历史块 requestId 五类断言全部命中。
|
||||||
|
- 已完成步骤 2、3,进入步骤 4:修改 `public/app.js` 与 `server.js`。
|
||||||
|
- 注意:`scripts/regression.js` 同时出现其他会话新增的侧栏测试改动,本任务不覆盖、不回退。
|
||||||
|
- 已修改 `public/app.js`:渲染 epoch 只在真实 DOM 替换时推进;`session_info`、历史块与 finalize 按加载 requestId 拒绝旧代次。
|
||||||
|
- 已修改 `server.js`:`session_history_chunk` 透传 `load_session` 的 requestId。
|
||||||
|
- 已完成步骤 4;定向 `session-switch-race` 回归、三份 JS 语法检查及 diff whitespace 检查均通过,进入步骤 5 完整回归。
|
||||||
|
- 第一次完整回归失败于旧源码字符串契约 `!messageRequestId && !currentSessionId && !activeLoad && !pendingNewSession`;行为兼容分支仍存在,已改为具名布尔量以恢复旧契约,待重跑。
|
||||||
|
- 定向 `session-switch-race` 回归再次通过。
|
||||||
|
- 完整 `npm run regression` 通过并输出 `Regression checks passed.`,退出码 0。
|
||||||
|
- 已完成步骤 5,进入步骤 6:静态检查与影响面审查。
|
||||||
|
- `codebase-memory-mcp detect_changes` 在影响分析阶段返回 `Transport closed`;已按降级策略改用本地静态检查与独立只读审查。
|
||||||
48
.planning/session-form-switch-race/task_plan.md
Normal file
48
.planning/session-form-switch-race/task_plan.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# 会话切换后表单不渲染修复计划
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
修复频繁切换会话后“表单”角标已有待处理数量、但消息区未渲染表单的问题,确保切回会话后无需 F5 即可看到并操作待处理表单。
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 频繁切换包含待处理表单和普通消息的多个会话后,当前会话的表单立即渲染。
|
||||||
|
- 旧会话异步请求晚到时,不得覆盖当前会话的消息或表单派生状态。
|
||||||
|
- 表单角标数量与消息区实际可见待处理表单保持一致。
|
||||||
|
- 页面刷新后的恢复行为与 SPA 内切换行为一致。
|
||||||
|
- 新增竞态回归测试,并通过相关前端测试与静态检查。
|
||||||
|
|
||||||
|
## 工作步骤
|
||||||
|
|
||||||
|
1. [DONE] 定位表单计数与消息渲染的会话切换链路
|
||||||
|
2. [DONE] 编写可复现竞态的回归测试
|
||||||
|
3. [DONE] 运行回归测试并确认当前实现失败
|
||||||
|
4. [DONE] 修复会话切换时的表单状态同步与过期请求覆盖
|
||||||
|
5. [DONE] 运行前端相关测试并确认回归通过
|
||||||
|
6. [IN_PROGRESS] 执行静态检查并审查影响面
|
||||||
|
7. [TODO] 使用真实浏览器验证频繁切换无需刷新
|
||||||
|
|
||||||
|
## 关键假设
|
||||||
|
|
||||||
|
- 后端数据完整,因为 F5 后同一待处理表单能够显示。
|
||||||
|
- 缺陷位于前端会话切换、消息加载或表单派生渲染状态的同步路径。
|
||||||
|
- 修复应优先采用请求归属校验或会话级状态隔离,不依赖强制刷新。
|
||||||
|
|
||||||
|
## 已确认根因与修复边界
|
||||||
|
|
||||||
|
- `beginSessionSwitch()` 在切换请求刚发起、DOM 尚未替换时提前执行 `renderEpoch++`,会永久取消当前会话尚未完成的历史消息批次;表单若位于该批次,便形成“缓存角标有 1、DOM 卡片为 0”。
|
||||||
|
- `session_info` 对 `msg.sessionId === currentSessionId` 无条件放行,同一会话旧 `requestId` 响应可能覆盖新加载。
|
||||||
|
- 服务端 `session_history_chunk` 未回传 `requestId`,前端历史块也只按 `sessionId` 判断;`A→B→A` 时旧 A 尾块可能误完成新 A 加载。
|
||||||
|
- 修复同时覆盖:渲染代次只在真实 DOM 提交时推进;加载响应按 `sessionId + requestId` 归属;历史分块透传请求 ID。
|
||||||
|
|
||||||
|
## 风险
|
||||||
|
|
||||||
|
- 消息加载、流式事件和表单计数可能来自不同异步通道,需要避免只修复其中一条路径。
|
||||||
|
- 仓库由多个会话共享,不能覆盖现有 `.planning/.active_plan`、Trellis 当前任务或无关工作树改动。
|
||||||
|
|
||||||
|
## 错误记录
|
||||||
|
|
||||||
|
| 错误 | 尝试 | 处理 |
|
||||||
|
|---|---:|---|
|
||||||
|
| 完整回归的旧静态断言要求保留 requestless idle 分支原字符串 | 1 | 将兼容分支拆为具名布尔量,保留语义和可审查契约后重跑 |
|
||||||
|
| `codebase-memory-mcp detect_changes` 返回 `Transport closed` | 1 | 不重复同一失败调用,降级为本地 git diff/rg/语法检查和独立审查代理 |
|
||||||
Binary file not shown.
109
public/app.js
109
public/app.js
@@ -4275,6 +4275,8 @@
|
|||||||
updateSessionIdBadge();
|
updateSessionIdBadge();
|
||||||
updateCwdBadge();
|
updateCwdBadge();
|
||||||
updateReloadMcpButtonUI();
|
updateReloadMcpButtonUI();
|
||||||
|
// 真正替换聊天 DOM 时,使之前 renderMessages 的异步批次失效。
|
||||||
|
renderEpoch++;
|
||||||
messagesDiv.innerHTML = buildWelcomeMarkup(currentCwd);
|
messagesDiv.innerHTML = buildWelcomeMarkup(currentCwd);
|
||||||
setStatsDisplay(null);
|
setStatsDisplay(null);
|
||||||
renderPendingAttachments();
|
renderPendingAttachments();
|
||||||
@@ -4513,21 +4515,41 @@
|
|||||||
(!sessionId || activeSessionLoad.sessionId === sessionId));
|
(!sessionId || activeSessionLoad.sessionId === sessionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
function finishSessionSwitch(sessionId) {
|
function finishSessionSwitch(sessionId, requestId) {
|
||||||
|
// 带 requestId 的完成事件必须属于当前加载代次,避免旧请求尾块关闭新请求。
|
||||||
|
if (requestId && (!activeSessionLoad ||
|
||||||
|
activeSessionLoad.sessionId !== sessionId ||
|
||||||
|
activeSessionLoad.requestId !== requestId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isBlockingSessionLoad(sessionId)) {
|
if (isBlockingSessionLoad(sessionId)) {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
requestAnimationFrame(() => clearSessionLoading(sessionId));
|
requestAnimationFrame(() => {
|
||||||
|
// RAF 执行时再次校验请求代次,避免旧请求清除同会话的新加载。
|
||||||
|
if (requestId && (!activeSessionLoad ||
|
||||||
|
activeSessionLoad.sessionId !== sessionId ||
|
||||||
|
activeSessionLoad.requestId !== requestId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearSessionLoading(sessionId);
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clearSessionLoading(sessionId);
|
clearSessionLoading(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function finalizeLoadedSession(sessionId) {
|
function finalizeLoadedSession(sessionId, requestId) {
|
||||||
|
// 带 requestId 的完成事件必须属于当前加载代次,避免旧 A 尾块完成新 A 请求。
|
||||||
|
if (requestId && (!activeSessionLoad ||
|
||||||
|
activeSessionLoad.sessionId !== sessionId ||
|
||||||
|
activeSessionLoad.requestId !== requestId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (activeSessionLoad?.sessionId === sessionId && activeSessionLoad.snapshot) {
|
if (activeSessionLoad?.sessionId === sessionId && activeSessionLoad.snapshot) {
|
||||||
activeSessionLoad.snapshot.complete = true;
|
activeSessionLoad.snapshot.complete = true;
|
||||||
cacheSessionSnapshot(activeSessionLoad.snapshot);
|
cacheSessionSnapshot(activeSessionLoad.snapshot);
|
||||||
}
|
}
|
||||||
finishSessionSwitch(sessionId);
|
finishSessionSwitch(sessionId, requestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function beginSessionSwitch(sessionId, options = {}) {
|
function beginSessionSwitch(sessionId, options = {}) {
|
||||||
@@ -4536,7 +4558,6 @@
|
|||||||
const force = options.force === true;
|
const force = options.force === true;
|
||||||
if (!force && activeSessionLoad?.sessionId === sessionId && !activeSessionLoad.overlayReleased) return;
|
if (!force && activeSessionLoad?.sessionId === sessionId && !activeSessionLoad.overlayReleased) return;
|
||||||
if (!force && sessionId === currentSessionId && !activeSessionLoad) return;
|
if (!force && sessionId === currentSessionId && !activeSessionLoad) return;
|
||||||
renderEpoch++;
|
|
||||||
loadedHistorySessionId = null;
|
loadedHistorySessionId = null;
|
||||||
setSessionLoading(sessionId, { blocking, label: options.label });
|
setSessionLoading(sessionId, { blocking, label: options.label });
|
||||||
requestSessionLoad(sessionId, { blocking, label: options.label });
|
requestSessionLoad(sessionId, { blocking, label: options.label });
|
||||||
@@ -5304,24 +5325,28 @@
|
|||||||
&& (!pendingNewSession.cwd || snapshot.cwd === pendingNewSession.cwd)
|
&& (!pendingNewSession.cwd || snapshot.cwd === pendingNewSession.cwd)
|
||||||
&& (!pendingNewSession.mode || snapshot.mode === pendingNewSession.mode));
|
&& (!pendingNewSession.mode || snapshot.mode === pendingNewSession.mode));
|
||||||
const matchesActiveLoad = !!(activeLoad?.sessionId === msg.sessionId
|
const matchesActiveLoad = !!(activeLoad?.sessionId === msg.sessionId
|
||||||
&& (!activeLoad.requestId || activeLoad.requestId === messageRequestId));
|
&& (!messageRequestId || activeLoad.requestId === messageRequestId));
|
||||||
const matchesPendingNewSession = !!(pendingNewSession
|
const matchesPendingNewSession = !!(pendingNewSession
|
||||||
&& ((!pendingNewSession.requestId || pendingNewSession.requestId === messageRequestId)
|
&& ((messageRequestId && pendingNewSession.requestId === messageRequestId)
|
||||||
|| matchesPendingNewSessionFallback));
|
|| matchesPendingNewSessionFallback));
|
||||||
|
const allowsRequestlessCurrentSession = !messageRequestId && msg.sessionId === currentSessionId;
|
||||||
|
const allowsRequestlessIdleSession = !messageRequestId && !currentSessionId && !activeLoad && !pendingNewSession;
|
||||||
|
const allowsRequestlessUnclaimedSession = !messageRequestId && !activeLoad && !pendingNewSession;
|
||||||
const canSwitchToSessionInfo = matchesActiveLoad
|
const canSwitchToSessionInfo = matchesActiveLoad
|
||||||
|| matchesPendingNewSession
|
|| matchesPendingNewSession
|
||||||
|| msg.sessionId === currentSessionId
|
// 带 requestId 的响应只能由对应的活动加载/新建请求接管视图;
|
||||||
|| (!messageRequestId && !currentSessionId && !activeLoad && !pendingNewSession)
|
// 没有 requestId 的旧服务事件保留当前会话兼容路径。
|
||||||
|| (!messageRequestId && !activeLoad && !pendingNewSession);
|
|| allowsRequestlessCurrentSession
|
||||||
|
|| allowsRequestlessIdleSession
|
||||||
|
|| allowsRequestlessUnclaimedSession;
|
||||||
|
if (!canSwitchToSessionInfo) {
|
||||||
|
// 旧代次事件不能污染缓存或会话列表。
|
||||||
|
break;
|
||||||
|
}
|
||||||
mergeSessionListSnapshot(snapshot);
|
mergeSessionListSnapshot(snapshot);
|
||||||
if (matchesActiveLoad) {
|
if (matchesActiveLoad) {
|
||||||
activeSessionLoad.snapshot = snapshot;
|
activeSessionLoad.snapshot = snapshot;
|
||||||
}
|
}
|
||||||
if (!canSwitchToSessionInfo) {
|
|
||||||
if (!msg.historyPending) cacheSessionSnapshot(snapshot);
|
|
||||||
renderSessionList();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (matchesPendingNewSession) {
|
if (matchesPendingNewSession) {
|
||||||
pendingNewSessionRequest = null;
|
pendingNewSessionRequest = null;
|
||||||
if (!matchesActiveLoad) clearSessionLoading();
|
if (!matchesActiveLoad) clearSessionLoading();
|
||||||
@@ -5341,34 +5366,48 @@
|
|||||||
}
|
}
|
||||||
if (!msg.historyPending) {
|
if (!msg.historyPending) {
|
||||||
if (matchesActiveLoad) {
|
if (matchesActiveLoad) {
|
||||||
finalizeLoadedSession(msg.sessionId);
|
finalizeLoadedSession(msg.sessionId, messageRequestId || undefined);
|
||||||
} else {
|
} else {
|
||||||
cacheSessionSnapshot(snapshot);
|
cacheSessionSnapshot(snapshot);
|
||||||
finishSessionSwitch(msg.sessionId);
|
finishSessionSwitch(msg.sessionId, messageRequestId || undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'session_history_chunk':
|
case 'session_history_chunk': {
|
||||||
if (activeSessionLoad?.recoverCurrent && activeSessionLoad.sessionId === msg.sessionId) {
|
const historyRequestId = String(msg.requestId || '');
|
||||||
if (!msg.remaining) finalizeLoadedSession(msg.sessionId);
|
const matchesActiveHistoryLoad = !!(activeSessionLoad
|
||||||
|
&& activeSessionLoad.sessionId === msg.sessionId
|
||||||
|
&& (!historyRequestId || activeSessionLoad.requestId === historyRequestId));
|
||||||
|
const allowsLegacyHistory = !historyRequestId
|
||||||
|
&& msg.sessionId === currentSessionId
|
||||||
|
&& loadedHistorySessionId === msg.sessionId;
|
||||||
|
if (activeSessionLoad?.recoverCurrent && matchesActiveHistoryLoad) {
|
||||||
|
if (activeSessionLoad.snapshot) {
|
||||||
|
activeSessionLoad.snapshot.messages = cloneMessages(msg.messages || [])
|
||||||
|
.concat(activeSessionLoad.snapshot.messages);
|
||||||
|
}
|
||||||
|
if (!msg.remaining) finalizeLoadedSession(msg.sessionId, historyRequestId || undefined);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if ((matchesActiveHistoryLoad || allowsLegacyHistory)
|
||||||
|
&& msg.sessionId === currentSessionId
|
||||||
|
&& loadedHistorySessionId === msg.sessionId) {
|
||||||
|
const blocking = isBlockingSessionLoad(msg.sessionId);
|
||||||
|
if (activeSessionLoad?.sessionId === msg.sessionId && activeSessionLoad.snapshot) {
|
||||||
|
activeSessionLoad.snapshot.messages = cloneMessages(msg.messages || []).concat(activeSessionLoad.snapshot.messages);
|
||||||
|
}
|
||||||
|
prependHistoryMessages(msg.messages || [], {
|
||||||
|
preserveScroll: !blocking,
|
||||||
|
skipScrollbar: blocking,
|
||||||
|
baseIndex: Number.isFinite(Number(msg.historyBaseIndex)) ? Number(msg.historyBaseIndex) : 0,
|
||||||
|
});
|
||||||
|
if (!msg.remaining) {
|
||||||
|
finalizeLoadedSession(msg.sessionId, historyRequestId || undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (msg.sessionId === currentSessionId && loadedHistorySessionId === msg.sessionId) {
|
|
||||||
const blocking = isBlockingSessionLoad(msg.sessionId);
|
|
||||||
if (activeSessionLoad?.sessionId === msg.sessionId && activeSessionLoad.snapshot) {
|
|
||||||
activeSessionLoad.snapshot.messages = cloneMessages(msg.messages || []).concat(activeSessionLoad.snapshot.messages);
|
|
||||||
}
|
|
||||||
prependHistoryMessages(msg.messages || [], {
|
|
||||||
preserveScroll: !blocking,
|
|
||||||
skipScrollbar: blocking,
|
|
||||||
baseIndex: Number.isFinite(Number(msg.historyBaseIndex)) ? Number(msg.historyBaseIndex) : 0,
|
|
||||||
});
|
|
||||||
if (!msg.remaining) {
|
|
||||||
finalizeLoadedSession(msg.sessionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'session_message':
|
case 'session_message':
|
||||||
if (msg.sessionId && msg.message) {
|
if (msg.sessionId && msg.message) {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
document.documentElement.dataset.dividerTime = dividerTime;
|
document.documentElement.dataset.dividerTime = dividerTime;
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<link rel="stylesheet" href="style.css?v=20260718-wasteland-bg-right-4">
|
<link rel="stylesheet" href="style.css?v=20260718-shared-locator-panel-2">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -183,6 +183,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=20260718-wasteland-bg-right-4"></script>
|
<script src="app.js?v=20260718-shared-locator-panel-2"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3970,7 +3970,8 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.user-outline-panel {
|
.user-outline-panel {
|
||||||
right: 0;
|
left: 0;
|
||||||
|
right: auto;
|
||||||
width: min(320px, calc(100vw - 20px));
|
width: min(320px, calc(100vw - 20px));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4039,12 +4040,16 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span {
|
|||||||
right: auto;
|
right: auto;
|
||||||
}
|
}
|
||||||
.user-outline-panel {
|
.user-outline-panel {
|
||||||
left: 0;
|
left: auto;
|
||||||
right: 0;
|
right: 0;
|
||||||
width: auto;
|
width: min(320px, calc(100vw - 20px));
|
||||||
max-height: min(44vh, 300px);
|
max-height: min(44vh, 300px);
|
||||||
bottom: calc(100% + 6px);
|
bottom: calc(100% + 6px);
|
||||||
}
|
}
|
||||||
|
.ccweb-prompt-outline-panel {
|
||||||
|
left: 0;
|
||||||
|
right: auto;
|
||||||
|
}
|
||||||
.msg-copy-btn {
|
.msg-copy-btn {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
@@ -9030,6 +9035,13 @@ html[data-theme='gilded'] {
|
|||||||
box-shadow: var(--sidebar-hover-shadow);
|
box-shadow: var(--sidebar-hover-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 收起后控制条会贴近左侧轨道,定位浮层需朝聊天画布展开,避免越过应用边界被裁切。 */
|
||||||
|
.app.sidebar-collapsed .user-outline-panel {
|
||||||
|
left: 0;
|
||||||
|
right: auto;
|
||||||
|
z-index: 120;
|
||||||
|
}
|
||||||
|
|
||||||
.app.sidebar-collapsed .sidebar::after {
|
.app.sidebar-collapsed .sidebar::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -538,6 +538,18 @@ function assertFrontendSidebarCollapseContract() {
|
|||||||
assert(source.includes("const SIDEBAR_DRAWER_MEDIA_QUERY = '(max-width: 768px)'"), 'Sidebar drawer mode should match the mobile CSS breakpoint');
|
assert(source.includes("const SIDEBAR_DRAWER_MEDIA_QUERY = '(max-width: 768px)'"), 'Sidebar drawer mode should match the mobile CSS breakpoint');
|
||||||
assert(source.includes("const SIDEBAR_DESKTOP_COLLAPSE_MEDIA_QUERY = '(width > 768px) and (hover: hover) and (pointer: fine)'"), 'Desktop collapse mode should match the hover rail CSS capabilities');
|
assert(source.includes("const SIDEBAR_DESKTOP_COLLAPSE_MEDIA_QUERY = '(width > 768px) and (hover: hover) and (pointer: fine)'"), 'Desktop collapse mode should match the hover rail CSS capabilities');
|
||||||
assert(source.includes("app.classList.toggle('sidebar-collapsed', nextCollapsed)"), 'Desktop sidebar should keep its fixed state on the app root');
|
assert(source.includes("app.classList.toggle('sidebar-collapsed', nextCollapsed)"), 'Desktop sidebar should keep its fixed state on the app root');
|
||||||
|
assert(
|
||||||
|
indexSource.includes('id="user-outline-panel" class="user-outline-panel"')
|
||||||
|
&& indexSource.includes('id="ccweb-prompt-outline-panel" class="user-outline-panel ccweb-prompt-outline-panel"'),
|
||||||
|
'User-message and pending-form locators should share the responsive outline panel layout'
|
||||||
|
);
|
||||||
|
const baseOutlinePanelStyleStart = styleSource.indexOf('.user-outline-panel {');
|
||||||
|
const baseOutlinePanelStyleEnd = styleSource.indexOf('.user-outline-empty', baseOutlinePanelStyleStart);
|
||||||
|
const baseOutlinePanelStyle = styleSource.slice(baseOutlinePanelStyleStart, baseOutlinePanelStyleEnd);
|
||||||
|
assert(
|
||||||
|
/\.user-outline-panel\s*\{[^}]*position:\s*absolute;[^}]*right:\s*0;/.test(baseOutlinePanelStyle),
|
||||||
|
'Fixed-expanded desktop locators should retain their right-aligned base placement'
|
||||||
|
);
|
||||||
assert(source.includes('persistSidebarCollapsedPreference(nextCollapsed)'), 'Desktop sidebar toggle should persist the user preference');
|
assert(source.includes('persistSidebarCollapsedPreference(nextCollapsed)'), 'Desktop sidebar toggle should persist the user preference');
|
||||||
assert(
|
assert(
|
||||||
source.includes("menuBtn.setAttribute('aria-expanded', String(expanded))")
|
source.includes("menuBtn.setAttribute('aria-expanded', String(expanded))")
|
||||||
@@ -555,10 +567,13 @@ function assertFrontendSidebarCollapseContract() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert(styleSource.includes('--sidebar-rail-width: 14px'), 'Collapsed desktop sidebar should retain a narrow hover rail');
|
assert(styleSource.includes('--sidebar-rail-width: 14px'), 'Collapsed desktop sidebar should retain a narrow hover rail');
|
||||||
|
const desktopCollapseStyleStart = styleSource.indexOf('@media (width > 768px) and (hover: hover) and (pointer: fine)');
|
||||||
|
const desktopCollapseStyleEnd = styleSource.indexOf('@media (prefers-reduced-motion: reduce)', desktopCollapseStyleStart);
|
||||||
assert(
|
assert(
|
||||||
styleSource.includes('@media (width > 768px) and (hover: hover) and (pointer: fine)'),
|
desktopCollapseStyleStart >= 0 && desktopCollapseStyleEnd > desktopCollapseStyleStart,
|
||||||
'Desktop hover behavior should stay isolated from touch and narrow-screen drawers'
|
'Desktop hover behavior should stay isolated from touch and narrow-screen drawers'
|
||||||
);
|
);
|
||||||
|
const desktopCollapseStyle = styleSource.slice(desktopCollapseStyleStart, desktopCollapseStyleEnd);
|
||||||
assert(
|
assert(
|
||||||
styleSource.includes('margin-right: calc(var(--sidebar-rail-width) - var(--sidebar-width))')
|
styleSource.includes('margin-right: calc(var(--sidebar-rail-width) - var(--sidebar-width))')
|
||||||
&& styleSource.includes('transform: translateX(calc(var(--sidebar-rail-width) - var(--sidebar-width)))'),
|
&& styleSource.includes('transform: translateX(calc(var(--sidebar-rail-width) - var(--sidebar-width)))'),
|
||||||
@@ -569,6 +584,28 @@ function assertFrontendSidebarCollapseContract() {
|
|||||||
&& styleSource.includes('transform: translateX(0)'),
|
&& styleSource.includes('transform: translateX(0)'),
|
||||||
'Collapsed sidebar should temporarily expand for pointer hover and keyboard focus'
|
'Collapsed sidebar should temporarily expand for pointer hover and keyboard focus'
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
/\.app\.sidebar-collapsed \.user-outline-panel\s*\{[^}]*left:\s*0;[^}]*right:\s*auto;[^}]*z-index:\s*120;/.test(desktopCollapseStyle),
|
||||||
|
'Both collapsed-sidebar locator panels should open toward the chat canvas above the hover preview'
|
||||||
|
);
|
||||||
|
const mobileSidebarStyleStart = styleSource.indexOf('@media (max-width: 768px)');
|
||||||
|
const mobileSidebarStyleEnd = styleSource.indexOf('@media (max-width: 480px)', mobileSidebarStyleStart);
|
||||||
|
const mobileSidebarStyle = styleSource.slice(mobileSidebarStyleStart, mobileSidebarStyleEnd);
|
||||||
|
assert(
|
||||||
|
/\.user-outline-panel\s*\{[^}]*left:\s*0;[^}]*right:\s*auto;[^}]*width:\s*min\(320px,\s*calc\(100vw - 20px\)\);/.test(mobileSidebarStyle),
|
||||||
|
'Closed mobile drawer should keep both locator panels inside the chat canvas'
|
||||||
|
);
|
||||||
|
const compactMobileStyleStart = mobileSidebarStyleEnd;
|
||||||
|
const compactMobileStyleEnd = styleSource.indexOf('/* === Utility === */', compactMobileStyleStart);
|
||||||
|
const compactMobileStyle = styleSource.slice(compactMobileStyleStart, compactMobileStyleEnd);
|
||||||
|
assert(
|
||||||
|
/\.user-outline-panel\s*\{[^}]*left:\s*auto;[^}]*right:\s*0;[^}]*width:\s*min\(320px,\s*calc\(100vw - 20px\)\);/.test(compactMobileStyle),
|
||||||
|
'Compact-mobile user locator should open left from the second grid column'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
/\.ccweb-prompt-outline-panel\s*\{[^}]*left:\s*0;[^}]*right:\s*auto;/.test(compactMobileStyle),
|
||||||
|
'Compact-mobile pending-form locator should open right from the first grid column'
|
||||||
|
);
|
||||||
assert(styleSource.includes('.menu-btn:focus-visible'), 'Sidebar toggle should retain a visible keyboard focus treatment');
|
assert(styleSource.includes('.menu-btn:focus-visible'), 'Sidebar toggle should retain a visible keyboard focus treatment');
|
||||||
assert(
|
assert(
|
||||||
source.includes('if (isSidebarDesktopCollapseMode())')
|
source.includes('if (isSidebarDesktopCollapseMode())')
|
||||||
@@ -581,8 +618,8 @@ function assertFrontendSidebarCollapseContract() {
|
|||||||
'Rich themes should provide isolated rail treatments on top of the shared semantic fallback'
|
'Rich themes should provide isolated rail treatments on top of the shared semantic fallback'
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
indexSource.includes('style.css?v=20260718-wasteland-bg-right-4')
|
indexSource.includes('style.css?v=20260718-shared-locator-panel-2')
|
||||||
&& indexSource.includes('app.js?v=20260718-wasteland-bg-right-4'),
|
&& indexSource.includes('app.js?v=20260718-shared-locator-panel-2'),
|
||||||
'Sidebar interaction assets should share the reviewed cache-busting version'
|
'Sidebar interaction assets should share the reviewed cache-busting version'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -874,8 +911,8 @@ function assertFrontendGildedThemeContract() {
|
|||||||
assert(contrast('#655446', '#fff7ea') >= 4.5, 'Gilded muted text should remain readable on ivory panels');
|
assert(contrast('#655446', '#fff7ea') >= 4.5, 'Gilded muted text should remain readable on ivory panels');
|
||||||
assert(contrast('#fff7ea', '#7a3f20') >= 7, 'Gilded primary action text should reach AAA contrast on copper');
|
assert(contrast('#fff7ea', '#7a3f20') >= 7, 'Gilded primary action text should reach AAA contrast on copper');
|
||||||
assert(themeStyle.includes('@media (prefers-reduced-motion: reduce)'), 'Gilded theme motion should respect reduced-motion preferences');
|
assert(themeStyle.includes('@media (prefers-reduced-motion: reduce)'), 'Gilded theme motion should respect reduced-motion preferences');
|
||||||
assert(indexSource.includes('style.css?v=20260718-wasteland-bg-right-4'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
|
assert(indexSource.includes('style.css?v=20260718-shared-locator-panel-2'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
|
||||||
assert(indexSource.includes('app.js?v=20260718-wasteland-bg-right-4'), 'Theme bundle app script should use the current cache-busted asset URL');
|
assert(indexSource.includes('app.js?v=20260718-shared-locator-panel-2'), 'Theme bundle app script should use the current cache-busted asset URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertFrontendWastelandThemeContract() {
|
function assertFrontendWastelandThemeContract() {
|
||||||
@@ -1128,8 +1165,8 @@ function assertFrontendWastelandThemeContract() {
|
|||||||
assert(contrast('#c9bda6', backgroundColor) >= 4.5, `Wasteland muted text should reach AA contrast on ${backgroundColor}`);
|
assert(contrast('#c9bda6', backgroundColor) >= 4.5, `Wasteland muted text should reach AA contrast on ${backgroundColor}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
assert(indexSource.includes('style.css?v=20260718-wasteland-bg-right-4'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
|
assert(indexSource.includes('style.css?v=20260718-shared-locator-panel-2'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
|
||||||
assert(indexSource.includes('app.js?v=20260718-wasteland-bg-right-4'), 'Wasteland registration should share the cache-busted theme bundle URL');
|
assert(indexSource.includes('app.js?v=20260718-shared-locator-panel-2'), 'Wasteland registration should share the cache-busted theme bundle URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertFrontendCcwebPromptContract() {
|
function assertFrontendCcwebPromptContract() {
|
||||||
@@ -2130,6 +2167,472 @@ function assertSessionSwitchResilienceContract() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertSessionRenderEpochRaceContract() {
|
||||||
|
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||||
|
const beginSessionSwitchSource = extractFunctionSource(frontendSource, 'beginSessionSwitch');
|
||||||
|
const renderMessagesSource = extractFunctionSource(frontendSource, 'renderMessages');
|
||||||
|
const api = new Function(`
|
||||||
|
let renderEpoch = 0;
|
||||||
|
let loadedHistorySessionId = 'session-a';
|
||||||
|
let activeSessionLoad = null;
|
||||||
|
let currentSessionId = 'session-a';
|
||||||
|
let closedCollabAgentIds = new Set();
|
||||||
|
let collabAgentStateCache = new Map();
|
||||||
|
let collabAgentIdsByToolUseId = new Map();
|
||||||
|
let closedCollabAgentIdsByToolUseId = new Map();
|
||||||
|
const scheduled = [];
|
||||||
|
const messagesDiv = {
|
||||||
|
nodes: [],
|
||||||
|
scrollTop: 0,
|
||||||
|
get scrollHeight() { return this.nodes.length; },
|
||||||
|
get firstChild() { return this.nodes[0] || null; },
|
||||||
|
get innerHTML() { return ''; },
|
||||||
|
set innerHTML(value) { this.nodes = []; },
|
||||||
|
appendChild(node) {
|
||||||
|
this.nodes.push(...(node && node.__fragment ? node.nodes : [node]));
|
||||||
|
},
|
||||||
|
insertBefore(node) {
|
||||||
|
this.nodes.unshift(...(node && node.__fragment ? node.nodes : [node]));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const document = {
|
||||||
|
createDocumentFragment() {
|
||||||
|
return {
|
||||||
|
__fragment: true,
|
||||||
|
nodes: [],
|
||||||
|
appendChild(node) { this.nodes.push(node); },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const currentCwd = '/tmp/session-render-race';
|
||||||
|
function setTimeout(callback) { scheduled.push(callback); return scheduled.length; }
|
||||||
|
function setSessionLoading(sessionId) {
|
||||||
|
activeSessionLoad = sessionId ? { sessionId, overlayReleased: false } : null;
|
||||||
|
}
|
||||||
|
function requestSessionLoad() {}
|
||||||
|
function collectClosedCollabAgentIds() { return new Set(); }
|
||||||
|
function clearUserMessageIndex() {}
|
||||||
|
function buildWelcomeMarkup() { return '<p>welcome</p>'; }
|
||||||
|
function updateUserOutlinePanel() {}
|
||||||
|
function renderPendingNotes() {}
|
||||||
|
function scrollToBottom() {}
|
||||||
|
function updateScrollbar() {}
|
||||||
|
function buildMsgElement(message) { return { id: message.id }; }
|
||||||
|
${beginSessionSwitchSource}
|
||||||
|
${renderMessagesSource}
|
||||||
|
return {
|
||||||
|
beginSessionSwitch,
|
||||||
|
renderMessages,
|
||||||
|
flushTimers() {
|
||||||
|
while (scheduled.length > 0) scheduled.shift()();
|
||||||
|
},
|
||||||
|
renderedIds: () => messagesDiv.nodes.map((node) => node.id),
|
||||||
|
};
|
||||||
|
`)();
|
||||||
|
|
||||||
|
const sessionAMessages = Array.from({ length: 11 }, (_, index) => ({ id: `a-${index}` }));
|
||||||
|
api.renderMessages(sessionAMessages);
|
||||||
|
assert(
|
||||||
|
api.renderedIds().length === 10 && !api.renderedIds().includes('a-0'),
|
||||||
|
'Render epoch race fixture should leave the oldest A message in the delayed batch'
|
||||||
|
);
|
||||||
|
api.beginSessionSwitch('session-b', { force: true, blocking: false });
|
||||||
|
api.flushTimers();
|
||||||
|
assert(
|
||||||
|
JSON.stringify(api.renderedIds()) === JSON.stringify(sessionAMessages.map((message) => message.id)),
|
||||||
|
'Beginning a B load must not cancel A delayed batches before the B snapshot is committed'
|
||||||
|
);
|
||||||
|
|
||||||
|
api.renderMessages(sessionAMessages);
|
||||||
|
api.beginSessionSwitch('session-b', { force: true, blocking: false });
|
||||||
|
api.renderMessages([{ id: 'b-0' }]);
|
||||||
|
api.flushTimers();
|
||||||
|
assert(
|
||||||
|
JSON.stringify(api.renderedIds()) === JSON.stringify(['b-0']),
|
||||||
|
'Committing the B render must invalidate A delayed batches so stale messages cannot leak into B'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!/\brenderEpoch\s*(?:\+\+|--|[+\-*/%]?=)/.test(beginSessionSwitchSource),
|
||||||
|
'beginSessionSwitch should not advance renderEpoch before the replacement snapshot renders'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSessionRequestIdRaceContract() {
|
||||||
|
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||||
|
const sessionInfoStart = frontendSource.indexOf("case 'session_info':");
|
||||||
|
const sessionInfoDecisionStart = frontendSource.indexOf('const messageRequestId', sessionInfoStart);
|
||||||
|
const sessionInfoDecisionEnd = frontendSource.indexOf('if (!canSwitchToSessionInfo)', sessionInfoDecisionStart);
|
||||||
|
assert(
|
||||||
|
sessionInfoStart >= 0 && sessionInfoDecisionStart > sessionInfoStart && sessionInfoDecisionEnd > sessionInfoDecisionStart,
|
||||||
|
'Frontend should keep an extractable request gate before applying session_info'
|
||||||
|
);
|
||||||
|
const sessionInfoDecisionSource = frontendSource.slice(sessionInfoDecisionStart, sessionInfoDecisionEnd);
|
||||||
|
const decideSessionInfo = new Function('msg', 'snapshot', 'activeLoad', 'pendingNewSession', 'currentSessionId', `
|
||||||
|
${sessionInfoDecisionSource}
|
||||||
|
return { canSwitchToSessionInfo, matchesActiveLoad };
|
||||||
|
`);
|
||||||
|
const staleCurrentDecision = decideSessionInfo(
|
||||||
|
{
|
||||||
|
type: 'session_info',
|
||||||
|
sessionId: 'session-a',
|
||||||
|
requestId: 'request-old-a',
|
||||||
|
messages: [{ id: 'stale-a' }],
|
||||||
|
historyPending: false,
|
||||||
|
isRunning: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sessionId: 'session-a',
|
||||||
|
agent: 'codexapp',
|
||||||
|
messages: [{ id: 'stale-a' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sessionId: 'session-b',
|
||||||
|
requestId: 'request-new-b',
|
||||||
|
snapshot: null,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
'session-a'
|
||||||
|
);
|
||||||
|
const rejectsStaleCurrentSessionInfo = staleCurrentDecision.canSwitchToSessionInfo === false;
|
||||||
|
|
||||||
|
const finalizeLoadedSessionSource = extractFunctionSource(frontendSource, 'finalizeLoadedSession');
|
||||||
|
const finalizeApi = new Function(`
|
||||||
|
let activeSessionLoad = {
|
||||||
|
sessionId: 'session-b',
|
||||||
|
requestId: 'request-new-b',
|
||||||
|
snapshot: { messages: [{ id: 'b-0' }] },
|
||||||
|
};
|
||||||
|
const cached = [];
|
||||||
|
const finished = [];
|
||||||
|
function cacheSessionSnapshot(snapshot) { cached.push(snapshot); }
|
||||||
|
function finishSessionSwitch(sessionId) { finished.push(sessionId); }
|
||||||
|
${finalizeLoadedSessionSource}
|
||||||
|
return {
|
||||||
|
finalizeLoadedSession,
|
||||||
|
cached,
|
||||||
|
finished,
|
||||||
|
reset() { cached.length = 0; finished.length = 0; },
|
||||||
|
};
|
||||||
|
`)();
|
||||||
|
finalizeApi.finalizeLoadedSession('session-b', 'request-old-b');
|
||||||
|
const rejectsStaleFinalize = finalizeApi.cached.length === 0 && finalizeApi.finished.length === 0;
|
||||||
|
finalizeApi.reset();
|
||||||
|
finalizeApi.finalizeLoadedSession('session-b', 'request-new-b');
|
||||||
|
const acceptsMatchingFinalize = finalizeApi.cached.length === 1 && finalizeApi.finished.length === 1;
|
||||||
|
|
||||||
|
const historyStart = frontendSource.indexOf("case 'session_history_chunk':");
|
||||||
|
const historyEnd = frontendSource.indexOf("case 'session_message':", historyStart);
|
||||||
|
assert(historyStart >= 0 && historyEnd > historyStart, 'Frontend should keep an extractable session_history_chunk handler');
|
||||||
|
const historyCaseSource = frontendSource.slice(historyStart, historyEnd);
|
||||||
|
const historyApi = new Function(`
|
||||||
|
let activeSessionLoad = {
|
||||||
|
sessionId: 'session-b',
|
||||||
|
requestId: 'request-new-b',
|
||||||
|
recoverCurrent: false,
|
||||||
|
snapshot: { messages: [{ id: 'b-recent' }] },
|
||||||
|
};
|
||||||
|
let currentSessionId = 'session-b';
|
||||||
|
let loadedHistorySessionId = 'session-b';
|
||||||
|
const prepended = [];
|
||||||
|
const finalized = [];
|
||||||
|
function cloneMessages(messages) { return messages.slice(); }
|
||||||
|
function isBlockingSessionLoad() { return false; }
|
||||||
|
function prependHistoryMessages(messages) { prepended.push(...messages); }
|
||||||
|
function finalizeLoadedSession(sessionId, requestId) { finalized.push({ sessionId, requestId }); }
|
||||||
|
function handleHistoryMessage(msg) {
|
||||||
|
switch (msg.type) {
|
||||||
|
${historyCaseSource}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
handleHistoryMessage,
|
||||||
|
prepended,
|
||||||
|
finalized,
|
||||||
|
reset() { prepended.length = 0; finalized.length = 0; },
|
||||||
|
};
|
||||||
|
`)();
|
||||||
|
historyApi.handleHistoryMessage({
|
||||||
|
type: 'session_history_chunk',
|
||||||
|
sessionId: 'session-b',
|
||||||
|
requestId: 'request-old-b',
|
||||||
|
messages: [{ id: 'stale-history' }],
|
||||||
|
remaining: 0,
|
||||||
|
historyBaseIndex: 0,
|
||||||
|
});
|
||||||
|
const rejectsStaleHistoryChunk = historyApi.prepended.length === 0 && historyApi.finalized.length === 0;
|
||||||
|
historyApi.reset();
|
||||||
|
historyApi.handleHistoryMessage({
|
||||||
|
type: 'session_history_chunk',
|
||||||
|
sessionId: 'session-b',
|
||||||
|
requestId: 'request-new-b',
|
||||||
|
messages: [{ id: 'matching-history' }],
|
||||||
|
remaining: 0,
|
||||||
|
historyBaseIndex: 0,
|
||||||
|
});
|
||||||
|
const acceptsMatchingHistoryChunk = historyApi.prepended.some((message) => message.id === 'matching-history')
|
||||||
|
&& historyApi.finalized.some((entry) => entry.requestId === 'request-new-b');
|
||||||
|
|
||||||
|
const failures = [];
|
||||||
|
if (!rejectsStaleCurrentSessionInfo) {
|
||||||
|
failures.push('A stale requestId-bearing session_info for the current A view must not overwrite the pending B load');
|
||||||
|
}
|
||||||
|
if (!rejectsStaleFinalize) {
|
||||||
|
failures.push('finalizeLoadedSession must reject a stale requestId even when the sessionId still matches');
|
||||||
|
}
|
||||||
|
if (!acceptsMatchingFinalize) {
|
||||||
|
failures.push('finalizeLoadedSession should still commit the matching active load');
|
||||||
|
}
|
||||||
|
if (!rejectsStaleHistoryChunk) {
|
||||||
|
failures.push('session_history_chunk must reject stale requestIds before prepending or finalizing history');
|
||||||
|
}
|
||||||
|
if (!acceptsMatchingHistoryChunk) {
|
||||||
|
failures.push('session_history_chunk should preserve the matching active request path');
|
||||||
|
}
|
||||||
|
assert(failures.length === 0, failures.join('; '));
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertBlockingFinishRafRequestRaceContract() {
|
||||||
|
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||||
|
const isBlockingSessionLoadSource = extractFunctionSource(frontendSource, 'isBlockingSessionLoad');
|
||||||
|
const clearSessionLoadingSource = extractFunctionSource(frontendSource, 'clearSessionLoading');
|
||||||
|
const finishSessionSwitchSource = extractFunctionSource(frontendSource, 'finishSessionSwitch');
|
||||||
|
const api = new Function(`
|
||||||
|
let activeSessionLoad = {
|
||||||
|
sessionId: 'session-a',
|
||||||
|
requestId: 'request-a1',
|
||||||
|
blocking: true,
|
||||||
|
};
|
||||||
|
const rafCallbacks = [];
|
||||||
|
let scrollCount = 0;
|
||||||
|
function setSessionLoading(sessionId) {
|
||||||
|
activeSessionLoad = sessionId ? { sessionId, requestId: 'unexpected', blocking: true } : null;
|
||||||
|
}
|
||||||
|
function scrollToBottom() { scrollCount += 1; }
|
||||||
|
function requestAnimationFrame(callback) { rafCallbacks.push(callback); return rafCallbacks.length; }
|
||||||
|
${isBlockingSessionLoadSource}
|
||||||
|
${clearSessionLoadingSource}
|
||||||
|
${finishSessionSwitchSource}
|
||||||
|
return {
|
||||||
|
finishA1() { finishSessionSwitch('session-a', 'request-a1'); },
|
||||||
|
replaceWithA2() {
|
||||||
|
activeSessionLoad = {
|
||||||
|
sessionId: 'session-a',
|
||||||
|
requestId: 'request-a2',
|
||||||
|
blocking: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
flushRaf() {
|
||||||
|
while (rafCallbacks.length > 0) rafCallbacks.shift()();
|
||||||
|
},
|
||||||
|
activeRequestId: () => activeSessionLoad?.requestId || null,
|
||||||
|
queuedRafCount: () => rafCallbacks.length,
|
||||||
|
scrollCount: () => scrollCount,
|
||||||
|
};
|
||||||
|
`)();
|
||||||
|
|
||||||
|
api.finishA1();
|
||||||
|
assert(
|
||||||
|
api.queuedRafCount() === 1 && api.scrollCount() === 1 && api.activeRequestId() === 'request-a1',
|
||||||
|
'Blocking A1 completion should defer clearing through the real finishSessionSwitch RAF path'
|
||||||
|
);
|
||||||
|
api.replaceWithA2();
|
||||||
|
api.flushRaf();
|
||||||
|
assert(
|
||||||
|
api.activeRequestId() === 'request-a2',
|
||||||
|
'The deferred A1 RAF callback must not clear a newer A2 load for the same sessionId'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertRecoverCurrentHistoryMergeContract() {
|
||||||
|
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||||
|
const finalizeLoadedSessionSource = extractFunctionSource(frontendSource, 'finalizeLoadedSession');
|
||||||
|
const historyStart = frontendSource.indexOf("case 'session_history_chunk':");
|
||||||
|
const historyEnd = frontendSource.indexOf("case 'session_message':", historyStart);
|
||||||
|
assert(historyStart >= 0 && historyEnd > historyStart, 'Frontend should keep an extractable recovery history handler');
|
||||||
|
const historyCaseSource = frontendSource.slice(historyStart, historyEnd);
|
||||||
|
const api = new Function(`
|
||||||
|
let activeSessionLoad = {
|
||||||
|
sessionId: 'session-recover',
|
||||||
|
requestId: 'request-recover-new',
|
||||||
|
recoverCurrent: true,
|
||||||
|
blocking: false,
|
||||||
|
snapshot: { messages: [{ id: 'recent' }] },
|
||||||
|
};
|
||||||
|
let currentSessionId = 'session-recover';
|
||||||
|
let loadedHistorySessionId = 'session-recover';
|
||||||
|
const prepended = [];
|
||||||
|
const cached = [];
|
||||||
|
const finished = [];
|
||||||
|
function cloneMessages(messages) { return messages.map((message) => ({ ...message })); }
|
||||||
|
function isBlockingSessionLoad() { return false; }
|
||||||
|
function prependHistoryMessages(messages) { prepended.push(...messages); }
|
||||||
|
function cacheSessionSnapshot(snapshot) { cached.push(JSON.parse(JSON.stringify(snapshot))); }
|
||||||
|
function finishSessionSwitch(sessionId, requestId) { finished.push({ sessionId, requestId }); }
|
||||||
|
${finalizeLoadedSessionSource}
|
||||||
|
function handleHistoryMessage(msg) {
|
||||||
|
switch (msg.type) {
|
||||||
|
${historyCaseSource}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
handleHistoryMessage,
|
||||||
|
snapshotMessageIds: () => activeSessionLoad.snapshot.messages.map((message) => message.id),
|
||||||
|
prepended,
|
||||||
|
cached,
|
||||||
|
finished,
|
||||||
|
};
|
||||||
|
`)();
|
||||||
|
|
||||||
|
api.handleHistoryMessage({
|
||||||
|
type: 'session_history_chunk',
|
||||||
|
sessionId: 'session-recover',
|
||||||
|
requestId: 'request-recover-stale',
|
||||||
|
messages: [{ id: 'stale-history' }],
|
||||||
|
remaining: 0,
|
||||||
|
historyBaseIndex: 0,
|
||||||
|
});
|
||||||
|
const rejectsStaleChunk = JSON.stringify(api.snapshotMessageIds()) === JSON.stringify(['recent'])
|
||||||
|
&& api.prepended.length === 0
|
||||||
|
&& api.cached.length === 0
|
||||||
|
&& api.finished.length === 0;
|
||||||
|
|
||||||
|
api.handleHistoryMessage({
|
||||||
|
type: 'session_history_chunk',
|
||||||
|
sessionId: 'session-recover',
|
||||||
|
requestId: 'request-recover-new',
|
||||||
|
messages: [{ id: 'history-later' }],
|
||||||
|
remaining: 1,
|
||||||
|
historyBaseIndex: 1,
|
||||||
|
});
|
||||||
|
const mergesIntermediateChunk = JSON.stringify(api.snapshotMessageIds())
|
||||||
|
=== JSON.stringify(['history-later', 'recent']);
|
||||||
|
const keepsRecoveryOffDom = api.prepended.length === 0;
|
||||||
|
const defersRecoveryFinalize = api.cached.length === 0 && api.finished.length === 0;
|
||||||
|
|
||||||
|
api.handleHistoryMessage({
|
||||||
|
type: 'session_history_chunk',
|
||||||
|
sessionId: 'session-recover',
|
||||||
|
requestId: 'request-recover-new',
|
||||||
|
messages: [{ id: 'history-oldest' }],
|
||||||
|
remaining: 0,
|
||||||
|
historyBaseIndex: 0,
|
||||||
|
});
|
||||||
|
const cachedSnapshot = api.cached[0] || null;
|
||||||
|
const cachesCompleteHistory = api.cached.length === 1
|
||||||
|
&& cachedSnapshot.complete === true
|
||||||
|
&& JSON.stringify((cachedSnapshot.messages || []).map((message) => message.id))
|
||||||
|
=== JSON.stringify(['history-oldest', 'history-later', 'recent']);
|
||||||
|
const finalizesMatchingRecovery = api.finished.length === 1
|
||||||
|
&& api.finished[0].requestId === 'request-recover-new';
|
||||||
|
const neverPrependsRecoveryHistory = api.prepended.length === 0;
|
||||||
|
|
||||||
|
const failures = [];
|
||||||
|
if (!rejectsStaleChunk) failures.push('recoverCurrent must still reject stale requestId history chunks');
|
||||||
|
if (!mergesIntermediateChunk) failures.push('recoverCurrent must merge each accepted history chunk into the active snapshot');
|
||||||
|
if (!keepsRecoveryOffDom || !neverPrependsRecoveryHistory) failures.push('recoverCurrent history chunks must not prepend the live DOM');
|
||||||
|
if (!defersRecoveryFinalize) failures.push('recoverCurrent must wait for the last history chunk before caching');
|
||||||
|
if (!cachesCompleteHistory) failures.push('recoverCurrent finalization must cache recent and historical messages as one complete snapshot');
|
||||||
|
if (!finalizesMatchingRecovery) failures.push('recoverCurrent finalization must retain the matching requestId');
|
||||||
|
assert(failures.length === 0, failures.join('; '));
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertServerSessionHistoryRequestIdContract() {
|
||||||
|
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||||||
|
const handleLoadSessionSource = extractFunctionSource(serverSource, 'handleLoadSession');
|
||||||
|
const api = new Function(`
|
||||||
|
const sent = [];
|
||||||
|
const fixture = {
|
||||||
|
id: 'session-b',
|
||||||
|
title: 'Session B',
|
||||||
|
pinnedAt: null,
|
||||||
|
permissionMode: 'yolo',
|
||||||
|
model: 'gpt-5.5',
|
||||||
|
agent: 'codexapp',
|
||||||
|
hasUnread: false,
|
||||||
|
cwd: '/tmp/session-b',
|
||||||
|
totalCost: 0,
|
||||||
|
totalUsage: null,
|
||||||
|
updated: '2026-07-18T00:00:00.000Z',
|
||||||
|
messages: Array.from({ length: 11 }, (_, index) => ({ id: 'b-' + index })),
|
||||||
|
};
|
||||||
|
const activeProcesses = new Map();
|
||||||
|
const activeCodexAppTurns = new Map();
|
||||||
|
const wsSessionMap = new Map();
|
||||||
|
function sanitizeId(value) { return String(value || ''); }
|
||||||
|
function reconcilePendingCrossConversationReplies() {}
|
||||||
|
function loadSession() { return fixture; }
|
||||||
|
function wsSend(ws, message) { sent.push(message); }
|
||||||
|
function attachClientRequestId(message, source) {
|
||||||
|
return source && source.requestId ? { ...message, requestId: source.requestId } : message;
|
||||||
|
}
|
||||||
|
function flushPendingCrossConversationReplies() {}
|
||||||
|
function getSessionAgent(session) { return session.agent; }
|
||||||
|
function splitHistoryMessages(messages) {
|
||||||
|
return {
|
||||||
|
recentMessages: messages.slice(1),
|
||||||
|
olderChunks: [messages.slice(0, 1)],
|
||||||
|
historyRemaining: 0,
|
||||||
|
historyBuffered: messages.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function crossConversationWaitState() {
|
||||||
|
return {
|
||||||
|
waitingOnChildren: false,
|
||||||
|
pendingReplyCount: 0,
|
||||||
|
readyReplyCount: 0,
|
||||||
|
waitingReplyCount: 0,
|
||||||
|
failedReplyCount: 0,
|
||||||
|
pendingReplies: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function detachWsFromActiveRuntimes() {}
|
||||||
|
function saveSession() {}
|
||||||
|
function publicTitleMetadata() { return {}; }
|
||||||
|
function sessionModelLabel(session) { return session.model; }
|
||||||
|
function isSessionRunning() { return false; }
|
||||||
|
function attachActiveRuntimeToWs() {}
|
||||||
|
function resolveClaudeSessionLocalMeta() { return null; }
|
||||||
|
${handleLoadSessionSource}
|
||||||
|
return {
|
||||||
|
load(requestId) {
|
||||||
|
sent.length = 0;
|
||||||
|
handleLoadSession({}, { type: 'load_session', sessionId: fixture.id, requestId });
|
||||||
|
return sent.slice();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
`)();
|
||||||
|
const messages = api.load('request-new-b');
|
||||||
|
const sessionInfo = messages.find((message) => message.type === 'session_info');
|
||||||
|
const historyChunks = messages.filter((message) => message.type === 'session_history_chunk');
|
||||||
|
assert(sessionInfo?.requestId === 'request-new-b', 'Server session_info fixture should echo the load requestId');
|
||||||
|
assert(historyChunks.length > 0, 'Server load fixture should emit at least one delayed history chunk');
|
||||||
|
assert(
|
||||||
|
historyChunks.every((message) => message.requestId === sessionInfo.requestId),
|
||||||
|
'Every server history chunk must echo the exact requestId carried by its session_info snapshot'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSessionSwitchRaceContract() {
|
||||||
|
const checks = [
|
||||||
|
['render epoch behavior', assertSessionRenderEpochRaceContract],
|
||||||
|
['frontend requestId behavior', assertSessionRequestIdRaceContract],
|
||||||
|
['blocking finish RAF request behavior', assertBlockingFinishRafRequestRaceContract],
|
||||||
|
['recoverCurrent history merge behavior', assertRecoverCurrentHistoryMergeContract],
|
||||||
|
['server history requestId behavior', assertServerSessionHistoryRequestIdContract],
|
||||||
|
];
|
||||||
|
const failures = [];
|
||||||
|
for (const [label, check] of checks) {
|
||||||
|
try {
|
||||||
|
check();
|
||||||
|
} catch (err) {
|
||||||
|
failures.push(`${label}: ${err?.message || err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failures.length > 0) {
|
||||||
|
throw new Error(`Session switch race regression failed:\n- ${failures.join('\n- ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function assertCodexAppStaleRunningRecoveryContract() {
|
function assertCodexAppStaleRunningRecoveryContract() {
|
||||||
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||||||
const completeBlock = extractFunctionSource(serverSource, 'handleCodexAppTurnComplete');
|
const completeBlock = extractFunctionSource(serverSource, 'handleCodexAppTurnComplete');
|
||||||
@@ -2820,6 +3323,11 @@ async function main() {
|
|||||||
console.log('Codex App stale running regression checks passed.');
|
console.log('Codex App stale running regression checks passed.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (regressionTarget === 'session-switch-race') {
|
||||||
|
assertSessionSwitchRaceContract();
|
||||||
|
console.log('Session switch race regression checks passed.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
throw new Error(`Unknown regression target: ${regressionTarget}`);
|
throw new Error(`Unknown regression target: ${regressionTarget}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2839,6 +3347,7 @@ async function main() {
|
|||||||
assertFrontendPrimaryCodexAppUiContract();
|
assertFrontendPrimaryCodexAppUiContract();
|
||||||
assertSetTitleMcpContract();
|
assertSetTitleMcpContract();
|
||||||
assertSessionSwitchResilienceContract();
|
assertSessionSwitchResilienceContract();
|
||||||
|
assertSessionSwitchRaceContract();
|
||||||
assertCodexAppChildToolFallbackContract();
|
assertCodexAppChildToolFallbackContract();
|
||||||
|
|
||||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-'));
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-'));
|
||||||
|
|||||||
@@ -7710,7 +7710,7 @@ function handleLoadSession(ws, msg) {
|
|||||||
let chunkEnd = Math.max(0, refreshedSession.messages.length - recentMessages.length);
|
let chunkEnd = Math.max(0, refreshedSession.messages.length - recentMessages.length);
|
||||||
olderChunks.forEach((chunk, index) => {
|
olderChunks.forEach((chunk, index) => {
|
||||||
const chunkStart = Math.max(0, chunkEnd - chunk.length);
|
const chunkStart = Math.max(0, chunkEnd - chunk.length);
|
||||||
wsSend(ws, {
|
wsSend(ws, attachClientRequestId({
|
||||||
type: 'session_history_chunk',
|
type: 'session_history_chunk',
|
||||||
sessionId: refreshedSession.id,
|
sessionId: refreshedSession.id,
|
||||||
messages: chunk,
|
messages: chunk,
|
||||||
@@ -7718,7 +7718,7 @@ function handleLoadSession(ws, msg) {
|
|||||||
historyCursor: index === olderChunks.length - 1 ? historyRemaining : null,
|
historyCursor: index === olderChunks.length - 1 ? historyRemaining : null,
|
||||||
historyBaseIndex: chunkStart,
|
historyBaseIndex: chunkStart,
|
||||||
historyTruncated: historyRemaining > 0,
|
historyTruncated: historyRemaining > 0,
|
||||||
});
|
}, msg));
|
||||||
chunkEnd = chunkStart;
|
chunkEnd = chunkStart;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user