Prechádzať zdrojové kódy

修复普通聊天后台历史补齐丢失与流式状态合并竞态

Samien 1 mesiac pred
rodič
commit
15e87d218d

+ 37 - 0
docs/verification/2026-09-14-ordinary-chat-consistency/README.md

@@ -0,0 +1,37 @@
+# 普通聊天跨端历史一致性排查
+
+基线:主分支 `29e60b5`(v0.4.17)。修复分支:`codex/ordinary-chat-consistency`。
+
+## 已复现的问题
+
+Web 收到 `sessions.changed / cache.synced` 时只刷新当前打开的会话。后台会话保留 `historySynced=true`,切回后跳过历史读取,继续展示已过时的中间结果。该判断来自 `c17b5f1`(2026-09-10),早于本次十路并发改动。
+
+此外,SSE 会替换 Web 的会话状态对象。历史读取若仍持有请求前的对象,就会把结果写到旧对象中,页面内容和加载状态无法正确更新。历史请求或分页进行中收到的新补齐通知也可能被忽略。
+
+## 修复
+
+- 按 Profile 和 session ID 标记对应缓存失效;后台会话保留可读内容,在再次选择时读取最新历史。
+- 补齐通知使用本地递增版本;请求期间的新通知合并为一次后续读取,不接受早于新通知的快照。
+- 历史读取和分页在异步返回后重新定位当前状态,合并现有流式尾部及已加载旧消息。
+- 历史请求采用单调递增编号,离开账号或被较新请求替代的结果不能修改新状态。
+- 不修改 Bot 调度、发送幂等规则或服务端数据库。
+
+## 验证结果
+
+`tests/client/chatCrossClientSync.test.ts` 的 4 个新增场景在原主线代码上全部失败(另外 12 项原测试通过),修复后通过。对照期间仅临时替换独立 worktree 的 Web store 文件,并在 `finally` 恢复修复版本。
+
+新增场景:后台会话补齐后重新进入;补齐通知与迟到快照交错;SSE 替换状态期间合并历史并保留流式回复;分页期间补齐且保留旧消息。
+
+相关 10 个测试文件共 **125 项全部通过**,`npm run typecheck` 通过。执行:
+
+```sh
+bash docs/verification/2026-09-14-ordinary-chat-consistency/verify.sh
+```
+
+原主线失败记录见 `baseline.txt`,修复后结果见 `results.txt`。
+
+## 线上对照范围
+
+沿用用户先前提供的服务与账号,只读抽查最近两条普通聊天。分别包含 68 条和 2 条记录;Web 本地历史接口与原始历史接口的角色和正文逐条一致。部分投影消息的 ID 与原始数据库 ID 不同,但此次对照中正文相同。登录会话已退出,原始业务数据仅保存在权限受限的临时目录,没有提交到仓库。
+
+用户尚未指定实际出现差异的会话,因此以上抽查不能证明所有会话均无差异,也未在用户 iPhone 上核对其缓存。本次仅修改已复现的 Web 问题,iOS 代码未修改;未合并或部署此修复。

+ 117 - 0
docs/verification/2026-09-14-ordinary-chat-consistency/baseline.txt

