Kaynağa Gözat

重构普通聊天为服务端统一消息协议 v2

Samien 1 ay önce
ebeveyn
işleme
c9bafbef7f

+ 75 - 0
docs/verification/2026-09-14-ordinary-chat-v2/README.md

@@ -0,0 +1,75 @@
+# 普通聊天 v2 统一消息链路
+
+实现范围为普通聊天。Web/macOS、iOS、Android 同时使用 `ordinary-chat-transcript-v2`;原生 Hermes Bot 和 Bot 模式保留原来的运行边界。本次只交付源码和验证,未发布、部署或安装正式客户端,也没有操作实际聊天数据库。
+
+## 服务端职责
+
+`ChatTranscriptStore` 直接消费经过规范化的输入回执、运行事件和恢复快照,持久化回合、消息、片段归属及上游别名。`chat_messages` 仅保留上游历史导入和核验职责;旧的 `chatEventProjection.ts` 与实时写入旧缓存的路径已移除。
+
+- 显示 ID 在流式输出、恢复、上游数据库 ID 出现后保持不变。回合、消息、工具调用分别建立身份关系。
+- 增量追加,完整快照替换所属片段;预览后的完成事件结束同一条消息。工具调用、明确的新消息边界和后续片段保留各自的正文。
+- 排队输入不会改变当前输出的归属,steer 保持在当前回合。迟到的命令回执或旧回合完成事件不会重新开启、结束当前运行。
+- 事件收据、源游标、规范记录和 SSE 日志在同一 SQLite 事务提交。上游事件 ID 使用上游 epoch、运行路由和序号,服务端进程重启不改变已保存收据的身份。
+- 无法取得连续上游重放时,保留当前记录并接收完整恢复快照,暂不追加无法确定位置的增量;终态和后台历史核验补齐内容。
+- 列表、附件来源及最终回复未读数从规范记录读取。历史核验不重新提交问题、不重发历史完成通知。流式热路径不再扫描整份聊天正文。
+
+## v2 契约与客户端
+
+沿用 `/api/app/chat/sessions/:id/snapshot` 和 `/events`。消息包含稳定 `id`、服务端顺序 `seq`、`revision`、`turn_id`、`segment_kind`,用户输入另有 `client_message_id`。上游 ID 只作为关联元数据。
+
+快照包含会话 epoch、游标、消息覆盖、删除 ID、运行/排队状态和待处理交互。每个事件包含 epoch、cursor、previousCursor、total;previousCursor 检测同一会话漏掉的事件,无须假设全局 SQLite cursor 连续。
+
+三端仅从规范记录生成普通聊天正文。运行路由的 resume 不再直接插入 assistant,旧历史拼接不再进入普通聊天路径。客户端保留独立的待确认输入,通过 client_message_id 消除本地输入与服务端回显的双份表示。
+
+消息、删除标记和 checkpoint 一起保存,保存失败不推进消费者游标;重复版本不再次应用。旧分页返回不能覆盖新版本或恢复已删除消息,分页不确认 SSE 游标。会话代次变化时替换已确认窗口并保留本地待发送输入。前台恢复重新订阅已保存位置,事件连接使用 45 秒无数据超时。
+
+新客户端不接受 v1 checkpoint,不回退到旧消息生成路径。新服务器对缺少 v2 声明的普通聊天通道返回 426 升级提示;原生 Bot 通道不受此门禁影响。v1 的试验环境变量不再控制这条正式代码路径。
+
+## 已有数据的核验修复
+
+启动时登记旧的已拥有会话,使用现有持久化后台队列核验,最多两个会话并发。运行中的会话延后,读取期间版本发生变化则重试。
+
+原始历史和规范记录、别名在切换前存入 `chat_transcript_archives`;修复任务保留状态,可中断后继续。结构变化产生新的会话 epoch,使三端用一个新快照替换旧窗口。无法确认的已接收输入及其关联回合保留,等待后续核验,不根据文本相同删除消息。用户真正重复发送的消息保持独立。
+
+实际旧数据的修复将在部署新服务后后台执行,本次验证仅使用临时 SQLite。
+
+## 验证
+
+- Web/服务端:全量 Vitest 1172 项通过、19 项按原配置跳过,`npm run typecheck`、`npm run build` 通过。
+- 服务端使用隔离事件驱动真实 HTTP/SSE 接口验证:事件重放结果与终态快照完全一致;预览和完成仅产生一个显示消息;旧普通聊天通道收到升级响应。
+- 场景覆盖:预览重复、ID 变化、保存失败、源游标重启、缺失重放、迟到事件、队列拒绝、steer、附件、删除与分页交错、旧 epoch、旧缓存升级以及会话切换。
+- Web、Swift、Kotlin 使用相同 JSON 协议输入;执行 `python3 docs/verification/2026-09-14-ordinary-chat-v2/verify-fixtures.py` 校验三份语义一致。规范化 SHA-256:`4512ac92a07783cd49f23949830c7c7e6fe94e96ce3164fa0a9b63760fdf6dbc`。
+- Android:`:core:model:test` 54 项、`:core:network:test` 46 项通过;`:app:compileDebugKotlin` 通过。
+- iOS 模拟器:ChatHistoryStore、ChatRuntime、HermesHTTPRealtime、OrdinaryTranscriptV2 共 355 项通过,包括将旧回复替换为规范记录、保留待确认输入并按身份合并回显。
+
+较大范围 iOS 检查还发现两个文件库已有失败:`testMessageFileArchiveQueryUsesContextAndReturnsCacheState`、`testYaoyaoFileLibraryUsesSupportedPluginQueryAndDecodesArchive`。在独立的未修改 `3dc20b6` 源码副本中,两项测试以同样的 URLProtocol transport 错误失败;这两个失败未混入本次修复。
+
+实体 iPhone 当前不可用。模拟器与协议重放验证不代表实体设备的后台挂起、蜂窝/Wi-Fi 切换已经验收;正式发布前还需在实体设备完成这部分验证。
+
+## 复验命令
+
+在 yaoyao:
+
+```sh
+NODE_OPTIONS=--no-experimental-webstorage npm test
+npm run typecheck
+npm run build
+python3 docs/verification/2026-09-14-ordinary-chat-v2/verify-fixtures.py
+```
+
+在 yaoyao-android(使用本机 JDK 17):
+
+```sh
+JAVA_HOME=/Users/samien/.local/share/hermes-android-toolchain/jdk-17 ./gradlew :core:model:test :core:network:test :app:compileDebugKotlin
+```
+
+在 yaoyao-mobile(将 destination 替换为可用模拟器):
+
+```sh
+xcodebuild -project YaoYaoAI.xcodeproj -scheme YaoYaoAI \
+  -destination 'platform=iOS Simulator,id=2AB1845D-8B54-4828-BA6E-0538CD08D488' \
+  test -only-testing:YaoYaoAITests/ChatRuntimeTests \
+  -only-testing:YaoYaoAITests/ChatHistoryStoreTests \
+  -only-testing:YaoYaoAITests/HermesHTTPRealtimeTests \
+  -only-testing:YaoYaoAITests/OrdinaryTranscriptV2Tests CODE_SIGNING_ALLOWED=NO
+```

+ 17 - 0
docs/verification/2026-09-14-ordinary-chat-v2/verify-fixtures.py

@@ -0,0 +1,17 @@
+#!/usr/bin/env python3
+"""Verify that TypeScript, Swift and Kotlin exercise the same v2 wire contract."""
+import hashlib
+import json
+from pathlib import Path
+import re
+
+root = Path(__file__).resolve().parents[3]
+source = json.loads((root / 'tests/fixtures/ordinary-chat-v2/protocol.json').read_text())
+android = json.loads((root.parent / 'yaoyao-android/core/network/src/test/resources/ordinary-chat-v2/protocol.json').read_text())
+ios_test = (root.parent / 'yaoyao-mobile/YaoYaoAITests/HermesHTTPRealtimeTests.swift').read_text()
+match = re.search(r'fixtureJSON = #"""\s*(.*?)\s*"""#', ios_test, re.S)
+assert match, 'iOS wire fixture is missing'
+ios = json.loads(match.group(1))
+assert source == android == ios, 'The three client fixtures have diverged'
+canonical = json.dumps(source, sort_keys=True, ensure_ascii=False, separators=(',', ':')).encode()
+print('Three-client fixture SHA-256:', hashlib.sha256(canonical).hexdigest())

+ 228 - 80
src/client/api/chatTranscript.ts

@@ -1,98 +1,246 @@
 import { apiRequest } from './client'
 import { SSEParser } from '@shared/sse'
-import { applyTranscriptEvent, CHAT_TRANSCRIPT_FEATURE, TranscriptGap,
-  type TranscriptEvent, type TranscriptMessage, type TranscriptSnapshot } from '@shared/chatTranscript'
+import {
+  applyTranscriptEvent,
+  CHAT_TRANSCRIPT_FEATURE,
+  TranscriptGap,
+  type TranscriptEvent,
+  type TranscriptMessage,
+  type TranscriptSnapshot,
+} from '@shared/chatTranscript'
 
