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

兼容 Hermes 分页缺少总数和结束标记

LSamien 1 месяц назад
Родитель
Сommit
ed99a85730
2 измененных файлов с 106 добавлено и 7 удалено
  1. 42 7
      src/server/chatCache.ts
  2. 64 0
      tests/server/chatCachePagination.test.ts

+ 42 - 7
src/server/chatCache.ts

@@ -855,8 +855,11 @@ export class ChatCacheStore {
     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)
     const offset = Math.max(0, Number(pagination.offset ?? payload.offset ?? 0) || 0)
+    const storedDetail = this.localDetail(owner, profile, sessionID)
+    const storedSummary = storedDetail ? parseResponse(storedDetail.response) ?? {} : {}
+    const total = Math.max(offset + messages.length,
+      Number(pagination.total ?? payload.total ?? storedSummary.message_count ?? storedSummary.messageCount ?? 0) || 0)
     const start = Math.max(0, total - offset - messages.length)
     messages.forEach((message, index) => {
       const data = JSON.stringify(message)
@@ -1019,10 +1022,14 @@ export class ChatCacheCoordinator {
       const detailStartedAt = Date.now()
       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 }]
+      const detailPayload = parseResponse(detail) ?? {}
+      const detailSummary = object(detailPayload.session) ?? detailPayload
+      const summaryTotal = Math.max(0, Number(detailSummary.message_count ?? detailSummary.messageCount ?? previousTotal) || 0)
+      const pages = [{ key: `detail:${profile}:${sessionID}`, kind: 'detail', response: detail, startedAt: detailStartedAt, offset: 0 }]
       let offset = 0
       let done = false
       let expectedTotal: number | undefined
+      let resolvedTotal = summaryTotal
       // 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++) {
@@ -1033,19 +1040,47 @@ export class ChatCacheCoordinator {
         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)
+        const explicitTotal = pagination.total ?? payload.total
+        const total = Math.max(offset + messages.length, Number(explicitTotal ?? summaryTotal) || 0)
         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
+        if (explicitTotal !== undefined) {
+          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 }
+        pages.push({ key: `messages:${profile}:${sessionID}:${offset}:100`, kind: 'messages', response, startedAt, offset })
+        const more = pagination.hasMore ?? pagination.has_more ?? payload.hasMore ?? payload.has_more
+        const hasMore = more === undefined ? messages.length >= 100 : bool(more)
+        if (overlap || !hasMore) {
+          if (!overlap && expectedTotal !== undefined && expectedTotal !== offset + messages.length) throw new Error('Conversation pagination ended before its declared total')
+          resolvedTotal = overlap ? total : offset + messages.length
+          done = true
+          break
+        }
         if (!messages.length) break
         offset += messages.length
       }
       if (!done) throw new Error('Conversation synchronization is incomplete')
+      // Newer Hermes omits total/has_more. Once the end is known, project the
+      // same verified total onto every page before assigning absolute positions.
+      for (let index = 0; index < pages.length; index++) {
+        const entry = pages[index]!
+        const payload = parseResponse(entry.response)!
+        if (entry.kind === 'detail') {
+          const summary = object(payload.session) ?? payload
+          summary.message_count = resolvedTotal
+        } else {
+          const pagination = object(payload.pagination) ?? {}
+          const pageOffset = entry.offset
+          payload.pagination = { ...pagination, total: resolvedTotal, offset: pageOffset,
+            has_more: pageOffset + (payload.messages as unknown[]).length < resolvedTotal }
+        }
+        const headers = new Headers(entry.response.headers)
+        headers.delete('content-length')
+        entry.response = { ...entry.response, headers, body: Buffer.from(JSON.stringify(payload)) }
+      }
       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.

+ 64 - 0
tests/server/chatCachePagination.test.ts

