chatModelSync.test.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  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(() => ({ getSession: vi.fn() }))
  9. vi.mock('@/api/realtime', () => {
  10. class RpcError extends Error {}
  11. class ChatRpcSocket {
  12. onState(): void {}
  13. onEvent(handler: (event: unknown) => void): void { realtime.eventHandler = handler }
  14. async connect(): Promise<void> {}
  15. close(): void {}
  16. async request(...args: unknown[]): Promise<unknown> { return realtime.request(...args) }
  17. }
  18. return { ChatRpcSocket, RpcError }
  19. })
  20. vi.mock('@/api/sessions', () => ({
  21. deleteSession: vi.fn(),
  22. getMessages: vi.fn(),
  23. getSession: sessionsApi.getSession,
  24. getSessions: vi.fn(),
  25. getSessionUnread: vi.fn(),
  26. markSessionRead: vi.fn(),
  27. updateSession: vi.fn(),
  28. }))
  29. vi.mock('@/api/profiles', () => ({ getModels: vi.fn() }))
  30. vi.mock('@/stores/auth', () => ({
  31. useAuthStore: () => ({
  32. user: { id: 'account-1' },
  33. activeProfile: { name: 'alpha' },
  34. status: 'authenticated',
  35. }),
  36. }))
  37. import { useChatStore } from '@/stores/chat'
  38. describe('chat model realtime synchronization', () => {
  39. beforeEach(() => {
  40. realtime.eventHandler = undefined
  41. realtime.request.mockReset().mockResolvedValue({})
  42. sessionsApi.getSession.mockReset()
  43. setActivePinia(createPinia())
  44. })
  45. it('applies another viewer model switch to the active selector and session summary', () => {
  46. const chat = useChatStore()
  47. const oldModel = { id: 'model-a', name: 'Model A', provider: 'provider-a' }
  48. const newModel = { id: 'model-b', name: 'Model B', provider: 'provider-b' }
  49. chat.models = [oldModel, newModel]
  50. chat.sessions = [{
  51. id: 'session-1', profile: 'alpha', source: 'web', title: '会话',
  52. model: oldModel.id, provider: oldModel.provider, messageCount: 0,
  53. toolCallCount: 0, startedAt: 1, updatedAt: 1,
  54. }]
  55. chat.activeSessionId = 'session-1'
  56. chat.activeProfileName = 'alpha'
  57. chat.routes[routeKey('alpha', 'session-1')] = {
  58. route: { profile: 'alpha', sessionId: 'session-1' },
  59. runtimeSessionId: 'runtime-1', messages: [], historySynced: true,
  60. hasMoreBefore: false, loadedMessageCount: 0, messageTotal: 0,
  61. isLoadingHistory: false, isStreaming: false, isQueued: false, generation: 1,
  62. }
  63. chat.selectedModel = oldModel
  64. realtime.eventHandler?.({
  65. type: 'session.info', session_id: 'runtime-1',
  66. payload: { info: { model: newModel.id, provider: newModel.provider } },
  67. })
  68. expect(chat.selectedModel).toEqual(newModel)
  69. expect(chat.sessions[0]).toMatchObject({ model: newModel.id, provider: newModel.provider })
  70. })
  71. it('reconciles the active model through REST when another client owns realtime events', async () => {
  72. const chat = useChatStore()
  73. const oldModel = { id: 'model-a', name: 'Model A', provider: 'provider-a' }
  74. const newModel = { id: 'model-b', name: 'Model B', provider: 'provider-b' }
  75. chat.models = [oldModel, newModel]
  76. chat.sessions = [{
  77. id: 'session-1', profile: 'alpha', source: 'web', title: '会话',
  78. model: oldModel.id, provider: oldModel.provider, messageCount: 0,
  79. toolCallCount: 0, startedAt: 1, updatedAt: 1,
  80. }]
  81. chat.activeSessionId = 'session-1'
  82. chat.activeProfileName = 'alpha'
  83. chat.routes[routeKey('alpha', 'session-1')] = {
  84. route: { profile: 'alpha', sessionId: 'session-1' }, messages: [], historySynced: true,
  85. hasMoreBefore: false, loadedMessageCount: 0, messageTotal: 0,
  86. isLoadingHistory: false, isStreaming: false, isQueued: false, generation: 1,
  87. }
  88. chat.selectedModel = oldModel
  89. sessionsApi.getSession.mockResolvedValue({
  90. ...chat.sessions[0], model: newModel.id, provider: newModel.provider,
  91. })
  92. await chat.refreshActiveSessionModel()
  93. expect(sessionsApi.getSession).toHaveBeenCalledWith('session-1', 'alpha')
  94. expect(chat.selectedModel).toEqual(newModel)
  95. })
  96. it('releases the local model guard after a failed switch', async () => {
  97. const chat = useChatStore()
  98. const oldModel = { id: 'model-a', name: 'Model A', provider: 'provider-a' }
  99. const newModel = { id: 'model-b', name: 'Model B', provider: 'provider-b' }
  100. chat.models = [oldModel, newModel]
  101. chat.sessions = [{
  102. id: 'session-1', profile: 'alpha', source: 'web', title: '会话',
  103. model: oldModel.id, provider: oldModel.provider, messageCount: 0,
  104. toolCallCount: 0, startedAt: 1, updatedAt: 1,
  105. }]
  106. chat.activeSessionId = 'session-1'
  107. chat.activeProfileName = 'alpha'
  108. chat.routes[routeKey('alpha', 'session-1')] = {
  109. route: { profile: 'alpha', sessionId: 'session-1' }, runtimeSessionId: 'runtime-1',
  110. messages: [], historySynced: true, hasMoreBefore: false, loadedMessageCount: 0,
  111. messageTotal: 0, isLoadingHistory: false, isStreaming: false, isQueued: false, generation: 1,
  112. }
  113. chat.selectedModel = oldModel
  114. realtime.request.mockRejectedValueOnce(new Error('switch failed'))
  115. await expect(chat.setModel(newModel)).rejects.toThrow('switch failed')
  116. expect(chat.selectedModel).toEqual(oldModel)
  117. realtime.eventHandler?.({
  118. type: 'session.info', session_id: 'runtime-1',
  119. payload: { model: newModel.id, provider: newModel.provider },
  120. })
  121. expect(chat.selectedModel).toEqual(newModel)
  122. })
  123. it('does not resume an old session merely to show its context usage', async () => {
  124. const chat = useChatStore()
  125. chat.sessions = [{
  126. id: 'session-1', profile: 'alpha', source: 'web', title: '旧会话',
  127. messageCount: 1, toolCallCount: 0, startedAt: 1, updatedAt: 1,
  128. }]
  129. chat.routes[routeKey('alpha', 'session-1')] = {
  130. route: { profile: 'alpha', sessionId: 'session-1' }, messages: [], historySynced: true,
  131. hasMoreBefore: false, loadedMessageCount: 0, messageTotal: 0,
  132. isLoadingHistory: false, isStreaming: false, isQueued: false, generation: 1,
  133. }
  134. await chat.selectSession('session-1', 'alpha')
  135. await Promise.resolve()
  136. expect(realtime.request).not.toHaveBeenCalled()
  137. })
  138. })