diff --git a/apps/desktop/src/renderer/components/chat/MessageStream.tsx b/apps/desktop/src/renderer/components/chat/MessageStream.tsx index 994e157eee1..ab5808fbe28 100644 --- a/apps/desktop/src/renderer/components/chat/MessageStream.tsx +++ b/apps/desktop/src/renderer/components/chat/MessageStream.tsx @@ -1579,11 +1579,18 @@ function renderItemEndMs(item: RenderItem): number | null { return startMs === null ? item.resultTsMs : Math.max(startMs, item.resultTsMs); } if (item.type === 'work_group') { - for (let i = item.children.length - 1; i >= 0; i--) { - const childMs = renderItemEndMs(item.children[i]); - if (childMs !== null) return childMs; + // 全量取 max,不是"最后一个 child":children 按**发起**时刻排列,并行的 Agent/Task 乱序完成时 + // 真正的结束时刻可能落在更靠前的 child 上(先发起、更晚 settle)。取最后一个会低估组的结束 + // 时间,于是空洞判定的锚点变小、把本来连续的 turn 误判成空洞切开 —— 与本函数 tool_segment + // 分支、以及 groupWorkRuns 里 prevEndMs 的 Math.max 是同一条理由(#676 review codex P1)。 + // 手机端同款函数(maker-shared 的 itemEndTimestamp)已按此收敛,#1210 review 指出这里镜像存在。 + let latest: number | null = null; + for (const child of item.children) { + const childMs = renderItemEndMs(child); + if (childMs === null) continue; + latest = latest === null ? childMs : Math.max(latest, childMs); } - return null; + return latest; } // thinking 的 createdAt 是块**开始**的时刻,真正结束要加 thinkingDurationMs // (与 workRunEndTs 同口径)。一个想了半小时以上的 thinking 块后面紧跟工具或正文时, diff --git a/apps/desktop/src/renderer/lib/historyGap.ts b/apps/desktop/src/renderer/lib/historyGap.ts index c0eaa468e96..b8ef743debe 100644 --- a/apps/desktop/src/renderer/lib/historyGap.ts +++ b/apps/desktop/src/renderer/lib/historyGap.ts @@ -1,20 +1,12 @@ /** - * 历史窗口空洞的判定阈值 —— 单一来源。 + * 历史窗口空洞的判定阈值 —— 桌面侧入口,正本在 `@cindy/maker-shared/history-gap`。 * - * 唯一消费方:`components/chat/MessageStream` —— tool_segment 按它切段、工作组按它切组。 - * - * 为什么单独放在 lib 而不是埋在 MessageStream 里:它是一条产品级阈值(多久算"历史不 - * 连续"),独立成文件便于查找与调整,也留出被 main / 其它 renderer 模块复用的位置而不必 - * 反向依赖 component(见 docs/dev-rules/architecture-invariants.md 的依赖方向)。 + * 桌面消费方:`components/chat/MessageStream` —— tool_segment 按它切段、工作组按它切组。 * makerChatStore 一度按它模拟切段来估算跳转补齐预算,现已改为按行数取保守上界 * (见 JUMP_BACKFILL_MAX_ITEMS),不再依赖本常量。 * - * 为什么是 30 分钟:跳转到历史消息时,目标附近的窗口与已加载的尾部窗口之间可能隔着 - * 大段没加载的历史(补齐失败时)。渲染层看到的是两段"相邻"item,中间的 user 行(唯一的 - * turn 边界)全部缺席,于是跨越空洞的所有动作被折成同一个「已工作 Xs」:实测出现过一条 - * 组吞掉 47 小时、40 条 user 消息的会话,组时长也跟着谎报成 2820m。 - * - * 单个 turn 内相邻动作(工具调用 / thinking)正常在秒级到分钟级,等长任务最多几十分钟; - * 真被误切也只是多出一个折叠条,代价远小于把不相干的两段并成一条并谎报时长。 + * 为什么保留这层 re-export 而不让 MessageStream 直接引 shared:阈值原本是桌面常量, + * 手机端接入后成为两端共用的产品级阈值(见正本文件头的完整理由)。留住这个路径让桌面侧 + * 既有引用与文档指向不必跟着改,同时保证两端逐字节同一把尺子。 */ -export const HISTORY_GAP_SPLIT_MS = 30 * 60 * 1000; +export { HISTORY_GAP_SPLIT_MS } from '@cindy/maker-shared/history-gap'; diff --git a/apps/mobile/app/sessions/[sessionId].tsx b/apps/mobile/app/sessions/[sessionId].tsx index 598956d520f..62e8d0d2141 100644 --- a/apps/mobile/app/sessions/[sessionId].tsx +++ b/apps/mobile/app/sessions/[sessionId].tsx @@ -370,6 +370,14 @@ import { shouldRefreshLatestMessageWindowOnReopen, shouldKeepOlderMessagesAffordance, } from '@/session/messagePaging'; +import { + HISTORY_BACKFILL_MAX_GAPS_PER_VISIT, + HISTORY_GAP_MAX_CONSIDERED_PER_VISIT, + HISTORY_GAP_PROBE_LIMIT, + backfillHistoryWindowGap, + findHistoryWindowGap, + historyWindowGapKey, +} from '@/session/historyWindowGap'; import { buildMobileMessageRenderItems, insertMobileForkOriginItem, @@ -2888,13 +2896,17 @@ export default function SessionScreen() { activeSessionSnapshot.activityEpochAtFetchStart, ); const historyPage: RemoteMessage[] = Array.isArray(history.messages) ? history.messages : []; + // moreBeyondWindow:本页上沿之外服务端还有历史(满页 / 被裁行)。为真时 store 不保留早于 + // 本页的缓存段 —— 它与本页之间可能隔着从未加载的行,保留就是孤岛(#1222)。判据与 + // 「加载更早」入口同源,两者本就该一致。 + const moreBeyondWindow = shouldKeepOlderMessagesAffordance(history); if (options.replaceMessages) { remoteSessionStore.setMessages(sessionId, historyPage); } else { - remoteSessionStore.setLatestMessageWindow(sessionId, historyPage); + remoteSessionStore.setLatestMessageWindow(sessionId, historyPage, { moreBeyondWindow }); } remoteSessionStore.markSessionMessagesSynced(sessionId, sessionMeta); - setHasOlderMessages(shouldKeepOlderMessagesAffordance(history)); + setHasOlderMessages(moreBeyondWindow); remoteSessionStore.setPendingInteractions(sessionId, Array.isArray(pendingInteractions) ? pendingInteractions : []); remoteSessionStore.setInputProjection(sessionId, projection); } else { @@ -2936,9 +2948,11 @@ export default function SessionScreen() { ); if (syncRun.isStale()) return; const historyPage: RemoteMessage[] = Array.isArray(history.messages) ? history.messages : []; - remoteSessionStore.setLatestMessageWindow(sessionId, historyPage); + // 同首开路径:上沿之外还有历史时不保留更早的缓存段(#1222)。 + const moreBeyondWindow = shouldKeepOlderMessagesAffordance(history); + remoteSessionStore.setLatestMessageWindow(sessionId, historyPage, { moreBeyondWindow }); remoteSessionStore.markSessionMessagesSynced(sessionId, sessionMeta); - setHasOlderMessages(shouldKeepOlderMessagesAffordance(history)); + setHasOlderMessages(moreBeyondWindow); } else { // 回归修复:没新内容也要补设 hasOlderMessages —— 屏幕重开把该 state 重置为 false,跳过整窗 // 重拉时若不补设,「加载更早」入口会消失、往上拖刷不出老消息。用服务端总数 vs in-store 已加载 @@ -3783,6 +3797,30 @@ export default function SessionScreen() { remoteMediaQueueRef.current = createRemoteMediaQueue(); }, [sessionId, createRemoteMediaQueue, deleteRemoteMediaObject]); + /** 单调递增的补齐轮次计数器;`latest` 是本屏最新那一轮的序号(旧轮据此自我作废)。 */ + const backfillRunSeqRef = useRef(0); + const backfillLatestRunSeqRef = useRef(0); + /** + * 作废在飞的那一轮补齐:占掉一个序号但不启动任何轮,于是它在下一次 isCancelled 上收手。 + * + * 走单调序号而不是在 isCancelled 里比"当前会话 id / loadingEarlier 是否变了":那类判据会随 + * 状态摆回而把取消撤销掉(#1210 review 的 P1);序号只增不减,作废是终态。 + */ + const abandonInFlightBackfill = useCallback(() => { + backfillRunSeqRef.current += 1; + backfillLatestRunSeqRef.current = backfillRunSeqRef.current; + }, []); + // 会话切走、或连接代变化(重连)时作废:用户已经离开的会话不值得继续花翻页请求;换连接后在飞的 + // 请求走的是旧连接,让它早点收手比等它超时干净。 + // + // 手动「加载更早」的作废**不在这里**:effect 是被动的,而 loadEarlierMessages 在 + // setLoadingEarlier(true) 之后同步就发请求 —— 若自动补齐在这个 effect 跑之前返回,它仍会通过 + // isCancelled、merge 并继续下一页,两条分页流程短暂并发(#1210 review)。所以那条路在手动入口的 + // **同步路径**里直接调 abandonInFlightBackfill。 + useEffect(() => { + abandonInFlightBackfill(); + }, [abandonInFlightBackfill, sessionId, connectionEpoch]); + const loadEarlierMessages = useCallback(async () => { if (!deviceId || !sessionId || loadingEarlier || !hasOlderMessages) return; const before = oldestMessageCursor(messages); @@ -3790,6 +3828,10 @@ export default function SessionScreen() { setHasOlderMessages(false); return; } + // 同步作废在飞的自动补齐:两者都按 before 游标翻页,并发只会重复拉取、反复 merge。必须在 + // 发请求**之前**同步做掉,不能只靠依赖 loadingEarlier 的 effect —— 那是被动的,自动补齐可能 + // 在它执行前就返回并继续下一页(#1210 review)。 + abandonInFlightBackfill(); setLoadingEarlier(true); setError(null); try { @@ -3797,14 +3839,168 @@ export default function SessionScreen() { listMessagesWithPayloadRetry((limit) => maker.listMessages(sessionId, { limit, before })), ); const pageList = Array.isArray(page.messages) ? page.messages : []; - remoteSessionStore.mergeMessages(sessionId, pageList); + // 用 mergeEarlierMessages 而不是 mergeMessages:这一页是沿 before 从窗口最旧端**连续**取的, + // 登记进「已验证连续」区间后,后续满页的最新窗口同步才不会把用户一路翻出来的历史当成来源 + // 不明的缓存丢掉(#1210 review)。 + remoteSessionStore.mergeEarlierMessages(sessionId, pageList); setHasOlderMessages(shouldKeepOlderMessagesAffordance(page)); } catch (err) { setError(formatRemoteError(err)); } finally { setLoadingEarlier(false); } - }, [deviceId, hasOlderMessages, loadingEarlier, maker, messages, sessionId]); + }, [abandonInFlightBackfill, deviceId, hasOlderMessages, loadingEarlier, maker, messages, sessionId]); + + /** + * 历史窗口空洞的自动补齐(见 `historyWindowGap.ts` 的文件头)。 + * + * 缓存旧页 + 最新页拼接、断连期间漏收 push,都会让窗口出现"首段 + 尾段"的孤岛,中间几百行 + * 从未加载 —— 手机上看起来就是"中间掉了一大段"。这里在窗口就位后检测最靠尾部的一处跳变, + * 先花一次 `limit=1` 探测确认服务端两行是否本来就相邻(正常的隔夜会话不该白翻页),确认有洞 + * 才沿 `before` 游标补齐。 + * + * 后台跑、不阻塞首屏,也不写 `error` / `loadingEarlier`:补齐是静默自愈,失败时渲染层的 + * `HISTORY_GAP_SPLIT_MS` 守卫兜底(不谎报时长),用户仍可用「加载更早」自己往上翻。 + */ + /** + * 本次访问考察过的空洞,按**结局**分三类 —— 它们的"遗忘条件"和"是否消耗额度"都不同,合成 + * 一个集合会同时踩两个坑(#1210 review): + * + * - `contiguous`:探测确认服务端两行本来就相邻(隔夜等正常停顿)。这是**事实**,与连接状态 + * 无关,所以本次访问内永久跳过;但它一个请求的翻页都没花,**不占额度** —— 否则窗口里 + * 只要有三处正常停顿,更早处的真实缺行就永远排不到探测。 + * - `backfilled`:真的翻过页(covered / budget / exhausted)。跳过 + **占额度**,额度限制的 + * 正是"一次访问最多翻多少段历史"。 + * - `failed`:请求异常(断线等)。跳过是为了防抖(messages 每变一次就重试会打成请求风暴), + * 但**绑在连接代上**:`connectionEpoch` 变化即清空,重连后同一处可以再试。不占额度。 + * `cancelled` 也归这里 —— 会话切走 / 锚点行被 /clear、rewind 拿掉,都属于"这次没做成"。 + * + * 检测的跳过表是三者的并集;额度只看 `backfilled`。 + */ + const backfillGapStateRef = useRef<{ + sid: string; + epoch: number; + contiguous: Set; + backfilled: Set; + failed: Set; + } | null>(null); + /** + * 飞行中的那一轮补齐:会话 id + **单调递增的运行序号**。 + * + * 为什么必须有 seq、且一切判据都对着它比:所有"当前状态是否仍等于启动时状态"的判据都不可靠, + * 因为会话 id 会**摆回来** —— A 的补齐在飞时切到 B 再快速切回 A,`sessionId === 'A'` 会重新 + * 成立,于是旧那一轮的取消被撤销、effect 又放行一轮新的 A,同一会话并发翻页;旧轮收尾时还会 + * 按 sid 把新轮的飞行标记误清,继续放行更多轮(#1210 review 的 P1)。seq 只增不减,"我还是不是 + * 本会话最新那一轮"是单调判据,撤销不了。 + * + * 用 state 而不是 ref 的理由不变:ref 在 `finally` 里改写不触发重渲染,那样本次访问里就不会 + * 再检测下一处空洞,要等新消息或重开会话。互斥仍只按 `sid` 判 —— 别的会话残留的那一轮不连坐 + * 当前会话(它自己会在下一次 isCancelled 上收手)。 + */ + const [backfillInFlightRun, setBackfillInFlightRun] = useState<{ sid: string; seq: number } | null>(null); + useEffect(() => { + if (!deviceId || !sessionId) return; + // 同步门槛必须按 **session + 连接代** 判定,不能用 lastSyncedAt:屏实例复用、原地从会话 A + // 切到有缓存消息的 B 时,lastSyncedAt 仍是 A 留下的非空值,补齐会在 B 的 listMessages 对账 + // 完成前就基于旧缓存快照动手 —— 而空洞 key 在请求前已记为已考察,那一处从此不再重试 + // (#1210 review)。readAckSyncedKey 正是「本会话在当前连接代完成过整窗同步」这个判据的 + // 既有单一来源(见它的声明处),这里直接复用。 + if (readAckSyncedKey !== `${sessionId}:${connectionEpoch}`) return; + // 与「加载更早」互斥:两者都按 before 游标翻页,同时跑只会让窗口反复 merge、白拉页。 + // 飞行判定只挡**同一会话**,别的会话残留的那一轮不连坐(见 backfillInFlightRun)。 + if (loading || loadingEarlier || backfillInFlightRun?.sid === sessionId) return; + // 换会话时整体重置;同一会话内换了连接代只清 failed —— 断线那次不该把这处空洞永久钉死, + // 重连并重新同步后要能再试(#1210 review)。contiguous / backfilled 是与连接无关的结论, + // 重连后不必重来。 + const existingState = backfillGapStateRef.current; + const gapState = existingState?.sid === sessionId + ? existingState + : { + sid: sessionId, + epoch: connectionEpoch, + contiguous: new Set(), + backfilled: new Set(), + failed: new Set(), + }; + if (gapState.epoch !== connectionEpoch) { + gapState.epoch = connectionEpoch; + gapState.failed.clear(); + } + backfillGapStateRef.current = gapState; + // 跳过表是三类的并集:contiguous 那种"不 merge、跳变留在窗口里"的结局若不跳过,检测会永远 + // 返回同一处,更早处的真实缺行进不了探测。 + const consideredKeys = new Set([ + ...gapState.contiguous, + ...gapState.backfilled, + ...gapState.failed, + ]); + // 两道闸各管一件事,都不能省(常量注释里有完整理由): + // - 翻页额度只算真花了翻页请求的结局 —— 正常停顿不该把它吃光,否则更早的真实缺行排不到; + // - 考察总闸管住探测本身 —— 跨数百天的会话可能有上百处正常停顿,只有翻页额度的话会串行 + // 发出上百次 limit=1 探测。 + if (gapState.backfilled.size >= HISTORY_BACKFILL_MAX_GAPS_PER_VISIT) return; + if (consideredKeys.size >= HISTORY_GAP_MAX_CONSIDERED_PER_VISIT) return; + const gap = findHistoryWindowGap(messages, consideredKeys); + if (!gap) return; + const gapKey = historyWindowGapKey(gap); + const sessionIdAtStart = sessionId; + const epochAtStart = connectionEpoch; + // 本轮的身份:单调序号。启动即成为"最新一轮",此前还在飞的那一轮由此自我作废。 + const runSeq = backfillRunSeqRef.current + 1; + backfillRunSeqRef.current = runSeq; + backfillLatestRunSeqRef.current = runSeq; + setBackfillInFlightRun({ sid: sessionIdAtStart, seq: runSeq }); + void backfillHistoryWindowGap(gap, { + listPage: async (before, limit) => { + const page = await listMessagesWithPayloadRetry( + (retryLimit) => maker.listMessages(sessionIdAtStart, { limit: retryLimit, before }), + // 探测页只要一行,不能沿用默认阶梯(它从 80 起降,第一枪就是满页,探测的成本优势没了); + // 翻页页照常走默认阶梯,帧超限时要能降级重试,否则大 tool 输出的会话一枪就 failed。 + limit === HISTORY_GAP_PROBE_LIMIT ? [HISTORY_GAP_PROBE_LIMIT] : undefined, + ); + return Array.isArray(page.messages) ? page.messages : []; + }, + merge: (rows) => { + if (rows.length > 0) remoteSessionStore.mergeMessages(sessionIdAtStart, rows); + }, + // 两个收手条件: + // - **我不再是最新那一轮** —— 屏幕已经为别的会话(或切回来后的同一会话)起了新的一轮。 + // 判据必须是单调的 seq,不能比"当前会话 id 是否仍等于启动时的":会话切走再切回时后者会 + // 重新成立,取消被撤销、同一会话并发翻页(#1210 review 的 P1)。 + // - 空洞较新一侧那行已不在窗口里 —— /clear、rewind 或整窗替换把它拿掉了,继续 merge 会 + // 把刚被移除的历史(甚至 clearedAt 之前的消息)塞回窗口。锚点没了就等于这处空洞不存在了。 + isCancelled: () => backfillLatestRunSeqRef.current !== runSeq + || !remoteSessionStore.getMessages(sessionIdAtStart).some((row) => row.id === gap.newerId), + }).then((outcome) => { + // 按结局归类(容器的三类语义见 backfillGapStateRef 的注释)。归类发生在**收尾**而不是发起 + // 前:发起期间的重入由飞行标记挡住,不需要预先占位。切会话 / 换连接代之后落地的旧结局 + // 一律丢弃 —— 它属于上一个容器,写进新容器会污染当前会话的判断。 + // 已被新一轮取代的旧轮不写结论:它看到的窗口已经不是当前的了。 + if (backfillLatestRunSeqRef.current !== runSeq) return; + const state = backfillGapStateRef.current; + if (!state || state.sid !== sessionIdAtStart || state.epoch !== epochAtStart) return; + // cancelled 刻意**不记**:它的两个触发条件本身就不会招来立刻重试 —— 会话切走时当前会话 + // 的检测看的是另一个窗口,回到这个会话时理应重新考察;锚点行被 /clear、rewind 拿掉时那处 + // 跳变也随之消失,检测不会再返回它。记下来只会让"切走再回来"白白丢掉一次自愈机会。 + if (outcome === 'contiguous') state.contiguous.add(gapKey); + else if (outcome === 'failed') state.failed.add(gapKey); + else if (outcome !== 'cancelled') state.backfilled.add(gapKey); + }).finally(() => { + // 按 **seq** 精确匹配再清:切会话(甚至切回同一会话)后可能已经起了新的一轮,按 sid 比会把 + // 新轮的标记误清、于是又放行一轮,越滚越多(#1210 review 的 P1)。 + setBackfillInFlightRun((current) => (current?.seq === runSeq ? null : current)); + }); + }, [ + backfillInFlightRun, + connectionEpoch, + deviceId, + loading, + loadingEarlier, + maker, + messages, + readAckSyncedKey, + sessionId, + ]); const selectSlashCommand = useCallback((command: MobileSlashCommand) => { // 点选 agent-skill 时记录名字+会话 id:palette 关闭后 slashCommands 被清,发送侧 diff --git a/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts b/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts new file mode 100644 index 00000000000..66974f0ab1e --- /dev/null +++ b/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts @@ -0,0 +1,148 @@ +/** + * 空洞补齐在会话屏幕上的接线守卫。 + * + * 补齐算法本身在 `historyWindowGap.test.ts` 有行为测试;这里锁住屏幕侧那些"删掉也照样跑、但会 + * 悄悄踩坑"的前置条件。#1210 的三轮 review 全部集中在这一层,所以先把不变量写清,再逐条对着断言 + * ——每条不变量在代码里只允许有**一个**判据,所有对称路径复用它: + * + * 1. **一轮补齐的身份是单调的**:每次启动分配只增不减的 `runSeq`;"是否已被取代"、飞行标记的 + * 清除、结论的写入,全都对着 seq 比。凡是"当前状态是否仍等于启动时状态"的判据都不可靠 —— + * 会话 id 会摆回来(A 在飞 → 切到 B → 快速切回 A),那种判据会把取消**撤销**掉,于是同一会话 + * 并发翻页、旧轮收尾还误清新轮的标记,越滚越多。 + * 2. **同一会话同一时刻最多一轮在飞**:互斥按 `inFlight.sid === sessionId`;别的会话残留的那一轮 + * 不连坐当前会话(它自己会在下一次 isCancelled 上收手)。 + * 3. **同步门槛按 session + 连接代判定**:屏实例会被原地复用,屏幕级 `lastSyncedAt` 在切会话后 + * 仍是上一个会话的非空值,补齐会基于旧缓存快照动手。 + * 4. **每个结局有独立的遗忘条件与预算归属**:contiguous(事实,永久跳过,不占翻页额度)/ + * backfilled(真翻过页,占翻页额度)/ failed(绑 connectionEpoch,重连后可重试)/ cancelled(不记)。 + * 5. **两道预算闸**:考察总次数(防海量正常停顿打出上百次探测)、翻页段数(防一路翻整场历史)。 + * 6. **补齐永不写用户可见的加载态或错误**:它是静默自愈,失败由渲染层的空洞守卫兜底。 + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('history window backfill wiring', () => { + const source = readFileSync(resolve(process.cwd(), 'app/sessions/[sessionId].tsx'), 'utf8'); + + it('不变量 1:一轮的身份是单调 seq,取消不可撤销', () => { + expect(source).toContain('const runSeq = backfillRunSeqRef.current + 1;'); + expect(source).toContain('backfillLatestRunSeqRef.current = runSeq;'); + // 取消判据对着 seq 比,不是"当前会话 id 是否仍等于启动时的"——后者会随切回来而摆回。 + expect(source).toContain('isCancelled: () => backfillLatestRunSeqRef.current !== runSeq'); + // 结论写入同样要求"我还是最新那一轮"。 + expect(source).toContain('if (backfillLatestRunSeqRef.current !== runSeq) return;'); + // 收尾按 seq 精确清标记:按 sid 比会把切回同一会话后新起那一轮的标记误清。 + expect(source).toContain('setBackfillInFlightRun((current) => (current?.seq === runSeq ? null : current));'); + // 作废收敛成一个函数,三个入口共用(单向,不启动新轮): + expect(source).toContain('const abandonInFlightBackfill = useCallback(() => {'); + expect(source).toContain('backfillRunSeqRef.current += 1;'); + // ①切会话 ②换连接代 —— 被动 effect 足够 + expect(source).toContain('}, [abandonInFlightBackfill, sessionId, connectionEpoch]);'); + // ③手动「加载更早」—— 必须在**同步路径**里作废:effect 是被动的,而 loadEarlierMessages 在 + // setLoadingEarlier(true) 之后同步就发请求,自动补齐可能在 effect 跑之前返回并继续下一页, + // 两条分页流程短暂并发(#1210 review)。 + const manualEntry = source.slice( + source.indexOf('const loadEarlierMessages = useCallback'), + source.indexOf('setLoadingEarlier(true);', source.indexOf('const loadEarlierMessages = useCallback')), + ); + expect(manualEntry).toContain('abandonInFlightBackfill();'); + // 已退役的可摆动判据不得回归。 + expect(source).not.toContain('backfillSessionRef'); + expect(source).not.toContain('backfillInFlightRef'); + }); + + it('不变量 2:互斥只挡同一会话,且飞行标记是可观察 state', () => { + expect(source).toContain('if (loading || loadingEarlier || backfillInFlightRun?.sid === sessionId) return;'); + expect(source).toContain('const [backfillInFlightRun, setBackfillInFlightRun] = useState<{ sid: string; seq: number } | null>(null);'); + }); + + it('不变量 3:同步门槛按 session + 连接代,不用屏幕级 lastSyncedAt', () => { + expect(source).toContain('if (readAckSyncedKey !== `${sessionId}:${connectionEpoch}`) return;'); + expect(source).not.toContain('|| lastSyncedAt === null) return;'); + }); + + it('不变量 4:结局分三类,失败绑连接代,cancelled 不记,跳过表取并集', () => { + expect(source).toContain("if (outcome === 'contiguous') state.contiguous.add(gapKey);"); + expect(source).toContain("else if (outcome === 'failed') state.failed.add(gapKey);"); + expect(source).toContain("else if (outcome !== 'cancelled') state.backfilled.add(gapKey);"); + // 断线那次不得把空洞永久钉死:换连接代只清 failed,重连后同一处可以再试。 + expect(source).toContain('gapState.failed.clear();'); + // 换会话时整体重置,否则上个会话的已考察集合会压住新会话的补齐。 + expect(source).toContain('existingState?.sid === sessionId'); + expect(source).toContain('state.sid !== sessionIdAtStart || state.epoch !== epochAtStart'); + expect(source).toContain('findHistoryWindowGap(messages, consideredKeys)'); + expect(source).toContain('...gapState.contiguous,'); + expect(source).toContain('...gapState.backfilled,'); + expect(source).toContain('...gapState.failed,'); + }); + + it('不变量 5:两道预算闸都在,且额度只算翻过页的', () => { + expect(source).toContain('if (gapState.backfilled.size >= HISTORY_BACKFILL_MAX_GAPS_PER_VISIT) return;'); + expect(source).toContain('if (consideredKeys.size >= HISTORY_GAP_MAX_CONSIDERED_PER_VISIT) return;'); + // 硬编码的 3 不得回归:两道闸的语义与理由写在常量注释里。 + expect(source).not.toContain('.backfilled.size >= 3'); + }); + + it('不变量 6:补齐不写 error / loadingEarlier,锚点行消失即收手', () => { + expect(source).toContain('.some((row) => row.id === gap.newerId)'); + const effectStart = source.indexOf('const backfillGapStateRef'); + const effectEnd = source.indexOf('const selectSlashCommand', effectStart); + expect(effectStart).toBeGreaterThan(0); + expect(effectEnd).toBeGreaterThan(effectStart); + const effectSource = source.slice(effectStart, effectEnd); + expect(effectSource).not.toContain('setError('); + expect(effectSource).not.toContain('setLoadingEarlier('); + }); + + it('探测页不沿用默认降级阶梯(第一枪就满页则探测白花),翻页页保留降级重试', () => { + expect(source).toContain('limit === HISTORY_GAP_PROBE_LIMIT ? [HISTORY_GAP_PROBE_LIMIT] : undefined,'); + }); +}); + +/** + * 实时流「生效 / 中断」通知的接线守卫。 + * + * 不变量:**store 只在该会话的实时行确实会送到本端时,才允许 push 续推覆盖区间的上界**(见 + * remoteSessionStore 的 `sessionWindowCoverage.liveTailTrusted`;行为测试在 + * `remoteSessionStore.test.ts`)。于是两侧都要接线:订阅被远端 ACK(生效)一处、断流三处。漏掉任何 + * 一处都会让窗口凭空背书出一段没收到的历史,而这种孤岛在半小时内产生时连自动探测都发现不了 + * (#1210 review)。 + */ +describe('live stream interruption wiring', () => { + const source = readFileSync(resolve(process.cwd(), 'src/device-link/DeviceLinkContext.tsx'), 'utf8'); + + it('订阅被远端 ACK:按真正记进 ACK 表的 topic 生效', () => { + const sendSubscribe = source.slice( + source.indexOf('const sendTrackedSubscribe = useCallback'), + source.indexOf('const probeUnresponsiveDevice'), + ); + // 传的是 markHeldRemoteTopicsSubscribed 的返回值(仍被持有的那些),不是原始 toSend —— + // 中途被释放的 topic 不算订阅生效。 + expect(sendSubscribe).toContain('noteSessionLiveStreamsAcked(\n markHeldRemoteTopicsSubscribed('); + }); + + it('socket 掉线:整体失效(影响所有订阅)', () => { + const offlineBranch = source.slice( + source.indexOf("if (next !== 'online') {"), + source.indexOf('clearRehydrateRetry(true);', source.indexOf("if (next !== 'online') {")), + ); + expect(offlineBranch).toContain('remoteSessionStore.noteLiveStreamInterrupted();'); + }); + + it('退后台释放 session 订阅:按被释放的会话失效', () => { + const release = source.slice( + source.indexOf('const releaseHeavyTopics = ()'), + source.indexOf('return releases;'), + ); + expect(release).toContain('noteSessionLiveStreamsInterrupted(heavy);'); + }); + + it('离开会话取消订阅:按被释放的会话失效', () => { + const unsubscribe = source.slice( + source.indexOf('const unsubscribe = useCallback'), + source.indexOf('const value = useMemo'), + ); + expect(unsubscribe).toContain('noteSessionLiveStreamsInterrupted(toSend);'); + }); +}); diff --git a/apps/mobile/src/__tests__/historyWindowGap.test.ts b/apps/mobile/src/__tests__/historyWindowGap.test.ts new file mode 100644 index 00000000000..9d9fce83112 --- /dev/null +++ b/apps/mobile/src/__tests__/historyWindowGap.test.ts @@ -0,0 +1,328 @@ +/** + * 历史窗口空洞的检测与补齐(见 `historyWindowGap.ts` 的文件头)。 + * + * 现场:2026-07-31 手机端打开一个 445 行的会话,窗口只有"冷开缓存的首段 + 最新页的尾段", + * 中间 400 余行从未加载,6 轮对话在界面上凭空消失(被折进一条「已工作 142m 32s」)。 + */ +import { describe, expect, it, vi } from 'vitest'; + +import { + HISTORY_BACKFILL_MAX_REQUESTS, + HISTORY_BACKFILL_MAX_ROWS, + backfillHistoryWindowGap, + findHistoryWindowGap, + historyWindowGapKey, + type HistoryWindowGap, +} from '@/session/historyWindowGap'; +import type { RemoteMessage } from '@/session/types'; + +const BASE_MS = Date.parse('2026-07-31T06:00:00.000Z'); + +function row(id: string, minutes: number): RemoteMessage { + return { + id, + clientId: id, + sessionId: 'session-1', + role: 'assistant', + content: 'text', + toolUseId: null, + agentMeta: null, + createdAt: new Date(BASE_MS + minutes * 60_000).toISOString(), + }; +} + +describe('findHistoryWindowGap', () => { + it('连续窗口没有空洞', () => { + expect(findHistoryWindowGap([row('a', 0), row('b', 5), row('c', 20)])).toBeNull(); + }); + + it('找到跳变两侧的行', () => { + const gap = findHistoryWindowGap([row('head', 0), row('head-2', 2), row('tail', 140)]); + expect(gap).toEqual({ + newerId: 'tail', + olderId: 'head-2', + newerMs: BASE_MS + 140 * 60_000, + olderMs: BASE_MS + 2 * 60_000, + gapMs: 138 * 60_000, + }); + }); + + it('多处跳变时取最靠尾部的一处', () => { + // 补齐沿 before 从新往旧翻页,先补最靠尾部的洞时游标离窗口尾最近、翻页量最小。 + const gap = findHistoryWindowGap([row('a', 0), row('b', 100), row('c', 300)]); + expect(gap?.olderId).toBe('b'); + expect(gap?.newerId).toBe('c'); + }); + + it('恰好等于阈值不算空洞(严格大于才切)', () => { + expect(findHistoryWindowGap([row('a', 0), row('b', 30)])).toBeNull(); + expect(findHistoryWindowGap([row('a', 0), row('b', 31)])?.newerId).toBe('b'); + }); + + it('跳过本地合成系统卡:它没有服务端对应行,拿它当游标什么都匹配不上', () => { + const localCard = { ...row('mobile-system-pwd-1', 200), id: 'mobile-system-pwd-1' }; + const gap = findHistoryWindowGap([row('a', 0), row('b', 2), localCard]); + expect(gap).toBeNull(); + }); + + it('时间不可解析的行不参与判定', () => { + const broken = { ...row('broken', 0), createdAt: 'not-a-date' }; + expect(findHistoryWindowGap([row('a', 0), broken, row('b', 5)])).toBeNull(); + }); + + it('空洞 key 取两侧时刻对,同毫秒组换了锚点也不变', () => { + // 回归:key 若取两侧的行 id,探测往同毫秒组内 merge 进一行、且它的 id 字典序排到原锚点之后 + // 时,下一轮检测会挑出**同一处停顿**的另一个 id 组合 → key 变了 → 同一处被重新探测,一处 + // 停顿吃掉两次考察额度,更早的真实空洞在本次访问里仍然补不上(#1210 review)。 + const before = findHistoryWindowGap([row('older', 0), row('zzz-newer', 140)]) as HistoryWindowGap; + // 同一毫秒又并进来一行,且 id 字典序排在原锚点之前 → 成为新的锚点。 + const after = findHistoryWindowGap([ + row('older', 0), + row('aaa-newer', 140), + row('zzz-newer', 140), + ]) as HistoryWindowGap; + + expect(after.newerId).not.toBe(before.newerId); + expect(historyWindowGapKey(after)).toBe(historyWindowGapKey(before)); + // key 只由两侧时刻决定。 + expect(historyWindowGapKey(before)).toBe(`${BASE_MS}→${BASE_MS + 140 * 60_000}`); + }); + + it('跳过已考察的跳变,继续往更早处找', () => { + // 关键回归:contiguous(隔夜等合法间隔)既不 merge、跳变也一直留在窗口里。若检测恒定返回 + // 最靠尾部那一处,更早处的真实缺行永远进不了探测 —— 补齐只盯着这处 contiguous 收工, + // 而「加载更早」只从最旧行往外翻、够不到窗口内部的空洞。 + const window = [row('a', 0), row('b', 100), row('c', 300)]; + const tailGap = findHistoryWindowGap(window) as HistoryWindowGap; + expect(tailGap.newerId).toBe('c'); + + const earlierGap = findHistoryWindowGap(window, new Set([historyWindowGapKey(tailGap)])); + expect(earlierGap).toEqual({ + newerId: 'b', + olderId: 'a', + newerMs: BASE_MS + 100 * 60_000, + olderMs: BASE_MS, + gapMs: 100 * 60_000, + }); + + const bothConsidered = new Set([ + historyWindowGapKey(tailGap), + historyWindowGapKey(earlierGap as HistoryWindowGap), + ]); + expect(findHistoryWindowGap(window, bothConsidered)).toBeNull(); + }); +}); + +describe('backfillHistoryWindowGap', () => { + const gap: HistoryWindowGap = { + newerId: 'tail', + olderId: 'head', + newerMs: BASE_MS + 140 * 60_000, + olderMs: BASE_MS + 2 * 60_000, + gapMs: 138 * 60_000, + }; + + it('探测发现两行本来就相邻 → 真安静的会话,不翻页也不 merge', async () => { + const merge = vi.fn(); + const listPage = vi.fn(async () => [row('head', 2)]); + const outcome = await backfillHistoryWindowGap(gap, { + listPage, + merge, + isCancelled: () => false, + }); + + expect(outcome).toBe('contiguous'); + // 只花一次 limit=1 的探测:正常的隔夜会话不该为此白翻整页。 + expect(listPage).toHaveBeenCalledTimes(1); + expect(listPage).toHaveBeenCalledWith('tail', 1); + expect(merge).not.toHaveBeenCalled(); + }); + + it('探测发现别的行 → 继续翻页直到取回目标行', async () => { + const merged: string[] = []; + // 每页按服务端契约排列:最新在前、页尾最旧(见 nextPageCursor)。 + const pages: Record = { + tail: [row('mid-1', 100)], + 'mid-1': [row('mid-3', 80), row('mid-2', 60)], + 'mid-2': [row('head-2', 4), row('head', 2)], + }; + const listPage = vi.fn(async (before: string) => pages[before] ?? []); + const outcome = await backfillHistoryWindowGap(gap, { + listPage, + merge: (rows) => merged.push(...rows.map((r) => r.id)), + isCancelled: () => false, + }); + + expect(outcome).toBe('covered'); + expect(merged).toEqual(['mid-1', 'mid-3', 'mid-2', 'head-2', 'head']); + // 游标按页尾取,所以第二页是从 mid-2(页内最旧)继续往前翻。 + expect(listPage.mock.calls.map((call) => call[0])).toEqual(['tail', 'mid-1', 'mid-2']); + }); + + it('同一毫秒的多行按服务端顺序取页尾,不按 id 字典序', async () => { + // 回归:同毫秒落库的行在客户端只剩相同 createdAt,拿 id 字典序当次级键会与服务端的 + // rowid 次序脱钩,可能挑中页内**较新**那行当游标 → 下一页把已 merge 的行再取回来, + // 补齐在预算内只前进几行就把空洞记成 budget(#1210 review)。 + const pages: Record = { + // 服务端顺序(最新在前)恰好与 id 字典序相反:页尾是 zzz,字典序最小是 aaa。 + tail: [row('aaa', 100), row('mmm', 100), row('zzz', 100)], + zzz: [row('head', 2)], + }; + const listPage = vi.fn(async (before: string) => pages[before] ?? []); + const outcome = await backfillHistoryWindowGap(gap, { + listPage, + merge: () => undefined, + isCancelled: () => false, + }); + + expect(outcome).toBe('covered'); + expect(listPage.mock.calls.map((call) => call[0])).toEqual(['tail', 'zzz']); + }); + + it('锚点不是同毫秒组最旧那行时,探测沿组内继续往更旧处走,仍判为真安静', async () => { + // 回归:同毫秒落库的多行在手机端只剩相同 createdAt,服务端的次级键 rowid 不在每一行上都有 + // (push 追加的行就没有),所以检测阶段挑出的 newerId 可能不是该组最旧的一行。此时 limit=1 + // 探测会取回同组另一行 —— 按 id 精确匹配就会误判成有洞,一路翻页并把这处正常停顿记成 + // backfilled,三处这样的停顿即可耗尽翻页额度、让更早的真实空洞继续缺失(#1210 review)。 + const merge = vi.fn(); + const pages: Record = { + 'tail-b': [row('tail-a', 140)], // 同一毫秒的另一行 + 'tail-a': [row('head', 2)], // 组内走到底,前一刻就是较旧侧 + }; + const listPage = vi.fn(async (before: string) => pages[before] ?? []); + const outcome = await backfillHistoryWindowGap({ ...gap, newerId: 'tail-b' }, { + listPage, + merge, + isCancelled: () => false, + }); + + expect(outcome).toBe('contiguous'); + expect(listPage.mock.calls.map((call) => call[0])).toEqual(['tail-b', 'tail-a']); + // 只在同毫秒组内推进,每步都是 limit=1;没有退化成整页翻页。 + expect(listPage).toHaveBeenCalledWith('tail-b', 1); + expect(listPage).toHaveBeenCalledWith('tail-a', 1); + }); + + it('较旧侧是同毫秒组的另一行时,翻页仍判为已连上', async () => { + // 连上判定同样不能按 olderId 精确匹配:取回同组的另一行就等价于两段之间再无缺口, + // 按 id 比会漏判成"还没连上",于是白翻到预算耗尽、把已经补好的空洞记成 budget。 + const listPage = vi.fn() + .mockResolvedValueOnce([row('mid-1', 100)]) + .mockResolvedValueOnce([row('head-a', 2)]); // 与 olderId('head') 同毫秒、不同 id + const outcome = await backfillHistoryWindowGap(gap, { + listPage, + merge: () => undefined, + isCancelled: () => false, + }); + + expect(outcome).toBe('covered'); + expect(listPage).toHaveBeenCalledTimes(2); + }); + + it('判定只看本页取回的行,不看合并后的窗口', async () => { + // 较旧那一段本来就躺在窗口里。若拿合并结果判定,随便一页(内容完全无关)都会让判定成立, + // 空洞就永远补不回来。这里第一页不含 head → 必须继续翻。 + const listPage = vi.fn() + .mockResolvedValueOnce([row('mid-1', 100)]) + .mockResolvedValueOnce([row('mid-2', 90)]) + .mockResolvedValueOnce([row('head', 2)]); + const outcome = await backfillHistoryWindowGap(gap, { + listPage, + merge: () => undefined, + isCancelled: () => false, + }); + + expect(outcome).toBe('covered'); + expect(listPage).toHaveBeenCalledTimes(3); + }); + + it('翻到历史起点仍未连上 → exhausted', async () => { + const listPage = vi.fn() + .mockResolvedValueOnce([row('mid-1', 100)]) + .mockResolvedValueOnce([]); + const outcome = await backfillHistoryWindowGap(gap, { + listPage, + merge: () => undefined, + isCancelled: () => false, + }); + + expect(outcome).toBe('exhausted'); + }); + + it('游标不前进 → 停手,不进死循环', async () => { + // 被控端反复返回同一段(或整页都是没有 id 的行)时,before 会原地打转。 + const listPage = vi.fn(async () => [row('mid-1', 100)]); + const outcome = await backfillHistoryWindowGap(gap, { + listPage, + merge: () => undefined, + isCancelled: () => false, + }); + + expect(outcome).toBe('exhausted'); + expect(listPage.mock.calls.length).toBeLessThanOrEqual(3); + }); + + it('超出请求数预算 → budget,交给渲染层守卫兜底', async () => { + // 帧超限的会话会被降级成每页几行:请求数会先于行数预算耗尽。 + let seq = 0; + const listPage = vi.fn(async () => { + seq += 1; + return [row(`mid-${seq}`, 100 - seq)]; + }); + const outcome = await backfillHistoryWindowGap(gap, { + listPage, + merge: () => undefined, + isCancelled: () => false, + }); + + expect(outcome).toBe('budget'); + expect(listPage).toHaveBeenCalledTimes(HISTORY_BACKFILL_MAX_REQUESTS); + }); + + it('超出行数预算 → budget', async () => { + let seq = 0; + const listPage = vi.fn(async () => { + seq += 1; + // 每页 80 行,5 页即触顶(400 行),此时请求数还远没用完。 + return Array.from({ length: 80 }, (_, index) => row(`p${seq}-${index}`, 1000 - seq * 80 - index)); + }); + const outcome = await backfillHistoryWindowGap(gap, { + listPage, + merge: () => undefined, + isCancelled: () => false, + }); + + expect(outcome).toBe('budget'); + expect(listPage.mock.calls.length).toBeLessThan(HISTORY_BACKFILL_MAX_REQUESTS); + expect(seq * 80).toBeGreaterThanOrEqual(HISTORY_BACKFILL_MAX_ROWS); + }); + + it('会话切走 / 锚点行已被移除 → cancelled,且不再 merge', async () => { + const merge = vi.fn(); + let cancelled = false; + const listPage = vi.fn(async () => { + cancelled = true; + return [row('mid-1', 100)]; + }); + const outcome = await backfillHistoryWindowGap(gap, { + listPage, + merge, + isCancelled: () => cancelled, + }); + + expect(outcome).toBe('cancelled'); + expect(merge).not.toHaveBeenCalled(); + }); + + it('请求异常 → failed,不抛给调用方', async () => { + const outcome = await backfillHistoryWindowGap(gap, { + listPage: async () => { + throw new Error('offline'); + }, + merge: () => undefined, + isCancelled: () => false, + }); + + expect(outcome).toBe('failed'); + }); +}); diff --git a/apps/mobile/src/__tests__/messageRenderModel.test.ts b/apps/mobile/src/__tests__/messageRenderModel.test.ts index d3de2be9768..7021e89e1bb 100644 --- a/apps/mobile/src/__tests__/messageRenderModel.test.ts +++ b/apps/mobile/src/__tests__/messageRenderModel.test.ts @@ -718,6 +718,35 @@ describe('messageRenderModel', () => { expect(liveGroup.tools[0].toolSettled).toBe(false); }); }); + + describe('历史窗口空洞', () => { + /** + * 2026-07-31 手机端实测:窗口由"冷开缓存的首段 + 最新页的尾段"拼成(中间 400 余行从未 + * 加载),整场会话被渲染成 3 个 item —— 首条 user、一条「已工作 142m 32s」、最后一条回复。 + * 那条组吞掉了中间 6 轮对话,时长也谎报成整场跨度。窗口本身由 historyWindowGap 的补齐 + * 自愈,这里锁住渲染层的兜底:即使补不回来,也不许把两段不相干的历史折成一条。 + */ + const minutes = (value: number): string => + new Date(Date.parse('2026-07-31T06:00:00.000Z') + value * 60_000).toISOString(); + + it('跨空洞不折成一条工作组,时长不横跨空洞', () => { + const items = buildMobileMessageRenderItems([ + message({ id: 'ask', role: 'user', content: { text: '帮我解决下这个问题', images: [], files: [] }, createdAt: minutes(0) }), + message({ id: 'head-thinking', role: 'thinking', content: { thinking: '先看仓库规则', durationMs: 2_000 }, createdAt: minutes(0) }), + message({ id: 'head-tool', role: 'tool_use', toolUseId: 'head-tool', content: { toolUseId: 'head-tool', toolName: 'Read', input: { file_path: '/repo/AGENTS.md' } }, createdAt: minutes(1) }), + // ↑ 首段到此为止;↓ 尾段直接跳到两小时后(中间的 user 行与动作全部缺席) + message({ id: 'tail-tool', role: 'tool_use', toolUseId: 'tail-tool', content: { toolUseId: 'tail-tool', toolName: 'Bash', input: { command: 'gh pr view 1194' } }, createdAt: minutes(140) }), + message({ id: 'tail-answer', role: 'assistant', content: 'PR #1194 已合并', createdAt: minutes(142) }), + ], { isSessionStreaming: false }); + + // 修复前:['message','work_group','message'],那条 work_group 的 durationMs = 142 分钟。 + expect(items.map((item) => item.type)).toEqual(['message', 'work_group', 'work_group', 'message']); + for (const item of items) { + if (item.type !== 'work_group') continue; + expect(item.durationMs ?? 0).toBeLessThan(30 * 60_000); + } + }); + }); }); function expectType( diff --git a/apps/mobile/src/__tests__/remoteSessionStore.test.ts b/apps/mobile/src/__tests__/remoteSessionStore.test.ts index 4c8b18cca98..2bf414486cc 100644 --- a/apps/mobile/src/__tests__/remoteSessionStore.test.ts +++ b/apps/mobile/src/__tests__/remoteSessionStore.test.ts @@ -1182,7 +1182,290 @@ describe('remoteSessionStore', () => { ]); }); + it('丢弃无法确认相接的更早缓存段:本页上沿之外服务端还有历史时(#1222)', () => { + // 「有交集」不等于「连续」:交集只说明两段有共同的行,不排除更早那一段与本页之间还隔着 + // 服务端仍有、本地从未加载的行。断连期间漏收几十上百条 push 时就是这样,而漏收的量不大时 + // 两侧时间差很小 —— 时间阈值的空洞检测发现不了,窗口会静默留下孤岛。 + // moreBeyondWindow(本页满页 / 被裁行)为真时,更早的缓存段一律丢弃,窗口保持连续区间。 + // 用冷开缓存入口种入:它刻意不登记「已验证连续」,正是"来源不明"的那类段。 + remoteSessionStore.hydrateMessagesIfEmpty('s1', [ + messageAt('cached-old', 's1', '2026-01-01T00:00:01.000Z'), + messageAt('latest-1', 's1', '2026-01-01T10:00:01.000Z'), + ]); + + remoteSessionStore.setLatestMessageWindow('s1', [ + messageAt('latest-1', 's1', '2026-01-01T10:00:01.000Z'), + messageAt('latest-2', 's1', '2026-01-01T10:00:02.000Z'), + ], { moreBeyondWindow: true }); + + expect(remoteSessionStore.getMessages('s1').map((item) => item.id)).toEqual([ + 'latest-1', + 'latest-2', + ]); + }); + + it('用户「加载更早」翻出来的历史在满页重连时保留(已验证连续)', () => { + // 回归(#1210 review):只凭"最新页满页"就清空更早的行,会把用户一路翻出来的历史与滚动锚点 + // 一起丢掉,而且补齐也不会拉回(裁完窗口里已没有内部跳变可发现)。「加载更早」是沿 before 从 + // 窗口最旧端连续取的,登记进已验证连续区间后必须保住。 + remoteSessionStore.setMessages('s1', [ + messageAt('latest-1', 's1', '2026-01-01T10:00:01.000Z'), + ]); + remoteSessionStore.mergeEarlierMessages('s1', [ + messageAt('earlier-1', 's1', '2026-01-01T09:00:01.000Z'), + messageAt('earlier-2', 's1', '2026-01-01T09:30:01.000Z'), + ]); + + // 断连重连:最新快照恰好满页 → moreBeyondWindow=true,但这些行在已验证区间内。 + remoteSessionStore.setLatestMessageWindow('s1', [ + messageAt('latest-1', 's1', '2026-01-01T10:00:01.000Z'), + messageAt('latest-2', 's1', '2026-01-01T10:00:02.000Z'), + ], { moreBeyondWindow: true }); + + expect(remoteSessionStore.getMessages('s1').map((item) => item.id)).toEqual([ + 'earlier-1', + 'earlier-2', + 'latest-1', + 'latest-2', + ]); + }); + + it('断流后先到的一条 push 不能让旧段继续被连续性结论背书(#1210 review)', () => { + // 只记「下界」时,它断言的是"从下界到**窗口最新端**连续"——而窗口最新端会被断流期间漏收的行 + // 悄悄作废:旧窗 09:00 那两行,掉线期间服务端产出一大段(本端全漏),重连后先到一条尾部 push, + // 于是"有交集"仅靠这条 push 成立,旧下界却还在背书,窗口重新变成孤岛(而这些行若在半小时内 + // 产生,自动探测也发现不了)。上界显式记下来后,"接不上"就是一次比较。 + remoteSessionStore.setMessages('s1', [ + messageAt('old-1', 's1', '2026-01-01T09:00:01.000Z'), + messageAt('old-2', 's1', '2026-01-01T09:00:02.000Z'), + ]); + remoteSessionStore.noteLiveStreamInterrupted(); + remoteSessionStore.appendMessage('s1', messageAt('resumed-tail', 's1', '2026-01-01T10:00:09.000Z')); + + remoteSessionStore.setLatestMessageWindow('s1', [ + messageAt('gap-tail', 's1', '2026-01-01T10:00:08.000Z'), + messageAt('resumed-tail', 's1', '2026-01-01T10:00:09.000Z'), + ], { moreBeyondWindow: true }); + + expect(remoteSessionStore.getMessages('s1').map((item) => item.id)).toEqual([ + 'gap-tail', + 'resumed-tail', + ]); + }); + + it('订阅未断时实时 push 推进上界,涨过一页后满页刷新仍保留更早的已验证历史', () => { + // 上一条的反面:订阅已 ACK、又没断流时,推送是顺序且完整的,窗口从下界一直连续到最新那条 + // push。这时最新页只回尾段(会话已涨过一页)不代表更早的行来源不明——收紧过头会在活跃会话里 + // 反复清空历史。 + remoteSessionStore.noteLiveStreamAcked('s1'); + remoteSessionStore.setMessages('s1', [ + messageAt('a1', 's1', '2026-01-01T09:00:01.000Z'), + messageAt('a2', 's1', '2026-01-01T09:00:02.000Z'), + ]); + remoteSessionStore.appendMessage('s1', messageAt('live-1', 's1', '2026-01-01T09:30:00.000Z')); + remoteSessionStore.appendMessage('s1', messageAt('live-2', 's1', '2026-01-01T09:30:01.000Z')); + + remoteSessionStore.setLatestMessageWindow('s1', [ + messageAt('live-1', 's1', '2026-01-01T09:30:00.000Z'), + messageAt('live-2', 's1', '2026-01-01T09:30:01.000Z'), + ], { moreBeyondWindow: true }); + + expect(remoteSessionStore.getMessages('s1').map((item) => item.id)).toEqual([ + 'a1', + 'a2', + 'live-1', + 'live-2', + ]); + }); + + it('断流只作用于被中断的会话(退后台释放的是各自的 session 订阅)', () => { + remoteSessionStore.noteLiveStreamAcked('s1'); + remoteSessionStore.noteLiveStreamAcked('s2'); + remoteSessionStore.setMessages('s1', [messageAt('a1', 's1', '2026-01-01T09:00:01.000Z')]); + // 另一个会话的订阅被释放,不能顺带作废 s1 的上界。 + remoteSessionStore.noteLiveStreamInterrupted('s2'); + remoteSessionStore.appendMessage('s1', messageAt('live-1', 's1', '2026-01-01T09:30:00.000Z')); + + remoteSessionStore.setLatestMessageWindow('s1', [ + messageAt('live-1', 's1', '2026-01-01T09:30:00.000Z'), + ], { moreBeyondWindow: true }); + + expect(remoteSessionStore.getMessages('s1').map((item) => item.id)).toEqual(['a1', 'live-1']); + }); + + it('最新页与缓存逐行相同时也登记连续性(#1210 review)', () => { + // 冷开缓存恰好等于服务端最新页是常态。相等早退发生在记账之前时,这次权威响应白来:之后 + // 会话靠 push 涨过一页、再遇一次满页重连刷新,这些**已被权威页确认过**的行会被当成来源不明 + // 全部丢弃,用户当前历史与滚动位置随之消失。 + remoteSessionStore.noteLiveStreamAcked('s1'); + remoteSessionStore.hydrateMessagesIfEmpty('s1', [ + messageAt('c1', 's1', '2026-01-01T09:00:01.000Z'), + messageAt('c2', 's1', '2026-01-01T09:00:02.000Z'), + ]); + remoteSessionStore.setLatestMessageWindow('s1', [ + messageAt('c1', 's1', '2026-01-01T09:00:01.000Z'), + messageAt('c2', 's1', '2026-01-01T09:00:02.000Z'), + ]); + remoteSessionStore.appendMessage('s1', messageAt('live-1', 's1', '2026-01-01T09:30:00.000Z')); + + remoteSessionStore.setLatestMessageWindow('s1', [ + messageAt('live-1', 's1', '2026-01-01T09:30:00.000Z'), + ], { moreBeyondWindow: true }); + + expect(remoteSessionStore.getMessages('s1').map((item) => item.id)).toEqual(['c1', 'c2', 'live-1']); + }); + + it('整窗替换与缓存逐行相同时也登记连续性', () => { + // setMessages 的相等早退同理:两条路径的记账必须一致,否则走哪条入口决定历史保不保得住。 + remoteSessionStore.noteLiveStreamAcked('s1'); + remoteSessionStore.hydrateMessagesIfEmpty('s1', [ + messageAt('c1', 's1', '2026-01-01T09:00:01.000Z'), + messageAt('c2', 's1', '2026-01-01T09:00:02.000Z'), + ]); + remoteSessionStore.setMessages('s1', [ + messageAt('c1', 's1', '2026-01-01T09:00:01.000Z'), + messageAt('c2', 's1', '2026-01-01T09:00:02.000Z'), + ]); + remoteSessionStore.appendMessage('s1', messageAt('live-1', 's1', '2026-01-01T09:30:00.000Z')); + + remoteSessionStore.setLatestMessageWindow('s1', [ + messageAt('live-1', 's1', '2026-01-01T09:30:00.000Z'), + ], { moreBeyondWindow: true }); + + expect(remoteSessionStore.getMessages('s1').map((item) => item.id)).toEqual(['c1', 'c2', 'live-1']); + }); + + it('订阅 ACK 之前落库的权威页,尾部不算可信(#1210 review)', () => { + // 屏幕侧的 openAndSubscribe / startFocusedTopicSubscription 都是 `void subscribe(...)`,不等 + // ACK 就拉页(订阅只管之后的推送,不该挡数据读),所以"页比订阅先到"是常态。这个空窗里被控端 + // 写下的行既不在这一页、也不会被推过来;若这时仍把尾部标成可信,之后一条 push 就会把上界抬过 + // 那几行,而等尾部涨过一页后最新页已不含它们,事实自检也发现不了 —— 孤岛就此固化下来。 + remoteSessionStore.setMessages('s1', [ + messageAt('a1', 's1', '2026-01-01T09:00:01.000Z'), + messageAt('a2', 's1', '2026-01-01T09:00:02.000Z'), + ]); + // 空窗里服务端写了 m81/m82(本端全没收到);订阅生效后才收到更新的这一条。 + remoteSessionStore.noteLiveStreamAcked('s1'); + remoteSessionStore.appendMessage('s1', messageAt('live-1', 's1', '2026-01-01T09:30:00.000Z')); + + remoteSessionStore.setLatestMessageWindow('s1', [ + messageAt('live-1', 's1', '2026-01-01T09:30:00.000Z'), + ], { moreBeyondWindow: true }); + + // 上界仍是 09:00:02,接不上 09:30 的页 → a1/a2 丢弃,窗口保持连续。 + expect(remoteSessionStore.getMessages('s1').map((item) => item.id)).toEqual(['live-1']); + }); + + it.each([ + ['socket 掉线(不带 sessionId)', undefined], + ['退后台释放 / 离开会话(按 sessionId)', 's1'], + ])('断流时 ACK 记录一并作废:%s', (_label, interruptedSessionId) => { + // 断流清掉信任位只管**既有**区间;若 ACK 记录还留着,断线后才落库的在途页(请求在断线前发出、 + // 响应迟到)会重新把尾部标成可信,重连后先到的 push 又把上界抬过漏收的行 —— 绕一圈回到同一个 + // 孤岛。生效与失效必须成对。 + remoteSessionStore.noteLiveStreamAcked('s1'); + remoteSessionStore.setMessages('s1', [ + messageAt('a1', 's1', '2026-01-01T09:00:01.000Z'), + messageAt('a2', 's1', '2026-01-01T09:00:02.000Z'), + ]); + remoteSessionStore.noteLiveStreamInterrupted(interruptedSessionId); + // 断线后才落库的在途页(内容与窗口相同,走相等早退,但记账照做)。 + remoteSessionStore.setMessages('s1', [ + messageAt('a1', 's1', '2026-01-01T09:00:01.000Z'), + messageAt('a2', 's1', '2026-01-01T09:00:02.000Z'), + ]); + remoteSessionStore.appendMessage('s1', messageAt('live-1', 's1', '2026-01-01T09:30:00.000Z')); + + remoteSessionStore.setLatestMessageWindow('s1', [ + messageAt('live-1', 's1', '2026-01-01T09:30:00.000Z'), + ], { moreBeyondWindow: true }); + + expect(remoteSessionStore.getMessages('s1').map((item) => item.id)).toEqual(['live-1']); + }); + + it('最新页在已验证区间内带来窗口没有的行时,旧结论作废', () => { + // 覆盖区间是对服务端事实的断言,可以被更新的权威页推翻(桌面侧改写历史、迟到落库)。 + // 区间内出现窗口没有的行 → 断言本来就是假的,当次按"未知"处置,不能继续背书更早的段。 + remoteSessionStore.setMessages('s1', [ + messageAt('a1', 's1', '2026-01-01T09:00:01.000Z'), + messageAt('a3', 's1', '2026-01-01T09:00:03.000Z'), + ]); + + remoteSessionStore.setLatestMessageWindow('s1', [ + messageAt('a2', 's1', '2026-01-01T09:00:02.000Z'), + messageAt('a3', 's1', '2026-01-01T09:00:03.000Z'), + messageAt('a4', 's1', '2026-01-01T09:00:04.000Z'), + ], { moreBeyondWindow: true }); + + expect(remoteSessionStore.getMessages('s1').map((item) => item.id)).toEqual(['a2', 'a3', 'a4']); + }); + + it('断流后到达的 push 不推进上界(本页被裁到只剩那条 push 时,信任位是唯一守卫)', () => { + // 上界只有在"实时推送链路自它建立以来没断过"时才能被 push 续推:断流期间漏收的行与新到的 + // push 之间可能隔着任意多行,续推等于凭空声明覆盖了它们。 + // 这里刻意把最新页裁到只剩那条 push(device-link payload 超限时的常态,moreBeyondWindow 仍为 + // 真):本页没带来任何"区间内缺失"的行,事实自检无从发现 —— 信任位是这一档唯一的守卫。 + remoteSessionStore.setMessages('s1', [ + messageAt('a1', 's1', '2026-01-01T09:00:01.000Z'), + messageAt('a2', 's1', '2026-01-01T09:00:02.000Z'), + ]); + remoteSessionStore.noteLiveStreamInterrupted('s1'); + // 断线期间服务端还产出了 09:10 / 09:20 等行,本端全漏;重连后先到的是更新的这一条。 + remoteSessionStore.appendMessage('s1', messageAt('resumed', 's1', '2026-01-01T09:30:00.000Z')); + + remoteSessionStore.setLatestMessageWindow('s1', [ + messageAt('resumed', 's1', '2026-01-01T09:30:00.000Z'), + ], { moreBeyondWindow: true }); + + expect(remoteSessionStore.getMessages('s1').map((item) => item.id)).toEqual(['resumed']); + }); + + it('rewind / clear 之后连续性结论失效,来源不明的旧段照旧丢弃', () => { + // 连续性是对"窗口"的结论:rewind 可能删掉中间的行,不能让旧结论继续背书。 + remoteSessionStore.setMessages('s1', [ + messageAt('latest-1', 's1', '2026-01-01T10:00:01.000Z'), + ]); + remoteSessionStore.mergeEarlierMessages('s1', [ + messageAt('earlier-1', 's1', '2026-01-01T09:00:01.000Z'), + ]); + // 被控端删除某行 → 窗口连续性结论重置。 + remoteSessionStore.removeMessages('s1', ['latest-1']); + + remoteSessionStore.setLatestMessageWindow('s1', [ + messageAt('latest-2', 's1', '2026-01-01T10:00:02.000Z'), + messageAt('latest-3', 's1', '2026-01-01T10:00:03.000Z'), + ], { moreBeyondWindow: true }); + + expect(remoteSessionStore.getMessages('s1').map((item) => item.id)).toEqual([ + 'latest-2', + 'latest-3', + ]); + }); + + it('丢弃更早缓存段时仍保留比本页更新的实时 push 行与本地系统卡', () => { + // 收紧的只是"更早那一段"这一条判据:尾部的 live push 与没有服务端对应行的本地卡不受影响。 + remoteSessionStore.hydrateMessagesIfEmpty('s1', [ + messageAt('cached-old', 's1', '2026-01-01T00:00:01.000Z'), + messageAt('latest-1', 's1', '2026-01-01T10:00:01.000Z'), + messageAt('live-tail', 's1', '2026-01-01T10:00:09.000Z'), + messageAt('mobile-system-pwd-1', 's1', '2026-01-01T00:00:05.000Z'), + ]); + + remoteSessionStore.setLatestMessageWindow('s1', [ + messageAt('latest-1', 's1', '2026-01-01T10:00:01.000Z'), + messageAt('latest-2', 's1', '2026-01-01T10:00:02.000Z'), + ], { moreBeyondWindow: true }); + + expect(remoteSessionStore.getMessages('s1').map((item) => item.id)).toEqual([ + 'mobile-system-pwd-1', + 'latest-1', + 'latest-2', + 'live-tail', + ]); + }); + it('preserves loaded older pages when the refreshed latest page overlaps the current window', () => { + // 未给 moreBeyondWindow(或为 false)= 本页已到会话起点,不存在中间缺口,旧段可信照旧保留。 remoteSessionStore.setMessages('s1', [ messageAt('older-1', 's1', '2026-01-01T00:00:01.000Z'), messageAt('latest-1', 's1', '2026-01-01T10:00:01.000Z'), diff --git a/apps/mobile/src/__tests__/scrollWindowModel.test.ts b/apps/mobile/src/__tests__/scrollWindowModel.test.ts index f171c06f511..15fde2aebe9 100644 --- a/apps/mobile/src/__tests__/scrollWindowModel.test.ts +++ b/apps/mobile/src/__tests__/scrollWindowModel.test.ts @@ -100,8 +100,12 @@ describe('scrollWindowModel', () => { const source = readFileSync(resolve(process.cwd(), 'app/sessions/[sessionId].tsx'), 'utf8'); // 重开"无新内容"分支(metaChanged=false)也补设 hasOlderMessages,用服务端总数 vs in-store 推断。 expect(source).toContain('hasOlderMessagesAfterReopen(freshCount, remoteSessionStore.getMessages(sessionId))'); - // 仍保留有新内容时的精确(page-based)判定。 - expect(source).toContain('setHasOlderMessages(shouldKeepOlderMessagesAffordance(history));'); + // 仍保留有新内容时的精确(page-based)判定。这个值现在同时喂给 store 的窗口连续性判据 + // (moreBeyondWindow,见 #1222):两者本就该同源 —— 「本页上沿之外还有历史」既决定是否点亮 + // 「加载更早」,也决定能不能信任早于本页的缓存段。 + expect(source).toContain('const moreBeyondWindow = shouldKeepOlderMessagesAffordance(history);'); + expect(source).toContain('setHasOlderMessages(moreBeyondWindow);'); + expect(source).toContain('setLatestMessageWindow(sessionId, historyPage, { moreBeyondWindow })'); }); }); diff --git a/apps/mobile/src/device-link/DeviceLinkContext.tsx b/apps/mobile/src/device-link/DeviceLinkContext.tsx index 38d2882db9c..010334886c1 100644 --- a/apps/mobile/src/device-link/DeviceLinkContext.tsx +++ b/apps/mobile/src/device-link/DeviceLinkContext.tsx @@ -96,6 +96,7 @@ import { schedulePresenceWipeTimer, updatePresenceAvailability, } from '@/device-link/presenceRecovery'; +import { hasMoreOlderMessages } from '@/session/messagePaging'; import type { InputProjection, PendingInteraction, RemoteMessage } from '@/session/types'; import { createVisualMockDeviceLinkContext, seedVisualMockStore } from '@/debug/visualMock'; @@ -182,6 +183,42 @@ const PRESENCE_OFFLINE_WIPE_GRACE_MS = 5_000; * 一次普通 invoke 往返,但只在旧 timer 剩余时间更短时向后延,不随抖动无限重置。 */ const RECONNECT_MIN_WIPE_GRACE_MS = 3_000; +/** + * 断连补齐时拉的最新窗口大小。与 `hasMoreOlderMessages` 的判定共用同一个数:满页即说明这一页 + * 上沿之外服务端还有历史,store 据此丢弃无法确认相接的更早缓存段(见 setLatestMessageWindow + * 与 #1222)。 + */ +const RECONNECT_MESSAGE_WINDOW_LIMIT = 80; + +const SESSION_TOPIC_PREFIX = 'session:'; + +/** + * `session:` 订阅停了 = 该会话的实时行从此不再送到本端(退后台释放重量级订阅、离开会话 + * 取消订阅)。窗口「已验证连续」区间的上界因此不能再被之后到达的 push 续算 —— 与它之间可能漏了 + * 任意多行(见 remoteSessionStore 的 sessionWindowCoverage)。socket 掉线走不带 sessionId 的整体 + * 失效:那影响所有订阅。 + */ +function noteSessionLiveStreamsInterrupted(topics: readonly string[]): void { + for (const topic of topics) { + if (!topic.startsWith(SESSION_TOPIC_PREFIX)) continue; + const sessionId = topic.slice(SESSION_TOPIC_PREFIX.length); + if (sessionId) remoteSessionStore.noteLiveStreamInterrupted(sessionId); + } +} + +/** + * `session:` 订阅被远端 ACK = 从此刻起该会话的行会被推过来。屏幕侧刻意不等 ACK 就拉页 + * (`void subscribe(...)`),所以「页落库时订阅是否已 ACK」正是 store 判断尾部可不可信的依据 + * (见 remoteSessionStore 的 `liveTailTrusted`)。ACK 本身不点亮既有区间:ACK 之前的空窗里可能 + * 已经漏了行。 + */ +function noteSessionLiveStreamsAcked(topics: readonly string[]): void { + for (const topic of topics) { + if (!topic.startsWith(SESSION_TOPIC_PREFIX)) continue; + const sessionId = topic.slice(SESSION_TOPIC_PREFIX.length); + if (sessionId) remoteSessionStore.noteLiveStreamAcked(sessionId); + } +} export function DeviceLinkProvider({ children }: { children: ReactNode }) { if (MOBILE_VISUAL_MOCK_ENABLED) { @@ -258,7 +295,10 @@ export function DeviceLinkProvider({ children }: { children: ReactNode }) { || backgroundReleaseInFlightRef.current || backgroundReleaseGenerationRef.current !== releaseGeneration ) return; - markHeldRemoteTopicsSubscribed(remoteSubscribedTopicsRef.current, registryRef.current, deviceId, toSend); + // 只有仍被持有、真正记进 ACK 表的 topic 才算订阅生效(中途被释放的那些不算)。 + noteSessionLiveStreamsAcked( + markHeldRemoteTopicsSubscribed(remoteSubscribedTopicsRef.current, registryRef.current, deviceId, toSend), + ); }, []); // 熔断 open 设备的显式代表性探测:openLink 建链(成功按不定论,不关熔断), @@ -571,6 +611,8 @@ export function DeviceLinkProvider({ children }: { children: ReactNode }) { if (next !== 'online') { openLinkInFlightRef.current.clear(); remoteSubscribedTopicsRef.current.clear(); + // 掉线:所有会话的实时行都可能从此漏收,窗口连续性结论的上界不再可续算。 + remoteSessionStore.noteLiveStreamInterrupted(); // 掉线即取消挂起的补齐重试:重新 online 会触发全量补齐,无需旧计时器 clearRehydrateRetry(true); return; @@ -731,9 +773,10 @@ export function DeviceLinkProvider({ children }: { children: ReactNode }) { const releaseHeavyTopics = (): Promise[] => { const releases: Promise[] = []; for (const plan of registryRef.current.snapshot()) { - const heavy = plan.topics.filter((topic) => topic.startsWith('session:')); + const heavy = plan.topics.filter((topic) => topic.startsWith(SESSION_TOPIC_PREFIX)); if (heavy.length === 0) continue; markRemoteTopicsUnsubscribed(remoteSubscribedTopicsRef.current, plan.deviceId, heavy); + noteSessionLiveStreamsInterrupted(heavy); if (client.getStatus() === 'online') { releases.push(sendUnsubscribe(client, plan.deviceId, heavy)); } @@ -861,6 +904,7 @@ export function DeviceLinkProvider({ children }: { children: ReactNode }) { isDeviceLinkTopic(topic) && !releasedSet.has(topic) && !registryRef.current.hasTopic(deviceId, topic)); const toSend = normalizeDeviceLinkTopics([...new Set([...released, ...staleUnheld])]); markRemoteTopicsUnsubscribed(remoteSubscribedTopicsRef.current, deviceId, toSend); + noteSessionLiveStreamsInterrupted(toSend); if (toSend.length === 0) return; await sendUnsubscribe(requireClient(clientRef.current), deviceId, toSend); }, []); @@ -966,7 +1010,7 @@ async function rebuildSessionSnapshot( const [history, pending, projection, goal] = await Promise.allSettled([ sendInvokeWithAccessHandling(client, deviceId, 'local-db:messages:list', [ sessionId, - { limit: 80 }, + { limit: RECONNECT_MESSAGE_WINDOW_LIMIT }, ], sendOpts), sendInvokeWithAccessHandling( client, @@ -991,7 +1035,12 @@ async function rebuildSessionSnapshot( ), ]); if (history.status === 'fulfilled' && Array.isArray(history.value)) { - remoteSessionStore.setLatestMessageWindow(sessionId, history.value); + // moreBeyondWindow:这一页上沿之外服务端还有历史(满 80 条,或被 device-link 裁过行)。为真时 + // store 不保留早于本页的缓存段 —— 断连期间漏收的 push 可能正落在两段之间,保留就在窗口里 + // 留下孤岛,而漏收的量不大时两侧时间差很小、时间阈值的空洞检测发现不了(#1222)。 + remoteSessionStore.setLatestMessageWindow(sessionId, history.value, { + moreBeyondWindow: hasMoreOlderMessages(history.value, RECONNECT_MESSAGE_WINDOW_LIMIT), + }); } if (pending.status === 'fulfilled' && Array.isArray(pending.value)) { remoteSessionStore.setPendingInteractions(sessionId, pending.value, { finalizeStreaming: true }); diff --git a/apps/mobile/src/session/historyWindowGap.ts b/apps/mobile/src/session/historyWindowGap.ts new file mode 100644 index 00000000000..115932f37cd --- /dev/null +++ b/apps/mobile/src/session/historyWindowGap.ts @@ -0,0 +1,330 @@ +/** + * 历史窗口空洞的检测与补齐(手机端)。 + * + * ## 空洞是怎么来的 + * + * 手机端的消息窗口不是一次拉全的,它由几个来源拼起来: + * - 冷开时的本地缓存(`mobileSessionMessageCache`,上次看到的那一页); + * - `listMessages` 的最新窗口(`MESSAGE_PAGE_SIZE` 条,payload 超限时还会降级到更少); + * - `local-db:messages:created` push 逐条追加的尾部。 + * + * 这些来源之间原本没有"必须连续"的保证:app 退到后台 / 网络抖动 / relay 断连期间漏收的 push + * 不会补回来,而 `setLatestMessageWindow` 曾只要求缓存旧页与最新页**有交集**就整段保留。于是 + * store 里会出现"首段 + 尾段"这种孤岛窗口,中间几百行从未加载。 + * + * 现在窗口连续性由 store 从**源头**保证:最新页上沿之外还有服务端历史时(满页 / 被裁行),早于 + * 本页的缓存段一律不保留(见 `setLatestMessageWindow` 的 `moreBeyondWindow` 与 #1222)。本模块 + * 因此退居两个角色: + * - **兜底**:旧版本客户端留下的缓存、以及任何绕过那条判据形成的不连续窗口,仍能被发现并补齐; + * - **补内容**:store 那条判据只保证"不把不可信的段当相邻",它把段丢掉、不会把中间的行取回来; + * 真要让用户看到完整历史,还是得靠这里沿 `before` 游标把缺的那段拉回窗口。 + * + * 渲染层看到的是两段"相邻"item,中间的 user 行(唯一的 turn 边界)全部缺席,跨空洞的动作会被 + * 折成同一个「已工作 Xs」——2026-07-31 实测:一条「已工作 142m 32s」吞掉整场会话的 6 轮对话, + * 手机上看起来就是"中间掉了一大段"。渲染层现在有 `HISTORY_GAP_SPLIT_MS` 守卫兜底(不会再谎报 + * 时长),但内容还是得靠本模块把它拉回来。 + * + * ## 为什么先探测再补齐 + * + * 时间跳变**不等于**空洞:正常会话里"用户隔夜回来继续聊"同样会留下几小时的相邻间隔。拿时间 + * 阈值直接触发翻页,那类会话每次打开都要白翻几页。所以补齐前先花一次 `limit=1` 的请求探测: + * 以空洞较新一侧那行为 `before` 游标只取 1 行,取回的正是较旧一侧那行 → 两行在服务端本来就 + * 相邻(真安静,不是空洞),直接收工;取回别的行 → 确实有洞,再进翻页循环。这一次探测的 payload + * 只有一行,不会触发 relay 帧上限。 + * + * ## 不改跨端协议 + * + * 全程只用既有的 `local-db:messages:list` 的 `before` 游标(与「加载更早」同一个通道),不需要给 + * device-link 隧道加 `after` 方向,不触碰 wire protocol。 + */ +import { HISTORY_GAP_SPLIT_MS } from '@cindy/maker-shared/history-gap'; + +import { MESSAGE_PAGE_SIZE } from '@/session/messagePaging'; +import type { RemoteMessage } from '@/session/types'; + +/** + * 一次补齐的行数预算。 + * + * 对照的是**本次补齐已取回的行数**(每页 `page.length` 累加),不是窗口总行数。它是循环的 + * 准入条件而非硬上限:判定在取页**之前**,所以最后一页整页拉回时总数会略微超出预算 + * (例如已取 390 行仍会再拉一整页)。这是有意的 —— 为省下几十行去要半页会多一次往返。 + * + * 手机内存与列表挂载树都比桌面紧,桌面的 `JUMP_BACKFILL_MAX_ITEMS`(600)在这里偏激进。 + * 400 行 ≈ 5 个满页,足以覆盖"看了个开头就切走、两小时后回来"这类真实空洞(实测那场 445 行 + * 的会话,空洞两侧相隔约 420 行);超出仍未连上时交给渲染层的空洞守卫兜底,并保留 + * 「加载更早」入口让用户自己往上翻。 + */ +export const HISTORY_BACKFILL_MAX_ROWS = 400; + +/** + * 一次访问最多**翻页补齐**几处空洞。 + * + * 多段拼接的窗口确实可能有几处真实缺行,但不设总闸就成了一路往上翻整场历史。只统计真的花了 + * 翻页请求的那些结局(covered / budget / exhausted);正常停顿的探测另有下面那道闸。 + */ +export const HISTORY_BACKFILL_MAX_GAPS_PER_VISIT = 3; + +/** + * 一次访问最多**考察**几处跳变(探测 + 翻页共用的总闸)。 + * + * 为什么翻页额度之外还要这一道:`contiguous`(探测确认服务端本来就相邻)刻意不占翻页额度, + * 否则窗口里几处隔夜停顿就能把额度吃光、更早的真实缺行永远排不到。但跨数百天的长期会话可能 + * 有几十上百处正常停顿,而每处 contiguous 结局都会在飞行标记清除时触发下一次检测 —— 只有翻页 + * 额度的话,打开这种会话会串行发出上百次 `limit=1` 探测,每次重新访问还会重来一遍(#1210 review)。 + * + * 6 次是"典型多段窗口(缓存段 + 若干次在不同位置打开留下的段)都够考察一遍"与"最坏情况也只有 + * 6 个几百字节请求"之间的折中。耗尽后停手:渲染层的空洞守卫仍保证不谎报时长,用户也仍可用 + * 「加载更早」自己往上翻。 + * + * 为什么不跨访问持久化探测结论:那要把结果落到 AsyncStorage 并处理失效(消息被 rewind / clear + * 之后 key 就不再指向同一对相邻行),复杂度远超收益 —— 真实空洞几乎总在窗口尾部附近,前几次 + * 考察就能覆盖。 + */ +export const HISTORY_GAP_MAX_CONSIDERED_PER_VISIT = 6; + +/** + * 请求次数上限,与行数预算分开计。 + * + * 不能只按行数算预算:被控端结果帧超限时 `listMessagesWithPayloadRetry` 会一路降 limit, + * device-link 侧还会静默裁行(`remoteRowsTrimmed`),那种分片每次只带回几行。若与行数共用一个 + * 计数器,这类会话会在远未取到 400 行时就发出几十个请求。12 次是"5 个满页 + 若干降级页"的 + * 保守上界,也兼作防死循环兜底。 + */ +export const HISTORY_BACKFILL_MAX_REQUESTS = 12; + +/** 探测两行在服务端是否真的相邻时的页大小(只要一行就够,payload 最小)。 */ +export const HISTORY_GAP_PROBE_LIMIT = 1; + +/** + * 探测最多往同一毫秒组内推进几步。 + * + * 同一毫秒落库的多行,在手机端只剩相同的 `createdAt`;服务端的次级排序键是 `rowid`,而它不在 + * 手机端窗口里的每一行上都有(push 追加的行就没有),所以**检测阶段无法知道同毫秒组内谁更旧**。 + * 于是跳变较新一侧挑出的锚点可能不是该组最旧的一行,`limit=1` 探测会取回同组的另一行 —— 若把 + * 这当成"取回了别的行"就会误判成有洞,一路翻页并把这处正常停顿记成 `backfilled`,三处这样的 + * 停顿就能耗尽翻页额度、让更早的真实空洞继续缺失(#1210 review)。 + * + * 处置办法不是去猜组内次序,而是让探测**沿组内继续往更旧处走**:取回的行仍落在同一毫秒时,把 + * 它当新游标再探一次。同毫秒组通常只有几行(一次 turn 的并发落库),8 步足够;病态数据(几百行 + * 挤在同一毫秒)超出后按"有洞"处理,翻页循环自己会正常前进,不会卡住。 + */ +export const HISTORY_GAP_PROBE_MAX_STEPS = 8; + +export interface HistoryWindowGap { + /** 空洞较新一侧那一行的 id —— 探测与向上翻页的起始 `before` 游标。 */ + newerId: string; + /** 空洞较旧一侧那一行的 id —— 取回它即视为窗口已连上。 */ + olderId: string; + /** + * 两侧的落库时刻。判定"是否相邻"用的是**时刻**而不是 id 精确匹配:两侧都可能是同毫秒组里的 + * 任意一行(见 `HISTORY_GAP_PROBE_MAX_STEPS`),按时刻比就把这种模糊性一并吃掉 —— 探测取回的行 + * 只要落在 `olderMs` 或更早,就说明 `newerId` 之前没有别的时刻,这段安静是真的。 + */ + newerMs: number; + olderMs: number; + /** 两行的时间差,仅用于日志与测试断言。 */ + gapMs: number; +} + +export type HistoryBackfillOutcome = + /** 已连上:本次翻页真正取回了 `olderId`。 */ + | 'covered' + /** 两行在服务端本来就相邻,不存在空洞(真安静的会话)。 */ + | 'contiguous' + /** 沿 `before` 游标翻到历史起点仍未连上(中间的行已被 rewind 软删 / clear 边界切掉等)。 */ + | 'exhausted' + /** 超出行数或请求数预算,交给渲染层的空洞守卫兜底。 */ + | 'budget' + /** 会话已切走 / 窗口已重置,调用方不得再 merge 本次抓到的行。 */ + | 'cancelled' + /** 请求异常。 */ + | 'failed'; + +export interface HistoryBackfillDeps { + /** + * 按 `before` 游标取一页(实现方负责 payload 降级重试)。 + * + * **必须保持服务端返回顺序**(`local-db:messages:list` 是最新在前、页尾最旧):下一页的游标按 + * 页尾取,见 `nextPageCursor` 的说明。实现方不要在这里重排。 + */ + listPage(before: string, limit: number): Promise; + /** 把取回的行并入窗口(按 key 合并,不覆盖更完整的既有行)。 */ + merge(rows: readonly RemoteMessage[]): void; + /** 会话切走 / `/clear` / rewind 等让本次补齐失去意义;每次 await 前后都会问一次。 */ + isCancelled(): boolean; +} + +/** + * 找窗口里**最靠尾部的、尚未考察过**的一处空洞;没有则返回 null。 + * + * 为什么从尾部找而不是从头:补齐是沿 `before` 从新往旧翻页,先补最靠尾部的洞时游标离窗口尾 + * 最近、翻页量最小。 + * + * 为什么必须能跳过已考察的:补齐成功(`covered`)会把中间行 merge 进来、那处跳变自然消失,但 + * 其它结局不会 —— 尤其 `contiguous`(隔夜等合法间隔,探测确认服务端本来就相邻)既不 merge、 + * 跳变也一直留在窗口里。若检测恒定返回最靠尾部那一处,窗口有 ≥3 段时(多次在不同位置打开 + * 同一会话拼出的缓存),只要最尾部那处是合法间隔,更早处真实缺行就永远进不了探测:补齐只盯 + * 着这处 contiguous 收工,而「加载更早」只从最旧行往外翻、够不到窗口内部的空洞,内容于是静默 + * 丢失(#1210 review)。所以调用方把**已考察过的** gapKey 传进来,检测跳过它们继续往前找。 + * + * 只看真实 host 行:本地合成的系统卡(`mobile-system-*`,/pwd、/context 等)没有服务端对应行, + * 拿它当 `before` 游标什么都匹配不上,只会白拉一页最新消息。 + */ +export function findHistoryWindowGap( + messages: readonly RemoteMessage[], + /** 已考察过的空洞(见 `historyWindowGapKey`);命中的跳变会被跳过,继续往更早处找。 */ + consideredGapKeys: ReadonlySet = new Set(), +): HistoryWindowGap | null { + const rows = messages + .filter((message) => !!message.id && !message.id.startsWith('mobile-system-')) + .map((message) => ({ id: message.id, ms: Date.parse(message.createdAt) })) + .filter((row) => Number.isFinite(row.ms)) + .sort((a, b) => (a.ms === b.ms ? a.id.localeCompare(b.id) : a.ms - b.ms)); + for (let index = rows.length - 1; index > 0; index--) { + const newer = rows[index]; + const older = rows[index - 1]; + const gapMs = newer.ms - older.ms; + if (gapMs <= HISTORY_GAP_SPLIT_MS) continue; + // 同毫秒组内的次序在手机端不可知(见 HISTORY_GAP_PROBE_MAX_STEPS),所以这里挑出的两侧锚点 + // 只保证"落在正确的那一毫秒",不保证是组内最旧/最新那一行;探测阶段按时刻判定,把这种 + // 模糊性吃掉,不需要在这里猜 rowid 次序。 + const gap: HistoryWindowGap = { + newerId: newer.id, + olderId: older.id, + newerMs: newer.ms, + olderMs: older.ms, + gapMs, + }; + if (consideredGapKeys.has(historyWindowGapKey(gap))) continue; + return gap; + } + return null; +} + +/** + * 把一处空洞补齐到"窗口连续"。 + * + * 先探测两行是否本来就相邻(见文件头),确认有洞后沿 `before` 游标向上翻页,直到**本页真正取回 + * 了** `olderId`。判定必须看本页取回的行,不能看合并后的窗口:较旧那一段本来就躺在窗口里,拿 + * 合并结果判定的话,随便一页(哪怕内容完全无关)都会让判定成立,空洞就永远补不回来。 + */ +export async function backfillHistoryWindowGap( + gap: HistoryWindowGap, + deps: HistoryBackfillDeps, +): Promise { + if (deps.isCancelled()) return 'cancelled'; + try { + // ── 探测:先确认这段安静到底是不是空洞 ──────────────────────────────────────────── + // 判定按**时刻**而不是 id 精确匹配,并允许沿同毫秒组往更旧处推进(见 + // HISTORY_GAP_PROBE_MAX_STEPS):取回的行落在 olderMs 或更早 → 服务端本来相邻;仍落在 + // newerMs → 是同组另一行,换成它继续探;落在两者之间 → 确实有洞,它就是洞里的第一行。 + let probeCursor = gap.newerId; + let probeRows = 0; + let probeRequests = 0; + for (let step = 0; step < HISTORY_GAP_PROBE_MAX_STEPS; step++) { + const probe = await deps.listPage(probeCursor, HISTORY_GAP_PROBE_LIMIT); + if (deps.isCancelled()) return 'cancelled'; + probeRequests += 1; + if (probe.length === 0) return 'exhausted'; + const probedId = nextPageCursor(probe); + const probedMs = probedRowMs(probe); + // 整页都是没有 id / 时间不可解析的行:无法继续判定,按"有洞"交给翻页循环。 + if (!probedId || probedMs === null) { + deps.merge(probe); + probeRows += probe.length; + probeCursor = probedId ?? probeCursor; + break; + } + if (probedMs <= gap.olderMs) { + // 服务端相邻:窗口本来就连续。探测到的行已在窗口里,不留副作用直接收工。 + return 'contiguous'; + } + deps.merge(probe); + probeRows += probe.length; + probeCursor = probedId; + // 只有**恰好落在 newerMs 那一毫秒**才继续沿组内往更旧处探;其它情况(落在两侧之间 → + // 确实有洞、这一行就是洞里的第一行;或被控端违反 before 语义返回了更新的行 → 保守处理) + // 一律交给翻页循环。 + if (probedMs !== gap.newerMs) break; + } + + let before = probeCursor; + let rows = probeRows; + let requests = probeRequests; + while (rows < HISTORY_BACKFILL_MAX_ROWS && requests < HISTORY_BACKFILL_MAX_REQUESTS) { + const page = await deps.listPage(before, MESSAGE_PAGE_SIZE); + if (deps.isCancelled()) return 'cancelled'; + requests += 1; + if (page.length === 0) return 'exhausted'; + deps.merge(page); + rows += page.length; + // 连上判定同样按**时刻**而不是 `olderId` 精确匹配:较旧一侧也可能是同毫秒组里的任意一行 + // (见 HISTORY_GAP_PROBE_MAX_STEPS),按 id 比会漏判成"还没连上",于是白翻到预算耗尽、把 + // 已经补好的空洞记成 budget。翻页是沿 before 连续往前的,所以"本页取到了 olderMs 或更早的 + // 行"就等价于"两段之间再无缺口"。 + if (page.some((row) => rowMsAtOrBefore(row, gap.olderMs))) return 'covered'; + const nextBefore = nextPageCursor(page); + // 游标没有前进(整页都是没有 id 的行,或被控端反复返回同一段)→ 停手,避免死循环。 + if (!nextBefore || nextBefore === before) return 'exhausted'; + before = nextBefore; + } + return 'budget'; + } catch { + return 'failed'; + } +} + +/** + * 下一页的 `before` 游标:按**服务端返回顺序**取页尾那一行,不自己按时间戳排序。 + * + * `local-db:messages:list` 的分页契约是 `ORDER BY createdAt DESC, rowid DESC`(见桌面 + * `localDb/ipc/messages.ts`),也就是**最新在前、页尾最旧**;device-link 隧道的裁行只截前缀 + * (`sliceRemoteMessageWindowForChannel`),不改顺序。 + * + * 为什么不能自己排:同一毫秒落库的多行在客户端只剩相同的 `createdAt`,再拿消息 id 的字典序做 + * 次级键就与服务端的 `rowid` 次序脱钩(id 是 cuid 之类,与插入顺序无关)。于是可能挑中页内**较 + * 新**的那一行当游标,下一页把已经 merge 过的行再取回来 —— 连续同毫秒消息时,补齐会在 12 次 + * 请求预算里只前进几行,然后把这处空洞永久记成 `budget`,历史仍然缺失(#1210 review)。 + * rowid 不在手机端的 `RemoteMessage` 契约里(compact 后也不保证带上),所以只能依赖顺序。 + * + * 跳过本地合成的系统卡(`mobile-system-*`):它们没有服务端对应行,当游标什么都匹配不上。 + */ +function nextPageCursor(page: readonly RemoteMessage[]): string | null { + return pageTailRow(page)?.id ?? null; +} + +/** 页尾那一行的落库时刻(同 `nextPageCursor` 的取行口径);不可解析时 null。 */ +function probedRowMs(page: readonly RemoteMessage[]): number | null { + const row = pageTailRow(page); + if (!row) return null; + const ms = Date.parse(row.createdAt); + return Number.isFinite(ms) ? ms : null; +} + +/** 这一行是否落在 `ms` 或更早(时间不可解析的行一律不算,免得把噪声当成"已连上")。 */ +function rowMsAtOrBefore(row: RemoteMessage, ms: number): boolean { + const rowMs = Date.parse(row.createdAt); + return Number.isFinite(rowMs) && rowMs <= ms; +} + +function pageTailRow(page: readonly RemoteMessage[]): RemoteMessage | null { + for (let index = page.length - 1; index >= 0; index--) { + const message = page[index]; + if (!message.id || message.id.startsWith('mobile-system-')) continue; + return message; + } + return null; +} + +/** + * 同一处空洞的稳定标识:补齐失败 / 判定为真安静之后,不再对同一处重复发请求。 + * + * 身份取**两侧的时刻对**,不是两侧的行 id:一处跳变的本质是"这两个时刻之间没有东西",而两侧各自 + * 是同毫秒组里的哪一行是不确定的(见 `HISTORY_GAP_PROBE_MAX_STEPS`)。用 id 组 key 时,只要探测 + * 往组内 merge 进一行、而它的 id 在字典序上排到原锚点之后,下一轮检测就会挑出**同一处停顿**的 + * 另一个 id 组合 → key 变了 → 同一处被重新探测,一处停顿吃掉两次考察额度,更早的真实空洞在本次 + * 访问里仍然补不上(#1210 review)。时刻对不受组内成员变化影响。 + */ +export function historyWindowGapKey(gap: HistoryWindowGap): string { + return `${gap.olderMs}→${gap.newerMs}`; +} diff --git a/apps/mobile/src/session/messageNormalize.ts b/apps/mobile/src/session/messageNormalize.ts index 08fee44241e..1d515d4a039 100644 --- a/apps/mobile/src/session/messageNormalize.ts +++ b/apps/mobile/src/session/messageNormalize.ts @@ -72,6 +72,11 @@ export interface NormalizedRemoteMessage { diff?: NormalizedToolDiff; align: 'user' | 'agent'; createdAt: string; + /** + * tool 消息专用:配对 tool_result 的落库时刻(ISO),即这次调用的结束时刻。渲染层用它做 + * 历史空洞判定的锚点(见共享 `MessageRenderNormalizedMessage.settledAt`)。 + */ + settledAt?: string; isStreaming?: boolean; /** Host 在 SDK done 边界写入;后台自动续跑时每个 sealed assistant 都是正式回复。 */ turnCompleted?: boolean; @@ -196,6 +201,8 @@ export function normalizeRemoteMessages(messages: readonly RemoteMessage[]): Nor diff: tool.diff, align: 'agent', createdAt: message.createdAt, + // 结束时刻(配对 tool_result 落库时间)驱动渲染层的历史空洞判定,详见共享类型上的说明。 + settledAt: toolResultPairing.resultCreatedAtFor(message, tool), toolSettled: toolResultPairing.hasResultFor(message, tool), }); continue; diff --git a/apps/mobile/src/session/remoteSessionStore.ts b/apps/mobile/src/session/remoteSessionStore.ts index afc5a4e6197..afe94f5b242 100644 --- a/apps/mobile/src/session/remoteSessionStore.ts +++ b/apps/mobile/src/session/remoteSessionStore.ts @@ -72,6 +72,19 @@ interface SessionMessageSyncMarker { updatedAt: string; } +export interface SetLatestMessageWindowOptions { + /** + * 本页**上沿之外服务端还有历史**(满页,或被 device-link 裁过行)。 + * + * 真为真时,早于本页最旧行的缓存段一律不保留 —— 它与本页之间可能隔着从未加载的行,保留就会 + * 在窗口里留下孤岛(详见 `setLatestMessageWindow` 里的说明与 #1222)。调用方用既有的 + * `hasMoreOlderMessages` / `shouldKeepOlderMessagesAffordance` 判定即可,不需要自己数。 + * + * 省略时按 false 处理(保持旧行为):调用方拿不到分页元信息时不该因此丢历史。 + */ + moreBeyondWindow?: boolean; +} + interface LivePlanSnapshot { content: Record; persistId?: string; @@ -203,6 +216,206 @@ const sessionRunStatus = new Map(); const sessionMakerActivityEpochs = new Map(); let makerActivityEpoch = 0; const sessionMessageSyncMarkers = new Map(); +/** + * 每会话「已验证连续覆盖区间」:`[since, until]`(闭区间,ISO createdAt)内的**所有**服务端行都在 + * 窗口里 —— 中间没有服务端有、本地没加载的行。缺省(未登记)= 未知,窗口不被信任为连续。 + * + * 为什么需要它:`setLatestMessageWindow` 判断"要不要保留早于本页的旧段"时,只有两种旧段 + * - **来源不明的缓存**(冷开 hydrate 的那页):与本页的关系无从确认,本页上沿之外还有服务端历史 + * 时必须丢弃,否则窗口留下孤岛(#1222); + * - **已验证连续的历史**(整窗替换的那一页、用户一路「加载更早」翻出来的、以及订阅未断时收到的 + * 实时 push):与本页确实相接。把它一并丢掉会让用户正在看的历史和滚动锚点凭空消失,而且自动 + * 补齐也不会拉回(裁完窗口里已经没有内部跳变可发现了)——#1210 review 实测到的回归。 + * 区间就是区分这两者的那条线:旧行落在 `[since, until]` 内、**且本页最旧行不晚于 `until`** + * (两段首尾相接)时才保留。 + * + * 为什么不能只记下界:下界断言的是"从它到**窗口最新端**连续",而"窗口最新端"会被断流期间漏收的 + * 行悄悄作废 —— 旧窗 1–80,断线漏收 81–200,重连后先到一条 push 201,再收到最新页 122–201: + * 窗口最新端已经跳到 201,下界仍指着 1,旧结论于是给"1–80 + 122–201"这个孤岛背了书。把上界显式 + * 记下来,这类"接不上"就是一次比较(#1210 review)。 + * + * `liveTailTrusted`:自 `until` 建立以来实时推送链路没断过 —— 只有这时新到的 push 才能把 `until` + * 往后推(订阅内的推送是顺序且完整的)。它由**权威页落库那一刻订阅是否已 ACK**决定:屏幕侧的 + * `openAndSubscribe` 与 `startFocusedTopicSubscription` 都是 `void subscribe(...)`,刻意不等 ACK + * (订阅只管之后的推送,不该挡数据读),所以页比订阅先到是常态 —— 这个空窗里被控端写下的行既不会 + * 进这一页、也不会被推过来,之后一条 push 就会把 `until` 抬过它们,而等尾部涨过一页后最新页已不含 + * 那几行,事实自检也发现不了(#1210 review)。反过来重连补齐路径(`rehydrate`)是 `await subscribe` + * 之后才拉页的,所以那条路径拿到的是可信尾部。 + * 断流(socket 掉线、退后台释放 session 订阅、离开会话取消订阅)一律清掉信任位与 ACK 记录:之后 + * 收到的 push 与 `until` 之间可能漏了任意多行,不能续算。区间本身保留 —— 断流不会让断流前已验证 + * 的那段失效。信任位只能由「ACK 之后落库的权威页」重新点亮(ACK 本身不行:ACK 之前的空窗里可能 + * 已经漏了行,只有新的权威页能重新确定尾部)。 + * + * 建立 / 扩展点(都对应"服务端一次给出的连续段"或"订阅内的顺序推送"):整窗替换 `setMessages`、 + * 最新窗口 `setLatestMessageWindow`、「加载更早」`mergeEarlierMessages`、实时 push `appendMessage`。 + * 冷开 hydrate 与空洞补齐 `mergeMessages` 刻意不登记:前者来源不明;后者补的是窗口内部的洞,补完 + * 要算准新区间得先确认窗口里没有别的洞——而这正是补齐本身在解决的问题(保守不动的代价是补回来 + * 的段可能在下一次满页同步时被丢弃,下次打开会重新补,方向上是安全的那一侧)。 + * 清空 / rewind / 会话回收一律删除(重置为未知),下次最新窗口同步会重建。 + * + * 事实自检:最新页若在 `[since, until]` 内带来窗口没有的行,说明旧结论已被服务端事实推翻(桌面侧 + * 改写历史、迟到落库等),当次按"未知"处置,见 `joinableWindowCoverage`。 + */ +type SessionWindowCoverage = { + /** 区间下界(含)。 */ + since: string; + /** 区间上界(含)。 */ + until: string; + /** 见上:实时推送链路自 `until` 建立以来未断过,`until` 可被新 push 续推。 */ + liveTailTrusted: boolean; +}; +const sessionWindowCoverage = new Map(); +/** + * 远端已 ACK「该会话实时流」订阅(topic `session:`)的会话集合。只用来决定新落库的权威页能否 + * 把尾部标成可信(见 `liveTailTrusted`);由 device-link 的订阅 ACK / 释放两侧记账。 + */ +const sessionLiveStreamAcked = new Set(); + +/** 取一批行里最旧的 createdAt(空列表 → undefined)。 */ +function oldestCreatedAt(list: readonly RemoteMessage[]): string | undefined { + let oldest: string | undefined; + for (const item of list) { + if (!item.createdAt) continue; + if (oldest === undefined || item.createdAt.localeCompare(oldest) < 0) oldest = item.createdAt; + } + return oldest; +} + +/** 取一批行里最新的 createdAt(空列表 → undefined)。 */ +function newestCreatedAt(list: readonly RemoteMessage[]): string | undefined { + let newest: string | undefined; + for (const item of list) { + if (!item.createdAt) continue; + if (newest === undefined || item.createdAt.localeCompare(newest) > 0) newest = item.createdAt; + } + return newest; +} + +function forgetWindowCoverage(sessionId: string): void { + sessionWindowCoverage.delete(sessionId); +} + +/** + * 这一页落库时尾部是否可信:订阅已 ACK → 之后的行会被推过来,`until` 可由 push 续推;订阅还没 + * ACK(页比订阅先到,屏幕侧的常态)→ 空窗里被控端写下的行既不在这一页、也不会被推来,尾部不可信。 + */ +function liveTailTrustedForPage(sessionId: string): boolean { + return sessionLiveStreamAcked.has(sessionId); +} + +/** 整窗替换:窗口就是这一页,区间即这一页 —— 不与旧结论求并(旧内容已经不在窗口里了)。 */ +function coverReplacedWindow(sessionId: string, list: readonly RemoteMessage[]): void { + const since = oldestCreatedAt(list); + const until = newestCreatedAt(list); + if (!since || !until) { + forgetWindowCoverage(sessionId); + return; + } + sessionWindowCoverage.set(sessionId, { + since, + until, + liveTailTrusted: liveTailTrustedForPage(sessionId), + }); +} + +/** + * 最新页对账后登记:本页自身是连续段。`joined` 是本次**实际采纳**的旧结论(与本页首尾相接、 + * 因此其覆盖的旧段被保留),undefined 表示旧段没被采纳、区间收敛到本页。 + * + * 保留判据与这里必须用同一个 `joined`:否则"保留了旧段却不声明覆盖它"(下次同步照丢)或"声明了 + * 覆盖却已经把它丢掉"(凭空背书出一个孤岛)两个方向都会让区间与窗口对不上。 + */ +function coverLatestPage( + sessionId: string, + pageOldest: string, + pageNewest: string, + joined: SessionWindowCoverage | undefined, +): void { + const since = joined && joined.since.localeCompare(pageOldest) < 0 ? joined.since : pageOldest; + const until = joined && joined.until.localeCompare(pageNewest) > 0 ? joined.until : pageNewest; + // 采纳的旧结论若已经是可信尾部(它的 until 就是本次上界),沿用它;否则由本页落库时的 ACK 状态决定。 + const inheritsTrustedTail = joined?.liveTailTrusted === true + && joined.until.localeCompare(pageNewest) >= 0; + sessionWindowCoverage.set(sessionId, { + since, + until, + liveTailTrusted: inheritsTrustedTail || liveTailTrustedForPage(sessionId), + }); +} + +/** + * 「加载更早」:沿 `before` 从窗口最旧端连续取的一页,把下界前移。 + * + * 还没有任何结论时(冷开 hydrate 的窗口上翻),这一页只能证明"从它到它接上的那一行"连续 —— + * `joinsAt` 就是合并前窗口的最旧行;窗口更上面的部分来源仍然不明,上界不能顺手抬到窗口最新端。 + */ +function coverEarlierPage(sessionId: string, pageOldest: string, joinsAt: string | undefined): void { + const current = sessionWindowCoverage.get(sessionId); + if (current) { + if (pageOldest.localeCompare(current.since) < 0) { + sessionWindowCoverage.set(sessionId, { ...current, since: pageOldest }); + } + return; + } + if (!joinsAt || pageOldest.localeCompare(joinsAt) > 0) return; + sessionWindowCoverage.set(sessionId, { since: pageOldest, until: joinsAt, liveTailTrusted: false }); +} + +/** 订阅内到达的实时 push:顺序且完整,可把上界往后推。 */ +function coverLiveRow(sessionId: string, message: RemoteMessage): void { + const current = sessionWindowCoverage.get(sessionId); + if (!current || !current.liveTailTrusted) return; + // 本地系统卡(/learn、/context 等)没有服务端对应行:用它推上界等于凭空声明"服务端到这一刻的 + // 行都在窗口里"。 + if (messageKey(message).startsWith('mobile-system-')) return; + const createdAt = message.createdAt; + if (!createdAt || createdAt.localeCompare(current.until) <= 0) return; + sessionWindowCoverage.set(sessionId, { ...current, until: createdAt }); +} + +/** + * 实时推送链路中断:ACK 记录作废,上界也不再能被 push 续算(见 `liveTailTrusted`)。区间本身保留。 + * 省略 sessionId = 全部会话(socket 掉线影响所有订阅)。 + */ +function noteLiveStreamInterrupted(sessionId?: string): void { + if (sessionId !== undefined) { + sessionLiveStreamAcked.delete(sessionId); + const current = sessionWindowCoverage.get(sessionId); + if (current?.liveTailTrusted) { + sessionWindowCoverage.set(sessionId, { ...current, liveTailTrusted: false }); + } + return; + } + sessionLiveStreamAcked.clear(); + for (const [key, current] of sessionWindowCoverage) { + if (current.liveTailTrusted) { + sessionWindowCoverage.set(key, { ...current, liveTailTrusted: false }); + } + } +} + +/** + * 取本次可采纳的旧结论:必须与本页首尾相接(本页最旧行不晚于上界),且没有被本页的事实推翻 + * (本页在区间内带来了窗口里没有的行)。任一不成立 → undefined,按"未知"处置。 + */ +function joinableWindowCoverage( + sessionId: string, + existingIndex: MessageIdentityIndex, + latestWindow: readonly RemoteMessage[], + pageOldest: string, +): SessionWindowCoverage | undefined { + const coverage = sessionWindowCoverage.get(sessionId); + if (!coverage) return undefined; + if (pageOldest.localeCompare(coverage.until) > 0) return undefined; + for (const item of latestWindow) { + const createdAt = item.createdAt; + if (!createdAt) continue; + if (createdAt.localeCompare(coverage.since) < 0) continue; + if (createdAt.localeCompare(coverage.until) > 0) continue; + if (!messageIdentityIndexHas(existingIndex, item)) return undefined; + } + return coverage; +} // Per-session live sub-agent task state, decoded from `agent_task_update` events (live-only, // never persisted — see @cindy/maker-shared/agent-task). Keyed taskId/parentToolUseId → update. const sessionTaskUpdates = new Map>(); @@ -487,23 +700,38 @@ function findMessageMergeKey(byKey: ReadonlyMap, target: return null; } -function messageWindowsOverlap(a: readonly RemoteMessage[], b: readonly RemoteMessage[]): boolean { - // Keep the overlap probe linear even when the cached history contains many pages. - // `messageKey` is the common path; the separate identity sets preserve the - // clientId/id migration case without falling back to an O(n×m) nested scan. - const keys = new Set(); - const ids = new Set(); - const clientIds = new Set(); - for (const message of a) { - keys.add(messageKey(message)); - if (message.id) ids.add(message.id); - if (message.clientId) clientIds.add(message.clientId); +/** + * 「这一行在不在那一批里」的线性判据。`messageKey` 是常态路径;单独的 id / clientId 集合保留 + * clientId→id 迁移那一档,不必退回 O(n×m) 嵌套扫描。 + * + * 交集探测(`messageWindowsOverlap`)与覆盖区间的事实自检(`joinableWindowCoverage`)问的是同一件 + * 事,共用这一份索引 —— 两处各写一遍迟早会在迁移档上分叉。 + */ +type MessageIdentityIndex = { + keys: Set; + ids: Set; + clientIds: Set; +}; + +function buildMessageIdentityIndex(list: readonly RemoteMessage[]): MessageIdentityIndex { + const index: MessageIdentityIndex = { keys: new Set(), ids: new Set(), clientIds: new Set() }; + for (const message of list) { + index.keys.add(messageKey(message)); + if (message.id) index.ids.add(message.id); + if (message.clientId) index.clientIds.add(message.clientId); } - return b.some((message) => ( - keys.has(messageKey(message)) - || (message.id ? ids.has(message.id) : false) - || (message.clientId ? clientIds.has(message.clientId) : false) - )); + return index; +} + +function messageIdentityIndexHas(index: MessageIdentityIndex, message: RemoteMessage): boolean { + return index.keys.has(messageKey(message)) + || (message.id ? index.ids.has(message.id) : false) + || (message.clientId ? index.clientIds.has(message.clientId) : false); +} + +function messageWindowsOverlap(a: readonly RemoteMessage[], b: readonly RemoteMessage[]): boolean { + const index = buildMessageIdentityIndex(a); + return b.some((message) => messageIdentityIndexHas(index, message)); } function streamingMeta(meta: Record | null | undefined): Record { @@ -1108,6 +1336,10 @@ export const remoteSessionStore = { setMessages(sessionId: string, list: readonly RemoteMessage[]): void { const textFlushed = flushPendingTextDelta(sessionId); const next = normalizeMessages(list); + // 记账在相等早退**之前**:这一页是服务端一次给出的连续段,它带来的连续性结论与"窗口内容有没有 + // 变"无关。冷开缓存恰好与服务端最新页逐行相同时(常态)若被早退跳过,这次权威响应就白来了 —— + // 之后会话涨过一页、再遇一次满页重连刷新,本可保留的历史会被当成来源不明全丢(#1210 review)。 + coverReplacedWindow(sessionId, next); if (remoteMessageListsEqual(messages.get(sessionId) ?? emptyMessages, next)) { if (textFlushed) emit(); return; @@ -1135,7 +1367,11 @@ export const remoteSessionStore = { emit(); }, - setLatestMessageWindow(sessionId: string, list: readonly RemoteMessage[]): void { + setLatestMessageWindow( + sessionId: string, + list: readonly RemoteMessage[], + options: SetLatestMessageWindowOptions = {}, + ): void { const textFlushed = flushPendingTextDelta(sessionId); const latestWindow = normalizeMessages(list); if (latestWindow.length === 0) { @@ -1152,6 +1388,8 @@ export const remoteSessionStore = { const preserved = existing.filter((item) => messageKey(item).startsWith('mobile-system-')); const next = preserved.length > 0 ? preserved : []; if (!remoteMessageListsEqual(existing, next)) { + // 服务端行被清空(只余本地卡):旧覆盖区间连同它背书的那些行一起没了,不能留着背书。 + forgetWindowCoverage(sessionId); messages.set(sessionId, next); bumpMessageVersion(); emit(); @@ -1164,7 +1402,8 @@ export const remoteSessionStore = { const existing = messages.get(sessionId) ?? []; const latestOldestCreatedAt = latestWindow[0].createdAt; const latestNewestCreatedAt = latestWindow[latestWindow.length - 1].createdAt; - const hasOverlap = messageWindowsOverlap(existing, latestWindow); + const existingIdentityIndex = buildMessageIdentityIndex(existing); + const hasOverlap = latestWindow.some((item) => messageIdentityIndexHas(existingIdentityIndex, item)); const byKey = new Map(); // 截断保护的比较基准必须覆盖全部 existing 行:下面的循环只把窗口外(更新/更旧) // 的行 seed 进 byKey,窗口内重叠的完整行若不在基准里,payload 超限的窗口刷新 @@ -1174,10 +1413,42 @@ export const remoteSessionStore = { // A latest-page sync is authoritative for the tail of the conversation. // Only keep older cached pages when they overlap that page; otherwise stale // old windows can be rendered as if they were adjacent to fresh pushes. + // + // 「有交集」这个判据不足以保证**连续**:交集只说明两段有共同的行,不排除更早那一段与本页 + // 之间还隔着服务端仍有、本地从未加载的行。于是窗口会留下"首段 + 尾段"的孤岛,中间几百行 + // 缺失(手机端实测:整场会话的 6 轮对话在界面上凭空消失)。 + // + // `moreBeyondWindow` 是调用方给的结构信号:本页是满页、或被 device-link 裁过行 —— 两者都 + // 意味着**本页上沿之外服务端还有历史**。这时任何早于本页最旧行的缓存段都无法确认与本页 + // 相接,一律丢弃,窗口于是始终是"某点 → 最新"的连续区间。代价是用户可见的历史变少(丢掉的 + // 是不可信的那一段),「加载更早」入口仍在、可以按连续分页重新取回。 + // + // 反之本页不满页时,服务端从会话起点到最新已经全给了,不存在中间缺口,旧段照原判据保留。 + // + // 为什么不靠时间阈值判断空洞:那只能发现"两侧间隔很久"的孤岛。断连期间漏收几十上百条、 + // 而它们在半小时内快速产生时(一个长 turn 里的连续工具调用就是),两侧间隔根本不大,检测不到 + // (#1222)。从源头保证连续区间才覆盖得住。 + // + // 例外(#1210 review):已验证连续的历史(整窗替换的那页、用户一路「加载更早」翻出来的、订阅 + // 未断时收到的实时 push)与本页确实相接 —— 它由 `sessionWindowCoverage` 的覆盖区间标出。把这种 + // 段也丢掉会让用户正在看的历史与滚动锚点凭空消失,而且补齐不会拉回它(裁完已无内部跳变可 + // 发现)。所以判据是"在已验证覆盖区间内、且本页与该区间首尾相接 → 保留;否则才按 + // moreBeyondWindow 处置"。 + const keepUnverifiedOlderPages = hasOverlap && options.moreBeyondWindow !== true; + // 相接与否是一次判断,保留判据与下面的记账共用它:两处分开算迟早会让区间与窗口对不上。 + const joinedCoverage = joinableWindowCoverage( + sessionId, + existingIdentityIndex, + latestWindow, + latestOldestCreatedAt, + ); for (const item of existing) { const createdAt = item.createdAt; const isNewerThanLatestPage = createdAt.localeCompare(latestNewestCreatedAt) >= 0; - const isOlderLoadedPage = hasOverlap && createdAt.localeCompare(latestOldestCreatedAt) < 0; + const isVerifiedContiguous = joinedCoverage !== undefined + && createdAt.localeCompare(joinedCoverage.since) >= 0; + const isOlderLoadedPage = createdAt.localeCompare(latestOldestCreatedAt) < 0 + && (isVerifiedContiguous || keepUnverifiedOlderPages); // 本地系统卡(/learn、/context 等)没有服务端对应行:不管时序落在窗口哪里都 // 不会出现在 latestKeys 里,若不单独保留会被 window 刷新时静默丢弃。 const isLocalSystemCard = messageKey(item).startsWith('mobile-system-'); @@ -1230,6 +1501,9 @@ export const remoteSessionStore = { } const next = normalizeMessages([...byKey.values()]); + // 记账在相等早退**之前**(同 setMessages):这一页是服务端一次给出的连续段,它带来的结论与 + // "窗口内容有没有变"无关。被早退跳过时这次权威响应就白来了(#1210 review)。 + coverLatestPage(sessionId, latestOldestCreatedAt, latestNewestCreatedAt, joinedCoverage); if (remoteMessageListsEqual(existing, next)) { if (textFlushed) emit(); return; @@ -1295,12 +1569,48 @@ export const remoteSessionStore = { emit(); }, + /** + * 「加载更早」拉回的一页:沿 `before` 游标**从窗口最旧端连续**往前取,所以它与既有窗口相接。 + * + * 与 `mergeMessages` 的唯一差别是它会把「已验证连续」覆盖区间的下界前移到这一页的最旧行 + * (见 `sessionWindowCoverage`):这样后续满页的最新窗口同步就不会把用户一路翻出来的历史 + * 当成来源不明的缓存丢掉(#1210 review 实测到的回归)。 + * + * 只有真正沿窗口最旧端连续翻页的调用方可以用它;补内部空洞请继续用 `mergeMessages`。 + */ + mergeEarlierMessages(sessionId: string, list: readonly RemoteMessage[]): void { + // 合并前窗口的最旧行 = 这一页接上的那一行,尚无结论时它就是区间上界(见 coverEarlierPage)。 + const joinsAt = oldestCreatedAt(messages.get(sessionId) ?? emptyMessages); + this.mergeMessages(sessionId, list); + const pageOldest = oldestCreatedAt(list); + if (pageOldest) coverEarlierPage(sessionId, pageOldest, joinsAt); + }, + appendMessage(sessionId: string, message: RemoteMessage): void { let changed = flushPendingTextDelta(sessionId); changed = upsertMessage(sessionId, overlayLivePlanSnapshot(sessionId, message)) || changed; + // 订阅内到达的实时行可以把覆盖区间的上界往后推;断流后收到的不行(见 liveTailTrusted)。 + coverLiveRow(sessionId, message); if (changed) emit(); }, + /** + * 该会话的实时流订阅已被远端 ACK。只影响**此后**落库的权威页能否把尾部标成可信 —— ACK 之前的 + * 空窗里可能已经漏了行,所以 ACK 本身不点亮既有区间的信任位(见 `sessionWindowCoverage`)。 + */ + noteLiveStreamAcked(sessionId: string): void { + if (sessionId) sessionLiveStreamAcked.add(sessionId); + }, + + /** + * 实时推送链路中断:socket 掉线(省略 sessionId = 全部会话)、退后台释放 `session:` 订阅、 + * 或离开会话取消订阅。此后到达的 push 与覆盖区间上界之间可能漏了任意多行,不能再续算 + * (见 `sessionWindowCoverage` 的 `liveTailTrusted`)。 + */ + noteLiveStreamInterrupted(sessionId?: string): void { + noteLiveStreamInterrupted(sessionId); + }, + /** * 被控端已原子清除一轮消息后,按稳定 clientId 集合移除控制端镜像。 * 同时失效 latest-window marker;sessions patch 与 deletion push 无顺序保证, @@ -1317,6 +1627,9 @@ export const remoteSessionStore = { !deletedClientIds.has(message.clientId) && !deletedClientIds.has(message.id) )); sessionMessageSyncMarkers.delete(sessionId); + // 连续性结论随窗口一起失效:rewind 可能删掉中间的行,清空/回收更是整窗重来。 + // 重置为未知,下一次最新窗口同步会重建(见 sessionWindowCoverage)。 + forgetWindowCoverage(sessionId); const messagesChanged = next.length !== existing.length; if (messagesChanged) messages.set(sessionId, next); const deletedTaskAliases = new Set(deletedClientIds); @@ -1680,6 +1993,9 @@ export const remoteSessionStore = { // session 页面监听到 pendingRefreshSessions 变化后调 load(),走 reopen 路径: // sync marker 已失效 → metaChanged=true → 拉最新消息窗口(含 error 行)整窗替换。 sessionMessageSyncMarkers.delete(sessionId); + // 连续性结论随窗口一起失效:rewind 可能删掉中间的行,清空/回收更是整窗重来。 + // 重置为未知,下一次最新窗口同步会重建(见 sessionWindowCoverage)。 + forgetWindowCoverage(sessionId); pendingRefreshSessions.add(sessionId); } else { // 未缓存:清 sync marker + 标记待刷新。 @@ -1689,6 +2005,9 @@ export const remoteSessionStore = { // 触发同步失效 → metaChanged=true → 整窗替换,error 卡正常浮现。 messages.delete(sessionId); sessionMessageSyncMarkers.delete(sessionId); + // 连续性结论随窗口一起失效:rewind 可能删掉中间的行,清空/回收更是整窗重来。 + // 重置为未知,下一次最新窗口同步会重建(见 sessionWindowCoverage)。 + forgetWindowCoverage(sessionId); pendingRefreshSessions.add(sessionId); } bumpMessageVersion(); @@ -2099,6 +2418,11 @@ export const remoteSessionStore = { sessionRunStatus.delete(sessionId); sessionMakerActivityEpochs.delete(sessionId); sessionMessageSyncMarkers.delete(sessionId); + // 连续性结论随窗口一起失效:rewind 可能删掉中间的行,清空/回收更是整窗重来。 + // 重置为未知,下一次最新窗口同步会重建(见 sessionWindowCoverage)。 + forgetWindowCoverage(sessionId); + // 会话镜像整体回收:它的 `session:` 订阅也随之消失,ACK 记录不能留着给后面的页背书。 + sessionLiveStreamAcked.delete(sessionId); sessionTaskUpdates.delete(sessionId); streamingAssistantClientIds.delete(sessionId); discardPendingTextDelta(sessionId); @@ -2135,6 +2459,8 @@ export const remoteSessionStore = { sessionMakerActivityEpochs.clear(); makerActivityEpoch = 0; sessionMessageSyncMarkers.clear(); + sessionWindowCoverage.clear(); + sessionLiveStreamAcked.clear(); sessionTaskUpdates.clear(); streamingAssistantClientIds.clear(); pendingLiveAssistantClientIds.clear(); diff --git a/packages/maker-shared/package.json b/packages/maker-shared/package.json index c7e7c196fa1..a9c929e661b 100644 --- a/packages/maker-shared/package.json +++ b/packages/maker-shared/package.json @@ -25,6 +25,7 @@ "./file-browser-grid": "./src/fileBrowserGrid.ts", "./file-preview": "./src/filePreview.ts", "./fixtures": "./src/fixtures.ts", + "./history-gap": "./src/historyGap.ts", "./interaction": "./src/interaction.ts", "./math-markdown": "./src/mathMarkdown.ts", "./mention-ref": "./src/mentionRef.ts", diff --git a/packages/maker-shared/src/__tests__/messageNormalize.test.ts b/packages/maker-shared/src/__tests__/messageNormalize.test.ts index 8f41f4d8860..191a8cfe93d 100644 --- a/packages/maker-shared/src/__tests__/messageNormalize.test.ts +++ b/packages/maker-shared/src/__tests__/messageNormalize.test.ts @@ -115,6 +115,64 @@ describe('message normalize shared model', () => { expect(pairing.resultContentFor(sorted[0], parseMessageToolUse(sorted[0]))).toBe('legacy file contents'); }); + it('结束时刻只认 toolUseId 精确配对,不吃邻接兜底', () => { + // 回归(#1210 review):邻接兜底按"紧邻的下一条 tool_result"猜归属,在稀疏窗口里会猜错 —— + // 孤岛窗口的旧段末尾 tool_use 恰好与新段开头某条不相关的 tool_result 相邻时,若结束时刻也 + // 吃这份兜底,旧工具的 settledAt 会变成几小时后那条结果的时刻,渲染层 tool_group 的结束锚点 + // 被推过去,真实的窗口空洞不再触发切组 —— 兜底正好在最需要它的场景失效。 + // 内容与 settled 状态照旧走邻接(猜错最坏是显示串了,live 中按 id 到达即自愈);时刻不猜。 + const sorted = sortMessagesByCreatedAt([ + message({ + id: 'island-tail-tool', + role: 'tool_use', + content: { toolName: 'Read', input: { file_path: '/repo/a.ts' } }, + createdAt: '2026-07-31T06:00:00.000Z', + }), + message({ + id: 'next-island-result', + role: 'tool_result', + content: 'unrelated result from two hours later', + createdAt: '2026-07-31T08:20:00.000Z', + }), + ]); + + const pairing = buildMessageToolResultPairing(sorted); + const tool = parseMessageToolUse(sorted[0]); + expect(pairing.resultContentFor(sorted[0], tool)).toBe('unrelated result from two hours later'); + expect(pairing.hasResultFor(sorted[0], tool)).toBe(true); + expect(pairing.resultCreatedAtFor(sorted[0], tool)).toBeUndefined(); + }); + + it('结束时刻按 toolUseId 命中时取最晚一条结果', () => { + const sorted = sortMessagesByCreatedAt([ + message({ + id: 'paired-tool', + role: 'tool_use', + toolUseId: 'tu_end', + content: { toolUseId: 'tu_end', toolName: 'Bash', input: { command: 'sleep' } }, + createdAt: '2026-07-31T06:00:00.000Z', + }), + message({ + id: 'paired-result-early', + role: 'tool_result', + toolUseId: 'tu_end', + content: 'partial', + createdAt: '2026-07-31T06:10:00.000Z', + }), + message({ + id: 'paired-result-late', + role: 'tool_result', + toolUseId: 'tu_end', + content: 'done', + createdAt: '2026-07-31T06:20:00.000Z', + }), + ]); + + const pairing = buildMessageToolResultPairing(sorted); + expect(pairing.resultCreatedAtFor(sorted[0], parseMessageToolUse(sorted[0]))) + .toBe('2026-07-31T06:20:00.000Z'); + }); + it('suppresses empty Orca communication results but keeps user-facing details', () => { const sorted = sortMessagesByCreatedAt([ message({ diff --git a/packages/maker-shared/src/__tests__/messageRenderHistoryGap.test.ts b/packages/maker-shared/src/__tests__/messageRenderHistoryGap.test.ts new file mode 100644 index 00000000000..368019d7121 --- /dev/null +++ b/packages/maker-shared/src/__tests__/messageRenderHistoryGap.test.ts @@ -0,0 +1,224 @@ +/** + * 回归:历史窗口空洞不得被折进同一个「已工作 Xs」。 + * + * 手机端 2026-07-31 实测:会话窗口由"冷开缓存的首段"+"最新页的尾段"拼成(中间 400 余行从未 + * 加载),渲染出来只剩 3 个 item —— 首条 user、一条「已工作 142m 32s」、最后一条回复。那条组 + * 吞掉了中间 6 轮对话,时长也从"某一段真实工作"谎报成整场会话的跨度。桌面早有 + * HISTORY_GAP_SPLIT_MS 守卫(groupWorkRuns + tool_segment 双层切分),手机走的这份共享分组 + * 一直没有,本组测试锁住补上的行为。 + */ +import { describe, expect, it } from 'vitest'; + +import { HISTORY_GAP_SPLIT_MS } from '../historyGap.js'; +import { + buildMessageRenderItems, + type MessageRenderItem, + type MessageRenderNormalizedMessage, + type MessageRenderSourceMessageLike, +} from '../messageRender.js'; + +type GapFixtureSource = MessageRenderSourceMessageLike & { + id: string; + clientId: string; + content: unknown; + createdAt: string; +}; + +type GapFixtureMessage = MessageRenderNormalizedMessage; + +const BASE_MS = Date.parse('2026-07-31T06:00:00.000Z'); + +function at(minutes: number): string { + return new Date(BASE_MS + minutes * 60_000).toISOString(); +} + +function message( + kind: GapFixtureMessage['kind'], + id: string, + minutes: number, + extra: { body?: string; content?: unknown; settledAt?: string } = {}, +): GapFixtureMessage { + const source: GapFixtureSource = { + id, + clientId: id, + content: extra.content ?? 'text', + createdAt: at(minutes), + }; + return { + key: id, + source, + kind, + label: kind, + body: extra.body ?? '', + createdAt: source.createdAt, + ...(extra.settledAt !== undefined ? { settledAt: extra.settledAt } : {}), + }; +} + +function toolItem(id: string, minutes: number, settledAtMinutes?: number): GapFixtureMessage { + return message('tool', id, minutes, { + body: `Read(${id})`, + content: { toolName: 'Read', input: {} }, + ...(settledAtMinutes !== undefined ? { settledAt: at(settledAtMinutes) } : {}), + }); +} + +/** thinking 的时长由 source.content.durationMs 解析(见 parseThinking),不是 normalized 字段。 */ +function thinkingItem(id: string, minutes: number, durationMs: number): GapFixtureMessage { + return message('thinking', id, minutes, { + body: 'thinking', + content: { thinking: 'thinking', durationMs }, + }); +} + +function assistantItem(id: string, minutes: number, body: string): GapFixtureMessage { + return message('assistant', id, minutes, { body }); +} + +function userItem(id: string, minutes: number, body: string): GapFixtureMessage { + return message('user', id, minutes, { body }); +} + +function typesOf(items: readonly MessageRenderItem[]): string[] { + return items.map((item) => item.type); +} + +/** 每个工作组内 tool_group 的 tool id 列表(按组、按组内顺序)。 */ +function toolIdsOf(items: readonly MessageRenderItem[]): string[][] { + const out: string[][] = []; + for (const item of items) { + if (item.type !== 'work_group') continue; + for (const child of item.children) { + if (child.type !== 'tool_group') continue; + out.push(child.tools.map((toolMessage) => toolMessage.source.id)); + } + } + return out; +} + +function groupDurations(items: readonly MessageRenderItem[]): (number | undefined)[] { + return items + .filter((item): item is Extract, { type: 'work_group' }> => + item.type === 'work_group') + .map((group) => group.durationMs); +} + +describe('工作组分组 — 历史窗口空洞', () => { + it('跨空洞的动作切成两组,时长不再横跨空洞', () => { + // 首段(06:00~06:02)+ 尾段(08:20~08:22):中间两小时的行从未加载,连 user 边界一起缺席。 + const items = buildMessageRenderItems([ + toolItem('head-tool', 0, 0), + thinkingItem('head-thinking', 1, 5_000), + toolItem('tail-tool', 140, 140), + assistantItem('tail-answer', 142, '最终回复'), + ]); + + // 修复前:['work_group','message'],那条组的 durationMs = 142 分钟(整场跨度)。 + expect(typesOf(items)).toEqual(['work_group', 'work_group', 'message']); + const durations = groupDurations(items); + expect(durations).toHaveLength(2); + for (const duration of durations) { + expect(duration ?? 0).toBeLessThan(HISTORY_GAP_SPLIT_MS); + } + }); + + it('空洞正好落在两次工具调用之间时,tool_group 也切开', () => { + const items = buildMessageRenderItems([ + toolItem('tool-a', 0, 0), + toolItem('tool-b', 1, 1), + toolItem('tool-c', 140, 140), + assistantItem('answer', 141, '最终回复'), + ]); + + // 空洞两侧的调用没有被并进同一个 tool_group:组首尾时间差会直接变成跨空洞的假时长, + // 而工作组分组只看组首时间、发现不了组内部的跳变。 + expect(typesOf(items)).toEqual(['work_group', 'work_group', 'message']); + expect(toolIdsOf(items)).toEqual([['tool-a', 'tool-b'], ['tool-c']]); + }); + + it('一次跑了 40 分钟的工具调用不算空洞(锚点取结果落库时刻)', () => { + // 调用 06:00 发起、06:40 才回结果,紧随其后的下一个动作与**结果**只隔 1 分钟。 + // 只看调用发起时刻会把它误判成空洞,把一段连续工作切碎。 + const items = buildMessageRenderItems([ + toolItem('slow-tool', 0, 40), + toolItem('next-tool', 41, 41), + assistantItem('answer', 42, '最终回复'), + ]); + + expect(typesOf(items)).toEqual(['work_group', 'message']); + }); + + it('想了 40 分钟的 thinking 块不算空洞(锚点加上时长)', () => { + const items = buildMessageRenderItems([ + thinkingItem('long-thinking', 0, 40 * 60_000), + toolItem('after-thinking', 41, 41), + assistantItem('answer', 42, '最终回复'), + ]); + + expect(typesOf(items)).toEqual(['work_group', 'message']); + }); + + it('空洞前那一段的时长按动作结束时刻结算,不低报', () => { + // 空洞前的段永远没有 nextItem 可作结算边界,退回段内锚点时若取组内第一条调用的**开始** + // 时间,一个 20 分钟后才回结果的单工具段会显示约 1 秒 —— 空洞不再产生超大时长,却换成了 + // 同样离谱的低报(#1210 review)。 + const items = buildMessageRenderItems([ + toolItem('slow-tool', 0, 20), + // ↓ 空洞:与上一段的结束(06:20)相隔 2 小时 + toolItem('tail-tool', 140, 140), + assistantItem('answer', 141, '最终回复'), + ]); + + expect(typesOf(items)).toEqual(['work_group', 'work_group', 'message']); + const [headDuration] = groupDurations(items); + expect(headDuration).toBe(20 * 60_000); + }); + + it('无 nextItem 时组时长取全体子项结束时刻的最大值,不是最后一个子项', () => { + // 子项按**发起**时刻排序,但并行动作会乱序完成:想了 40 分钟的 thinking 排在前,紧随其后 + // 2 分钟就结束的工具排在后。取"最后一个子项的结束时刻"会把 40 分钟丢掉(#1210 review)。 + const items = buildMessageRenderItems([ + thinkingItem('long-thinking', 0, 40 * 60_000), + toolItem('quick-tool', 5, 7), + // ↓ 空洞:让这一段没有 nextItem 可作结算边界 + toolItem('tail-tool', 140, 140), + assistantItem('answer', 141, '最终回复'), + ]); + + expect(typesOf(items)).toEqual(['work_group', 'work_group', 'message']); + const [headDuration] = groupDurations(items); + expect(headDuration).toBe(40 * 60_000); + }); + + it('缺 settledAt 的工具行按调用发起时刻算结束,空洞照常切开', () => { + // 与 messageNormalize 那条「结束时刻只认 toolUseId 精确配对」配套:归属不确定时 settledAt + // 缺失,这里退回调用发起时刻。代价只是可能多切一个折叠条;反过来(吃邻接兜底猜出来的时刻) + // 会把结束锚点推到几小时后,真实空洞不再触发切组 —— 渲染兜底正好在最需要它的场景失效 + // (#1210 review)。 + const items = buildMessageRenderItems([ + // 没有 settledAt 的工具行(归属不确定,上游刻意不给时刻) + toolItem('no-settled-tool', 0), + // ↓ 空洞另一侧:窗口里"相邻",实际属于两小时后的另一段工作 + toolItem('tail-tool', 140, 140), + assistantItem('answer', 141, '最终回复'), + ]); + + expect(typesOf(items)).toEqual(['work_group', 'work_group', 'message']); + }); + + it('窗口连续时分组不变(user 行照常是唯一边界)', () => { + const items = buildMessageRenderItems([ + userItem('user-1', 0, '第一问'), + toolItem('tool-1', 1, 1), + assistantItem('answer-1', 2, '第一答'), + userItem('user-2', 3, '第二问'), + toolItem('tool-2', 4, 4), + assistantItem('answer-2', 5, '第二答'), + ]); + + expect(typesOf(items)).toEqual([ + 'message', 'work_group', 'message', + 'message', 'work_group', 'message', + ]); + }); +}); diff --git a/packages/maker-shared/src/historyGap.ts b/packages/maker-shared/src/historyGap.ts new file mode 100644 index 00000000000..4218e27e13e --- /dev/null +++ b/packages/maker-shared/src/historyGap.ts @@ -0,0 +1,23 @@ +/** + * 历史窗口空洞的判定阈值 —— 桌面与手机共用的单一来源。 + * + * 消费方: + * - 桌面 `apps/desktop/src/renderer/lib/historyGap.ts`(re-export)→ `MessageStream` + * 按它切 tool_segment、切工作组; + * - 手机 `apps/mobile/src/session/historyWindowGap.ts` 按它找窗口空洞并触发补齐; + * - 共享渲染 `messageRender.ts` 按它切 tool_group 与工作组(手机侧的分组实现)。 + * + * 为什么是 30 分钟:分页窗口之间可能隔着大段没加载的历史(缓存旧页 + 最新页拼接、 + * 断连期间漏收 push、补齐失败退回的孤岛窗口)。渲染层看到的是两段"相邻"item,中间的 + * user 行(唯一的 turn 边界)全部缺席,于是跨越空洞的所有动作被折成同一个「已工作 Xs」: + * 桌面实测出现过一条组吞掉 47 小时、40 条 user 消息;手机端 2026-07-31 实测一条 + * 「已工作 142m 32s」吞掉整场会话的 6 轮对话(会话首段 + 尾段拼接,中间 400 余行未加载)。 + * + * 单个 turn 内相邻动作(工具调用 / thinking)正常在秒级到分钟级,等长任务最多几十分钟; + * 真被误切也只是多出一个折叠条,代价远小于把不相干的两段并成一条并谎报时长。 + * + * 为什么放在 maker-shared:它是一条产品级阈值(多久算"历史不连续"),两端的渲染分组与 + * 手机端的窗口补齐都要按同一把尺子判断,分成两份复制迟早漂移。包内零 React/Electron/Expo + * 依赖,符合 `docs/dev-rules/architecture-invariants.md` 的依赖方向。 + */ +export const HISTORY_GAP_SPLIT_MS = 30 * 60 * 1000; diff --git a/packages/maker-shared/src/messageNormalize.ts b/packages/maker-shared/src/messageNormalize.ts index fb640c131c8..e7de7edcb7d 100644 --- a/packages/maker-shared/src/messageNormalize.ts +++ b/packages/maker-shared/src/messageNormalize.ts @@ -26,6 +26,22 @@ export interface MessageToolResultPairing< * (running/done)必须计入,否则这类行会永久显示进行中。 */ hasResultFor(message: TMessage, tool: MessageNormalizeToolUse): boolean; + /** + * 配对 tool_result 的落库时刻(ISO),即这次工具调用的**结束**时刻;未到达时 undefined。 + * + * 两条刻意的口径: + * - **不过 `shouldHideToolResult` 滤网**:结束时间与内容是否展示无关(桌面 MessageStream 的 + * resultTsMap 同款)。渲染层用它做历史空洞判定的锚点 —— 只看调用发起时刻会把「一次跑了 + * 半小时以上的调用」之后的下一个动作误判成空洞。 + * - **只认 `toolUseId` 精确配对,不吃邻接兜底**(与 `resultContentFor` 的差别)。邻接兜底是按 + * "紧邻的下一条 tool_result"猜归属,在稀疏窗口里会猜错:孤岛窗口的旧段末尾 tool_use 恰好 + * 与新段开头某条不相关的 tool_result 相邻时,兜底会把新段结果的时刻当成旧工具的结束时刻, + * 于是 `tool_group` 的结束锚点被推到几小时后,真实的窗口空洞**不再触发切组** —— 渲染兜底 + * 正好在最需要它的场景失效(#1210 review)。内容可以猜错(最坏是显示串了,live 中 result 按 + * id 到达即自愈),归属不确定的**时刻**不能进判据。缺失时上游退回调用发起时刻,代价只是 + * 可能多切一个折叠条。 + */ + resultCreatedAtFor(message: TMessage, tool: MessageNormalizeToolUse): string | undefined; } export interface MessageToolResultPairingOptions { @@ -79,12 +95,17 @@ export function buildMessageToolResultPairing< // settled 集合独立于内容 map:即使结果被 shouldHideToolResult 隐藏,工具也已完成。 const settledToolUseIds = new Set(); + // 结束时刻同样独立于内容 map(隐藏的结果也是结束信号,见 resultCreatedAtFor)。 + const resultCreatedAtByToolUseId = new Map(); for (const message of sortedMessages) { if (message.role !== 'tool_result') continue; const toolUseId = readToolResultUseId(message); if (!toolUseId) continue; settledToolUseIds.add(toolUseId); + const resultCreatedAt = readNonEmptyString(message.createdAt); + // 同一 toolUseId 多条结果时取最晚一条:sortedMessages 已按时间升序,后写覆盖即最晚。 + if (resultCreatedAt) resultCreatedAtByToolUseId.set(toolUseId, resultCreatedAt); const content = contentToPreview(message.content); if (shouldHideToolResult(toolNameByUseId.get(toolUseId) ?? '', content)) continue; resultByToolUseId.set(toolUseId, content); @@ -128,6 +149,12 @@ export function buildMessageToolResultPairing< if (tool.toolUseId && settledToolUseIds.has(tool.toolUseId)) return true; return settledAdjacencyMessageKeys.has(messageNormalizeKey(message)); }, + resultCreatedAtFor(_message, tool) { + // 只按 toolUseId 精确配对 —— 邻接兜底猜出来的归属不足以支撑"结束时刻"这个判据,理由见 + // 接口上的说明。 + if (!tool.toolUseId) return undefined; + return resultCreatedAtByToolUseId.get(tool.toolUseId); + }, }; } diff --git a/packages/maker-shared/src/messageRender.ts b/packages/maker-shared/src/messageRender.ts index a2ca59826b9..da57fe81c08 100644 --- a/packages/maker-shared/src/messageRender.ts +++ b/packages/maker-shared/src/messageRender.ts @@ -3,6 +3,7 @@ import { findAgentTaskUpdate, isAgentTaskToolName, } from './agentTask'; +import { HISTORY_GAP_SPLIT_MS } from './historyGap'; export interface MessageRenderSourceMessageLike { id?: string | null; @@ -47,6 +48,14 @@ export interface MessageRenderNormalizedMessage< secondaryBody?: string; createdAt: string; isStreaming?: boolean; + /** + * tool 消息专用:配对 tool_result 的落库时刻(ISO)。`createdAt` 是**调用发起**时刻, + * 单靠它无法知道一次工具调用什么时候结束 —— 于是一个跑了半小时以上的调用(长 Bash、 + * 子 agent)后面紧跟的下一个调用会被空洞判定误伤,把一段连续工作切碎。空洞锚点优先取 + * 本字段,与桌面 `MessageStream` 的 resultTsMap 同口径。缺失(结果未到 / 老数据)时退回 + * `createdAt`。 + */ + settledAt?: string; /** Host 在 SDK done 边界写入;每个 true 都是一条不应折入工作过程的正式回复。 */ turnCompleted?: boolean; /** tool 消息专用:配对 tool_result 提取出的产出媒体(驱动 tool_media 独立渲染项)。 */ @@ -219,8 +228,18 @@ function buildLinearItems< // agent_task card, so the orphan-update sweep below doesn't render the same task twice. const renderedTaskKeys = new Set(); let pendingTools: TMessage[] = []; + // 段内已见过的最晚**结束**时刻(调用发起 / 结果落库取最大值),空洞判定的锚点。 + // 不能只比紧邻的上一条:并行工具会乱序完成(A 跑 40 分钟还没回,B 紧随其后一分钟就结束, + // 这时又发起 C),只比 B 的早结束时间会把 C 误判成空洞、把一段连续工作切碎 + // (与桌面 MessageStream 的 pendingSegmentEndMs 同口径)。 + let pendingToolsEndMs: number | null = null; + const notePendingToolEnd = (ms: number | null) => { + if (ms === null) return; + pendingToolsEndMs = pendingToolsEndMs === null ? ms : Math.max(pendingToolsEndMs, ms); + }; const flushTools = () => { + pendingToolsEndMs = null; if (pendingTools.length === 0) return; items.push({ type: 'tool_group', @@ -280,7 +299,22 @@ function buildLinearItems< } continue; } + // 历史窗口空洞可能正好落在两次工具调用之间(缺的是 user 行):那样两段窗口的调用会被 + // 合进同一个 tool_group,组首尾时间差直接成了跨空洞的假时长,而工作组分组只看组首时间、 + // 发现不了组内部的跳变。所以段内也按同一阈值切开,让「已工作 Xs」的时长和分组都落在 + // 真实连续的动作上(对齐桌面 MessageStream 的段内切分)。 + const callMs = parseTimestampMs(message.createdAt); + if ( + pendingTools.length > 0 + && pendingToolsEndMs !== null + && callMs !== null + && callMs - pendingToolsEndMs > HISTORY_GAP_SPLIT_MS + ) { + flushTools(); + } pendingTools.push(message); + notePendingToolEnd(callMs); + notePendingToolEnd(parseTimestampMs(message.settledAt)); continue; } @@ -844,13 +878,37 @@ function groupMessageWorkRuns< currentTurn = []; }; + // 空洞判定的锚点:上一个 item 的**结束**时间(见 itemEndTimestamp)。用开始时间会让一个 + // 正常的长时段工具组/thinking 把紧随其后的 item 误判成空洞。取已见过的最大值而非无条件 + // 覆盖:并行的 Agent/Task 可能乱序完成,锚点回退会让后面的最终答复被误切、时长被低报。 + // 无时间戳的 item 不重置锚点,让间隔判定跨过它继续比对上一个有时间的动作。 + let prevEndMs: number | null = null; + const noteEnd = (item: MessageRenderItem) => { + const endMs = itemEndTimestamp(item); + if (endMs === null) return; + prevEndMs = prevEndMs === null ? endMs : Math.max(prevEndMs, endMs); + }; + for (const item of items) { if (item.type === 'message' && item.message.kind === 'user') { flushTurn(false); out.push(item); + noteEnd(item); continue; } + // 窗口空洞:user 行是唯一的 turn 边界,窗口里缺了它,两段不相干的历史就会被折进同一个 + // 「已工作 Xs」并谎报时长(手机端实测一条组吞掉整场会话的 6 轮对话)。相邻动作间隔超过 + // 阈值时同样切断 —— 见 HISTORY_GAP_SPLIT_MS 的完整理由。 + const startMs = itemTimestamp(item); + if ( + prevEndMs !== null + && startMs !== null + && startMs - prevEndMs > HISTORY_GAP_SPLIT_MS + ) { + flushTurn(false); + } currentTurn.push(item); + noteEnd(item); } flushTurn(true); return out; @@ -1183,17 +1241,27 @@ function createCompletedWorkGroup( run: readonly MessageRenderWorkChildItem[], ): number | null { - for (let index = run.length - 1; index >= 0; index--) { - const item = run[index]; - const start = itemTimestamp(item); - if (start === null) continue; - if (item.type === 'thinking') return start + (item.durationMs ?? 0); - return start; + let latest: number | null = null; + for (const item of run) { + latest = maxTimestamp(latest, itemEndTimestamp(item)); } - return null; + return latest; } export function formatDuration(ms: number): string { @@ -1222,6 +1290,68 @@ function itemCreatedAt< return item.message.createdAt; } +/** + * item 的**结束**时刻,空洞判定的锚点(口径与桌面 `renderItemEndMs` 一致): + * + * - tool_group / tool_media:组内全部调用与结果时刻的最大值(结果时刻见 `settledAt`); + * - agent_task:live update 的 updatedAt → createdAt → 调用发起时刻,再与配对结果时刻取更晚 + * (历史会话没有 live update,只有结果时刻才是这张卡真正的结束); + * - thinking:createdAt 是块**开始**的时刻,要加上时长 —— 一个想了半小时以上的 thinking 块 + * 后面紧跟工具或正文时,只看 createdAt 会把它误判成空洞、切开一个本来连续的 turn; + * - 其余:退回开始时刻。 + */ +function itemEndTimestamp< + TMessage extends MessageRenderNormalizedMessage, +>(item: MessageRenderItem): number | null { + if (item.type === 'tool_group' || item.type === 'tool_media') { + let end: number | null = null; + for (const tool of item.tools) { + end = maxTimestamp(end, parseTimestampMs(tool.createdAt)); + end = maxTimestamp(end, parseTimestampMs(tool.settledAt)); + } + return end ?? itemTimestamp(item); + } + if (item.type === 'agent_task') { + const liveEnd = parseTimestampMs( + item.update?.updatedAt ?? item.update?.createdAt ?? item.toolCall?.createdAt, + ) ?? itemTimestamp(item); + return maxTimestamp(liveEnd, parseTimestampMs(item.toolCall?.settledAt)); + } + if (item.type === 'work_group') { + // 全量取 max,不是"最后一个 child":children 按**发起**时刻排列,并行动作乱序完成时真正的 + // 结束时刻可能落在更靠前的 child 上(先发起、更晚 settle)。取最后一个会低估组的结束时间, + // 于是空洞判定的锚点变小、把本来连续的 turn 误判成空洞切开(#1210 review)。与 + // `workRunFallbackEnd` / `groupMessageWorkRuns` 的锚点同一口径。 + let latest: number | null = null; + for (const child of item.children) { + latest = maxTimestamp(latest, itemEndTimestamp(child)); + } + return latest; + } + const start = itemTimestamp(item); + if (item.type === 'thinking' && start !== null) { + // durationMs 可能是负数 / 非有限值(上游同样做了夹断防御)。不夹断会得出 end < start, + // 空洞判定与工作组时长都跟着错。 + const durationMs = item.durationMs; + return start + (typeof durationMs === 'number' && Number.isFinite(durationMs) + ? Math.max(0, durationMs) + : 0); + } + return start; +} + +function maxTimestamp(a: number | null, b: number | null): number | null { + if (a === null) return b; + if (b === null) return a; + return Math.max(a, b); +} + +function parseTimestampMs(createdAt: string | null | undefined): number | null { + if (!createdAt) return null; + const timestamp = Date.parse(createdAt); + return Number.isFinite(timestamp) ? timestamp : null; +} + function workChildKey< TMessage extends MessageRenderNormalizedMessage, >(item: MessageRenderWorkChildItem): string {