+/** Serial, durable replica: no state/cursor becomes visible before changed commits it. */
 export class ChatTranscriptClient {
   private abort = new AbortController()
-  private messages: TranscriptMessage[] = []
-  private cursor = 0
-  private epoch = ''
   private snapshot?: TranscriptSnapshot
   private fetchingOlder = false
-  constructor(readonly profile:string,readonly sessionId:string,
-    readonly changed:(snapshot:TranscriptSnapshot)=>Promise<void>,readonly failed:(error:Error)=>void,
-    readonly initialCount=150){}
-  static async supported():Promise<boolean>{
-    try{return (await apiRequest<{features:string[]}>('/api/app/chat/capabilities')).features.includes(CHAT_TRANSCRIPT_FEATURE)}catch{return false}
+  private operations: Promise<unknown> = Promise.resolve()
+  private request?: AbortController
+  private readonly foreground = () => {
+    if (document.visibilityState === 'visible') this.request?.abort()
   }
-  close(){this.abort.abort()}
-  restore(value:TranscriptSnapshot){if(value.profile===this.profile&&value.sessionId===this.sessionId){this.snapshot=value;this.messages=value.messages;this.cursor=value.cursor;this.epoch=value.epoch}}
-  private path(suffix:string,query:Record<string,string>={}) {return `/api/app/chat/sessions/${encodeURIComponent(this.sessionId)}/${suffix}?${new URLSearchParams({profile:this.profile,...query})}`}
-  private async page(before?:number):Promise<TranscriptSnapshot>{
-    const value=await apiRequest<TranscriptSnapshot>(this.path('snapshot',{limit:'150',...(before===undefined?{}:{before:String(before)})}),{signal:this.abort.signal})
-    if(value.protocol!==CHAT_TRANSCRIPT_FEATURE||value.profile!==this.profile||value.sessionId!==this.sessionId)throw new TranscriptGap()
+  constructor(
+    readonly profile: string,
+    readonly sessionId: string,
+    readonly changed: (snapshot: TranscriptSnapshot) => Promise<void>,
+    readonly failed: (error: Error) => void,
+    readonly initialCount = 150,
+  ) {
+    if (typeof document !== 'undefined') document.addEventListener('visibilitychange', this.foreground)
+  }
+  static async supported(): Promise<boolean> {
+    return (await apiRequest<{ features: string[] }>('/api/app/chat/capabilities')).features.includes(
+      CHAT_TRANSCRIPT_FEATURE,
+    )
+  }
+  close() {
+    this.abort.abort()
+    this.request?.abort()
+    if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', this.foreground)
+  }
+  restore(value: TranscriptSnapshot) {
+    if (
+      value.protocol === CHAT_TRANSCRIPT_FEATURE &&
+      value.profile === this.profile &&
+      value.sessionId === this.sessionId
+    )
+      this.snapshot = value
+  }
+  private serial<T>(operation: () => Promise<T>): Promise<T> {
+    const task = this.operations.then(operation, operation)
+    this.operations = task.catch(() => {})
+    return task
+  }
+  private path(suffix: string, query: Record<string, string> = {}) {
+    return `/api/app/chat/sessions/${encodeURIComponent(this.sessionId)}/${suffix}?${new URLSearchParams({ profile: this.profile, ...query })}`
+  }
+  private async page(before?: number): Promise<TranscriptSnapshot> {
+    const value = await apiRequest<TranscriptSnapshot>(
+      this.path('snapshot', { limit: '150', ...(before === undefined ? {} : { before: String(before) }) }),
+      { signal: this.abort.signal },
+    )
+    if (
+      value.protocol !== CHAT_TRANSCRIPT_FEATURE ||
+      value.profile !== this.profile ||
+      value.sessionId !== this.sessionId
+    )
+      throw new TranscriptGap()
     return value
   }
-  private async publish(){if(this.snapshot&&!this.abort.signal.aborted)await this.changed({...this.snapshot,messages:this.messages,cursor:this.cursor,epoch:this.epoch})}
-  private async hydrate(){
-    const page=await this.page()
-    if(this.abort.signal.aborted)return
-    const first=page.messages[0]?.seq??Infinity,deleted=new Set(page.deletedIds??[])
-    const old=this.epoch===page.epoch?this.messages.filter(m=>m.seq<first&&!deleted.has(m.id)):[]
-    this.messages=[...old,...page.messages];this.epoch=page.epoch;this.cursor=page.cursor;this.snapshot=page
-    // Upgrade already loaded legacy pages before replacing the visible cache.
-    while(this.messages.length<this.initialCount&&this.snapshot.hasOlder&&this.messages.length){
-      const older=await this.page(this.messages[0]!.seq)
-      if(older.epoch!==this.epoch)throw new TranscriptGap()
-      const ids=new Set(this.messages.map(m=>m.id))
-      const added=older.messages.filter(m=>!ids.has(m.id))
-      this.messages=[...added,...this.messages];this.snapshot.hasOlder=older.hasOlder
-      if(!added.length)break
+  private async commit(value: TranscriptSnapshot) {
+    if (this.abort.signal.aborted) return
+    await this.changed(value)
+    if (!this.abort.signal.aborted) this.snapshot = value
+  }
+  private merge(current: TranscriptSnapshot, page: TranscriptSnapshot): TranscriptMessage[] {
+    const deleted = new Set([...(current.deletedIds ?? []), ...(page.deletedIds ?? [])])
+    const messages = new Map(current.messages.filter((m) => !deleted.has(m.id)).map((m) => [m.id, m]))
+    for (const message of page.messages)
+      if (!deleted.has(message.id) && (messages.get(message.id)?.revision ?? 0) < message.revision)
+        messages.set(message.id, message)
+    return [...messages.values()].sort((a, b) => a.seq - b.seq || a.id.localeCompare(b.id))
+  }
+  private async hydrate() {
+    let value = await this.page()
+    if (this.abort.signal.aborted) return
+    while (value.messages.length < this.initialCount && value.hasOlder && value.messages.length) {
+      const older = await this.page(value.messages[0]!.seq)
+      if (older.epoch !== value.epoch) throw new TranscriptGap()
+      const messages = this.merge(value, older),
+        added = messages.length - value.messages.length
+      value = {
+        ...value,
+        messages,
+        hasOlder: older.hasOlder,
+        deletedIds: [...new Set([...(value.deletedIds ?? []), ...(older.deletedIds ?? [])])],
+      }
+      if (!added) break
+    }
+    await this.serial(async () => {
+      const old = this.snapshot
+      if (old?.epoch === value.epoch && old.cursor > value.cursor) return
+      // An authoritative generation replaces the covered window. Older loaded
+      // pages can be retained only inside the same epoch and outside that window.
+      if (old?.epoch === value.epoch && value.messages.length) {
+        const first = value.messages[0]!.seq
+        value = {
+          ...value,
+          messages: this.merge({ ...old, messages: old.messages.filter((m) => m.seq < first) }, value),
+        }
+      }
+      await this.commit(value)
+    })
+  }
+  async loadOlder() {
+    const starting = this.snapshot
+    if (this.fetchingOlder || !starting?.hasOlder || !starting.messages.length) return
+    this.fetchingOlder = true
+    try {
+      const page = await this.page(starting.messages[0]!.seq)
+      await this.serial(async () => {
+        const current = this.snapshot
+        if (!current || page.epoch !== current.epoch) return
+        await this.commit({
+          ...current,
+          messages: this.merge(current, page),
+          hasOlder: page.hasOlder,
+          deletedIds: [...new Set([...(current.deletedIds ?? []), ...(page.deletedIds ?? [])])],
+        })
+      })
+    } finally {
+      this.fetchingOlder = false
     }
-    await this.publish()
   }
-  async loadOlder(){
-    if(this.fetchingOlder||!this.snapshot?.hasOlder||!this.messages.length)return
-    this.fetchingOlder=true
-    try{
-      const page=await this.page(this.messages[0]!.seq)
-      if(page.epoch!==this.epoch)throw new TranscriptGap()
-      const current=new Map(this.messages.map(m=>[m.id,m]))
-      for(const message of page.messages)if((current.get(message.id)?.revision??0)<message.revision)current.set(message.id,message)
-      this.messages=[...current.values()].sort((a,b)=>a.seq-b.seq||a.id.localeCompare(b.id));this.snapshot.hasOlder=page.hasOlder
-      // A history page is not an event acknowledgement; never move cursor here.
-      await this.publish()
-    }finally{this.fetchingOlder=false}
+  private async receive(event: TranscriptEvent) {
+    await this.serial(async () => {
+      const current = this.snapshot
+      if (!current) throw new TranscriptGap()
+      if (
+        event.profile !== this.profile ||
+        event.sessionId !== this.sessionId ||
+        event.epoch !== current.epoch ||
+        !Number.isSafeInteger(event.cursor)
+      )
+        throw new TranscriptGap()
+      if (event.cursor <= current.cursor) return
+      if (
+        event.previousCursor !== current.cursor ||
+        event.type === 'session.deleted' ||
+        event.type === 'session.migrated'
+      )
+        throw new TranscriptGap()
+      const data = event.data as Record<string, any>,
+        deleted = new Set(current.deletedIds ?? [])
+      if (event.type === 'message.deleted') deleted.add(String(data.id))
+      const before = current.messages[0]?.seq
+      const unloaded =
+        ['message.patch', 'message.upsert'].includes(event.type) &&
+        typeof data.seq === 'number' &&
+        before !== undefined &&
+        data.seq < before
+      const messages =
+        unloaded || (event.type !== 'message.deleted' && deleted.has(String(data.id)))
+          ? current.messages
+          : applyTranscriptEvent(current.messages, event)
+      const next: TranscriptSnapshot = {
+        ...current,
+        messages,
+        cursor: event.cursor,
+        deletedIds: [...deleted],
+        total: event.total ?? current.total,
+      }
+      if (event.type === 'session.changed') {
+        for (const key of [
+          'running',
+          'queued',
+          'pendingApproval',
+          'pendingClarification',
+          'liveStatus',
+          'error',
+        ] as const)
+          if (data[key] !== undefined) (next as any)[key] = data[key]
+        if (data.eventType === 'session.info' && data.payload)
+          next.session = { ...next.session, ...data.payload }
+      }
+      await this.commit(next)
+    })
   }
-  async run(){
-    let needsSnapshot=!this.snapshot
-    while(!this.abort.signal.aborted){
-      let reader:ReadableStreamDefaultReader<Uint8Array>|undefined
-      try{
-        if(needsSnapshot){await this.hydrate();needsSnapshot=false}
-        const response=await fetch(this.path('events'),{headers:{Accept:'text/event-stream','Last-Event-ID':`${this.epoch}:${this.cursor}`},credentials:'include',cache:'no-store',signal:this.abort.signal})
-        if(response.status===409){needsSnapshot=true;throw new TranscriptGap()}
-        if(!response.ok||!response.body)throw new Error(`聊天消息流 HTTP ${response.status}`)
-        const parser=new SSEParser(),decoder=new TextDecoder();reader=response.body.getReader()
-        while(!this.abort.signal.aborted){
-          const {value,done}=await reader.read();if(done)break
-          for(const frame of parser.feed(decoder.decode(value,{stream:true}))){
-            if(frame.event==='reset')throw new TranscriptGap()
-            if(frame.event!=='transcript')continue
-            const event=JSON.parse(frame.data) as TranscriptEvent
-            if(event.profile!==this.profile||event.sessionId!==this.sessionId||!Number.isSafeInteger(event.cursor))throw new TranscriptGap()
-            if(event.cursor<=this.cursor)continue
-            if(event.type==='session.deleted'||event.type==='session.migrated')throw new TranscriptGap()
-            const data=event.data as {seq?:number}
-            // Older, unloaded pages are read from their current snapshot later.
-            if(!(event.type==='message.patch'&&data.seq!==undefined&&this.messages[0]&&data.seq<this.messages[0].seq))
-              this.messages=applyTranscriptEvent(this.messages,event)
-            if(this.snapshot&&event.type==='session.changed'){
-              const detail=event.data as {eventType?:string;payload?:Record<string,unknown>;running?:boolean;queued?:boolean}
-              this.snapshot.running=detail.running;this.snapshot.queued=detail.queued
-              if(detail.eventType==='session.info'&&detail.payload)this.snapshot.session={...this.snapshot.session,...detail.payload}
-            }
-            const previous=this.cursor;this.cursor=event.cursor
-            try{await this.publish()}catch(error){this.cursor=previous;throw error}
+  async run() {
+    let needsSnapshot = !this.snapshot
+    while (!this.abort.signal.aborted) {
+      let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
+      let watchdog: ReturnType<typeof setTimeout> | undefined
+      try {
+        if (needsSnapshot) {
+          await this.hydrate()
+          needsSnapshot = false
+        }
+        const current = this.snapshot
+        if (!current) return
+        const attempt = new AbortController()
+        this.request = attempt
+        watchdog = setTimeout(() => attempt.abort(), 45_000)
+        const response = await fetch(this.path('events'), {
+          headers: { Accept: 'text/event-stream', 'Last-Event-ID': `${current.epoch}:${current.cursor}` },
+          credentials: 'include',
+          cache: 'no-store',
+          signal: attempt.signal,
+        })
+        if (response.status === 409) throw new TranscriptGap()
+        if (!response.ok || !response.body) throw new Error(`聊天消息流 HTTP ${response.status}`)
+        const parser = new SSEParser(),
+          decoder = new TextDecoder()
+        reader = response.body.getReader()
+        while (!this.abort.signal.aborted) {
+          clearTimeout(watchdog)
+          watchdog = setTimeout(() => attempt.abort(), 45_000)
+          const { value, done } = await reader.read()
+          if (done) break
+          for (const frame of parser.feed(decoder.decode(value, { stream: true }))) {
+            if (frame.event === 'reset') throw new TranscriptGap()
+            if (frame.event === 'transcript') await this.receive(JSON.parse(frame.data))
           }
         }
-      }catch(error){
-        if(this.abort.signal.aborted)return
-        if(error instanceof TranscriptGap)needsSnapshot=true
-        this.failed(error instanceof Error?error:new Error('聊天消息需要同步'))
-      }finally{await reader?.cancel().catch(()=>{});reader?.releaseLock()}
-      await new Promise<void>(resolve=>{const finish=()=>{clearTimeout(timer);this.abort.signal.removeEventListener('abort',finish);resolve()};const timer=setTimeout(finish,1000);this.abort.signal.addEventListener('abort',finish,{once:true});if(this.abort.signal.aborted)finish()})
+      } catch (error) {
+        if (this.abort.signal.aborted) return
+        if (error instanceof TranscriptGap) needsSnapshot = true
+        this.failed(error instanceof Error ? error : new Error('聊天消息需要同步'))
+      } finally {
+        clearTimeout(watchdog)
+        this.request = undefined
+        await reader?.cancel().catch(() => {})
+        reader?.releaseLock()
+      }
+      await new Promise<void>((resolve) => {
+        const finish = () => {
+          clearTimeout(timer)
+          this.abort.signal.removeEventListener('abort', finish)
+          resolve()
+        }
+        const timer = setTimeout(finish, 1000)
+        this.abort.signal.addEventListener('abort', finish, { once: true })
+        if (this.abort.signal.aborted) finish()
+      })
     }
   }
 }

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

@@ -2,6 +2,7 @@ import type { JsonValue } from '@shared/types'
 import { SSEParser } from '@shared/sse'
 import { apiRequest, ApiError, setApiCsrfToken } from './client'
 import { createId } from '@/utils/id'
+import { CHAT_TRANSCRIPT_FEATURE } from '@shared/chatTranscript'
 
 export class HTTPRPCError extends Error {
   constructor(message: string, readonly code?: string | number, readonly data?: JsonValue) { super(message) }
@@ -26,7 +27,7 @@ export class HTTPRealtimeChannel {
     return value.features ?? []
   }
   async open(channel: 'chat' | 'groups', anchor?: { epoch: string; cursor: number }): Promise<void> {
-    const created = await apiRequest<{ id: string }>('/api/realtime/channels', { method: 'POST', body: { channel, ...anchor } })
+    const created = await apiRequest<{ id: string }>('/api/realtime/channels', { method: 'POST', body: { channel, ...(channel==='chat'?{chatProtocol:CHAT_TRANSCRIPT_FEATURE}:{}), ...anchor } })
     this.id = created.id
     if (this.stopped) { this.close(); return }
     await new Promise<void>((resolve, reject) => {

+ 3 - 1
src/client/api/realtime.ts

@@ -3,6 +3,7 @@ import type { GroupSocketEnvelope, JsonValue, RealtimeConnectionState, RpcEventF
 import { ApiError } from './client'
 import { number, record, string } from '@/utils/normalize'
 import { HTTPRealtimeChannel, HTTPRPCError } from './httpRealtime'
+import { CHAT_TRANSCRIPT_FEATURE } from '@shared/chatTranscript'
 
 export class RpcError extends Error {
   constructor(message: string, readonly code?: number | string, readonly data?: JsonValue) {
@@ -30,7 +31,8 @@ export class ChatRpcSocket {
     this.close()
     const generation = ++this.generation
     this.publishState('connecting')
-    this.transcriptSupported = (await HTTPRealtimeChannel.requireSupport()).includes('ordinary-chat-transcript-v1')
+    this.transcriptSupported = (await HTTPRealtimeChannel.requireSupport()).includes(CHAT_TRANSCRIPT_FEATURE)
+    if(!this.transcriptSupported)throw new ApiError('普通聊天需要新版服务端,请先升级服务端',426,'ordinary_chat_upgrade_required')
     if (generation !== this.generation) return
     this.http = new HTTPRealtimeChannel(frame => this.handleMessage(frame, generation), error => {
       if (generation === this.generation) this.publishState('failed', error.message)

+ 38 - 106
src/client/stores/chat.ts

@@ -118,6 +118,7 @@ export const useChatStore = defineStore('chat', () => {
   let persistedRecoveryPromise: Promise<void> | undefined
   const runtimePromises = new Map<string, Promise<ChatRouteState>>()
   const transcriptSupported = ref(false)
+  const outboxCache = new ScopedCache<ChatMessage[]>('ordinary-outbox-v2')
   let transcript: ChatTranscriptClient | undefined
   let transcriptGeneration = 0
 
@@ -125,17 +126,20 @@ export const useChatStore = defineStore('chat', () => {
     const generation=++transcriptGeneration
     transcript?.close();transcript=undefined
     const profile=activeProfileName.value,id=activeSessionId.value,owner=accountId()
-    if(!transcriptSupported.value||!profile||!id||id.startsWith('draft-')||sessionView==='history')return
+    if(!transcriptSupported.value||!profile||!id||id.startsWith('draft-')||sessionView==='history'||!sessions.value.some(s=>s.id===id&&s.owned===true))return
     const key=routeKey(profile,id),state=ensureRoute(profile,id),scope=cacheScope(profile)
     const current=()=>generation===transcriptGeneration&&owner===accountId()?routes[key]:undefined
     void (async()=>{
-      const cached=await historyCache.get(scope,id)
+      const [cached,outbox]=await Promise.all([historyCache.get(scope,id),outboxCache.get(scope,id)])
       if(!current())return
       if(!state.messages.length&&cached?.messages.length)state.messages=cached.messages
+      if(outbox?.length)state.messages=mergeChatMessages(state.messages,outbox)
       const client=new ChatTranscriptClient(profile,id,async value=>{
         const initial=current();if(!initial)return
         const canonical=value.messages.map(message=>{
           const mapped=normalizeChatMessage(message,id,profile)
+          const local=initial.messages.find(m=>m.role==='user'&&mapped.clientMessageId&&m.clientMessageId===mapped.clientMessageId)
+          if(!mapped.attachments?.length&&local?.attachments?.length)mapped.attachments=local.attachments
           if(message.status==='pending')mapped.stage='pending'
           return mapped
         })
@@ -143,15 +147,24 @@ export const useChatStore = defineStore('chat', () => {
         const pending=initial.messages.filter(m=>m.role==='user'&&m.stage!=='settled'&&!ids.has(m.id)&&!ids.has(m.clientMessageId))
         const messages=[...canonical,...pending]
         const {messages:_,...checkpoint}=value
-        await historyCache.set(scope,id,{messages,total:value.total,savedAt:Date.now(),transcript:checkpoint})
+        await historyCache.set(scope,id,{messages,total:value.total,savedAt:Date.now(),transcript:checkpoint},true)
         const latest=current();if(!latest)return
-        latest.messages=messages;latest.messageTotal=value.total;latest.loadedMessageCount=canonical.length
-        latest.hasMoreBefore=value.hasOlder;latest.historySynced=true;latest.isLoadingHistory=false;latest.error=undefined
+        const newerPending=latest.messages.filter(m=>m.role==='user'&&m.stage!=='settled'&&!ids.has(m.id)&&!ids.has(m.clientMessageId))
+        latest.messages=[...canonical,...newerPending];latest.messageTotal=value.total;latest.loadedMessageCount=canonical.length
+        await outboxCache.set(scope,id,newerPending,true)
+        if(current()!==latest)return
+        const approval=value.pendingApproval,clarification=value.pendingClarification
+        latest.pendingApproval=approval?{id:string(approval.request_id??approval.id),sessionId:id,message:string(approval.message??approval.prompt),toolName:string(approval.tool_name),choices:values(approval.choices).map(String),payload:approval as Record<string,JsonValue>}:undefined
+        latest.pendingClarification=clarification?{id:string(clarification.request_id??clarification.id),sessionId:id,question:string(clarification.question??clarification.prompt),payload:clarification as Record<string,JsonValue>}:undefined
+        latest.liveStatus=value.liveStatus??undefined
+        latest.hasMoreBefore=value.hasOlder;latest.historySynced=true;latest.isLoadingHistory=false;latest.error=value.error??undefined
+        const wasStreaming=latest.isStreaming
         latest.isStreaming=value.running??canonical.some(m=>m.isStreaming);latest.isQueued=value.queued??false
+        if(wasStreaming&&!latest.isStreaming)void refreshContextUsage(latest).catch(()=>{})
         if(!latest.isStreaming)latest.liveStatus=undefined
         updateInflightMarker(latest)
       },error=>{const latest=current();if(latest){latest.error=error.message;latest.historySynced=false}},Math.max(150,state.messages.length))
-      if(cached?.transcript){
+      if(cached?.transcript?.protocol==='ordinary-chat-transcript-v2'){
         const canonical=cached.messages.map(m=>m.raw as unknown as TranscriptMessage).filter(m=>m?.id&&Number.isSafeInteger(m.revision)&&Number.isSafeInteger(m.seq))
         client.restore({...cached.transcript,messages:canonical})
       }
@@ -377,17 +390,12 @@ export const useChatStore = defineStore('chat', () => {
     }
     if (!key) return
     let state = routes[key]
-    if(transcriptSupported.value&&sessionView==='chat'&&(
+    if(sessionView==='chat'&&(
       /^(message\.|tool\.|reasoning\.|thinking\.)/.test(event.type)
       || ['run.started','run.completed','run.failed','run.peer_user_message','peer.user.message'].includes(event.type))){
-      // Message bodies come exclusively from the persisted transcript stream.
-      // Legacy control events may only wake/settle the execution indicator.
-      if(['message.start','run.started'].includes(event.type))state.isStreaming=true
-      if(['message.complete','run.completed','run.failed'].includes(event.type)){
-        state.isQueued=number(payload.queue_remaining)>0;state.isStreaming=state.isQueued||number(payload.background_pending)>0
-        if(!state.isStreaming){state.liveStatus=undefined;state.pendingApproval=undefined;state.pendingClarification=undefined}
-      }
-      updateInflightMarker(state);return
+      // All ordinary message bodies and execution state come from the durable
+      // transcript. Raw upstream frames cannot create a second representation.
+      return
     }
     if (event.type === 'session.info') {
       const storedId = string(payload.stored_session_id ?? payload.storedSessionId ?? payload.session_key)
@@ -407,6 +415,7 @@ export const useChatStore = defineStore('chat', () => {
       if (!state.fastModeDirty) state.fastMode = liveFastMode
       persistFastMode(state)
     }
+    if(sessionView==='chat'&&!['session.usage','usage.update','context.update'].includes(event.type))return
     const attributedPayload = { ...payload, session_id: state.route.sessionId } as JsonValue
     const updated = applyChatEvent(state, {
       ...event, session_id: state.route.sessionId, profile: state.route.profile, payload: attributedPayload,
@@ -548,7 +557,14 @@ export const useChatStore = defineStore('chat', () => {
   }
 
   async function loadHistory(state: ChatRouteState, forceRefresh = false): Promise<void> {
-    if(transcriptSupported.value&&sessionView==='chat'){if(state===activeRouteState.value)startTranscript();return}
+    if(sessionView==='chat'&&sessions.value.some(s=>s.id===state.route.sessionId&&s.owned===true)){
+      const cached=await historyCache.get(cacheScope(state.route.profile),state.route.sessionId)
+      if(!state.messages.length&&cached?.messages.length)state.messages=cached.messages
+      await connect()
+      if(!transcriptSupported.value)throw new Error('普通聊天需要升级服务端后继续')
+      if(state===activeRouteState.value)startTranscript()
+      return
+    }
     const key = routeKey(state.route.profile, state.route.sessionId)
     const generation = ++historyLoadGeneration
     state.generation = generation
@@ -631,7 +647,7 @@ export const useChatStore = defineStore('chat', () => {
   }
 
   async function loadOlder(): Promise<void> {
-    if(transcriptSupported.value&&transcript){await transcript.loadOlder();return}
+    if(sessionView==='chat'&&activeSession.value?.owned===true){await transcript?.loadOlder();return}
     const state = activeRouteState.value
     if (!state || state.isLoadingHistory || !state.hasMoreBefore) return
     const key = routeKey(state.route.profile, state.route.sessionId)
@@ -717,82 +733,8 @@ export const useChatStore = defineStore('chat', () => {
     migrated.serverFastMode = liveFastMode
     if (!migrated.fastModeDirty && liveFastMode !== undefined) migrated.fastMode = liveFastMode
     persistFastMode(migrated)
-    const resumedMessages = values(result.messages)
-      .map(value => normalizeChatMessage(value, storedId, migrated.route.profile))
-    if (!transcriptSupported.value&&resumedMessages.length) migrated.messages = mergeChatMessages(migrated.messages, resumedMessages, 'snapshot')
-    const inflight = record(result.inflight ?? result.active_run)
-    const assistant = string(inflight.assistant)
-    const inflightError = string(inflight.error)
-    migrated.isStreaming = bool(result.running) || bool(info.running) || bool(inflight.streaming)
-    const queued = record(result.queued)
-    const queuedUser = string(queued.user)
-    migrated.isQueued = Boolean(queuedUser)
-    // A turn can be thinking or calling tools before producing any text.
-    // Create its live row now so subsequent tool events have a visible target.
-    const existing = [...migrated.messages].reverse().find(message => message.role === 'assistant' && message.isStreaming)
-    if (!transcriptSupported.value&&(migrated.isStreaming || inflightError || (existing && assistant))) {
-      migrated.messages = mergeChatMessages(migrated.messages, [{
-        ...existing,
-        id: existing?.id ?? `resume:${runtimeId}`,
-        sessionId: storedId,
-        profile: migrated.route.profile,
-        role: 'assistant',
-        content: assistant,
-        timestamp: number(result.turn_started_at, Date.now() / 1000),
-        stage: inflightError ? 'failed' : migrated.isStreaming ? 'streaming' : 'settled',
-        isStreaming: migrated.isStreaming,
-        error: inflightError || undefined,
-      }])
-    }
-    if (!migrated.isStreaming) {
-      migrated.liveStatus = undefined
-      if(!transcriptSupported.value)migrated.messages = settleChatMessages(migrated.messages)
-    }
-    migrated.pendingApproval = undefined
-    migrated.pendingClarification = undefined
-    values(transcriptSupported.value?[]:inflight.corrections).forEach((correction, index) => {
-      const content = string(correction)
-      if (!content) return
-      migrated.messages = mergeChatMessages(migrated.messages, [{
-        id: `resume-correction:${runtimeId}:${index}`,
-        sessionId: storedId,
-        profile: migrated.route.profile,
-        role: 'user',
-        content,
-        timestamp: number(result.turn_started_at, Date.now() / 1000) + (index + 1) / 1000,
-        stage: 'accepted',
-      }])
-    })
-    if (!transcriptSupported.value&&queuedUser) {
-      migrated.messages = mergeChatMessages(migrated.messages, [{
-        id: `resume-queued:${runtimeId}`,
-        sessionId: storedId,
-        profile: migrated.route.profile,
-        role: 'user',
-        content: queuedUser,
-        timestamp: Date.now() / 1000,
-        stage: 'accepted',
-      }])
-    }
-    const approval = record(result.pending_approval)
-    const approvalID = string(approval.request_id ?? approval.requestId ?? approval.id)
-    if (approvalID) migrated.pendingApproval = {
-      id: approvalID,
-      sessionId: storedId,
-      message: string(approval.message ?? approval.prompt) || undefined,
-      toolName: string(approval.tool_name ?? approval.tool) || undefined,
-      choices: Array.isArray(approval.choices) ? approval.choices.map(String) : undefined,
-      payload: approval as Record<string, JsonValue>,
-    }
-    const clarification = record(result.pending_clarify)
-    const clarificationID = string(clarification.request_id ?? clarification.requestId ?? clarification.id)
-    if (clarificationID) migrated.pendingClarification = {
-      id: clarificationID,
-      sessionId: storedId,
-      question: string(clarification.question ?? clarification.message ?? clarification.prompt, '需要补充信息'),
-      choices: Array.isArray(clarification.choices) ? clarification.choices.map(String) : undefined,
-      payload: clarification as Record<string, JsonValue>,
-    }
+    // session.resume restores the execution route only. The server ingests its
+    // snapshot into the canonical turn before clients receive transcript events.
     if (storedId !== initialState.route.sessionId) clearInflightMarker(initialState.route.profile, initialState.route.sessionId)
     updateInflightMarker(migrated)
     return migrated
@@ -881,6 +823,7 @@ export const useChatStore = defineStore('chat', () => {
       }
       updateDelivery(state, clientMessageId, { stage: files.length ? 'attached' : 'pending', sessionId: state.route.sessionId })
       updateDelivery(state, clientMessageId, { stage: 'pending' })
+      await outboxCache.set(cacheScope(state.route.profile),state.route.sessionId,state.messages.filter(m=>m.role==='user'&&m.stage!=='settled'),true)
       submitted = true
       const effectiveMode = mode === 'steer' && files.length ? 'queue' : mode
       const method = effectiveMode === 'steer' ? 'session.steer' : 'prompt.submit'
@@ -894,19 +837,8 @@ export const useChatStore = defineStore('chat', () => {
         throw new RpcError(string(result.error ?? result.message, 'Hermes 拒绝了 Steer 请求'), status)
       }
       updateDelivery(state, clientMessageId, { stage: 'accepted' })
-      const running = ['running', 'started', 'streaming'].includes(status)
-      if (effectiveMode === 'steer') {
-        // A steer receipt acknowledges injection into the current run. It is
-        // not evidence of a new run, including Hermes's `status: queued`.
-        state.isQueued = false
-        if (running) state.isStreaming = true
-      } else {
-        state.isQueued = status === 'queued'
-        if (running || status === 'accepted') {
-          state.isQueued = false
-          state.isStreaming = true
-        }
-      }
+      // A delayed HTTP receipt confirms the input, not a newer execution state.
+      // The transcript may already contain the completed reply at this point.
       updateInflightMarker(state)
       // The optimistic row remains local, but the sidebar is refreshed only
       // after a 9119 receipt and keeps the order returned by the server.

+ 11 - 4
src/client/utils/cache.ts

@@ -25,13 +25,20 @@ export class ScopedCache<T> {
     try { return await db.get('records', storageKey) as T | undefined } catch { return memory.get(storageKey) as T | undefined }
   }
 
-  async set(scope: string, key: string, value: T): Promise<void> {
+  async set(scope: string, key: string, value: T, durable = false): Promise<void> {
     const serialized = JSON.stringify(value)
-    if (new Blob([serialized]).size > this.maximumBytes) return
+    if (new Blob([serialized]).size > this.maximumBytes) {
+      if(durable)throw new Error('聊天缓存超出容量,未推进同步位置')
+      return
+    }
     const storageKey = this.key(scope, key)
-    memory.set(storageKey, value)
     const db = await database()
-    if (db) await db.put('records', value, storageKey).catch(() => undefined)
+    if(durable&&typeof indexedDB!=='undefined'&&!db)throw new Error('聊天缓存不可写,未推进同步位置')
+    if (db) {
+      if(durable)await db.put('records',value,storageKey)
+      else await db.put('records', value, storageKey).catch(() => undefined)
+    }
+    memory.set(storageKey, value)
   }
 
   async delete(scope: string, key: string): Promise<void> {

+ 5 - 7
src/server/app.ts

@@ -265,14 +265,10 @@ export function createApplication(options: ApplicationOptions = {}): Application
   realtime.transcriptsAvailable = chatCache.store.transcripts.enabled
   realtime.broker.onNativeEvent = (owner, profile, storedId, frame) => {
     chatCache?.observe(owner, profile, storedId, frame)
-    if(/^(message\.(start|delta|interim|complete)|reasoning\.|tool\.|run\.(completed|failed))/.test(frame.type)){
-      const id=chatCache?.store.projectedMessageID(owner,profile,storedId)
-      if(id)frame.payload={...frame.payload,message_id:id}
-    }
     if (owner.startsWith('device:')) return
     if (!['message.complete', 'attachment.staged'].includes(frame.type)) return
     const data = frame.payload ?? {}, text = frame.type === 'attachment.staged' ? JSON.stringify(data) : nativeMessageFileText(data)
-    const messageId = String(data.row_id ?? data.message_id ?? data.id ?? createHash('sha256').update(text).digest('hex'))
+    const messageId = String(chatCache.store.projectedMessageID(owner,profile,storedId) ?? data.row_id ?? data.message_id ?? data.id ?? createHash('sha256').update(text).digest('hex'))
     void workspaceAssets.archiveText(owner, text, 'local', profile, storedId, messageId, frame.type === 'attachment.staged' ? 'user' : 'agent').catch(() => {})
   }
   workspace.nativeMessageForFile = (owner, file) => {
@@ -292,8 +288,10 @@ export function createApplication(options: ApplicationOptions = {}): Application
   realtime.broker.onNativeCommand = (owner, profile, storedId, method, params) => {
     chatCache?.command(owner, profile, storedId, method, params)
   }
-  realtime.broker.onNativeRoute = (owner, profile, storedId, runtimeId, running) => {
-    chatCache?.route(owner, profile, storedId, runtimeId, running)
+  realtime.broker.nativeSourceCursor = (owner,profile,id,runtime,epoch)=>chatCache.store.transcripts.sourceCursor(owner,profile,id,runtime,epoch)
+  realtime.broker.onNativeRoute = (owner, profile, storedId, runtimeId, running, snapshot) => {
+    chatCache?.route(owner, profile, storedId, runtimeId, snapshot?undefined:running)
+    if(snapshot)chatCache.store.recordEvent(owner,profile,storedId,{type:'route.resumed',payload:snapshot})
   }
   chatPushJobs.setTransportFactory(realtime.recoveryTransport, job => realtime.ownsPushJob(job.id))
   try {

+ 66 - 75
src/server/chatCache.ts

@@ -1,4 +1,3 @@
-import { projectChatEvent } from './chatEventProjection.js'
 import { ChatTranscriptStore } from './chatTranscriptStore.js'
 import { isFinalChatResult } from './chatUnread.js'
 import { HttpError } from './errors.js'
@@ -138,6 +137,7 @@ function referencedPaths(value: unknown, output = new Set<string>()): Set<string
 }
 
 export class ChatCacheStore {
+  private synchronizingHistory = false
   readonly db: DatabaseSync
   readonly assetsRoot: string
   readonly transcripts: ChatTranscriptStore
@@ -382,12 +382,10 @@ export class ChatCacheStore {
       CASE WHEN COALESCE(l.read_initialized,0)=1 THEN l.read_count ELSE COALESCE(s.message_total,0) END read_message_count FROM chat_sessions s LEFT JOIN chat_local_state l USING(owner,profile,session_id)
       WHERE s.owner=? AND s.owned_at IS NOT NULL AND (?='' OR s.profile=?)`).all(owner,profile,profile) as any[])
       .map(({final_read_count,...r})=>{
-        const messages = this.db.prepare(`SELECT data FROM chat_messages
-          WHERE owner=? AND profile=? AND session_id=? AND position>=?
-          AND json_extract(data,'$.role')='assistant'`)
-          .all(owner,r.profile,r.session_id,final_read_count) as {data:string}[]
+        this.transcripts.seed(owner,r.profile,r.session_id)
+        r.message_count=this.transcripts.total(owner,r.profile,r.session_id)
         return {...r,updated_at:Date.now(),unread_count:Math.max(0,r.message_count-r.read_message_count),
-          final_unread_count:messages.filter(row=>isFinalChatResult(JSON.parse(row.data))).length}
+          final_unread_count:this.transcripts.unread(owner,r.profile,r.session_id)}
       })
     return {profile,supported:true,total_unread:sessions.reduce((n,r)=>n+r.unread_count,0),sessions}
   }
@@ -395,8 +393,8 @@ export class ChatCacheStore {
   markRead(owner: string, profile: string, id: string, count?: number) {
     id=this.canonicalSessionID(owner,profile,id)
     this.requireOwned(owner,profile,id)
-    const total = Number((this.db.prepare('SELECT message_total FROM chat_sessions WHERE owner=? AND profile=? AND session_id=?')
-      .get(owner,profile,id) as any).message_total ?? 0)
+    this.transcripts.seed(owner,profile,id)
+    const total = this.transcripts.total(owner,profile,id)
     const read = Math.max(0,Math.min(total, count ?? total))
     // Reading a streaming placeholder must not consume its future final answer.
     // Keep the original cursor untouched for existing consumers.
@@ -409,6 +407,8 @@ export class ChatCacheStore {
     this.db.prepare(`INSERT INTO chat_local_state(owner,profile,session_id,read_count,final_read_count,read_initialized) VALUES(?,?,?,?,?,1)
       ON CONFLICT(owner,profile,session_id) DO UPDATE SET read_count=MAX(read_count,excluded.read_count),
       final_read_count=MAX(COALESCE(final_read_count,read_count),excluded.final_read_count),read_initialized=1`).run(owner,profile,id,read,finalRead)
+    this.transcripts.seed(owner,profile,id)
+    this.transcripts.markRead(owner,profile,id,count??Infinity)
     return this.unread(owner,profile).sessions.find(r=>r.session_id===id)!
   }
 
@@ -425,7 +425,7 @@ export class ChatCacheStore {
         ON CONFLICT(owner,profile,session_id) DO UPDATE SET pinned=excluded.pinned`).run(owner,profile,id,patch.pinned?1:0)
       const row = this.localDetail(owner,profile,id)!
       const data = {...JSON.parse(row.response.body.toString()),...patch}
-      this.db.prepare('UPDATE chat_sessions SET data=? WHERE owner=? AND profile=? AND session_id=?').run(JSON.stringify(data),owner,profile,id)
+      this.db.prepare("UPDATE chat_sessions SET data=?,sync_state=CASE WHEN sync_state='gap' THEN sync_state WHEN complete=1 THEN 'current' ELSE sync_state END WHERE owner=? AND profile=? AND session_id=?").run(JSON.stringify(data),owner,profile,id)
       for(const key of ['title','archived'] as const)if(patch[key]!==undefined){
         const value=key==='archived'?(patch[key]?1:0):String(patch[key])
         this.db.prepare(`INSERT INTO chat_local_state(owner,profile,session_id,${key}) VALUES(?,?,?,?) ON CONFLICT(owner,profile,session_id) DO UPDATE SET ${key}=excluded.${key}`).run(owner,profile,id,value)
@@ -437,61 +437,23 @@ export class ChatCacheStore {
     } catch(e) {this.db.exec('ROLLBACK TO local_chat_patch; RELEASE local_chat_patch');throw e}
   }
 
-  private writeLiveMessage(owner:string,profile:string,id:string,message:Record<string,any>): void {
-    const prior = this.db.prepare('SELECT position FROM chat_messages WHERE owner=? AND profile=? AND session_id=? AND message_id=?').get(owner,profile,id,message.id) as {position:number}|undefined
-    const row = this.db.prepare('SELECT message_total,complete,data FROM chat_sessions WHERE owner=? AND profile=? AND session_id=?').get(owner,profile,id) as any
-    const extent = Number((this.db.prepare('SELECT COALESCE(MAX(position)+1,0) n FROM chat_messages WHERE owner=? AND profile=? AND session_id=?').get(owner,profile,id) as any).n)
-    const position = prior?.position ?? Math.max(extent,Number(row.message_total??0))
-    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 data=excluded.data,content_hash=excluded.content_hash`).run(owner,profile,id,message.id,position,data,createHash('sha256').update(data).digest('hex'))
-    const total=Math.max(Number(row.message_total??0),position+1), summary=JSON.parse(row.data)
-    const now=Date.now()
-    summary.message_count=total; summary.last_active=now/1000
-    if (typeof message.content==='string' && message.content.trim()) summary.preview=message.content.slice(0,160)
-    this.db.prepare('UPDATE chat_sessions SET data=?,message_total=?,last_synced_at=? WHERE owner=? AND profile=? AND session_id=?').run(JSON.stringify(summary),total,now,owner,profile,id)
-    for(const path of referencedPaths(message)) this.db.prepare(`INSERT OR IGNORE INTO chat_attachments(owner,profile,session_id,source_path,state,updated_at) VALUES(?,?,?,?,'pending',?)`).run(owner,profile,id,path,now)
-    this.transcripts.upsert(owner,profile,id,message,position)
-  }
-
   projectedMessageID(owner:string,profile:string,id:string):string|undefined {
-    return (this.db.prepare(`SELECT message_id FROM chat_messages WHERE owner=? AND profile=? AND session_id=?
-      AND json_extract(data,'$.role')='assistant' ORDER BY position DESC LIMIT 1`).get(owner,profile,id) as {message_id:string}|undefined)?.message_id
+    return this.transcripts.head(owner,profile,id).current
   }
 
-  private projectEvent(owner:string,profile:string,id:string,type:string,p:Record<string,any>,eventID:string): void {
-    if(!this.ownsSession(owner,profile,id))return
-    if(type==='command.confirmed'||type==='command.rejected'){
-      const userID=`user:${p.delivery_id}`
-      const row=this.db.prepare('SELECT data FROM chat_messages WHERE owner=? AND profile=? AND session_id=? AND message_id=?').get(owner,profile,id,userID) as any
-      if(row)this.writeLiveMessage(owner,profile,id,{...JSON.parse(row.data),status:type==='command.confirmed'?'complete':'failed',...(p.error?{error:p.error}:{})})
-      this.db.prepare("UPDATE chat_sessions SET sync_state=CASE WHEN sync_state='gap' THEN 'gap' WHEN complete=1 THEN 'current' ELSE 'stale' END WHERE owner=? AND profile=? AND session_id=?").run(owner,profile,id)
-      return
-    }
-    if (type==='message.user' || type==='peer_user_message') {
-      this.writeLiveMessage(owner,profile,id,{...p,id:String(p.message_id??p.id??eventID),role:'user',content:p.text??p.content??'',timestamp:Date.now()/1000});return
-    }
-    const state=this.db.prepare('SELECT assistant_id FROM chat_local_state WHERE owner=? AND profile=? AND session_id=?').get(owner,profile,id) as any
-    const last=state?.assistant_id?this.db.prepare('SELECT data FROM chat_messages WHERE owner=? AND profile=? AND session_id=? AND message_id=?').get(owner,profile,id,state.assistant_id) as any:undefined
-    const previous=last?JSON.parse(last.data):undefined
-    // A new run must never append to the assistant row from the preceding turn.
-    if(['message.complete','run.completed'].includes(type) && !previous && !p.text && !p.content && !p.message){
-      this.db.prepare("UPDATE chat_sessions SET sync_state='gap' WHERE owner=? AND profile=? AND session_id=?").run(owner,profile,id);return
-    }
-    const startsNew=type==='message.start' && previous?.status!=='streaming'
-    const message=projectChatEvent(type,p,startsNew?undefined:previous,`event:${eventID}`,Date.now())
-    if(message){
-      if(previous && previous.id !== message.id && !startsNew){
-        const exists=this.db.prepare('SELECT 1 FROM chat_messages WHERE owner=? AND profile=? AND session_id=? AND message_id=?').get(owner,profile,id,message.id)
-        if(!exists){this.transcripts.promote(owner,profile,id,String(previous.id),String(message.id));this.db.prepare('UPDATE chat_messages SET message_id=? WHERE owner=? AND profile=? AND session_id=? AND message_id=?').run(message.id,owner,profile,id,previous.id)}
-        else {this.db.prepare("UPDATE chat_sessions SET sync_state='gap' WHERE owner=? AND profile=? AND session_id=?").run(owner,profile,id);return}
-      }
-      this.writeLiveMessage(owner,profile,id,message)
-      this.db.prepare(`INSERT INTO chat_local_state(owner,profile,session_id,assistant_id) VALUES(?,?,?,?)
-        ON CONFLICT(owner,profile,session_id) DO UPDATE SET assistant_id=excluded.assistant_id`).run(owner,profile,id,type==='message.interim'?null:message.id)
-      this.db.prepare(`UPDATE chat_sessions SET sync_state=CASE WHEN sync_state='gap' THEN 'gap' WHEN complete=1 THEN 'current' ELSE 'stale' END
-        WHERE owner=? AND profile=? AND session_id=?`).run(owner,profile,id)
-    }
+  private updateTranscriptSummary(owner:string,profile:string,id:string):void {
+    const total=this.transcripts.total(owner,profile,id)
+    const lastRow=this.db.prepare('SELECT data FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=? AND deleted=0 ORDER BY seq DESC LIMIT 1').get(owner,profile,id)
+    const last=lastRow?JSON.parse(String(lastRow.data)):undefined
+    const row=this.db.prepare('SELECT data FROM chat_sessions WHERE owner=? AND profile=? AND session_id=?').get(owner,profile,id)
+    if(!row)return
+    const data=JSON.parse(String(row.data))
+    data.message_count=total
+    if(last){data.last_active=last.timestamp;if(typeof last.content==='string'&&last.content.trim())data.preview=last.content.slice(0,160)}
+    this.db.prepare("UPDATE chat_sessions SET data=?,sync_state=CASE WHEN sync_state='gap' THEN sync_state WHEN complete=1 THEN 'current' ELSE sync_state END WHERE owner=? AND profile=? AND session_id=?").run(JSON.stringify(data),owner,profile,id)
+    const currentID=this.transcripts.head(owner,profile,id).current
+    const current=currentID?this.transcripts.message(owner,profile,id,currentID):undefined
+    for(const message of [last,current].filter(Boolean))for(const path of referencedPaths(message!))this.db.prepare(`INSERT OR IGNORE INTO chat_attachments(owner,profile,session_id,source_path,state,updated_at) VALUES(?,?,?,?,'pending',?)`).run(owner,profile,id,path,Date.now())
   }
 
   needsListMetadata(owner: string, profile: string): boolean {
@@ -536,6 +498,7 @@ export class ChatCacheStore {
       // Hydrate all registered sessions from the unsliced upstream list, while
       // persisting only the owner-filtered page as the public response snapshot.
       this.ingestPayload(owner, profile, sessionID, parseResponse(metadataResponse) ?? payload, now, kind, requestStartedAt)
+      if(kind==='messages'&&sessionID&&!this.synchronizingHistory&&!this.transcripts.head(owner,profile,sessionID).running){this.transcripts.reconcile(owner,profile,sessionID);this.updateTranscriptSummary(owner,profile,sessionID)}
       this.db.prepare(`INSERT INTO chat_snapshots(owner,cache_key,kind,profile,session_id,status,headers,body,sync_state,saved_at)
         VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(owner,cache_key) DO UPDATE SET status=excluded.status,headers=excluded.headers,
         body=excluded.body,sync_state='current',saved_at=excluded.saved_at`)
@@ -551,9 +514,11 @@ export class ChatCacheStore {
 
   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
+    if (this.revision(owner, profile, sessionID) !== revision || this.transcripts.head(owner,profile,sessionID).running) return false
     this.db.exec('SAVEPOINT history_sync')
+    this.synchronizingHistory=true
     try {
+      this.transcripts.archive([owner,profile,sessionID])
       if (force) this.db.prepare('DELETE FROM chat_messages WHERE owner=? AND profile=? AND session_id=?').run(owner, profile, sessionID)
       // Recovery promotes locally generated stream identities to upstream row IDs.
       // Remove only replaced rows inside the verified window; keep older coverage.
@@ -584,7 +549,7 @@ export class ChatCacheStore {
       for (const page of pages) this.putSnapshot(owner, page.key, page.kind, profile, sessionID, page.response, false, page.startedAt)
       // Resolve legacy identities only after all pages are stored. Resolving
       // pages individually could consume a send before seeing an ambiguous row.
-      const coverage = this.db.prepare('SELECT complete FROM chat_sessions WHERE owner=? AND profile=? AND session_id=?')
+      const coverage = this.db.prepare('SELECT complete,message_total FROM chat_sessions WHERE owner=? AND profile=? AND session_id=?')
         .get(owner, profile, sessionID) as {complete: number} | undefined
       if (coverage?.complete) this.userMessageIdentities(owner, profile, sessionID, pages.flatMap(page => {
         const messages = page.kind === 'messages' ? parseResponse(page.response)?.messages : undefined
@@ -593,10 +558,13 @@ export class ChatCacheStore {
       const latest=this.db.prepare("SELECT message_id FROM chat_messages WHERE owner=? AND profile=? AND session_id=? AND json_extract(data,'$.role')='assistant' ORDER BY position DESC LIMIT 1").get(owner,profile,sessionID) as {message_id:string}|undefined
       if(latest)this.db.prepare('UPDATE chat_local_state SET assistant_id=? WHERE owner=? AND profile=? AND session_id=?').run(latest.message_id,owner,profile,sessionID)
       this.transcripts.reconcile(owner,profile,sessionID)
+      this.updateTranscriptSummary(owner,profile,sessionID)
       this.transcripts.sessionChanged(owner,profile,sessionID,{eventType:'history.synced'})
+      this.synchronizingHistory=false
       this.db.exec('RELEASE history_sync')
       return true
     } catch (error) {
+      this.synchronizingHistory=false
       this.db.exec('ROLLBACK TO history_sync; RELEASE history_sync')
       throw error
     }
@@ -679,6 +647,21 @@ export class ChatCacheStore {
   }
 
   messagePage(owner: string, profile: string, sessionID: string, offset: number, limit: number): MessagePage | undefined {
+    if(!this.ownsSession(owner,profile,sessionID))return this.sourceMessagePage(owner,profile,sessionID,offset,limit)
+    this.transcripts.seed(owner,profile,sessionID)
+    const known=this.db.prepare('SELECT complete,message_total FROM chat_sessions WHERE owner=? AND profile=? AND session_id=?').get(owner,profile,sessionID)
+    if(!known?.complete&&!this.transcripts.total(owner,profile,sessionID))return undefined
+    const all=this.transcripts.all(owner,profile,sessionID),end=Math.max(0,all.length-offset),start=Math.max(0,end-limit)
+    const total=known?.complete?all.length:Math.max(all.length,Number(known?.message_total??0))
+    if(!known?.complete&&offset>=all.length&&offset<total)return undefined
+    const detail=this.localDetail(owner,profile,sessionID)!
+    return {state:detail.state,response:{status:200,headers:new Headers({'content-type':'application/json'}),body:Buffer.from(JSON.stringify({
+      session_id:sessionID,session:JSON.parse(detail.response.body.toString()),messages:all.slice(start,end),
+      pagination:{total,returned:end-start,offset,limit,has_more:start>0||total>all.length}
+    }))}}
+  }
+
+  sourceMessagePage(owner: string, profile: string, sessionID: string, offset: number, limit: number): MessagePage | undefined {
     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) return undefined
@@ -755,8 +738,11 @@ export class ChatCacheStore {
       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)
-    this.projectEvent(owner,profile,sessionID,type,object(frame.payload)??{},eventID)
-    if (!['message.delta','reasoning.delta','thinking.delta'].includes(type)&&!type.startsWith('tool.')) this.transcripts.sessionChanged(owner,profile,sessionID,{eventType:type,payload:object(frame.payload)??{}})
+    if(this.ownsSession(owner,profile,sessionID)){
+      this.transcripts.recordSource([owner,profile,sessionID],frame)
+      this.transcripts.reduce(owner,profile,sessionID,type,object(frame.payload)??{})
+      this.updateTranscriptSummary(owner,profile,sessionID)
+    }
     this.db.exec('RELEASE live_event')
     return true
     }catch(e){this.db.exec('ROLLBACK TO live_event; RELEASE live_event');throw e}
@@ -823,13 +809,10 @@ export class ChatCacheStore {
         establishesOwnership ? now : null, establishesOwnership ? `command:${method}` : null)
     if(method==='session.create') this.db.prepare(`INSERT INTO chat_local_state(owner,profile,session_id,read_initialized) VALUES(?,?,?,1) ON CONFLICT(owner,profile,session_id) DO UPDATE SET read_initialized=1`).run(owner,profile,sessionID)
     if(method==='session.create') this.db.prepare(`UPDATE chat_sessions SET complete=1,metadata_complete=1,message_total=0,sync_state='current' WHERE owner=? AND profile=? AND session_id=? AND message_total IS NULL`).run(owner,profile,sessionID)
+    this.transcripts.seed(owner,profile,sessionID)
     if((method==='prompt.submit'||method==='session.steer') && params._delivery_id){
-      const userID=`user:${params._delivery_id}`
-      this.writeLiveMessage(owner,profile,sessionID,{id:userID,role:'user',content:params.text??params.input??'',timestamp:now/1000,status:'pending',client_message_id:String(params._delivery_id).replace(/^(web|ios):prompt:/,'')})
-      const streaming=this.db.prepare(`SELECT 1 FROM chat_local_state l JOIN chat_messages m ON m.owner=l.owner AND m.profile=l.profile AND m.session_id=l.session_id AND m.message_id=l.assistant_id
-        WHERE l.owner=? AND l.profile=? AND l.session_id=? AND json_extract(m.data,'$.status')='streaming'`).get(owner,profile,sessionID)
-      if(!streaming)this.db.prepare(`INSERT INTO chat_local_state(owner,profile,session_id,assistant_id) VALUES(?,?,?,NULL) ON CONFLICT(owner,profile,session_id) DO UPDATE SET assistant_id=NULL`).run(owner,profile,sessionID)
-      this.transcripts.sessionChanged(owner,profile,sessionID,{eventType:'command.submitted',payload:{delivery_id:params._delivery_id}})
+      this.transcripts.reduce(owner,profile,sessionID,'command.submitted',{...params,method,delivery_id:params._delivery_id})
+      this.updateTranscriptSummary(owner,profile,sessionID)
     }
     const payload = JSON.stringify(params)
     const seq = Number.parseInt(createHash('sha256').update(`${method}:${payload}`).digest('hex').slice(0, 12), 16)
@@ -842,6 +825,8 @@ export class ChatCacheStore {
   }
 
   fileSourceMessage(owner: string, profile: string, sessionID: string, messageID: string): Record<string, unknown> | undefined {
+    const canonical=this.transcripts.resolveMessage(owner,profile,sessionID,messageID)
+    if(canonical)return canonical
     const row = this.db.prepare('SELECT data FROM chat_messages WHERE owner=? AND profile=? AND session_id=? AND message_id=?')
       .get(owner, profile, sessionID, messageID)
     return row ? JSON.parse(String(row.data)) : undefined
@@ -1246,7 +1231,6 @@ export class ChatCacheStore {
         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'))
-      this.transcripts.upsert(owner,profile,sessionID,{...message,id:messageID(message,String(start+index))},start+index)
       for (const sourcePath of referencedPaths(message)) {
         this.db.prepare(`INSERT INTO chat_attachments(owner,profile,session_id,source_path,state,updated_at)
           VALUES(?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id,source_path) DO UPDATE SET updated_at=excluded.updated_at`)
@@ -1302,6 +1286,8 @@ export class ChatCacheCoordinator {
         this.retryTimers.delete(timer)
         if(this.store.isClosed||this.stopped)return
         void this.restoreReadPositions()
+        for(const row of this.store.db.prepare("SELECT owner,profile,session_id FROM chat_sessions WHERE owned_at IS NOT NULL").all() as any[]){this.store.transcripts.seed(row.owner,row.profile,row.session_id)}
+        for(const row of this.store.db.prepare("SELECT * FROM chat_transcript_repairs WHERE status='pending'").all() as any[])this.schedule(row.owner,row.profile,row.session_id,true)
         for(const row of this.store.db.prepare('SELECT * FROM chat_recovery_jobs').all() as any[])this.schedule(row.owner,row.profile,row.session_id,!!row.force)
       },0);timer.unref();this.retryTimers.add(timer)
     }
@@ -1476,15 +1462,18 @@ export class ChatCacheCoordinator {
     const key = JSON.stringify([owner, profile, sessionID])
     const pending = this.reconciling.get(key)
     if (pending) {
-      await pending.catch(() => {})
-      if (force || this.store.localDetail(owner, profile, sessionID)?.state !== 'current') {
+      const failed=await pending.then(()=>false,()=>true)
+      if (failed || force || this.store.localDetail(owner, profile, sessionID)?.state !== 'current') {
         return this.reconcile(owner, profile, sessionID, force)
       }
       return
     }
     const task = (async () => {
+      if(this.store.transcripts.head(owner,profile,sessionID).running)throw new Error('Conversation is running; defer history repair')
+      const repair=this.store.db.prepare("SELECT 1 FROM chat_transcript_repairs WHERE owner=? AND profile=? AND session_id=? AND status='pending'").get(owner,profile,sessionID)
+      force ||= !!repair
       const revision = this.store.revision(owner, profile, sessionID)
-      const previous = this.store.messagePage(owner, profile, sessionID, 0, 1)
+      const previous = this.store.sourceMessagePage(owner, profile, sessionID, 0, 1)
       const previousTotal = previous ? Number(object(parseResponse(previous.response)?.pagination)?.total ?? 0) : 0
       const detailPath = `/api/sessions/${encodeURIComponent(sessionID)}`
       const detailStartedAt = Date.now()
@@ -1560,6 +1549,8 @@ export class ChatCacheCoordinator {
         // Its completion joins this flight and schedules the next tail read.
         throw new Error('Conversation changed during refresh; retry after the reply completes')
       } else {
+        if(this.store.db.prepare("SELECT 1 FROM chat_transcript_repairs WHERE owner=? AND profile=? AND session_id=? AND status='pending'").get(owner,profile,sessionID))
+          throw new Error('History identity is not yet confirmed; preserve the existing conversation')
         this.onSynchronized(owner, profile, sessionID)
       }
     })().finally(() => { if (this.reconciling.get(key) === task) this.reconciling.delete(key) })

+ 0 - 55
src/server/chatEventProjection.ts

@@ -1,55 +0,0 @@
-import { toolStatus } from '../shared/chatTools.js'
-/** Shared durable representation of an ordinary Hermes turn. No client timers or UI state. */
-export type ChatRecord = Record<string, any>
-const text = (p: ChatRecord) => String(p.delta ?? p.text_delta ?? p.content_delta ?? p.text ?? '')
-export function projectChatEvent(type: string, payload: ChatRecord, previous: ChatRecord | undefined,
-  fallbackID: string, now: number): ChatRecord | undefined {
-  const supported = /^(message\.(start|delta|interim|complete)|(?:reasoning|thinking)\.(delta|complete)|tool\.|run\.(completed|failed)|error$)/.test(type)
-  if (!supported) return undefined
-  // Parameter-generation notifications have no invocation identity. They are
-  // progress text, not an additional tool call which can later complete.
-  if (type.startsWith('tool.') && !String(payload.tool_id ?? payload.tool_call_id ?? payload.id ?? '').trim()) return undefined
-  const supplied = payload.message && typeof payload.message === 'object' ? payload.message : {}
-  const id = String(supplied.id ?? payload.message_id ?? previous?.id ?? fallbackID)
-  const message: ChatRecord = { role: 'assistant', content: '', reasoning: '', timestamp: now / 1000,
-    ...previous, ...supplied, id }
-  if (type === 'message.delta') message.content = String(message.content ?? '') + text(payload)
-  if (type === 'message.interim') {
-    if (typeof payload.text === 'string') message.content = payload.text
-    else if (typeof payload.content === 'string') message.content = payload.content
-    message.status = 'complete'
-  }
-  if (type === 'reasoning.delta' || type === 'thinking.delta') message.reasoning = String(message.reasoning ?? '') + text(payload)
-  if (type === 'reasoning.complete' || type === 'thinking.complete') {
-    const reasoning = payload.reasoning ?? payload.text ?? payload.content
-    if (typeof reasoning === 'string') message.reasoning = reasoning
-  }
-  if (type.startsWith('tool.')) {
-    const tools: ChatRecord[] = Array.isArray(message.tool_calls) ? [...message.tool_calls] : []
-    const toolID = String(payload.tool_id ?? payload.tool_call_id ?? payload.id ?? '')
-    const index = tools.findIndex(t => t.id === toolID)
-    const value = { ...(index >= 0 ? tools[index] : {}), ...payload, id: toolID,
-      status: toolStatus(type, payload.result ?? payload.output, payload.error) }
-    if (index >= 0) tools[index] = value; else tools.push(value)
-    message.tool_calls = tools
-  }
-  if (type === 'message.complete' || type === 'run.completed') {
-    if (typeof payload.output === 'string') message.content = payload.output
-    else if (typeof payload.content === 'string') message.content = payload.content
-    else if (typeof payload.text === 'string') message.content = payload.text
-    message.status = ['interrupted','cancelled','canceled'].includes(payload.status) ? 'interrupted'
-      : payload.error || ['error','failed'].includes(payload.status) ? 'failed' : 'complete'
-    if(payload.error)message.error=payload.error
-  } else if (type === 'error' || type === 'run.failed') {
-    message.status = 'failed'; message.error = String(payload.error ?? payload.message ?? '运行失败')
-  } else if (type !== 'message.interim' && !(type.startsWith('tool.') && ['complete', 'failed'].includes(previous?.status))) message.status = 'streaming'
-  message.final_result = type.startsWith('tool.') && previous?.final_result === true
-    || (type === 'message.complete' || type === 'run.completed') && message.status === 'complete'
-  if (['message.complete', 'run.completed', 'run.failed', 'error'].includes(type)) {
-    message.tool_calls = (message.tool_calls ?? []).filter((tool: ChatRecord) => tool.id).map((tool: ChatRecord) => ({
-      ...tool, status: toolStatus(tool.status, tool.result, tool.error) === 'running' ? 'completed' : toolStatus(tool.status, tool.result, tool.error),
-      ...(toolStatus(tool.status, tool.result, tool.error) === 'running' ? { completion_unknown: true } : {}),
-    }))
-  }
-  return message
-}

+ 8 - 7
src/server/chatTranscriptApi.ts

@@ -21,10 +21,8 @@ export function chatTranscriptRouter(cache:ChatCacheCoordinator,auth:LocalAuthSt
     const {owner,profile,id}=scope(ctx)
     const before=ctx.query.before===undefined?Number.MAX_SAFE_INTEGER:Number(ctx.query.before),limit=ctx.query.limit===undefined?150:Number(ctx.query.limit)
     if(!Number.isSafeInteger(before)||before<1||!Number.isSafeInteger(limit)||limit<1||limit>500)throw new HttpError(400,'历史分页参数无效','invalid_page')
-    const total=Number(JSON.parse(store.localDetail(owner,profile,id)!.response.body.toString()).message_count??0)
-    const offset=before===Number.MAX_SAFE_INTEGER?0:Math.max(0,total-(before-1))
-    if(!store.messagePage(owner,profile,id,offset,before===Number.MAX_SAFE_INTEGER?limit:Math.min(limit,before-1))){cache.schedule(owner,profile,id,true);throw new HttpError(409,'历史正在后台补齐','CHAT_HISTORY_SYNC_PENDING')}
-    cache.readLocal(owner,'messages',profile,id,{limit:1})
+    const coverage=store.db.prepare('SELECT complete FROM chat_sessions WHERE owner=? AND profile=? AND session_id=?').get(owner,profile,id)
+    if(!coverage?.complete||store.needsListMetadata(owner,profile)||store.localDetail(owner,profile,id)?.state==='gap')cache.schedule(owner,profile,id)
     store.db.exec('SAVEPOINT transcript_snapshot')
     try{
       transcripts.seed(owner,profile,id)
@@ -37,7 +35,9 @@ export function chatTranscriptRouter(cache:ChatCacheCoordinator,auth:LocalAuthSt
     const {owner,profile,id}=scope(ctx),version=auth.pushAuthorizationVersion(owner)
     const raw=ctx.get('last-event-id')||String(ctx.query.after??''),parts=raw.split(':')
     let cursor=Number(parts.at(-1)),closed=false,lastFlush=0
-    if(parts.length!==2||parts[0]!==transcripts.epoch||!Number.isSafeInteger(cursor)||cursor<0||cursor>transcripts.cursor(owner,profile,id))throw new HttpError(409,'聊天事件需要重新同步','transcript_reset')
+    transcripts.seed(owner,profile,id)
+    const epoch=transcripts.epochFor(owner,profile,id)
+    if(parts.length!==2||parts[0]!==epoch||!Number.isSafeInteger(cursor)||cursor<0||cursor>transcripts.cursor(owner,profile,id))throw new HttpError(409,'聊天事件需要重新同步','transcript_reset')
     const res=ctx.res
     let timer:ReturnType<typeof setTimeout>|undefined,heartbeat:ReturnType<typeof setInterval>|undefined,off=()=>{}
     const valid=()=>!store.isClosed&&auth.isUserActive(owner)&&auth.pushAuthorizationVersion(owner)===version&&auth.current(ctx)?.id===owner&&store.ownsSession(owner,profile,id)&&auth.canUseSource(owner,'local',profile)
@@ -50,8 +50,9 @@ export function chatTranscriptRouter(cache:ChatCacheCoordinator,auth:LocalAuthSt
     const flush=()=>{
       clearTimeout(timer);timer=undefined
       if(!valid()){close();return}
+      if(transcripts.epochFor(owner,profile,id)!==epoch){write('event: reset\ndata: {}\n\n');close();return}
       const events=transcripts.events(owner,profile,id,cursor)
-      for(const event of events){if(!write(`id: ${transcripts.epoch}:${event.cursor}\nevent: transcript\ndata: ${JSON.stringify(event)}\n\n`))return;cursor=event.cursor}
+      for(const event of events){if(event.epoch!==epoch){write('event: reset\ndata: {}\n\n');close();return}if(!write(`id: ${epoch}:${event.cursor}\nevent: transcript\ndata: ${JSON.stringify(event)}\n\n`))return;cursor=event.cursor}
       if(events.length)lastFlush=Date.now()
       if(events.length===250)timer=setTimeout(flush,0)
     }
@@ -66,7 +67,7 @@ export function chatTranscriptRouter(cache:ChatCacheCoordinator,auth:LocalAuthSt
     res.writeHead(200,{'Content-Type':'text/event-stream; charset=utf-8','Cache-Control':'no-store','X-Accel-Buffering':'no'})
     res.flushHeaders();res.once('close',close);res.once('error',close)
     off=transcripts.subscribe(changed)
-    flush();write(`event: ready\ndata: ${JSON.stringify({epoch:transcripts.epoch,cursor})}\n\n`)
+    flush();write(`event: ready\ndata: ${JSON.stringify({epoch,cursor})}\n\n`)
     heartbeat=setInterval(()=>{if(valid())write(': heartbeat\n\n');else close()},15_000);heartbeat.unref()
   })
   return router

+ 1065 - 113
src/server/chatTranscriptStore.ts

@@ -1,16 +1,62 @@
 import { randomUUID } from 'node:crypto'
 import type { DatabaseSync } from 'node:sqlite'
-import { CHAT_TRANSCRIPT_FEATURE, type TranscriptEvent, type TranscriptMessage, type TranscriptSnapshot } from '../shared/chatTranscript.js'
+import {
+  CHAT_TRANSCRIPT_FEATURE,
+  type TranscriptEvent,
+  type TranscriptMessage,
+  type TranscriptSnapshot,
+} from '../shared/chatTranscript.js'
+import { toolStatus } from '../shared/chatTools.js'
+import { isFinalChatResult } from './chatUnread.js'
 
-/** A versioned display projection and replay log in the chat cache transaction. */
+type Scope = [string, string, string]
+type Data = Record<string, any>
+interface TurnHead {
+  turn?: string
+  current?: string
+  preview?: boolean
+  terminal?: boolean
+  running: boolean
+  queued: boolean
+  queue: Array<{ turn: string; user: string; delivery: string }>
+  tools: Record<string, string>
+  pending: Record<string, { user: string; turn: string; previousRunning: boolean }>
+  pendingApproval?: Data | null
+  pendingClarification?: Data | null
+  liveStatus?: string | null
+  resyncing?: boolean
+  error?: string | null
+  stagedAttachments?: Data[]
+  localReadEstablished?: boolean
+  importedReadPosition?: number
+}
+const emptyHead = (): TurnHead => ({ running: false, queued: false, queue: [], tools: {}, pending: {} })
+const text = (p: Data): string => String(p.output ?? p.text ?? p.content ?? '')
+const controlState = (head: TurnHead) => ({
+  running: head.running,
+  queued: head.queued,
+  pendingApproval: head.pendingApproval ?? null,
+  pendingClarification: head.pendingClarification ?? null,
+  liveStatus: head.liveStatus ?? null,
+  error: head.error ?? null,
+})
+
+/** The sole ordinary-chat writer. Raw history is an import source, never a live projection. */
 export class ChatTranscriptStore {
-  // Opt in until the three clients finish end-to-end transcript validation.
-  readonly enabled = process.env.HERMES_YAOYAO_ORDINARY_TRANSCRIPTS === '1'
+  readonly enabled = true
   readonly epoch: string
   private listeners = new Set<() => void>()
   private scheduled = false
   private closed = false
-  constructor(readonly db: DatabaseSync,readonly enrich:(owner:string,profile:string,sessionId:string,rows:Record<string,any>[])=>Record<string,any>[] = (_o,_p,_s,rows)=>rows) {
+  constructor(
+    readonly db: DatabaseSync,
+    readonly enrich: (
+      owner: string,
+      profile: string,
+      sessionId: string,
+      rows: Record<string, any>[],
+    ) => Record<string, any>[] = (_o, _p, _s, rows) => rows,
+  ) {
     db.exec(`CREATE TABLE IF NOT EXISTS chat_transcript_meta(key TEXT PRIMARY KEY,value TEXT NOT NULL);
       CREATE TABLE IF NOT EXISTS chat_transcript_scopes(owner TEXT NOT NULL,profile TEXT NOT NULL,session_id TEXT NOT NULL,PRIMARY KEY(owner,profile,session_id));
       CREATE TABLE IF NOT EXISTS chat_transcript_execution(owner TEXT NOT NULL,profile TEXT NOT NULL,session_id TEXT NOT NULL,data TEXT NOT NULL,PRIMARY KEY(owner,profile,session_id));
@@ -23,124 +69,1030 @@ export class ChatTranscriptStore {
       CREATE TABLE IF NOT EXISTS chat_transcript_events(cursor INTEGER PRIMARY KEY AUTOINCREMENT,owner TEXT NOT NULL,
         profile TEXT NOT NULL,session_id TEXT NOT NULL,type TEXT NOT NULL,data TEXT NOT NULL,created_at INTEGER NOT NULL);
       CREATE INDEX IF NOT EXISTS chat_transcript_replay ON chat_transcript_events(owner,profile,session_id,cursor);`)
+    db.exec(`CREATE TABLE IF NOT EXISTS chat_transcript_heads(owner TEXT NOT NULL,profile TEXT NOT NULL,session_id TEXT NOT NULL,epoch TEXT NOT NULL,data TEXT NOT NULL,
+      PRIMARY KEY(owner,profile,session_id));
+      CREATE TABLE IF NOT EXISTS chat_transcript_archives(owner TEXT NOT NULL,profile TEXT NOT NULL,session_id TEXT NOT NULL,epoch TEXT NOT NULL,data TEXT NOT NULL,created_at INTEGER NOT NULL,
+      PRIMARY KEY(owner,profile,session_id,epoch));
+      CREATE TABLE IF NOT EXISTS chat_transcript_reads(owner TEXT NOT NULL,profile TEXT NOT NULL,session_id TEXT NOT NULL,id TEXT NOT NULL,PRIMARY KEY(owner,profile,session_id,id));
+      CREATE TABLE IF NOT EXISTS chat_transcript_source_cursors(owner TEXT NOT NULL,profile TEXT NOT NULL,session_id TEXT NOT NULL,runtime TEXT NOT NULL,epoch TEXT NOT NULL,seq INTEGER NOT NULL,
+      PRIMARY KEY(owner,profile,session_id,runtime,epoch));
+      CREATE TABLE IF NOT EXISTS chat_transcript_repairs(owner TEXT NOT NULL,profile TEXT NOT NULL,session_id TEXT NOT NULL,status TEXT NOT NULL DEFAULT 'pending',
+      PRIMARY KEY(owner,profile,session_id));`)
     db.prepare("INSERT OR IGNORE INTO chat_transcript_meta VALUES('epoch',?)").run(randomUUID())
     this.epoch = String(db.prepare("SELECT value FROM chat_transcript_meta WHERE key='epoch'").get()!.value)
   }
-  close() { this.closed=true; this.listeners.clear() }
-  subscribe(listener: () => void) { this.listeners.add(listener); return () => { this.listeners.delete(listener) } }
+  close() {
+    this.closed = true
+    this.listeners.clear()
+  }
+  subscribe(listener: () => void) {
+    this.listeners.add(listener)
+    return () => {
+      this.listeners.delete(listener)
+    }
+  }
   private notify() {
     if (this.scheduled || this.closed) return
-    this.scheduled=true
+    this.scheduled = true
     // All enclosing cache savepoints finish synchronously. Read committed log
     // rows in subscribers; a rolled-back notification therefore emits nothing.
-    queueMicrotask(() => { this.scheduled=false; if(!this.closed) for(const listener of this.listeners) { try{listener()}catch{} } })
+    queueMicrotask(() => {
+      this.scheduled = false
+      if (!this.closed)
+        for (const listener of this.listeners) {
+          try {
+            listener()
+          } catch {}
+        }
+    })
   }
-  private event(owner:string,profile:string,sessionId:string,type:TranscriptEvent['type'],data:unknown) {
-    this.db.prepare('INSERT INTO chat_transcript_events(owner,profile,session_id,type,data,created_at) VALUES(?,?,?,?,?,?)')
-      .run(owner,profile,sessionId,type,JSON.stringify(data),Date.now())
+  private event(
+    owner: string,
+    profile: string,
+    sessionId: string,
+    type: TranscriptEvent['type'],
+    data: unknown,
+  ) {
+    const previousCursor = this.cursor(owner, profile, sessionId)
+    const envelope = {
+      data,
+      previousCursor,
+      epoch: this.epochFor(owner, profile, sessionId),
+      total: this.total(owner, profile, sessionId),
+    }
+    this.db
+      .prepare(
+        'INSERT INTO chat_transcript_events(owner,profile,session_id,type,data,created_at) VALUES(?,?,?,?,?,?)',
+      )
+      .run(owner, profile, sessionId, type, JSON.stringify(envelope), Date.now())
     this.notify()
   }
-  private alias(owner:string,profile:string,sessionId:string,key:string):string|undefined {
-    return (this.db.prepare('SELECT id FROM chat_transcript_aliases WHERE owner=? AND profile=? AND session_id=? AND source=?')
-      .get(owner,profile,sessionId,key) as {id:string}|undefined)?.id
-  }
-  promote(owner:string,profile:string,sessionId:string,oldSource:string,newSource:string) {
-    const id=this.alias(owner,profile,sessionId,`source:${oldSource}`)
-    if(id&&!this.alias(owner,profile,sessionId,`source:${newSource}`)) this.db.prepare('INSERT INTO chat_transcript_aliases VALUES(?,?,?,?,?)').run(owner,profile,sessionId,`source:${newSource}`,id)
-  }
-  upsert(owner:string,profile:string,sessionId:string,source:Record<string,any>,position:number) {
-    const sourceID=String(source.id??source.message_id??'')
-    if(!sourceID)return
-    const keys=[`source:${sourceID}`]
-    if(source.role==='user'&&source.client_message_id)keys.unshift(`client:${source.client_message_id}`)
-    if(source.role==='tool'&&source.tool_call_id)keys.unshift(`tool:${source.tool_call_id}`)
-    const id=keys.map(key=>this.alias(owner,profile,sessionId,key)).find(Boolean)??randomUUID()
-    for(const duplicate of new Set(keys.map(key=>this.alias(owner,profile,sessionId,key)).filter((value):value is string=>!!value&&value!==id))){
-      const old=this.db.prepare('SELECT revision,deleted FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=? AND id=?').get(owner,profile,sessionId,duplicate) as {revision:number;deleted:number}|undefined
-      this.db.prepare('UPDATE chat_transcript_aliases SET id=? WHERE owner=? AND profile=? AND session_id=? AND id=?').run(id,owner,profile,sessionId,duplicate)
-      if(old&&!old.deleted){this.db.prepare('UPDATE chat_transcript_messages SET deleted=1,revision=revision+1 WHERE owner=? AND profile=? AND session_id=? AND id=?').run(owner,profile,sessionId,duplicate);this.event(owner,profile,sessionId,'message.deleted',{id:duplicate,revision:old.revision+1})}
-    }
-    const row=this.db.prepare('SELECT data,revision,deleted FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=? AND id=?')
-      .get(owner,profile,sessionId,id) as {data:string;revision:number;deleted:number}|undefined
-    const old:TranscriptMessage|undefined=row?JSON.parse(row.data):undefined
-    const next:TranscriptMessage={...source,id,source_message_id:sourceID,seq:position+1,revision:row?.revision??0,
-      role:String(source.role??'assistant'),content:source.content??'',reasoning:String(source.reasoning??source.reasoning_content??'')}
-    if(!next.client_message_id&&old?.client_message_id)next.client_message_id=old.client_message_id
-    for(const key of keys)this.db.prepare('INSERT INTO chat_transcript_aliases VALUES(?,?,?,?,?) ON CONFLICT(owner,profile,session_id,source) DO UPDATE SET id=excluded.id').run(owner,profile,sessionId,key,id)
-    if(old&&!row?.deleted&&JSON.stringify(old)===JSON.stringify(next))return
+  private alias(owner: string, profile: string, sessionId: string, key: string): string | undefined {
+    return (
+      this.db
+        .prepare(
+          'SELECT id FROM chat_transcript_aliases WHERE owner=? AND profile=? AND session_id=? AND source=?',
+        )
+        .get(owner, profile, sessionId, key) as { id: string } | undefined
+    )?.id
+  }
+  promote(owner: string, profile: string, sessionId: string, oldSource: string, newSource: string) {
+    const id = this.alias(owner, profile, sessionId, `source:${oldSource}`)
+    if (id && !this.alias(owner, profile, sessionId, `source:${newSource}`))
+      this.db
+        .prepare('INSERT INTO chat_transcript_aliases VALUES(?,?,?,?,?)')
+        .run(owner, profile, sessionId, `source:${newSource}`, id)
+  }
+  upsert(
+    owner: string,
+    profile: string,
+    sessionId: string,
+    source: Record<string, any>,
+    position: number,
+  ): TranscriptMessage | undefined {
+    const sourceID = String(source.id ?? source.message_id ?? '')
+    if (!sourceID) return
+    const keys = [`source:${sourceID}`]
+    if (source.role === 'user' && source.client_message_id) keys.unshift(`client:${source.client_message_id}`)
+    if (source.role === 'tool' && source.tool_call_id)
+      keys.unshift(`tool:${source.turn_id ?? ''}:${source.tool_call_id}`)
+    const id =
+      this.message(owner, profile, sessionId, sourceID)?.id ??
+      keys.map((key) => this.alias(owner, profile, sessionId, key)).find(Boolean) ??
+      randomUUID()
+    for (const duplicate of new Set(
+      keys
+        .map((key) => this.alias(owner, profile, sessionId, key))
+        .filter((value): value is string => !!value && value !== id),
+    )) {
+      const old = this.db
+        .prepare(
+          'SELECT revision,deleted FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=? AND id=?',
+        )
+        .get(owner, profile, sessionId, duplicate) as { revision: number; deleted: number } | undefined
+      this.db
+        .prepare(
+          'UPDATE chat_transcript_aliases SET id=? WHERE owner=? AND profile=? AND session_id=? AND id=?',
+        )
+        .run(id, owner, profile, sessionId, duplicate)
+      if (old && !old.deleted) {
+        this.db
+          .prepare(
+            'UPDATE chat_transcript_messages SET deleted=1,revision=revision+1 WHERE owner=? AND profile=? AND session_id=? AND id=?',
+          )
+          .run(owner, profile, sessionId, duplicate)
+        this.event(owner, profile, sessionId, 'message.deleted', {
+          id: duplicate,
+          revision: old.revision + 1,
+        })
+      }
+    }
+    const row = this.db
+      .prepare(
+        'SELECT data,revision,deleted FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=? AND id=?',
+      )
+      .get(owner, profile, sessionId, id) as { data: string; revision: number; deleted: number } | undefined
+    const old: TranscriptMessage | undefined = row ? JSON.parse(row.data) : undefined
+    const next: TranscriptMessage = {
+      ...source,
+      id,
+      source_message_id: source.source_message_id ?? old?.source_message_id ?? sourceID,
+      seq: old?.seq ?? position + 1,
+      revision: row?.revision ?? 0,
+      role: String(source.role ?? 'assistant'),
+      content: source.content ?? '',
+      reasoning: String(source.reasoning ?? source.reasoning_content ?? ''),
+    }
+    if (!next.client_message_id && old?.client_message_id) next.client_message_id = old.client_message_id
+    for (const key of keys)
+      this.db
+        .prepare(
+          'INSERT INTO chat_transcript_aliases VALUES(?,?,?,?,?) ON CONFLICT(owner,profile,session_id,source) DO UPDATE SET id=excluded.id',
+        )
+        .run(owner, profile, sessionId, key, id)
+    if (old && !row?.deleted && JSON.stringify(old) === JSON.stringify(next)) return old
     next.revision++
-    this.db.prepare('INSERT INTO chat_transcript_messages VALUES(?,?,?,?,?,?,?,0) ON CONFLICT(owner,profile,session_id,id) DO UPDATE SET seq=excluded.seq,revision=excluded.revision,data=excluded.data,deleted=0')
-      .run(owner,profile,sessionId,id,next.seq,next.revision,JSON.stringify(next))
-    let patch:Record<string,unknown>|undefined
-    if(old&&!row?.deleted&&old.status==='streaming'&&next.status==='streaming'&&typeof old.content==='string'&&typeof next.content==='string'
-      &&next.content.startsWith(old.content)&&next.reasoning!.startsWith(String(old.reasoning??''))){
-      const {content:a,reasoning:b,revision:c,...oldMeta}=old
-      const {content:d,reasoning:e,revision:f,...newMeta}=next
-      if(JSON.stringify(oldMeta)===JSON.stringify(newMeta))patch={id,seq:next.seq,baseRevision:old.revision,revision:next.revision,
-        contentAppend:next.content.slice(old.content.length),reasoningAppend:next.reasoning!.slice(String(old.reasoning??'').length)}
-    }
-    this.event(owner,profile,sessionId,patch?'message.patch':'message.upsert',patch??next)
-  }
-  reconcile(owner:string,profile:string,sessionId:string) {
-    const rows=this.db.prepare('SELECT data,position FROM chat_messages WHERE owner=? AND profile=? AND session_id=? ORDER BY position').all(owner,profile,sessionId) as {data:string;position:number}[]
-    const live=new Set<string>()
-    const enriched=this.enrich(owner,profile,sessionId,rows.map(row=>JSON.parse(row.data)))
-    for(const [index,row] of rows.entries()){const source=enriched[index]!;this.upsert(owner,profile,sessionId,source,row.position);const id=this.alias(owner,profile,sessionId,`source:${source.id??source.message_id}`);if(id)live.add(id)}
-    for(const row of this.db.prepare('SELECT id,revision FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=? AND deleted=0').all(owner,profile,sessionId) as {id:string;revision:number}[]){
-      if(live.has(row.id))continue
-      this.db.prepare('UPDATE chat_transcript_messages SET deleted=1,revision=revision+1 WHERE owner=? AND profile=? AND session_id=? AND id=?').run(owner,profile,sessionId,row.id)
-      this.event(owner,profile,sessionId,'message.deleted',{id:row.id,revision:row.revision+1})
-    }
-  }
-  seed(owner:string,profile:string,sessionId:string) {
-    if(this.db.prepare('SELECT 1 FROM chat_transcript_scopes WHERE owner=? AND profile=? AND session_id=?').get(owner,profile,sessionId))return
-    this.reconcile(owner,profile,sessionId)
-    this.db.prepare('INSERT INTO chat_transcript_scopes VALUES(?,?,?)').run(owner,profile,sessionId)
-  }
-  sessionChanged(owner:string,profile:string,sessionId:string,data:Record<string,unknown>) {
-    const row=this.db.prepare('SELECT data FROM chat_transcript_execution WHERE owner=? AND profile=? AND session_id=?').get(owner,profile,sessionId) as {data:string}|undefined
-    const state=row?JSON.parse(row.data):{running:false,queued:false,pending:{}}
-    const p=(data.payload??{}) as Record<string,any>,type=String(data.eventType)
-    state.pending??={}
-    if(type==='command.submitted'){state.pending[p.delivery_id]=state.running;state.queued=state.running;state.running=true}
-    if(type==='command.confirmed'||type==='command.rejected'){
-      if(type==='command.rejected')state.running=state.pending[p.delivery_id]??state.running
-      else{state.running=true;state.queued=p.status==='queued'}
-      delete state.pending[p.delivery_id]
-    }
-    if(['message.start','run.started','approval.request','clarify.request'].includes(type))state.running=true
-    if(type==='route.resumed'&&typeof p.running==='boolean')state.running=p.running
-    if(['message.complete','run.completed','run.failed','error'].includes(type)){
-      state.queued=Number(p.queue_remaining??p.queueRemaining??0)>0
-      state.running=state.queued||Number(p.background_pending??p.backgroundPending??0)>0
-    }
-    this.db.prepare('INSERT INTO chat_transcript_execution VALUES(?,?,?,?) ON CONFLICT(owner,profile,session_id) DO UPDATE SET data=excluded.data').run(owner,profile,sessionId,JSON.stringify(state))
-    this.event(owner,profile,sessionId,'session.changed',{...data,running:state.running,queued:state.queued})
-  }
-  migrate(owner:string,profile:string,oldID:string,newID:string) {
-    for(const table of ['chat_transcript_messages','chat_transcript_aliases','chat_transcript_scopes','chat_transcript_execution'])this.db.prepare(`UPDATE ${table} SET session_id=? WHERE owner=? AND profile=? AND session_id=?`).run(newID,owner,profile,oldID)
-    this.event(owner,profile,oldID,'session.migrated',{sessionId:newID})
-  }
-  remove(owner:string,profile:string,sessionId:string) {
-    this.event(owner,profile,sessionId,'session.deleted',{sessionId})
-    for(const table of ['chat_transcript_messages','chat_transcript_aliases','chat_transcript_scopes','chat_transcript_execution'])this.db.prepare(`DELETE FROM ${table} WHERE owner=? AND profile=? AND session_id=?`).run(owner,profile,sessionId)
-  }
-  cursor(owner:string,profile:string,sessionId:string):number {
-    return Number(this.db.prepare('SELECT COALESCE(MAX(cursor),0) cursor FROM chat_transcript_events WHERE owner=? AND profile=? AND session_id=?').get(owner,profile,sessionId)!.cursor)
-  }
-  events(owner:string,profile:string,sessionId:string,after:number,limit=250):TranscriptEvent[] {
-    return (this.db.prepare('SELECT cursor,type,data FROM chat_transcript_events WHERE owner=? AND profile=? AND session_id=? AND cursor>? ORDER BY cursor LIMIT ?').all(owner,profile,sessionId,after,limit) as {cursor:number;type:TranscriptEvent['type'];data:string}[])
-      .map(row=>({...row,profile,sessionId,data:JSON.parse(row.data)}))
-  }
-  snapshot(owner:string,profile:string,sessionId:string,session:Record<string,unknown>,state:string,before=Number.MAX_SAFE_INTEGER,limit=150):TranscriptSnapshot {
-    const execution=this.db.prepare('SELECT data FROM chat_transcript_execution WHERE owner=? AND profile=? AND session_id=?').get(owner,profile,sessionId) as {data:string}|undefined
-    const run=execution?JSON.parse(execution.data):{}
-    const rows=this.db.prepare('SELECT data FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=? AND deleted=0 AND seq<? ORDER BY seq DESC,id DESC LIMIT ?')
-      .all(owner,profile,sessionId,before,limit+1) as {data:string}[]
-    return {protocol:CHAT_TRANSCRIPT_FEATURE,epoch:this.epoch,cursor:this.cursor(owner,profile,sessionId),profile,sessionId,running:run.running,queued:run.queued,
-      messages:rows.slice(0,limit).reverse().map(row=>JSON.parse(row.data)),session,state,
-      deletedIds:(this.db.prepare('SELECT id FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=? AND deleted=1').all(owner,profile,sessionId) as {id:string}[]).map(row=>row.id),
-      hasOlder:rows.length>limit||(rows.length>0&&Number(JSON.parse(rows[Math.min(limit,rows.length)-1]!.data).seq)>1),
-      total:Number(this.db.prepare('SELECT COUNT(*) n FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=? AND deleted=0').get(owner,profile,sessionId)!.n)}
+    this.db
+      .prepare(
+        'INSERT INTO chat_transcript_messages VALUES(?,?,?,?,?,?,?,0) ON CONFLICT(owner,profile,session_id,id) DO UPDATE SET seq=excluded.seq,revision=excluded.revision,data=excluded.data,deleted=0',
+      )
+      .run(owner, profile, sessionId, id, next.seq, next.revision, JSON.stringify(next))
+    let patch: Record<string, unknown> | undefined
+    if (
+      old &&
+      !row?.deleted &&
+      old.status === 'streaming' &&
+      next.status === 'streaming' &&
+      typeof old.content === 'string' &&
+      typeof next.content === 'string' &&
+      next.content.startsWith(old.content) &&
+      next.reasoning!.startsWith(String(old.reasoning ?? ''))
+    ) {
+      const { content: a, reasoning: b, revision: c, ...oldMeta } = old
+      const { content: d, reasoning: e, revision: f, ...newMeta } = next
+      if (JSON.stringify(oldMeta) === JSON.stringify(newMeta))
+        patch = {
+          id,
+          seq: next.seq,
+          baseRevision: old.revision,
+          revision: next.revision,
+          contentAppend: next.content.slice(old.content.length),
+          reasoningAppend: next.reasoning!.slice(String(old.reasoning ?? '').length),
+        }
+    }
+    this.event(owner, profile, sessionId, patch ? 'message.patch' : 'message.upsert', patch ?? next)
+    return next
+  }
+  reconcile(owner: string, profile: string, sessionId: string) {
+    const scope: Scope = [owner, profile, sessionId]
+    this.seed(...scope)
+    if (this.head(...scope).running) throw new Error('Conversation is running; defer history repair')
+    this.importHistory(scope, true)
+  }
+  seed(owner: string, profile: string, sessionId: string) {
+    const scope: Scope = [owner, profile, sessionId]
+    if (
+      this.db
+        .prepare('SELECT 1 FROM chat_transcript_heads WHERE owner=? AND profile=? AND session_id=?')
+        .get(...scope)
+    )
+      return
+    this.db.exec('SAVEPOINT transcript_seed')
+    try {
+      this.archive(scope)
+      // v1 records were projections of the legacy writer. Preserve their source
+      // for verification; do not mix their IDs/checkpoints with a v2 generation.
+      for (const table of [
+        'chat_transcript_messages',
+        'chat_transcript_aliases',
+        'chat_transcript_execution',
+      ])
+        this.db.prepare(`DELETE FROM ${table} WHERE owner=? AND profile=? AND session_id=?`).run(...scope)
+      this.db
+        .prepare('INSERT INTO chat_transcript_heads VALUES(?,?,?,?,?)')
+        .run(...scope, randomUUID(), JSON.stringify(emptyHead()))
+      this.db.prepare('INSERT OR IGNORE INTO chat_transcript_scopes VALUES(?,?,?)').run(...scope)
+      this.importHistory(scope, false)
+      const active = this.all(...scope)
+        .reverse()
+        .find((m) => m.status === 'streaming' && m.role === 'assistant')
+      if (active)
+        this.db
+          .prepare('UPDATE chat_transcript_heads SET data=? WHERE owner=? AND profile=? AND session_id=?')
+          .run(
+            JSON.stringify({ ...emptyHead(), turn: active.turn_id, current: active.id, running: true }),
+            ...scope,
+          )
+      if (this.total(...scope))
+        this.db
+          .prepare('INSERT OR IGNORE INTO chat_transcript_repairs VALUES(?,?,?,?)')
+          .run(...scope, 'pending')
+      this.db.exec('RELEASE transcript_seed')
+    } catch (error) {
+      this.db.exec('ROLLBACK TO transcript_seed; RELEASE transcript_seed')
+      throw error
+    }
+  }
+  sessionChanged(owner: string, profile: string, sessionId: string, data: Record<string, unknown>) {
+    this.reduce(owner, profile, sessionId, String(data.eventType), (data.payload ?? {}) as Data)
+  }
+  epochFor(...scope: Scope): string {
+    return String(
+      this.db
+        .prepare('SELECT epoch FROM chat_transcript_heads WHERE owner=? AND profile=? AND session_id=?')
+        .get(...scope)?.epoch ?? this.epoch,
+    )
+  }
+  head(...scope: Scope): TurnHead {
+    const row = this.db
+      .prepare('SELECT data FROM chat_transcript_heads WHERE owner=? AND profile=? AND session_id=?')
+      .get(...scope)
+    return row ? JSON.parse(String(row.data)) : emptyHead()
+  }
+  message(owner: string, profile: string, sessionId: string, id: string): TranscriptMessage | undefined {
+    const row = this.db
+      .prepare(
+        'SELECT data FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=? AND id=? AND deleted=0',
+      )
+      .get(owner, profile, sessionId, id)
+    return row ? JSON.parse(String(row.data)) : undefined
+  }
+  resolveMessage(
+    owner: string,
+    profile: string,
+    sessionId: string,
+    id: string,
+  ): TranscriptMessage | undefined {
+    return (
+      this.message(owner, profile, sessionId, id) ??
+      this.message(owner, profile, sessionId, this.alias(owner, profile, sessionId, `source:${id}`) ?? '')
+    )
+  }
+  all(...scope: Scope): TranscriptMessage[] {
+    return this.db
+      .prepare(
+        'SELECT data FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=? AND deleted=0 ORDER BY seq,id',
+      )
+      .all(...scope)
+      .map((row) => JSON.parse(String(row.data)))
+  }
+  total(...scope: Scope): number {
+    return Number(
+      this.db
+        .prepare(
+          'SELECT COUNT(*) n FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=? AND deleted=0',
+        )
+        .get(...scope)!.n,
+    )
+  }
+  private put(scope: Scope, value: Data): TranscriptMessage {
+    const position = Number(
+      this.db
+        .prepare(
+          'SELECT COALESCE(MAX(seq),0) n FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=?',
+        )
+        .get(...scope)!.n,
+    )
+    return this.upsert(
+      ...scope,
+      { timestamp: Date.now() / 1000, ...value, id: value.id ?? randomUUID() },
+      position,
+    )!
+  }
+  private bind(scope: Scope, source: string, id: string) {
+    if (source)
+      this.db
+        .prepare(
+          'INSERT INTO chat_transcript_aliases VALUES(?,?,?,?,?) ON CONFLICT(owner,profile,session_id,source) DO UPDATE SET id=excluded.id',
+        )
+        .run(...scope, `source:${source}`, id)
+  }
+  archive(scope: Scope) {
+    const data = {
+      messages: this.all(...scope),
+      head: this.head(...scope),
+      rawMessages: this.db
+        .prepare(
+          'SELECT message_id,position,data FROM chat_messages WHERE owner=? AND profile=? AND session_id=? ORDER BY position',
+        )
+        .all(...scope),
+      aliases: this.db
+        .prepare('SELECT source,id FROM chat_transcript_aliases WHERE owner=? AND profile=? AND session_id=?')
+        .all(...scope),
+    }
+    if (data.messages.length || data.rawMessages.length)
+      this.db
+        .prepare('INSERT OR IGNORE INTO chat_transcript_archives VALUES(?,?,?,?,?,?)')
+        .run(...scope, this.epochFor(...scope), JSON.stringify(data), Date.now())
+  }
+  private importHistory(scope: Scope, verified: boolean) {
+    if (verified) this.archive(scope)
+    const raw = this.db
+      .prepare(
+        'SELECT data,position FROM chat_messages WHERE owner=? AND profile=? AND session_id=? ORDER BY position',
+      )
+      .all(...scope) as { data: string; position: number }[]
+    const incoming = this.enrich(
+      ...scope,
+      raw.map((row) => JSON.parse(row.data)),
+    )
+    const existing = this.all(...scope),
+      retained = new Set<string>(),
+      wasRead = new Set(
+        this.db
+          .prepare('SELECT id FROM chat_transcript_reads WHERE owner=? AND profile=? AND session_id=?')
+          .all(...scope)
+          .map((row) => String(row.id)),
+      )
+    if (
+      verified &&
+      existing.some(
+        (m) =>
+          m.role === 'user' &&
+          m.status === 'complete' &&
+          m.client_message_id &&
+          m.source_message_id.startsWith('user:') &&
+          !incoming.some(
+            (source) =>
+              source.role === 'user' &&
+              (source.client_message_id === m.client_message_id ||
+                String(source.id ?? source.message_id) === m.source_message_id),
+          ),
+      )
+    ) {
+      this.db
+        .prepare(
+          "INSERT INTO chat_transcript_repairs VALUES(?,?,?,'pending') ON CONFLICT(owner,profile,session_id) DO UPDATE SET status='pending'",
+        )
+        .run(...scope)
+      return // The archive is available for verification; ambiguous live turns remain untouched.
+    }
+    const legacyRead = this.db
+      .prepare(
+        'SELECT COALESCE(final_read_count,read_count) n,read_initialized FROM chat_local_state WHERE owner=? AND profile=? AND session_id=?',
+      )
+      .get(...scope)
+    const boundary = legacyRead?.read_initialized ? Number(legacyRead.n) : Infinity
+    let turn = 'history:orphan',
+      ordinal = 0
+    for (const [index, source] of incoming.entries()) {
+      const sourceID = String(source.id ?? source.message_id ?? raw[index]!.position)
+      let known = this.resolveMessage(...scope, sourceID)
+      if (source.role === 'user' && source.client_message_id)
+        known ??= existing.find((m) => m.client_message_id === source.client_message_id)
+      if (source.role === 'user') turn = known?.turn_id ?? `history:${sourceID}`
+      const nextUser = incoming.findIndex((m, i) => i > index && m.role === 'user')
+      const rest = incoming.slice(index + 1, nextUser < 0 ? undefined : nextUser)
+      const final =
+        isFinalChatResult(source) &&
+        (source.final_result === true || !rest.some((message) => isFinalChatResult(message)))
+      // Only a proven user identity can associate an upstream final with the
+      // one live final of that turn. Content similarity is never an identity.
+      if (!known && verified && final) {
+        const candidates = existing.filter(
+          (m) => m.turn_id === turn && m.final_result === true && !retained.has(m.id),
+        )
+        if (candidates.length === 1) known = candidates[0]
+      }
+      const value = this.upsert(
+        ...scope,
+        {
+          ...source,
+          id: known?.id ?? sourceID,
+          source_message_id: sourceID,
+          turn_id: turn,
+          segment_kind:
+            source.role === 'user'
+              ? 'input'
+              : source.role === 'tool'
+                ? 'tool'
+                : final
+                  ? 'answer'
+                  : 'commentary',
+          final_result: final,
+        },
+        verified ? ordinal++ : raw[index]!.position,
+      )!
+      this.bind(scope, sourceID, value.id)
+      retained.add(value.id)
+      if (wasRead.has(known?.id ?? '') || raw[index]!.position < boundary)
+        this.db.prepare('INSERT OR IGNORE INTO chat_transcript_reads VALUES(?,?,?,?)').run(...scope, value.id)
+    }
+    if (!verified) return
+    // An unconfirmed local send is not disproven by a history snapshot.
+    for (const message of existing)
+      if (message.role === 'user' && message.status === 'pending') retained.add(message.id)
+    let structural = [...retained].some((id) => !existing.some((message) => message.id === id))
+    for (const message of existing)
+      if (!retained.has(message.id)) {
+        structural = true
+        this.db
+          .prepare(
+            'UPDATE chat_transcript_messages SET deleted=1,revision=revision+1 WHERE owner=? AND profile=? AND session_id=? AND id=?',
+          )
+          .run(...scope, message.id)
+        this.event(...scope, 'message.deleted', { id: message.id, revision: message.revision + 1 })
+      }
+    // Rebuild order only at an explicit generation boundary. Old pages/events
+    // cannot be merged into this generation, even while a client is paging.
+    const ordered = [...retained]
+    for (const [index, id] of ordered.entries()) {
+      const message = this.message(...scope, id)!
+      if (message.seq !== index + 1) {
+        structural = true
+        this.db
+          .prepare(
+            'UPDATE chat_transcript_messages SET seq=?,revision=revision+1,data=? WHERE owner=? AND profile=? AND session_id=? AND id=?',
+          )
+          .run(
+            index + 1,
+            JSON.stringify({ ...message, seq: index + 1, revision: message.revision + 1 }),
+            ...scope,
+            id,
+          )
+      }
+    }
+    if (structural) {
+      this.db
+        .prepare('UPDATE chat_transcript_heads SET epoch=? WHERE owner=? AND profile=? AND session_id=?')
+        .run(randomUUID(), ...scope)
+      this.event(...scope, 'session.changed', {
+        eventType: 'history.rebuilt',
+        ...controlState(this.head(...scope)),
+      })
+    }
+    this.db
+      .prepare(
+        "UPDATE chat_transcript_repairs SET status='complete' WHERE owner=? AND profile=? AND session_id=?",
+      )
+      .run(...scope)
+  }
+  markRead(owner: string, profile: string, sessionId: string, count = Infinity) {
+    const head = this.head(owner, profile, sessionId)
+    head.localReadEstablished = true
+    this.db
+      .prepare('UPDATE chat_transcript_heads SET data=? WHERE owner=? AND profile=? AND session_id=?')
+      .run(JSON.stringify(head), owner, profile, sessionId)
+    for (const m of this.all(owner, profile, sessionId))
+      if (m.seq <= count && isFinalChatResult(m))
+        this.db
+          .prepare('INSERT OR IGNORE INTO chat_transcript_reads VALUES(?,?,?,?)')
+          .run(owner, profile, sessionId, m.id)
+  }
+  unread(...scope: Scope): number {
+    // Legacy read cursors can finish importing after the initial local snapshot.
+    // Map only persisted source rows, never consume a live streaming result.
+    const legacy = this.db
+      .prepare(
+        'SELECT COALESCE(final_read_count,read_count) n FROM chat_local_state WHERE owner=? AND profile=? AND session_id=? AND read_initialized=1',
+      )
+      .get(...scope)
+    const head = this.head(...scope)
+    if (legacy && !head.localReadEstablished && Number(legacy.n) > (head.importedReadPosition ?? -1)) {
+      for (const row of this.db
+        .prepare(
+          'SELECT message_id FROM chat_messages WHERE owner=? AND profile=? AND session_id=? AND position<?',
+        )
+        .all(...scope, Number(legacy.n))) {
+        const m = this.resolveMessage(...scope, String(row.message_id))
+        if (m && isFinalChatResult(m))
+          this.db.prepare('INSERT OR IGNORE INTO chat_transcript_reads VALUES(?,?,?,?)').run(...scope, m.id)
+      }
+      head.importedReadPosition = Number(legacy.n)
+      this.db
+        .prepare('UPDATE chat_transcript_heads SET data=? WHERE owner=? AND profile=? AND session_id=?')
+        .run(JSON.stringify(head), ...scope)
+    }
+    return this.db
+      .prepare(
+        `SELECT m.data FROM chat_transcript_messages m LEFT JOIN chat_transcript_reads r USING(owner,profile,session_id,id)
+      WHERE m.owner=? AND m.profile=? AND m.session_id=? AND m.deleted=0 AND r.id IS NULL AND json_extract(m.data,'$.final_result')=1`,
+      )
+      .all(...scope)
+      .filter((row) => isFinalChatResult(JSON.parse(String(row.data)))).length
+  }
+  sourceCursor(owner: string, profile: string, sessionId: string, runtime: string, epoch: string): number {
+    return Number(
+      this.db
+        .prepare(
+          'SELECT seq FROM chat_transcript_source_cursors WHERE owner=? AND profile=? AND session_id=? AND runtime=? AND epoch=?',
+        )
+        .get(owner, profile, sessionId, runtime, epoch)?.seq ?? 0,
+    )
+  }
+  recordSource(scope: Scope, frame: Data) {
+    if (frame.epoch && frame.session_id && Number.isSafeInteger(frame.seq))
+      this.db
+        .prepare(
+          'INSERT INTO chat_transcript_source_cursors VALUES(?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id,runtime,epoch) DO UPDATE SET seq=MAX(seq,excluded.seq)',
+        )
+        .run(...scope, frame.session_id, frame.epoch, frame.seq)
+  }
+
+  /** Caller holds the transaction containing the input receipt and raw event. */
+  reduce(owner: string, profile: string, sessionId: string, type: string, p: Data) {
+    type =
+      (
+        {
+          'message.started': 'message.start',
+          'message.completed': 'message.complete',
+          'run.complete': 'run.completed',
+          'reasoning.completed': 'reasoning.complete',
+          'peer.user.message': 'message.user',
+          peer_user_message: 'message.user',
+          'run.peer_user_message': 'message.user',
+        } as Record<string, string>
+      )[type] ?? type
+    const scope: Scope = [owner, profile, sessionId]
+    this.seed(...scope)
+    const state = this.head(...scope)
+    const before = JSON.stringify(state)
+    const sourceID = String(p.message_id ?? p.message?.id ?? '')
+    const upstreamRun = String(p.run_id ?? p.runId ?? p.inflight?.run_id ?? '')
+    const knownTurn = upstreamRun ? this.alias(...scope, `source:turn:${upstreamRun}`) : undefined
+    if (
+      knownTurn &&
+      (knownTurn !== state.turn || state.terminal) &&
+      ['run.started', 'run.start', 'message.start'].includes(type)
+    )
+      return
+    const known =
+      (sourceID ? this.resolveMessage(...scope, sourceID) : undefined) ??
+      (knownTurn && knownTurn !== state.turn
+        ? this.all(...scope)
+            .reverse()
+            .find((m) => m.turn_id === knownTurn && m.role === 'assistant')
+        : undefined)
+    if (
+      known &&
+      state.turn &&
+      known.turn_id !== state.turn &&
+      ['message.complete', 'run.completed', 'message.delta', 'message.interim'].includes(type)
+    ) {
+      const terminal = ['message.complete', 'run.completed'].includes(type)
+      this.put(scope, {
+        ...known,
+        content:
+          type === 'message.delta'
+            ? String(known.content ?? '') + String(p.delta ?? p.text ?? '')
+            : text(p) || known.content,
+        ...(terminal ? { status: 'complete', final_result: true } : {}),
+      })
+      return
+    }
+    const current = () => (state.current ? this.message(...scope, state.current) : undefined)
+    const begin = () => {
+      if (!state.turn || state.terminal) {
+        const queued = state.queue.shift()
+        state.turn = queued?.turn ?? randomUUID()
+        state.current = undefined
+        state.preview = false
+        state.terminal = false
+        state.tools = {}
+        state.error = null
+      }
+      if (upstreamRun) this.bind(scope, `turn:${upstreamRun}`, state.turn!)
+      state.running = true
+      state.queued = state.queue.length > 0
+    }
+    const assistant = (): TranscriptMessage => {
+      begin()
+      const old = current()
+      if (old) return old
+      const created = this.put(scope, {
+        role: 'assistant',
+        turn_id: state.turn,
+        segment_kind: 'answer',
+        content: '',
+        reasoning: '',
+        status: 'streaming',
+        final_result: false,
+      })
+      state.current = created.id
+      return created
+    }
+    const saveAssistant = (value: Data) => {
+      const old = assistant(),
+        source = String(p.message_id ?? p.message?.id ?? '')
+      const attachments = p.attachments ?? p.message?.attachments
+      const saved = this.put(scope, {
+        ...old,
+        ...value,
+        ...(attachments ? { attachments } : {}),
+        ...(source ? { source_message_id: source } : {}),
+      })
+      this.bind(scope, source, saved.id)
+      return saved
+    }
+    if (type === 'command.submitted') {
+      const delivery = String(p.delivery_id),
+        client = String(p.client_message_id ?? delivery.replace(/^(web|ios|android):prompt:/, ''))
+      if (!this.alias(...scope, `client:${client}`)) {
+        const previousRunning = state.running,
+          steer = p.method === 'session.steer' && state.running
+        const turn = steer ? state.turn! : randomUUID()
+        const user = this.put(scope, {
+          id: `user:${delivery}`,
+          role: 'user',
+          turn_id: turn,
+          segment_kind: 'input',
+          client_message_id: client,
+          content: p.text ?? '',
+          status: 'pending',
+          ...(state.stagedAttachments?.length ? { attachments: state.stagedAttachments } : {}),
+        })
+        state.stagedAttachments = []
+        state.error = null
+        state.pending[delivery] = { user: user.id, turn, previousRunning }
+        if (previousRunning && !steer) {
+          state.queue.push({ turn, user: user.id, delivery })
+          state.queued = true
+        } else if (!steer) {
+          state.turn = turn
+          state.current = undefined
+          state.preview = false
+          state.terminal = false
+          state.tools = {}
+        }
+        state.running = true
+      }
+    } else if (type === 'command.confirmed' || type === 'command.rejected') {
+      const pending = state.pending[String(p.delivery_id)]
+      if (pending) {
+        const user = this.message(...scope, pending.user)
+        if (user)
+          this.put(scope, {
+            ...user,
+            status: type === 'command.rejected' ? 'failed' : 'complete',
+            ...(p.error ? { error: p.error } : {}),
+          })
+        if (type === 'command.rejected') {
+          state.queue = state.queue.filter((q) => q.delivery !== p.delivery_id)
+          if (state.turn === pending.turn && !state.current) state.terminal = true
+          state.running = !state.terminal || state.queue.length > 0
+        }
+        delete state.pending[String(p.delivery_id)]
+        state.queued = state.queue.length > 0
+      }
+    } else if (type === 'message.user') {
+      const client = String(p.client_message_id ?? p.queue_id ?? '').replace(/^(web|ios|android):prompt:/, '')
+      const source = String(p.message_id ?? p.id ?? '')
+      const old =
+        (client
+          ? this.all(...scope).find((m) => m.role === 'user' && m.client_message_id === client)
+          : undefined) ?? (source ? this.resolveMessage(...scope, source) : undefined)
+      if (source || client) {
+        const turn = old?.turn_id ?? (p.steer ? state.turn : undefined) ?? randomUUID()
+        const user = this.put(scope, {
+          ...old,
+          id: old?.id || source || randomUUID(),
+          role: 'user',
+          turn_id: turn,
+          segment_kind: 'input',
+          content: p.text ?? p.content ?? '',
+          status: p.status === 'failed' ? 'failed' : 'complete',
+          ...(client ? { client_message_id: client } : {}),
+        })
+        if (!old && p.status !== 'failed') {
+          if (state.running && !p.steer) {
+            state.queue.push({ turn, user: user.id, delivery: client || source })
+            state.queued = true
+          } else if (!p.steer) {
+            state.turn = turn
+            state.current = undefined
+            state.terminal = false
+            state.preview = false
+          }
+        }
+      }
+    } else if (['message.start', 'run.started', 'run.start'].includes(type)) {
+      const old = current()
+      if (
+        type === 'message.start' &&
+        sourceID &&
+        old &&
+        old.source_message_id !== sourceID &&
+        !this.resolveMessage(...scope, sourceID) &&
+        (old.content || old.reasoning || old.tool_calls)
+      ) {
+        this.put(scope, { ...old, status: 'complete', segment_kind: 'commentary', final_result: false })
+        state.current = undefined
+        state.preview = false
+      }
+      const row = assistant()
+      if (sourceID) {
+        this.bind(scope, sourceID, row.id)
+        this.put(scope, { ...row, source_message_id: sourceID })
+      }
+    } else if (['message.delta', 'content.delta', 'assistant.delta'].includes(type) && !state.resyncing) {
+      if (state.preview) {
+        state.current = undefined
+        state.preview = false
+      }
+      const old = assistant()
+      saveAssistant({
+        content:
+          String(old.content ?? '') +
+          String(p.delta ?? p.text_delta ?? p.content_delta ?? p.text ?? p.content ?? ''),
+        status: 'streaming',
+      })
+    } else if (type === 'message.interim') {
+      saveAssistant({
+        content: text(p) || current()?.content || '',
+        status: 'streaming',
+        segment_kind: 'commentary',
+        final_result: false,
+      })
+      state.preview = true
+    } else if (type.startsWith('reasoning.') || type === 'thinking.delta' || type === 'thinking.complete') {
+      const old = assistant(),
+        full = p.reasoning ?? p.text ?? p.content ?? p.delta ?? ''
+      saveAssistant({
+        reasoning: type.endsWith('delta')
+          ? String(old.reasoning ?? '') + String(p.delta ?? full)
+          : String(full),
+      })
+    } else if (type.startsWith('tool.')) {
+      const toolID = String(p.tool_call_id ?? p.tool_id ?? p.id ?? '')
+      if (toolID) {
+        let target = state.tools[toolID] ? this.message(...scope, state.tools[toolID]!) : undefined
+        if (!target && /complete|failed/.test(type)) {
+          const candidates = this.all(...scope).filter((m) =>
+            (m.tool_calls as Data[] | undefined)?.some((t) => t.id === toolID),
+          )
+          if (candidates.length === 1) target = candidates[0]
+        }
+        if (!target) {
+          if (state.preview) {
+            const old = current()
+            if (old) this.put(scope, { ...old, status: 'complete' })
+            state.current = undefined
+            state.preview = false
+          }
+          target = assistant()
+          state.tools[toolID] = target.id
+        }
+        const tools = Array.isArray(target.tool_calls) ? ([...target.tool_calls] as Data[]) : [],
+          index = tools.findIndex((t) => t.id === toolID)
+        const old = index < 0 ? {} : tools[index]!,
+          status = toolStatus(type, p.result ?? p.output, p.error)
+        const tool = {
+          ...old,
+          ...p,
+          id: toolID,
+          status: ['completed', 'failed'].includes(old.status) && status === 'running' ? old.status : status,
+        }
+        if (index < 0) tools.push(tool)
+        else tools[index] = tool
+        this.put(scope, { ...target, tool_calls: tools })
+      } else state.liveStatus = p.name ? `正在准备 ${p.name}` : state.liveStatus
+    } else if (['message.complete', 'run.completed', 'run.complete', 'run.failed', 'error'].includes(type)) {
+      // A replayed terminal snapshot updates the terminal slot. It never starts
+      // another turn (the next run.started/delta owns advancing the queue).
+      const old = current()
+      const wasTerminal = state.terminal
+      state.terminal = false
+      const failed =
+        ['run.failed', 'error'].includes(type) || p.error || ['failed', 'error'].includes(p.status)
+      const interrupted = ['interrupted', 'cancelled', 'canceled'].includes(p.status)
+      const output = p.output ?? p.text ?? p.content ?? p.message?.content
+      if (old || output || p.attachments)
+        saveAssistant({
+          content: output === '' ? (old?.content ?? '') : (output ?? old?.content ?? ''),
+          status: failed ? 'failed' : interrupted ? 'interrupted' : 'complete',
+          segment_kind: 'answer',
+          final_result: !failed && !interrupted,
+          ...(failed ? { error: p.error ?? p.message ?? '运行失败' } : {}),
+        })
+      state.error = failed ? String(p.error ?? p.message ?? '运行失败') : null
+      for (const m of this.all(...scope))
+        if (m.turn_id === state.turn) {
+          const calls = (m.tool_calls as Data[] | undefined)?.map((t) =>
+            ['running', 'pending'].includes(t.status) ? { ...t, status: 'interrupted' } : t,
+          )
+          if (m.status === 'streaming' || calls)
+            this.put(scope, {
+              ...m,
+              status: m.status === 'streaming' ? 'complete' : m.status,
+              ...(calls ? { tool_calls: calls } : {}),
+            })
+        }
+      state.terminal = true
+      state.preview = false
+      state.queued = state.queue.length > 0 || Number(p.queue_remaining ?? 0) > 0
+      state.running = state.queued || Number(p.background_pending ?? 0) > 0
+      state.resyncing = false
+      state.pendingApproval = null
+      state.pendingClarification = null
+      state.liveStatus = null
+      if (wasTerminal && state.queue.length === 0) state.running = false
+    } else if (type === 'route.resumed') {
+      const inflight = p.inflight ?? {},
+        running = p.running === true || p.info?.running === true || inflight.streaming === true
+      if (running) {
+        const hadCurrent = !!current()
+        begin()
+        if (typeof inflight.assistant === 'string' && (!hadCurrent || state.resyncing))
+          saveAssistant({ content: inflight.assistant, status: 'streaming' })
+        else assistant()
+      } else if (p.running === false) {
+        const old = current()
+        if (old && typeof inflight.assistant === 'string' && inflight.assistant)
+          this.put(scope, {
+            ...old,
+            content: inflight.assistant,
+            status: 'complete',
+            final_result: true,
+            segment_kind: 'answer',
+          })
+        state.running = false
+        state.terminal = true
+        state.resyncing = false
+        state.queued = Boolean(p.queued?.user) || Number(p.queue_length ?? p.info?.queue_length ?? 0) > 0
+        if (!state.queued) state.queue = []
+        for (const m of this.all(...scope))
+          if (m.status === 'streaming') this.put(scope, { ...m, status: 'complete' })
+      }
+      state.pendingApproval = p.pending_approval ?? inflight.pending_approval ?? null
+      state.pendingClarification = p.pending_clarify ?? inflight.pending_clarify ?? null
+    } else if (type === 'attachment.staged') {
+      const attachment = p.attachment ?? p.file ?? p
+      if (attachment.path || attachment.url)
+        state.stagedAttachments = [...(state.stagedAttachments ?? []), attachment]
+    } else if (type === 'gateway.reset') {
+      // Without a replay barrier, a resume body can overlap deferred deltas.
+      // Accept full snapshots until the terminal boundary, never append bytes
+      // whose position cannot be established. The idle history job fills gaps.
+      state.resyncing = true
+      state.liveStatus = '正在恢复回复'
+    } else if (['approval.request', 'approval.requested'].includes(type)) {
+      state.pendingApproval = p
+      state.running = true
+    } else if (type === 'approval.resolved') state.pendingApproval = null
+    else if (['clarify.request', 'clarify.requested'].includes(type)) {
+      state.pendingClarification = p
+      state.running = true
+    } else if (['clarify.resolved', 'clarify.expire'].includes(type)) state.pendingClarification = null
+    else if (type === 'status.update') {
+      state.liveStatus = text(p)
+      if (p.kind === 'warn') {
+        const source = `status:${state.turn ?? 'session'}:warning`
+        this.put(scope, {
+          id: this.alias(...scope, `source:${source}`) ?? source,
+          role: 'system',
+          turn_id: state.turn,
+          segment_kind: 'system',
+          content: text(p),
+          status: 'complete',
+          display_kind: 'live_warning',
+          final_result: false,
+        })
+      }
+    } else if (
+      [
+        'compression.started',
+        'compression.completed',
+        'session.command',
+        'session.workspace.updated',
+      ].includes(type)
+    ) {
+      const source = `system:${state.turn ?? 'session'}:${type.startsWith('compression.') ? 'compression' : type}`
+      const content =
+        type === 'compression.started'
+          ? '正在压缩上下文…'
+          : type === 'compression.completed'
+            ? '上下文压缩完成,继续回复'
+            : text(p)
+      if (content)
+        this.put(scope, {
+          id: this.alias(...scope, `source:${source}`) ?? source,
+          role: 'system',
+          turn_id: state.turn,
+          segment_kind: 'system',
+          content,
+          status: type.endsWith('started') ? 'streaming' : 'complete',
+          display_kind: type.startsWith('compression.') ? 'live_compression' : 'live_status',
+          final_result: false,
+        })
+    }
+    if (type === 'session.info') state.liveStatus = state.liveStatus ?? null
+    this.db
+      .prepare('UPDATE chat_transcript_heads SET data=? WHERE owner=? AND profile=? AND session_id=?')
+      .run(JSON.stringify(state), ...scope)
+    if (before !== JSON.stringify(state) || ['session.info', 'history.synced'].includes(type))
+      this.event(...scope, 'session.changed', {
+        eventType: type,
+        payload: type === 'session.info' ? p : {},
+        ...controlState(state),
+      })
+  }
+  migrate(owner: string, profile: string, oldID: string, newID: string) {
+    this.event(owner, profile, oldID, 'session.migrated', { sessionId: newID })
+    for (const table of [
+      'chat_transcript_messages',
+      'chat_transcript_aliases',
+      'chat_transcript_scopes',
+      'chat_transcript_execution',
+      'chat_transcript_heads',
+      'chat_transcript_reads',
+      'chat_transcript_source_cursors',
+      'chat_transcript_repairs',
+      'chat_transcript_archives',
+    ])
+      this.db
+        .prepare(`UPDATE ${table} SET session_id=? WHERE owner=? AND profile=? AND session_id=?`)
+        .run(newID, owner, profile, oldID)
+  }
+  remove(owner: string, profile: string, sessionId: string) {
+    this.event(owner, profile, sessionId, 'session.deleted', { sessionId })
+    for (const table of [
+      'chat_transcript_messages',
+      'chat_transcript_aliases',
+      'chat_transcript_scopes',
+      'chat_transcript_execution',
+      'chat_transcript_heads',
+      'chat_transcript_reads',
+      'chat_transcript_source_cursors',
+      'chat_transcript_repairs',
+      'chat_transcript_archives',
+    ])
+      this.db
+        .prepare(`DELETE FROM ${table} WHERE owner=? AND profile=? AND session_id=?`)
+        .run(owner, profile, sessionId)
+  }
+  cursor(owner: string, profile: string, sessionId: string): number {
+    return Number(
+      this.db
+        .prepare(
+          'SELECT COALESCE(MAX(cursor),0) cursor FROM chat_transcript_events WHERE owner=? AND profile=? AND session_id=?',
+        )
+        .get(owner, profile, sessionId)!.cursor,
+    )
+  }
+  events(owner: string, profile: string, sessionId: string, after: number, limit = 250): TranscriptEvent[] {
+    return (
+      this.db
+        .prepare(
+          'SELECT cursor,type,data FROM chat_transcript_events WHERE owner=? AND profile=? AND session_id=? AND cursor>? ORDER BY cursor LIMIT ?',
+        )
+        .all(owner, profile, sessionId, after, limit) as {
+        cursor: number
+        type: TranscriptEvent['type']
+        data: string
+      }[]
+    ).map((row) => ({ ...row, profile, sessionId, ...JSON.parse(row.data) }))
+  }
+  snapshot(
+    owner: string,
+    profile: string,
+    sessionId: string,
+    session: Record<string, unknown>,
+    state: string,
+    before = Number.MAX_SAFE_INTEGER,
+    limit = 150,
+  ): TranscriptSnapshot {
+    const run = this.head(owner, profile, sessionId)
+    const coverage = this.db
+      .prepare(
+        'SELECT complete,message_total FROM chat_sessions WHERE owner=? AND profile=? AND session_id=?',
+      )
+      .get(owner, profile, sessionId)
+    const incomplete = coverage?.complete === 0
+    const rows = this.db
+      .prepare(
+        'SELECT data FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=? AND deleted=0 AND seq<? ORDER BY seq DESC,id DESC LIMIT ?',
+      )
+      .all(owner, profile, sessionId, before, limit + 1) as { data: string }[]
+    return {
+      protocol: CHAT_TRANSCRIPT_FEATURE,
+      epoch: this.epochFor(owner, profile, sessionId),
+      cursor: this.cursor(owner, profile, sessionId),
+      profile,
+      sessionId,
+      running: run.running,
+      queued: run.queued,
+      pendingApproval: run.pendingApproval,
+      pendingClarification: run.pendingClarification,
+      liveStatus: run.liveStatus,
+      error: run.error,
+      messages: rows
+        .slice(0, limit)
+        .reverse()
+        .map((row) => JSON.parse(row.data)),
+      session,
+      state,
+      deletedIds: (
+        this.db
+          .prepare(
+            'SELECT id FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=? AND deleted=1',
+          )
+          .all(owner, profile, sessionId) as { id: string }[]
+      ).map((row) => row.id),
+      hasOlder: rows.length > limit || (incomplete && before > 1),
+      total: Math.max(
+        this.total(owner, profile, sessionId),
+        incomplete ? Number(coverage?.message_total ?? 0) : 0,
+      ),
+    }
   }
 }

+ 4 - 1
src/server/realtimeApi.ts

@@ -13,6 +13,7 @@ import { HttpError } from './errors.js'
 import { configuredWorkingDirectory } from './sessionWorkingDirectory.js'
 import { canonicalEpoch, groupCursor } from './realtimeProtocol.js'
 import { CHAT_MAX_PAYLOAD } from './realtimeProtocol.js'
+import { CHAT_TRANSCRIPT_FEATURE } from '../shared/chatTranscript.js'
 import { ChatPushRelayObserver, type ChatNotificationResolver, type PushEventCoordinator, type ChatPushTransportFactory } from './pushEvents.js'
 
 export class RealtimeAPI {
@@ -170,7 +171,7 @@ export class RealtimeAPI {
       const path = match![2]!
       if (ctx.method === 'GET' && path === '/capabilities') {
         ctx.body = { protocolVersion: 1, channels: ['chat'], brokerEpoch: this.broker.epoch,
-          features: this.transcriptsAvailable && !device && !ctx.state.hermesBotNative && !ctx.state.workspaceAgentExport ? ['ordinary-chat-transcript-v1'] : [],
+          features: this.transcriptsAvailable && !device && !ctx.state.hermesBotNative && !ctx.state.workspaceAgentExport ? [CHAT_TRANSCRIPT_FEATURE] : [],
           ...(device ? {} : { csrfToken: this.csrf.issue(ctx) }) }
         return
       }
@@ -179,6 +180,8 @@ export class RealtimeAPI {
         const body = await this.body(ctx)
         if (ctx.state.workspaceAgentExport && body.channel !== 'chat') throw new HttpError(403,'机器人仅支持聊天通道','invalid_channel')
         if (body.channel !== 'chat' && body.channel !== 'groups') throw new HttpError(400, 'Invalid channel', 'invalid_channel')
+        if(this.transcriptsAvailable&&body.channel==='chat'&&!device&&!principal.nativeBot&&!ctx.state.workspaceAgentExport&&body.chatProtocol!==CHAT_TRANSCRIPT_FEATURE)
+          throw new HttpError(426,'普通聊天协议已升级,请更新客户端后继续','ordinary_chat_upgrade_required')
         const c = await this.broker.create(principal, body.channel, body.channel === 'groups'
           ? { epoch: canonicalEpoch(body.epoch), cursor: groupCursor(body.cursor) } : undefined)
         ctx.status = 201; ctx.body = { id: c.id, brokerEpoch: this.broker.epoch }; return

+ 18 - 7
src/server/realtimeBroker.ts

@@ -50,7 +50,8 @@ export class RealtimeBroker {
   onNativeEvent: (owner: string, profile: string, storedId: string, frame: Frame) => void = () => {}
   onNativeGlobalEvent: (owner: string, type: string, upstreamInvalidation?: boolean) => void = () => {}
   onNativeCommand: (owner: string, profile: string, storedId: string, method: string, params: Frame) => void = () => {}
-  onNativeRoute: (owner: string, profile: string, storedId: string, runtimeId: string, running?: boolean) => void = () => {}
+  onNativeRoute: (owner: string, profile: string, storedId: string, runtimeId: string, running?: boolean, snapshot?: Frame) => void = () => {}
+  nativeSourceCursor: (owner:string,profile:string,storedId:string,runtimeId:string,epoch:string)=>number = () => 0
   readonly epoch = randomUUID()
   readonly channels = new Map<string, RealtimeChannel>()
   private upstreams = new Map<string, Upstream>()
@@ -283,7 +284,10 @@ export class RealtimeBroker {
         r.cwd = typeof info.cwd === 'string' ? info.cwd : undefined
         u.routes.set(key, r); u.byRuntime.set(runtime, r); c.routes.add(key)
         r.observers.set(c.principal.key, c.principal)
-        if (nativeOwner) this.onNativeRoute(nativeOwner, profile, stored, runtime, r.active)
+        if (nativeOwner) {
+          r.seq=Math.max(r.seq,this.nativeSourceCursor(nativeOwner,profile,stored,runtime,u.epoch??''))
+          this.onNativeRoute(nativeOwner, profile, stored, runtime, r.active, result)
+        }
         if (nativeOwner && (method === 'session.create' || method === 'session.branch')) {
           this.onNativeCommand(nativeOwner, profile, stored, method, {...p,_delivery_id:deliveryID})
         }
@@ -433,13 +437,13 @@ export class RealtimeBroker {
       if (p.payload?.request_id) this.interactions.set(`${u.principal.upstreamKey}:${p.payload.request_id}`, sid)
       while (this.interactions.size > 10_000) this.interactions.delete(this.interactions.keys().next().value!)
     }
-    const deliveryID = Number.isSafeInteger(p.seq) ? `${this.epoch}:${sid}:${p.seq}` : randomUUID()
+    const deliveryID = Number.isSafeInteger(p.seq) ? `${u.epoch??this.epoch}:${sid}:${p.seq}` : randomUUID()
     const persistedOwners = new Set<string>()
     if (r) for (const principal of r.observers.values()) {
       if (principal.valid()) {
         principal.observeEvent?.(JSON.stringify(f))
         const owner = this.nativeOwner(principal)
-        if (owner && !persistedOwners.has(owner)) { persistedOwners.add(owner); const projected={...p,delivery_id:deliveryID,epoch:this.epoch}; this.onNativeEvent(owner, r.profile, r.stored, projected); p.payload=projected.payload }
+        if (owner && !persistedOwners.has(owner)) { persistedOwners.add(owner); this.onNativeEvent(owner, r.profile, r.stored, {...p,delivery_id:deliveryID,epoch:u.epoch??this.epoch}) }
       }
     }
     if (!r && p.type === 'sessions.changed') {
@@ -493,13 +497,20 @@ export class RealtimeBroker {
         r.workingDirectory = undefined
         r.cwd = typeof resumed.result?.info?.cwd === 'string' ? resumed.result.info.cwd : undefined
         if (typeof resumed.result?.running === 'boolean') r.active = resumed.result.running
+        const restore=(snapshot:boolean)=>{
+          for(const principal of r.observers.values()){
+            const owner=this.nativeOwner(principal)
+            if(owner&&principal.valid()&&resumed.result)this.onNativeRoute(owner,r.profile,r.stored,String(resumed.result.session_id??r.runtime),r.active,
+              snapshot?resumed.result:{...resumed.result,inflight:undefined})
+          }
+        }
         if (!resumed.result || resumed.result.session_id !== r.runtime || changed) {
           if (resumed.result?.session_id) { u.byRuntime.delete(r.runtime); r.runtime = resumed.result.session_id; u.byRuntime.set(r.runtime, r) }
-          r.seq = 0; this.reset(u, 'upstream_reset'); continue
+          r.seq = 0; this.reset(u, 'upstream_reset'); restore(true); continue
         }
         const replay = await this.rpc(u, 'session.events.since', { session_id: r.runtime, last_seen: lastSeen })
-        if (replay.error || replay.result?.truncated || replay.result?.epoch !== u.epoch) this.reset(u, 'upstream_replay_unavailable')
-        else for (const p of replay.result?.events ?? []) this.event(u, { jsonrpc: '2.0', method: 'event', params: p }, true)
+        if (replay.error || replay.result?.truncated || replay.result?.epoch !== u.epoch) {this.reset(u, 'upstream_replay_unavailable');restore(true)}
+        else {for (const p of replay.result?.events ?? []) this.event(u, { jsonrpc: '2.0', method: 'event', params: p }, true);restore(false)}
       }
     } catch { this.reset(u, 'upstream_recovery_failed') }
     finally {

+ 73 - 20
src/shared/chatTranscript.ts

@@ -1,11 +1,13 @@
 /** Ordinary-chat display protocol. Upstream identities remain server metadata. */
-export const CHAT_TRANSCRIPT_FEATURE = 'ordinary-chat-transcript-v1'
+export const CHAT_TRANSCRIPT_FEATURE = 'ordinary-chat-transcript-v2'
 export interface TranscriptMessage {
   id: string
   seq: number
   revision: number
   source_message_id: string
   client_message_id?: string
+  turn_id?: string
+  segment_kind?: 'input' | 'commentary' | 'answer' | 'tool' | 'system'
   role: string
   content: unknown
   reasoning?: string
@@ -13,47 +15,98 @@ export interface TranscriptMessage {
   [key: string]: unknown
 }
 export interface TranscriptPatch {
-  id: string; seq: number; baseRevision: number; revision: number
-  contentAppend: string; reasoningAppend: string
+  id: string
+  seq: number
+  baseRevision: number
+  revision: number
+  contentAppend: string
+  reasoningAppend: string
 }
 export interface TranscriptEvent {
-  cursor: number; profile: string; sessionId: string
-  type: 'message.upsert' | 'message.patch' | 'message.deleted' | 'session.changed' | 'session.deleted' | 'session.migrated'
+  cursor: number
+  profile: string
+  sessionId: string
+  epoch?: string
+  previousCursor?: number
+  total?: number
+  type:
+    | 'message.upsert'
+    | 'message.patch'
+    | 'message.deleted'
+    | 'session.changed'
+    | 'session.deleted'
+    | 'session.migrated'
   data: unknown
 }
 export interface TranscriptSnapshot {
-  protocol: typeof CHAT_TRANSCRIPT_FEATURE; epoch: string; cursor: number
-  profile: string; sessionId: string; messages: TranscriptMessage[]
-  session: Record<string, unknown>; hasOlder: boolean; total: number
+  protocol: typeof CHAT_TRANSCRIPT_FEATURE
+  epoch: string
+  cursor: number
+  profile: string
+  sessionId: string
+  messages: TranscriptMessage[]
+  session: Record<string, unknown>
+  hasOlder: boolean
+  total: number
   state: string
   deletedIds?: string[]
   running?: boolean
   queued?: boolean
+  pendingApproval?: Record<string, unknown> | null
+  pendingClarification?: Record<string, unknown> | null
+  liveStatus?: string | null
+  error?: string | null
 }
 export class TranscriptGap extends Error {
-  constructor() { super('普通聊天消息版本不连续,需要重新同步') }
+  constructor() {
+    super('普通聊天消息版本不连续,需要重新同步')
+  }
 }
-export function applyTranscriptEvent(messages: TranscriptMessage[], event: TranscriptEvent): TranscriptMessage[] {
+export function applyTranscriptEvent(
+  messages: TranscriptMessage[],
+  event: TranscriptEvent,
+): TranscriptMessage[] {
   const data = event.data as TranscriptMessage & TranscriptPatch
-  const index = messages.findIndex(message => message.id === data.id)
+  const index = messages.findIndex((message) => message.id === data.id)
   const old = messages[index]
   if (event.type === 'message.deleted') {
-    if(!Number.isSafeInteger(data.revision)||data.revision<1)throw new TranscriptGap()
-    return old&&old.revision>=data.revision?messages:messages.filter(message => message.id !== data.id)
+    if (!Number.isSafeInteger(data.revision) || data.revision < 1) throw new TranscriptGap()
+    return old && old.revision >= data.revision
+      ? messages
+      : messages.filter((message) => message.id !== data.id)
   }
   if (event.type === 'message.upsert') {
-    if (!data.id || !Number.isSafeInteger(data.seq) || !Number.isSafeInteger(data.revision) || data.revision < 1) throw new TranscriptGap()
+    if (
+      !data.id ||
+      !Number.isSafeInteger(data.seq) ||
+      !Number.isSafeInteger(data.revision) ||
+      data.revision < 1
+    )
+      throw new TranscriptGap()
     if (old && old.revision >= data.revision) return messages
-    return [...messages.filter(message => message.id !== data.id), data].sort((a,b) => a.seq-b.seq || a.id.localeCompare(b.id))
+    return [...messages.filter((message) => message.id !== data.id), data].sort(
+      (a, b) => a.seq - b.seq || a.id.localeCompare(b.id),
+    )
   }
   if (event.type === 'message.patch') {
     if (old && old.revision >= data.revision) return messages
-    if (!old || old.revision !== data.baseRevision || data.revision !== data.baseRevision+1
-      || old.seq !== data.seq || typeof old.content !== 'string'
-      || typeof data.contentAppend !== 'string' || typeof data.reasoningAppend !== 'string') throw new TranscriptGap()
+    if (
+      !old ||
+      old.revision !== data.baseRevision ||
+      data.revision !== data.baseRevision + 1 ||
+      old.seq !== data.seq ||
+      typeof old.content !== 'string' ||
+      typeof data.contentAppend !== 'string' ||
+      typeof data.reasoningAppend !== 'string'
+    )
+      throw new TranscriptGap()
     const next = [...messages]
-    next[index] = {...old, revision:data.revision, content:old.content+data.contentAppend,
-      reasoning:String(old.reasoning??'')+data.reasoningAppend}
+    next[index] = {
+      ...old,
+      revision: data.revision,
+      content: old.content + data.contentAppend,
+      reasoning: String(old.reasoning ?? '') + data.reasoningAppend,
+    }
     return next
   }
   return messages

+ 233 - 236
tests/client/chatCrossClientSync.test.ts

@@ -1,278 +1,275 @@
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { createPinia, setActivePinia } from 'pinia'
-import type { RealtimeConnectionState, RpcEventFrame, SessionSummary } from '@shared/types'
-import { routeKey } from '@/utils/id'
-
-const realtime = vi.hoisted(() => ({
-  event: undefined as ((event: RpcEventFrame['params']) => void) | undefined,
-  state: undefined as ((state: RealtimeConnectionState) => void) | undefined,
+import type { TranscriptSnapshot } from '@shared/chatTranscript'
+import type { RpcEventFrame, RealtimeConnectionState } from '@shared/types'
+const wire = vi.hoisted(() => ({
+  event: undefined as ((e: RpcEventFrame['params']) => void) | undefined,
+  state: undefined as ((s: RealtimeConnectionState) => void) | undefined,
   request: vi.fn(),
+  clients: [] as any[],
+  owner: 0,
+  supported: true,
 }))
-const api = vi.hoisted(() => ({ getMessages: vi.fn() }))
+const api = vi.hoisted(() => ({ messages: vi.fn(), sessions: vi.fn() }))
 vi.mock('@/api/realtime', () => ({
   RpcError: class extends Error {},
   ChatRpcSocket: class {
-    onEvent(handler: typeof realtime.event) { realtime.event = handler }
-    onState(handler: typeof realtime.state) { realtime.state = handler }
-    async connect() { realtime.state?.('ready') }
+    get transcriptSupported() {
+      return wire.supported
+    }
+    onEvent(f: typeof wire.event) {
+      wire.event = f
+    }
+    onState(f: typeof wire.state) {
+      wire.state = f
+    }
+    async connect() {
+      wire.state?.('ready')
+    }
     close() {}
-    async request(...args: unknown[]) { return realtime.request(...args) }
+    async request(...args: unknown[]) {
+      return wire.request(...args)
+    }
+  },
+}))
+vi.mock('@/api/chatTranscript', () => ({
+  ChatTranscriptClient: class {
+    close = vi.fn()
+    loadOlder = vi.fn()
+    restore = vi.fn()
+    run = vi.fn()
+    constructor(
+      readonly profile: string,
+      readonly id: string,
+      readonly changed: (value: TranscriptSnapshot) => Promise<void>,
+      readonly failed: (error: Error) => void,
+    ) {
+      wire.clients.push(this)
+    }
   },
 }))
 vi.mock('@/api/sessions', () => ({
-  getMessages: api.getMessages, getSession: vi.fn(), getSessions: vi.fn(),
-  deleteSession: vi.fn(), getSessionUnread: vi.fn(), markSessionRead: vi.fn(), updateSession: vi.fn(),
+  getMessages: api.messages,
+  getSessions: api.sessions,
+  getSession: vi.fn(),
+  deleteSession: vi.fn(),
+  getSessionUnread: vi.fn(),
+  markSessionRead: vi.fn(),
+  updateSession: vi.fn(),
+  requestHistorySync: vi.fn(),
 }))
 vi.mock('@/api/profiles', () => ({ getModels: vi.fn() }))
 vi.mock('@/stores/auth', () => ({
   useAuthStore: () => ({
-    user: { id: 'viewer' }, activeProfile: { name: 'alpha' },
-    status: 'authenticated', isAuthenticated: true,
+    user: { id: `v2-viewer-${wire.owner}` },
+    activeProfile: { name: 'p' },
+    status: 'authenticated',
+    isAuthenticated: true,
   }),
 }))
-
 import { useChatStore } from '@/stores/chat'
-
-function session(id = 'stored-1'): SessionSummary {
-  return { id, profile: 'alpha', owned: true, source: 'ios', title: '跨端聊天',
-    messageCount: 0, toolCallCount: 0, startedAt: 1, updatedAt: 2 }
+function session(id = 's', owned = true) {
+  return {
+    id,
+    profile: 'p',
+    owned,
+    source: 'web',
+    title: id,
+    messageCount: 0,
+    toolCallCount: 0,
+    startedAt: 1,
+    updatedAt: 1,
+  }
 }
-function emit(type: string, payload: Record<string, unknown> = {}) {
-  realtime.event?.({ type, session_id: 'runtime-1', profile: 'alpha', payload: payload as never })
+function snapshot(messages: any[], id = 's', extra: Partial<TranscriptSnapshot> = {}): TranscriptSnapshot {
+  return {
+    protocol: 'ordinary-chat-transcript-v2',
+    epoch: 'test',
+    cursor: 1,
+    profile: 'p',
+    sessionId: id,
+    messages,
+    session: {},
+    hasOlder: false,
+    total: messages.length,
+    state: 'current',
+    running: false,
+    queued: false,
+    ...extra,
+  }
 }
-
-describe('cross-client chat subscriptions', () => {
+const answer = (content = '规范正文', extra = {}) => ({
+  id: 'canonical-answer',
+  seq: 2,
+  revision: 1,
+  source_message_id: 'upstream',
+  turn_id: 'turn',
+  role: 'assistant',
+  content,
+  status: 'complete',
+  ...extra,
+})
+function emit(type: string, payload: any = {}) {
+  wire.event?.({ type, session_id: 'runtime', profile: 'p', payload })
+}
+describe('ordinary v2 cross-client store', () => {
   let chat: ReturnType<typeof useChatStore>
   beforeEach(() => {
     setActivePinia(createPinia())
-    realtime.request.mockReset().mockResolvedValue({ session_id: 'runtime-1', stored_session_id: 'stored-1', running: false })
-    api.getMessages.mockReset().mockResolvedValue({ messages: [], total: 0, returned: 0, hasMore: false })
+    wire.owner++
+    wire.clients = []
+    wire.supported = true
+    wire.request
+      .mockReset()
+      .mockResolvedValue({ session_id: 'runtime', stored_session_id: 's', running: false })
+    api.messages.mockReset().mockResolvedValue({ messages: [], total: 0, returned: 0, hasMore: false })
+    api.sessions.mockReset().mockResolvedValue({ items: [session()] })
     chat = useChatStore()
     chat.sessions = [session()]
   })
-  afterEach(() => { chat.disconnect(); vi.useRealTimers() })
-
-  it('subscribes a viewer before another device starts, then shows thinking, tools, text and completion', async () => {
-    await chat.selectSession('stored-1', 'alpha')
-    expect(realtime.request).toHaveBeenCalledExactlyOnceWith('session.resume', {
-      session_id: 'stored-1', profile: 'alpha', source: 'web', omit_messages: true,
-    })
-    expect(chat.sessions[0].updatedAt).toBe(2)
-    emit('message.start')
-    emit('thinking.delta', { text: '正在检查' })
-    expect(chat.isStreaming).toBe(true)
-    expect(chat.activeRouteState?.liveStatus).toBe('正在检查')
-    emit('tool.start', { tool_id: 'tool-1', name: 'read_file' })
-    expect(chat.messages.at(-1)?.toolCalls?.[0]).toMatchObject({ name: 'read_file', status: 'running' })
-    emit('tool.complete', { tool_id: 'tool-1', name: 'read_file', result: 'ok' })
-    emit('message.delta', { text: '检查完成' })
-    expect(chat.messages.at(-1)?.content).toBe('检查完成')
-    emit('message.complete', { text: '检查完成' })
-    expect(chat.isStreaming).toBe(false)
-    expect(chat.activeRouteState?.liveStatus).toBeUndefined()
-    expect(chat.messages.at(-1)?.toolCalls?.[0].status).toBe('completed')
+  afterEach(() => {
+    chat.disconnect()
+    vi.useRealTimers()
   })
-
-  it('restores a run opened before its first text, so tool events are not dropped', async () => {
-    realtime.request.mockResolvedValue({ session_id: 'runtime-1', stored_session_id: 'stored-1',
-      running: true, inflight: { streaming: true, assistant: '' } })
-    await chat.selectSession('stored-1', 'alpha')
+  async function open(id = 's') {
+    await chat.selectSession(id, 'p')
+    await vi.waitFor(() => expect(wire.clients.some((c) => c.id === id)).toBe(true))
+    return wire.clients.filter((c) => c.id === id).at(-1)!
+  }
+  it('renders only canonical messages and ignores raw upstream bodies and completions', async () => {
+    const client = await open()
+    await client.changed(snapshot([answer('前半', { status: 'streaming' })], 's', { running: true }))
+    emit('message.delta', { text: '不应追加' })
+    emit('message.interim', { text: '不应重复' })
+    emit('message.complete', { text: '不应替换' })
+    expect(chat.messages.map((m) => m.content)).toEqual(['前半'])
     expect(chat.isStreaming).toBe(true)
-    expect(chat.messages).toHaveLength(1)
-    emit('tool.start', { tool_id: 'tool-1', name: 'terminal' })
-    expect(chat.messages[0].toolCalls?.[0].name).toBe('terminal')
-  })
-
-  it('restores a partial answer and appends later deltas without an extra row', async () => {
-    realtime.request.mockResolvedValue({ session_id: 'runtime-1', stored_session_id: 'stored-1',
-      running: true, inflight: { assistant: '前半段' } })
-    await chat.selectSession('stored-1', 'alpha')
-    emit('message.delta', { text: '后半段' })
-    expect(chat.messages.map(message => message.content)).toEqual(['前半段后半段'])
-  })
-
-  it('resubscribes an idle viewer after disconnection and discovers a remote run', async () => {
-    await chat.selectSession('stored-1', 'alpha')
+    await client.changed(snapshot([answer('完整正文', { revision: 2 })], 's', { cursor: 2 }))
+    expect(chat.messages.map((m) => m.content)).toEqual(['完整正文'])
     expect(chat.isStreaming).toBe(false)
-    realtime.request.mockResolvedValue({ session_id: 'runtime-1', stored_session_id: 'stored-1',
-      running: true, inflight: { assistant: '断线期间开始的回复' } })
-    vi.useFakeTimers()
-    realtime.state?.('failed')
-    await vi.advanceTimersByTimeAsync(500)
-    expect(realtime.request).toHaveBeenCalledTimes(2)
-    expect(chat.isStreaming).toBe(true)
-    expect(chat.messages.at(-1)?.content).toBe('断线期间开始的回复')
-  })
-
-  it('does not repeatedly resume a subscribed session when revisiting it', async () => {
-    await chat.selectSession('stored-1', 'alpha')
-    await chat.selectSession('stored-1', 'alpha')
-    expect(realtime.request).toHaveBeenCalledTimes(1)
-    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)
+    expect(api.messages).not.toHaveBeenCalled()
   })
-
-  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('does not materialize a resume snapshot beside the canonical reply', async () => {
+    wire.request.mockResolvedValue({
+      session_id: 'runtime',
+      stored_session_id: 's',
+      running: true,
+      inflight: { assistant: '旧恢复快照' },
+    })
+    const client = await open()
+    await client.changed(snapshot([answer('当前正文')]))
+    expect(chat.messages.map((m) => m.content)).toEqual(['当前正文'])
+    expect(chat.messages[0]!.id).toBe('canonical-answer')
   })
-
-  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('restores tools and pending interactions from the canonical snapshot', async () => {
+    const client = await open()
+    await client.changed(
+      snapshot(
+        [
+          answer('', {
+            status: 'streaming',
+            tool_calls: [{ id: 'tool', name: 'terminal', status: 'running' }],
+          }),
+        ],
+        's',
+        {
+          running: true,
+          pendingApproval: { request_id: 'approval', message: '允许执行' },
+          pendingClarification: { request_id: 'clarify', question: '请选择' },
+        },
+      ),
+    )
+    expect(chat.messages[0]!.toolCalls?.[0]?.id).toBe('tool')
+    expect(chat.pendingApproval?.id).toBe('approval')
+    expect(chat.pendingClarification?.id).toBe('clarify')
+    await client.changed(
+      snapshot([answer()], 's', { cursor: 2, pendingApproval: null, pendingClarification: null }),
+    )
+    expect(chat.pendingApproval).toBeUndefined()
+    expect(chat.pendingClarification).toBeUndefined()
   })
-
-  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('rejects a closed session callback after switching to another conversation', async () => {
+    const first = await open()
+    chat.sessions.push(session('second'))
+    wire.request.mockResolvedValue({ session_id: 'runtime-2', stored_session_id: 'second' })
+    const second = await open('second')
+    await second.changed(snapshot([answer('第二个会话')], 'second'))
+    await first.changed(snapshot([answer('旧回调')]))
+    expect(chat.activeSessionId).toBe('second')
+    expect(chat.messages[0]!.content).toBe('第二个会话')
   })
-
-  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 })
-    await chat.selectSession('identity-session', 'alpha')
-    let accept: (value: unknown) => void = () => {}
-    realtime.request.mockImplementation((method: string) => method === 'prompt.submit'
-      ? new Promise(resolve => { accept = resolve }) : Promise.resolve({}))
-    const sending = chat.send('查看服务器情况')
-    await vi.waitFor(() => expect(realtime.request.mock.calls.some(([method]) => method === 'prompt.submit')).toBe(true))
-    const local = chat.messages.find(message => message.role === 'user')!
-    const persisted = { ...local, id: '1155', serverMessageId: '1155', stage: 'settled' as const }
-    api.getMessages.mockResolvedValue({ messages: [persisted], total: 1, returned: 1, hasMore: false })
-    emit('sessions.changed', { reason: 'cache.synced', profile: 'alpha', session_id: 'identity-session' })
-    await vi.waitFor(() => expect(chat.messages[0]?.id).toBe('1155'))
+  it('does not let a late submit receipt re-open a completed turn or duplicate the user', async () => {
+    const client = await open()
+    let accept!: (value: any) => void
+    wire.request.mockImplementation((method: string) =>
+      method === 'prompt.submit'
+        ? new Promise((resolve) => {
+            accept = resolve
+          })
+        : Promise.resolve({}),
+    )
+    const sending = chat.send('问题')
+    await vi.waitFor(() => expect(accept).toBeTypeOf('function'))
+    const local = chat.messages.find((m) => m.role === 'user')!
+    await client.changed(
+      snapshot(
+        [
+          {
+            id: 'canonical-user',
+            seq: 1,
+            revision: 2,
+            source_message_id: 'persisted-user',
+            client_message_id: local.clientMessageId,
+            role: 'user',
+            content: '问题',
+            status: 'complete',
+          },
+          answer(),
+        ],
+        's',
+        { cursor: 3, running: false },
+      ),
+    )
     accept({ status: 'streaming' })
     await sending
-    expect(chat.messages.filter(message => message.role === 'user')).toHaveLength(1)
-    expect(chat.messages[0]).toMatchObject({ id: '1155', clientMessageId: local.clientMessageId, stage: 'settled' })
-  })
-
-  it('settles a run and clears an interaction completed while the viewer was offline', async () => {
-    await chat.selectSession('stored-1', 'alpha')
-    emit('message.start')
-    emit('message.delta', { text: '部分回复' })
-    emit('thinking.delta', { text: '等待确认' })
-    emit('approval.request', { request_id: 'approval-1' })
-    realtime.request.mockResolvedValue({ session_id: 'runtime-1', stored_session_id: 'stored-1',
-      running: false, inflight: { assistant: '完整回复', streaming: false } })
-    vi.useFakeTimers()
-    realtime.state?.('failed')
-    await vi.advanceTimersByTimeAsync(500)
+    expect(chat.messages.filter((m) => m.role === 'user')).toHaveLength(1)
+    expect(chat.messages[0]!.stage).toBe('settled')
     expect(chat.isStreaming).toBe(false)
-    expect(chat.messages).toHaveLength(1)
-    expect(chat.messages[0]).toMatchObject({ content: '完整回复', stage: 'settled', isStreaming: false })
-    expect(chat.activeRouteState?.liveStatus).toBeUndefined()
-    expect(chat.pendingApproval).toBeUndefined()
   })
-
-  it('does not duplicate completed history from a retained inactive snapshot', async () => {
-    api.getMessages.mockResolvedValue({ messages: [{ id: 'answer-1', sessionId: 'stored-1',
-      role: 'assistant', content: '已完成回复', timestamp: 1, stage: 'settled' }], total: 1, returned: 1, hasMore: false })
-    realtime.request.mockResolvedValue({ session_id: 'runtime-1', stored_session_id: 'stored-1',
-      running: false, inflight: { assistant: '已完成回复', streaming: false } })
-    await chat.selectSession('stored-1', 'alpha')
-    expect(chat.messages.map(message => message.id)).toEqual(['answer-1'])
+  it('keeps a pending local input during a new generation replacement', async () => {
+    const client = await open()
+    let reject!: (error: Error) => void
+    wire.request.mockImplementation((method: string) =>
+      method === 'prompt.submit'
+        ? new Promise((_resolve, r) => {
+            reject = r
+          })
+        : Promise.resolve({}),
+    )
+    const sending = chat.send('待确认').catch(() => {})
+    await vi.waitFor(() => expect(reject).toBeTypeOf('function'))
+    const local = chat.messages[0]!.id
+    await client.changed(snapshot([answer('历史重建')], 's', { epoch: 'rebuilt' }))
+    expect(chat.messages.some((m) => m.id === local)).toBe(true)
+    reject(new Error('connection lost'))
+    await sending
   })
-
-  it.each([false, undefined])('keeps history-only sessions unattached with owned=%s', async owned => {
-    chat.sessions[0].owned = owned
-    await chat.selectSession('stored-1', 'alpha')
-    realtime.state?.('ready')
-    expect(realtime.request).not.toHaveBeenCalled()
+  it('uses transcript pagination without requesting legacy message pages', async () => {
+    const client = await open()
+    await client.changed(snapshot([answer()], 's', { hasOlder: true }))
+    await chat.loadOlder()
+    expect(client.loadOlder).toHaveBeenCalledOnce()
+    expect(api.messages).not.toHaveBeenCalled()
   })
-
-  it('never creates a runtime for an unsent draft on ready', () => {
-    const id = chat.createSession('alpha')
-    chat.routes[routeKey('alpha', id)].historySynced = true
-    realtime.state?.('ready')
-    expect(realtime.request).not.toHaveBeenCalled()
+  it('requires v2 and never falls back to legacy history for owned chat', async () => {
+    wire.supported = false
+    await expect(chat.selectSession('s', 'p')).rejects.toThrow('升级服务端')
+    expect(api.messages).not.toHaveBeenCalled()
   })
-
-  it('retains readable history when subscribing fails and can retry on selection', async () => {
-    realtime.request.mockRejectedValueOnce(new Error('connection lost'))
-    await chat.selectSession('stored-1', 'alpha')
-    expect(chat.historySynced).toBe(true)
-    expect(chat.activeRouteState?.error).toBe('connection lost')
-    await chat.selectSession('stored-1', 'alpha')
-    expect(chat.activeRouteState?.runtimeSessionId).toBe('runtime-1')
+  it('keeps read-only native history outside the ordinary transcript', async () => {
+    chat.sessions = [session('s', false)]
+    await chat.selectSession('s', 'p')
+    expect(api.messages).toHaveBeenCalledOnce()
+    expect(wire.request).not.toHaveBeenCalled()
   })
 })

+ 2 - 6
tests/client/chatLifecycle.test.ts

@@ -5,15 +5,14 @@ import { normalizeChatMessage } from '@/utils/normalize'
 import { reconcileChatHistory } from '@/utils/chatHistory'
 import { chatMessagesToUi } from '@/components/workspace/viewModels'
 import { buildMessageTimelineRows } from '@/utils/turnTrace'
-import { projectChatEvent } from '../../src/server/chatEventProjection'
 
 function state(): ChatRouteState { return { route: { sessionId: 's', profile: 'p' }, messages: [], isStreaming: false, isQueued: false,
   historySynced: true, hasMoreBefore: false, loadedMessageCount: 0, messageTotal: 0, isLoadingHistory: false, generation: 1 } }
 const user = (id: string): ChatMessage => ({ id, sessionId: 's', role: 'user', content: 'pwd', timestamp: 1, stage: 'settled' })
 
 describe('ordinary tool lifecycle and authoritative history', () => {
-  it('counts one invocation across generating/start/complete in both realtime and durable projection', () => {
-    let current = state(), durable: Record<string, any> | undefined
+  it('counts one invocation across generating/start/complete in legacy history presentation', () => {
+    let current = state()
     for (const [type, payload] of [
       ['message.start', {}], ['tool.generating', { name: 'terminal' }],
       ['tool.start', { tool_id: 'call-1', name: 'terminal', args: { command: 'pwd' } }],
@@ -21,11 +20,8 @@ describe('ordinary tool lifecycle and authoritative history', () => {
       ['message.complete', { text: '/work', status: 'complete' }],
     ] as Array<[string, any]>) {
       current = applyChatEvent(current, { type, payload })
-      durable = projectChatEvent(type, payload, durable, 'event:1', 1000) ?? durable
     }
     expect(current.messages.flatMap(message => message.toolCalls ?? [])).toHaveLength(1)
-    expect(durable!.tool_calls).toHaveLength(1)
-    expect(durable!.tool_calls[0].status).toBe('completed')
     expect(buildMessageTimelineRows(chatMessagesToUi(current.messages)).filter(row => row.kind === 'trace').map(row => row.status)).toEqual(['success'])
   })
 

+ 1 - 1
tests/client/chatProtocol.test.ts

@@ -11,7 +11,7 @@ function httpFixture() {
   const send = (frame: object) => stream!.enqueue(new TextEncoder().encode('event: frame\ndata: ' + JSON.stringify(frame) + '\n\n'))
   const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
     const path = String(input)
-    if (path.endsWith('/capabilities')) return Response.json({ protocolVersion: 1, csrfToken: 'csrf-test' })
+    if (path.endsWith('/capabilities')) return Response.json({ protocolVersion: 1, csrfToken: 'csrf-test', features:['ordinary-chat-transcript-v2'] })
     if (init?.method === 'DELETE') return new Response(null, { status: 204 })
     if (path.endsWith('/channels')) {
       const body = JSON.parse(String(init?.body)); kind = body.channel; epoch = body.epoch; cursor = body.cursor

+ 90 - 0
tests/client/ordinaryTranscriptV2.test.ts

@@ -0,0 +1,90 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import fixture from '../fixtures/ordinary-chat-v2/protocol.json'
+import { ChatTranscriptClient } from '@/api/chatTranscript'
+import type { TranscriptSnapshot } from '@shared/chatTranscript'
+const api = vi.hoisted(() => ({ request: vi.fn() }))
+vi.mock('@/api/client', () => ({ apiRequest: api.request }))
+describe('ordinary v2 durable Web replica', () => {
+  beforeEach(() => {
+    api.request.mockReset()
+  })
+  function setup() {
+    const changed = vi.fn().mockResolvedValue(undefined),
+      client = new ChatTranscriptClient('p', 's', changed, vi.fn()) as any
+    client.restore(structuredClone(fixture.snapshot))
+    return { client, changed }
+  }
+  it('converges on the common fixture and does not resurrect a deletion from a delayed page', async () => {
+    const { client, changed } = setup()
+    let release!: (value: unknown) => void
+    api.request.mockImplementation(
+      () =>
+        new Promise((resolve) => {
+          release = resolve
+        }),
+    )
+    const older = client.loadOlder()
+    for (const event of fixture.events) await client.receive(event)
+    release(fixture.stalePage)
+    await older
+    expect(changed.mock.lastCall![0]).toMatchObject({
+      cursor: 4,
+      messages: fixture.expectedMessages,
+      deletedIds: ['old-preview'],
+    })
+  })
+  it('does not advance messages or cursor when persistence fails, and can retry exactly once', async () => {
+    const { client, changed } = setup()
+    changed.mockRejectedValueOnce(new Error('disk full'))
+    await expect(client.receive(fixture.events[0])).rejects.toThrow('disk full')
+    expect(client.snapshot).toEqual(fixture.snapshot)
+    await client.receive(fixture.events[0])
+    await client.receive(fixture.events[0])
+    expect(client.snapshot.messages[0].content).toBe('前半段后半段')
+    expect(client.snapshot.cursor).toBe(2)
+  })
+  it('rejects gaps and old generations without corrupting its checkpoint', async () => {
+    const { client } = setup()
+    await expect(client.receive(fixture.events[2])).rejects.toThrow('版本不连续')
+    await expect(client.receive({ ...fixture.events[0], epoch: 'old' })).rejects.toThrow('版本不连续')
+    expect(client.snapshot).toEqual(fixture.snapshot)
+  })
+  it('discards v1 checkpoints rather than merging them into v2', () => {
+    const client = new ChatTranscriptClient(
+      'p',
+      's',
+      async () => {},
+      () => {},
+    ) as any
+    client.restore({
+      ...fixture.snapshot,
+      protocol: 'ordinary-chat-transcript-v1',
+    } as unknown as TranscriptSnapshot)
+    expect(client.snapshot).toBeUndefined()
+  })
+  it('reopens an inactive event connection without dropping the durable cursor', async () => {
+    vi.useFakeTimers()
+    const { client } = setup()
+    const fetchMock = vi.fn(
+      (_url: unknown, options: RequestInit) =>
+        new Promise((_resolve, reject) => {
+          options.signal?.addEventListener('abort', () => reject(new Error('inactive stream')), {
+            once: true,
+          })
+        }),
+    )
+    vi.stubGlobal('fetch', fetchMock)
+    const running = client.run()
+    try {
+      await vi.advanceTimersByTimeAsync(46_000)
+      expect(fetchMock).toHaveBeenCalledTimes(2)
+      expect(client.snapshot.cursor).toBe(1)
+      expect(fetchMock.mock.calls[1]![1].headers).toMatchObject({ 'Last-Event-ID': 'fixture-epoch:1' })
+    } finally {
+      client.close()
+      await running
+      vi.unstubAllGlobals()
+      vi.useRealTimers()
+    }
+  })
+})

+ 135 - 0
tests/fixtures/ordinary-chat-v2/protocol.json

@@ -0,0 +1,135 @@
+{
+  "protocol": "ordinary-chat-transcript-v2",
+  "snapshot": {
+    "protocol": "ordinary-chat-transcript-v2",
+    "epoch": "fixture-epoch",
+    "cursor": 1,
+    "profile": "p",
+    "sessionId": "s",
+    "messages": [
+      {
+        "id": "answer",
+        "source_message_id": "upstream-a",
+        "turn_id": "turn-1",
+        "segment_kind": "answer",
+        "seq": 2,
+        "revision": 1,
+        "role": "assistant",
+        "content": "前半段",
+        "reasoning": "思考",
+        "status": "streaming"
+      }
+    ],
+    "session": {},
+    "hasOlder": true,
+    "total": 2,
+    "state": "current",
+    "running": true,
+    "queued": false
+  },
+  "events": [
+    {
+      "cursor": 2,
+      "previousCursor": 1,
+      "epoch": "fixture-epoch",
+      "profile": "p",
+      "sessionId": "s",
+      "type": "message.patch",
+      "total": 2,
+      "data": {
+        "id": "answer",
+        "seq": 2,
+        "baseRevision": 1,
+        "revision": 2,
+        "contentAppend": "后半段",
+        "reasoningAppend": "继续"
+      }
+    },
+    {
+      "cursor": 2,
+      "previousCursor": 1,
+      "epoch": "fixture-epoch",
+      "profile": "p",
+      "sessionId": "s",
+      "type": "message.patch",
+      "total": 2,
+      "data": {
+        "id": "answer",
+        "seq": 2,
+        "baseRevision": 1,
+        "revision": 2,
+        "contentAppend": "后半段",
+        "reasoningAppend": "继续"
+      }
+    },
+    {
+      "cursor": 3,
+      "previousCursor": 2,
+      "epoch": "fixture-epoch",
+      "profile": "p",
+      "sessionId": "s",
+      "type": "message.upsert",
+      "total": 2,
+      "data": {
+        "id": "answer",
+        "source_message_id": "persisted-42",
+        "turn_id": "turn-1",
+        "segment_kind": "answer",
+        "seq": 2,
+        "revision": 3,
+        "role": "assistant",
+        "content": "前半段后半段",
+        "reasoning": "思考继续",
+        "status": "complete",
+        "final_result": true
+      }
+    },
+    {
+      "cursor": 4,
+      "previousCursor": 3,
+      "epoch": "fixture-epoch",
+      "profile": "p",
+      "sessionId": "s",
+      "type": "message.deleted",
+      "total": 1,
+      "data": { "id": "old-preview", "seq": 1, "revision": 2 }
+    }
+  ],
+  "stalePage": {
+    "protocol": "ordinary-chat-transcript-v2",
+    "epoch": "fixture-epoch",
+    "cursor": 1,
+    "profile": "p",
+    "sessionId": "s",
+    "messages": [
+      {
+        "id": "old-preview",
+        "seq": 1,
+        "revision": 1,
+        "role": "assistant",
+        "content": "前半段",
+        "status": "complete"
+      }
+    ],
+    "deletedIds": [],
+    "session": {},
+    "hasOlder": false,
+    "total": 2,
+    "state": "current"
+  },
+  "expectedMessages": [
+    {
+      "id": "answer",
+      "source_message_id": "persisted-42",
+      "turn_id": "turn-1",
+      "segment_kind": "answer",
+      "seq": 2,
+      "revision": 3,
+      "role": "assistant",
+      "content": "前半段后半段",
+      "reasoning": "思考继续",
+      "status": "complete",
+      "final_result": true
+    }
+  ]
+}

+ 12 - 12
tests/server/chatCache.test.ts

@@ -354,12 +354,12 @@ describe('durable source=web chat cache', () => {
       pagination: { total: 2, returned: 2, offset: 0, limit: 100, hasMore: false },
     }))
 
-    const page = f.store.messagePage(owner, profile, sessionID, 0, 100)
+    const page = f.store.sourceMessagePage(owner, profile, sessionID, 0, 100)
     const payload = JSON.parse(page!.response.body.toString())
     expect(payload.session_id).toBe(sessionID)
     expect(payload.pagination).toMatchObject({ total: 2, returned: 2, offset: 0, limit: 100, has_more: false })
     expect(payload.messages.map((item: { id: string }) => item.id)).toEqual(['m1', 'm2'])
-    expect(f.store.messagePage('another-owner', profile, sessionID, 0, 100)).toBeUndefined()
+    expect(f.store.sourceMessagePage('another-owner', profile, sessionID, 0, 100)).toBeUndefined()
     f.store.close()
   })
 
@@ -465,7 +465,7 @@ describe('durable source=web chat cache', () => {
       pagination: { total: 1, returned: 1, offset: 0, limit: 100, hasMore: false },
     }))).toBe(true)
 
-    const page = f.store.messagePage(owner, profile, ownedSessionID, 0, 100)
+    const page = f.store.sourceMessagePage(owner, profile, ownedSessionID, 0, 100)
     expect(JSON.parse(page!.response.body.toString())).toMatchObject({
       session_id: ownedSessionID,
       session: { source: 'ios', title: 'iOS 权威标题' },
@@ -648,10 +648,10 @@ describe('local history and bounded tail synchronization', () => {
     coordinator.observe(owner, profile, sessionID, { type: 'message.complete', seq: 1 })
     await coordinator.reconcile(owner, profile, sessionID)
     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(JSON.parse(page.response.body.toString()).messages.at(-1).source_message_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()
+    expect(f.store.sourceMessagePage(owner, profile, sessionID, 902, 100)).toBeDefined()
     coordinator.observe(owner, profile, sessionID, { type: 'message.complete', seq: 1 })
     expect(request).toHaveBeenCalledTimes(2)
     f.store.close()
@@ -663,7 +663,7 @@ describe('local history and bounded tail synchronization', () => {
     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)
+    expect(JSON.parse(f.store.sourceMessagePage(owner, profile, sessionID, 0, 10)!.response.body.toString()).pagination.total).toBe(1130)
     f.store.close()
   })
 
@@ -672,11 +672,11 @@ describe('local history and bounded tail synchronization', () => {
     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')
+    expect(JSON.parse(f.store.sourceMessagePage(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())
+    const page = JSON.parse(f.store.sourceMessagePage(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)
@@ -687,8 +687,8 @@ describe('local history and bounded tail synchronization', () => {
     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()
+    expect(f.store.sourceMessagePage(owner, profile, sessionID, 0, 1)).toBeDefined()
+    expect(f.store.sourceMessagePage(owner, profile, sessionID, 1, 100)).toBeUndefined()
     f.store.close()
   })
 
@@ -701,7 +701,7 @@ describe('local history and bounded tail synchronization', () => {
       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()
+    expect(f.store.sourceMessagePage(owner, profile, sessionID, 900, 100)).toBeDefined()
     f.store.close()
   })
 
@@ -732,7 +732,7 @@ describe('local history and bounded tail synchronization', () => {
       return original(path, options)
     })
     const published = vi.fn(() => {
-      const stored = JSON.parse(f.store.messagePage(owner, profile, sessionID, 0, 1)!.response.body.toString())
+      const stored = JSON.parse(f.store.sourceMessagePage(owner, profile, sessionID, 0, 1)!.response.body.toString())
       expect(stored.messages[0].id).toBe('newest')
     })
     coordinator.onSynchronized = published

+ 4 - 4
tests/server/chatCachePagination.test.ts

@@ -32,7 +32,7 @@ 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 page = f.store.sourceMessagePage('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')
@@ -45,9 +45,9 @@ describe('Hermes pagination without total and has_more', () => {
     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())
+    const payload = JSON.parse(f.store.sourceMessagePage('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()
+    expect(f.store.sourceMessagePage('owner', 'default', 'chat', 100, 100)).toBeUndefined()
     f.store.close()
   })
 
@@ -56,7 +56,7 @@ describe('Hermes pagination without total and has_more', () => {
     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())
+    const page = JSON.parse(f.store.sourceMessagePage('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()

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

@@ -183,7 +183,7 @@ describe('source=web chat cache routes', () => {
     expect(ownedMessages.body).toMatchObject({
       owned: true,
       session: { id: 'owned-session', source: 'ios', owned: true },
-      messages: [{ id: 'owned-message' }],
+      messages: [{ source_message_id: 'owned-message' }],
     })
 
     const searched = await agent.get('/api/app/sessions/search?q=Hermes&profile=default')

+ 7 - 4
tests/server/chatMessageIdentity.test.ts

@@ -35,12 +35,14 @@ function submit(store: ChatCacheStore, client = 'local', at = now, options: Row
 }
 function row(id = '1155', at = now + 39): Row { return { id, role: 'user', content: text, timestamp: at / 1000 } }
 function sync(store: ChatCacheStore, messages: Row[], force = false) {
+  // History repair is admitted only after the execution route is idle.
+  store.recordEvent(...scope,{type:'route.resumed',payload:{running:false}})
   return store.applySync(...scope, store.revision(...scope), [{
     key: 'messages', kind: 'messages', response: response(messages), startedAt: now + 2000,
   }], force)
 }
 function read(store: ChatCacheStore, offset = 0, limit = 100): Row[] {
-  return JSON.parse(store.messagePage(...scope, offset, limit)!.response.body.toString()).messages
+  return JSON.parse(store.sourceMessagePage(...scope, offset, limit)!.response.body.toString()).messages
 }
 afterEach(() => {
   vi.restoreAllMocks()
@@ -110,9 +112,9 @@ describe('ordinary chat submission identities', () => {
   it('keeps an unconfirmed send when an older identical user message occupies its position', () => {
     const { store } = fixture()
     submit(store, 'pending', now, {}, 'pending')
-    expect(() => sync(store, [row('old', now - 60_000)]))
-      .toThrow('Unconfirmed submission must be reconciled')
-    expect(read(store)[0]).toMatchObject({ id: 'user:web:prompt:pending', status: 'pending' })
+    expect(sync(store, [row('old', now - 60_000)])).toBe(true)
+    expect(store.transcripts.all(...scope)).toContainEqual(expect.objectContaining({client_message_id:'pending',status:'pending'}))
+    expect(read(store)[0]).toMatchObject({id:'old'})
   })
 
   it('uses an explicit upstream identity for queued messages without timestamp heuristics', () => {
@@ -133,6 +135,7 @@ describe('ordinary chat submission identities', () => {
   it('does not bind the first page of an ambiguous multi-page refresh', () => {
     const { store } = fixture()
     submit(store)
+    store.recordEvent(...scope,{type:'route.resumed',payload:{running:false}})
     store.applySync(...scope, store.revision(...scope), [
       { key: 'tail', kind: 'messages', response: response([row('2', now + 40)], 0, 2), startedAt: now + 2000 },
       { key: 'older', kind: 'messages', response: response([row('1')], 1, 2), startedAt: now + 2000 },

+ 2 - 2
tests/server/chatTranscript.test.ts

@@ -55,7 +55,7 @@ describe('ordinary server-owned transcript',()=>{
     expect(events).toHaveLength(1);expect(events[0]!.type).toBe('message.patch')
     const replay=events.reduce(applyTranscriptEvent,base.messages)
     expect(replay).toEqual(snapshot(store).messages)
-    expect(replay[0]).toMatchObject({id,content:'中文🙂',revision:2})
+    expect(replay[0]).toMatchObject({id,content:'中文🙂',revision:base.messages[0]!.revision+1})
     expect(applyTranscriptEvent(replay,events[0]!)).toEqual(replay)
   })
   it('retains stable message identity when an upstream row ID becomes known',()=>{
@@ -84,7 +84,7 @@ describe('ordinary server-owned transcript',()=>{
     cache.observe('owner','p','s',{type:'message.complete',payload:{text:'结束'},delivery_id:'two'})
     const after=snapshot(store);cache.close();store.close()
     const reopened=new ChatCacheStore(home)
-    try{expect(reopened.transcripts.epoch).toBe(before.epoch);expect(reopened.transcripts.events('owner','p','s',before.cursor).reduce(applyTranscriptEvent,before.messages)).toEqual(after.messages)}finally{reopened.close()}
+    try{expect(reopened.transcripts.epochFor('owner','p','s')).toBe(before.epoch);expect(reopened.transcripts.events('owner','p','s',before.cursor).reduce(applyTranscriptEvent,before.messages)).toEqual(after.messages)}finally{reopened.close()}
   })
   it('keeps owner/session scopes and history pagination independent',()=>{
     const {store,cache}=fixture()

+ 2 - 1
tests/server/ordinaryChatLocal.test.ts

@@ -92,7 +92,8 @@ describe('ordinary chat migration and bounded recovery',()=>{
  it('promotes an assistant identity without creating a second history row',()=>{
    const {store,coordinator}=setup();store.recordCommand('o','p','s','session.create',{})
    coordinator.observe('o','p','s',{type:'message.delta',payload:{text:'hello'},delivery_id:'delta'})
+   const stableID=store.transcripts.all('o','p','s')[0]!.id
    coordinator.observe('o','p','s',{type:'message.complete',payload:{message_id:'canonical',text:'hello'},delivery_id:'done'})
-   const page=json(coordinator.readLocal('o','messages','p','s',{}));expect(page.messages).toHaveLength(1);expect(page.messages[0].id).toBe('canonical')
+   const page=json(coordinator.readLocal('o','messages','p','s',{}));expect(page.messages).toHaveLength(1);expect(page.messages[0]).toMatchObject({id:stableID,source_message_id:'canonical'})
  })
 })

+ 149 - 0
tests/server/ordinaryTranscriptApi.test.ts

@@ -0,0 +1,149 @@
+// @vitest-environment node
+import { createServer, type Server } from 'node:http'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import request from 'supertest'
+import { afterEach, describe, expect, it } from 'vitest'
+import { createApplication, type ApplicationRuntime } from '../../src/server/app'
+import type { ServerConfig } from '../../src/server/config'
+import { SSEParser } from '../../src/shared/sse'
+import {
+  applyTranscriptEvent,
+  CHAT_TRANSCRIPT_FEATURE,
+  type TranscriptEvent,
+  type TranscriptMessage,
+} from '../../src/shared/chatTranscript'
+const fixtures: Array<{ home: string; runtime: ApplicationRuntime; server: Server }> = []
+afterEach(async () => {
+  for (const f of fixtures.splice(0)) {
+    f.runtime.close()
+    f.server.closeAllConnections()
+    await new Promise<void>((resolve) => f.server.close(() => resolve()))
+    rmSync(f.home, { recursive: true, force: true })
+  }
+})
+describe('ordinary v2 HTTP snapshot and durable SSE integration', () => {
+  it('replays the exact committed snapshot and rejects a legacy ordinary channel', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'ordinary-v2-api-'))
+    const server = createServer()
+    await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
+    const port = (server.address() as { port: number }).port
+    const config: ServerConfig = {
+      host: '127.0.0.1',
+      port,
+      upstream: new URL('http://127.0.0.1:9119'),
+      allowedHosts: new Set(),
+      home,
+      mediaRoot: home,
+      attachmentsRoot: home,
+      imagesRoot: home,
+      mediaOwner: 'test',
+      allowInsecureLan: false,
+      insecureLan: false,
+      production: false,
+    }
+    const runtime = createApplication({
+      config,
+      fetchImpl: async (input) => {
+        const path = new URL(String(input)).pathname
+        return path === '/api/status'
+          ? Response.json({ auth_required: false })
+          : path === '/'
+            ? new Response(
+                '<script>window.__HERMES_SESSION_TOKEN__="fixture-token-1234567890123456";</script>',
+              )
+            : path === '/api/profiles'
+              ? Response.json({ profiles: [{ name: 'default', is_default: true }] })
+              : Response.json({})
+      },
+    })
+    server.on('request', runtime.app.callback())
+    fixtures.push({ home, runtime, server })
+    const host = `127.0.0.1:${port}`,
+      agent = request.agent(server)
+    const bootstrap = await agent.get('/api/app/bootstrap').set('Host', host).expect(200)
+    const setup = await agent
+      .post('/api/app/setup')
+      .set('Host', host)
+      .set('Origin', `http://${host}`)
+      .set('X-CSRF-Token', bootstrap.body.csrfToken)
+      .send({ username: 'owner', password: 'fixture-password' })
+      .expect(200)
+    const owner = String(setup.body.user.id),
+      store = runtime.chatCache.store
+    const capabilities = await agent.get('/api/realtime/capabilities').set('Host', host).expect(200)
+    expect(capabilities.body.features).toContain(CHAT_TRANSCRIPT_FEATURE)
+    await agent
+      .post('/api/realtime/channels')
+      .set('Host', host)
+      .set('Origin', `http://${host}`)
+      .set('X-CSRF-Token', capabilities.body.csrfToken)
+      .send({ channel: 'chat' })
+      .expect(426)
+    store.recordCommand(owner, 'default', 's', 'session.create', { source: 'web' })
+    const base = (
+      await agent.get('/api/app/chat/sessions/s/snapshot?profile=default').set('Host', host).expect(200)
+    ).body
+    const cookies = new Map<string, string>()
+    for (const response of [bootstrap, setup, capabilities])
+      for (const value of (response.headers['set-cookie'] ?? []) as unknown as string[]) {
+        const pair = value.split(';')[0]!
+        cookies.set(pair.split('=')[0]!, pair)
+      }
+    const controller = new AbortController()
+    const stream = await fetch(`http://127.0.0.1:${port}/api/app/chat/sessions/s/events?profile=default`, {
+      headers: {
+        Host: host,
+        Cookie: [...cookies.values()].join('; '),
+        'Last-Event-ID': `${base.epoch}:${base.cursor}`,
+      },
+      signal: controller.signal,
+    })
+    expect(stream.status).toBe(200)
+    const reader = stream.body!.getReader(),
+      parser = new SSEParser(),
+      decoder = new TextDecoder()
+    let seq = 0
+    for (const [type, payload] of [
+      ['message.start', {}],
+      ['message.delta', { text: '完成' }],
+      ['message.interim', { text: '完成', already_streamed: true }],
+      ['message.complete', { text: '完成', response_previewed: true }],
+    ] as Array<[string, object]>)
+      runtime.realtime.broker.onNativeEvent(owner, 'default', 's', {
+        type,
+        payload,
+        session_id: 'runtime',
+        seq: ++seq,
+        epoch: 'native-epoch',
+        delivery_id: `native:${seq}`,
+      })
+    const final = (
+      await agent.get('/api/app/chat/sessions/s/snapshot?profile=default').set('Host', host).expect(200)
+    ).body
+    let rows: TranscriptMessage[] = base.messages,
+      cursor = base.cursor
+    try {
+      while (cursor < final.cursor) {
+        const chunk = await reader.read()
+        expect(chunk.done).toBe(false)
+        for (const frame of parser.feed(decoder.decode(chunk.value, { stream: true }))) {
+          if (frame.event !== 'transcript') continue
+          const event = JSON.parse(frame.data) as TranscriptEvent
+          expect(event.epoch).toBe(base.epoch)
+          expect(event.previousCursor).toBe(cursor)
+          rows = applyTranscriptEvent(rows, event)
+          cursor = event.cursor
+        }
+      }
+    } finally {
+      controller.abort()
+      await reader.cancel().catch(() => {})
+    }
+    expect(rows).toEqual(final.messages)
+    expect(rows).toHaveLength(1)
+    expect(rows[0]).toMatchObject({ content: '完成', status: 'complete', final_result: true })
+    expect(store.db.prepare('SELECT COUNT(*) n FROM chat_messages').get()!.n).toBe(0)
+  })
+})

+ 246 - 0
tests/server/ordinaryTranscriptV2.test.ts

@@ -0,0 +1,246 @@
+// @vitest-environment node
+import { afterEach, describe, expect, it } from 'vitest'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { ChatCacheStore } from '../../src/server/chatCache'
+import { applyTranscriptEvent } from '../../src/shared/chatTranscript'
+import fixture from '../fixtures/ordinary-chat-v2/protocol.json'
+const scope = ['owner', 'p', 's'] as const
+const stores: Array<{ home: string; store: ChatCacheStore }> = []
+function setup() {
+  const home = mkdtempSync(join(tmpdir(), 'ordinary-v2-')),
+    store = new ChatCacheStore(home)
+  stores.push({ home, store })
+  store.recordCommand(...scope, 'session.create', { source: 'web' })
+  let seq = 0
+  return {
+    store,
+    send: (type: string, payload: object = {}, id?: string) =>
+      store.recordEvent(...scope, { type, payload, delivery_id: id ?? String(++seq) }),
+  }
+}
+afterEach(() => {
+  for (const f of stores.splice(0)) {
+    if (!f.store.isClosed) f.store.close()
+    rmSync(f.home, { recursive: true, force: true })
+  }
+})
+const messages = (store: ChatCacheStore) => store.transcripts.all(...scope)
+describe('ordinary v2 canonical writer', () => {
+  it('updates a final preview in place and never writes another live representation', () => {
+    const { store, send } = setup()
+    send('message.start')
+    send('message.delta', { text: '已经完成。' })
+    const id = messages(store)[0]!.id
+    for (let i = 0; i < 3; i++) send('message.interim', { text: '已经完成。', already_streamed: true })
+    send('message.complete', { text: '已经完成。', response_previewed: true })
+    send('run.completed', { text: '已经完成。' })
+    expect(messages(store)).toHaveLength(1)
+    expect(messages(store)[0]).toMatchObject({
+      id,
+      content: '已经完成。',
+      status: 'complete',
+      final_result: true,
+    })
+    expect(store.db.prepare('SELECT COUNT(*) n FROM chat_messages').get()!.n).toBe(0)
+  })
+  it('restores an active turn through a snapshot then promotes its upstream ID', () => {
+    const { store, send } = setup()
+    send('route.resumed', { running: true, inflight: { assistant: '前半段' } })
+    const id = messages(store)[0]!.id
+    send('message.delta', { message_id: 'upstream-event', text: '后半段' })
+    send('message.complete', { message_id: 'persisted-42', text: '前半段后半段' })
+    expect(messages(store)).toHaveLength(1)
+    expect(messages(store)[0]).toMatchObject({
+      id,
+      source_message_id: 'persisted-42',
+      content: '前半段后半段',
+    })
+  })
+  it('preserves two identical actual sends and their queued turns across restart', () => {
+    const { store, send } = setup()
+    store.recordCommand(...scope, 'prompt.submit', { text: '相同问题', _delivery_id: 'web:prompt:one' })
+    send('command.confirmed', { delivery_id: 'web:prompt:one' })
+    send('message.start')
+    send('message.delta', { text: '相同回复' })
+    store.recordCommand(...scope, 'prompt.submit', {
+      text: '相同问题',
+      _delivery_id: 'web:prompt:two',
+      queued: true,
+    })
+    send('command.confirmed', { delivery_id: 'web:prompt:two', status: 'queued' })
+    send('message.complete', { text: '相同回复', queue_remaining: 1 })
+    send('message.start')
+    send('message.delta', { text: '相同回复' })
+    send('message.complete', { text: '相同回复' })
+    const all = messages(store)
+    expect(all.filter((m) => m.role === 'user')).toHaveLength(2)
+    expect(all.filter((m) => m.final_result)).toHaveLength(2)
+    expect(new Set(all.filter((m) => m.role === 'assistant').map((m) => m.turn_id)).size).toBe(2)
+    const { home } = stores.at(-1)!
+    store.close()
+    const reopened = new ChatCacheStore(home)
+    try {
+      expect(messages(reopened)).toEqual(all)
+    } finally {
+      reopened.close()
+    }
+  })
+  it('keeps steer in the current turn without moving the reply behind a future user', () => {
+    const { store, send } = setup()
+    store.recordCommand(...scope, 'prompt.submit', { text: '开始', _delivery_id: 'web:prompt:one' })
+    send('message.delta', { text: '前' })
+    const assistant = messages(store).find((m) => m.role === 'assistant')!
+    store.recordCommand(...scope, 'session.steer', { text: '补充', _delivery_id: 'web:prompt:steer' })
+    send('message.delta', { text: '后' })
+    send('message.complete')
+    expect(messages(store).filter((m) => m.role === 'assistant')).toMatchObject([
+      { id: assistant.id, content: '前后' },
+    ])
+    expect(new Set(messages(store).map((m) => m.turn_id)).size).toBe(1)
+  })
+  it('keeps a delayed tool result attached to its original segment', () => {
+    const { store, send } = setup()
+    send('message.interim', { text: '开始检查' })
+    send('tool.start', { tool_id: 't', name: 'terminal' })
+    send('message.interim', { text: '过程说明' })
+    send('message.delta', { text: '完成' })
+    send('tool.complete', { tool_id: 't', result: 'ok' })
+    send('message.complete', { text: '完成' })
+    expect(messages(store).map((m) => m.content)).toEqual(['开始检查', '过程说明', '完成'])
+    expect(messages(store).flatMap((m) => (m.tool_calls ?? []) as object[])).toMatchObject([
+      { id: 't', result: 'ok' },
+    ])
+  })
+  it('deduplicates persisted source receipts after a restart', () => {
+    const { store } = setup(),
+      frame = {
+        type: 'message.delta',
+        session_id: 'runtime',
+        epoch: 'upstream-epoch',
+        seq: 3,
+        payload: { text: '哈' },
+        delivery_id: 'upstream-epoch:runtime:3',
+      }
+    store.recordEvent(...scope, frame)
+    const before = messages(store)
+    const home = stores.at(-1)!.home
+    store.close()
+    const reopened = new ChatCacheStore(home)
+    try {
+      expect(reopened.recordEvent(...scope, frame)).toBe(false)
+      expect(messages(reopened)).toEqual(before)
+      expect(reopened.transcripts.sourceCursor(...scope, 'runtime', 'upstream-epoch')).toBe(3)
+    } finally {
+      reopened.close()
+    }
+  })
+  it('repairs a verified idle history in a new epoch without duplicating a partial page', () => {
+    const { store, send } = setup()
+    send('message.complete', { text: '最终结果' })
+    const epoch = store.transcripts.epochFor(...scope)
+    const result = store.applySync(
+      ...scope,
+      store.revision(...scope),
+      [
+        {
+          key: 'history',
+          kind: 'messages',
+          startedAt: Date.now(),
+          response: {
+            status: 200,
+            headers: new Headers(),
+            body: Buffer.from(
+              JSON.stringify({
+                messages: [{ id: 'server-final', role: 'assistant', content: '最终结果' }],
+                pagination: { offset: 0, total: 1 },
+              }),
+            ),
+          },
+        },
+      ],
+      true,
+    )
+    expect(result).toBe(true)
+    expect(messages(store)).toHaveLength(1)
+    expect(messages(store)[0]!.content).toBe('最终结果')
+    expect(store.transcripts.epochFor(...scope)).not.toBe(epoch)
+    expect(store.db.prepare('SELECT COUNT(*) n FROM chat_transcript_archives').get()!.n).toBeGreaterThan(0)
+  })
+  it('does not replace an active turn or its pending input while history is fetched', () => {
+    const { store, send } = setup()
+    store.recordCommand(...scope, 'prompt.submit', { text: '保留', _delivery_id: 'web:prompt:one' })
+    send('message.delta', { text: '正在回复' })
+    const before = messages(store)
+    expect(store.applySync(...scope, store.revision(...scope), [], true)).toBe(false)
+    expect(messages(store)).toEqual(before)
+  })
+  it('matches the shared protocol fixture including duplicate delivery', () => {
+    let rows = fixture.snapshot.messages as any
+    for (const event of fixture.events) rows = applyTranscriptEvent(rows, event as any)
+    expect(rows).toEqual(fixture.expectedMessages)
+  })
+  it('does not append ambiguous replay bytes after an upstream reset snapshot', () => {
+    const { store, send } = setup()
+    send('message.delta', { text: '前' })
+    const id = messages(store)[0]!.id
+    send('gateway.reset', { reason: 'upstream_replay_unavailable' })
+    send('route.resumed', { running: true, inflight: { assistant: '前后' } })
+    send('message.delta', { text: '后' })
+    expect(messages(store)[0]).toMatchObject({ id, content: '前后' })
+    send('message.complete', { text: '前后完整结果' })
+    expect(messages(store)).toHaveLength(1)
+    expect(messages(store)[0]).toMatchObject({ id, content: '前后完整结果' })
+  })
+  it('does not allow another viewer resume to overwrite a newer live stream', () => {
+    const { store, send } = setup()
+    send('message.delta', { text: '较新的正文' })
+    send('route.resumed', { running: true, inflight: { assistant: '旧快照' } })
+    expect(messages(store).map((m) => m.content)).toEqual(['较新的正文'])
+  })
+  it('routes a late terminal snapshot to its identified old turn', () => {
+    const { store, send } = setup()
+    send('message.complete', { message_id: 'first', text: '第一轮' })
+    store.recordCommand(...scope, 'prompt.submit', { text: '下一轮', _delivery_id: 'web:prompt:two' })
+    send('message.delta', { message_id: 'second', text: '正在继续' })
+    const current = store.transcripts.head(...scope).current
+    send('message.complete', { message_id: 'first', text: '第一轮' })
+    expect(store.transcripts.head(...scope)).toMatchObject({ current, running: true })
+    expect(
+      messages(store)
+        .filter((m) => m.role === 'assistant')
+        .map((m) => m.content),
+    ).toEqual(['第一轮', '正在继续'])
+  })
+  it('keeps structured final attachments and error state in the canonical snapshot', () => {
+    const { store, send } = setup()
+    send('message.complete', { content: [{ type: 'file', path: '/result.pdf', name: '结果.pdf' }] })
+    expect(messages(store)[0]!.content).toEqual([{ type: 'file', path: '/result.pdf', name: '结果.pdf' }])
+    send('message.start')
+    send('run.failed', { error: '工具执行失败' })
+    expect(store.transcripts.snapshot(...scope, {}, 'current')).toMatchObject({
+      running: false,
+      error: '工具执行失败',
+    })
+  })
+  it('does not resurrect a finished turn when a queued command is rejected late', () => {
+    const { store, send } = setup()
+    store.recordCommand(...scope, 'prompt.submit', { text: '第一问', _delivery_id: 'web:prompt:first' })
+    send('message.delta', { text: '第一轮回复' })
+    store.recordCommand(...scope, 'prompt.submit', { text: '排队', _delivery_id: 'web:prompt:queued' })
+    send('message.complete', { text: '第一轮回复' })
+    send('command.rejected', { delivery_id: 'web:prompt:queued', error: '未加入队列' })
+    expect(store.transcripts.head(...scope)).toMatchObject({ running: false, queued: false })
+    expect(messages(store).find((m) => m.client_message_id === 'queued')?.status).toBe('failed')
+  })
+  it('clears obsolete execution queues on an authoritative idle resume without deleting inputs', () => {
+    const { store, send } = setup()
+    store.recordCommand(...scope, 'prompt.submit', { text: '第一问', _delivery_id: 'web:prompt:first' })
+    send('message.delta', { text: '部分回复' })
+    store.recordCommand(...scope, 'prompt.submit', { text: '下一问', _delivery_id: 'web:prompt:queued' })
+    send('route.resumed', { running: false, queued: null })
+    expect(store.transcripts.head(...scope)).toMatchObject({ running: false, queued: false, queue: [] })
+    expect(messages(store).filter((m) => m.role === 'user')).toHaveLength(2)
+  })
+})