chatModelSync.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. import { beforeEach, describe, expect, it, vi } from 'vitest'
  2. import { createPinia, setActivePinia } from 'pinia'
  3. import { routeKey } from '@/utils/id'
  4. const realtime = vi.hoisted(() => ({
  5. eventHandler: undefined as ((event: unknown) => void) | undefined,
  6. request: vi.fn(),
  7. }))
  8. const sessionsApi = vi.hoisted(() => ({
  9. getSession: vi.fn(),
  10. getMessages: vi.fn(),
  11. getSessions: vi.fn(),
  12. requestHistorySync: vi.fn(),
  13. }))
  14. vi.mock('@/api/realtime', () => {
  15. class RpcError extends Error {}
  16. class ChatRpcSocket {
  17. onState(): void {}
  18. onEvent(handler: (event: unknown) => void): void { realtime.eventHandler = handler }
  19. async connect(): Promise<void> {}
  20. close(): void {}
  21. async request(...args: unknown[]): Promise<unknown> { return realtime.request(...args) }
  22. }
  23. return { ChatRpcSocket, RpcError }
  24. })
  25. vi.mock('@/api/sessions', () => ({
  26. deleteSession: vi.fn(),
  27. getMessages: sessionsApi.getMessages,
  28. getSession: sessionsApi.getSession,
  29. getSessions: sessionsApi.getSessions,
  30. requestHistorySync: sessionsApi.requestHistorySync,
  31. getSessionUnread: vi.fn(),
  32. markSessionRead: vi.fn(),
  33. updateSession: vi.fn(),
  34. }))
  35. vi.mock('@/api/profiles', () => ({ getModels: vi.fn() }))
  36. vi.mock('@/stores/auth', () => ({
  37. useAuthStore: () => ({
  38. user: { id: 'account-1' },
  39. activeProfile: { name: 'alpha' },
  40. status: 'authenticated',
  41. }),
  42. }))
  43. import { useChatStore } from '@/stores/chat'
  44. describe('chat model realtime synchronization', () => {
  45. beforeEach(() => {
  46. realtime.eventHandler = undefined
  47. realtime.request.mockReset().mockResolvedValue({})
  48. sessionsApi.getMessages.mockReset()
  49. sessionsApi.requestHistorySync.mockReset().mockResolvedValue(undefined)
  50. sessionsApi.getSession.mockReset()
  51. sessionsApi.getSessions.mockReset().mockResolvedValue({ items: [], nextCursor: null })
  52. setActivePinia(createPinia())
  53. })
  54. it('applies another viewer model switch to the active selector and session summary', () => {
  55. const chat = useChatStore()
  56. const oldModel = { id: 'model-a', name: 'Model A', provider: 'provider-a' }
  57. const newModel = { id: 'model-b', name: 'Model B', provider: 'provider-b' }
  58. chat.models = [oldModel, newModel]
  59. chat.sessions = [{
  60. id: 'session-1', profile: 'alpha', source: 'web', title: '会话',
  61. model: oldModel.id, provider: oldModel.provider, messageCount: 0,
  62. toolCallCount: 0, startedAt: 1, updatedAt: 1,
  63. }]
  64. chat.activeSessionId = 'session-1'
  65. chat.activeProfileName = 'alpha'
  66. chat.routes[routeKey('alpha', 'session-1')] = {
  67. route: { profile: 'alpha', sessionId: 'session-1' },
  68. runtimeSessionId: 'runtime-1', messages: [], historySynced: true,
  69. hasMoreBefore: false, loadedMessageCount: 0, messageTotal: 0,
  70. isLoadingHistory: false, isStreaming: false, isQueued: false, generation: 1,
  71. }
  72. chat.selectedModel = oldModel
  73. realtime.eventHandler?.({
  74. type: 'session.info', session_id: 'runtime-1',
  75. payload: { info: { model: newModel.id, provider: newModel.provider } },
  76. })
  77. expect(chat.selectedModel).toEqual(newModel)
  78. expect(chat.sessions[0]).toMatchObject({ model: newModel.id, provider: newModel.provider })
  79. })
  80. it('reconciles the active model through REST when another client owns realtime events', async () => {
  81. const chat = useChatStore()
  82. const oldModel = { id: 'model-a', name: 'Model A', provider: 'provider-a' }
  83. const newModel = { id: 'model-b', name: 'Model B', provider: 'provider-b' }
  84. chat.models = [oldModel, newModel]
  85. chat.sessions = [{
  86. id: 'session-1', profile: 'alpha', source: 'web', title: '会话',
  87. model: oldModel.id, provider: oldModel.provider, messageCount: 0,
  88. toolCallCount: 0, startedAt: 1, updatedAt: 1,
  89. }]
  90. chat.activeSessionId = 'session-1'
  91. chat.activeProfileName = 'alpha'
  92. chat.routes[routeKey('alpha', 'session-1')] = {
  93. route: { profile: 'alpha', sessionId: 'session-1' }, messages: [], historySynced: true,
  94. hasMoreBefore: false, loadedMessageCount: 0, messageTotal: 0,
  95. isLoadingHistory: false, isStreaming: false, isQueued: false, generation: 1,
  96. }
  97. chat.selectedModel = oldModel
  98. sessionsApi.getSession.mockResolvedValue({
  99. ...chat.sessions[0], model: newModel.id, provider: newModel.provider,
  100. })
  101. await chat.refreshActiveSessionModel()
  102. expect(sessionsApi.getSession).toHaveBeenCalledWith('session-1', 'alpha')
  103. expect(chat.selectedModel).toEqual(newModel)
  104. })
  105. it('releases the local model guard after a failed switch', async () => {
  106. const chat = useChatStore()
  107. const oldModel = { id: 'model-a', name: 'Model A', provider: 'provider-a' }
  108. const newModel = { id: 'model-b', name: 'Model B', provider: 'provider-b' }
  109. chat.models = [oldModel, newModel]
  110. chat.sessions = [{
  111. id: 'session-1', profile: 'alpha', source: 'web', title: '会话',
  112. model: oldModel.id, provider: oldModel.provider, messageCount: 0,
  113. toolCallCount: 0, startedAt: 1, updatedAt: 1,
  114. }]
  115. chat.activeSessionId = 'session-1'
  116. chat.activeProfileName = 'alpha'
  117. chat.routes[routeKey('alpha', 'session-1')] = {
  118. route: { profile: 'alpha', sessionId: 'session-1' }, runtimeSessionId: 'runtime-1',
  119. messages: [], historySynced: true, hasMoreBefore: false, loadedMessageCount: 0,
  120. messageTotal: 0, isLoadingHistory: false, isStreaming: false, isQueued: false, generation: 1,
  121. }
  122. chat.selectedModel = oldModel
  123. realtime.request.mockRejectedValueOnce(new Error('switch failed'))
  124. await expect(chat.setModel(newModel)).rejects.toThrow('switch failed')
  125. expect(chat.selectedModel).toEqual(oldModel)
  126. realtime.eventHandler?.({
  127. type: 'session.info', session_id: 'runtime-1',
  128. payload: { model: newModel.id, provider: newModel.provider },
  129. })
  130. expect(chat.selectedModel).toEqual(newModel)
  131. })
  132. it('does not resume an old session merely to show its context usage', async () => {
  133. const chat = useChatStore()
  134. chat.sessions = [{
  135. id: 'session-1', profile: 'alpha', source: 'web', title: '旧会话',
  136. messageCount: 1, toolCallCount: 0, startedAt: 1, updatedAt: 1,
  137. }]
  138. chat.routes[routeKey('alpha', 'session-1')] = {
  139. route: { profile: 'alpha', sessionId: 'session-1' }, messages: [], historySynced: true,
  140. hasMoreBefore: false, loadedMessageCount: 0, messageTotal: 0,
  141. isLoadingHistory: false, isStreaming: false, isQueued: false, generation: 1,
  142. }
  143. await chat.selectSession('session-1', 'alpha')
  144. await Promise.resolve()
  145. expect(realtime.request).not.toHaveBeenCalled()
  146. })
  147. it('debounces global session changes and reloads the visible server projection', async () => {
  148. vi.useFakeTimers()
  149. try {
  150. const chat = useChatStore()
  151. sessionsApi.getSessions.mockResolvedValue({
  152. items: [{
  153. id: 'ios-session', profile: 'alpha', source: 'web', title: 'iOS 新会话',
  154. messageCount: 2, toolCallCount: 0, startedAt: 2, updatedAt: 2,
  155. }],
  156. nextCursor: null,
  157. })
  158. realtime.eventHandler?.({ type: 'sessions.changed', payload: {} })
  159. realtime.eventHandler?.({ type: 'sessions.changed', payload: {} })
  160. realtime.eventHandler?.({ type: 'sessions.changed', payload: {} })
  161. expect(sessionsApi.getSessions).not.toHaveBeenCalled()
  162. await vi.advanceTimersByTimeAsync(200)
  163. expect(sessionsApi.getSessions).toHaveBeenCalledTimes(1)
  164. expect(sessionsApi.getSessions).toHaveBeenCalledWith('alpha', undefined, 100, 'chat')
  165. expect(chat.sessions).toEqual([
  166. expect.objectContaining({ id: 'ios-session', title: 'iOS 新会话' }),
  167. ])
  168. } finally {
  169. vi.useRealTimers()
  170. }
  171. })
  172. it.each(['session.title', 'session.title.updated'])(
  173. 'applies %s immediately and schedules one authoritative list refresh',
  174. async eventType => {
  175. vi.useFakeTimers()
  176. try {
  177. const chat = useChatStore()
  178. chat.sessions = [{
  179. id: 'session-1', profile: 'alpha', source: 'web', title: '新会话',
  180. messageCount: 2, toolCallCount: 0, startedAt: 1, updatedAt: 1,
  181. }]
  182. chat.routes[routeKey('alpha', 'session-1')] = {
  183. route: { profile: 'alpha', sessionId: 'session-1' }, runtimeSessionId: 'runtime-1',
  184. messages: [], historySynced: true, hasMoreBefore: false, loadedMessageCount: 0,
  185. messageTotal: 0, isLoadingHistory: false, isStreaming: false, isQueued: false, generation: 1,
  186. }
  187. sessionsApi.getSessions.mockResolvedValue({
  188. items: [{ ...chat.sessions[0], title: '服务端自动标题' }],
  189. nextCursor: null,
  190. })
  191. realtime.eventHandler?.({
  192. type: eventType,
  193. session_id: 'runtime-1',
  194. payload: { session_id: 'session-1', title: '服务端自动标题' },
  195. })
  196. expect(chat.sessions[0].title).toBe('服务端自动标题')
  197. expect(sessionsApi.getSessions).not.toHaveBeenCalled()
  198. await vi.advanceTimersByTimeAsync(200)
  199. expect(sessionsApi.getSessions).toHaveBeenCalledTimes(1)
  200. } finally {
  201. vi.useRealTimers()
  202. }
  203. },
  204. )
  205. it('uses a synchronized route without history reads and forces refresh only on request', async () => {
  206. const chat = useChatStore()
  207. const key = routeKey('alpha', 'session-1')
  208. chat.activeSessionId = 'session-1'
  209. chat.activeProfileName = 'alpha'
  210. chat.routes[key] = {
  211. route: { profile: 'alpha', sessionId: 'session-1' },
  212. messages: [
  213. { id: 'old', sessionId: 'session-1', role: 'assistant', content: 'old history', timestamp: 1, stage: 'settled' },
  214. { id: 'unsent', sessionId: 'session-1', role: 'user', content: 'keep failed send', timestamp: 2, stage: 'failed' },
  215. ], historySynced: true, hasMoreBefore: false, loadedMessageCount: 1, messageTotal: 1,
  216. isLoadingHistory: false, isStreaming: false, isQueued: false, generation: 1,
  217. }
  218. for (let i = 0; i < 5; i++) await chat.selectSession('session-1', 'alpha')
  219. expect(sessionsApi.getMessages).not.toHaveBeenCalled()
  220. sessionsApi.getMessages.mockResolvedValue({ messages: [
  221. { id: 'new', sessionId: 'session-1', role: 'assistant', content: 'authoritative', timestamp: 1, stage: 'settled' },
  222. ], returned: 1, total: 1, hasMore: false })
  223. await chat.forceRefreshHistory()
  224. expect(sessionsApi.requestHistorySync).toHaveBeenCalledWith('session-1','alpha')
  225. expect(sessionsApi.getMessages).not.toHaveBeenCalled()
  226. expect(chat.messages.map(message => message.id)).toEqual(['old', 'unsent'])
  227. expect(chat.routes[key].error).toContain('后台补齐')
  228. })
  229. })