chatModelSync.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  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('keeps an unsent draft in its stable empty state without requesting history', async () => {
  55. const chat = useChatStore()
  56. const id = chat.createSession('alpha')
  57. chat.clearSelection()
  58. await chat.selectSession(id, 'alpha')
  59. expect(sessionsApi.getMessages).not.toHaveBeenCalled()
  60. expect(chat.activeRouteState?.historySynced).toBe(true)
  61. expect(chat.activeRouteState?.isLoadingHistory).toBe(false)
  62. })
  63. it('applies another viewer model switch to the active selector and session summary', () => {
  64. const chat = useChatStore()
  65. const oldModel = { id: 'model-a', name: 'Model A', provider: 'provider-a' }
  66. const newModel = { id: 'model-b', name: 'Model B', provider: 'provider-b' }
  67. chat.models = [oldModel, newModel]
  68. chat.sessions = [{
  69. id: 'session-1', profile: 'alpha', source: 'web', title: '会话',
  70. model: oldModel.id, provider: oldModel.provider, messageCount: 0,
  71. toolCallCount: 0, startedAt: 1, updatedAt: 1,
  72. }]
  73. chat.activeSessionId = 'session-1'
  74. chat.activeProfileName = 'alpha'
  75. chat.routes[routeKey('alpha', 'session-1')] = {
  76. route: { profile: 'alpha', sessionId: 'session-1' },
  77. runtimeSessionId: 'runtime-1', messages: [], historySynced: true,
  78. hasMoreBefore: false, loadedMessageCount: 0, messageTotal: 0,
  79. isLoadingHistory: false, isStreaming: false, isQueued: false, generation: 1,
  80. }
  81. chat.selectedModel = oldModel
  82. realtime.eventHandler?.({
  83. type: 'session.info', session_id: 'runtime-1',
  84. payload: { info: { model: newModel.id, provider: newModel.provider } },
  85. })
  86. expect(chat.selectedModel).toEqual(newModel)
  87. expect(chat.sessions[0]).toMatchObject({ model: newModel.id, provider: newModel.provider })
  88. })
  89. it('reconciles the active model through REST when another client owns realtime events', async () => {
  90. const chat = useChatStore()
  91. const oldModel = { id: 'model-a', name: 'Model A', provider: 'provider-a' }
  92. const newModel = { id: 'model-b', name: 'Model B', provider: 'provider-b' }
  93. chat.models = [oldModel, newModel]
  94. chat.sessions = [{
  95. id: 'session-1', profile: 'alpha', source: 'web', title: '会话',
  96. model: oldModel.id, provider: oldModel.provider, messageCount: 0,
  97. toolCallCount: 0, startedAt: 1, updatedAt: 1,
  98. }]
  99. chat.activeSessionId = 'session-1'
  100. chat.activeProfileName = 'alpha'
  101. chat.routes[routeKey('alpha', 'session-1')] = {
  102. route: { profile: 'alpha', sessionId: 'session-1' }, messages: [], historySynced: true,
  103. hasMoreBefore: false, loadedMessageCount: 0, messageTotal: 0,
  104. isLoadingHistory: false, isStreaming: false, isQueued: false, generation: 1,
  105. }
  106. chat.selectedModel = oldModel
  107. sessionsApi.getSession.mockResolvedValue({
  108. ...chat.sessions[0], model: newModel.id, provider: newModel.provider,
  109. })
  110. await chat.refreshActiveSessionModel()
  111. expect(sessionsApi.getSession).toHaveBeenCalledWith('session-1', 'alpha')
  112. expect(chat.selectedModel).toEqual(newModel)
  113. })
  114. it('releases the local model guard after a failed switch', async () => {
  115. const chat = useChatStore()
  116. const oldModel = { id: 'model-a', name: 'Model A', provider: 'provider-a' }
  117. const newModel = { id: 'model-b', name: 'Model B', provider: 'provider-b' }
  118. chat.models = [oldModel, newModel]
  119. chat.sessions = [{
  120. id: 'session-1', profile: 'alpha', source: 'web', title: '会话',
  121. model: oldModel.id, provider: oldModel.provider, messageCount: 0,
  122. toolCallCount: 0, startedAt: 1, updatedAt: 1,
  123. }]
  124. chat.activeSessionId = 'session-1'
  125. chat.activeProfileName = 'alpha'
  126. chat.routes[routeKey('alpha', 'session-1')] = {
  127. route: { profile: 'alpha', sessionId: 'session-1' }, runtimeSessionId: 'runtime-1',
  128. messages: [], historySynced: true, hasMoreBefore: false, loadedMessageCount: 0,
  129. messageTotal: 0, isLoadingHistory: false, isStreaming: false, isQueued: false, generation: 1,
  130. }
  131. chat.selectedModel = oldModel
  132. realtime.request.mockRejectedValueOnce(new Error('switch failed'))
  133. await expect(chat.setModel(newModel)).rejects.toThrow('switch failed')
  134. expect(chat.selectedModel).toEqual(oldModel)
  135. realtime.eventHandler?.({
  136. type: 'session.info', session_id: 'runtime-1',
  137. payload: { model: newModel.id, provider: newModel.provider },
  138. })
  139. expect(chat.selectedModel).toEqual(newModel)
  140. })
  141. it('does not resume an old session merely to show its context usage', async () => {
  142. const chat = useChatStore()
  143. chat.sessions = [{
  144. id: 'session-1', profile: 'alpha', source: 'web', title: '旧会话',
  145. messageCount: 1, toolCallCount: 0, startedAt: 1, updatedAt: 1,
  146. }]
  147. chat.routes[routeKey('alpha', 'session-1')] = {
  148. route: { profile: 'alpha', sessionId: 'session-1' }, messages: [], historySynced: true,
  149. hasMoreBefore: false, loadedMessageCount: 0, messageTotal: 0,
  150. isLoadingHistory: false, isStreaming: false, isQueued: false, generation: 1,
  151. }
  152. await chat.selectSession('session-1', 'alpha')
  153. await Promise.resolve()
  154. expect(realtime.request).not.toHaveBeenCalled()
  155. })
  156. it('debounces global session changes and reloads the visible server projection', async () => {
  157. vi.useFakeTimers()
  158. try {
  159. const chat = useChatStore()
  160. sessionsApi.getSessions.mockResolvedValue({
  161. items: [{
  162. id: 'ios-session', profile: 'alpha', source: 'web', title: 'iOS 新会话',
  163. messageCount: 2, toolCallCount: 0, startedAt: 2, updatedAt: 2,
  164. }],
  165. nextCursor: null,
  166. })
  167. realtime.eventHandler?.({ type: 'sessions.changed', payload: {} })
  168. realtime.eventHandler?.({ type: 'sessions.changed', payload: {} })
  169. realtime.eventHandler?.({ type: 'sessions.changed', payload: {} })
  170. expect(sessionsApi.getSessions).not.toHaveBeenCalled()
  171. await vi.advanceTimersByTimeAsync(200)
  172. expect(sessionsApi.getSessions).toHaveBeenCalledTimes(1)
  173. expect(sessionsApi.getSessions).toHaveBeenCalledWith('alpha', undefined, 100, 'chat')
  174. expect(chat.sessions).toEqual([
  175. expect.objectContaining({ id: 'ios-session', title: 'iOS 新会话' }),
  176. ])
  177. } finally {
  178. vi.useRealTimers()
  179. }
  180. })
  181. it.each(['session.title', 'session.title.updated'])(
  182. 'applies %s immediately and schedules one authoritative list refresh',
  183. async eventType => {
  184. vi.useFakeTimers()
  185. try {
  186. const chat = useChatStore()
  187. chat.sessions = [{
  188. id: 'session-1', profile: 'alpha', source: 'web', title: '新会话',
  189. messageCount: 2, toolCallCount: 0, startedAt: 1, updatedAt: 1,
  190. }]
  191. chat.routes[routeKey('alpha', 'session-1')] = {
  192. route: { profile: 'alpha', sessionId: 'session-1' }, runtimeSessionId: 'runtime-1',
  193. messages: [], historySynced: true, hasMoreBefore: false, loadedMessageCount: 0,
  194. messageTotal: 0, isLoadingHistory: false, isStreaming: false, isQueued: false, generation: 1,
  195. }
  196. sessionsApi.getSessions.mockResolvedValue({
  197. items: [{ ...chat.sessions[0], title: '服务端自动标题' }],
  198. nextCursor: null,
  199. })
  200. realtime.eventHandler?.({
  201. type: eventType,
  202. session_id: 'runtime-1',
  203. payload: { session_id: 'session-1', title: '服务端自动标题' },
  204. })
  205. expect(chat.sessions[0].title).toBe('服务端自动标题')
  206. expect(sessionsApi.getSessions).not.toHaveBeenCalled()
  207. await vi.advanceTimersByTimeAsync(200)
  208. expect(sessionsApi.getSessions).toHaveBeenCalledTimes(1)
  209. } finally {
  210. vi.useRealTimers()
  211. }
  212. },
  213. )
  214. it('uses a synchronized route without history reads and forces refresh only on request', async () => {
  215. const chat = useChatStore()
  216. const key = routeKey('alpha', 'session-1')
  217. chat.activeSessionId = 'session-1'
  218. chat.activeProfileName = 'alpha'
  219. chat.routes[key] = {
  220. route: { profile: 'alpha', sessionId: 'session-1' },
  221. messages: [
  222. { id: 'old', sessionId: 'session-1', role: 'assistant', content: 'old history', timestamp: 1, stage: 'settled' },
  223. { id: 'unsent', sessionId: 'session-1', role: 'user', content: 'keep failed send', timestamp: 2, stage: 'failed' },
  224. ], historySynced: true, hasMoreBefore: false, loadedMessageCount: 1, messageTotal: 1,
  225. isLoadingHistory: false, isStreaming: false, isQueued: false, generation: 1,
  226. }
  227. for (let i = 0; i < 5; i++) await chat.selectSession('session-1', 'alpha')
  228. expect(sessionsApi.getMessages).not.toHaveBeenCalled()
  229. sessionsApi.getMessages.mockResolvedValue({ messages: [
  230. { id: 'new', sessionId: 'session-1', role: 'assistant', content: 'authoritative', timestamp: 1, stage: 'settled' },
  231. ], returned: 1, total: 1, hasMore: false })
  232. await chat.forceRefreshHistory()
  233. expect(sessionsApi.requestHistorySync).toHaveBeenCalledWith('session-1','alpha')
  234. expect(sessionsApi.getMessages).not.toHaveBeenCalled()
  235. expect(chat.messages.map(message => message.id)).toEqual(['old', 'unsent'])
  236. expect(chat.routes[key].error).toContain('后台补齐')
  237. })
  238. })