chatCrossClientSync.test.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  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. function session(id = 's', owned = true) {
  72. return {
  73. id,
  74. profile: 'p',
  75. owned,
  76. source: 'web',
  77. title: id,
  78. messageCount: 0,
  79. toolCallCount: 0,
  80. startedAt: 1,
  81. updatedAt: 1,
  82. }
  83. }
  84. function snapshot(messages: any[], id = 's', extra: Partial<TranscriptSnapshot> = {}): TranscriptSnapshot {
  85. return {
  86. protocol: 'ordinary-chat-transcript-v2',
  87. epoch: 'test',
  88. cursor: 1,
  89. profile: 'p',
  90. sessionId: id,
  91. messages,
  92. session: {},
  93. hasOlder: false,
  94. total: messages.length,
  95. state: 'current',
  96. running: false,
  97. queued: false,
  98. ...extra,
  99. }
  100. }
  101. const answer = (content = '规范正文', extra = {}) => ({
  102. id: 'canonical-answer',
  103. seq: 2,
  104. revision: 1,
  105. source_message_id: 'upstream',
  106. turn_id: 'turn',
  107. role: 'assistant',
  108. content,
  109. status: 'complete',
  110. ...extra,
  111. })
  112. function emit(type: string, payload: any = {}) {
  113. wire.event?.({ type, session_id: 'runtime', profile: 'p', payload })
  114. }
  115. describe('ordinary v2 cross-client store', () => {
  116. let chat: ReturnType<typeof useChatStore>
  117. beforeEach(() => {
  118. setActivePinia(createPinia())
  119. wire.owner++
  120. wire.clients = []
  121. wire.supported = true
  122. wire.request
  123. .mockReset()
  124. .mockResolvedValue({ session_id: 'runtime', stored_session_id: 's', running: false })
  125. api.messages.mockReset().mockResolvedValue({ messages: [], total: 0, returned: 0, hasMore: false })
  126. api.sessions.mockReset().mockResolvedValue({ items: [session()] })
  127. chat = useChatStore()
  128. chat.sessions = [session()]
  129. })
  130. afterEach(() => {
  131. chat.disconnect()
  132. vi.useRealTimers()
  133. })
  134. async function open(id = 's') {
  135. await chat.selectSession(id, 'p')
  136. await vi.waitFor(() => expect(wire.clients.some((c) => c.id === id)).toBe(true))
  137. return wire.clients.filter((c) => c.id === id).at(-1)!
  138. }
  139. it('renders only canonical messages and ignores raw upstream bodies and completions', async () => {
  140. const client = await open()
  141. await client.changed(snapshot([answer('前半', { status: 'streaming' })], 's', { running: true }))
  142. emit('message.delta', { text: '不应追加' })
  143. emit('message.interim', { text: '不应重复' })
  144. emit('message.complete', { text: '不应替换' })
  145. expect(chat.messages.map((m) => m.content)).toEqual(['前半'])
  146. expect(chat.isStreaming).toBe(true)
  147. await client.changed(snapshot([answer('完整正文', { revision: 2 })], 's', { cursor: 2 }))
  148. expect(chat.messages.map((m) => m.content)).toEqual(['完整正文'])
  149. expect(chat.isStreaming).toBe(false)
  150. expect(api.messages).not.toHaveBeenCalled()
  151. })
  152. it('does not materialize a resume snapshot beside the canonical reply', async () => {
  153. wire.request.mockResolvedValue({
  154. session_id: 'runtime',
  155. stored_session_id: 's',
  156. running: true,
  157. inflight: { assistant: '旧恢复快照' },
  158. })
  159. const client = await open()
  160. await client.changed(snapshot([answer('当前正文')]))
  161. expect(chat.messages.map((m) => m.content)).toEqual(['当前正文'])
  162. expect(chat.messages[0]!.id).toBe('canonical-answer')
  163. })
  164. it('restores tools and pending interactions from the canonical snapshot', async () => {
  165. const client = await open()
  166. await client.changed(
  167. snapshot(
  168. [
  169. answer('', {
  170. status: 'streaming',
  171. tool_calls: [{ id: 'tool', name: 'terminal', status: 'running' }],
  172. }),
  173. ],
  174. 's',
  175. {
  176. running: true,
  177. pendingApproval: { request_id: 'approval', message: '允许执行' },
  178. pendingClarification: { request_id: 'clarify', question: '请选择' },
  179. },
  180. ),
  181. )
  182. expect(chat.messages[0]!.toolCalls?.[0]?.id).toBe('tool')
  183. expect(chat.pendingApproval?.id).toBe('approval')
  184. expect(chat.pendingClarification?.id).toBe('clarify')
  185. await client.changed(
  186. snapshot([answer()], 's', { cursor: 2, pendingApproval: null, pendingClarification: null }),
  187. )
  188. expect(chat.pendingApproval).toBeUndefined()
  189. expect(chat.pendingClarification).toBeUndefined()
  190. })
  191. it('rejects a closed session callback after switching to another conversation', async () => {
  192. const first = await open()
  193. chat.sessions.push(session('second'))
  194. wire.request.mockResolvedValue({ session_id: 'runtime-2', stored_session_id: 'second' })
  195. const second = await open('second')
  196. await second.changed(snapshot([answer('第二个会话')], 'second'))
  197. await first.changed(snapshot([answer('旧回调')]))
  198. expect(chat.activeSessionId).toBe('second')
  199. expect(chat.messages[0]!.content).toBe('第二个会话')
  200. })
  201. it('does not let a late submit receipt re-open a completed turn or duplicate the user', async () => {
  202. const client = await open()
  203. let accept!: (value: any) => void
  204. wire.request.mockImplementation((method: string) =>
  205. method === 'prompt.submit'
  206. ? new Promise((resolve) => {
  207. accept = resolve
  208. })
  209. : Promise.resolve({}),
  210. )
  211. const sending = chat.send('问题')
  212. await vi.waitFor(() => expect(accept).toBeTypeOf('function'))
  213. const local = chat.messages.find((m) => m.role === 'user')!
  214. await client.changed(
  215. snapshot(
  216. [
  217. {
  218. id: 'canonical-user',
  219. seq: 1,
  220. revision: 2,
  221. source_message_id: 'persisted-user',
  222. client_message_id: local.clientMessageId,
  223. role: 'user',
  224. content: '问题',
  225. status: 'complete',
  226. },
  227. answer(),
  228. ],
  229. 's',
  230. { cursor: 3, running: false },
  231. ),
  232. )
  233. accept({ status: 'streaming' })
  234. await sending
  235. expect(chat.messages.filter((m) => m.role === 'user')).toHaveLength(1)
  236. expect(chat.messages[0]!.stage).toBe('settled')
  237. expect(chat.isStreaming).toBe(false)
  238. })
  239. it('keeps a pending local input during a new generation replacement', async () => {
  240. const client = await open()
  241. let reject!: (error: Error) => void
  242. wire.request.mockImplementation((method: string) =>
  243. method === 'prompt.submit'
  244. ? new Promise((_resolve, r) => {
  245. reject = r
  246. })
  247. : Promise.resolve({}),
  248. )
  249. const sending = chat.send('待确认').catch(() => {})
  250. await vi.waitFor(() => expect(reject).toBeTypeOf('function'))
  251. const local = chat.messages[0]!.id
  252. await client.changed(snapshot([answer('历史重建')], 's', { epoch: 'rebuilt' }))
  253. expect(chat.messages.some((m) => m.id === local)).toBe(true)
  254. reject(new Error('connection lost'))
  255. await sending
  256. })
  257. it('uses transcript pagination without requesting legacy message pages', async () => {
  258. const client = await open()
  259. await client.changed(snapshot([answer()], 's', { hasOlder: true }))
  260. await chat.loadOlder()
  261. expect(client.loadOlder).toHaveBeenCalledOnce()
  262. expect(api.messages).not.toHaveBeenCalled()
  263. })
  264. it('requires v2 and never falls back to legacy history for owned chat', async () => {
  265. wire.supported = false
  266. await expect(chat.selectSession('s', 'p')).rejects.toThrow('升级服务端')
  267. expect(api.messages).not.toHaveBeenCalled()
  268. })
  269. it('keeps read-only native history outside the ordinary transcript', async () => {
  270. chat.sessions = [session('s', false)]
  271. await chat.selectSession('s', 'p')
  272. expect(api.messages).toHaveBeenCalledOnce()
  273. expect(wire.request).not.toHaveBeenCalled()
  274. })
  275. })