chatModelSync.test.ts 8.7 KB

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