Просмотр исходного кода

普通聊天历史优先读取 SQLite 并按变化增量同步

LSamien 1 месяц назад
Родитель
Сommit
1424d71cf3

+ 21 - 0
docs/ordinary-chat-incremental-sync.md

@@ -0,0 +1,21 @@
+# 普通聊天按需读取与增量同步
+
+范围为应用登记的普通聊天。独立的只读历史入口继续走原来的上游读取链路。
+
+- 已持久化的列表和详情从 SQLite 生成。旧版本或新建会话缺少摘要时,才补齐摘要;换分页参数不再使已知列表回源。
+- 消息页按实际位置覆盖范围读取,本地只存了尾部也可以命中该页,不必先下载完整会话。新回合不清除已经保存的历史覆盖信息。
+- 回复完成或实时事件出现缺口时,校对当前会话的详情及最新消息,每页 100 条,遇到已保存的稳定位置即停止。按消息 ID 和内容摘要更新,未变化的消息不重写。
+- 同一会话合并并发同步。读取期间收到新事件时,旧结果不能覆盖新修订;同步提交后发送账号隔离的失效通知,客户端再按需读取。
+- 已缓存的旧页不等待后台同步。最新页可以等待正在提交的尾部;同步失败仍返回已有本地内容。
+- 普通页面刷新不绕过本地缓存。会话操作中的“强制刷新历史”才使用 `X-Yaoyao-Cache: bypass`,完整校对并替换历史。下载失败或分页期间消息总数变化时,不替换已有记录。
+- 旧消息删除、重排等改变历史位置的情况保留本地内容,等待明确强制刷新,不把它当作普通尾部追加。
+- 模型变化继续由实时事件和进入页面时的必要校验处理,取消每 3 秒无条件读取会话详情。
+
+## 验证
+
+- 1,000 条已缓存消息,20 次历史分页读取不请求上游。
+- 新增回复只读取一页尾部;漏掉 130 条消息只补两页;原有消息不重新写入。
+- 明确强制刷新才覆盖旧编辑和删除;失败时保留旧记录;并发完成事件提交后再通知客户端。
+- 浏览器验证普通重载命中本地、强制刷新回源,以及只读历史和普通聊天的边界。
+
+这些请求数来自隔离测试,并非对真实网络耗时的估计。

+ 2 - 1
src/client/api/sessions.ts

@@ -71,6 +71,7 @@ export async function getMessages(
   offset: number,
   limit = 150,
   profile?: string,
+  forceRefresh = false,
 ): Promise<MessagePage> {
   const safeOffset = Math.max(0, Math.trunc(offset))
   const safeLimit = Math.max(1, Math.min(500, Math.trunc(limit)))
@@ -81,7 +82,7 @@ export async function getMessages(
     include_compacted: true,
     profile,
   })
-  const payload = unwrapData(await apiRequest<unknown>(url))
+  const payload = unwrapData(await apiRequest<unknown>(url, forceRefresh ? { headers: { 'X-Yaoyao-Cache': 'bypass' } } : {}))
   const source = record(payload)
   const pagination = record(source.pagination)
   const messages = values(source.messages ?? source.items ?? payload).map(message => normalizeChatMessage(message, sessionId, profile))

+ 14 - 4
src/client/stores/chat.ts