@@ -0,0 +1,117 @@
+
+> yaoyao@0.4.17 test
+> vitest run tests/client/chatCrossClientSync.test.ts
+
+
+ RUN  v3.2.7 /Users/samien/git/ordinary-chat-consistency/yaoyao
+
+ ❯ tests/client/chatCrossClientSync.test.ts (16 tests | 4 failed) 4256ms
+   ✓ cross-client chat subscriptions > subscribes a viewer before another device starts, then shows thinking, tools, text and completion 6ms
+   ✓ cross-client chat subscriptions > restores a run opened before its first text, so tool events are not dropped 1ms
+   ✓ cross-client chat subscriptions > restores a partial answer and appends later deltas without an extra row 1ms
+   ✓ cross-client chat subscriptions > resubscribes an idle viewer after disconnection and discovers a remote run 1ms
+   ✓ cross-client chat subscriptions > does not repeatedly resume a subscribed session when revisiting it 0ms
+   × cross-client chat subscriptions > reconciles a background session after cache synchronization before reusing it 1012ms
+     → expected [ '第一个问题', '旧的中间结果' ] to deeply equal [ '第一个问题', '后台完成的完整结果' ]
+   × cross-client chat subscriptions > coalesces history invalidations that arrive while an older snapshot is loading 1056ms
+     → expected [ '已过时快照' ] to deeply equal [ '较新完整历史' ]
+   × cross-client chat subscriptions > merges synchronized history into the current route when SSE replaces its state during the read 1061ms
+     → expected '旧正文' to be '之前的完整正文' // Object.is equality
+   × cross-client chat subscriptions > keeps loaded older messages and follows up a synchronization received during pagination 1007ms
+     → expected [ '旧的最新页' ] to deeply equal [ '之前加载的老消息', '补齐后的最新页' ]
+   ✓ cross-client chat subscriptions > reconciles synchronized history before a delayed submit receipt without duplicating or demoting the user row 107ms
+   ✓ cross-client chat subscriptions > settles a run and clears an interaction completed while the viewer was offline 2ms
+   ✓ cross-client chat subscriptions > does not duplicate completed history from a retained inactive snapshot 0ms
+   ✓ cross-client chat subscriptions > keeps history-only sessions unattached with owned=false 0ms
+   ✓ cross-client chat subscriptions > keeps history-only sessions unattached with owned=undefined 0ms
+   ✓ cross-client chat subscriptions > never creates a runtime for an unsent draft on ready 0ms
+   ✓ cross-client chat subscriptions > retains readable history when subscribing fails and can retry on selection 0ms
+
+⎯⎯⎯⎯⎯⎯⎯ Failed Tests 4 ⎯⎯⎯⎯⎯⎯⎯
+
+ FAIL  tests/client/chatCrossClientSync.test.ts > cross-client chat subscriptions > reconciles a background session after cache synchronization before reusing it
+AssertionError: expected [ '第一个问题', '旧的中间结果' ] to deeply equal [ '第一个问题', '后台完成的完整结果' ]
+
+- Expected
++ Received
+
+  [
+    "第一个问题",
+-   "后台完成的完整结果",
++   "旧的中间结果",
+  ]
+
+ ❯ tests/client/chatCrossClientSync.test.ts:136:82
+    134|     expect(chat.messages).toEqual([])
+    135|     await chat.selectSession(first, 'alpha')
+    136|     await vi.waitFor(() => expect(chat.messages.map(message => message…
+       |                                                                                  ^
+    137|     expect(realtime.request.mock.calls.filter(([method]) => method ===…
+    138|   })
+
+⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/4]⎯
+
+ FAIL  tests/client/chatCrossClientSync.test.ts > cross-client chat subscriptions > coalesces history invalidations that arrive while an older snapshot is loading
+AssertionError: expected [ '已过时快照' ] to deeply equal [ '较新完整历史' ]
+
+- Expected
++ Received
+
+  [
+-   "较新完整历史",
++   "已过时快照",
+  ]
+
+ ❯ tests/client/chatCrossClientSync.test.ts:156:82
+    154|     emit('sessions.changed', { reason: 'cache.synced', profile: 'alpha…
+    155|     release(page('已过时快照'))
+    156|     await vi.waitFor(() => expect(chat.messages.map(message => message…
+       |                                                                                  ^
+    157|     expect(api.getMessages).toHaveBeenCalledTimes(3)
+    158|     expect(chat.activeRouteState?.isLoadingHistory).toBe(false)
+
+⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/4]⎯
+
+ FAIL  tests/client/chatCrossClientSync.test.ts > cross-client chat subscriptions > merges synchronized history into the current route when SSE replaces its state during the read
+AssertionError: expected '旧正文' to be '之前的完整正文' // Object.is equality
+
+Expected: "之前的完整正文"
+Received: "旧正文"
+
+ ❯ tests/client/chatCrossClientSync.test.ts:180:106
+    178|     emit('message.delta', { text: '新回复正在继续' })
+    179|     release(page('之前的完整正文'))
+    180|     await vi.waitFor(() => expect(chat.messages.find(message => messag…
+       |                                                                                                          ^
+    181|     expect(chat.messages.at(-1)?.content).toBe('新回复正在继续')
+    182|     expect(chat.isStreaming).toBe(true)
+
+⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/4]⎯
+
+ FAIL  tests/client/chatCrossClientSync.test.ts > cross-client chat subscriptions > keeps loaded older messages and follows up a synchronization received during pagination
+AssertionError: expected [ '旧的最新页' ] to deeply equal [ '之前加载的老消息', '补齐后的最新页' ]
+
+- Expected
++ Received
+
+  [
+-   "之前加载的老消息",
+-   "补齐后的最新页",
++   "旧的最新页",
+  ]
+
+ ❯ tests/client/chatCrossClientSync.test.ts:204:82
+    202|       role: 'user', content: '之前加载的老消息', timestamp: 1, stage: 'settled…
+    203|     await older
+    204|     await vi.waitFor(() => expect(chat.messages.map(message => message…
+       |                                                                                  ^
+    205|     expect(api.getMessages).toHaveBeenCalledTimes(3)
+    206|     expect(chat.activeRouteState?.isLoadingHistory).toBe(false)
+
+⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/4]⎯
+
+
+ Test Files  1 failed (1)
+      Tests  4 failed | 12 passed (16)
+   Start at  07:06:49
+   Duration  4.66s (transform 70ms, setup 10ms, collect 101ms, tests 4.26s, environment 148ms, prepare 28ms)

+ 32 - 0
docs/verification/2026-09-14-ordinary-chat-consistency/results.txt

@@ -0,0 +1,32 @@
+
+> yaoyao@0.4.17 test
+> vitest run tests/client/chatCrossClientSync.test.ts tests/client/chatLifecycle.test.ts tests/client/chatStore.test.ts tests/client/chatModelSync.test.ts tests/client/chatProtocol.test.ts tests/client/sessionProtocol.test.ts tests/server/ordinaryChatLocal.test.ts tests/server/chatCache.test.ts tests/server/chatCachePagination.test.ts tests/server/realtimeBroker.test.ts
+
+
+ RUN  v3.2.7 /Users/samien/git/ordinary-chat-consistency/yaoyao
+
+ ✓ tests/server/chatCachePagination.test.ts (4 tests) 59ms
+ ✓ tests/client/sessionProtocol.test.ts (1 test) 5ms
+ ✓ tests/client/chatStore.test.ts (20 tests) 6ms
+ ✓ tests/client/chatProtocol.test.ts (6 tests) 10ms
+ ✓ tests/client/chatModelSync.test.ts (8 tests) 11ms
+ ✓ tests/client/chatLifecycle.test.ts (8 tests) 6ms
+ ✓ tests/server/ordinaryChatLocal.test.ts (9 tests) 310ms
+ ✓ tests/server/chatCache.test.ts (29 tests) 442ms
+ ✓ tests/client/chatCrossClientSync.test.ts (16 tests) 383ms
+ ✓ tests/server/realtimeBroker.test.ts (24 tests) 1682ms
+   ✓ realtime broker > recovers the upstream replay ring without losing offline deltas  569ms
+   ✓ realtime broker > reports reset when upstream history was truncated  522ms
+
+ Test Files  10 passed (10)
+      Tests  125 passed (125)
+   Start at  07:08:17
+   Duration  2.15s (transform 379ms, setup 71ms, collect 825ms, tests 2.92s, environment 1.50s, prepare 378ms)
+
+
+
+> yaoyao@0.4.17 typecheck
+> vue-tsc -b && tsc --noEmit -p tsconfig.server.json
+
+
+Typecheck exit code: 0

+ 10 - 0
docs/verification/2026-09-14-ordinary-chat-consistency/verify.sh

@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(git -C "$(dirname "$0")" rev-parse --show-toplevel)"
+export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--no-experimental-webstorage"
+npm test -- tests/client/chatCrossClientSync.test.ts tests/client/chatLifecycle.test.ts \
+  tests/client/chatStore.test.ts tests/client/chatModelSync.test.ts \
+  tests/client/chatProtocol.test.ts tests/client/sessionProtocol.test.ts \
+  tests/server/ordinaryChatLocal.test.ts tests/server/chatCache.test.ts \
+  tests/server/chatCachePagination.test.ts tests/server/realtimeBroker.test.ts
+npm run typecheck

+ 58 - 28
src/client/stores/chat.ts

@@ -111,6 +111,7 @@ export const useChatStore = defineStore('chat', () => {
   let nextSessionCursor: string | undefined
   let modelLoadGeneration = 0
   let unreadLoadGeneration = 0
+  let historyLoadGeneration = 0
   let reconnectResumePromise: Promise<unknown> | undefined
   let persistedRecoveryPromise: Promise<void> | undefined
   const runtimePromises = new Map<string, Promise<ChatRouteState>>()
@@ -304,9 +305,13 @@ export const useChatStore = defineStore('chat', () => {
     if (event.type === 'gateway.ready') return
     const payload = record(event.payload)
     if (event.type === 'sessions.changed') {
-      if(payload.reason==='cache.synced'){
-        const state=activeRouteState.value
-        if(state && state.route.profile===payload.profile && state.route.sessionId===payload.session_id && !state.isLoadingHistory)
+      if(payload.reason==='cache.synced' && typeof payload.profile==='string' && typeof payload.session_id==='string'){
+        const state=routes[routeKey(payload.profile,payload.session_id)]
+        if(state){
+          state.historySyncRevision=(state.historySyncRevision??0)+1
+          state.historySynced=false
+        }
+        if(state && state===activeRouteState.value && !state.isLoadingHistory)
           void loadHistory(state).catch(()=>{})
       }
       scheduleSessionListRefresh()
@@ -486,40 +491,54 @@ export const useChatStore = defineStore('chat', () => {
   }
 
   async function loadHistory(state: ChatRouteState, forceRefresh = false): Promise<void> {
-    const generation = ++state.generation
+    const key = routeKey(state.route.profile, state.route.sessionId)
+    const generation = ++historyLoadGeneration
+    state.generation = generation
+    const syncRevision = state.historySyncRevision ?? 0
+    // SSE replaces a route state object. Resolve the current object after each
+    // await so history merges into its live tail instead of a detached copy.
+    const current = () => routes[key]?.generation === generation ? routes[key] : undefined
     state.isLoadingHistory = true
     state.error = undefined
     const scope = cacheScope(state.route.profile)
     const cached = await historyCache.get(scope, state.route.sessionId)
-    if (generation !== state.generation) return
+    let latest = current()
+    if (!latest) return
     if (cached?.messages.length) {
-      state.messages = mergeChatMessages(state.messages, cached.messages, 'snapshot')
-      state.messageTotal = cached.total
-      state.loadedMessageCount = cached.messages.length
+      latest.messages = mergeChatMessages(latest.messages, cached.messages, 'snapshot')
+      latest.messageTotal = cached.total
+      latest.loadedMessageCount = cached.messages.length
     }
     try {
       const page = await getMessages(state.route.sessionId, 0, 150, state.route.profile, false, sessionView)
-      if (generation !== state.generation) return
-      if (forceRefresh && routes[routeKey(state.route.profile, state.route.sessionId)]?.isStreaming) {
+      latest = current()
+      if (!latest || (latest.historySyncRevision ?? 0) !== syncRevision) return
+      if (forceRefresh && latest.isStreaming) {
         throw new Error('正在回复,请结束后再刷新历史')
       }
-      const retained = forceRefresh ? state.messages.filter(message => message.role === 'user' && message.stage !== 'settled') : state.messages
-      state.messages = reconcileChatHistory(retained, page.messages, state.isStreaming)
-      state.messageTotal = page.total
-      state.loadedMessageCount = page.returned
-      state.hasMoreBefore = page.hasMore
-      state.historySynced = true
+      const retained = forceRefresh ? latest.messages.filter(message => message.role === 'user' && message.stage !== 'settled') : latest.messages
+      latest.messages = reconcileChatHistory(retained, page.messages, latest.isStreaming)
+      latest.messageTotal = page.total
+      latest.loadedMessageCount = page.returned
+      latest.hasMoreBefore = page.hasMore
+      latest.historySynced = true
       if (page.session) {
         const index = sessions.value.findIndex(session => session.id === page.session!.id && session.profile === page.session!.profile)
         if (index >= 0) sessions.value.splice(index, 1, page.session)
       }
-      await historyCache.set(scope, state.route.sessionId, { messages: state.messages, total: page.total, savedAt: Date.now() })
+      await historyCache.set(scope, state.route.sessionId, { messages: latest.messages, total: page.total, savedAt: Date.now() })
     } catch (cause) {
-      if (generation === state.generation) state.error = errorMessage(cause)
+      latest = current()
+      if (latest) latest.error = errorMessage(cause)
       if(cause instanceof ApiError && cause.code==='CHAT_HISTORY_SYNC_PENDING')return
       throw cause
     } finally {
-      if (generation === state.generation) state.isLoadingHistory = false
+      latest = current()
+      if (latest) {
+        latest.isLoadingHistory = false
+        if ((latest.historySyncRevision ?? 0) !== syncRevision && latest === activeRouteState.value)
+          void loadHistory(latest).catch(() => {})
+      }
     }
   }
 
@@ -538,7 +557,7 @@ export const useChatStore = defineStore('chat', () => {
     syncSelectedModel(refreshed?.model, refreshed?.provider)
     if (refreshed?.owned === true && !sessionId.startsWith('draft-')
       && activeSessionId.value === sessionId && activeProfileName.value === selectedProfile) {
-      await ensureRuntime(state).catch(cause => {
+      await ensureRuntime(routes[routeKey(selectedProfile, sessionId)] ?? state).catch(cause => {
         const current = routes[routeKey(selectedProfile, sessionId)]
         if (current) current.error = errorMessage(cause)
       })
@@ -556,20 +575,31 @@ export const useChatStore = defineStore('chat', () => {
   async function loadOlder(): Promise<void> {
     const state = activeRouteState.value
     if (!state || state.isLoadingHistory || !state.hasMoreBefore) return
+    const key = routeKey(state.route.profile, state.route.sessionId)
     const generation = state.generation
+    const syncRevision = state.historySyncRevision ?? 0
+    const current = () => routes[key]?.generation === generation ? routes[key] : undefined
     state.isLoadingHistory = true
     try {
       const page = await getMessages(state.route.sessionId, state.loadedMessageCount, 150, state.route.profile, false, sessionView)
-      if (generation !== state.generation) return
-      state.messages = mergeChatMessages(state.messages, page.messages, 'prepend')
-      state.loadedMessageCount += page.returned
-      state.messageTotal = Math.max(state.messageTotal, page.total)
-      state.hasMoreBefore = page.hasMore
+      const latest = current()
+      if (!latest) return
+      latest.messages = mergeChatMessages(latest.messages, page.messages, 'prepend')
+      latest.loadedMessageCount += page.returned
+      latest.messageTotal = Math.max(latest.messageTotal, page.total)
+      latest.hasMoreBefore = page.hasMore
       await historyCache.set(cacheScope(state.route.profile), state.route.sessionId, {
-        messages: state.messages, total: state.messageTotal, savedAt: Date.now(),
+        messages: latest.messages, total: latest.messageTotal, savedAt: Date.now(),
       })
-    } catch (cause) { state.error = errorMessage(cause) }
-    finally { if (generation === state.generation) state.isLoadingHistory = false }
+    } catch (cause) { const latest = current(); if (latest) latest.error = errorMessage(cause) }
+    finally {
+      const latest = current()
+      if (latest) {
+        latest.isLoadingHistory = false
+        if ((latest.historySyncRevision ?? 0) !== syncRevision && latest === activeRouteState.value)
+          void loadHistory(latest).catch(() => {})
+      }
+    }
   }
 
   function createSession(profile = auth.activeProfile?.name ?? 'default'): string {

+ 2 - 0
src/shared/types.ts

@@ -201,6 +201,8 @@ export interface ChatRouteState {
   messages: ChatMessage[]
   runtimeSessionId?: string
   historySynced: boolean
+  /** Server history invalidations received since this route was opened. */
+  historySyncRevision?: number
   hasMoreBefore: boolean
   loadedMessageCount: number
   messageTotal: number

+ 95 - 0
tests/client/chatCrossClientSync.test.ts

@@ -111,6 +111,101 @@ describe('cross-client chat subscriptions', () => {
     expect(api.getMessages).toHaveBeenCalledTimes(1)
   })
 
+  it('reconciles a background session after cache synchronization before reusing it', async () => {
+    const first = 'background-sync-first', second = 'background-sync-second'
+    chat.sessions = [session(first), session(second)]
+    realtime.request.mockImplementation((_method: string, params: Record<string, string>) => Promise.resolve({
+      session_id: `runtime-${params.session_id}`, stored_session_id: params.session_id, running: false,
+    }))
+    const user = { id: 'user-background', serverMessageId: 'user-background', sessionId: first,
+      role: 'user', content: '第一个问题', timestamp: 1, stage: 'settled' }
+    let synchronized = false
+    api.getMessages.mockImplementation((id: string) => Promise.resolve({
+      messages: id === first ? [user, { id: synchronized ? 'server-final' : 'event:interim',
+        serverMessageId: synchronized ? 'server-final' : undefined, sessionId: first,
+        role: 'assistant', content: synchronized ? '后台完成的完整结果' : '旧的中间结果', timestamp: 2, stage: 'settled' }] : [],
+      total: id === first ? 2 : 0, returned: id === first ? 2 : 0, hasMore: false,
+    }))
+    await chat.selectSession(first, 'alpha')
+    await chat.selectSession(second, 'alpha')
+    synchronized = true
+    emit('sessions.changed', { reason: 'cache.synced', profile: 'alpha', session_id: first })
+    expect(chat.activeSessionId).toBe(second)
+    expect(chat.messages).toEqual([])
+    await chat.selectSession(first, 'alpha')
+    await vi.waitFor(() => expect(chat.messages.map(message => message.content)).toEqual(['第一个问题', '后台完成的完整结果']))
+    expect(realtime.request.mock.calls.filter(([method]) => method === 'prompt.submit')).toHaveLength(0)
+  })
+
+  it('coalesces history invalidations that arrive while an older snapshot is loading', async () => {
+    const id = 'sync-during-history-load'
+    chat.sessions = [session(id)]
+    realtime.request.mockResolvedValue({ session_id: 'runtime-1', stored_session_id: id, running: false })
+    const page = (text: string) => ({ messages: [{ id: 'answer-sync', serverMessageId: 'answer-sync',
+      sessionId: id, role: 'assistant', content: text, timestamp: 1, stage: 'settled' }], total: 1, returned: 1, hasMore: false })
+    api.getMessages.mockResolvedValue(page('原缓存'))
+    await chat.selectSession(id, 'alpha')
+    let release!: (value: unknown) => void
+    api.getMessages.mockImplementationOnce(() => new Promise(resolve => { release = resolve }))
+    emit('sessions.changed', { reason: 'cache.synced', profile: 'alpha', session_id: id })
+    await vi.waitFor(() => expect(api.getMessages).toHaveBeenCalledTimes(2))
+    api.getMessages.mockResolvedValue(page('较新完整历史'))
+    emit('sessions.changed', { reason: 'cache.synced', profile: 'alpha', session_id: id })
+    emit('sessions.changed', { reason: 'cache.synced', profile: 'alpha', session_id: id })
+    release(page('已过时快照'))
+    await vi.waitFor(() => expect(chat.messages.map(message => message.content)).toEqual(['较新完整历史']))
+    expect(api.getMessages).toHaveBeenCalledTimes(3)
+    await vi.waitFor(() => expect(chat.activeRouteState?.isLoadingHistory).toBe(false))
+    expect(chat.historySynced).toBe(true)
+  })
+
+  it('merges synchronized history into the current route when SSE replaces its state during the read', async () => {
+    const id = 'stream-during-history-load'
+    chat.sessions = [session(id)]
+    realtime.request.mockResolvedValue({ session_id: 'runtime-1', stored_session_id: id, running: false })
+    const page = (text: string) => ({ messages: [
+      { id: 'prior-user', serverMessageId: 'prior-user', sessionId: id, role: 'user', content: '之前的问题', timestamp: 1, stage: 'settled' },
+      { id: 'prior-answer', serverMessageId: 'prior-answer', sessionId: id, role: 'assistant', content: text, timestamp: 2, stage: 'settled' },
+    ], total: 2, returned: 2, hasMore: false })
+    api.getMessages.mockResolvedValue(page('旧正文'))
+    await chat.selectSession(id, 'alpha')
+    let release!: (value: unknown) => void
+    api.getMessages.mockImplementationOnce(() => new Promise(resolve => { release = resolve }))
+    emit('sessions.changed', { reason: 'cache.synced', profile: 'alpha', session_id: id })
+    await vi.waitFor(() => expect(api.getMessages).toHaveBeenCalledTimes(2))
+    emit('run.peer_user_message', { message_id: 'live-user', client_message_id: 'live-client', text: '新问题', timestamp: 3, status: 'accepted' })
+    emit('message.start')
+    emit('message.delta', { text: '新回复正在继续' })
+    release(page('之前的完整正文'))
+    await vi.waitFor(() => expect(chat.messages.find(message => message.id === 'prior-answer')?.content).toBe('之前的完整正文'))
+    expect(chat.messages.at(-1)?.content).toBe('新回复正在继续')
+    expect(chat.isStreaming).toBe(true)
+    await vi.waitFor(() => expect(chat.activeRouteState?.isLoadingHistory).toBe(false))
+  })
+
+  it('keeps loaded older messages and follows up a synchronization received during pagination', async () => {
+    const id = 'sync-during-older-page'
+    chat.sessions = [session(id)]
+    realtime.request.mockResolvedValue({ session_id: 'runtime-1', stored_session_id: id, running: false })
+    const recent = (text: string) => ({ messages: [{ id: 'recent-answer', serverMessageId: 'recent-answer',
+      sessionId: id, role: 'assistant', content: text, timestamp: 2, stage: 'settled' }], total: 2, returned: 1, hasMore: true })
+    api.getMessages.mockResolvedValue(recent('旧的最新页'))
+    await chat.selectSession(id, 'alpha')
+    let release!: (value: unknown) => void
+    api.getMessages.mockImplementationOnce(() => new Promise(resolve => { release = resolve }))
+    const older = chat.loadOlder()
+    await vi.waitFor(() => expect(api.getMessages).toHaveBeenCalledTimes(2))
+    emit('session.info', { model: 'new-model' })
+    api.getMessages.mockResolvedValue(recent('补齐后的最新页'))
+    emit('sessions.changed', { reason: 'cache.synced', profile: 'alpha', session_id: id })
+    release({ messages: [{ id: 'older-user', serverMessageId: 'older-user', sessionId: id,
+      role: 'user', content: '之前加载的老消息', timestamp: 1, stage: 'settled' }], total: 2, returned: 1, hasMore: false })
+    await older
+    await vi.waitFor(() => expect(chat.messages.map(message => message.content)).toEqual(['之前加载的老消息', '补齐后的最新页']))
+    expect(api.getMessages).toHaveBeenCalledTimes(3)
+    await vi.waitFor(() => expect(chat.activeRouteState?.isLoadingHistory).toBe(false))
+  })
+
   it('reconciles synchronized history before a delayed submit receipt without duplicating or demoting the user row', async () => {
     chat.sessions = [session('identity-session')]
     realtime.request.mockResolvedValue({ session_id: 'runtime-1', stored_session_id: 'identity-session', running: false })