chatModelSync.test.ts 10 KB

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