chatCrossClientSync.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  2. import { createPinia, setActivePinia } from 'pinia'
  3. import type { TranscriptSnapshot } from '@shared/chatTranscript'
  4. import type { RpcEventFrame, RealtimeConnectionState } from '@shared/types'
  5. const wire = vi.hoisted(() => ({
  6. event: undefined as ((e: RpcEventFrame['params']) => void) | undefined,
  7. state: undefined as ((s: RealtimeConnectionState) => void) | undefined,
  8. request: vi.fn(),
  9. clients: [] as any[],
  10. owner: 0,
  11. supported: true,
  12. }))
  13. const api = vi.hoisted(() => ({ messages: vi.fn(), sessions: vi.fn() }))
  14. vi.mock('@/api/realtime', () => ({
  15. RpcError: class extends Error {},
  16. ChatRpcSocket: class {
  17. get transcriptSupported() {
  18. return wire.supported
  19. }
  20. onEvent(f: typeof wire.event) {
  21. wire.event = f
  22. }
  23. onState(f: typeof wire.state) {
  24. wire.state = f
  25. }
  26. async connect() {
  27. wire.state?.('ready')
  28. }
  29. close() {}
  30. async request(...args: unknown[]) {
  31. return wire.request(...args)
  32. }
  33. },
  34. }))
  35. vi.mock('@/api/chatTranscript', () => ({
  36. ChatTranscriptClient: class {
  37. close = vi.fn()
  38. loadOlder = vi.fn()
  39. restore = vi.fn()
  40. run = vi.fn()
  41. constructor(
  42. readonly profile: string,
  43. readonly id: string,
  44. readonly changed: (value: TranscriptSnapshot) => Promise<void>,
  45. readonly failed: (error: Error) => void,
  46. ) {
  47. wire.clients.push(this)
  48. }
  49. },
  50. }))
  51. vi.mock('@/api/sessions', () => ({
  52. getMessages: api.messages,
  53. getSessions: api.sessions,
  54. getSession: vi.fn(),
  55. deleteSession: vi.fn(),
  56. getSessionUnread: vi.fn(),
  57. markSessionRead: vi.fn(),
  58. updateSession: vi.fn(),
  59. requestHistorySync: vi.fn(),
  60. }))
  61. vi.mock('@/api/profiles', () => ({ getModels: vi.fn() }))
  62. vi.mock('@/stores/auth', () => ({
  63. useAuthStore: () => ({
  64. user: { id: `v2-viewer-${wire.owner}` },
  65. activeProfile: { name: 'p' },
  66. status: 'authenticated',
  67. isAuthenticated: true,
  68. }),
  69. }))
  70. import { useChatStore } from '@/stores/chat'
  71. import { ScopedCache } from '@/utils/cache'
  72. function session(id = 's', owned = true) {
  73. return {
  74. id,
  75. profile: 'p',
  76. owned,
  77. source: 'web',
  78. title: id,
  79. messageCount: 0,
  80. toolCallCount: 0,
  81. startedAt: 1,
  82. updatedAt: 1,
  83. }
  84. }
  85. function snapshot(messages: any[], id = 's', extra: Partial<TranscriptSnapshot> = {}): TranscriptSnapshot {
  86. return {
  87. protocol: 'ordinary-chat-transcript-v2',
  88. epoch: 'test',
  89. cursor: 1,
  90. profile: 'p',
  91. sessionId: id,
  92. messages,
  93. session: {},
  94. hasOlder: false,
  95. total: messages.length,
  96. state: 'current',
  97. running: false,
  98. queued: false,
  99. ...extra,
  100. }
  101. }
  102. const answer = (content = '规范正文', extra = {}) => ({
  103. id: 'canonical-answer',
  104. seq: 2,
  105. revision: 1,
  106. source_message_id: 'upstream',
  107. turn_id: 'turn',
  108. role: 'assistant',
  109. content,
  110. status: 'complete',
  111. ...extra,
  112. })
  113. function emit(type: string, payload: any = {}) {
  114. wire.event?.({ type, session_id: 'runtime', profile: 'p', payload })
  115. }
  116. describe('ordinary v2 cross-client store', () => {
  117. let chat: ReturnType<typeof useChatStore>
  118. beforeEach(() => {
  119. setActivePinia(createPinia())
  120. wire.owner++
  121. wire.clients = []
  122. wire.supported = true
  123. wire.request
  124. .mockReset()
  125. .mockResolvedValue({ session_id: 'runtime', stored_session_id: 's', running: false })
  126. api.messages.mockReset().mockResolvedValue({ messages: [], total: 0, returned: 0, hasMore: false })
  127. api.sessions.mockReset().mockResolvedValue({ items: [session()] })
  128. chat = useChatStore()
  129. chat.sessions = [session()]
  130. })
  131. afterEach(() => {
  132. chat.disconnect()
  133. vi.useRealTimers()
  134. })
  135. async function open(id = 's') {
  136. await chat.selectSession(id, 'p')
  137. await vi.waitFor(() => expect(wire.clients.some((c) => c.id === id)).toBe(true))
  138. return wire.clients.filter((c) => c.id === id).at(-1)!
  139. }
  140. it('renders only canonical messages and ignores raw upstream bodies and completions', async () => {
  141. const client = await open()
  142. await client.changed(snapshot([answer('前半', { status: 'streaming' })], 's', { running: true }))
  143. emit('message.delta', { text: '不应追加' })
  144. emit('message.interim', { text: '不应重复' })
  145. emit('message.complete', { text: '不应替换' })
  146. expect(chat.messages.map((m) => m.content)).toEqual(['前半'])
  147. expect(chat.isStreaming).toBe(true)
  148. await client.changed(snapshot([answer('完整正文', { revision: 2 })], 's', { cursor: 2 }))
  149. expect(chat.messages.map((m) => m.content)).toEqual(['完整正文'])
  150. expect(chat.isStreaming).toBe(false)
  151. expect(api.messages).not.toHaveBeenCalled()
  152. })
  153. it('keeps checkpoint state when a usage update replaces the route during outbox persistence', async () => {
  154. const client = await open()
  155. const original = ScopedCache.prototype.set
  156. let entered!: () => void, release!: () => void
  157. const started = new Promise<void>(resolve => { entered = resolve })
  158. const gate = new Promise<void>(resolve => { release = resolve })
  159. const spy = vi.spyOn(ScopedCache.prototype, 'set').mockImplementation(async function(this: ScopedCache<unknown>, scope, key, value, durable) {
  160. if ((this as unknown as { namespace: string }).namespace === 'ordinary-outbox-v2') {
  161. entered()
  162. await gate
  163. }
  164. await original.call(this, scope, key, value, durable)
  165. })
  166. const saving = client.changed(snapshot([answer('正在处理', { status: 'streaming' })], 's', {
  167. running: true, liveStatus: '正在思考', pendingApproval: { request_id: 'approval-1', message: '请确认' },
  168. }))
  169. try {
  170. await started
  171. wire.event?.({ type: 'session.usage', session_id: 's', profile: 'p', payload: { input_tokens: 7 } })
  172. release()
  173. await saving
  174. expect(chat.messages.map(m => m.content)).toEqual(['正在处理'])
  175. expect(chat.isStreaming).toBe(true)
  176. expect(chat.pendingApproval?.id).toBe('approval-1')
  177. expect(chat.contextUsage?.inputTokens).toBe(7)
  178. } finally {
  179. release()
  180. await saving.catch(() => {})
  181. spy.mockRestore()
  182. }
  183. })
  184. it('does not materialize a resume snapshot beside the canonical reply', async () => {
  185. wire.request.mockResolvedValue({
  186. session_id: 'runtime',
  187. stored_session_id: 's',
  188. running: true,
  189. inflight: { assistant: '旧恢复快照' },
  190. })
  191. const client = await open()
  192. await client.changed(snapshot([answer('当前正文')]))
  193. expect(chat.messages.map((m) => m.content)).toEqual(['当前正文'])
  194. expect(chat.messages[0]!.id).toBe('canonical-answer')
  195. })
  196. it('restores tools and pending interactions from the canonical snapshot', async () => {
  197. const client = await open()
  198. await client.changed(
  199. snapshot(
  200. [
  201. answer('', {
  202. status: 'streaming',
  203. tool_calls: [{ id: 'tool', name: 'terminal', status: 'running' }],
  204. }),
  205. ],
  206. 's',
  207. {
  208. running: true,
  209. pendingApproval: { request_id: 'approval', message: '允许执行' },
  210. pendingClarification: { request_id: 'clarify', question: '请选择' },
  211. },
  212. ),
  213. )
  214. expect(chat.messages[0]!.toolCalls?.[0]?.id).toBe('tool')
  215. expect(chat.pendingApproval?.id).toBe('approval')
  216. expect(chat.pendingClarification?.id).toBe('clarify')
  217. await client.changed(
  218. snapshot([answer()], 's', { cursor: 2, pendingApproval: null, pendingClarification: null }),
  219. )
  220. expect(chat.pendingApproval).toBeUndefined()
  221. expect(chat.pendingClarification).toBeUndefined()
  222. })
  223. it('rejects a closed session callback after switching to another conversation', async () => {
  224. const first = await open()
  225. chat.sessions.push(session('second'))
  226. wire.request.mockResolvedValue({ session_id: 'runtime-2', stored_session_id: 'second' })
  227. const second = await open('second')
  228. await second.changed(snapshot([answer('第二个会话')], 'second'))
  229. await first.changed(snapshot([answer('旧回调')]))
  230. expect(chat.activeSessionId).toBe('second')
  231. expect(chat.messages[0]!.content).toBe('第二个会话')
  232. })
  233. it('does not let a late submit receipt re-open a completed turn or duplicate the user', async () => {
  234. const client = await open()
  235. let accept!: (value: any) => void
  236. wire.request.mockImplementation((method: string) =>
  237. method === 'prompt.submit'
  238. ? new Promise((resolve) => {
  239. accept = resolve
  240. })
  241. : Promise.resolve({}),
  242. )
  243. const sending = chat.send('问题')
  244. await vi.waitFor(() => expect(accept).toBeTypeOf('function'))
  245. const local = chat.messages.find((m) => m.role === 'user')!
  246. await client.changed(
  247. snapshot(
  248. [
  249. {
  250. id: 'canonical-user',
  251. seq: 1,
  252. revision: 2,
  253. source_message_id: 'persisted-user',
  254. client_message_id: local.clientMessageId,
  255. role: 'user',
  256. content: '问题',
  257. status: 'complete',
  258. },
  259. answer(),
  260. ],
  261. 's',
  262. { cursor: 3, running: false },
  263. ),
  264. )
  265. accept({ status: 'streaming' })
  266. await sending
  267. expect(chat.messages.filter((m) => m.role === 'user')).toHaveLength(1)
  268. expect(chat.messages[0]!.stage).toBe('settled')
  269. expect(chat.isStreaming).toBe(false)
  270. })
  271. it('keeps a pending local input during a new generation replacement', async () => {
  272. const client = await open()
  273. let reject!: (error: Error) => void
  274. wire.request.mockImplementation((method: string) =>
  275. method === 'prompt.submit'
  276. ? new Promise((_resolve, r) => {
  277. reject = r
  278. })
  279. : Promise.resolve({}),
  280. )
  281. const sending = chat.send('待确认').catch(() => {})
  282. await vi.waitFor(() => expect(reject).toBeTypeOf('function'))
  283. const local = chat.messages[0]!.id
  284. await client.changed(snapshot([answer('历史重建')], 's', { epoch: 'rebuilt' }))
  285. expect(chat.messages.some((m) => m.id === local)).toBe(true)
  286. reject(new Error('connection lost'))
  287. await sending
  288. })
  289. it('uses transcript pagination without requesting legacy message pages', async () => {
  290. const client = await open()
  291. await client.changed(snapshot([answer()], 's', { hasOlder: true }))
  292. await chat.loadOlder()
  293. expect(client.loadOlder).toHaveBeenCalledOnce()
  294. expect(api.messages).not.toHaveBeenCalled()
  295. })
  296. it('requires v2 and never falls back to legacy history for owned chat', async () => {
  297. wire.supported = false
  298. await expect(chat.selectSession('s', 'p')).rejects.toThrow('升级服务端')
  299. expect(api.messages).not.toHaveBeenCalled()
  300. })
  301. it('keeps read-only native history outside the ordinary transcript', async () => {
  302. chat.sessions = [session('s', false)]
  303. await chat.selectSession('s', 'p')
  304. expect(api.messages).toHaveBeenCalledOnce()
  305. expect(wire.request).not.toHaveBeenCalled()
  306. })
  307. })