From 18d2b0184ef631cccdbd66b1cc035b0d6d2ed008 Mon Sep 17 00:00:00 2001 From: Dash Date: Fri, 31 Jul 2026 17:58:05 +0800 Subject: [PATCH 01/12] =?UTF-8?q?fix(mobile):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E7=AA=97=E5=8F=A3=E7=A9=BA=E6=B4=9E,?= =?UTF-8?q?=E8=B7=A8=E7=A9=BA=E6=B4=9E=E4=B8=8D=E5=86=8D=E6=8A=98=E6=88=90?= =?UTF-8?q?=E4=B8=80=E6=9D=A1=E5=B7=A5=E4=BD=9C=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 手机端的会话窗口由冷开缓存的旧页、listMessages 的最新页与 push 尾部拼成,几者 之间没有连续性保证:断连期间漏收的 push 不会补回,setLatestMessageWindow 只要求 旧页与最新页**有交集**就整段保留。于是窗口会出现「首段 + 尾段」的孤岛,中间几百 行从未加载;而手机端走的那份共享渲染分组一直缺桌面的 HISTORY_GAP_SPLIT_MS 守卫, 跨空洞的动作被折进同一个「已工作 Xs」——实测一条组吞掉整场会话的 6 轮对话,时长 从某段真实工作谎报成整场跨度 142m 32s,界面上就是"中间掉了一大段"。 两层一起修: - 阈值上移:新增 packages/maker-shared/src/historyGap.ts 作两端共用正本, desktop 侧 lib/historyGap.ts 改为 re-export,既有引用与文档指向不变。 - 渲染兜底:共享 messageRender 的工作组分组与 tool_group 段内双层按阈值切开 (对齐桌面 MessageStream)。锚点取**结束**时刻并取已见最大值 —— 新增 settledAt (配对 tool_result 的落库时刻,由 shared pairing 的 resultCreatedAtFor 提供、 mobile normalize 填充),所以一次跑 40 分钟的工具、想 40 分钟的 thinking、以及 并行工具乱序完成都不会被误切。 - 窗口自愈:新增 apps/mobile/src/session/historyWindowGap.ts,找最靠尾部的跳变后 先花一次 limit=1 探测确认服务端两行是否本来就相邻(正常的隔夜会话不白翻页), 确认有洞才沿既有 before 游标补齐,预算 400 行 / 12 请求。会话屏幕侧只在窗口 对账后动手,与「加载更早」互斥,每处空洞只试一次,会话切走或锚点行被 /clear、rewind 拿掉即收手。全程复用 local-db:messages:list,不触碰 device-link wire protocol,也不改 mobile runtime fingerprint。 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Dash --- apps/desktop/src/renderer/lib/historyGap.ts | 20 +- apps/mobile/app/sessions/[sessionId].tsx | 74 ++++++ .../historyWindowBackfillWiring.test.ts | 50 ++++ .../src/__tests__/historyWindowGap.test.ts | 216 ++++++++++++++++++ .../src/__tests__/messageRenderModel.test.ts | 29 +++ apps/mobile/src/session/historyWindowGap.ts | 182 +++++++++++++++ apps/mobile/src/session/messageNormalize.ts | 7 + packages/maker-shared/package.json | 1 + .../__tests__/messageRenderHistoryGap.test.ts | 176 ++++++++++++++ packages/maker-shared/src/historyGap.ts | 23 ++ packages/maker-shared/src/messageNormalize.ts | 25 ++ packages/maker-shared/src/messageRender.ts | 116 ++++++++++ 12 files changed, 905 insertions(+), 14 deletions(-) create mode 100644 apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts create mode 100644 apps/mobile/src/__tests__/historyWindowGap.test.ts create mode 100644 apps/mobile/src/session/historyWindowGap.ts create mode 100644 packages/maker-shared/src/__tests__/messageRenderHistoryGap.test.ts create mode 100644 packages/maker-shared/src/historyGap.ts 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..524e8a352ff 100644 --- a/apps/mobile/app/sessions/[sessionId].tsx +++ b/apps/mobile/app/sessions/[sessionId].tsx @@ -370,6 +370,12 @@ import { shouldRefreshLatestMessageWindowOnReopen, shouldKeepOlderMessagesAffordance, } from '@/session/messagePaging'; +import { + HISTORY_GAP_PROBE_LIMIT, + backfillHistoryWindowGap, + findHistoryWindowGap, + historyWindowGapKey, +} from '@/session/historyWindowGap'; import { buildMobileMessageRenderItems, insertMobileForkOriginItem, @@ -3806,6 +3812,74 @@ export default function SessionScreen() { } }, [deviceId, hasOlderMessages, loadingEarlier, maker, messages, sessionId]); + /** + * 历史窗口空洞的自动补齐(见 `historyWindowGap.ts` 的文件头)。 + * + * 缓存旧页 + 最新页拼接、断连期间漏收 push,都会让窗口出现"首段 + 尾段"的孤岛,中间几百行 + * 从未加载 —— 手机上看起来就是"中间掉了一大段"。这里在窗口就位后检测最靠尾部的一处跳变, + * 先花一次 `limit=1` 探测确认服务端两行是否本来就相邻(正常的隔夜会话不该白翻页),确认有洞 + * 才沿 `before` 游标补齐。 + * + * 后台跑、不阻塞首屏,也不写 `error` / `loadingEarlier`:补齐是静默自愈,失败时渲染层的 + * `HISTORY_GAP_SPLIT_MS` 守卫兜底(不谎报时长),用户仍可用「加载更早」自己往上翻。 + */ + const backfillAttemptedGapsRef = useRef<{ sid: string; keys: Set } | null>(null); + const backfillInFlightRef = useRef(false); + // 当前屏幕的会话 id 镜像:补齐是后台异步的,不能靠 effect 闭包里的 sessionId 判断"是否已切走" + // (那个值恒等于启动时的值)。本 effect 声明在补齐 effect **之前**,切会话时同一 commit 里先 + // 更新镜像,飞行中的补齐随即在下一次 isCancelled 上收手。 + const backfillSessionRef = useRef(sessionId); + useEffect(() => { + backfillSessionRef.current = sessionId; + }, [sessionId]); + useEffect(() => { + // 窗口尚未与被控端对账过(lastSyncedAt===null)时不动手:此刻 messages 可能只是冷开缓存, + // 首屏那次 listMessages 马上会把窗口替换掉,基于旧快照找的空洞随即失效。 + if (!deviceId || !sessionId || lastSyncedAt === null) return; + // 与「加载更早」互斥:两者都按 before 游标翻页,同时跑只会让窗口反复 merge、白拉页。 + if (loading || loadingEarlier || backfillInFlightRef.current) return; + const gap = findHistoryWindowGap(messages); + if (!gap) return; + // 每处空洞只尝试一次(判定为真安静、超预算或失败都算尝试过),否则每次 messages 变化都会 + // 重新发请求。换会话时连同 sid 一起重置。 + const attempted = backfillAttemptedGapsRef.current?.sid === sessionId + ? backfillAttemptedGapsRef.current + : { sid: sessionId, keys: new Set() }; + backfillAttemptedGapsRef.current = attempted; + const gapKey = historyWindowGapKey(gap); + if (attempted.keys.has(gapKey)) return; + // 每次打开最多起 3 轮补齐。一处补到预算上限而没连上时,窗口里会露出一处**新的**跳变 + // (锚点换成刚拉到的最旧行),key 不同 → 下次 messages 变化时可以再起一轮;不设总闸就成了 + // 一路往上翻整场历史。注意这是**上限**不是节奏:补齐自己的 merge 触发的那次重跑会被 + // in-flight 挡住,所以一次打开通常只补一处,余下的等新消息到达或用户重开会话。 + if (attempted.keys.size >= 3) return; + attempted.keys.add(gapKey); + backfillInFlightRef.current = true; + const sessionIdAtStart = sessionId; + 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); + }, + // 两个收手条件: + // - 会话已切走 —— 补进来的行属于另一个屏幕的窗口; + // - 空洞较新一侧那行已不在窗口里 —— /clear、rewind 或整窗替换把它拿掉了,继续 merge 会 + // 把刚被移除的历史(甚至 clearedAt 之前的消息)塞回窗口。锚点没了就等于这处空洞不存在了。 + isCancelled: () => backfillSessionRef.current !== sessionIdAtStart + || !remoteSessionStore.getMessages(sessionIdAtStart).some((row) => row.id === gap.newerId), + }).finally(() => { + backfillInFlightRef.current = false; + }); + }, [deviceId, lastSyncedAt, loading, loadingEarlier, maker, messages, sessionId]); + const selectSlashCommand = useCallback((command: MobileSlashCommand) => { // 点选 agent-skill 时记录名字+会话 id:palette 关闭后 slashCommands 被清,发送侧 // 凭此 ref 识别「用户明确选中的 skill」;sid 绑定防止切换会话后旧点选残留。 diff --git a/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts b/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts new file mode 100644 index 00000000000..9aaa7d2e52a --- /dev/null +++ b/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts @@ -0,0 +1,50 @@ +/** + * 空洞补齐在会话屏幕上的接线守卫。 + * + * 补齐算法本身在 `historyWindowGap.test.ts` 有行为测试;这里锁住屏幕侧那几个"删掉也照样跑、 + * 但会悄悄踩坑"的前置条件 —— 它们各自对应一个具体故障: + * - 窗口没与被控端对账过就动手 → 基于冷开缓存快照找的空洞随即被整窗替换作废,白发请求; + * - 不与「加载更早」/ 自身飞行互斥 → 两条 before 游标并发翻页,窗口反复 merge; + * - 每处空洞不去重、不设总闸 → messages 每变一次就重新发请求,一路往上翻整场历史; + * - isCancelled 只比 effect 闭包里的 sessionId → 那个值恒等于启动时的值,等于没有取消; + * - 不检查锚点行是否还在窗口 → /clear、rewind 之后把刚被移除的历史 merge 回去。 + */ +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('只在窗口已与被控端对账后动手,并与加载中/加载更早/自身飞行互斥', () => { + expect(source).toContain('if (!deviceId || !sessionId || lastSyncedAt === null) return;'); + expect(source).toContain('if (loading || loadingEarlier || backfillInFlightRef.current) return;'); + }); + + it('每处空洞只尝试一次,且每次打开设总闸', () => { + expect(source).toContain('if (attempted.keys.has(gapKey)) return;'); + expect(source).toContain('if (attempted.keys.size >= 3) return;'); + // 换会话时连同 sid 一起重置,否则上个会话的已尝试集合会压住新会话的补齐。 + expect(source).toContain('backfillAttemptedGapsRef.current?.sid === sessionId'); + }); + + it('取消判定走会话镜像 ref 与锚点行是否仍在窗口,不是 effect 闭包里的 sessionId', () => { + expect(source).toContain('backfillSessionRef.current = sessionId;'); + expect(source).toContain('isCancelled: () => backfillSessionRef.current !== sessionIdAtStart'); + expect(source).toContain('.some((row) => row.id === gap.newerId)'); + }); + + it('补齐失败不写 error / loadingEarlier:它是静默自愈,不占用户可见的加载态', () => { + const effectStart = source.indexOf('const backfillAttemptedGapsRef'); + 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,'); + }); +}); diff --git a/apps/mobile/src/__tests__/historyWindowGap.test.ts b/apps/mobile/src/__tests__/historyWindowGap.test.ts new file mode 100644 index 00000000000..929e517e08d --- /dev/null +++ b/apps/mobile/src/__tests__/historyWindowGap.test.ts @@ -0,0 +1,216 @@ +/** + * 历史窗口空洞的检测与补齐(见 `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', 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 稳定可用于去重', () => { + const gap = findHistoryWindowGap([row('older', 0), row('newer', 140)]) as HistoryWindowGap; + expect(historyWindowGapKey(gap)).toBe('older→newer'); + }); +}); + +describe('backfillHistoryWindowGap', () => { + const gap: HistoryWindowGap = { newerId: 'tail', olderId: 'head', 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[] = []; + const pages: Record = { + tail: [row('mid-1', 100)], + 'mid-1': [row('mid-2', 60), row('mid-3', 80)], + 'mid-2': [row('head', 2), row('head-2', 4)], + }; + 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-2', 'mid-3', 'head', 'head-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/session/historyWindowGap.ts b/apps/mobile/src/session/historyWindowGap.ts new file mode 100644 index 00000000000..1e00ec69370 --- /dev/null +++ b/apps/mobile/src/session/historyWindowGap.ts @@ -0,0 +1,182 @@ +/** + * 历史窗口空洞的检测与补齐(手机端)。 + * + * ## 空洞是怎么来的 + * + * 手机端的消息窗口不是一次拉全的,它由几个来源拼起来: + * - 冷开时的本地缓存(`mobileSessionMessageCache`,上次看到的那一页); + * - `listMessages` 的最新窗口(`MESSAGE_PAGE_SIZE` 条,payload 超限时还会降级到更少); + * - `local-db:messages:created` push 逐条追加的尾部。 + * + * 这些来源之间没有"必须连续"的保证:app 退到后台 / 网络抖动 / relay 断连期间漏收的 push + * 不会补回来,而 `setLatestMessageWindow` 只要求缓存旧页与最新页**有交集**就整段保留。于是 + * store 里会出现"首段 + 尾段"这种孤岛窗口,中间几百行从未加载。 + * + * 渲染层看到的是两段"相邻"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'; + +/** + * 一次补齐最多拉回的行数。 + * + * 对照的是**本次补齐新增的行**,不是窗口总行数:手机内存与列表挂载树都比桌面紧,桌面的 + * `JUMP_BACKFILL_MAX_ITEMS`(600)在这里偏激进。400 行 ≈ 5 个满页,足以覆盖"看了个开头就切走、 + * 两小时后回来"这类真实空洞(实测那场 445 行的会话,空洞两侧相隔约 420 行);超出仍未连上时 + * 交给渲染层的空洞守卫兜底,并保留「加载更早」入口让用户自己往上翻。 + */ +export const HISTORY_BACKFILL_MAX_ROWS = 400; + +/** + * 请求次数上限,与行数预算分开计。 + * + * 不能只按行数算预算:被控端结果帧超限时 `listMessagesWithPayloadRetry` 会一路降 limit, + * device-link 侧还会静默裁行(`remoteRowsTrimmed`),那种分片每次只带回几行。若与行数共用一个 + * 计数器,这类会话会在远未取到 400 行时就发出几十个请求。12 次是"5 个满页 + 若干降级页"的 + * 保守上界,也兼作防死循环兜底。 + */ +export const HISTORY_BACKFILL_MAX_REQUESTS = 12; + +/** 探测两行在服务端是否真的相邻时的页大小(只要一行就够,payload 最小)。 */ +export const HISTORY_GAP_PROBE_LIMIT = 1; + +export interface HistoryWindowGap { + /** 空洞较新一侧那一行的 id —— 向上翻页的 `before` 游标。 */ + newerId: string; + /** 空洞较旧一侧那一行的 id —— 取回它即视为窗口已连上。 */ + olderId: string; + /** 两行的时间差,仅用于日志与测试断言。 */ + gapMs: number; +} + +export type HistoryBackfillOutcome = + /** 已连上:本次翻页真正取回了 `olderId`。 */ + | 'covered' + /** 两行在服务端本来就相邻,不存在空洞(真安静的会话)。 */ + | 'contiguous' + /** 沿 `before` 游标翻到历史起点仍未连上(中间的行已被 rewind 软删 / clear 边界切掉等)。 */ + | 'exhausted' + /** 超出行数或请求数预算,交给渲染层的空洞守卫兜底。 */ + | 'budget' + /** 会话已切走 / 窗口已重置,调用方不得再 merge 本次抓到的行。 */ + | 'cancelled' + /** 请求异常。 */ + | 'failed'; + +export interface HistoryBackfillDeps { + /** + * 按 `before` 游标取一页(实现方负责 payload 降级重试)。返回的行序不限,本模块只按 id 判定。 + */ + listPage(before: string, limit: number): Promise; + /** 把取回的行并入窗口(按 key 合并,不覆盖更完整的既有行)。 */ + merge(rows: readonly RemoteMessage[]): void; + /** 会话切走 / `/clear` / rewind 等让本次补齐失去意义;每次 await 前后都会问一次。 */ + isCancelled(): boolean; +} + +/** + * 找窗口里**最靠尾部**的一处空洞;没有则返回 null。 + * + * 为什么从尾部找而不是从头:补齐是沿 `before` 从新往旧翻页,先补最靠尾部的洞时游标离窗口尾 + * 最近、翻页量最小。这一处补完(或判定为真安静)之后,下一轮检测自然会露出更早的那一处。 + * + * 只看真实 host 行:本地合成的系统卡(`mobile-system-*`,/pwd、/context 等)没有服务端对应行, + * 拿它当 `before` 游标什么都匹配不上,只会白拉一页最新消息。 + */ +export function findHistoryWindowGap(messages: readonly RemoteMessage[]): 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) { + return { newerId: newer.id, olderId: older.id, gapMs }; + } + } + return null; +} + +/** + * 把一处空洞补齐到"窗口连续"。 + * + * 先探测两行是否本来就相邻(见文件头),确认有洞后沿 `before` 游标向上翻页,直到**本页真正取回 + * 了** `olderId`。判定必须看本页取回的行,不能看合并后的窗口:较旧那一段本来就躺在窗口里,拿 + * 合并结果判定的话,随便一页(哪怕内容完全无关)都会让判定成立,空洞就永远补不回来。 + */ +export async function backfillHistoryWindowGap( + gap: HistoryWindowGap, + deps: HistoryBackfillDeps, +): Promise { + if (deps.isCancelled()) return 'cancelled'; + try { + const probe = await deps.listPage(gap.newerId, HISTORY_GAP_PROBE_LIMIT); + if (deps.isCancelled()) return 'cancelled'; + if (probe.length === 0) return 'exhausted'; + if (probe.some((row) => row.id === gap.olderId)) { + // 服务端相邻:窗口本来就连续,这段安静是真的。探测到的行已在窗口里,merge 是幂等的, + // 但也没有必要——直接收工,不留副作用。 + return 'contiguous'; + } + deps.merge(probe); + + let before = oldestRowId(probe) ?? gap.newerId; + let rows = probe.length; + let requests = 1; + 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; + if (page.some((row) => row.id === gap.olderId)) return 'covered'; + const nextBefore = oldestRowId(page); + // 游标没有前进(整页都是没有 id 的行,或被控端反复返回同一段)→ 停手,避免死循环。 + if (!nextBefore || nextBefore === before) return 'exhausted'; + before = nextBefore; + } + return 'budget'; + } catch { + return 'failed'; + } +} + +function oldestRowId(page: readonly RemoteMessage[]): string | null { + let oldest: { id: string; ms: number } | null = null; + for (const message of page) { + if (!message.id || message.id.startsWith('mobile-system-')) continue; + const ms = Date.parse(message.createdAt); + if (!Number.isFinite(ms)) continue; + if (!oldest || ms < oldest.ms || (ms === oldest.ms && message.id.localeCompare(oldest.id) < 0)) { + oldest = { id: message.id, ms }; + } + } + return oldest?.id ?? null; +} + +/** 同一处空洞的稳定标识:补齐失败 / 判定为真安静之后,不再对同一处重复发请求。 */ +export function historyWindowGapKey(gap: HistoryWindowGap): string { + return `${gap.olderId}→${gap.newerId}`; +} 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/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__/messageRenderHistoryGap.test.ts b/packages/maker-shared/src/__tests__/messageRenderHistoryGap.test.ts new file mode 100644 index 00000000000..9c5eff0d344 --- /dev/null +++ b/packages/maker-shared/src/__tests__/messageRenderHistoryGap.test.ts @@ -0,0 +1,176 @@ +/** + * 回归:历史窗口空洞不得被折进同一个「已工作 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('窗口连续时分组不变(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..442ed97b48d 100644 --- a/packages/maker-shared/src/messageNormalize.ts +++ b/packages/maker-shared/src/messageNormalize.ts @@ -26,6 +26,14 @@ export interface MessageToolResultPairing< * (running/done)必须计入,否则这类行会永久显示进行中。 */ hasResultFor(message: TMessage, tool: MessageNormalizeToolUse): boolean; + /** + * 配对 tool_result 的落库时刻(ISO),即这次工具调用的**结束**时刻;未到达时 undefined。 + * + * 与 `hasResultFor` 同口径**不过 `shouldHideToolResult` 滤网**:结束时间与内容是否展示 + * 无关(桌面 MessageStream 的 resultTsMap 同款)。渲染层用它做历史空洞判定的锚点 —— 只看 + * 调用发起时刻会把「一次跑了半小时以上的调用」之后的下一个动作误判成空洞。 + */ + resultCreatedAtFor(message: TMessage, tool: MessageNormalizeToolUse): string | undefined; } export interface MessageToolResultPairingOptions { @@ -79,12 +87,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); @@ -92,6 +105,7 @@ export function buildMessageToolResultPairing< const adjacencyResultByMessageKey = new Map(); const settledAdjacencyMessageKeys = new Set(); + const adjacencyResultCreatedAtByMessageKey = new Map(); for (let index = 0; index < sortedMessages.length; index++) { const message = sortedMessages[index]; if (message.role !== 'tool_use') continue; @@ -108,6 +122,10 @@ export function buildMessageToolResultPairing< const adjacent = sortedMessages[index + 1]; if (adjacent?.role !== 'tool_result') continue; settledAdjacencyMessageKeys.add(messageNormalizeKey(message)); + const adjacentCreatedAt = readNonEmptyString(adjacent.createdAt); + if (adjacentCreatedAt) { + adjacencyResultCreatedAtByMessageKey.set(messageNormalizeKey(message), adjacentCreatedAt); + } const content = contentToPreview(adjacent.content); if (shouldHideToolResult(tool.toolName, content)) continue; adjacencyResultByMessageKey.set(messageNormalizeKey(message), content); @@ -128,6 +146,13 @@ export function buildMessageToolResultPairing< if (tool.toolUseId && settledToolUseIds.has(tool.toolUseId)) return true; return settledAdjacencyMessageKeys.has(messageNormalizeKey(message)); }, + resultCreatedAtFor(message, tool) { + if (tool.toolUseId) { + const byId = resultCreatedAtByToolUseId.get(tool.toolUseId); + if (byId !== undefined) return byId; + } + return adjacencyResultCreatedAtByMessageKey.get(messageNormalizeKey(message)); + }, }; } diff --git a/packages/maker-shared/src/messageRender.ts b/packages/maker-shared/src/messageRender.ts index a2ca59826b9..1df5feb8fe9 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; @@ -1222,6 +1280,64 @@ 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') { + for (let index = item.children.length - 1; index >= 0; index--) { + const childEnd = itemEndTimestamp(item.children[index]); + if (childEnd !== null) return childEnd; + } + return null; + } + 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 { From e66c1a0b55383b421ea06dcf08ce78ceeb4e4633 Mon Sep 17 00:00:00 2001 From: Dash Date: Fri, 31 Jul 2026 18:24:34 +0800 Subject: [PATCH 02/12] =?UTF-8?q?fix(mobile):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E9=97=A8=E6=A7=9B=E6=8C=89=E4=BC=9A=E8=AF=9D+=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E4=BB=A3=E5=88=A4=E5=AE=9A,=E5=B7=B2=E8=80=83?= =?UTF-8?q?=E5=AF=9F=E7=A9=BA=E6=B4=9E=E4=B8=8D=E5=86=8D=E6=8C=A1=E4=BD=8F?= =?UTF-8?q?=E6=9B=B4=E6=97=A9=E7=9A=84=E6=B4=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第一轮 review 的四族反馈,归族后一并修: 1. 屏幕侧的前置状态没绑定「当前会话 + 本次同步代」(greptile P1、codex 两条 P2): - 同步门槛从屏幕级 lastSyncedAt 换成 readAckSyncedKey ——原地从会话 A 切到有缓存 消息的 B 时,lastSyncedAt 仍是 A 留下的非空值,补齐会在 B 对账完成前基于旧缓存 动手,而空洞 key 已记为已考察,那一处从此不再重试。readAckSyncedKey 是仓内既有的 「本会话在当前连接代完成过整窗同步」判据,直接复用。 - 飞行标记从 boolean ref 换成带会话 id 的 state:带 sid 让 A 的在途补齐不连坐 B; 可观察让收尾本身触发一次重跑 —— 用 ref 时 finally 只改 ref、不触发渲染,B 的空洞 在本次访问期间再也不会被检测。收尾用函数式更新,避免误清新会话刚起的那一轮。 2. contiguous 结局让更早的真实空洞永远够不到(自动 review P2):findHistoryWindowGap 恒定返回最靠尾部的跳变,而 contiguous(隔夜等合法间隔)既不 merge、跳变也留在窗口里。 现在把「已考察」集合传进检测作跳过表,跳变逐处往更早处推进。 3. 空洞前那一段的时长低报(codex P2):被空洞切开的段永远没有 nextItem, workRunFallbackEnd 退回段内锚点时用的是组内第一条调用的开始时间 —— 一个 20 分钟后 才回结果的单工具段会显示约 1 秒。改用 itemEndTimestamp,与本 PR 新增的 settledAt 同源。 4. HISTORY_BACKFILL_MAX_ROWS 的注释口径与实现对齐(copilot):它是循环准入条件而非硬 上限,判定在取页之前,最后一页整页拉回时总数会略微超出。 另修 CI:check:pr-design-basis 要求命中 UI 路径时「引用的设计规范」写「不涉及:<理由>」, PR 描述已补理由(改动无视觉/交互/文案变化)。 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Dash --- apps/mobile/app/sessions/[sessionId].tsx | 60 +++++++++++++------ .../historyWindowBackfillWiring.test.ts | 28 ++++++--- .../src/__tests__/historyWindowGap.test.ts | 18 ++++++ apps/mobile/src/session/historyWindowGap.ts | 38 ++++++++---- .../__tests__/messageRenderHistoryGap.test.ts | 16 +++++ packages/maker-shared/src/messageRender.ts | 15 +++-- 6 files changed, 133 insertions(+), 42 deletions(-) diff --git a/apps/mobile/app/sessions/[sessionId].tsx b/apps/mobile/app/sessions/[sessionId].tsx index 524e8a352ff..6439cebe2aa 100644 --- a/apps/mobile/app/sessions/[sessionId].tsx +++ b/apps/mobile/app/sessions/[sessionId].tsx @@ -3824,7 +3824,16 @@ export default function SessionScreen() { * `HISTORY_GAP_SPLIT_MS` 守卫兜底(不谎报时长),用户仍可用「加载更早」自己往上翻。 */ const backfillAttemptedGapsRef = useRef<{ sid: string; keys: Set } | null>(null); - const backfillInFlightRef = useRef(false); + /** + * 飞行中的补齐属于哪个会话。两个刻意的选择: + * + * - **带会话 id**,不是裸 boolean:屏实例会被原地复用,会话 A 的补齐还在飞时切到 B,裸标记 + * 会把 B 的补齐一并挡掉(A 那次自己会在下一次 isCancelled 上收手,不该连坐 B)。 + * - **可观察 state**,不是 ref:ref 在 `finally` 里改写不会触发重渲染,于是 B 的空洞在本次 + * 访问期间再也不会被检测,要等新消息到达或重开会话(#1210 review 的 P1)。用 state 后 + * 清除标记本身就是一次重跑,同一次访问里可以接着补下一处。 + */ + const [backfillInFlightSessionId, setBackfillInFlightSessionId] = useState(null); // 当前屏幕的会话 id 镜像:补齐是后台异步的,不能靠 effect 闭包里的 sessionId 判断"是否已切走" // (那个值恒等于启动时的值)。本 effect 声明在补齐 effect **之前**,切会话时同一 commit 里先 // 更新镜像,飞行中的补齐随即在下一次 isCancelled 上收手。 @@ -3833,28 +3842,30 @@ export default function SessionScreen() { backfillSessionRef.current = sessionId; }, [sessionId]); useEffect(() => { - // 窗口尚未与被控端对账过(lastSyncedAt===null)时不动手:此刻 messages 可能只是冷开缓存, - // 首屏那次 listMessages 马上会把窗口替换掉,基于旧快照找的空洞随即失效。 - if (!deviceId || !sessionId || lastSyncedAt === null) return; + if (!deviceId || !sessionId) return; + // 同步门槛必须按 **session + 连接代** 判定,不能用 lastSyncedAt:屏实例复用、原地从会话 A + // 切到有缓存消息的 B 时,lastSyncedAt 仍是 A 留下的非空值,补齐会在 B 的 listMessages 对账 + // 完成前就基于旧缓存快照动手 —— 而空洞 key 在请求前已记为已考察,那一处从此不再重试 + // (#1210 review)。readAckSyncedKey 正是「本会话在当前连接代完成过整窗同步」这个判据的 + // 既有单一来源(见它的声明处),这里直接复用。 + if (readAckSyncedKey !== `${sessionId}:${connectionEpoch}`) return; // 与「加载更早」互斥:两者都按 before 游标翻页,同时跑只会让窗口反复 merge、白拉页。 - if (loading || loadingEarlier || backfillInFlightRef.current) return; - const gap = findHistoryWindowGap(messages); - if (!gap) return; - // 每处空洞只尝试一次(判定为真安静、超预算或失败都算尝试过),否则每次 messages 变化都会 - // 重新发请求。换会话时连同 sid 一起重置。 + // 飞行判定只挡**同一会话**,别的会话残留的那次不连坐(见 backfillInFlightSessionId)。 + if (loading || loadingEarlier || backfillInFlightSessionId === sessionId) return; + // 每处空洞每次访问只考察一次(判定为真安静、超预算或失败都算考察过)。已考察的集合同时 + // 作为检测的跳过表:否则 contiguous 那种"不 merge、跳变留在窗口里"的结局会让检测永远返回 + // 同一处,更早处的真实缺行进不了探测(#1210 review)。换会话时连同 sid 一起重置。 const attempted = backfillAttemptedGapsRef.current?.sid === sessionId ? backfillAttemptedGapsRef.current : { sid: sessionId, keys: new Set() }; backfillAttemptedGapsRef.current = attempted; - const gapKey = historyWindowGapKey(gap); - if (attempted.keys.has(gapKey)) return; - // 每次打开最多起 3 轮补齐。一处补到预算上限而没连上时,窗口里会露出一处**新的**跳变 - // (锚点换成刚拉到的最旧行),key 不同 → 下次 messages 变化时可以再起一轮;不设总闸就成了 - // 一路往上翻整场历史。注意这是**上限**不是节奏:补齐自己的 merge 触发的那次重跑会被 - // in-flight 挡住,所以一次打开通常只补一处,余下的等新消息到达或用户重开会话。 + // 每次访问最多起 3 轮补齐:多段拼接的窗口确实可能有几处洞,但不设总闸就成了一路往上翻 + // 整场历史。超出后交给渲染层守卫 + 用户手动「加载更早」。 if (attempted.keys.size >= 3) return; - attempted.keys.add(gapKey); - backfillInFlightRef.current = true; + const gap = findHistoryWindowGap(messages, attempted.keys); + if (!gap) return; + attempted.keys.add(historyWindowGapKey(gap)); + setBackfillInFlightSessionId(sessionId); const sessionIdAtStart = sessionId; void backfillHistoryWindowGap(gap, { listPage: async (before, limit) => { @@ -3876,9 +3887,20 @@ export default function SessionScreen() { isCancelled: () => backfillSessionRef.current !== sessionIdAtStart || !remoteSessionStore.getMessages(sessionIdAtStart).some((row) => row.id === gap.newerId), }).finally(() => { - backfillInFlightRef.current = false; + // 函数式更新:切会话后新会话可能已经起了自己的那一轮,不能被这次收尾误清。 + setBackfillInFlightSessionId((current) => (current === sessionIdAtStart ? null : current)); }); - }, [deviceId, lastSyncedAt, loading, loadingEarlier, maker, messages, sessionId]); + }, [ + backfillInFlightSessionId, + 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 index 9aaa7d2e52a..4f3de82b778 100644 --- a/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts +++ b/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts @@ -3,9 +3,14 @@ * * 补齐算法本身在 `historyWindowGap.test.ts` 有行为测试;这里锁住屏幕侧那几个"删掉也照样跑、 * 但会悄悄踩坑"的前置条件 —— 它们各自对应一个具体故障: - * - 窗口没与被控端对账过就动手 → 基于冷开缓存快照找的空洞随即被整窗替换作废,白发请求; + * - 同步门槛用 lastSyncedAt 而不是 session+连接代 → 原地切会话时它仍是上一个会话的非空值, + * 补齐基于旧缓存快照动手,而空洞 key 已记为已考察,那一处从此不再重试(#1210 review); * - 不与「加载更早」/ 自身飞行互斥 → 两条 before 游标并发翻页,窗口反复 merge; + * - 飞行标记不带会话 id → 会话 A 的补齐挡掉 B 的;标记用 ref 而非 state → 清除时不触发重跑, + * B 的空洞在本次访问期间再也不被检测(#1210 review P1); * - 每处空洞不去重、不设总闸 → messages 每变一次就重新发请求,一路往上翻整场历史; + * - 已考察集合不传给检测 → contiguous 结局不 merge、跳变留在窗口里,检测永远返回同一处, + * 更早处的真实缺行进不了探测(#1210 review); * - isCancelled 只比 effect 闭包里的 sessionId → 那个值恒等于启动时的值,等于没有取消; * - 不检查锚点行是否还在窗口 → /clear、rewind 之后把刚被移除的历史 merge 回去。 */ @@ -16,15 +21,24 @@ import { describe, expect, it } from 'vitest'; describe('history window backfill wiring', () => { const source = readFileSync(resolve(process.cwd(), 'app/sessions/[sessionId].tsx'), 'utf8'); - it('只在窗口已与被控端对账后动手,并与加载中/加载更早/自身飞行互斥', () => { - expect(source).toContain('if (!deviceId || !sessionId || lastSyncedAt === null) return;'); - expect(source).toContain('if (loading || loadingEarlier || backfillInFlightRef.current) return;'); + it('同步门槛按 session + 连接代判定,不用屏幕级 lastSyncedAt', () => { + expect(source).toContain('if (readAckSyncedKey !== `${sessionId}:${connectionEpoch}`) return;'); + expect(source).not.toContain('|| lastSyncedAt === null) return;'); }); - it('每处空洞只尝试一次,且每次打开设总闸', () => { - expect(source).toContain('if (attempted.keys.has(gapKey)) return;'); + it('与加载中/加载更早互斥,飞行标记带会话 id 且是可观察 state', () => { + expect(source).toContain('if (loading || loadingEarlier || backfillInFlightSessionId === sessionId) return;'); + expect(source).toContain('const [backfillInFlightSessionId, setBackfillInFlightSessionId] = useState(null);'); + // 收尾用函数式更新:切会话后新会话可能已起了自己那一轮,不能被旧的收尾误清。 + expect(source).toContain('setBackfillInFlightSessionId((current) => (current === sessionIdAtStart ? null : current));'); + expect(source).not.toContain('backfillInFlightRef'); + }); + + it('每处空洞每次访问只考察一次、设总闸,且已考察集合作为检测的跳过表', () => { expect(source).toContain('if (attempted.keys.size >= 3) return;'); - // 换会话时连同 sid 一起重置,否则上个会话的已尝试集合会压住新会话的补齐。 + expect(source).toContain('findHistoryWindowGap(messages, attempted.keys)'); + expect(source).toContain('attempted.keys.add(historyWindowGapKey(gap));'); + // 换会话时连同 sid 一起重置,否则上个会话的已考察集合会压住新会话的补齐。 expect(source).toContain('backfillAttemptedGapsRef.current?.sid === sessionId'); }); diff --git a/apps/mobile/src/__tests__/historyWindowGap.test.ts b/apps/mobile/src/__tests__/historyWindowGap.test.ts index 929e517e08d..d05114c681f 100644 --- a/apps/mobile/src/__tests__/historyWindowGap.test.ts +++ b/apps/mobile/src/__tests__/historyWindowGap.test.ts @@ -68,6 +68,24 @@ describe('findHistoryWindowGap', () => { const gap = findHistoryWindowGap([row('older', 0), row('newer', 140)]) as HistoryWindowGap; expect(historyWindowGapKey(gap)).toBe('older→newer'); }); + + 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', gapMs: 100 * 60_000 }); + + const bothConsidered = new Set([ + historyWindowGapKey(tailGap), + historyWindowGapKey(earlierGap as HistoryWindowGap), + ]); + expect(findHistoryWindowGap(window, bothConsidered)).toBeNull(); + }); }); describe('backfillHistoryWindowGap', () => { diff --git a/apps/mobile/src/session/historyWindowGap.ts b/apps/mobile/src/session/historyWindowGap.ts index 1e00ec69370..bb3d279706d 100644 --- a/apps/mobile/src/session/historyWindowGap.ts +++ b/apps/mobile/src/session/historyWindowGap.ts @@ -36,12 +36,16 @@ import { MESSAGE_PAGE_SIZE } from '@/session/messagePaging'; import type { RemoteMessage } from '@/session/types'; /** - * 一次补齐最多拉回的行数。 + * 一次补齐的行数预算。 * - * 对照的是**本次补齐新增的行**,不是窗口总行数:手机内存与列表挂载树都比桌面紧,桌面的 - * `JUMP_BACKFILL_MAX_ITEMS`(600)在这里偏激进。400 行 ≈ 5 个满页,足以覆盖"看了个开头就切走、 - * 两小时后回来"这类真实空洞(实测那场 445 行的会话,空洞两侧相隔约 420 行);超出仍未连上时 - * 交给渲染层的空洞守卫兜底,并保留「加载更早」入口让用户自己往上翻。 + * 对照的是**本次补齐已取回的行数**(每页 `page.length` 累加),不是窗口总行数。它是循环的 + * 准入条件而非硬上限:判定在取页**之前**,所以最后一页整页拉回时总数会略微超出预算 + * (例如已取 390 行仍会再拉一整页)。这是有意的 —— 为省下几十行去要半页会多一次往返。 + * + * 手机内存与列表挂载树都比桌面紧,桌面的 `JUMP_BACKFILL_MAX_ITEMS`(600)在这里偏激进。 + * 400 行 ≈ 5 个满页,足以覆盖"看了个开头就切走、两小时后回来"这类真实空洞(实测那场 445 行 + * 的会话,空洞两侧相隔约 420 行);超出仍未连上时交给渲染层的空洞守卫兜底,并保留 + * 「加载更早」入口让用户自己往上翻。 */ export const HISTORY_BACKFILL_MAX_ROWS = 400; @@ -93,15 +97,26 @@ export interface HistoryBackfillDeps { } /** - * 找窗口里**最靠尾部**的一处空洞;没有则返回 null。 + * 找窗口里**最靠尾部的、尚未考察过**的一处空洞;没有则返回 null。 * * 为什么从尾部找而不是从头:补齐是沿 `before` 从新往旧翻页,先补最靠尾部的洞时游标离窗口尾 - * 最近、翻页量最小。这一处补完(或判定为真安静)之后,下一轮检测自然会露出更早的那一处。 + * 最近、翻页量最小。 + * + * 为什么必须能跳过已考察的:补齐成功(`covered`)会把中间行 merge 进来、那处跳变自然消失,但 + * 其它结局不会 —— 尤其 `contiguous`(隔夜等合法间隔,探测确认服务端本来就相邻)既不 merge、 + * 跳变也一直留在窗口里。若检测恒定返回最靠尾部那一处,窗口有 ≥3 段时(多次在不同位置打开 + * 同一会话拼出的缓存),只要最尾部那处是合法间隔,更早处真实缺行就永远进不了探测:补齐只盯 + * 着这处 contiguous 收工,而「加载更早」只从最旧行往外翻、够不到窗口内部的空洞,内容于是静默 + * 丢失(#1210 review)。所以调用方把**已考察过的** gapKey 传进来,检测跳过它们继续往前找。 * * 只看真实 host 行:本地合成的系统卡(`mobile-system-*`,/pwd、/context 等)没有服务端对应行, * 拿它当 `before` 游标什么都匹配不上,只会白拉一页最新消息。 */ -export function findHistoryWindowGap(messages: readonly RemoteMessage[]): HistoryWindowGap | null { +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) })) @@ -111,9 +126,10 @@ export function findHistoryWindowGap(messages: readonly RemoteMessage[]): Histor const newer = rows[index]; const older = rows[index - 1]; const gapMs = newer.ms - older.ms; - if (gapMs > HISTORY_GAP_SPLIT_MS) { - return { newerId: newer.id, olderId: older.id, gapMs }; - } + if (gapMs <= HISTORY_GAP_SPLIT_MS) continue; + const gap: HistoryWindowGap = { newerId: newer.id, olderId: older.id, gapMs }; + if (consideredGapKeys.has(historyWindowGapKey(gap))) continue; + return gap; } return null; } diff --git a/packages/maker-shared/src/__tests__/messageRenderHistoryGap.test.ts b/packages/maker-shared/src/__tests__/messageRenderHistoryGap.test.ts index 9c5eff0d344..d61720080db 100644 --- a/packages/maker-shared/src/__tests__/messageRenderHistoryGap.test.ts +++ b/packages/maker-shared/src/__tests__/messageRenderHistoryGap.test.ts @@ -158,6 +158,22 @@ describe('工作组分组 — 历史窗口空洞', () => { 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('窗口连续时分组不变(user 行照常是唯一边界)', () => { const items = buildMessageRenderItems([ userItem('user-1', 0, '第一问'), diff --git a/packages/maker-shared/src/messageRender.ts b/packages/maker-shared/src/messageRender.ts index 1df5feb8fe9..c4de2da749f 100644 --- a/packages/maker-shared/src/messageRender.ts +++ b/packages/maker-shared/src/messageRender.ts @@ -1241,15 +1241,20 @@ 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; + const end = itemEndTimestamp(run[index]); + if (end !== null) return end; } return null; } From 70f794d49973b5fb3f51b8753e3b99adae168b5c Mon Sep 17 00:00:00 2001 From: Dash Date: Fri, 31 Jul 2026 18:49:59 +0800 Subject: [PATCH 03/12] =?UTF-8?q?fix(mobile):=20=E5=B7=B2=E8=80=83?= =?UTF-8?q?=E5=AF=9F=E7=A9=BA=E6=B4=9E=E6=8C=89=E7=BB=93=E5=B1=80=E5=88=86?= =?UTF-8?q?=E7=B1=BB,=E7=BB=84=E6=97=B6=E9=95=BF=E5=8F=96=E5=85=A8?= =?UTF-8?q?=E4=BD=93=E5=AD=90=E9=A1=B9=E7=BB=93=E6=9D=9F=E6=97=B6=E5=88=BB?= =?UTF-8?q?=E6=9C=80=E5=A4=A7=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第二轮 review 的两族反馈: 1. 「已考察空洞」一个集合承担了三种语义,同时踩两个坑(codex 两条 P2)。按结局拆成三类, 各自的遗忘条件与是否占额度都不同: - contiguous(探测确认服务端相邻):与连接状态无关的事实,本次访问内永久跳过;但一个 翻页请求都没花,**不占额度** —— 否则窗口里几处隔夜停顿就能吃光额度,更早处的真实 缺行永远排不到探测。 - backfilled(covered / budget / exhausted):真翻过页,跳过并占额度。额度限制的正是 "一次访问最多翻多少段历史"。 - failed(请求异常):跳过只为防抖,但绑在 connectionEpoch 上 —— 换连接代即清空,重连 并重新同步后同一处可以再试,不再被断线那次永久钉死。 cancelled 刻意不记:会话切走时当前检测看的是另一个窗口、回来理应重新考察;锚点行被 /clear、rewind 拿掉时那处跳变本身也消失了。归类移到收尾做,跨会话 / 跨连接代落地的 旧结局一律丢弃。 2. workRunFallbackEnd 取的是「最后一个子项」而不是全体最大值(自动 review P2)。子项按 发起时刻排序,并行动作乱序完成:想了 40 分钟的 thinking 排在前、紧随其后 2 分钟就结束 的工具排在后,取最后一个就把 40 分钟丢了。改为遍历取 max,与桌面 workRunEndTs (#676 review codex P1)及本文件 groupMessageWorkRuns 的锚点同口径。 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Dash --- apps/mobile/app/sessions/[sessionId].tsx | 78 +++++++++++++++---- .../historyWindowBackfillWiring.test.ts | 28 +++++-- .../__tests__/messageRenderHistoryGap.test.ts | 16 ++++ packages/maker-shared/src/messageRender.ts | 23 +++--- 4 files changed, 116 insertions(+), 29 deletions(-) diff --git a/apps/mobile/app/sessions/[sessionId].tsx b/apps/mobile/app/sessions/[sessionId].tsx index 6439cebe2aa..6e1cbf27619 100644 --- a/apps/mobile/app/sessions/[sessionId].tsx +++ b/apps/mobile/app/sessions/[sessionId].tsx @@ -3823,7 +3823,28 @@ export default function SessionScreen() { * 后台跑、不阻塞首屏,也不写 `error` / `loadingEarlier`:补齐是静默自愈,失败时渲染层的 * `HISTORY_GAP_SPLIT_MS` 守卫兜底(不谎报时长),用户仍可用「加载更早」自己往上翻。 */ - const backfillAttemptedGapsRef = useRef<{ sid: string; keys: Set } | null>(null); + /** + * 本次访问考察过的空洞,按**结局**分三类 —— 它们的"遗忘条件"和"是否消耗额度"都不同,合成 + * 一个集合会同时踩两个坑(#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); /** * 飞行中的补齐属于哪个会话。两个刻意的选择: * @@ -3852,21 +3873,40 @@ export default function SessionScreen() { // 与「加载更早」互斥:两者都按 before 游标翻页,同时跑只会让窗口反复 merge、白拉页。 // 飞行判定只挡**同一会话**,别的会话残留的那次不连坐(见 backfillInFlightSessionId)。 if (loading || loadingEarlier || backfillInFlightSessionId === sessionId) return; - // 每处空洞每次访问只考察一次(判定为真安静、超预算或失败都算考察过)。已考察的集合同时 - // 作为检测的跳过表:否则 contiguous 那种"不 merge、跳变留在窗口里"的结局会让检测永远返回 - // 同一处,更早处的真实缺行进不了探测(#1210 review)。换会话时连同 sid 一起重置。 - const attempted = backfillAttemptedGapsRef.current?.sid === sessionId - ? backfillAttemptedGapsRef.current - : { sid: sessionId, keys: new Set() }; - backfillAttemptedGapsRef.current = attempted; - // 每次访问最多起 3 轮补齐:多段拼接的窗口确实可能有几处洞,但不设总闸就成了一路往上翻 - // 整场历史。超出后交给渲染层守卫 + 用户手动「加载更早」。 - if (attempted.keys.size >= 3) return; - const gap = findHistoryWindowGap(messages, attempted.keys); + // 换会话时整体重置;同一会话内换了连接代只清 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; + // 额度只算真的翻过页的那些:每次访问最多翻 3 段历史。正常停顿(contiguous)与失败重试 + // 不消耗它 —— 否则窗口里几处隔夜停顿就能把额度吃光,更早的真实缺行永远排不到。 + if (gapState.backfilled.size >= 3) return; + // 跳过表是三类的并集:contiguous 那种"不 merge、跳变留在窗口里"的结局若不跳过,检测会永远 + // 返回同一处,更早处的真实缺行进不了探测。 + const consideredKeys = new Set([ + ...gapState.contiguous, + ...gapState.backfilled, + ...gapState.failed, + ]); + const gap = findHistoryWindowGap(messages, consideredKeys); if (!gap) return; - attempted.keys.add(historyWindowGapKey(gap)); + const gapKey = historyWindowGapKey(gap); setBackfillInFlightSessionId(sessionId); const sessionIdAtStart = sessionId; + const epochAtStart = connectionEpoch; void backfillHistoryWindowGap(gap, { listPage: async (before, limit) => { const page = await listMessagesWithPayloadRetry( @@ -3886,6 +3926,18 @@ export default function SessionScreen() { // 把刚被移除的历史(甚至 clearedAt 之前的消息)塞回窗口。锚点没了就等于这处空洞不存在了。 isCancelled: () => backfillSessionRef.current !== sessionIdAtStart || !remoteSessionStore.getMessages(sessionIdAtStart).some((row) => row.id === gap.newerId), + }).then((outcome) => { + // 按结局归类(容器的三类语义见 backfillGapStateRef 的注释)。归类发生在**收尾**而不是发起 + // 前:发起期间的重入由飞行标记挡住,不需要预先占位。切会话 / 换连接代之后落地的旧结局 + // 一律丢弃 —— 它属于上一个容器,写进新容器会污染当前会话的判断。 + 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(() => { // 函数式更新:切会话后新会话可能已经起了自己的那一轮,不能被这次收尾误清。 setBackfillInFlightSessionId((current) => (current === sessionIdAtStart ? null : current)); diff --git a/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts b/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts index 4f3de82b778..7d19a59086a 100644 --- a/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts +++ b/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts @@ -34,12 +34,26 @@ describe('history window backfill wiring', () => { expect(source).not.toContain('backfillInFlightRef'); }); - it('每处空洞每次访问只考察一次、设总闸,且已考察集合作为检测的跳过表', () => { - expect(source).toContain('if (attempted.keys.size >= 3) return;'); - expect(source).toContain('findHistoryWindowGap(messages, attempted.keys)'); - expect(source).toContain('attempted.keys.add(historyWindowGapKey(gap));'); - // 换会话时连同 sid 一起重置,否则上个会话的已考察集合会压住新会话的补齐。 - expect(source).toContain('backfillAttemptedGapsRef.current?.sid === sessionId'); + it('已考察空洞按结局分三类:额度只算翻过页的,失败绑连接代,跳过表取并集', () => { + // 额度只看 backfilled:正常停顿(contiguous)不该吃掉额度,否则几处隔夜间隔就能让更早的 + // 真实缺行永远排不到探测。 + expect(source).toContain('if (gapState.backfilled.size >= 3) return;'); + expect(source).toContain('findHistoryWindowGap(messages, consideredKeys)'); + expect(source).toContain('...gapState.contiguous,'); + expect(source).toContain('...gapState.backfilled,'); + expect(source).toContain('...gapState.failed,'); + // 断线那次不得把空洞永久钉死:换连接代只清 failed,重连后同一处可以再试。 + expect(source).toContain('gapState.failed.clear();'); + // 换会话时整体重置,否则上个会话的已考察集合会压住新会话的补齐。 + expect(source).toContain('existingState?.sid === sessionId'); + }); + + it('结局归类在收尾而非发起前,且丢弃跨会话/跨连接代落地的旧结局', () => { + expect(source).toContain("if (outcome === 'contiguous') state.contiguous.add(gapKey);"); + expect(source).toContain("else if (outcome === 'failed') state.failed.add(gapKey);"); + // cancelled 不记:会话切走后回来理应重新考察,锚点行被移除时那处跳变本身也不在了。 + expect(source).toContain("else if (outcome !== 'cancelled') state.backfilled.add(gapKey);"); + expect(source).toContain('state.sid !== sessionIdAtStart || state.epoch !== epochAtStart'); }); it('取消判定走会话镜像 ref 与锚点行是否仍在窗口,不是 effect 闭包里的 sessionId', () => { @@ -49,7 +63,7 @@ describe('history window backfill wiring', () => { }); it('补齐失败不写 error / loadingEarlier:它是静默自愈,不占用户可见的加载态', () => { - const effectStart = source.indexOf('const backfillAttemptedGapsRef'); + const effectStart = source.indexOf('const backfillGapStateRef'); const effectEnd = source.indexOf('const selectSlashCommand', effectStart); expect(effectStart).toBeGreaterThan(0); expect(effectEnd).toBeGreaterThan(effectStart); diff --git a/packages/maker-shared/src/__tests__/messageRenderHistoryGap.test.ts b/packages/maker-shared/src/__tests__/messageRenderHistoryGap.test.ts index d61720080db..f491560c4e9 100644 --- a/packages/maker-shared/src/__tests__/messageRenderHistoryGap.test.ts +++ b/packages/maker-shared/src/__tests__/messageRenderHistoryGap.test.ts @@ -174,6 +174,22 @@ describe('工作组分组 — 历史窗口空洞', () => { 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('窗口连续时分组不变(user 行照常是唯一边界)', () => { const items = buildMessageRenderItems([ userItem('user-1', 0, '第一问'), diff --git a/packages/maker-shared/src/messageRender.ts b/packages/maker-shared/src/messageRender.ts index c4de2da749f..58f77cb1658 100644 --- a/packages/maker-shared/src/messageRender.ts +++ b/packages/maker-shared/src/messageRender.ts @@ -1242,21 +1242,26 @@ function createCompletedWorkGroup( run: readonly MessageRenderWorkChildItem[], ): number | null { - for (let index = run.length - 1; index >= 0; index--) { - const end = itemEndTimestamp(run[index]); - if (end !== null) return end; + 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 { From f0d9480cbd647531a3e71c9d5f0c2b262c9310a7 Mon Sep 17 00:00:00 2001 From: Dash Date: Fri, 31 Jul 2026 19:19:58 +0800 Subject: [PATCH 04/12] =?UTF-8?q?fix(mobile):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E8=BD=AE=E6=AC=A1=E6=94=B9=E7=94=A8=E5=8D=95=E8=B0=83=E5=BA=8F?= =?UTF-8?q?=E5=8F=B7,=E5=B9=B6=E7=BB=99=E6=AD=A3=E5=B8=B8=E5=81=9C?= =?UTF-8?q?=E9=A1=BF=E7=9A=84=E6=8E=A2=E6=B5=8B=E5=8A=A0=E6=80=BB=E9=97=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第三轮 review 的两条,连同一次收敛检查点: 1. 跨会话取消可被撤销(greptile P1)。旧判据是"当前会话 id 是否仍等于启动时的",而会话 id 会 **摆回来**:A 的补齐在飞时切到 B 再快速切回 A,判据重新成立 → 旧轮的取消被撤销、effect 又 放行一轮新的 A,同一会话并发翻页;旧轮收尾还会按 sid 把新轮的飞行标记误清,继续放行更多轮。 改为给每轮分配只增不减的 runSeq:isCancelled、结论写入、飞行标记清除全部对着 seq 比, "已被取代"是终态,撤销不了。切会话时占掉一个序号(不启动新轮)即作废在飞的那一轮,顺带保留 "用户离开就不再花翻页请求"的语义。退役 backfillSessionRef 这个可摆动判据。 2. 正常停顿的探测没有独立上限(codex P2)。上一轮把 contiguous 从翻页额度里移出去是对的,但 跨数百天的会话可能有几十上百处正常停顿,每处 contiguous 都会在飞行标记清除时触发下一次 检测 —— 于是打开这种会话会串行发出上百次 limit=1 探测,每次重新访问还会重来。补第二道闸: HISTORY_GAP_MAX_CONSIDERED_PER_VISIT(6)管住考察总次数,HISTORY_BACKFILL_MAX_GAPS_PER_VISIT(3) 仍只管真翻页的段数。不做跨访问持久化:要落 AsyncStorage 并处理 rewind/clear 后的失效, 复杂度远超收益(真实空洞几乎总在窗口尾部附近)。 收敛检查点(三轮反馈全部落在这一层,按 git skill 的止损规则做一次):把补齐的状态模型与 6 条 不变量写进 PR 描述与接线守卫测试的文件头,每条不变量在代码里只留一个判据、所有对称路径复用它; 断言里额外钉住"已退役的可摆动判据不得回归"(backfillSessionRef / backfillInFlightRef / 硬编码 3)。 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Dash --- apps/mobile/app/sessions/[sessionId].tsx | 67 +++++++++----- .../historyWindowBackfillWiring.test.ts | 92 +++++++++++-------- apps/mobile/src/session/historyWindowGap.ts | 26 ++++++ 3 files changed, 122 insertions(+), 63 deletions(-) diff --git a/apps/mobile/app/sessions/[sessionId].tsx b/apps/mobile/app/sessions/[sessionId].tsx index 6e1cbf27619..4c722330cb0 100644 --- a/apps/mobile/app/sessions/[sessionId].tsx +++ b/apps/mobile/app/sessions/[sessionId].tsx @@ -371,6 +371,8 @@ import { shouldKeepOlderMessagesAffordance, } from '@/session/messagePaging'; import { + HISTORY_BACKFILL_MAX_GAPS_PER_VISIT, + HISTORY_GAP_MAX_CONSIDERED_PER_VISIT, HISTORY_GAP_PROBE_LIMIT, backfillHistoryWindowGap, findHistoryWindowGap, @@ -3846,21 +3848,28 @@ export default function SessionScreen() { failed: Set; } | null>(null); /** - * 飞行中的补齐属于哪个会话。两个刻意的选择: + * 飞行中的那一轮补齐:会话 id + **单调递增的运行序号**。 * - * - **带会话 id**,不是裸 boolean:屏实例会被原地复用,会话 A 的补齐还在飞时切到 B,裸标记 - * 会把 B 的补齐一并挡掉(A 那次自己会在下一次 isCancelled 上收手,不该连坐 B)。 - * - **可观察 state**,不是 ref:ref 在 `finally` 里改写不会触发重渲染,于是 B 的空洞在本次 - * 访问期间再也不会被检测,要等新消息到达或重开会话(#1210 review 的 P1)。用 state 后 - * 清除标记本身就是一次重跑,同一次访问里可以接着补下一处。 + * 为什么必须有 seq、且一切判据都对着它比:所有"当前状态是否仍等于启动时状态"的判据都不可靠, + * 因为会话 id 会**摆回来** —— A 的补齐在飞时切到 B 再快速切回 A,`sessionId === 'A'` 会重新 + * 成立,于是旧那一轮的取消被撤销、effect 又放行一轮新的 A,同一会话并发翻页;旧轮收尾时还会 + * 按 sid 把新轮的飞行标记误清,继续放行更多轮(#1210 review 的 P1)。seq 只增不减,"我还是不是 + * 本会话最新那一轮"是单调判据,撤销不了。 + * + * 用 state 而不是 ref 的理由不变:ref 在 `finally` 里改写不触发重渲染,那样本次访问里就不会 + * 再检测下一处空洞,要等新消息或重开会话。互斥仍只按 `sid` 判 —— 别的会话残留的那一轮不连坐 + * 当前会话(它自己会在下一次 isCancelled 上收手)。 */ - const [backfillInFlightSessionId, setBackfillInFlightSessionId] = useState(null); - // 当前屏幕的会话 id 镜像:补齐是后台异步的,不能靠 effect 闭包里的 sessionId 判断"是否已切走" - // (那个值恒等于启动时的值)。本 effect 声明在补齐 effect **之前**,切会话时同一 commit 里先 - // 更新镜像,飞行中的补齐随即在下一次 isCancelled 上收手。 - const backfillSessionRef = useRef(sessionId); + const [backfillInFlightRun, setBackfillInFlightRun] = useState<{ sid: string; seq: number } | null>(null); + /** 单调递增的补齐轮次计数器;`latest` 是本屏最新那一轮的序号(旧轮据此自我作废)。 */ + const backfillRunSeqRef = useRef(0); + const backfillLatestRunSeqRef = useRef(0); + // 切会话即作废在飞的那一轮:占掉一个序号但不启动任何轮,于是在飞的旧轮在下一次 isCancelled 上 + // 收手 —— 用户已经离开的会话不值得继续花翻页请求。走同一个单调序号而不是"比较当前会话 id", + // 是因为后者会随切回来而摆回、把取消撤销掉(#1210 review 的 P1);序号只增不减,作废是终态。 useEffect(() => { - backfillSessionRef.current = sessionId; + backfillRunSeqRef.current += 1; + backfillLatestRunSeqRef.current = backfillRunSeqRef.current; }, [sessionId]); useEffect(() => { if (!deviceId || !sessionId) return; @@ -3871,8 +3880,8 @@ export default function SessionScreen() { // 既有单一来源(见它的声明处),这里直接复用。 if (readAckSyncedKey !== `${sessionId}:${connectionEpoch}`) return; // 与「加载更早」互斥:两者都按 before 游标翻页,同时跑只会让窗口反复 merge、白拉页。 - // 飞行判定只挡**同一会话**,别的会话残留的那次不连坐(见 backfillInFlightSessionId)。 - if (loading || loadingEarlier || backfillInFlightSessionId === sessionId) return; + // 飞行判定只挡**同一会话**,别的会话残留的那一轮不连坐(见 backfillInFlightRun)。 + if (loading || loadingEarlier || backfillInFlightRun?.sid === sessionId) return; // 换会话时整体重置;同一会话内换了连接代只清 failed —— 断线那次不该把这处空洞永久钉死, // 重连并重新同步后要能再试(#1210 review)。contiguous / backfilled 是与连接无关的结论, // 重连后不必重来。 @@ -3891,9 +3900,6 @@ export default function SessionScreen() { gapState.failed.clear(); } backfillGapStateRef.current = gapState; - // 额度只算真的翻过页的那些:每次访问最多翻 3 段历史。正常停顿(contiguous)与失败重试 - // 不消耗它 —— 否则窗口里几处隔夜停顿就能把额度吃光,更早的真实缺行永远排不到。 - if (gapState.backfilled.size >= 3) return; // 跳过表是三类的并集:contiguous 那种"不 merge、跳变留在窗口里"的结局若不跳过,检测会永远 // 返回同一处,更早处的真实缺行进不了探测。 const consideredKeys = new Set([ @@ -3901,12 +3907,22 @@ export default function SessionScreen() { ...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); - setBackfillInFlightSessionId(sessionId); 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( @@ -3921,15 +3937,19 @@ export default function SessionScreen() { if (rows.length > 0) remoteSessionStore.mergeMessages(sessionIdAtStart, rows); }, // 两个收手条件: - // - 会话已切走 —— 补进来的行属于另一个屏幕的窗口; + // - **我不再是最新那一轮** —— 屏幕已经为别的会话(或切回来后的同一会话)起了新的一轮。 + // 判据必须是单调的 seq,不能比"当前会话 id 是否仍等于启动时的":会话切走再切回时后者会 + // 重新成立,取消被撤销、同一会话并发翻页(#1210 review 的 P1)。 // - 空洞较新一侧那行已不在窗口里 —— /clear、rewind 或整窗替换把它拿掉了,继续 merge 会 // 把刚被移除的历史(甚至 clearedAt 之前的消息)塞回窗口。锚点没了就等于这处空洞不存在了。 - isCancelled: () => backfillSessionRef.current !== sessionIdAtStart + 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 刻意**不记**:它的两个触发条件本身就不会招来立刻重试 —— 会话切走时当前会话 @@ -3939,11 +3959,12 @@ export default function SessionScreen() { else if (outcome === 'failed') state.failed.add(gapKey); else if (outcome !== 'cancelled') state.backfilled.add(gapKey); }).finally(() => { - // 函数式更新:切会话后新会话可能已经起了自己的那一轮,不能被这次收尾误清。 - setBackfillInFlightSessionId((current) => (current === sessionIdAtStart ? null : current)); + // 按 **seq** 精确匹配再清:切会话(甚至切回同一会话)后可能已经起了新的一轮,按 sid 比会把 + // 新轮的标记误清、于是又放行一轮,越滚越多(#1210 review 的 P1)。 + setBackfillInFlightRun((current) => (current?.seq === runSeq ? null : current)); }); }, [ - backfillInFlightSessionId, + backfillInFlightRun, connectionEpoch, deviceId, loading, diff --git a/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts b/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts index 7d19a59086a..6128610b949 100644 --- a/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts +++ b/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts @@ -1,18 +1,22 @@ /** * 空洞补齐在会话屏幕上的接线守卫。 * - * 补齐算法本身在 `historyWindowGap.test.ts` 有行为测试;这里锁住屏幕侧那几个"删掉也照样跑、 - * 但会悄悄踩坑"的前置条件 —— 它们各自对应一个具体故障: - * - 同步门槛用 lastSyncedAt 而不是 session+连接代 → 原地切会话时它仍是上一个会话的非空值, - * 补齐基于旧缓存快照动手,而空洞 key 已记为已考察,那一处从此不再重试(#1210 review); - * - 不与「加载更早」/ 自身飞行互斥 → 两条 before 游标并发翻页,窗口反复 merge; - * - 飞行标记不带会话 id → 会话 A 的补齐挡掉 B 的;标记用 ref 而非 state → 清除时不触发重跑, - * B 的空洞在本次访问期间再也不被检测(#1210 review P1); - * - 每处空洞不去重、不设总闸 → messages 每变一次就重新发请求,一路往上翻整场历史; - * - 已考察集合不传给检测 → contiguous 结局不 merge、跳变留在窗口里,检测永远返回同一处, - * 更早处的真实缺行进不了探测(#1210 review); - * - isCancelled 只比 effect 闭包里的 sessionId → 那个值恒等于启动时的值,等于没有取消; - * - 不检查锚点行是否还在窗口 → /clear、rewind 之后把刚被移除的历史 merge 回去。 + * 补齐算法本身在 `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'; @@ -21,48 +25,56 @@ import { describe, expect, it } from 'vitest'; describe('history window backfill wiring', () => { const source = readFileSync(resolve(process.cwd(), 'app/sessions/[sessionId].tsx'), 'utf8'); - it('同步门槛按 session + 连接代判定,不用屏幕级 lastSyncedAt', () => { - expect(source).toContain('if (readAckSyncedKey !== `${sessionId}:${connectionEpoch}`) return;'); - expect(source).not.toContain('|| lastSyncedAt === null) return;'); + 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('backfillRunSeqRef.current += 1;'); + // 已退役的可摆动判据不得回归。 + expect(source).not.toContain('backfillSessionRef'); + expect(source).not.toContain('backfillInFlightRef'); }); - it('与加载中/加载更早互斥,飞行标记带会话 id 且是可观察 state', () => { - expect(source).toContain('if (loading || loadingEarlier || backfillInFlightSessionId === sessionId) return;'); - expect(source).toContain('const [backfillInFlightSessionId, setBackfillInFlightSessionId] = useState(null);'); - // 收尾用函数式更新:切会话后新会话可能已起了自己那一轮,不能被旧的收尾误清。 - expect(source).toContain('setBackfillInFlightSessionId((current) => (current === sessionIdAtStart ? null : current));'); - 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('已考察空洞按结局分三类:额度只算翻过页的,失败绑连接代,跳过表取并集', () => { - // 额度只看 backfilled:正常停顿(contiguous)不该吃掉额度,否则几处隔夜间隔就能让更早的 - // 真实缺行永远排不到探测。 - expect(source).toContain('if (gapState.backfilled.size >= 3) return;'); - expect(source).toContain('findHistoryWindowGap(messages, consideredKeys)'); - expect(source).toContain('...gapState.contiguous,'); - expect(source).toContain('...gapState.backfilled,'); - expect(source).toContain('...gapState.failed,'); - // 断线那次不得把空洞永久钉死:换连接代只清 failed,重连后同一处可以再试。 - expect(source).toContain('gapState.failed.clear();'); - // 换会话时整体重置,否则上个会话的已考察集合会压住新会话的补齐。 - expect(source).toContain('existingState?.sid === sessionId'); + it('不变量 3:同步门槛按 session + 连接代,不用屏幕级 lastSyncedAt', () => { + expect(source).toContain('if (readAckSyncedKey !== `${sessionId}:${connectionEpoch}`) return;'); + expect(source).not.toContain('|| lastSyncedAt === null) return;'); }); - it('结局归类在收尾而非发起前,且丢弃跨会话/跨连接代落地的旧结局', () => { + it('不变量 4:结局分三类,失败绑连接代,cancelled 不记,跳过表取并集', () => { expect(source).toContain("if (outcome === 'contiguous') state.contiguous.add(gapKey);"); expect(source).toContain("else if (outcome === 'failed') state.failed.add(gapKey);"); - // cancelled 不记:会话切走后回来理应重新考察,锚点行被移除时那处跳变本身也不在了。 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('取消判定走会话镜像 ref 与锚点行是否仍在窗口,不是 effect 闭包里的 sessionId', () => { - expect(source).toContain('backfillSessionRef.current = sessionId;'); - expect(source).toContain('isCancelled: () => backfillSessionRef.current !== sessionIdAtStart'); - expect(source).toContain('.some((row) => row.id === gap.newerId)'); + 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('补齐失败不写 error / loadingEarlier:它是静默自愈,不占用户可见的加载态', () => { + 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); diff --git a/apps/mobile/src/session/historyWindowGap.ts b/apps/mobile/src/session/historyWindowGap.ts index bb3d279706d..59954dcd007 100644 --- a/apps/mobile/src/session/historyWindowGap.ts +++ b/apps/mobile/src/session/historyWindowGap.ts @@ -49,6 +49,32 @@ import type { RemoteMessage } from '@/session/types'; */ 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; + /** * 请求次数上限,与行数预算分开计。 * From 01789c3b0e452e6c36dfea0d5a169ed6bde6c553 Mon Sep 17 00:00:00 2001 From: Dash Date: Fri, 31 Jul 2026 19:41:49 +0800 Subject: [PATCH 05/12] =?UTF-8?q?fix(mobile):=20=E5=88=86=E9=A1=B5?= =?UTF-8?q?=E6=B8=B8=E6=A0=87=E6=8C=89=E6=9C=8D=E5=8A=A1=E7=AB=AF=E9=A1=BA?= =?UTF-8?q?=E5=BA=8F=E5=8F=96=E9=A1=B5=E5=B0=BE,=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E7=BF=BB=E9=A1=B5=E6=97=B6=E4=BD=9C=E5=BA=9F=E5=9C=A8=E9=A3=9E?= =?UTF-8?q?=E7=9A=84=E8=A1=A5=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第四轮 review 的两条 P2: 1. 游标选择与服务端分页契约脱钩。`local-db:messages:list` 按 createdAt DESC, rowid DESC 分页, 而补齐自己按 (createdAt, id 字典序) 挑"最旧"行:同一毫秒落库的多行在客户端只剩相同的 createdAt,再拿 id 字典序做次级键就与 rowid 次序无关,可能挑中页内**较新**那行当 before, 下一页把已 merge 的行再取回来 —— 连续同毫秒消息时补齐在 12 次预算内只前进几行,然后把这处 空洞永久记成 budget,历史仍然缺失。改为按服务端返回顺序取页尾(最新在前、页尾最旧), 并把这个契约写进 listPage 的文档。rowid 不在手机端 RemoteMessage 契约里(compact 后也不 保证带上),所以只能依赖顺序。 2. 手动「加载更早」与在飞的自动补齐并发。启动前那道 loadingEarlier 守卫只挡住"手动先开始"的 顺序;"自动先开始、用户随后点击"时那一轮已经在飞,守卫管不到,于是手动翻页与最多 12 轮自动 before 请求并发,重复拉取、反复 merge。把 loadingEarlier 纳入作废 effect 的依赖:开始手动 翻页即占掉一个序号,在飞那轮在下一次 isCancelled 上收手。仍是同一个单调判据,不新增第二套 取消条件(不变量 1)。 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Dash --- apps/mobile/app/sessions/[sessionId].tsx | 12 ++++-- .../historyWindowBackfillWiring.test.ts | 4 +- .../src/__tests__/historyWindowGap.test.ts | 29 ++++++++++++-- apps/mobile/src/session/historyWindowGap.ts | 38 +++++++++++++------ 4 files changed, 63 insertions(+), 20 deletions(-) diff --git a/apps/mobile/app/sessions/[sessionId].tsx b/apps/mobile/app/sessions/[sessionId].tsx index 4c722330cb0..4ac607ae3e2 100644 --- a/apps/mobile/app/sessions/[sessionId].tsx +++ b/apps/mobile/app/sessions/[sessionId].tsx @@ -3864,13 +3864,17 @@ export default function SessionScreen() { /** 单调递增的补齐轮次计数器;`latest` 是本屏最新那一轮的序号(旧轮据此自我作废)。 */ const backfillRunSeqRef = useRef(0); const backfillLatestRunSeqRef = useRef(0); - // 切会话即作废在飞的那一轮:占掉一个序号但不启动任何轮,于是在飞的旧轮在下一次 isCancelled 上 - // 收手 —— 用户已经离开的会话不值得继续花翻页请求。走同一个单调序号而不是"比较当前会话 id", - // 是因为后者会随切回来而摆回、把取消撤销掉(#1210 review 的 P1);序号只增不减,作废是终态。 + // 两种情况要作废在飞的那一轮:占掉一个序号但不启动任何轮,于是它在下一次 isCancelled 上收手。 + // - **切会话**:用户已经离开的会话不值得继续花翻页请求; + // - **用户手动开始「加载更早」**:两者都按 before 游标翻页,并发只会重复拉取、反复 merge。 + // 启动前那道 `loadingEarlier` 守卫只挡住"手动先开始"的顺序,挡不住"自动先开始、用户随后 + // 点击"——那一轮已经在飞,必须由这里作废(#1210 review)。 + // 作废走同一个单调序号,而不是在 isCancelled 里比"当前会话 id / loadingEarlier 是否变了": + // 那类判据会随状态摆回而把取消撤销掉(#1210 review 的 P1);序号只增不减,作废是终态。 useEffect(() => { backfillRunSeqRef.current += 1; backfillLatestRunSeqRef.current = backfillRunSeqRef.current; - }, [sessionId]); + }, [sessionId, loadingEarlier]); useEffect(() => { if (!deviceId || !sessionId) return; // 同步门槛必须按 **session + 连接代** 判定,不能用 lastSyncedAt:屏实例复用、原地从会话 A diff --git a/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts b/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts index 6128610b949..c650cbd28be 100644 --- a/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts +++ b/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts @@ -34,8 +34,10 @@ describe('history window backfill wiring', () => { expect(source).toContain('if (backfillLatestRunSeqRef.current !== runSeq) return;'); // 收尾按 seq 精确清标记:按 sid 比会把切回同一会话后新起那一轮的标记误清。 expect(source).toContain('setBackfillInFlightRun((current) => (current?.seq === runSeq ? null : current));'); - // 切会话即占掉一个序号作废在飞的那一轮(单向,不启动新轮)。 + // 切会话、或用户手动开始「加载更早」时占掉一个序号作废在飞的那一轮(单向,不启动新轮)。 + // loadingEarlier 必须在依赖里:启动前守卫只挡"手动先开始",挡不住"自动先开始、用户随后点"。 expect(source).toContain('backfillRunSeqRef.current += 1;'); + expect(source).toContain('}, [sessionId, loadingEarlier]);'); // 已退役的可摆动判据不得回归。 expect(source).not.toContain('backfillSessionRef'); expect(source).not.toContain('backfillInFlightRef'); diff --git a/apps/mobile/src/__tests__/historyWindowGap.test.ts b/apps/mobile/src/__tests__/historyWindowGap.test.ts index d05114c681f..f63e6fc60f8 100644 --- a/apps/mobile/src/__tests__/historyWindowGap.test.ts +++ b/apps/mobile/src/__tests__/historyWindowGap.test.ts @@ -109,10 +109,11 @@ describe('backfillHistoryWindowGap', () => { it('探测发现别的行 → 继续翻页直到取回目标行', async () => { const merged: string[] = []; + // 每页按服务端契约排列:最新在前、页尾最旧(见 nextPageCursor)。 const pages: Record = { tail: [row('mid-1', 100)], - 'mid-1': [row('mid-2', 60), row('mid-3', 80)], - 'mid-2': [row('head', 2), row('head-2', 4)], + '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, { @@ -122,7 +123,29 @@ describe('backfillHistoryWindowGap', () => { }); expect(outcome).toBe('covered'); - expect(merged).toEqual(['mid-1', 'mid-2', 'mid-3', 'head', 'head-2']); + 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 () => { diff --git a/apps/mobile/src/session/historyWindowGap.ts b/apps/mobile/src/session/historyWindowGap.ts index 59954dcd007..0b44b850020 100644 --- a/apps/mobile/src/session/historyWindowGap.ts +++ b/apps/mobile/src/session/historyWindowGap.ts @@ -113,7 +113,10 @@ export type HistoryBackfillOutcome = export interface HistoryBackfillDeps { /** - * 按 `before` 游标取一页(实现方负责 payload 降级重试)。返回的行序不限,本模块只按 id 判定。 + * 按 `before` 游标取一页(实现方负责 payload 降级重试)。 + * + * **必须保持服务端返回顺序**(`local-db:messages:list` 是最新在前、页尾最旧):下一页的游标按 + * 页尾取,见 `nextPageCursor` 的说明。实现方不要在这里重排。 */ listPage(before: string, limit: number): Promise; /** 把取回的行并入窗口(按 key 合并,不覆盖更完整的既有行)。 */ @@ -183,7 +186,7 @@ export async function backfillHistoryWindowGap( } deps.merge(probe); - let before = oldestRowId(probe) ?? gap.newerId; + let before = nextPageCursor(probe) ?? gap.newerId; let rows = probe.length; let requests = 1; while (rows < HISTORY_BACKFILL_MAX_ROWS && requests < HISTORY_BACKFILL_MAX_REQUESTS) { @@ -194,7 +197,7 @@ export async function backfillHistoryWindowGap( deps.merge(page); rows += page.length; if (page.some((row) => row.id === gap.olderId)) return 'covered'; - const nextBefore = oldestRowId(page); + const nextBefore = nextPageCursor(page); // 游标没有前进(整页都是没有 id 的行,或被控端反复返回同一段)→ 停手,避免死循环。 if (!nextBefore || nextBefore === before) return 'exhausted'; before = nextBefore; @@ -205,17 +208,28 @@ export async function backfillHistoryWindowGap( } } -function oldestRowId(page: readonly RemoteMessage[]): string | null { - let oldest: { id: string; ms: number } | null = null; - for (const message of page) { +/** + * 下一页的 `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 { + for (let index = page.length - 1; index >= 0; index--) { + const message = page[index]; if (!message.id || message.id.startsWith('mobile-system-')) continue; - const ms = Date.parse(message.createdAt); - if (!Number.isFinite(ms)) continue; - if (!oldest || ms < oldest.ms || (ms === oldest.ms && message.id.localeCompare(oldest.id) < 0)) { - oldest = { id: message.id, ms }; - } + return message.id; } - return oldest?.id ?? null; + return null; } /** 同一处空洞的稳定标识:补齐失败 / 判定为真安静之后,不再对同一处重复发请求。 */ From 19917b2e876a268f2d2cfb4a2371e0df56992a0d Mon Sep 17 00:00:00 2001 From: Dash Date: Fri, 31 Jul 2026 20:04:52 +0800 Subject: [PATCH 06/12] =?UTF-8?q?fix(mobile):=20=E7=A9=BA=E6=B4=9E?= =?UTF-8?q?=E5=88=A4=E5=AE=9A=E6=94=B9=E6=8C=89=E6=97=B6=E5=88=BB,?= =?UTF-8?q?=E5=90=83=E6=8E=89=E5=90=8C=E6=AF=AB=E7=A7=92=E7=BB=84=E5=9C=A8?= =?UTF-8?q?=E6=89=8B=E6=9C=BA=E7=AB=AF=E4=B8=8D=E5=8F=AF=E7=9F=A5=E7=9A=84?= =?UTF-8?q?=E6=AC=A1=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第五轮 review 的一条 P2,以及它在另外两条 settle 路径上的对称另一半。 问题:同一毫秒落库的多行在手机端只剩相同 createdAt,而服务端的次级排序键 rowid 并不在窗口里 每一行上都有(push 追加的行就没有),所以**检测阶段无法知道同毫秒组内谁更旧**。上一轮只修了翻页 游标(按服务端顺序取页尾),检测阶段仍按 id 字典序挑锚点:挑中的 newerId 可能不是该组最旧那行, limit=1 探测于是取回同组的另一行 —— 按 id 精确匹配就误判成有洞,一路翻页并把这处正常停顿记成 backfilled,三处这样的停顿即可耗尽翻页额度、让更早的真实空洞继续缺失。 修法不去猜组内次序(rowid 不可靠),而是让判定对这种模糊性免疫: - HistoryWindowGap 带上两侧的落库时刻(newerMs / olderMs); - 探测按**时刻**判定并允许沿同毫秒组往更旧处推进(HISTORY_GAP_PROBE_MAX_STEPS=8):取回的行落在 olderMs 或更早 → contiguous;恰好还在 newerMs → 同组另一行,换成它继续探;其它 → 有洞,交给 翻页循环。病态数据(几百行挤在同一毫秒)超出步数后按有洞处理,翻页自己会正常前进。 - 翻页的连上判定同样从「取回 olderId」改成「取回 olderMs 或更早的行」—— 较旧侧也可能是同毫秒组 里的任意一行,按 id 比会漏判成"还没连上",白翻到预算耗尽再把已补好的空洞记成 budget。 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Dash --- .../src/__tests__/historyWindowGap.test.ts | 64 ++++++++++- apps/mobile/src/session/historyWindowGap.ts | 107 +++++++++++++++--- 2 files changed, 153 insertions(+), 18 deletions(-) diff --git a/apps/mobile/src/__tests__/historyWindowGap.test.ts b/apps/mobile/src/__tests__/historyWindowGap.test.ts index f63e6fc60f8..51880976b3d 100644 --- a/apps/mobile/src/__tests__/historyWindowGap.test.ts +++ b/apps/mobile/src/__tests__/historyWindowGap.test.ts @@ -38,7 +38,13 @@ describe('findHistoryWindowGap', () => { it('找到跳变两侧的行', () => { const gap = findHistoryWindowGap([row('head', 0), row('head-2', 2), row('tail', 140)]); - expect(gap).toEqual({ newerId: 'tail', olderId: 'head-2', gapMs: 138 * 60_000 }); + 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('多处跳变时取最靠尾部的一处', () => { @@ -78,7 +84,13 @@ describe('findHistoryWindowGap', () => { expect(tailGap.newerId).toBe('c'); const earlierGap = findHistoryWindowGap(window, new Set([historyWindowGapKey(tailGap)])); - expect(earlierGap).toEqual({ newerId: 'b', olderId: 'a', gapMs: 100 * 60_000 }); + 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), @@ -89,7 +101,13 @@ describe('findHistoryWindowGap', () => { }); describe('backfillHistoryWindowGap', () => { - const gap: HistoryWindowGap = { newerId: 'tail', olderId: 'head', gapMs: 138 * 60_000 }; + 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(); @@ -148,6 +166,46 @@ describe('backfillHistoryWindowGap', () => { 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 → 必须继续翻。 diff --git a/apps/mobile/src/session/historyWindowGap.ts b/apps/mobile/src/session/historyWindowGap.ts index 0b44b850020..2e1583278e8 100644 --- a/apps/mobile/src/session/historyWindowGap.ts +++ b/apps/mobile/src/session/historyWindowGap.ts @@ -88,11 +88,33 @@ 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` 游标。 */ + /** 空洞较新一侧那一行的 id —— 探测与向上翻页的起始 `before` 游标。 */ newerId: string; /** 空洞较旧一侧那一行的 id —— 取回它即视为窗口已连上。 */ olderId: string; + /** + * 两侧的落库时刻。判定"是否相邻"用的是**时刻**而不是 id 精确匹配:两侧都可能是同毫秒组里的 + * 任意一行(见 `HISTORY_GAP_PROBE_MAX_STEPS`),按时刻比就把这种模糊性一并吃掉 —— 探测取回的行 + * 只要落在 `olderMs` 或更早,就说明 `newerId` 之前没有别的时刻,这段安静是真的。 + */ + newerMs: number; + olderMs: number; /** 两行的时间差,仅用于日志与测试断言。 */ gapMs: number; } @@ -156,7 +178,16 @@ export function findHistoryWindowGap( const older = rows[index - 1]; const gapMs = newer.ms - older.ms; if (gapMs <= HISTORY_GAP_SPLIT_MS) continue; - const gap: HistoryWindowGap = { newerId: newer.id, olderId: older.id, gapMs }; + // 同毫秒组内的次序在手机端不可知(见 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; } @@ -176,19 +207,43 @@ export async function backfillHistoryWindowGap( ): Promise { if (deps.isCancelled()) return 'cancelled'; try { - const probe = await deps.listPage(gap.newerId, HISTORY_GAP_PROBE_LIMIT); - if (deps.isCancelled()) return 'cancelled'; - if (probe.length === 0) return 'exhausted'; - if (probe.some((row) => row.id === gap.olderId)) { - // 服务端相邻:窗口本来就连续,这段安静是真的。探测到的行已在窗口里,merge 是幂等的, - // 但也没有必要——直接收工,不留副作用。 - return 'contiguous'; + // ── 探测:先确认这段安静到底是不是空洞 ──────────────────────────────────────────── + // 判定按**时刻**而不是 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; } - deps.merge(probe); - let before = nextPageCursor(probe) ?? gap.newerId; - let rows = probe.length; - let requests = 1; + 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'; @@ -196,7 +251,11 @@ export async function backfillHistoryWindowGap( if (page.length === 0) return 'exhausted'; deps.merge(page); rows += page.length; - if (page.some((row) => row.id === gap.olderId)) return 'covered'; + // 连上判定同样按**时刻**而不是 `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'; @@ -224,10 +283,28 @@ export async function backfillHistoryWindowGap( * 跳过本地合成的系统卡(`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.id; + return message; } return null; } From 1f65b52052965e685061c5ab6b78f7cbabb825fe Mon Sep 17 00:00:00 2001 From: Dash Date: Fri, 31 Jul 2026 20:27:45 +0800 Subject: [PATCH 07/12] =?UTF-8?q?fix(mobile):=20=E8=BA=AB=E4=BB=BD?= =?UTF-8?q?=E4=B8=8D=E7=A1=AE=E5=AE=9A=E7=9A=84=E4=BF=A1=E5=8F=B7=E9=80=80?= =?UTF-8?q?=E5=87=BA=E5=88=A4=E6=8D=AE(=E7=A9=BA=E6=B4=9E=20key=20?= =?UTF-8?q?=E7=94=A8=E6=97=B6=E5=88=BB=E5=AF=B9,settledAt=20=E5=8F=AA?= =?UTF-8?q?=E8=AE=A4=20toolUseId)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第六轮 review 的两条 P2。它们与前两轮是同一族(身份不精确污染下游判据)第三次出现,所以这次不再 逐条补,而是把这条边界写成不变量并让两处判据一起收敛: 1. 空洞去重 key 不稳定。key 原本是 `olderId→newerId`,而两侧各自是同毫秒组里的哪一行并不确定: 探测往组内 merge 进一行、且它的 id 字典序排到原锚点之前时,下一轮检测会挑出**同一处停顿**的 另一个 id 组合 → key 变了 → 同一处被重新探测,一处停顿吃掉两次考察额度,更早的真实空洞在本次 访问里仍然补不上。改成按**两侧时刻对**生成 key:一处跳变的本质是"这两个时刻之间没有东西", 与组内成员无关。 2. 邻接兜底猜出来的结果时刻不得当作工具结束锚点。内容侧的邻接兜底按"紧邻的下一条 tool_result" 猜归属,在稀疏窗口里会猜错:孤岛窗口的旧段末尾 tool_use 恰好与新段开头某条不相关的 tool_result 相邻时,兜底会把新段结果的时刻当成旧工具的 settledAt,于是 tool_group 的结束锚点 被推到几小时后,真实的窗口空洞**不再触发切组** —— 渲染兜底正好在最需要它的场景失效。 resultCreatedAtFor 改为只认 toolUseId 精确配对;内容与 settled 状态照旧走邻接(猜错最坏是显示 串了,live 中按 id 到达即自愈)。缺失时上游退回调用发起时刻,代价只是可能多切一个折叠条。 不变量 7(已写进 PR 描述与守卫测试文件头):身份不确定的信号不进判据 —— 同毫秒组内的行 id、 按邻接位置猜出的 tool_result 归属都不足以确定身份;内容可以猜,进入判据的时刻不能猜。 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Dash --- .../src/__tests__/historyWindowGap.test.ts | 19 +++++- apps/mobile/src/session/historyWindowGap.ts | 12 +++- .../src/__tests__/messageNormalize.test.ts | 58 +++++++++++++++++++ .../__tests__/messageRenderHistoryGap.test.ts | 16 +++++ packages/maker-shared/src/messageNormalize.ts | 30 +++++----- 5 files changed, 116 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/__tests__/historyWindowGap.test.ts b/apps/mobile/src/__tests__/historyWindowGap.test.ts index 51880976b3d..9d9fce83112 100644 --- a/apps/mobile/src/__tests__/historyWindowGap.test.ts +++ b/apps/mobile/src/__tests__/historyWindowGap.test.ts @@ -70,9 +70,22 @@ describe('findHistoryWindowGap', () => { expect(findHistoryWindowGap([row('a', 0), broken, row('b', 5)])).toBeNull(); }); - it('空洞 key 稳定可用于去重', () => { - const gap = findHistoryWindowGap([row('older', 0), row('newer', 140)]) as HistoryWindowGap; - expect(historyWindowGapKey(gap)).toBe('older→newer'); + 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('跳过已考察的跳变,继续往更早处找', () => { diff --git a/apps/mobile/src/session/historyWindowGap.ts b/apps/mobile/src/session/historyWindowGap.ts index 2e1583278e8..fd5c7d596ea 100644 --- a/apps/mobile/src/session/historyWindowGap.ts +++ b/apps/mobile/src/session/historyWindowGap.ts @@ -309,7 +309,15 @@ function pageTailRow(page: readonly RemoteMessage[]): RemoteMessage | null { return null; } -/** 同一处空洞的稳定标识:补齐失败 / 判定为真安静之后,不再对同一处重复发请求。 */ +/** + * 同一处空洞的稳定标识:补齐失败 / 判定为真安静之后,不再对同一处重复发请求。 + * + * 身份取**两侧的时刻对**,不是两侧的行 id:一处跳变的本质是"这两个时刻之间没有东西",而两侧各自 + * 是同毫秒组里的哪一行是不确定的(见 `HISTORY_GAP_PROBE_MAX_STEPS`)。用 id 组 key 时,只要探测 + * 往组内 merge 进一行、而它的 id 在字典序上排到原锚点之后,下一轮检测就会挑出**同一处停顿**的 + * 另一个 id 组合 → key 变了 → 同一处被重新探测,一处停顿吃掉两次考察额度,更早的真实空洞在本次 + * 访问里仍然补不上(#1210 review)。时刻对不受组内成员变化影响。 + */ export function historyWindowGapKey(gap: HistoryWindowGap): string { - return `${gap.olderId}→${gap.newerId}`; + return `${gap.olderMs}→${gap.newerMs}`; } 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 index f491560c4e9..368019d7121 100644 --- a/packages/maker-shared/src/__tests__/messageRenderHistoryGap.test.ts +++ b/packages/maker-shared/src/__tests__/messageRenderHistoryGap.test.ts @@ -190,6 +190,22 @@ describe('工作组分组 — 历史窗口空洞', () => { 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, '第一问'), diff --git a/packages/maker-shared/src/messageNormalize.ts b/packages/maker-shared/src/messageNormalize.ts index 442ed97b48d..e7de7edcb7d 100644 --- a/packages/maker-shared/src/messageNormalize.ts +++ b/packages/maker-shared/src/messageNormalize.ts @@ -29,9 +29,17 @@ export interface MessageToolResultPairing< /** * 配对 tool_result 的落库时刻(ISO),即这次工具调用的**结束**时刻;未到达时 undefined。 * - * 与 `hasResultFor` 同口径**不过 `shouldHideToolResult` 滤网**:结束时间与内容是否展示 - * 无关(桌面 MessageStream 的 resultTsMap 同款)。渲染层用它做历史空洞判定的锚点 —— 只看 - * 调用发起时刻会把「一次跑了半小时以上的调用」之后的下一个动作误判成空洞。 + * 两条刻意的口径: + * - **不过 `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; } @@ -105,7 +113,6 @@ export function buildMessageToolResultPairing< const adjacencyResultByMessageKey = new Map(); const settledAdjacencyMessageKeys = new Set(); - const adjacencyResultCreatedAtByMessageKey = new Map(); for (let index = 0; index < sortedMessages.length; index++) { const message = sortedMessages[index]; if (message.role !== 'tool_use') continue; @@ -122,10 +129,6 @@ export function buildMessageToolResultPairing< const adjacent = sortedMessages[index + 1]; if (adjacent?.role !== 'tool_result') continue; settledAdjacencyMessageKeys.add(messageNormalizeKey(message)); - const adjacentCreatedAt = readNonEmptyString(adjacent.createdAt); - if (adjacentCreatedAt) { - adjacencyResultCreatedAtByMessageKey.set(messageNormalizeKey(message), adjacentCreatedAt); - } const content = contentToPreview(adjacent.content); if (shouldHideToolResult(tool.toolName, content)) continue; adjacencyResultByMessageKey.set(messageNormalizeKey(message), content); @@ -146,12 +149,11 @@ export function buildMessageToolResultPairing< if (tool.toolUseId && settledToolUseIds.has(tool.toolUseId)) return true; return settledAdjacencyMessageKeys.has(messageNormalizeKey(message)); }, - resultCreatedAtFor(message, tool) { - if (tool.toolUseId) { - const byId = resultCreatedAtByToolUseId.get(tool.toolUseId); - if (byId !== undefined) return byId; - } - return adjacencyResultCreatedAtByMessageKey.get(messageNormalizeKey(message)); + resultCreatedAtFor(_message, tool) { + // 只按 toolUseId 精确配对 —— 邻接兜底猜出来的归属不足以支撑"结束时刻"这个判据,理由见 + // 接口上的说明。 + if (!tool.toolUseId) return undefined; + return resultCreatedAtByToolUseId.get(tool.toolUseId); }, }; } From 180f31df6c7410e24e199d2c5f5acab889d640e4 Mon Sep 17 00:00:00 2001 From: Dash Date: Fri, 31 Jul 2026 21:09:40 +0800 Subject: [PATCH 08/12] =?UTF-8?q?fix(mobile):=20=E4=BB=8E=E6=BA=90?= =?UTF-8?q?=E5=A4=B4=E4=BF=9D=E8=AF=81=E6=B6=88=E6=81=AF=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E8=BF=9E=E7=BB=AD,=E4=B8=8D=E5=86=8D=E5=8F=AA=E9=9D=A0?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E9=98=88=E5=80=BC=E5=8F=91=E7=8E=B0=E7=A9=BA?= =?UTF-8?q?=E6=B4=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1222。 原判据的漏洞:setLatestMessageWindow 只要求缓存旧页与最新页**有交集**就整段保留 —— 交集只说明 两段有共同的行,不排除更早那一段与本页之间还隔着服务端仍有、本地从未加载的行。于是窗口留下 "首段 + 尾段"的孤岛。此前这一半靠时间阈值的空洞检测(historyWindowGap)兜:两侧间隔超 30 分钟才 触发探测与补齐。断连期间漏收几十上百条、而它们在半小时内快速产生时(一个长 turn 里的连续工具 调用就是),两侧间隔根本不大 —— 检测不到,渲染守卫同阈值也不切组,那段历史静默缺失。 改用结构信号,不依赖时间:调用方把 moreBeyondWindow(本页满页、或被 device-link 裁过行 = 本页 上沿之外服务端还有历史)传给 store,为真时早于本页最旧行的缓存段一律丢弃 —— 它与本页之间无法 确认相接。窗口于是始终是"某点 → 最新"的连续区间,孤岛在源头消失。代价是用户可见历史变少(丢掉 的正是不可信那段),「加载更早」入口仍在、可按连续分页重新取回。本页不满页时服务端已给全, 不存在中间缺口,旧段照原判据保留。 判据与「加载更早」入口同源(shouldKeepOlderMessagesAffordance):「上沿之外还有历史」既决定是否 点亮那个入口,也决定能不能信任更早的缓存段,本就该是同一个判断。两个调用点(会话屏首开/重开、 DeviceLinkContext 断连补齐)都已接上。 historyWindowGap 保留,角色变成:①兜底旧版本客户端留下的缓存与任何绕过该判据的不连续窗口; ②把缺失的行真正取回来 —— store 那条判据只保证"不把不可信的段当相邻",它丢段而不补内容。 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Dash --- apps/mobile/app/sessions/[sessionId].tsx | 14 ++++-- .../src/__tests__/remoteSessionStore.test.ts | 44 +++++++++++++++++++ .../src/__tests__/scrollWindowModel.test.ts | 8 +++- .../src/device-link/DeviceLinkContext.tsx | 16 ++++++- apps/mobile/src/session/historyWindowGap.ts | 11 ++++- apps/mobile/src/session/remoteSessionStore.ts | 38 +++++++++++++++- 6 files changed, 119 insertions(+), 12 deletions(-) diff --git a/apps/mobile/app/sessions/[sessionId].tsx b/apps/mobile/app/sessions/[sessionId].tsx index 4ac607ae3e2..f17408bd9a3 100644 --- a/apps/mobile/app/sessions/[sessionId].tsx +++ b/apps/mobile/app/sessions/[sessionId].tsx @@ -2896,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 { @@ -2944,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 已加载 diff --git a/apps/mobile/src/__tests__/remoteSessionStore.test.ts b/apps/mobile/src/__tests__/remoteSessionStore.test.ts index 4c8b18cca98..3430b086abf 100644 --- a/apps/mobile/src/__tests__/remoteSessionStore.test.ts +++ b/apps/mobile/src/__tests__/remoteSessionStore.test.ts @@ -1182,7 +1182,51 @@ describe('remoteSessionStore', () => { ]); }); + it('丢弃无法确认相接的更早缓存段:本页上沿之外服务端还有历史时(#1222)', () => { + // 「有交集」不等于「连续」:交集只说明两段有共同的行,不排除更早那一段与本页之间还隔着 + // 服务端仍有、本地从未加载的行。断连期间漏收几十上百条 push 时就是这样,而漏收的量不大时 + // 两侧时间差很小 —— 时间阈值的空洞检测发现不了,窗口会静默留下孤岛。 + // moreBeyondWindow(本页满页 / 被裁行)为真时,更早的缓存段一律丢弃,窗口保持连续区间。 + remoteSessionStore.setMessages('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('丢弃更早缓存段时仍保留比本页更新的实时 push 行与本地系统卡', () => { + // 收紧的只是"更早那一段"这一条判据:尾部的 live push 与没有服务端对应行的本地卡不受影响。 + remoteSessionStore.setMessages('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..5233bb1c328 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,12 @@ 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; export function DeviceLinkProvider({ children }: { children: ReactNode }) { if (MOBILE_VISUAL_MOCK_ENABLED) { @@ -966,7 +973,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 +998,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 index fd5c7d596ea..115932f37cd 100644 --- a/apps/mobile/src/session/historyWindowGap.ts +++ b/apps/mobile/src/session/historyWindowGap.ts @@ -8,10 +8,17 @@ * - `listMessages` 的最新窗口(`MESSAGE_PAGE_SIZE` 条,payload 超限时还会降级到更少); * - `local-db:messages:created` push 逐条追加的尾部。 * - * 这些来源之间没有"必须连续"的保证:app 退到后台 / 网络抖动 / relay 断连期间漏收的 push - * 不会补回来,而 `setLatestMessageWindow` 只要求缓存旧页与最新页**有交集**就整段保留。于是 + * 这些来源之间原本没有"必须连续"的保证: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` 守卫兜底(不会再谎报 diff --git a/apps/mobile/src/session/remoteSessionStore.ts b/apps/mobile/src/session/remoteSessionStore.ts index afc5a4e6197..341f9d18ba1 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; @@ -1135,7 +1148,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) { @@ -1174,10 +1191,27 @@ 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)。从源头保证连续区间才覆盖得住。 + const keepOlderCachedPages = hasOverlap && options.moreBeyondWindow !== true; for (const item of existing) { const createdAt = item.createdAt; const isNewerThanLatestPage = createdAt.localeCompare(latestNewestCreatedAt) >= 0; - const isOlderLoadedPage = hasOverlap && createdAt.localeCompare(latestOldestCreatedAt) < 0; + const isOlderLoadedPage = keepOlderCachedPages + && createdAt.localeCompare(latestOldestCreatedAt) < 0; // 本地系统卡(/learn、/context 等)没有服务端对应行:不管时序落在窗口哪里都 // 不会出现在 latestKeys 里,若不单独保留会被 window 刷新时静默丢弃。 const isLocalSystemCard = messageKey(item).startsWith('mobile-system-'); From acee484bae28a564aa882cec1054ee9c3e372112 Mon Sep 17 00:00:00 2001 From: Dash Date: Fri, 31 Jul 2026 21:35:56 +0800 Subject: [PATCH 09/12] =?UTF-8?q?fix(mobile):=20=E5=B7=B2=E8=BF=9E?= =?UTF-8?q?=E7=BB=AD=E7=BF=BB=E5=87=BA=E7=9A=84=E5=8E=86=E5=8F=B2=E4=B8=8D?= =?UTF-8?q?=E8=A2=AB=E6=BB=A1=E9=A1=B5=E5=90=8C=E6=AD=A5=E6=B8=85=E6=8E=89?= =?UTF-8?q?,=E5=8F=AA=E4=B8=A2=E6=9D=A5=E6=BA=90=E4=B8=8D=E6=98=8E?= =?UTF-8?q?=E7=9A=84=E7=BC=93=E5=AD=98=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一个 commit 的收紧太粗:它只看"最新页是否满页",于是把用户一路「加载更早」翻出来的历史也当成 不可信段丢掉 —— 一次普通断线重连(最新快照恰好满 80 行)就会让正在看的历史与滚动锚点消失,而且 自动补齐不会拉回(裁完窗口里已经没有内部跳变可发现),只能手动重翻。#1210 review 指出。 窗口连续性因此分成两档,由 store 的 `sessionContiguousSince`(每会话「已验证连续」的下界)标出: - 建立下界的只有"服务端一次给出的连续段":整窗替换 `setMessages`、最新窗口 `setLatestMessageWindow`、 以及沿 before 从窗口最旧端连续翻页的「加载更早」—— 后者走新入口 `mergeEarlierMessages`。 - 冷开缓存 hydrate 刻意不建立(它与最新页的关系无从确认,正是 #1222 要丢的那类)。 - /clear、rewind、error-persisted 整窗失效、会话回收一律重置为未知,下次最新窗口同步重建。 - 判据本身以 hasOverlap 为前提:下界断言的是"到**窗口最新端**连续",本页与窗口完全不重叠时 (陈旧窗口)那个断言对并集不再成立,不能让旧结论赖着不走 —— 既有的 "replaces a stale cached window" / "keeps live-pushed tail" 两条测试正钉这一点。 - 空洞补齐的 `mergeMessages` 刻意不下移下界:补完虽然更连续,但要算准新下界得先确认窗口里没有 别的洞,而那正是补齐在解决的问题。保守不动的代价是补回的段可能在下次满页同步时被丢弃 (下次打开会重新补),方向上是安全的那一侧。 于是三种段各归其位:已验证连续的保留,来源不明的按 moreBeyondWindow 丢弃,比本页更新的 live push 行与本地系统卡照旧保留。 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Dash --- apps/mobile/app/sessions/[sessionId].tsx | 5 +- .../src/__tests__/remoteSessionStore.test.ts | 53 ++++++++++- apps/mobile/src/session/remoteSessionStore.ts | 94 ++++++++++++++++++- 3 files changed, 146 insertions(+), 6 deletions(-) diff --git a/apps/mobile/app/sessions/[sessionId].tsx b/apps/mobile/app/sessions/[sessionId].tsx index f17408bd9a3..89e2de3436e 100644 --- a/apps/mobile/app/sessions/[sessionId].tsx +++ b/apps/mobile/app/sessions/[sessionId].tsx @@ -3811,7 +3811,10 @@ 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)); diff --git a/apps/mobile/src/__tests__/remoteSessionStore.test.ts b/apps/mobile/src/__tests__/remoteSessionStore.test.ts index 3430b086abf..a23800dd793 100644 --- a/apps/mobile/src/__tests__/remoteSessionStore.test.ts +++ b/apps/mobile/src/__tests__/remoteSessionStore.test.ts @@ -1187,7 +1187,8 @@ describe('remoteSessionStore', () => { // 服务端仍有、本地从未加载的行。断连期间漏收几十上百条 push 时就是这样,而漏收的量不大时 // 两侧时间差很小 —— 时间阈值的空洞检测发现不了,窗口会静默留下孤岛。 // moreBeyondWindow(本页满页 / 被裁行)为真时,更早的缓存段一律丢弃,窗口保持连续区间。 - remoteSessionStore.setMessages('s1', [ + // 用冷开缓存入口种入:它刻意不登记「已验证连续」,正是"来源不明"的那类段。 + remoteSessionStore.hydrateMessagesIfEmpty('s1', [ messageAt('cached-old', 's1', '2026-01-01T00:00:01.000Z'), messageAt('latest-1', 's1', '2026-01-01T10:00:01.000Z'), ]); @@ -1203,9 +1204,57 @@ describe('remoteSessionStore', () => { ]); }); + 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('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.setMessages('s1', [ + 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'), diff --git a/apps/mobile/src/session/remoteSessionStore.ts b/apps/mobile/src/session/remoteSessionStore.ts index 341f9d18ba1..c4b856393fa 100644 --- a/apps/mobile/src/session/remoteSessionStore.ts +++ b/apps/mobile/src/session/remoteSessionStore.ts @@ -216,6 +216,46 @@ const sessionRunStatus = new Map(); const sessionMakerActivityEpochs = new Map(); let makerActivityEpoch = 0; const sessionMessageSyncMarkers = new Map(); +/** + * 每会话「已验证连续」的下界(ISO createdAt):从这一刻起到窗口最新端,窗口是**连续**的 —— 中间 + * 没有服务端有、本地没加载的行。缺省(未登记)= 未知,窗口不被信任为连续。 + * + * 为什么需要它:`setLatestMessageWindow` 判断"要不要保留早于本页的旧段"时,只有两种旧段 + * - **来源不明的缓存**(冷开 hydrate 的那页):与本页的关系无从确认,本页上沿之外还有服务端历史 + * 时必须丢弃,否则窗口留下孤岛(#1222); + * - **已由连续分页验证过的历史**(用户一路「加载更早」翻出来的):它是沿 before 游标从窗口最旧端 + * 连续拉的,与后面的页确实相接。把它一并丢掉会让用户正在看的历史和滚动锚点凭空消失,而且 + * 自动补齐也不会拉回(裁完窗口里已经没有内部跳变可发现了)——#1210 review 实测到的回归。 + * 下界就是区分这两者的那条线。 + * + * 写入点只有三处(都对应"服务端一次给出的连续段"):整窗替换 `setMessages`、最新窗口 + * `setLatestMessageWindow`、以及「加载更早」的 `mergeEarlierMessages`。冷开 hydrate 刻意不写。 + * 清空 / rewind / 会话回收一律删除(重置为未知),下次最新窗口同步会重建。 + * + * 空洞补齐(`mergeMessages`)刻意**不**下移它:那条路补的是窗口内部的洞,补完虽然也让窗口更连续, + * 但要准确算出"新的下界"得先确认窗口里没有别的洞——而这正是补齐本身在解决的问题。保守不动的 + * 代价是补回来的段可能在下一次满页同步时被丢弃(下次打开会重新补),方向上是安全的那一侧。 + */ +const sessionContiguousSince = new Map(); + +/** 取一批行里最旧的 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; +} + +/** 把「已验证连续」的下界向更早处推(只前移,不回退)。 */ +function lowerContiguousSince(sessionId: string, createdAt: string | undefined): void { + if (!createdAt) return; + const current = sessionContiguousSince.get(sessionId); + if (current === undefined || createdAt.localeCompare(current) < 0) { + sessionContiguousSince.set(sessionId, createdAt); + } +} // 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>(); @@ -1126,6 +1166,9 @@ export const remoteSessionStore = { return; } messages.set(sessionId, next); + // 整窗替换:这一页是服务端一次给出的连续段,下界即它的最旧行(前移语义,见 lowerContiguousSince)。 + sessionContiguousSince.delete(sessionId); + lowerContiguousSince(sessionId, oldestCreatedAt(next)); bumpMessageVersion(); emit(); }, @@ -1206,12 +1249,24 @@ export const remoteSessionStore = { // 为什么不靠时间阈值判断空洞:那只能发现"两侧间隔很久"的孤岛。断连期间漏收几十上百条、 // 而它们在半小时内快速产生时(一个长 turn 里的连续工具调用就是),两侧间隔根本不大,检测不到 // (#1222)。从源头保证连续区间才覆盖得住。 - const keepOlderCachedPages = hasOverlap && options.moreBeyondWindow !== true; + // + // 例外(#1210 review):用户一路「加载更早」翻出来的历史是沿 before 游标从窗口最旧端**连续** + // 拉的,与后面的页确实相接 —— 它由 `sessionContiguousSince` 的下界标出。把这种段也丢掉会让 + // 用户正在看的历史与滚动锚点凭空消失,而且补齐不会拉回它(裁完已无内部跳变可发现)。所以 + // 判据是"在已验证连续区间内 → 保留;否则才按 moreBeyondWindow 处置"。 + const keepUnverifiedOlderPages = hasOverlap && options.moreBeyondWindow !== true; + const contiguousSince = sessionContiguousSince.get(sessionId); for (const item of existing) { const createdAt = item.createdAt; const isNewerThanLatestPage = createdAt.localeCompare(latestNewestCreatedAt) >= 0; - const isOlderLoadedPage = keepOlderCachedPages - && createdAt.localeCompare(latestOldestCreatedAt) < 0; + // 下界断言的是"从它到**窗口最新端**连续"。本页与既有窗口完全不重叠时,说明窗口的最新端 + // 已经跟不上服务端(中间断了,或整个窗口早已过时),那个断言对"窗口 + 本页"的并集不再成立; + // 所以已验证连续同样以 hasOverlap 为前提,否则陈旧窗口会靠旧结论赖着不走。 + const isVerifiedContiguous = hasOverlap + && contiguousSince !== undefined + && createdAt.localeCompare(contiguousSince) >= 0; + const isOlderLoadedPage = createdAt.localeCompare(latestOldestCreatedAt) < 0 + && (isVerifiedContiguous || keepUnverifiedOlderPages); // 本地系统卡(/learn、/context 等)没有服务端对应行:不管时序落在窗口哪里都 // 不会出现在 latestKeys 里,若不单独保留会被 window 刷新时静默丢弃。 const isLocalSystemCard = messageKey(item).startsWith('mobile-system-'); @@ -1269,6 +1324,12 @@ export const remoteSessionStore = { return; } messages.set(sessionId, next); + // 本页自身是连续段;若更早的段被丢弃,下界就收敛到本页最旧行。用前移语义合并:既有下界更早 + // 且那些行仍在窗口里(已验证连续)时保持不动。 + if (!next.some((item) => item.createdAt.localeCompare(latestOldestCreatedAt) < 0)) { + sessionContiguousSince.delete(sessionId); + } + lowerContiguousSince(sessionId, latestOldestCreatedAt); bumpMessageVersion(); emit(); }, @@ -1329,6 +1390,20 @@ export const remoteSessionStore = { emit(); }, + /** + * 「加载更早」拉回的一页:沿 `before` 游标**从窗口最旧端连续**往前取,所以它与既有窗口相接。 + * + * 与 `mergeMessages` 的唯一差别是它会把「已验证连续」的下界前移到这一页的最旧行 + * (见 `sessionContiguousSince`):这样后续满页的最新窗口同步就不会把用户一路翻出来的历史 + * 当成来源不明的缓存丢掉(#1210 review 实测到的回归)。 + * + * 只有真正沿窗口最旧端连续翻页的调用方可以用它;补内部空洞请继续用 `mergeMessages`。 + */ + mergeEarlierMessages(sessionId: string, list: readonly RemoteMessage[]): void { + this.mergeMessages(sessionId, list); + lowerContiguousSince(sessionId, oldestCreatedAt(list)); + }, + appendMessage(sessionId: string, message: RemoteMessage): void { let changed = flushPendingTextDelta(sessionId); changed = upsertMessage(sessionId, overlayLivePlanSnapshot(sessionId, message)) || changed; @@ -1351,6 +1426,9 @@ export const remoteSessionStore = { !deletedClientIds.has(message.clientId) && !deletedClientIds.has(message.id) )); sessionMessageSyncMarkers.delete(sessionId); + // 连续性结论随窗口一起失效:rewind 可能删掉中间的行,清空/回收更是整窗重来。 + // 重置为未知,下一次最新窗口同步会重建(见 sessionContiguousSince)。 + sessionContiguousSince.delete(sessionId); const messagesChanged = next.length !== existing.length; if (messagesChanged) messages.set(sessionId, next); const deletedTaskAliases = new Set(deletedClientIds); @@ -1714,6 +1792,9 @@ export const remoteSessionStore = { // session 页面监听到 pendingRefreshSessions 变化后调 load(),走 reopen 路径: // sync marker 已失效 → metaChanged=true → 拉最新消息窗口(含 error 行)整窗替换。 sessionMessageSyncMarkers.delete(sessionId); + // 连续性结论随窗口一起失效:rewind 可能删掉中间的行,清空/回收更是整窗重来。 + // 重置为未知,下一次最新窗口同步会重建(见 sessionContiguousSince)。 + sessionContiguousSince.delete(sessionId); pendingRefreshSessions.add(sessionId); } else { // 未缓存:清 sync marker + 标记待刷新。 @@ -1723,6 +1804,9 @@ export const remoteSessionStore = { // 触发同步失效 → metaChanged=true → 整窗替换,error 卡正常浮现。 messages.delete(sessionId); sessionMessageSyncMarkers.delete(sessionId); + // 连续性结论随窗口一起失效:rewind 可能删掉中间的行,清空/回收更是整窗重来。 + // 重置为未知,下一次最新窗口同步会重建(见 sessionContiguousSince)。 + sessionContiguousSince.delete(sessionId); pendingRefreshSessions.add(sessionId); } bumpMessageVersion(); @@ -2133,6 +2217,9 @@ export const remoteSessionStore = { sessionRunStatus.delete(sessionId); sessionMakerActivityEpochs.delete(sessionId); sessionMessageSyncMarkers.delete(sessionId); + // 连续性结论随窗口一起失效:rewind 可能删掉中间的行,清空/回收更是整窗重来。 + // 重置为未知,下一次最新窗口同步会重建(见 sessionContiguousSince)。 + sessionContiguousSince.delete(sessionId); sessionTaskUpdates.delete(sessionId); streamingAssistantClientIds.delete(sessionId); discardPendingTextDelta(sessionId); @@ -2169,6 +2256,7 @@ export const remoteSessionStore = { sessionMakerActivityEpochs.clear(); makerActivityEpoch = 0; sessionMessageSyncMarkers.clear(); + sessionContiguousSince.clear(); sessionTaskUpdates.clear(); streamingAssistantClientIds.clear(); pendingLiveAssistantClientIds.clear(); From 7854016706b71844bc575700c4b4ab701d252334 Mon Sep 17 00:00:00 2001 From: Dash Date: Fri, 31 Jul 2026 22:04:55 +0800 Subject: [PATCH 10/12] =?UTF-8?q?fix(mobile):=20=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E7=BF=BB=E9=A1=B5=E5=90=8C=E6=AD=A5=E4=BD=9C=E5=BA=9F=E5=9C=A8?= =?UTF-8?q?=E9=A3=9E=E8=A1=A5=E9=BD=90;=E5=B7=A5=E4=BD=9C=E7=BB=84?= =?UTF-8?q?=E7=BB=93=E6=9D=9F=E6=97=B6=E5=88=BB=E4=B8=A4=E7=AB=AF=E9=83=BD?= =?UTF-8?q?=E5=85=A8=E9=87=8F=E5=8F=96=20max?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第七轮 review 的两条,并把这两个不变量的消费点穷举补齐,不等下一轮再被指出。 1. 手动翻页与自动补齐仍会短暂并发(codex P2)。上一轮把 loadingEarlier 加进作废 effect 的依赖, 但 effect 是**被动**的:loadEarlierMessages 在 setLoadingEarlier(true) 之后同步就发请求,若自动 补齐在 effect 执行前返回,它照样通过 isCancelled、merge 并继续下一页。作废因此抽成 abandonInFlightBackfill(),手动入口在发请求**之前**同步调用;被动 effect 只留给切会话与 连接代变化(重连 —— 在飞请求走的是旧连接,早收手比等超时干净,这条是穷举时补的第三个入口)。 2. 嵌套工作组的结束时刻取的是"最后一个 child",不是全体最大值(copilot)。children 按发起时刻 排列,并行动作乱序完成时真正的结束可能落在更靠前的 child 上。shared 的 itemEndTimestamp 与 桌面同款函数 renderItemEndMs 都改成全量取 max,与两处已有的 Math.max 锚点(groupWorkRuns 的 prevEndMs、workRunFallbackEnd)口径一致。 如实说明:这条在**当前调用图下不可达** —— 进入 end 计算的 run/items 都来自 buildLinearItems 的 线性输出,不含 work_group(内层组只出现在外组的 children 字段,不参与外组时长)。所以改的是口径 一致性与防御性,写不出能区分新旧实现的测试,也就没有为它硬造用例(试写过一条,新旧实现都通过, 已删掉——留着会假装钉住了什么)。 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Dash --- .../components/chat/MessageStream.tsx | 15 +++++-- apps/mobile/app/sessions/[sessionId].tsx | 44 ++++++++++++------- .../historyWindowBackfillWiring.test.ts | 15 +++++-- packages/maker-shared/src/messageRender.ts | 12 +++-- 4 files changed, 60 insertions(+), 26 deletions(-) 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/mobile/app/sessions/[sessionId].tsx b/apps/mobile/app/sessions/[sessionId].tsx index 89e2de3436e..62e8d0d2141 100644 --- a/apps/mobile/app/sessions/[sessionId].tsx +++ b/apps/mobile/app/sessions/[sessionId].tsx @@ -3797,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); @@ -3804,6 +3828,10 @@ export default function SessionScreen() { setHasOlderMessages(false); return; } + // 同步作废在飞的自动补齐:两者都按 before 游标翻页,并发只会重复拉取、反复 merge。必须在 + // 发请求**之前**同步做掉,不能只靠依赖 loadingEarlier 的 effect —— 那是被动的,自动补齐可能 + // 在它执行前就返回并继续下一页(#1210 review)。 + abandonInFlightBackfill(); setLoadingEarlier(true); setError(null); try { @@ -3821,7 +3849,7 @@ export default function SessionScreen() { } finally { setLoadingEarlier(false); } - }, [deviceId, hasOlderMessages, loadingEarlier, maker, messages, sessionId]); + }, [abandonInFlightBackfill, deviceId, hasOlderMessages, loadingEarlier, maker, messages, sessionId]); /** * 历史窗口空洞的自动补齐(见 `historyWindowGap.ts` 的文件头)。 @@ -3870,20 +3898,6 @@ export default function SessionScreen() { * 当前会话(它自己会在下一次 isCancelled 上收手)。 */ const [backfillInFlightRun, setBackfillInFlightRun] = useState<{ sid: string; seq: number } | null>(null); - /** 单调递增的补齐轮次计数器;`latest` 是本屏最新那一轮的序号(旧轮据此自我作废)。 */ - const backfillRunSeqRef = useRef(0); - const backfillLatestRunSeqRef = useRef(0); - // 两种情况要作废在飞的那一轮:占掉一个序号但不启动任何轮,于是它在下一次 isCancelled 上收手。 - // - **切会话**:用户已经离开的会话不值得继续花翻页请求; - // - **用户手动开始「加载更早」**:两者都按 before 游标翻页,并发只会重复拉取、反复 merge。 - // 启动前那道 `loadingEarlier` 守卫只挡住"手动先开始"的顺序,挡不住"自动先开始、用户随后 - // 点击"——那一轮已经在飞,必须由这里作废(#1210 review)。 - // 作废走同一个单调序号,而不是在 isCancelled 里比"当前会话 id / loadingEarlier 是否变了": - // 那类判据会随状态摆回而把取消撤销掉(#1210 review 的 P1);序号只增不减,作废是终态。 - useEffect(() => { - backfillRunSeqRef.current += 1; - backfillLatestRunSeqRef.current = backfillRunSeqRef.current; - }, [sessionId, loadingEarlier]); useEffect(() => { if (!deviceId || !sessionId) return; // 同步门槛必须按 **session + 连接代** 判定,不能用 lastSyncedAt:屏实例复用、原地从会话 A diff --git a/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts b/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts index c650cbd28be..0081236a1de 100644 --- a/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts +++ b/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts @@ -34,10 +34,19 @@ describe('history window backfill wiring', () => { expect(source).toContain('if (backfillLatestRunSeqRef.current !== runSeq) return;'); // 收尾按 seq 精确清标记:按 sid 比会把切回同一会话后新起那一轮的标记误清。 expect(source).toContain('setBackfillInFlightRun((current) => (current?.seq === runSeq ? null : current));'); - // 切会话、或用户手动开始「加载更早」时占掉一个序号作废在飞的那一轮(单向,不启动新轮)。 - // loadingEarlier 必须在依赖里:启动前守卫只挡"手动先开始",挡不住"自动先开始、用户随后点"。 + // 作废收敛成一个函数,三个入口共用(单向,不启动新轮): + expect(source).toContain('const abandonInFlightBackfill = useCallback(() => {'); expect(source).toContain('backfillRunSeqRef.current += 1;'); - expect(source).toContain('}, [sessionId, loadingEarlier]);'); + // ①切会话 ②换连接代 —— 被动 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'); diff --git a/packages/maker-shared/src/messageRender.ts b/packages/maker-shared/src/messageRender.ts index 58f77cb1658..da57fe81c08 100644 --- a/packages/maker-shared/src/messageRender.ts +++ b/packages/maker-shared/src/messageRender.ts @@ -1318,11 +1318,15 @@ function itemEndTimestamp< return maxTimestamp(liveEnd, parseTimestampMs(item.toolCall?.settledAt)); } if (item.type === 'work_group') { - for (let index = item.children.length - 1; index >= 0; index--) { - const childEnd = itemEndTimestamp(item.children[index]); - if (childEnd !== null) return childEnd; + // 全量取 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 null; + return latest; } const start = itemTimestamp(item); if (item.type === 'thinking' && start !== null) { From 4e1e219c309a78afe65f68047ce06b9a5685943b Mon Sep 17 00:00:00 2001 From: Dash Date: Fri, 31 Jul 2026 22:44:49 +0800 Subject: [PATCH 11/12] =?UTF-8?q?fix(mobile):=20=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E8=BF=9E=E7=BB=AD=E6=80=A7=E6=94=B9=E4=B8=BA=E6=98=BE=E5=BC=8F?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E5=8C=BA=E9=97=B4=EF=BC=8C=E6=96=AD=E6=B5=81?= =?UTF-8?q?=E5=90=8E=E4=B8=8D=E5=86=8D=E8=83=8C=E4=B9=A6=E6=BC=8F=E6=94=B6?= =?UTF-8?q?=E7=9A=84=E5=8E=86=E5=8F=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sessionContiguousSince` 只记「已验证连续」的下界,而下界断言的是「从它到**窗口 最新端**连续」——窗口最新端会被断流期间漏收的行悄悄作废:旧窗 1–80,断线漏收 81–200,重连后先到一条 push 201,再收到最新页 122–201,此时「有交集」仅靠那条 push 成立,旧下界却还在给「1–80 + 122–201」这个孤岛背书,而这些行若在 30 分钟内 产生,自动探测也发现不了。 改成显式的覆盖区间 `[since, until]` + `liveTailTrusted` 位: - 采纳旧段要求「旧行落在区间内」**且**「本页最旧行不晚于 `until`」(两段首尾相接); - `liveTailTrusted` 表示自 `until` 建立以来推送链路没断过,只有这时 push 才能后推 上界。断流的三个入口都通知 store:socket 掉线(全部会话)、退后台释放 `session:` 订阅、离开会话取消订阅(对应会话)。区间本身保留——断流不会让 断流前验证过的那段变假; - 事实自检:最新页若在区间内带来窗口没有的行,说明断言已被服务端事实推翻,当次 按「未知」处置; - 记账移到相等早退**之前**(`setMessages` 与 `setLatestMessageWindow` 口径一致): 权威页与冷开缓存逐行相同是常态,被早退跳过则这次响应白来,之后会话涨过一页、 再遇一次满页重连刷新,本已确认过的行会被当成来源不明全丢,用户当前历史与滚动 位置一起消失。 顺带把「这一行在不在那一批里」收敛成一份身份索引,交集探测与覆盖区间的事实自检 共用它,不再各写一遍迁移档判据。 新增 7 个 store 用例 + 3 个断流接线断言,全部经变异验证能区分新旧实现;`coverLiveRow` 里跳过本地系统卡那一档写不出能区分的用例(当前调用图下要叠两个改坏点才暴露), 按防御性保留、不硬造用例。 Signed-off-by: Dash Signed-off-by: Dash --- .../historyWindowBackfillWiring.test.ts | 36 +++ .../src/__tests__/remoteSessionStore.test.ts | 136 ++++++++ .../src/device-link/DeviceLinkContext.tsx | 22 +- apps/mobile/src/session/remoteSessionStore.ts | 294 ++++++++++++++---- 4 files changed, 420 insertions(+), 68 deletions(-) diff --git a/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts b/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts index 0081236a1de..6ba21d0d188 100644 --- a/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts +++ b/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts @@ -99,3 +99,39 @@ describe('history window backfill wiring', () => { expect(source).toContain('limit === HISTORY_GAP_PROBE_LIMIT ? [HISTORY_GAP_PROBE_LIMIT] : undefined,'); }); }); + +/** + * 断流通知的接线守卫。 + * + * 不变量:**只要该会话的实时行不再送到本端,store 的覆盖区间上界就不能再被之后到达的 push 续算** + * (见 remoteSessionStore 的 `sessionWindowCoverage.liveTailTrusted`;行为测试在 + * `remoteSessionStore.test.ts`)。断流有三个入口,漏掉任何一个都会让窗口凭空背书出一段没收到的 + * 历史,而这种孤岛在半小时内产生时连自动探测都发现不了(#1210 review)。这里锁住三个入口都通知到。 + */ +describe('live stream interruption wiring', () => { + const source = readFileSync(resolve(process.cwd(), 'src/device-link/DeviceLinkContext.tsx'), 'utf8'); + + 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__/remoteSessionStore.test.ts b/apps/mobile/src/__tests__/remoteSessionStore.test.ts index a23800dd793..e90b26ff84f 100644 --- a/apps/mobile/src/__tests__/remoteSessionStore.test.ts +++ b/apps/mobile/src/__tests__/remoteSessionStore.test.ts @@ -1230,6 +1230,142 @@ describe('remoteSessionStore', () => { ]); }); + 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 推进上界,涨过一页后满页刷新仍保留更早的已验证历史', () => { + // 上一条的反面:没断流时推送是顺序且完整的,窗口从下界一直连续到最新那条 push。这时最新页 + // 只回尾段(会话已涨过一页)不代表更早的行来源不明——收紧过头会在活跃会话里反复清空历史。 + 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.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.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.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('最新页在已验证区间内带来窗口没有的行时,旧结论作废', () => { + // 覆盖区间是对服务端事实的断言,可以被更新的权威页推翻(桌面侧改写历史、迟到落库)。 + // 区间内出现窗口没有的行 → 断言本来就是假的,当次按"未知"处置,不能继续背书更早的段。 + 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', [ diff --git a/apps/mobile/src/device-link/DeviceLinkContext.tsx b/apps/mobile/src/device-link/DeviceLinkContext.tsx index 5233bb1c328..1213c16082b 100644 --- a/apps/mobile/src/device-link/DeviceLinkContext.tsx +++ b/apps/mobile/src/device-link/DeviceLinkContext.tsx @@ -190,6 +190,22 @@ const RECONNECT_MIN_WIPE_GRACE_MS = 3_000; */ 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); + } +} + export function DeviceLinkProvider({ children }: { children: ReactNode }) { if (MOBILE_VISUAL_MOCK_ENABLED) { return {children}; @@ -578,6 +594,8 @@ export function DeviceLinkProvider({ children }: { children: ReactNode }) { if (next !== 'online') { openLinkInFlightRef.current.clear(); remoteSubscribedTopicsRef.current.clear(); + // 掉线:所有会话的实时行都可能从此漏收,窗口连续性结论的上界不再可续算。 + remoteSessionStore.noteLiveStreamInterrupted(); // 掉线即取消挂起的补齐重试:重新 online 会触发全量补齐,无需旧计时器 clearRehydrateRetry(true); return; @@ -738,9 +756,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)); } @@ -868,6 +887,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); }, []); diff --git a/apps/mobile/src/session/remoteSessionStore.ts b/apps/mobile/src/session/remoteSessionStore.ts index c4b856393fa..f08f2b41932 100644 --- a/apps/mobile/src/session/remoteSessionStore.ts +++ b/apps/mobile/src/session/remoteSessionStore.ts @@ -217,26 +217,47 @@ const sessionMakerActivityEpochs = new Map(); let makerActivityEpoch = 0; const sessionMessageSyncMarkers = new Map(); /** - * 每会话「已验证连续」的下界(ISO createdAt):从这一刻起到窗口最新端,窗口是**连续**的 —— 中间 - * 没有服务端有、本地没加载的行。缺省(未登记)= 未知,窗口不被信任为连续。 + * 每会话「已验证连续覆盖区间」:`[since, until]`(闭区间,ISO createdAt)内的**所有**服务端行都在 + * 窗口里 —— 中间没有服务端有、本地没加载的行。缺省(未登记)= 未知,窗口不被信任为连续。 * * 为什么需要它:`setLatestMessageWindow` 判断"要不要保留早于本页的旧段"时,只有两种旧段 * - **来源不明的缓存**(冷开 hydrate 的那页):与本页的关系无从确认,本页上沿之外还有服务端历史 * 时必须丢弃,否则窗口留下孤岛(#1222); - * - **已由连续分页验证过的历史**(用户一路「加载更早」翻出来的):它是沿 before 游标从窗口最旧端 - * 连续拉的,与后面的页确实相接。把它一并丢掉会让用户正在看的历史和滚动锚点凭空消失,而且 - * 自动补齐也不会拉回(裁完窗口里已经没有内部跳变可发现了)——#1210 review 实测到的回归。 - * 下界就是区分这两者的那条线。 + * - **已验证连续的历史**(整窗替换的那一页、用户一路「加载更早」翻出来的、以及订阅未断时收到的 + * 实时 push):与本页确实相接。把它一并丢掉会让用户正在看的历史和滚动锚点凭空消失,而且自动 + * 补齐也不会拉回(裁完窗口里已经没有内部跳变可发现了)——#1210 review 实测到的回归。 + * 区间就是区分这两者的那条线:旧行落在 `[since, until]` 内、**且本页最旧行不晚于 `until`** + * (两段首尾相接)时才保留。 * - * 写入点只有三处(都对应"服务端一次给出的连续段"):整窗替换 `setMessages`、最新窗口 - * `setLatestMessageWindow`、以及「加载更早」的 `mergeEarlierMessages`。冷开 hydrate 刻意不写。 + * 为什么不能只记下界:下界断言的是"从它到**窗口最新端**连续",而"窗口最新端"会被断流期间漏收的 + * 行悄悄作废 —— 旧窗 1–80,断线漏收 81–200,重连后先到一条 push 201,再收到最新页 122–201: + * 窗口最新端已经跳到 201,下界仍指着 1,旧结论于是给"1–80 + 122–201"这个孤岛背了书。把上界显式 + * 记下来,这类"接不上"就是一次比较(#1210 review)。 + * + * `liveTailTrusted`:自 `until` 建立以来实时推送链路没断过 —— 只有这时新到的 push 才能把 `until` + * 往后推(订阅内的推送是顺序且完整的,且权威页都在 subscribe 之后才拉)。断流(socket 掉线、 + * 退后台释放 session 订阅、离开会话取消订阅)一律清掉这个信任位:之后收到的 push 与 `until` 之间 + * 可能漏了任意多行,不能续算。区间本身保留 —— 断流不会让断流前已验证的那段失效。 + * + * 建立 / 扩展点(都对应"服务端一次给出的连续段"或"订阅内的顺序推送"):整窗替换 `setMessages`、 + * 最新窗口 `setLatestMessageWindow`、「加载更早」`mergeEarlierMessages`、实时 push `appendMessage`。 + * 冷开 hydrate 与空洞补齐 `mergeMessages` 刻意不登记:前者来源不明;后者补的是窗口内部的洞,补完 + * 要算准新区间得先确认窗口里没有别的洞——而这正是补齐本身在解决的问题(保守不动的代价是补回来 + * 的段可能在下一次满页同步时被丢弃,下次打开会重新补,方向上是安全的那一侧)。 * 清空 / rewind / 会话回收一律删除(重置为未知),下次最新窗口同步会重建。 * - * 空洞补齐(`mergeMessages`)刻意**不**下移它:那条路补的是窗口内部的洞,补完虽然也让窗口更连续, - * 但要准确算出"新的下界"得先确认窗口里没有别的洞——而这正是补齐本身在解决的问题。保守不动的 - * 代价是补回来的段可能在下一次满页同步时被丢弃(下次打开会重新补),方向上是安全的那一侧。 + * 事实自检:最新页若在 `[since, until]` 内带来窗口没有的行,说明旧结论已被服务端事实推翻(桌面侧 + * 改写历史、迟到落库等),当次按"未知"处置,见 `joinableWindowCoverage`。 */ -const sessionContiguousSince = new Map(); +type SessionWindowCoverage = { + /** 区间下界(含)。 */ + since: string; + /** 区间上界(含)。 */ + until: string; + /** 见上:实时推送链路自 `until` 建立以来未断过,`until` 可被新 push 续推。 */ + liveTailTrusted: boolean; +}; +const sessionWindowCoverage = new Map(); /** 取一批行里最旧的 createdAt(空列表 → undefined)。 */ function oldestCreatedAt(list: readonly RemoteMessage[]): string | undefined { @@ -248,13 +269,119 @@ function oldestCreatedAt(list: readonly RemoteMessage[]): string | undefined { return oldest; } -/** 把「已验证连续」的下界向更早处推(只前移,不回退)。 */ -function lowerContiguousSince(sessionId: string, createdAt: string | undefined): void { - if (!createdAt) return; - const current = sessionContiguousSince.get(sessionId); - if (current === undefined || createdAt.localeCompare(current) < 0) { - sessionContiguousSince.set(sessionId, createdAt); +/** 取一批行里最新的 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); +} + +/** 整窗替换:窗口就是这一页,区间即这一页 —— 不与旧结论求并(旧内容已经不在窗口里了)。 */ +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: true }); +} + +/** + * 最新页对账后登记:本页自身是连续段。`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; + sessionWindowCoverage.set(sessionId, { since, until, liveTailTrusted: true }); +} + +/** + * 「加载更早」:沿 `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 }); +} + +/** + * 实时推送链路中断:上界不再能被 push 续算(见 `liveTailTrusted`)。区间本身保留。 + * 省略 sessionId = 全部会话(socket 掉线影响所有订阅)。 + */ +function noteLiveStreamInterrupted(sessionId?: string): void { + if (sessionId !== undefined) { + const current = sessionWindowCoverage.get(sessionId); + if (current?.liveTailTrusted) { + sessionWindowCoverage.set(sessionId, { ...current, liveTailTrusted: false }); + } + return; + } + 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. @@ -540,23 +667,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 { @@ -1161,14 +1303,15 @@ 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; } messages.set(sessionId, next); - // 整窗替换:这一页是服务端一次给出的连续段,下界即它的最旧行(前移语义,见 lowerContiguousSince)。 - sessionContiguousSince.delete(sessionId); - lowerContiguousSince(sessionId, oldestCreatedAt(next)); bumpMessageVersion(); emit(); }, @@ -1212,6 +1355,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(); @@ -1224,7 +1369,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 超限的窗口刷新 @@ -1250,21 +1396,24 @@ export const remoteSessionStore = { // 而它们在半小时内快速产生时(一个长 turn 里的连续工具调用就是),两侧间隔根本不大,检测不到 // (#1222)。从源头保证连续区间才覆盖得住。 // - // 例外(#1210 review):用户一路「加载更早」翻出来的历史是沿 before 游标从窗口最旧端**连续** - // 拉的,与后面的页确实相接 —— 它由 `sessionContiguousSince` 的下界标出。把这种段也丢掉会让 - // 用户正在看的历史与滚动锚点凭空消失,而且补齐不会拉回它(裁完已无内部跳变可发现)。所以 - // 判据是"在已验证连续区间内 → 保留;否则才按 moreBeyondWindow 处置"。 + // 例外(#1210 review):已验证连续的历史(整窗替换的那页、用户一路「加载更早」翻出来的、订阅 + // 未断时收到的实时 push)与本页确实相接 —— 它由 `sessionWindowCoverage` 的覆盖区间标出。把这种 + // 段也丢掉会让用户正在看的历史与滚动锚点凭空消失,而且补齐不会拉回它(裁完已无内部跳变可 + // 发现)。所以判据是"在已验证覆盖区间内、且本页与该区间首尾相接 → 保留;否则才按 + // moreBeyondWindow 处置"。 const keepUnverifiedOlderPages = hasOverlap && options.moreBeyondWindow !== true; - const contiguousSince = sessionContiguousSince.get(sessionId); + // 相接与否是一次判断,保留判据与下面的记账共用它:两处分开算迟早会让区间与窗口对不上。 + const joinedCoverage = joinableWindowCoverage( + sessionId, + existingIdentityIndex, + latestWindow, + latestOldestCreatedAt, + ); for (const item of existing) { const createdAt = item.createdAt; const isNewerThanLatestPage = createdAt.localeCompare(latestNewestCreatedAt) >= 0; - // 下界断言的是"从它到**窗口最新端**连续"。本页与既有窗口完全不重叠时,说明窗口的最新端 - // 已经跟不上服务端(中间断了,或整个窗口早已过时),那个断言对"窗口 + 本页"的并集不再成立; - // 所以已验证连续同样以 hasOverlap 为前提,否则陈旧窗口会靠旧结论赖着不走。 - const isVerifiedContiguous = hasOverlap - && contiguousSince !== undefined - && createdAt.localeCompare(contiguousSince) >= 0; + const isVerifiedContiguous = joinedCoverage !== undefined + && createdAt.localeCompare(joinedCoverage.since) >= 0; const isOlderLoadedPage = createdAt.localeCompare(latestOldestCreatedAt) < 0 && (isVerifiedContiguous || keepUnverifiedOlderPages); // 本地系统卡(/learn、/context 等)没有服务端对应行:不管时序落在窗口哪里都 @@ -1319,17 +1468,14 @@ export const remoteSessionStore = { } const next = normalizeMessages([...byKey.values()]); + // 记账在相等早退**之前**(同 setMessages):这一页是服务端一次给出的连续段,它带来的结论与 + // "窗口内容有没有变"无关。被早退跳过时这次权威响应就白来了(#1210 review)。 + coverLatestPage(sessionId, latestOldestCreatedAt, latestNewestCreatedAt, joinedCoverage); if (remoteMessageListsEqual(existing, next)) { if (textFlushed) emit(); return; } messages.set(sessionId, next); - // 本页自身是连续段;若更早的段被丢弃,下界就收敛到本页最旧行。用前移语义合并:既有下界更早 - // 且那些行仍在窗口里(已验证连续)时保持不动。 - if (!next.some((item) => item.createdAt.localeCompare(latestOldestCreatedAt) < 0)) { - sessionContiguousSince.delete(sessionId); - } - lowerContiguousSince(sessionId, latestOldestCreatedAt); bumpMessageVersion(); emit(); }, @@ -1393,23 +1539,37 @@ export const remoteSessionStore = { /** * 「加载更早」拉回的一页:沿 `before` 游标**从窗口最旧端连续**往前取,所以它与既有窗口相接。 * - * 与 `mergeMessages` 的唯一差别是它会把「已验证连续」的下界前移到这一页的最旧行 - * (见 `sessionContiguousSince`):这样后续满页的最新窗口同步就不会把用户一路翻出来的历史 + * 与 `mergeMessages` 的唯一差别是它会把「已验证连续」覆盖区间的下界前移到这一页的最旧行 + * (见 `sessionWindowCoverage`):这样后续满页的最新窗口同步就不会把用户一路翻出来的历史 * 当成来源不明的缓存丢掉(#1210 review 实测到的回归)。 * * 只有真正沿窗口最旧端连续翻页的调用方可以用它;补内部空洞请继续用 `mergeMessages`。 */ mergeEarlierMessages(sessionId: string, list: readonly RemoteMessage[]): void { + // 合并前窗口的最旧行 = 这一页接上的那一行,尚无结论时它就是区间上界(见 coverEarlierPage)。 + const joinsAt = oldestCreatedAt(messages.get(sessionId) ?? emptyMessages); this.mergeMessages(sessionId, list); - lowerContiguousSince(sessionId, oldestCreatedAt(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(); }, + /** + * 实时推送链路中断:socket 掉线(省略 sessionId = 全部会话)、退后台释放 `session:` 订阅、 + * 或离开会话取消订阅。此后到达的 push 与覆盖区间上界之间可能漏了任意多行,不能再续算 + * (见 `sessionWindowCoverage` 的 `liveTailTrusted`)。 + */ + noteLiveStreamInterrupted(sessionId?: string): void { + noteLiveStreamInterrupted(sessionId); + }, + /** * 被控端已原子清除一轮消息后,按稳定 clientId 集合移除控制端镜像。 * 同时失效 latest-window marker;sessions patch 与 deletion push 无顺序保证, @@ -1427,8 +1587,8 @@ export const remoteSessionStore = { )); sessionMessageSyncMarkers.delete(sessionId); // 连续性结论随窗口一起失效:rewind 可能删掉中间的行,清空/回收更是整窗重来。 - // 重置为未知,下一次最新窗口同步会重建(见 sessionContiguousSince)。 - sessionContiguousSince.delete(sessionId); + // 重置为未知,下一次最新窗口同步会重建(见 sessionWindowCoverage)。 + forgetWindowCoverage(sessionId); const messagesChanged = next.length !== existing.length; if (messagesChanged) messages.set(sessionId, next); const deletedTaskAliases = new Set(deletedClientIds); @@ -1793,8 +1953,8 @@ export const remoteSessionStore = { // sync marker 已失效 → metaChanged=true → 拉最新消息窗口(含 error 行)整窗替换。 sessionMessageSyncMarkers.delete(sessionId); // 连续性结论随窗口一起失效:rewind 可能删掉中间的行,清空/回收更是整窗重来。 - // 重置为未知,下一次最新窗口同步会重建(见 sessionContiguousSince)。 - sessionContiguousSince.delete(sessionId); + // 重置为未知,下一次最新窗口同步会重建(见 sessionWindowCoverage)。 + forgetWindowCoverage(sessionId); pendingRefreshSessions.add(sessionId); } else { // 未缓存:清 sync marker + 标记待刷新。 @@ -1805,8 +1965,8 @@ export const remoteSessionStore = { messages.delete(sessionId); sessionMessageSyncMarkers.delete(sessionId); // 连续性结论随窗口一起失效:rewind 可能删掉中间的行,清空/回收更是整窗重来。 - // 重置为未知,下一次最新窗口同步会重建(见 sessionContiguousSince)。 - sessionContiguousSince.delete(sessionId); + // 重置为未知,下一次最新窗口同步会重建(见 sessionWindowCoverage)。 + forgetWindowCoverage(sessionId); pendingRefreshSessions.add(sessionId); } bumpMessageVersion(); @@ -2218,8 +2378,8 @@ export const remoteSessionStore = { sessionMakerActivityEpochs.delete(sessionId); sessionMessageSyncMarkers.delete(sessionId); // 连续性结论随窗口一起失效:rewind 可能删掉中间的行,清空/回收更是整窗重来。 - // 重置为未知,下一次最新窗口同步会重建(见 sessionContiguousSince)。 - sessionContiguousSince.delete(sessionId); + // 重置为未知,下一次最新窗口同步会重建(见 sessionWindowCoverage)。 + forgetWindowCoverage(sessionId); sessionTaskUpdates.delete(sessionId); streamingAssistantClientIds.delete(sessionId); discardPendingTextDelta(sessionId); @@ -2256,7 +2416,7 @@ export const remoteSessionStore = { sessionMakerActivityEpochs.clear(); makerActivityEpoch = 0; sessionMessageSyncMarkers.clear(); - sessionContiguousSince.clear(); + sessionWindowCoverage.clear(); sessionTaskUpdates.clear(); streamingAssistantClientIds.clear(); pendingLiveAssistantClientIds.clear(); From f94d36e36f97884d5ef82106eb8b4c5c4e520da9 Mon Sep 17 00:00:00 2001 From: Dash Date: Fri, 31 Jul 2026 23:23:20 +0800 Subject: [PATCH 12/12] =?UTF-8?q?fix(mobile):=20=E5=B0=BE=E9=83=A8?= =?UTF-8?q?=E5=8F=AF=E4=BF=A1=E6=94=B9=E7=94=B1=E8=AE=A2=E9=98=85=20ACK=20?= =?UTF-8?q?=E5=86=B3=E5=AE=9A=EF=BC=8C=E9=A1=B5=E6=AF=94=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E5=85=88=E5=88=B0=E4=B8=8D=E5=86=8D=E7=AE=97=E5=8F=AF=E4=BF=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 屏幕侧的 `openAndSubscribe` 与 `startFocusedTopicSubscription` 都是 `void subscribe(...)`(订阅只管之后的推送,不该挡数据读),所以「权威页比订阅 ACK 先到」是常态。上一版把每个权威页都标成 `liveTailTrusted: true`,于是这个空窗 里被控端写下的行——既不在这一页、也不会被推过来——会被之后一条 push 抬过去: `until` 越过漏收的行,等尾部涨过一页后最新页已不含它们,`joinableWindowCoverage` 的事实自检也发现不了,孤岛就此固化,而它们若在 30 分钟内产生,时间空洞补齐同样 不触发。 改成: - `liveTailTrusted` 由**权威页落库那一刻订阅是否已 ACK** 决定。device-link 在订阅 ACK 记账处通知 store,且只按 `markHeldRemoteTopicsSubscribed` 真正记进 ACK 表的 topic 生效(中途被释放的不算)。重连补齐路径(`rehydrate`)是 `await subscribe` 之后才拉页,因此那条路径拿到的仍是可信尾部。 - ACK 本身不点亮既有区间:ACK 之前的空窗里可能已经漏了行,只有此后落库的权威页 才能重新确定尾部。 - 断流两种形态都把 ACK 记录一并作废(socket 掉线 → 全部会话;退后台释放 `session:` 订阅、离开会话取消订阅 → 对应会话)。生效与失效不成对时,断线后 才落库的在途页会靠旧 ACK 重新把尾部标成可信,绕一圈回到同一个孤岛。 新增 4 个用例(ACK 之前落库的页不可信、两种断流形态各一例 ACK 失效、ACK 接线断言), 并给原有 4 个依赖 push 续推的用例补上「订阅已 ACK」前置;全部经变异验证能区分新旧 实现。 Signed-off-by: Dash Signed-off-by: Dash --- .../historyWindowBackfillWiring.test.ts | 21 +++++-- .../src/__tests__/remoteSessionStore.test.ts | 58 ++++++++++++++++++- .../src/device-link/DeviceLinkContext.tsx | 19 +++++- apps/mobile/src/session/remoteSessionStore.ts | 56 ++++++++++++++++-- 4 files changed, 140 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts b/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts index 6ba21d0d188..66974f0ab1e 100644 --- a/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts +++ b/apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts @@ -101,16 +101,27 @@ describe('history window backfill wiring', () => { }); /** - * 断流通知的接线守卫。 + * 实时流「生效 / 中断」通知的接线守卫。 * - * 不变量:**只要该会话的实时行不再送到本端,store 的覆盖区间上界就不能再被之后到达的 push 续算** - * (见 remoteSessionStore 的 `sessionWindowCoverage.liveTailTrusted`;行为测试在 - * `remoteSessionStore.test.ts`)。断流有三个入口,漏掉任何一个都会让窗口凭空背书出一段没收到的 - * 历史,而这种孤岛在半小时内产生时连自动探测都发现不了(#1210 review)。这里锁住三个入口都通知到。 + * 不变量:**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') {"), diff --git a/apps/mobile/src/__tests__/remoteSessionStore.test.ts b/apps/mobile/src/__tests__/remoteSessionStore.test.ts index e90b26ff84f..2bf414486cc 100644 --- a/apps/mobile/src/__tests__/remoteSessionStore.test.ts +++ b/apps/mobile/src/__tests__/remoteSessionStore.test.ts @@ -1254,8 +1254,10 @@ describe('remoteSessionStore', () => { }); it('订阅未断时实时 push 推进上界,涨过一页后满页刷新仍保留更早的已验证历史', () => { - // 上一条的反面:没断流时推送是顺序且完整的,窗口从下界一直连续到最新那条 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'), @@ -1277,6 +1279,8 @@ describe('remoteSessionStore', () => { }); it('断流只作用于被中断的会话(退后台释放的是各自的 session 订阅)', () => { + remoteSessionStore.noteLiveStreamAcked('s1'); + remoteSessionStore.noteLiveStreamAcked('s2'); remoteSessionStore.setMessages('s1', [messageAt('a1', 's1', '2026-01-01T09:00:01.000Z')]); // 另一个会话的订阅被释放,不能顺带作废 s1 的上界。 remoteSessionStore.noteLiveStreamInterrupted('s2'); @@ -1293,6 +1297,7 @@ describe('remoteSessionStore', () => { // 冷开缓存恰好等于服务端最新页是常态。相等早退发生在记账之前时,这次权威响应白来:之后 // 会话靠 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'), @@ -1312,6 +1317,7 @@ describe('remoteSessionStore', () => { 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'), @@ -1329,6 +1335,54 @@ describe('remoteSessionStore', () => { 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('最新页在已验证区间内带来窗口没有的行时,旧结论作废', () => { // 覆盖区间是对服务端事实的断言,可以被更新的权威页推翻(桌面侧改写历史、迟到落库)。 // 区间内出现窗口没有的行 → 断言本来就是假的,当次按"未知"处置,不能继续背书更早的段。 diff --git a/apps/mobile/src/device-link/DeviceLinkContext.tsx b/apps/mobile/src/device-link/DeviceLinkContext.tsx index 1213c16082b..010334886c1 100644 --- a/apps/mobile/src/device-link/DeviceLinkContext.tsx +++ b/apps/mobile/src/device-link/DeviceLinkContext.tsx @@ -206,6 +206,20 @@ function noteSessionLiveStreamsInterrupted(topics: readonly string[]): void { } } +/** + * `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) { return {children}; @@ -281,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 建链(成功按不定论,不关熔断), diff --git a/apps/mobile/src/session/remoteSessionStore.ts b/apps/mobile/src/session/remoteSessionStore.ts index f08f2b41932..afe94f5b242 100644 --- a/apps/mobile/src/session/remoteSessionStore.ts +++ b/apps/mobile/src/session/remoteSessionStore.ts @@ -235,9 +235,16 @@ const sessionMessageSyncMarkers = new Map(); * 记下来,这类"接不上"就是一次比较(#1210 review)。 * * `liveTailTrusted`:自 `until` 建立以来实时推送链路没断过 —— 只有这时新到的 push 才能把 `until` - * 往后推(订阅内的推送是顺序且完整的,且权威页都在 subscribe 之后才拉)。断流(socket 掉线、 - * 退后台释放 session 订阅、离开会话取消订阅)一律清掉这个信任位:之后收到的 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`。 @@ -258,6 +265,11 @@ type SessionWindowCoverage = { 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 { @@ -283,6 +295,14 @@ 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); @@ -291,7 +311,11 @@ function coverReplacedWindow(sessionId: string, list: readonly RemoteMessage[]): forgetWindowCoverage(sessionId); return; } - sessionWindowCoverage.set(sessionId, { since, until, liveTailTrusted: true }); + sessionWindowCoverage.set(sessionId, { + since, + until, + liveTailTrusted: liveTailTrustedForPage(sessionId), + }); } /** @@ -309,7 +333,14 @@ function coverLatestPage( ): void { const since = joined && joined.since.localeCompare(pageOldest) < 0 ? joined.since : pageOldest; const until = joined && joined.until.localeCompare(pageNewest) > 0 ? joined.until : pageNewest; - sessionWindowCoverage.set(sessionId, { since, until, liveTailTrusted: true }); + // 采纳的旧结论若已经是可信尾部(它的 until 就是本次上界),沿用它;否则由本页落库时的 ACK 状态决定。 + const inheritsTrustedTail = joined?.liveTailTrusted === true + && joined.until.localeCompare(pageNewest) >= 0; + sessionWindowCoverage.set(sessionId, { + since, + until, + liveTailTrusted: inheritsTrustedTail || liveTailTrustedForPage(sessionId), + }); } /** @@ -343,17 +374,19 @@ function coverLiveRow(sessionId: string, message: RemoteMessage): void { } /** - * 实时推送链路中断:上界不再能被 push 续算(见 `liveTailTrusted`)。区间本身保留。 + * 实时推送链路中断: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 }); @@ -1561,6 +1594,14 @@ export const remoteSessionStore = { if (changed) emit(); }, + /** + * 该会话的实时流订阅已被远端 ACK。只影响**此后**落库的权威页能否把尾部标成可信 —— ACK 之前的 + * 空窗里可能已经漏了行,所以 ACK 本身不点亮既有区间的信任位(见 `sessionWindowCoverage`)。 + */ + noteLiveStreamAcked(sessionId: string): void { + if (sessionId) sessionLiveStreamAcked.add(sessionId); + }, + /** * 实时推送链路中断:socket 掉线(省略 sessionId = 全部会话)、退后台释放 `session:` 订阅、 * 或离开会话取消订阅。此后到达的 push 与覆盖区间上界之间可能漏了任意多行,不能再续算 @@ -2380,6 +2421,8 @@ export const remoteSessionStore = { // 连续性结论随窗口一起失效:rewind 可能删掉中间的行,清空/回收更是整窗重来。 // 重置为未知,下一次最新窗口同步会重建(见 sessionWindowCoverage)。 forgetWindowCoverage(sessionId); + // 会话镜像整体回收:它的 `session:` 订阅也随之消失,ACK 记录不能留着给后面的页背书。 + sessionLiveStreamAcked.delete(sessionId); sessionTaskUpdates.delete(sessionId); streamingAssistantClientIds.delete(sessionId); discardPendingTextDelta(sessionId); @@ -2417,6 +2460,7 @@ export const remoteSessionStore = { makerActivityEpoch = 0; sessionMessageSyncMarkers.clear(); sessionWindowCoverage.clear(); + sessionLiveStreamAcked.clear(); sessionTaskUpdates.clear(); streamingAssistantClientIds.clear(); pendingLiveAssistantClientIds.clear();