chatCrossClientSync.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  2. import { createPinia, setActivePinia } from 'pinia'
  3. import type { RealtimeConnectionState, RpcEventFrame, SessionSummary } from '@shared/types'
  4. import { routeKey } from '@/utils/id'
  5. const realtime = vi.hoisted(() => ({
  6. event: undefined as ((event: RpcEventFrame['params']) => void) | undefined,
  7. state: undefined as ((state: RealtimeConnectionState) => void) | undefined,
  8. request: vi.fn(),
  9. }))
  10. const api = vi.hoisted(() => ({ getMessages: vi.fn() }))
  11. vi.mock('@/api/realtime', () => ({
  12. RpcError: class extends Error {},
  13. ChatRpcSocket: class {
  14. onEvent(handler: typeof realtime.event) { realtime.event = handler }
  15. onState(handler: typeof realtime.state) { realtime.state = handler }
  16. async connect() { realtime.state?.('ready') }
  17. close() {}
  18. async request(...args: unknown[]) { return realtime.request(...args) }
  19. },
  20. }))
  21. vi.mock('@/api/sessions', () => ({
  22. getMessages: api.getMessages, getSession: vi.fn(), getSessions: vi.fn(),
  23. deleteSession: vi.fn(), getSessionUnread: vi.fn(), markSessionRead: vi.fn(), updateSession: vi.fn(),
  24. }))
  25. vi.mock('@/api/profiles', () => ({ getModels: vi.fn() }))
  26. vi.mock('@/stores/auth', () => ({
  27. useAuthStore: () => ({
  28. user: { id: 'viewer' }, activeProfile: { name: 'alpha' },
  29. status: 'authenticated', isAuthenticated: true,
  30. }),
  31. }))
  32. import { useChatStore } from '@/stores/chat'
  33. function session(id = 'stored-1'): SessionSummary {
  34. return { id, profile: 'alpha', owned: true, source: 'ios', title: '跨端聊天',
  35. messageCount: 0, toolCallCount: 0, startedAt: 1, updatedAt: 2 }
  36. }
  37. function emit(type: string, payload: Record<string, unknown> = {}) {
  38. realtime.event?.({ type, session_id: 'runtime-1', profile: 'alpha', payload: payload as never })
  39. }
  40. describe('cross-client chat subscriptions', () => {
  41. let chat: ReturnType<typeof useChatStore>
  42. beforeEach(() => {
  43. setActivePinia(createPinia())
  44. realtime.request.mockReset().mockResolvedValue({ session_id: 'runtime-1', stored_session_id: 'stored-1', running: false })
  45. api.getMessages.mockReset().mockResolvedValue({ messages: [], total: 0, returned: 0, hasMore: false })
  46. chat = useChatStore()
  47. chat.sessions = [session()]
  48. })
  49. afterEach(() => { chat.disconnect(); vi.useRealTimers() })
  50. it('subscribes a viewer before another device starts, then shows thinking, tools, text and completion', async () => {
  51. await chat.selectSession('stored-1', 'alpha')
  52. expect(realtime.request).toHaveBeenCalledExactlyOnceWith('session.resume', {
  53. session_id: 'stored-1', profile: 'alpha', source: 'web', omit_messages: true,
  54. })
  55. expect(chat.sessions[0].updatedAt).toBe(2)
  56. emit('message.start')
  57. emit('thinking.delta', { text: '正在检查' })
  58. expect(chat.isStreaming).toBe(true)
  59. expect(chat.activeRouteState?.liveStatus).toBe('正在检查')
  60. emit('tool.start', { tool_id: 'tool-1', name: 'read_file' })
  61. expect(chat.messages.at(-1)?.toolCalls?.[0]).toMatchObject({ name: 'read_file', status: 'running' })
  62. emit('tool.complete', { tool_id: 'tool-1', name: 'read_file', result: 'ok' })
  63. emit('message.delta', { text: '检查完成' })
  64. expect(chat.messages.at(-1)?.content).toBe('检查完成')
  65. emit('message.complete', { text: '检查完成' })
  66. expect(chat.isStreaming).toBe(false)
  67. expect(chat.activeRouteState?.liveStatus).toBeUndefined()
  68. expect(chat.messages.at(-1)?.toolCalls?.[0].status).toBe('completed')
  69. })
  70. it('restores a run opened before its first text, so tool events are not dropped', async () => {
  71. realtime.request.mockResolvedValue({ session_id: 'runtime-1', stored_session_id: 'stored-1',
  72. running: true, inflight: { streaming: true, assistant: '' } })
  73. await chat.selectSession('stored-1', 'alpha')
  74. expect(chat.isStreaming).toBe(true)
  75. expect(chat.messages).toHaveLength(1)
  76. emit('tool.start', { tool_id: 'tool-1', name: 'terminal' })
  77. expect(chat.messages[0].toolCalls?.[0].name).toBe('terminal')
  78. })
  79. it('restores a partial answer and appends later deltas without an extra row', async () => {
  80. realtime.request.mockResolvedValue({ session_id: 'runtime-1', stored_session_id: 'stored-1',
  81. running: true, inflight: { assistant: '前半段' } })
  82. await chat.selectSession('stored-1', 'alpha')
  83. emit('message.delta', { text: '后半段' })
  84. expect(chat.messages.map(message => message.content)).toEqual(['前半段后半段'])
  85. })
  86. it('resubscribes an idle viewer after disconnection and discovers a remote run', async () => {
  87. await chat.selectSession('stored-1', 'alpha')
  88. expect(chat.isStreaming).toBe(false)
  89. realtime.request.mockResolvedValue({ session_id: 'runtime-1', stored_session_id: 'stored-1',
  90. running: true, inflight: { assistant: '断线期间开始的回复' } })
  91. vi.useFakeTimers()
  92. realtime.state?.('failed')
  93. await vi.advanceTimersByTimeAsync(500)
  94. expect(realtime.request).toHaveBeenCalledTimes(2)
  95. expect(chat.isStreaming).toBe(true)
  96. expect(chat.messages.at(-1)?.content).toBe('断线期间开始的回复')
  97. })
  98. it('does not repeatedly resume a subscribed session when revisiting it', async () => {
  99. await chat.selectSession('stored-1', 'alpha')
  100. await chat.selectSession('stored-1', 'alpha')
  101. expect(realtime.request).toHaveBeenCalledTimes(1)
  102. expect(api.getMessages).toHaveBeenCalledTimes(1)
  103. })
  104. it('reconciles a background session after cache synchronization before reusing it', async () => {
  105. const first = 'background-sync-first', second = 'background-sync-second'
  106. chat.sessions = [session(first), session(second)]
  107. realtime.request.mockImplementation((_method: string, params: Record<string, string>) => Promise.resolve({
  108. session_id: `runtime-${params.session_id}`, stored_session_id: params.session_id, running: false,
  109. }))
  110. const user = { id: 'user-background', serverMessageId: 'user-background', sessionId: first,
  111. role: 'user', content: '第一个问题', timestamp: 1, stage: 'settled' }
  112. let synchronized = false
  113. api.getMessages.mockImplementation((id: string) => Promise.resolve({
  114. messages: id === first ? [user, { id: synchronized ? 'server-final' : 'event:interim',
  115. serverMessageId: synchronized ? 'server-final' : undefined, sessionId: first,
  116. role: 'assistant', content: synchronized ? '后台完成的完整结果' : '旧的中间结果', timestamp: 2, stage: 'settled' }] : [],
  117. total: id === first ? 2 : 0, returned: id === first ? 2 : 0, hasMore: false,
  118. }))
  119. await chat.selectSession(first, 'alpha')
  120. await chat.selectSession(second, 'alpha')
  121. synchronized = true
  122. emit('sessions.changed', { reason: 'cache.synced', profile: 'alpha', session_id: first })
  123. expect(chat.activeSessionId).toBe(second)
  124. expect(chat.messages).toEqual([])
  125. await chat.selectSession(first, 'alpha')
  126. await vi.waitFor(() => expect(chat.messages.map(message => message.content)).toEqual(['第一个问题', '后台完成的完整结果']))
  127. expect(realtime.request.mock.calls.filter(([method]) => method === 'prompt.submit')).toHaveLength(0)
  128. })
  129. it('coalesces history invalidations that arrive while an older snapshot is loading', async () => {
  130. const id = 'sync-during-history-load'
  131. chat.sessions = [session(id)]
  132. realtime.request.mockResolvedValue({ session_id: 'runtime-1', stored_session_id: id, running: false })
  133. const page = (text: string) => ({ messages: [{ id: 'answer-sync', serverMessageId: 'answer-sync',
  134. sessionId: id, role: 'assistant', content: text, timestamp: 1, stage: 'settled' }], total: 1, returned: 1, hasMore: false })
  135. api.getMessages.mockResolvedValue(page('原缓存'))
  136. await chat.selectSession(id, 'alpha')
  137. let release!: (value: unknown) => void
  138. api.getMessages.mockImplementationOnce(() => new Promise(resolve => { release = resolve }))
  139. emit('sessions.changed', { reason: 'cache.synced', profile: 'alpha', session_id: id })
  140. await vi.waitFor(() => expect(api.getMessages).toHaveBeenCalledTimes(2))
  141. api.getMessages.mockResolvedValue(page('较新完整历史'))
  142. emit('sessions.changed', { reason: 'cache.synced', profile: 'alpha', session_id: id })
  143. emit('sessions.changed', { reason: 'cache.synced', profile: 'alpha', session_id: id })
  144. release(page('已过时快照'))
  145. await vi.waitFor(() => expect(chat.messages.map(message => message.content)).toEqual(['较新完整历史']))
  146. expect(api.getMessages).toHaveBeenCalledTimes(3)
  147. await vi.waitFor(() => expect(chat.activeRouteState?.isLoadingHistory).toBe(false))
  148. expect(chat.historySynced).toBe(true)
  149. })
  150. it('merges synchronized history into the current route when SSE replaces its state during the read', async () => {
  151. const id = 'stream-during-history-load'
  152. chat.sessions = [session(id)]
  153. realtime.request.mockResolvedValue({ session_id: 'runtime-1', stored_session_id: id, running: false })
  154. const page = (text: string) => ({ messages: [
  155. { id: 'prior-user', serverMessageId: 'prior-user', sessionId: id, role: 'user', content: '之前的问题', timestamp: 1, stage: 'settled' },
  156. { id: 'prior-answer', serverMessageId: 'prior-answer', sessionId: id, role: 'assistant', content: text, timestamp: 2, stage: 'settled' },
  157. ], total: 2, returned: 2, hasMore: false })
  158. api.getMessages.mockResolvedValue(page('旧正文'))
  159. await chat.selectSession(id, 'alpha')
  160. let release!: (value: unknown) => void
  161. api.getMessages.mockImplementationOnce(() => new Promise(resolve => { release = resolve }))
  162. emit('sessions.changed', { reason: 'cache.synced', profile: 'alpha', session_id: id })
  163. await vi.waitFor(() => expect(api.getMessages).toHaveBeenCalledTimes(2))
  164. emit('run.peer_user_message', { message_id: 'live-user', client_message_id: 'live-client', text: '新问题', timestamp: 3, status: 'accepted' })
  165. emit('message.start')
  166. emit('message.delta', { text: '新回复正在继续' })
  167. release(page('之前的完整正文'))
  168. await vi.waitFor(() => expect(chat.messages.find(message => message.id === 'prior-answer')?.content).toBe('之前的完整正文'))
  169. expect(chat.messages.at(-1)?.content).toBe('新回复正在继续')
  170. expect(chat.isStreaming).toBe(true)
  171. await vi.waitFor(() => expect(chat.activeRouteState?.isLoadingHistory).toBe(false))
  172. })
  173. it('keeps loaded older messages and follows up a synchronization received during pagination', async () => {
  174. const id = 'sync-during-older-page'
  175. chat.sessions = [session(id)]
  176. realtime.request.mockResolvedValue({ session_id: 'runtime-1', stored_session_id: id, running: false })
  177. const recent = (text: string) => ({ messages: [{ id: 'recent-answer', serverMessageId: 'recent-answer',
  178. sessionId: id, role: 'assistant', content: text, timestamp: 2, stage: 'settled' }], total: 2, returned: 1, hasMore: true })
  179. api.getMessages.mockResolvedValue(recent('旧的最新页'))
  180. await chat.selectSession(id, 'alpha')
  181. let release!: (value: unknown) => void
  182. api.getMessages.mockImplementationOnce(() => new Promise(resolve => { release = resolve }))
  183. const older = chat.loadOlder()
  184. await vi.waitFor(() => expect(api.getMessages).toHaveBeenCalledTimes(2))
  185. emit('session.info', { model: 'new-model' })
  186. api.getMessages.mockResolvedValue(recent('补齐后的最新页'))
  187. emit('sessions.changed', { reason: 'cache.synced', profile: 'alpha', session_id: id })
  188. release({ messages: [{ id: 'older-user', serverMessageId: 'older-user', sessionId: id,
  189. role: 'user', content: '之前加载的老消息', timestamp: 1, stage: 'settled' }], total: 2, returned: 1, hasMore: false })
  190. await older
  191. await vi.waitFor(() => expect(chat.messages.map(message => message.content)).toEqual(['之前加载的老消息', '补齐后的最新页']))
  192. expect(api.getMessages).toHaveBeenCalledTimes(3)
  193. await vi.waitFor(() => expect(chat.activeRouteState?.isLoadingHistory).toBe(false))
  194. })
  195. it('reconciles synchronized history before a delayed submit receipt without duplicating or demoting the user row', async () => {
  196. chat.sessions = [session('identity-session')]
  197. realtime.request.mockResolvedValue({ session_id: 'runtime-1', stored_session_id: 'identity-session', running: false })
  198. await chat.selectSession('identity-session', 'alpha')
  199. let accept: (value: unknown) => void = () => {}
  200. realtime.request.mockImplementation((method: string) => method === 'prompt.submit'
  201. ? new Promise(resolve => { accept = resolve }) : Promise.resolve({}))
  202. const sending = chat.send('查看服务器情况')
  203. await vi.waitFor(() => expect(realtime.request.mock.calls.some(([method]) => method === 'prompt.submit')).toBe(true))
  204. const local = chat.messages.find(message => message.role === 'user')!
  205. const persisted = { ...local, id: '1155', serverMessageId: '1155', stage: 'settled' as const }
  206. api.getMessages.mockResolvedValue({ messages: [persisted], total: 1, returned: 1, hasMore: false })
  207. emit('sessions.changed', { reason: 'cache.synced', profile: 'alpha', session_id: 'identity-session' })
  208. await vi.waitFor(() => expect(chat.messages[0]?.id).toBe('1155'))
  209. accept({ status: 'streaming' })
  210. await sending
  211. expect(chat.messages.filter(message => message.role === 'user')).toHaveLength(1)
  212. expect(chat.messages[0]).toMatchObject({ id: '1155', clientMessageId: local.clientMessageId, stage: 'settled' })
  213. })
  214. it('settles a run and clears an interaction completed while the viewer was offline', async () => {
  215. await chat.selectSession('stored-1', 'alpha')
  216. emit('message.start')
  217. emit('message.delta', { text: '部分回复' })
  218. emit('thinking.delta', { text: '等待确认' })
  219. emit('approval.request', { request_id: 'approval-1' })
  220. realtime.request.mockResolvedValue({ session_id: 'runtime-1', stored_session_id: 'stored-1',
  221. running: false, inflight: { assistant: '完整回复', streaming: false } })
  222. vi.useFakeTimers()
  223. realtime.state?.('failed')
  224. await vi.advanceTimersByTimeAsync(500)
  225. expect(chat.isStreaming).toBe(false)
  226. expect(chat.messages).toHaveLength(1)
  227. expect(chat.messages[0]).toMatchObject({ content: '完整回复', stage: 'settled', isStreaming: false })
  228. expect(chat.activeRouteState?.liveStatus).toBeUndefined()
  229. expect(chat.pendingApproval).toBeUndefined()
  230. })
  231. it('does not duplicate completed history from a retained inactive snapshot', async () => {
  232. api.getMessages.mockResolvedValue({ messages: [{ id: 'answer-1', sessionId: 'stored-1',
  233. role: 'assistant', content: '已完成回复', timestamp: 1, stage: 'settled' }], total: 1, returned: 1, hasMore: false })
  234. realtime.request.mockResolvedValue({ session_id: 'runtime-1', stored_session_id: 'stored-1',
  235. running: false, inflight: { assistant: '已完成回复', streaming: false } })
  236. await chat.selectSession('stored-1', 'alpha')
  237. expect(chat.messages.map(message => message.id)).toEqual(['answer-1'])
  238. })
  239. it.each([false, undefined])('keeps history-only sessions unattached with owned=%s', async owned => {
  240. chat.sessions[0].owned = owned
  241. await chat.selectSession('stored-1', 'alpha')
  242. realtime.state?.('ready')
  243. expect(realtime.request).not.toHaveBeenCalled()
  244. })
  245. it('never creates a runtime for an unsent draft on ready', () => {
  246. const id = chat.createSession('alpha')
  247. chat.routes[routeKey('alpha', id)].historySynced = true
  248. realtime.state?.('ready')
  249. expect(realtime.request).not.toHaveBeenCalled()
  250. })
  251. it('retains readable history when subscribing fails and can retry on selection', async () => {
  252. realtime.request.mockRejectedValueOnce(new Error('connection lost'))
  253. await chat.selectSession('stored-1', 'alpha')
  254. expect(chat.historySynced).toBe(true)
  255. expect(chat.activeRouteState?.error).toBe('connection lost')
  256. await chat.selectSession('stored-1', 'alpha')
  257. expect(chat.activeRouteState?.runtimeSessionId).toBe('runtime-1')
  258. })
  259. })