chatCrossClientSync.test.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  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 synchronized history before a delayed submit receipt without duplicating or demoting the user row', async () => {
  105. chat.sessions = [session('identity-session')]
  106. realtime.request.mockResolvedValue({ session_id: 'runtime-1', stored_session_id: 'identity-session', running: false })
  107. await chat.selectSession('identity-session', 'alpha')
  108. let accept: (value: unknown) => void = () => {}
  109. realtime.request.mockImplementation((method: string) => method === 'prompt.submit'
  110. ? new Promise(resolve => { accept = resolve }) : Promise.resolve({}))
  111. const sending = chat.send('查看服务器情况')
  112. await vi.waitFor(() => expect(realtime.request.mock.calls.some(([method]) => method === 'prompt.submit')).toBe(true))
  113. const local = chat.messages.find(message => message.role === 'user')!
  114. const persisted = { ...local, id: '1155', serverMessageId: '1155', stage: 'settled' as const }
  115. api.getMessages.mockResolvedValue({ messages: [persisted], total: 1, returned: 1, hasMore: false })
  116. emit('sessions.changed', { reason: 'cache.synced', profile: 'alpha', session_id: 'identity-session' })
  117. await vi.waitFor(() => expect(chat.messages[0]?.id).toBe('1155'))
  118. accept({ status: 'streaming' })
  119. await sending
  120. expect(chat.messages.filter(message => message.role === 'user')).toHaveLength(1)
  121. expect(chat.messages[0]).toMatchObject({ id: '1155', clientMessageId: local.clientMessageId, stage: 'settled' })
  122. })
  123. it('settles a run and clears an interaction completed while the viewer was offline', async () => {
  124. await chat.selectSession('stored-1', 'alpha')
  125. emit('message.start')
  126. emit('message.delta', { text: '部分回复' })
  127. emit('thinking.delta', { text: '等待确认' })
  128. emit('approval.request', { request_id: 'approval-1' })
  129. realtime.request.mockResolvedValue({ session_id: 'runtime-1', stored_session_id: 'stored-1',
  130. running: false, inflight: { assistant: '完整回复', streaming: false } })
  131. vi.useFakeTimers()
  132. realtime.state?.('failed')
  133. await vi.advanceTimersByTimeAsync(500)
  134. expect(chat.isStreaming).toBe(false)
  135. expect(chat.messages).toHaveLength(1)
  136. expect(chat.messages[0]).toMatchObject({ content: '完整回复', stage: 'settled', isStreaming: false })
  137. expect(chat.activeRouteState?.liveStatus).toBeUndefined()
  138. expect(chat.pendingApproval).toBeUndefined()
  139. })
  140. it('does not duplicate completed history from a retained inactive snapshot', async () => {
  141. api.getMessages.mockResolvedValue({ messages: [{ id: 'answer-1', sessionId: 'stored-1',
  142. role: 'assistant', content: '已完成回复', timestamp: 1, stage: 'settled' }], total: 1, returned: 1, hasMore: false })
  143. realtime.request.mockResolvedValue({ session_id: 'runtime-1', stored_session_id: 'stored-1',
  144. running: false, inflight: { assistant: '已完成回复', streaming: false } })
  145. await chat.selectSession('stored-1', 'alpha')
  146. expect(chat.messages.map(message => message.id)).toEqual(['answer-1'])
  147. })
  148. it.each([false, undefined])('keeps history-only sessions unattached with owned=%s', async owned => {
  149. chat.sessions[0].owned = owned
  150. await chat.selectSession('stored-1', 'alpha')
  151. realtime.state?.('ready')
  152. expect(realtime.request).not.toHaveBeenCalled()
  153. })
  154. it('never creates a runtime for an unsent draft on ready', () => {
  155. const id = chat.createSession('alpha')
  156. chat.routes[routeKey('alpha', id)].historySynced = true
  157. realtime.state?.('ready')
  158. expect(realtime.request).not.toHaveBeenCalled()
  159. })
  160. it('retains readable history when subscribing fails and can retry on selection', async () => {
  161. realtime.request.mockRejectedValueOnce(new Error('connection lost'))
  162. await chat.selectSession('stored-1', 'alpha')
  163. expect(chat.historySynced).toBe(true)
  164. expect(chat.activeRouteState?.error).toBe('connection lost')
  165. await chat.selectSession('stored-1', 'alpha')
  166. expect(chat.activeRouteState?.runtimeSessionId).toBe('runtime-1')
  167. })
  168. })