@@ -481,7 +481,7 @@ export const useChatStore = defineStore('chat', () => {
     unreadCounts.value = { ...unreadCounts.value, [sessionId]: 0 }
   }
 
-  async function loadHistory(state: ChatRouteState): Promise<void> {
+  async function loadHistory(state: ChatRouteState, forceRefresh = false): Promise<void> {
     const generation = ++state.generation
     state.isLoadingHistory = true
     state.error = undefined
@@ -494,9 +494,13 @@ export const useChatStore = defineStore('chat', () => {
       state.loadedMessageCount = cached.messages.length
     }
     try {
-      const page = await getMessages(state.route.sessionId, 0, 150, state.route.profile)
+      const page = await getMessages(state.route.sessionId, 0, 150, state.route.profile, forceRefresh)
       if (generation !== state.generation) return
-      state.messages = mergeChatMessages(state.messages, page.messages, 'snapshot')
+      if (forceRefresh && routes[routeKey(state.route.profile, state.route.sessionId)]?.isStreaming) {
+        throw new Error('正在回复,请结束后再刷新历史')
+      }
+      const retained = forceRefresh ? state.messages.filter(message => message.role === 'user' && message.stage !== 'settled') : state.messages
+      state.messages = mergeChatMessages(retained, page.messages, 'snapshot')
       state.messageTotal = page.total
       state.loadedMessageCount = page.returned
       state.hasMoreBefore = page.hasMore
@@ -529,6 +533,12 @@ export const useChatStore = defineStore('chat', () => {
     syncSelectedModel(refreshed?.model, refreshed?.provider)
   }
 
+  async function forceRefreshHistory(): Promise<void> {
+    const state = activeRouteState.value
+    if (!state || state.isLoadingHistory || state.isStreaming || state.isQueued || state.pendingApproval || state.pendingClarification || state.route.sessionId.startsWith('draft-')) return
+    await loadHistory(state, true)
+  }
+
   async function loadOlder(): Promise<void> {
     const state = activeRouteState.value
     if (!state || state.isLoadingHistory || !state.hasMoreBefore) return
@@ -1000,7 +1010,7 @@ export const useChatStore = defineStore('chat', () => {
     sessions, activeSessionId, activeProfileName, activeSession, routes, activeRouteState, messages,
     connectionState, historySynced, hasMoreBefore, isLoading, isLoadingMoreSessions, hasMoreSessions, isSending, isStreaming, isQueued,
     error, models, selectedModel, reasoningEffort, fastMode, contextUsage, pendingApproval, pendingClarification, unreadCounts,
-    loadSessions, loadMoreSessions, selectSession, loadOlder, createSession, clearSelection, connect, disconnect, send, interrupt,
+    loadSessions, loadMoreSessions, selectSession, loadOlder, forceRefreshHistory, createSession, clearSelection, connect, disconnect, send, interrupt,
     respondToApproval, respondToClarification, branchSession, renameSession, setSessionPinned, removeSession,
     loadModels, setModel, setFastMode, refreshActiveSessionModel, refreshContextUsage, loadUnread, markRead, switchProfile,
   }

+ 8 - 6
src/client/views/ChatView.vue

@@ -51,7 +51,6 @@ const composer = ref<InstanceType<typeof ComposerShell> | null>(null)
 const timeline = ref<InstanceType<typeof MessageTimeline> | null>(null)
 const headerSessionMenuButton = ref<HTMLButtonElement | null>(null)
 const restoringRouteProfile = ref('')
-let modelSyncTimer: number | undefined
 
 function handleModelCatalogChanged(event: Event) {
   const profile = modelCatalogChangedProfile(event)
@@ -260,7 +259,7 @@ function openSessionActions(id: string, event?: MouseEvent) {
   renameValue.value = session?.title || ''
   renaming.value = false
   const menuWidth = 208
-  const menuHeight = 176
+  const menuHeight = 210
   const inset = 8
   const anchor = event?.currentTarget instanceof HTMLElement ? event.currentTarget.getBoundingClientRect() : null
   const requestedX = event?.type === 'contextmenu' ? event.clientX : anchor ? anchor.right - menuWidth : window.innerWidth - menuWidth - 14
@@ -276,6 +275,12 @@ function closeSessionActions() {
   renaming.value = false
 }
 
+async function forceRefreshHistory() {
+  closeSessionActions()
+  try { await chat.forceRefreshHistory() }
+  catch { /* The chat store displays the refresh error without clearing messages. */ }
+}
+
 function handleSessionActionPointer(event: PointerEvent) {
   if (!actionSessionId.value || (event.target as HTMLElement).closest('.session-actions')) return
   closeSessionActions()
@@ -353,9 +358,6 @@ onMounted(async () => {
   await refreshSessions()
   await Promise.allSettled([chat.loadModels(auth.activeProfile?.name), chat.connect()])
   void chat.refreshActiveSessionModel().catch(() => undefined)
-  modelSyncTimer = window.setInterval(() => {
-    if (document.visibilityState === 'visible') void chat.refreshActiveSessionModel().catch(() => undefined)
-  }, 3_000)
   await revealSourceMessage()
   const pendingFile = await consumeLibraryItemForComposer()
   if (pendingFile) composer.value?.attachFiles([pendingFile])
@@ -365,7 +367,6 @@ onBeforeUnmount(() => {
   document.removeEventListener('pointerdown', handleSessionActionPointer)
   document.removeEventListener('keydown', handleSessionActionKey)
   window.removeEventListener(MODEL_CATALOG_CHANGED_EVENT, handleModelCatalogChanged)
-  if (modelSyncTimer !== undefined) window.clearInterval(modelSyncTimer)
 })
 
 watch(() => auth.activeProfile?.name, async (profile, previous) => {
@@ -511,6 +512,7 @@ watch(() => chat.activeSessionId, async id => {
         </template>
         <template v-else>
           <button class="action-row" role="menuitem" type="button" @click="openSessionOutline"><AppIcon name="menu" :size="14" />会话大纲</button>
+          <button v-if="actionSessionId === chat.activeSessionId" class="action-row" role="menuitem" type="button" :disabled="chat.isStreaming || chat.isQueued || chat.isSending || chat.activeRouteState?.isLoadingHistory" @click="forceRefreshHistory"><AppIcon name="refresh" :size="14" />强制刷新历史</button>
           <button class="action-row" role="menuitem" type="button" @click="toggleSessionPinned"><AppIcon :name="actionSession?.pinned ? 'pin-off' : 'pin'" :size="14" />{{ actionSession?.pinned ? '取消置顶' : '置顶会话' }}</button>
           <button class="action-row" role="menuitem" type="button" @click="renaming = true"><AppIcon name="edit" :size="14" />重命名</button>
           <button class="action-row danger" role="menuitem" type="button" @click="deleteSession"><AppIcon name="trash" :size="14" />删除会话</button>

+ 3 - 0
src/server/app.ts

@@ -227,6 +227,9 @@ export function createApplication(options: ApplicationOptions = {}): Application
     void workspaceAssets.archiveText(owner, text, 'local', profile, storedId, messageId, frame.type === 'attachment.staged' ? 'user' : 'agent').catch(() => {})
   }
   realtime.broker.onNativeGlobalEvent = (owner, type) => chatCache?.observeGlobal(owner, type)
+  chatCache.onSynchronized = (owner, profile, sessionID) => {
+    realtime.broker.publishOwnerSessionsChanged(owner, 'cache.synced', profile, sessionID)
+  }
   realtime.broker.onNativeCommand = (owner, profile, storedId, method, params) => {
     chatCache?.command(owner, profile, storedId, method, params)
   }

+ 167 - 52
src/server/chatCache.ts

@@ -135,6 +135,16 @@ export class ChatCacheStore {
     const sessionColumns = this.db.prepare('PRAGMA table_info(chat_sessions)').all() as Array<{ name: string }>
     this.db.exec('BEGIN IMMEDIATE')
     try {
+      if (!sessionColumns.some(column => column.name === 'sync_revision')) {
+        this.db.exec('ALTER TABLE chat_sessions ADD COLUMN sync_revision INTEGER NOT NULL DEFAULT 0')
+      }
+      if (!sessionColumns.some(column => column.name === 'message_total')) {
+        this.db.exec('ALTER TABLE chat_sessions ADD COLUMN message_total INTEGER')
+      }
+      if (!sessionColumns.some(column => column.name === 'metadata_complete')) {
+        this.db.exec('ALTER TABLE chat_sessions ADD COLUMN metadata_complete INTEGER NOT NULL DEFAULT 0')
+        this.db.exec("UPDATE chat_sessions SET metadata_complete=1 WHERE json_valid(data) AND (json_type(data,'$.message_count') IS NOT NULL OR json_type(data,'$.model') IS NOT NULL)")
+      }
       if (!sessionColumns.some(column => column.name === 'authoritative_title')) {
         this.db.exec('ALTER TABLE chat_sessions ADD COLUMN authoritative_title TEXT')
       }
@@ -227,6 +237,40 @@ export class ChatCacheStore {
     return row ? { response: responseFromRow(row), state: String(row.sync_state) as ChatCacheState } : undefined
   }
 
+  localList(owner: string, profile: string, page: { offset?: number; limit?: number; archived?: string }): UpstreamResponse {
+    return this.mergeOwnedSessionsIntoList(owner, profile, {
+      status: 200, headers: new Headers({ 'content-type': 'application/json' }),
+      body: Buffer.from('{"sessions":[]}'),
+    }, page)
+  }
+
+  needsListMetadata(owner: string, profile: string): boolean {
+    return Boolean(this.db.prepare(`SELECT 1 FROM chat_sessions WHERE owner=? AND owned_at IS NOT NULL
+      AND (?='' OR profile=?) AND metadata_complete=0 LIMIT 1`).get(owner, profile, profile))
+  }
+
+  localDetail(owner: string, profile: string, sessionID: string): StoredSnapshot | undefined {
+    const row = this.db.prepare(`SELECT data,sync_state FROM chat_sessions
+      WHERE owner=? AND profile=? AND session_id=? AND owned_at IS NOT NULL`)
+      .get(owner, profile, sessionID) as { data: string; sync_state: ChatCacheState } | undefined
+    if (!row) return undefined
+    return { response: { status: 200, headers: new Headers({ 'content-type': 'application/json' }),
+      body: Buffer.from(row.data) }, state: row.sync_state }
+  }
+
+  revision(owner: string, profile: string, sessionID: string): number {
+    return Number((this.db.prepare('SELECT sync_revision FROM chat_sessions WHERE owner=? AND profile=? AND session_id=?')
+      .get(owner, profile, sessionID) as { sync_revision?: number } | undefined)?.sync_revision ?? 0)
+  }
+
+  hasMessage(owner: string, profile: string, sessionID: string, message: Record<string, unknown>, position: number): boolean {
+    const id = messageID(message, String(position))
+    const row = this.db.prepare('SELECT position FROM chat_messages WHERE owner=? AND profile=? AND session_id=? AND message_id=?')
+      .get(owner, profile, sessionID, id) as { position: number } | undefined
+    if (row && row.position !== position) throw new Error('Stored history changed; explicit refresh is required')
+    return Boolean(row)
+  }
+
   putSnapshot(owner: string, key: string, kind: string, profile: string, sessionID: string | undefined,
     response: UpstreamResponse, ownedList = false, requestStartedAt = Date.now()): boolean {
     const payload = parseResponse(response)
@@ -235,7 +279,7 @@ export class ChatCacheStore {
     const isOwnedChat = ownedList || this.payloadBelongsToOwnedChat(owner, profile, sessionID, payload)
     if (!isOwnedChat) return false
     const now = Date.now()
-    this.db.exec('BEGIN IMMEDIATE')
+    this.db.exec('SAVEPOINT chat_snapshot')
     try {
       this.ingestPayload(owner, profile, sessionID, payload, now, kind, requestStartedAt)
       this.db.prepare(`INSERT INTO chat_snapshots(owner,cache_key,kind,profile,session_id,status,headers,body,sync_state,saved_at)
@@ -243,26 +287,44 @@ export class ChatCacheStore {
         body=excluded.body,sync_state='current',saved_at=excluded.saved_at`)
         .run(owner, key, kind, profile, sessionID ?? null, response.status, headerRecord(response.headers),
           response.body.toString('base64'), 'current', now)
-      this.db.exec('COMMIT')
+      this.db.exec('RELEASE chat_snapshot')
       return true
     } catch (error) {
-      this.db.exec('ROLLBACK')
+      this.db.exec('ROLLBACK TO chat_snapshot; RELEASE chat_snapshot')
+      throw error
+    }
+  }
+
+  applySync(owner: string, profile: string, sessionID: string, revision: number,
+    pages: Array<{ key: string; kind: string; response: UpstreamResponse; startedAt: number }>, force: boolean): boolean {
+    if (this.revision(owner, profile, sessionID) !== revision) return false
+    this.db.exec('SAVEPOINT history_sync')
+    try {
+      if (force) this.db.prepare('DELETE FROM chat_messages WHERE owner=? AND profile=? AND session_id=?').run(owner, profile, sessionID)
+      this.db.prepare("DELETE FROM chat_snapshots WHERE owner=? AND profile=? AND session_id=? AND kind='messages'").run(owner, profile, sessionID)
+      for (const page of pages) this.putSnapshot(owner, page.key, page.kind, profile, sessionID, page.response, false, page.startedAt)
+      this.db.exec('RELEASE history_sync')
+      return true
+    } catch (error) {
+      this.db.exec('ROLLBACK TO history_sync; RELEASE history_sync')
       throw error
     }
   }
 
   messagePage(owner: string, profile: string, sessionID: string, offset: number, limit: number): MessagePage | undefined {
-    const session = this.db.prepare(`SELECT data,sync_state,complete FROM chat_sessions
+    const session = this.db.prepare(`SELECT data,sync_state,complete,message_total FROM chat_sessions
       WHERE owner=? AND profile=? AND session_id=?`).get(owner, profile, sessionID) as Record<string, unknown> | undefined
-    if (!session || !bool(session.complete)) return undefined
-    const totalRow = this.db.prepare('SELECT COUNT(*) count FROM chat_messages WHERE owner=? AND profile=? AND session_id=?')
-      .get(owner, profile, sessionID) as { count: number }
-    const total = Number(totalRow.count)
+    if (!session) return undefined
+    const totalRow = this.db.prepare('SELECT COUNT(*) count, MAX(position)+1 extent FROM chat_messages WHERE owner=? AND profile=? AND session_id=?')
+      .get(owner, profile, sessionID) as { count: number; extent: number | null }
+    if (session.message_total == null && !bool(session.complete)) return undefined
+    const total = Number(session.message_total ?? totalRow.extent ?? 0)
     const end = Math.max(0, total - offset)
     const start = Math.max(0, end - limit)
     const rows = this.db.prepare(`SELECT data FROM chat_messages WHERE owner=? AND profile=? AND session_id=?
       AND position>=? AND position<? ORDER BY position`).all(owner, profile, sessionID, start, end) as Array<{ data: string }>
     const messages = rows.map(row => JSON.parse(row.data))
+    if (messages.length !== end - start) return undefined // Fetch only an actually missing page.
     const body = Buffer.from(JSON.stringify({
       session_id: sessionID,
       session: JSON.parse(String(session.data)),
@@ -281,7 +343,7 @@ export class ChatCacheStore {
     }
   }
 
-  recordEvent(owner: string, profile: string, sessionID: string, frame: Record<string, unknown>): void {
+  recordEvent(owner: string, profile: string, sessionID: string, frame: Record<string, unknown>): boolean {
     const rawSeq = Number(frame.seq ?? frame.sequence ?? 0)
     const type = String(frame.type ?? 'unknown')
     const payload = JSON.stringify(frame.payload ?? {})
@@ -292,22 +354,38 @@ export class ChatCacheStore {
     const previous = this.db.prepare(`SELECT last_event_seq FROM chat_sessions
       WHERE owner=? AND profile=? AND session_id=?`).get(owner, profile, sessionID) as { last_event_seq?: number } | undefined
     const lastSeen = Number(previous?.last_event_seq ?? 0)
-    this.db.prepare(`INSERT OR IGNORE INTO chat_events(owner,profile,session_id,event_seq,event_type,payload,received_at)
+    const inserted = this.db.prepare(`INSERT OR IGNORE INTO chat_events(owner,profile,session_id,event_seq,event_type,payload,received_at)
       VALUES(?,?,?,?,?,?,?)`).run(owner, profile, sessionID, Number.isSafeInteger(seq) ? seq : 0, type, payload, now)
+    if (!inserted.changes) return false
     const state: ChatCacheState = type === 'gateway.reset' || type.includes('reset')
       || (rawSeq > 0 && lastSeen > 0 && rawSeq > lastSeen + 1) ? 'gap' : 'stale'
-    this.db.prepare(`UPDATE chat_sessions SET sync_state=?,last_event_seq=MAX(last_event_seq,?),last_synced_at=?
-      WHERE owner=? AND profile=? AND session_id=?`).run(state, rawSeq > 0 ? rawSeq : lastSeen, now, owner, profile, sessionID)
+    const metadataOnly = ['session.info', 'session.title', 'session.title.updated', 'session.usage', 'gateway.ready'].includes(type) && state !== 'gap'
+    this.db.prepare(`UPDATE chat_sessions SET sync_state=CASE WHEN ? THEN sync_state ELSE ? END,
+      sync_revision=sync_revision+?,last_event_seq=MAX(last_event_seq,?),last_synced_at=?
+      WHERE owner=? AND profile=? AND session_id=?`).run(metadataOnly ? 1 : 0, state, metadataOnly ? 0 : 1, rawSeq > 0 ? rawSeq : lastSeen, now, owner, profile, sessionID)
+    if (type === 'session.info') {
+      const payload = object(frame.payload) ?? {}
+      const info = object(payload.info) ?? payload
+      const row = this.db.prepare('SELECT data FROM chat_sessions WHERE owner=? AND profile=? AND session_id=?')
+        .get(owner, profile, sessionID) as { data: string } | undefined
+      if (row) {
+        const summary = object(JSON.parse(row.data)) ?? {}
+        for (const field of ['model', 'provider', 'fast']) if (info[field] !== undefined) summary[field] = info[field]
+        this.db.prepare('UPDATE chat_sessions SET data=? WHERE owner=? AND profile=? AND session_id=?')
+          .run(JSON.stringify(summary), owner, profile, sessionID)
+      }
+    }
     if (type === 'session.title' || type === 'session.title.updated') {
       const title = String(object(frame.payload)?.title ?? frame.title ?? '').trim()
       if (title) this.updateOwnedSessionTitle(owner, profile, sessionID, title, now)
     }
-    this.db.prepare(`UPDATE chat_snapshots SET sync_state=? WHERE owner=? AND (session_id=? OR kind='list')`)
-      .run(state, owner, sessionID)
+    this.db.prepare(`UPDATE chat_snapshots SET sync_state=? WHERE owner=? AND (session_id=? OR kind='list')
+      AND (?=0 OR kind<>'messages')`).run(state, owner, sessionID, metadataOnly ? 1 : 0)
     this.db.prepare(`INSERT INTO chat_sync_cursors(owner,profile,event_cursor,sync_state,last_synced_at)
       VALUES(?,?,?,?,?) ON CONFLICT(owner,profile) DO UPDATE SET event_cursor=MAX(event_cursor,excluded.event_cursor),
       sync_state=excluded.sync_state,last_synced_at=excluded.last_synced_at`)
       .run(owner, profile, rawSeq > 0 ? rawSeq : lastSeen, state, now)
+    return true
   }
 
   recordRoute(owner: string, profile: string, sessionID: string, runtimeID: string): void {
@@ -330,7 +408,7 @@ export class ChatCacheStore {
     this.db.prepare(`INSERT INTO chat_sessions(owner,profile,session_id,source,data,sync_state,complete,last_synced_at,owned_at,ownership_evidence)
       VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id) DO UPDATE SET
       source=CASE WHEN excluded.source='unknown' THEN chat_sessions.source ELSE excluded.source END,
-      sync_state='stale',complete=0,last_synced_at=excluded.last_synced_at,
+      sync_state='stale',sync_revision=chat_sessions.sync_revision+1,last_synced_at=excluded.last_synced_at,
       owned_at=COALESCE(chat_sessions.owned_at,excluded.owned_at),
       ownership_evidence=COALESCE(chat_sessions.ownership_evidence,excluded.ownership_evidence)`)
       .run(owner, profile, sessionID, source, JSON.stringify(summary), 'stale', 0, now,
@@ -681,8 +759,8 @@ export class ChatCacheStore {
       const storedSession = preservesAuthoritativeTitle
         ? { ...session, title: authoritativeTitle }
         : session
-      this.db.prepare(`INSERT INTO chat_sessions(owner,profile,session_id,runtime_id,source,data,sync_state,complete,last_synced_at)
-        VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id) DO UPDATE SET data=excluded.data,
+      this.db.prepare(`INSERT INTO chat_sessions(owner,profile,session_id,runtime_id,source,data,sync_state,complete,last_synced_at,metadata_complete)
+        VALUES(?,?,?,?,?,?,?,?,?,1) ON CONFLICT(owner,profile,session_id) DO UPDATE SET data=excluded.data,metadata_complete=1,
         source=CASE WHEN excluded.source='unknown' THEN chat_sessions.source ELSE excluded.source END,
         sync_state='current',last_synced_at=excluded.last_synced_at`)
         .run(owner, profile, id, null, source || 'unknown', JSON.stringify(storedSession), 'current', 0, now)
@@ -700,7 +778,7 @@ export class ChatCacheStore {
       }
     }
     const messages = Array.isArray(payload.messages) ? payload.messages.flatMap(value => object(value) ? [object(value)!] : []) : []
-    if (!messages.length || !sessionID) return
+    if (!Array.isArray(payload.messages) || !sessionID) return
     const profile = fallbackProfile || profileOf(direct ?? {}, 'default') || 'default'
     const pagination = object(payload.pagination) ?? {}
     const total = Math.max(messages.length, Number(pagination.total ?? payload.total ?? messages.length) || messages.length)
@@ -710,7 +788,8 @@ export class ChatCacheStore {
       const data = JSON.stringify(message)
       this.db.prepare(`INSERT INTO chat_messages(owner,profile,session_id,message_id,position,data,content_hash)
         VALUES(?,?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id,message_id) DO UPDATE SET position=excluded.position,
-        data=excluded.data,content_hash=excluded.content_hash`)
+        data=excluded.data,content_hash=excluded.content_hash
+        WHERE chat_messages.content_hash<>excluded.content_hash OR chat_messages.position<>excluded.position`)
         .run(owner, profile, sessionID, messageID(message, String(start + index)), start + index, data,
           createHash('sha256').update(data).digest('hex'))
       for (const sourcePath of referencedPaths(message)) {
@@ -719,10 +798,12 @@ export class ChatCacheStore {
           .run(owner, profile, sessionID, sourcePath, 'pending', now)
       }
     })
-    const hasMore = bool(pagination.hasMore ?? pagination.has_more ?? payload.hasMore ?? payload.has_more)
-    const complete = !hasMore && offset + messages.length >= total
-    this.db.prepare(`UPDATE chat_sessions SET complete=MAX(complete,?),sync_state='current',last_synced_at=?
-      WHERE owner=? AND profile=? AND session_id=?`).run(complete ? 1 : 0, now, owner, profile, sessionID)
+    const coverage = this.db.prepare(`SELECT COUNT(*) count, COUNT(DISTINCT position) positions, MIN(position) first, MAX(position) last
+      FROM chat_messages WHERE owner=? AND profile=? AND session_id=?`).get(owner, profile, sessionID) as Record<string, number>
+    const complete = total === 0 ? coverage.count === 0
+      : coverage.count === total && coverage.positions === total && coverage.first === 0 && coverage.last === total - 1
+    this.db.prepare(`UPDATE chat_sessions SET complete=?,message_total=?,sync_state='current',last_synced_at=?
+      WHERE owner=? AND profile=? AND session_id=?`).run(complete ? 1 : 0, total, now, owner, profile, sessionID)
   }
 
   private updateOwnedSessionTitle(owner: string, profile: string, sessionID: string, title: string, now: number): void {
@@ -748,6 +829,7 @@ export class ChatCacheStore {
 
 export class ChatCacheCoordinator {
   private reconciling = new Map<string, Promise<void>>()
+  onSynchronized: (owner: string, profile: string, sessionID: string) => void = () => {}
   constructor(readonly store: ChatCacheStore, readonly upstream: UpstreamServiceSession,
     readonly mode: ChatCacheMode = 'prefer-local') {}
 
@@ -764,15 +846,20 @@ export class ChatCacheCoordinator {
         state: 'current',
       }
     }
-    const local = this.store.snapshot(owner, key)
+    if (this.mode === 'prefer-local' && kind === 'list' && ownedList && !this.store.needsListMetadata(owner, profile)) {
+      return { response: this.store.localList(owner, profile, listPage), source: 'local', state: 'current' }
+    }
+    const local = kind === 'list' && ownedList && this.store.needsListMetadata(owner, profile) ? undefined : kind === 'detail' && sessionID
+      ? this.store.localDetail(owner, profile, sessionID) ?? this.store.snapshot(owner, key)
+      : this.store.snapshot(owner, key)
     const projectedLocal = local && kind === 'list' && ownedList
       ? {
           ...local,
           response: this.store.mergeOwnedSessionsIntoList(owner, profile, local.response, listPage),
         }
       : local
-    if (this.mode === 'prefer-local' && local?.state === 'current') {
-      return { response: local.response, source: 'local', state: local.state }
+    if (this.mode === 'prefer-local' && local) {
+      return { response: projectedLocal!.response, source: 'local', state: local.state }
     }
     try {
       const requestStartedAt = Date.now()
@@ -799,22 +886,19 @@ export class ChatCacheCoordinator {
   async messages(owner: string, key: string, profile: string, sessionID: string, offset: number, limit: number,
     load: () => Promise<UpstreamResponse>): Promise<{ response: UpstreamResponse; source: ChatCacheSource; state: ChatCacheState }> {
     if (this.mode === 'prefer-local') {
+      // A completion may still be committing its authoritative tail. Join it for
+      // the latest page; older, already stored pages never wait for the network.
+      if (offset === 0) await this.reconciling.get(JSON.stringify([owner, profile, sessionID]))?.catch(() => {})
       const page = this.store.messagePage(owner, profile, sessionID, offset, limit)
-      if (page?.state === 'current') return { response: page.response, source: 'local', state: page.state }
+      if (page) return { response: page.response, source: 'local', state: page.state }
     }
-    const result = await this.read(owner, key, 'messages', profile, sessionID, load)
-    if (result.source === 'upstream'
-      && this.store.ownsSession(owner, profile, sessionID)
-      && !this.store.messagePage(owner, profile, sessionID, 0, 1)) {
-      void this.reconcile(owner, profile, sessionID)
-    }
-    return result
+    return this.read(owner, key, 'messages', profile, sessionID, load)
   }
 
   observe(owner: string, profile: string, sessionID: string, frame: Record<string, unknown>): void {
-    this.store.recordEvent(owner, profile, sessionID, frame)
-    if (this.mode !== 'upstream-only' && String(frame.type) === 'message.complete') {
-      void this.reconcile(owner, profile, sessionID)
+    if (!this.store.recordEvent(owner, profile, sessionID, frame)) return
+    if (['message.complete', 'gateway.reset'].includes(String(frame.type))) {
+      void this.reconcile(owner, profile, sessionID).catch(() => {})
     }
   }
 
@@ -831,29 +915,60 @@ export class ChatCacheCoordinator {
     this.store.recordRoute(owner, profile, sessionID, runtimeID)
   }
 
-  async reconcile(owner: string, profile: string, sessionID: string): Promise<void> {
-    if (this.mode === 'upstream-only') return
+  async reconcile(owner: string, profile: string, sessionID: string, force = false): Promise<void> {
+    if (this.mode === 'upstream-only' || !this.store.ownsSession(owner, profile, sessionID)) return
     const key = JSON.stringify([owner, profile, sessionID])
-    if (this.reconciling.has(key)) return this.reconciling.get(key)
+    const pending = this.reconciling.get(key)
+    if (pending) {
+      await pending
+      if (force || this.store.localDetail(owner, profile, sessionID)?.state !== 'current') {
+        return this.reconcile(owner, profile, sessionID, force)
+      }
+      return
+    }
     const task = (async () => {
+      const revision = this.store.revision(owner, profile, sessionID)
+      const previous = this.store.messagePage(owner, profile, sessionID, 0, 1)
+      const previousTotal = previous ? Number(object(parseResponse(previous.response)?.pagination)?.total ?? 0) : 0
       const detailPath = `/api/sessions/${encodeURIComponent(sessionID)}`
-      const detailSearch = new URLSearchParams({ profile })
       const detailStartedAt = Date.now()
-      const detail = await this.upstream.request(detailPath, { search: detailSearch })
-      this.store.putSnapshot(owner, `detail:${profile}:${sessionID}`, 'detail', profile, sessionID, detail, false, detailStartedAt)
+      const detail = await this.upstream.request(detailPath, { search: new URLSearchParams({ profile }), cache: 'reload' })
+      if (detail.status !== 200) throw new Error('Unable to synchronize conversation metadata')
+      const pages = [{ key: `detail:${profile}:${sessionID}`, kind: 'detail', response: detail, startedAt: detailStartedAt }]
       let offset = 0
-      for (let page = 0; page < 200; page++) {
-        const search = new URLSearchParams({ profile, offset: String(offset), limit: '500', order: 'latest', include_compacted: 'true' })
-        const messagesStartedAt = Date.now()
-        const response = await this.upstream.request(`${detailPath}/messages`, { search })
-        if (!this.store.putSnapshot(owner, `messages:${profile}:${sessionID}:${offset}:500`, 'messages', profile, sessionID, response, false, messagesStartedAt)) return
+      let done = false
+      let expectedTotal: number | undefined
+      // Stop as soon as the authoritative tail overlaps durable messages. Only
+      // explicit refresh (or uncached history) walks all the way to the beginning.
+      for (let page = 0; page < 1_000; page++) {
+        const search = new URLSearchParams({ profile, offset: String(offset), limit: '100', order: 'latest', include_compacted: 'true' })
+        const startedAt = Date.now()
+        const response = await this.upstream.request(`${detailPath}/messages`, { search, cache: 'reload' })
         const payload = parseResponse(response)
-        const messages = Array.isArray(payload?.messages) ? payload.messages : []
-        const pagination = object(payload?.pagination) ?? {}
-        if (!bool(pagination.hasMore ?? pagination.has_more ?? payload?.hasMore ?? payload?.has_more) || !messages.length) return
+        if (response.status !== 200 || !Array.isArray(payload?.messages)) throw new Error('Unable to synchronize conversation messages')
+        const messages = payload.messages.flatMap(value => object(value) ? [object(value)!] : [])
+        const pagination = object(payload.pagination) ?? {}
+        const total = Number(pagination.total ?? payload.total ?? messages.length)
+        if (!force && total < previousTotal) throw new Error('Stored history changed; explicit refresh is required')
+        if (expectedTotal !== undefined && expectedTotal !== total) throw new Error('Conversation changed during synchronization')
+        expectedTotal = total
+        const anchors = !force ? messages.map((message, index) =>
+          this.store.hasMessage(owner, profile, sessionID, message, total - offset - messages.length + index)) : []
+        const overlap = anchors.some(Boolean)
+        pages.push({ key: `messages:${profile}:${sessionID}:${offset}:100`, kind: 'messages', response, startedAt })
+        if (overlap || !bool(pagination.hasMore ?? pagination.has_more ?? payload.hasMore ?? payload.has_more)) { done = true; break }
+        if (!messages.length) break
         offset += messages.length
       }
-    })().catch(() => {}).finally(() => this.reconciling.delete(key))
+      if (!done) throw new Error('Conversation synchronization is incomplete')
+      if (!this.store.applySync(owner, profile, sessionID, revision, pages, force)) {
+        // Preserve the invalidation when a newer event arrived during the read.
+        // Its completion joins this flight and schedules the next tail read.
+        if (force) throw new Error('Conversation changed during refresh; retry after the reply completes')
+      } else {
+        this.onSynchronized(owner, profile, sessionID)
+      }
+    })().finally(() => { if (this.reconciling.get(key) === task) this.reconciling.delete(key) })
     this.reconciling.set(key, task)
     return task
   }

+ 3 - 2
src/server/realtimeBroker.ts

@@ -403,12 +403,13 @@ export class RealtimeBroker {
       for (const owner of owners) this.publishOwnerSessionsChanged(owner)
     }
   }
-  private publishOwnerSessionsChanged(owner: string): void {
+  publishOwnerSessionsChanged(owner: string, reason = 'session.title', profile?: string, sessionID?: string): void {
+    if (this.closed) return
     this.onNativeGlobalEvent(owner, 'sessions.changed')
     const frame = {
       jsonrpc: '2.0',
       method: 'event',
-      params: { type: 'sessions.changed', payload: { reason: 'session.title' } },
+      params: { type: 'sessions.changed', payload: { reason, profile, session_id: sessionID } },
     }
     for (const channel of this.channels.values()) {
       if (channel.kind !== 'chat' || !channel.principal.valid()

+ 13 - 2
src/server/routes.ts

@@ -743,6 +743,17 @@ async function cachedChatRead(
     throw new HttpError(503, 'Chat ownership registry is unavailable', 'chat_registry_unavailable')
   }
   if (ctx.get('x-yaoyao-cache').toLowerCase() === 'bypass') {
+    if (options.kind === 'messages' && options.sessionID
+      && dependencies.chatCache.mode !== 'upstream-only'
+      && dependencies.chatCache.store.ownsSession(user.id, options.profile, options.sessionID)) {
+      await dependencies.chatCache.reconcile(user.id, options.profile, options.sessionID, true)
+      const page = dependencies.chatCache.store.messagePage(user.id, options.profile, options.sessionID, options.offset ?? 0, options.limit ?? 100)
+      if (!page) throw new HttpError(503, 'History refresh is incomplete', 'chat_refresh_incomplete')
+      ctx.set('X-Yaoyao-Data-Source', 'upstream')
+      ctx.set('X-Yaoyao-Sync-State', page.state)
+      sendUpstreamResponse(ctx, page.response, dependencies.upstreamSession.jar)
+      return
+    }
     const requestStartedAt = Date.now()
     const upstreamResponse = await load()
     const response = options.kind === 'list' && options.ownedList
@@ -2076,7 +2087,7 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       key: chatCacheKey('list', profile, undefined, search),
       kind: 'list', profile, ownedList: true, offset, limit,
       archived: search.get('archived') || 'exclude',
-      load: jar => dependencies.upstream.request(path, jar, { search: upstreamSearch }),
+      load: jar => completeSessionList(dependencies.upstream, path, jar, upstreamSearch),
     })
   })
   router.get('/api/app/sessions/:sessionID/messages', async (ctx) => {
@@ -2129,7 +2140,7 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
           )
         }
         dependencies.chatCache?.store.markListsStale(owner)
-        void dependencies.chatCache?.reconcile(owner, profile, id)
+        void dependencies.chatCache?.reconcile(owner, profile, id).catch(() => {})
       }
       sendUpstreamResponse(ctx, response, jar)
     })

+ 26 - 1
tests/client/chatModelSync.test.ts

@@ -8,6 +8,7 @@ const realtime = vi.hoisted(() => ({
 }))
 const sessionsApi = vi.hoisted(() => ({
   getSession: vi.fn(),
+  getMessages: vi.fn(),
   getSessions: vi.fn(),
 }))
 
@@ -25,7 +26,7 @@ vi.mock('@/api/realtime', () => {
 
 vi.mock('@/api/sessions', () => ({
   deleteSession: vi.fn(),
-  getMessages: vi.fn(),
+  getMessages: sessionsApi.getMessages,
   getSession: sessionsApi.getSession,
   getSessions: sessionsApi.getSessions,
   getSessionUnread: vi.fn(),
@@ -47,6 +48,7 @@ describe('chat model realtime synchronization', () => {
   beforeEach(() => {
     realtime.eventHandler = undefined
     realtime.request.mockReset().mockResolvedValue({})
+    sessionsApi.getMessages.mockReset()
     sessionsApi.getSession.mockReset()
     sessionsApi.getSessions.mockReset().mockResolvedValue({ items: [], nextCursor: null })
     setActivePinia(createPinia())
@@ -221,4 +223,27 @@ describe('chat model realtime synchronization', () => {
       }
     },
   )
+  it('uses a synchronized route without history reads and forces refresh only on request', async () => {
+    const chat = useChatStore()
+    const key = routeKey('alpha', 'session-1')
+    chat.activeSessionId = 'session-1'
+    chat.activeProfileName = 'alpha'
+    chat.routes[key] = {
+      route: { profile: 'alpha', sessionId: 'session-1' },
+      messages: [
+        { id: 'old', sessionId: 'session-1', role: 'assistant', content: 'old history', timestamp: 1, stage: 'settled' },
+        { id: 'unsent', sessionId: 'session-1', role: 'user', content: 'keep failed send', timestamp: 2, stage: 'failed' },
+      ], historySynced: true, hasMoreBefore: false, loadedMessageCount: 1, messageTotal: 1,
+      isLoadingHistory: false, isStreaming: false, isQueued: false, generation: 1,
+    }
+    for (let i = 0; i < 5; i++) await chat.selectSession('session-1', 'alpha')
+    expect(sessionsApi.getMessages).not.toHaveBeenCalled()
+    sessionsApi.getMessages.mockResolvedValue({ messages: [
+      { id: 'new', sessionId: 'session-1', role: 'assistant', content: 'authoritative', timestamp: 1, stage: 'settled' },
+    ], returned: 1, total: 1, hasMore: false })
+    await chat.forceRefreshHistory()
+    expect(sessionsApi.getMessages).toHaveBeenCalledWith('session-1', 0, 150, 'alpha', true)
+    expect(chat.messages.map(message => message.id)).toEqual(['new', 'unsent'])
+  })
+
 })

+ 21 - 0
tests/e2e/workspace.spec.ts

@@ -1056,3 +1056,24 @@ test('renders the Kanban snapshot and mobile status control without page overflo
   expect(geometry.page).toBe(geometry.viewport)
   expect(geometry.board).toBeGreaterThan(geometry.viewport)
 })
+
+test('serves cached chat history locally and refreshes upstream only on explicit action', async ({ page }, testInfo) => {
+  const messagesURL = (url: string) => url.includes('/api/app/sessions/session-demo/messages')
+  const initial = page.waitForResponse(response => messagesURL(response.url()))
+  await page.goto('/chat/session-demo?profile=yaoyao')
+  expect((await initial).ok()).toBe(true)
+  await expect(page.getByRole('button', { name: '会话操作', exact: true })).toBeVisible()
+  const restored = page.waitForResponse(response => messagesURL(response.url()))
+  await page.reload()
+  expect((await restored).headers()['x-yaoyao-data-source']).toBe('local')
+  await page.getByRole('button', { name: '会话操作', exact: true }).click()
+  await expect(page.getByRole('menuitem', { name: '强制刷新历史' })).toBeVisible()
+  await expect(page.getByRole('menu', { name: '会话操作' })).toHaveCSS('opacity', '1')
+  await page.screenshot({ path: testInfo.outputPath('force-history-refresh.png') })
+  const forced = page.waitForResponse(response => messagesURL(response.url()) && response.request().headers()['x-yaoyao-cache'] === 'bypass')
+  await page.getByRole('menuitem', { name: '强制刷新历史' }).click()
+  const refreshed = await forced
+  expect(refreshed.ok()).toBe(true)
+  expect(refreshed.headers()['x-yaoyao-data-source']).toBe('upstream')
+  await expect(page.locator('.message--assistant').filter({ hasText: '验收摘要' })).toBeVisible()
+})

+ 160 - 5
tests/server/chatCache.test.ts

@@ -38,16 +38,17 @@ describe('durable source=web chat cache', () => {
     const key = chatCacheKey('list', profile, undefined, search)
     const load = vi.fn(async () => response({ sessions: [{ id: sessionID, profile, source: 'web', title: '本地会话' }] }))
 
+    f.store.putSnapshot(owner, 'seed-detail', 'detail', profile, sessionID, response({ id: sessionID, profile, title: '本地会话', message_count: 0 }))
     const first = await f.coordinator.read(owner, key, 'list', profile, undefined, load, true)
     const second = await f.coordinator.read(owner, key, 'list', profile, undefined, load, true)
-    expect(first.source).toBe('upstream')
+    expect(first.source).toBe('local')
     expect(second.source).toBe('local')
-    expect(load).toHaveBeenCalledTimes(1)
+    expect(load).not.toHaveBeenCalled()
 
     f.store.close()
     const restored = new ChatCacheStore(f.home)
-    const cached = restored.snapshot(owner, key)
-    expect(JSON.parse(cached!.response.body.toString()).sessions[0].title).toBe('本地会话')
+    const cached = restored.localList(owner, profile, {})
+    expect(JSON.parse(cached.body.toString()).sessions[0].title).toBe('本地会话')
     restored.close()
   })
 
@@ -87,7 +88,7 @@ describe('durable source=web chat cache', () => {
       true,
       { offset: 0, limit: 100 },
     )
-    expect(projected).toMatchObject({ source: 'local', state: 'stale' })
+    expect(projected).toMatchObject({ source: 'local', state: 'current' })
     expect(JSON.parse(projected.response.body.toString()).sessions[0]).toMatchObject({
       id: sessionID,
       source: 'ios',
@@ -498,3 +499,157 @@ describe('durable source=web chat cache', () => {
     f.store.close()
   })
 })
+
+describe('local history and bounded tail synchronization', () => {
+  function cached(count = 1_000) {
+    const f = fixture()
+    f.store.recordRoute(owner, profile, sessionID, 'runtime')
+    const rows = Array.from({ length: count }, (_, i) => ({ id: `row-${i}`, role: i % 2 ? 'assistant' : 'user', content: `message ${i}` }))
+    const summary = { id: sessionID, profile, title: 'cached', message_count: count }
+    f.store.putSnapshot(owner, 'seed', 'messages', profile, sessionID, response({ session: summary, messages: rows,
+      pagination: { total: count, offset: 0, has_more: false } }))
+    return { ...f, rows, summary }
+  }
+  function upstreamFor(f: ReturnType<typeof cached>, rows: typeof f.rows) {
+    const request = vi.fn(async (path: string, options: { search: URLSearchParams }) => {
+      if (!path.endsWith('/messages')) return response({ ...f.summary, message_count: rows.length })
+      const offset = Number(options.search.get('offset'))
+      const limit = Number(options.search.get('limit'))
+      const end = Math.max(0, rows.length - offset), start = Math.max(0, end - limit)
+      return response({ messages: rows.slice(start, end), pagination: { total: rows.length, offset, has_more: start > 0 } })
+    })
+    const coordinator = new ChatCacheCoordinator(f.store, { request } as unknown as UpstreamServiceSession)
+    return { coordinator, request }
+  }
+
+  it('never rereads stored pages while a new turn invalidates the tail', async () => {
+    const f = cached()
+    f.store.recordCommand(owner, profile, sessionID, 'prompt.submit', { text: 'next' })
+    f.store.recordEvent(owner, profile, sessionID, { type: 'message.delta', seq: 1, payload: { delta: 'new' } })
+    const load = vi.fn(async () => { throw new Error('must remain local') })
+    for (let i = 0; i < 20; i++) {
+      const page = await f.coordinator.messages(owner, `page-${i}`, profile, sessionID, (i % 10) * 100, 100, load)
+      expect(page.source).toBe('local')
+      expect(JSON.parse(page.response.body.toString()).messages).toHaveLength(100)
+    }
+    expect(load).not.toHaveBeenCalled()
+    f.store.close()
+  })
+
+  it('persists new rows using one tail page and does not rewrite unchanged history', async () => {
+    const f = cached()
+    f.store.db.exec('CREATE TABLE changed_rows(id TEXT); CREATE TRIGGER changed_message AFTER UPDATE ON chat_messages BEGIN INSERT INTO changed_rows VALUES(new.message_id); END')
+    const rows = [...f.rows, { id: 'new-user', role: 'user', content: 'next' }, { id: 'new-answer', role: 'assistant', content: 'answer' }]
+    const { coordinator, request } = upstreamFor(f, rows)
+    coordinator.observe(owner, profile, sessionID, { type: 'message.complete', seq: 1 })
+    const page = await coordinator.messages(owner, 'last', profile, sessionID, 0, 10, async () => { throw new Error('unexpected read') })
+    expect(JSON.parse(page.response.body.toString()).messages.at(-1).id).toBe('new-answer')
+    expect(request.mock.calls.filter(([path]) => path.endsWith('/messages'))).toHaveLength(1)
+    expect(f.store.db.prepare('SELECT * FROM changed_rows').all()).toEqual([])
+    expect(f.store.messagePage(owner, profile, sessionID, 902, 100)).toBeDefined()
+    coordinator.observe(owner, profile, sessionID, { type: 'message.complete', seq: 1 })
+    expect(request).toHaveBeenCalledTimes(2)
+    f.store.close()
+  })
+
+  it('fetches only the missing suffix after a replay gap', async () => {
+    const f = cached()
+    const rows = [...f.rows, ...Array.from({ length: 130 }, (_, i) => ({ id: `new-${i}`, role: 'assistant', content: 'reply' }))]
+    const { coordinator, request } = upstreamFor(f, rows)
+    await coordinator.reconcile(owner, profile, sessionID)
+    expect(request.mock.calls.filter(([path]) => path.endsWith('/messages'))).toHaveLength(2)
+    expect(JSON.parse(f.store.messagePage(owner, profile, sessionID, 0, 10)!.response.body.toString()).pagination.total).toBe(1130)
+    f.store.close()
+  })
+
+  it('refreshes old edits and deletions only when explicitly forced', async () => {
+    const f = cached()
+    const rows = f.rows.slice(1).map(row => row.id === 'row-2' ? { ...row, content: 'edited old message' } : row)
+    const { coordinator, request } = upstreamFor(f, rows)
+    await expect(coordinator.reconcile(owner, profile, sessionID)).rejects.toThrow('explicit refresh')
+    expect(JSON.parse(f.store.messagePage(owner, profile, sessionID, 900, 100)!.response.body.toString()).messages[0].id).toBe('row-0')
+    request.mockClear()
+    await coordinator.reconcile(owner, profile, sessionID, true)
+    expect(request.mock.calls.filter(([path]) => path.endsWith('/messages'))).toHaveLength(10)
+    const page = JSON.parse(f.store.messagePage(owner, profile, sessionID, 899, 100)!.response.body.toString())
+    expect(page.messages[0].id).toBe('row-1')
+    expect(page.messages[1].content).toBe('edited old message')
+    expect(page.pagination.total).toBe(999)
+    f.store.close()
+  })
+
+  it('does not require a full archive before serving a stored page', async () => {
+    const f = fixture()
+    f.store.recordRoute(owner, profile, sessionID, 'runtime')
+    f.store.putSnapshot(owner, 'tail', 'messages', profile, sessionID, response({ messages: [{ id: 'last', role: 'assistant', content: 'tail' }], pagination: { total: 400, offset: 0, has_more: true } }))
+    expect(f.store.messagePage(owner, profile, sessionID, 0, 1)).toBeDefined()
+    expect(f.store.messagePage(owner, profile, sessionID, 1, 100)).toBeUndefined()
+    f.store.close()
+  })
+
+  it('keeps the previous archive when a forced refresh fails halfway', async () => {
+    const f = cached()
+    let calls = 0
+    const coordinator = new ChatCacheCoordinator(f.store, { request: async (path: string) => {
+      if (!path.endsWith('/messages')) return response(f.summary)
+      if (++calls > 1) throw new Error('offline')
+      return response({ messages: f.rows.slice(-100), pagination: { total: 1000, offset: 0, has_more: true } })
+    } } as unknown as UpstreamServiceSession)
+    await expect(coordinator.reconcile(owner, profile, sessionID, true)).rejects.toThrow('offline')
+    expect(f.store.messagePage(owner, profile, sessionID, 900, 100)).toBeDefined()
+    f.store.close()
+  })
+
+  it('does not commit an older fetch over a newer event', async () => {
+    const f = cached()
+    const { coordinator, request } = upstreamFor(f, f.rows)
+    const original = request.getMockImplementation()!
+    request.mockImplementation(async (path, options) => {
+      const result = await original(path, options)
+      if (path.endsWith('/messages')) f.store.recordEvent(owner, profile, sessionID, { type: 'message.delta', seq: 2, payload: { delta: 'newer' } })
+      return result
+    })
+    await coordinator.reconcile(owner, profile, sessionID)
+    expect(f.store.localDetail(owner, profile, sessionID)?.state).toBe('stale')
+    f.store.close()
+  })
+
+  it('joins an in-flight completion and publishes only after the newest revision commits', async () => {
+    const f = cached()
+    const rows = [...f.rows, { id: 'newest', role: 'assistant', content: 'newest reply' }]
+    const { coordinator, request } = upstreamFor(f, rows)
+    const original = request.getMockImplementation()!
+    let release!: () => void
+    const gate = new Promise<void>(resolve => { release = resolve })
+    let first = true
+    request.mockImplementation(async (path, options) => {
+      if (first) { first = false; await gate }
+      return original(path, options)
+    })
+    const published = vi.fn(() => {
+      const stored = JSON.parse(f.store.messagePage(owner, profile, sessionID, 0, 1)!.response.body.toString())
+      expect(stored.messages[0].id).toBe('newest')
+    })
+    coordinator.onSynchronized = published
+    f.store.recordEvent(owner, profile, sessionID, { type: 'message.complete', seq: 1 })
+    const firstSync = coordinator.reconcile(owner, profile, sessionID)
+    f.store.recordEvent(owner, profile, sessionID, { type: 'message.complete', seq: 2 })
+    const latestSync = coordinator.reconcile(owner, profile, sessionID)
+    release()
+    await Promise.all([firstSync, latestSync])
+    expect(published).toHaveBeenCalledTimes(1)
+    expect(request).toHaveBeenCalledTimes(4)
+    expect(f.store.localDetail(owner, profile, sessionID)?.state).toBe('current')
+    f.store.close()
+  })
+
+  it('serves a cached empty conversation without requesting it again', async () => {
+    const f = cached(0)
+    const load = vi.fn(async () => { throw new Error('unexpected read') })
+    const page = await f.coordinator.messages(owner, 'empty', profile, sessionID, 0, 100, load)
+    expect(page.source).toBe('local')
+    expect(JSON.parse(page.response.body.toString()).messages).toEqual([])
+    expect(load).not.toHaveBeenCalled()
+    f.store.close()
+  })
+})

+ 1 - 1
tests/server/chatCacheRoutes.test.ts

@@ -421,7 +421,7 @@ describe('source=web chat cache routes', () => {
     const offlineMedia = await request(restarted.app.callback()).get(mediaPath).set('Host', host).set('Cookie', sessionCookie)
       .expect('X-Yaoyao-Data-Source', 'local').expect(200)
     expect(Buffer.from(offlineMedia.body).toString()).toBe('png-bytes')
-    expect(offlineFetch).toHaveBeenCalledTimes(1)
+    expect(offlineFetch).not.toHaveBeenCalled()
     await request(restarted.app.callback()).get('/api/app/sessions?view=history').set('Host', host).set('Cookie', sessionCookie)
       .expect(502)
     expect(offlineFetch).toHaveBeenCalled()