@@ -0,0 +1,64 @@
+// @vitest-environment node
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { ChatCacheCoordinator, ChatCacheStore } from '../../src/server/chatCache.js'
+import type { UpstreamResponse } from '../../src/server/upstream.js'
+import type { UpstreamServiceSession } from '../../src/server/localAuth.js'
+const homes: string[] = []
+afterEach(() => homes.splice(0).forEach(home => rmSync(home, { recursive: true, force: true })))
+function response(body: unknown): UpstreamResponse {
+  return { status: 200, headers: new Headers({ 'content-type': 'application/json' }), body: Buffer.from(JSON.stringify(body)) }
+}
+function fixture(count: number) {
+  const home = mkdtempSync(join(tmpdir(), 'hermes-pagination-')); homes.push(home)
+  const store = new ChatCacheStore(home)
+  store.recordRoute('owner', 'default', 'chat', 'runtime')
+  const rows = Array.from({ length: count }, (_, id) => ({ id: String(id), role: 'assistant', content: `row ${id}` }))
+  const request = vi.fn(async (path: string, options: { search: URLSearchParams }) => {
+    if (!path.endsWith('/messages')) return response({ id: 'chat', profile: 'default', title: 'History', message_count: count })
+    const offset = Number(options.search.get('offset')), limit = Number(options.search.get('limit'))
+    const end = Math.max(0, rows.length - offset)
+    const messages = rows.slice(Math.max(0, end - limit), end)
+    // Real Hermes 0.21 response: deliberately no total or has_more.
+    return response({ session_id: 'chat', messages, pagination: { offset, limit, returned: messages.length, order: 'latest' } })
+  })
+  const coordinator = new ChatCacheCoordinator(store, { request } as unknown as UpstreamServiceSession)
+  return { store, rows, request, coordinator }
+}
+
+describe('Hermes pagination without total and has_more', () => {
+  it.each([250, 300])('force refresh preserves all %i messages and correct page positions', async count => {
+    const f = fixture(count)
+    await f.coordinator.reconcile('owner', 'default', 'chat', true)
+    const page = f.store.messagePage('owner', 'default', 'chat', count - 100, 100)!
+    const payload = JSON.parse(page.response.body.toString())
+    expect(payload.pagination.total).toBe(count)
+    expect(payload.messages[0].id).toBe('0')
+    expect(payload.messages.at(-1).id).toBe('99')
+    expect(f.request.mock.calls.filter(([path]) => path.endsWith('/messages'))).toHaveLength(Math.floor(count / 100) + 1)
+    f.store.close()
+  })
+
+  it('uses known metadata for a partial page instead of declaring that page to be the entire history', () => {
+    const f = fixture(250)
+    f.store.putSnapshot('owner', 'detail', 'detail', 'default', 'chat', response({ id: 'chat', message_count: 250, title: 'History' }))
+    f.store.putSnapshot('owner', 'tail', 'messages', 'default', 'chat', response({ session_id: 'chat', messages: f.rows.slice(-100), pagination: { offset: 0, limit: 100, returned: 100 } }))
+    const payload = JSON.parse(f.store.messagePage('owner', 'default', 'chat', 0, 100)!.response.body.toString())
+    expect(payload.pagination).toMatchObject({ total: 250, has_more: true })
+    expect(f.store.messagePage('owner', 'default', 'chat', 100, 100)).toBeUndefined()
+    f.store.close()
+  })
+
+  it('still reads only one overlapping tail page for an existing cache', async () => {
+    const f = fixture(252)
+    f.store.putSnapshot('owner', 'seed', 'messages', 'default', 'chat', response({ session: { id: 'chat', message_count: 250 }, messages: f.rows.slice(0, 250), pagination: { total: 250, offset: 0, has_more: false } }))
+    await f.coordinator.reconcile('owner', 'default', 'chat')
+    expect(f.request.mock.calls.filter(([path]) => path.endsWith('/messages'))).toHaveLength(1)
+    const page = JSON.parse(f.store.messagePage('owner', 'default', 'chat', 0, 100)!.response.body.toString())
+    expect(page.pagination.total).toBe(252)
+    expect(page.messages.at(-1).id).toBe('251')
+    f.store.close()
+  })
+})