chatCrossClientSync.test.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  1. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  2. import { createPinia, setActivePinia } from 'pinia'
  3. import type { TranscriptSnapshot } from '@shared/chatTranscript'
  4. import type { RpcEventFrame, RealtimeConnectionState } from '@shared/types'
  5. const wire = vi.hoisted(() => ({
  6. event: undefined as ((e: RpcEventFrame['params']) => void) | undefined,
  7. state: undefined as ((s: RealtimeConnectionState) => void) | undefined,
  8. request: vi.fn(),
  9. clients: [] as any[],
  10. owner: 0,
  11. supported: true,
  12. }))
  13. const api = vi.hoisted(() => ({ messages: vi.fn(), sessions: vi.fn() }))
  14. vi.mock('@/api/realtime', () => ({
  15. RpcError: class extends Error {},
  16. ChatRpcSocket: class {
  17. get transcriptSupported() {
  18. return wire.supported
  19. }
  20. onEvent(f: typeof wire.event) {
  21. wire.event = f
  22. }
  23. onState(f: typeof wire.state) {
  24. wire.state = f
  25. }
  26. async connect() {
  27. wire.state?.('ready')
  28. }
  29. close() {}
  30. async request(...args: unknown[]) {
  31. return wire.request(...args)
  32. }
  33. },
  34. }))
  35. vi.mock('@/api/chatTranscript', () => ({
  36. ChatTranscriptClient: class {
  37. close = vi.fn()
  38. loadOlder = vi.fn()
  39. restore = vi.fn()
  40. run = vi.fn()
  41. constructor(
  42. readonly profile: string,
  43. readonly id: string,
  44. readonly changed: (value: TranscriptSnapshot) => Promise<void>,
  45. readonly failed: (error: Error) => void,
  46. ) {
  47. wire.clients.push(this)
  48. }
  49. },
  50. }))
  51. vi.mock('@/api/sessions', () => ({
  52. getMessages: api.messages,
  53. getSessions: api.sessions,
  54. getSession: vi.fn(),
  55. deleteSession: vi.fn(),
  56. getSessionUnread: vi.fn(),
  57. markSessionRead: vi.fn(),
  58. updateSession: vi.fn(),
  59. requestHistorySync: vi.fn(),
  60. }))
  61. vi.mock('@/api/profiles', () => ({ getModels: vi.fn() }))
  62. vi.mock('@/stores/auth', () => ({
  63. useAuthStore: () => ({
  64. user: { id: `v2-viewer-${wire.owner}` },
  65. activeProfile: { name: 'p' },
  66. status: 'authenticated',
  67. isAuthenticated: true,
  68. }),
  69. }))
  70. import { useChatStore } from '@/stores/chat'
  71. import { ScopedCache } from '@/utils/cache'
  72. function session(id = 's', owned = true) {
  73. return {
  74. id,
  75. profile: 'p',
  76. owned,
  77. source: 'web',
  78. title: id,
  79. messageCount: 0,
  80. toolCallCount: 0,
  81. startedAt: 1,
  82. updatedAt: 1,
  83. }
  84. }
  85. function snapshot(messages: any[], id = 's', extra: Partial<TranscriptSnapshot> = {}): TranscriptSnapshot {
  86. return {
  87. protocol: 'ordinary-chat-transcript-v2',
  88. epoch: 'test',
  89. cursor: 1,
  90. profile: 'p',
  91. sessionId: id,
  92. messages,
  93. session: {},
  94. hasOlder: false,
  95. total: messages.length,
  96. state: 'current',
  97. running: false,
  98. queued: false,
  99. ...extra,
  100. }
  101. }
  102. const answer = (content = '规范正文', extra = {}) => ({
  103. id: 'canonical-answer',
  104. seq: 2,
  105. revision: 1,
  106. source_message_id: 'upstream',
  107. turn_id: 'turn',
  108. role: 'assistant',
  109. content,
  110. status: 'complete',
  111. ...extra,
  112. })
  113. function emit(type: string, payload: any = {}) {
  114. wire.event?.({ type, session_id: 'runtime', profile: 'p', payload })
  115. }
  116. describe('ordinary v2 cross-client store', () => {
  117. let chat: ReturnType<typeof useChatStore>
  118. beforeEach(() => {
  119. setActivePinia(createPinia())
  120. wire.owner++
  121. wire.clients = []
  122. wire.supported = true
  123. wire.request
  124. .mockReset()
  125. .mockResolvedValue({ session_id: 'runtime', stored_session_id: 's', running: false })
  126. api.messages.mockReset().mockResolvedValue({ messages: [], total: 0, returned: 0, hasMore: false })
  127. api.sessions.mockReset().mockResolvedValue({ items: [session()] })
  128. chat = useChatStore()
  129. chat.sessions = [session()]
  130. })
  131. afterEach(() => {
  132. chat.disconnect()
  133. vi.useRealTimers()
  134. })
  135. async function open(id = 's') {
  136. await chat.selectSession(id, 'p')
  137. await vi.waitFor(() => expect(wire.clients.some((c) => c.id === id)).toBe(true))
  138. return wire.clients.filter((c) => c.id === id).at(-1)!
  139. }
  140. it('refreshes an opened owned session with the same gauge source order as mobile', async () => {
  141. wire.request.mockImplementation(async (method: string) => {
  142. if (method === 'session.resume') return { session_id: 'runtime', stored_session_id: 's' }
  143. if (method === 'session.usage') return { input_tokens: 1_800_000, output_tokens: 100_000, total_tokens: 1_900_000 }
  144. if (method === 'session.context_breakdown') return { context_used: 12_500, context_max: 114_688, context_percent: 10.9 }
  145. return {}
  146. })
  147. await open()
  148. await vi.waitFor(() => expect(chat.contextUsage).toMatchObject({
  149. totalTokens: 1_900_000, contextTokens: 12_500, contextLimit: 114_688, percentUsed: 10.9,
  150. }))
  151. expect(wire.request).toHaveBeenCalledWith('session.usage', { session_id: 'runtime' }, 15_000)
  152. expect(wire.request).toHaveBeenCalledWith('session.context_breakdown', { session_id: 'runtime' }, 15_000)
  153. })
  154. it('keeps read-only history unattached when context statistics are unavailable', async () => {
  155. chat.sessions = [session('s', false)]
  156. await chat.selectSession('s', 'p')
  157. expect(wire.request).not.toHaveBeenCalled()
  158. expect(chat.contextUsage).toBeUndefined()
  159. })
  160. it('does not let a slow usage response replace a newer realtime gauge', async () => {
  161. await open()
  162. let finish!: (value: unknown) => void
  163. wire.request.mockImplementation((method: string) => method === 'session.usage'
  164. ? new Promise(resolve => { finish = resolve }) : Promise.resolve({}))
  165. const refreshing = chat.refreshContextUsage()
  166. emit('session.usage', { context_used: 4000, context_max: 8000, context_percent: 50 })
  167. finish({ context_used: 1000, context_max: 8000, context_percent: 12.5 })
  168. await refreshing
  169. expect(chat.contextUsage).toMatchObject({ contextTokens: 4000, contextLimit: 8000, percentUsed: 50 })
  170. })
  171. it('refreshes context after a turn completed on another client', async () => {
  172. const client = await open()
  173. await client.changed(snapshot([answer('处理中', { status: 'streaming' })], 's', { running: true }))
  174. wire.request.mockImplementation(async (method: string) => method === 'session.usage'
  175. ? { context_used: 4000, context_max: 8000 } : {})
  176. await client.changed(snapshot([answer()], 's', { running: false }))
  177. await vi.waitFor(() => expect(chat.contextUsage).toMatchObject({ contextTokens: 4000, contextLimit: 8000 }))
  178. })
  179. it('restores a confirmed gauge from session metadata without inventing context from history', async () => {
  180. wire.request.mockResolvedValue({ session_id: 'runtime', stored_session_id: 's',
  181. info: { usage: { context_used: 2000, context_max: 8000 } } })
  182. const client = await open()
  183. expect(chat.contextUsage).toMatchObject({ contextTokens: 2000, contextLimit: 8000 })
  184. await client.changed(snapshot([answer('很长的旧记录'.repeat(200))], 's', {
  185. session: { input_tokens: 1_800_000, output_tokens: 100_000 },
  186. }))
  187. expect(chat.contextUsage).toMatchObject({ contextTokens: 2000, contextLimit: 8000 })
  188. })
  189. it.each(['image_path', 'path', 'ref_path'])('preserves uploaded image %s in pending and canonical messages', async field => {
  190. const client = await open()
  191. const paths = ['/home/user/.hermes/images/照片 one.png', '/tmp/photo-two.png']
  192. let uploaded = 0
  193. wire.request.mockImplementation((method: string) => Promise.resolve(
  194. method === 'image.attach_bytes' ? { [field]: paths[uploaded++] } : {},
  195. ))
  196. await chat.send('查看图片', paths.map((_, index) => new File(['image'], `photo-${index}.png`, { type: 'image/png' })))
  197. const submission = wire.request.mock.calls.find(([method]) => method === 'prompt.submit')![1]
  198. expect(submission.text).toBe([
  199. '查看图片',
  200. `[用户附加图片:photo-0.png]\n@image:${paths[0]}\n[screenshot]`,
  201. `[用户附加图片:photo-1.png]\n@image:${paths[1]}\n[screenshot]`,
  202. ].join('\n\n'))
  203. const pending = chat.messages.find(message => message.role === 'user')!
  204. expect(pending.attachments?.map(file => file.path)).toEqual(paths)
  205. const checkDownloads = () => chat.messages[0]!.attachments?.forEach((file, index) => {
  206. const url = new URL(file.url!, 'https://yaoyao.test')
  207. expect(url.pathname).toBe('/api/files/download')
  208. expect(url.searchParams.get('path')).toBe(paths[index])
  209. expect(url.searchParams.get('profile')).toBe('p')
  210. })
  211. checkDownloads()
  212. await client.changed(snapshot([{
  213. id: 'canonical-image', seq: 1, revision: 1, role: 'user', status: 'complete',
  214. client_message_id: pending.clientMessageId, content: submission.text,
  215. }]))
  216. expect(chat.messages).toHaveLength(1)
  217. expect(chat.messages[0]!.content).toBe('查看图片')
  218. expect(chat.messages[0]!.attachments?.map(file => file.path)).toEqual(paths)
  219. checkDownloads()
  220. })
  221. it('does not fabricate an image path when an older upload response omits it', async () => {
  222. await open()
  223. wire.request.mockResolvedValue({})
  224. await chat.send('', [new File(['image'], 'photo.png', { type: 'image/png' })])
  225. const submission = wire.request.mock.calls.find(([method]) => method === 'prompt.submit')![1]
  226. expect(submission.text).toBe('[用户附加图片:photo.png]\n[screenshot]')
  227. expect(chat.messages[0]!.attachments?.[0]?.url).toBeUndefined()
  228. })
  229. it.each(['failed', 'unknown-receipt'] as const)('keeps an older %s input before newer messages through sync and cache restoration', async stage => {
  230. const client = await open()
  231. const oldAnswer = answer('之前的回复', { timestamp: 10 })
  232. await client.changed(snapshot([oldAnswer]))
  233. chat.activeRouteState!.messages.push({
  234. id: 'local-failed', clientMessageId: 'local-failed', sessionId: 's', profile: 'p',
  235. role: 'user', content: '再试试', timestamp: 15, stage,
  236. })
  237. const newerUser = { id: 'new-user', seq: 3, revision: 1, role: 'user', content: '再试试', timestamp: 20, status: 'complete' }
  238. const newerAnswer = answer('新的回复', { id: 'new-answer', seq: 4, timestamp: 21 })
  239. const expected = ['canonical-answer', 'local-failed', 'new-user', 'new-answer']
  240. await client.changed(snapshot([oldAnswer, newerUser, newerAnswer], 's', { cursor: 2 }))
  241. expect(chat.messages.map(m => m.id)).toEqual(expected)
  242. await client.changed(snapshot([oldAnswer, newerUser, { ...newerAnswer, revision: 2 }], 's', { cursor: 3 }))
  243. expect(chat.messages.map(m => m.id)).toEqual(expected)
  244. const cached = await new ScopedCache<{ messages: { id: string }[] }>('chat-history-v1').get(`v2-viewer-${wire.owner}:p`, 's')
  245. expect(cached?.messages.map(m => m.id)).toEqual(expected)
  246. chat.disconnect()
  247. setActivePinia(createPinia())
  248. chat = useChatStore()
  249. chat.sessions = [session()]
  250. wire.clients = []
  251. const restored = await open()
  252. expect(chat.messages.map(m => m.id)).toEqual(expected)
  253. await restored.changed(snapshot([oldAnswer, newerUser, newerAnswer], 's', { cursor: 4 }))
  254. expect(chat.messages.map(m => m.id)).toEqual(expected)
  255. expect(chat.messages.find(m => m.id === 'local-failed')?.stage).toBe(stage)
  256. })
  257. it('keeps a model-switch confirmation through transcript updates and sends after confirmation', async () => {
  258. const client = await open()
  259. const model = { id: 'model-b', name: 'Model B', provider: 'provider-b' }
  260. chat.models = [model]
  261. wire.request.mockImplementation(async (method, params) => {
  262. if (method === 'session.resume') return { session_id: 'runtime', stored_session_id: 's', running: false }
  263. if (method === 'config.set' && params.key === 'model') return params.confirm_expensive_model
  264. ? { value: model.id, scope: 'session' }
  265. : { confirm_required: true, confirm_message: 'Switching this long conversation loses its cached input.' }
  266. return {}
  267. })
  268. await chat.setModel(model)
  269. await expect(chat.send('待发送')).rejects.toThrow('请先确认模型切换')
  270. const confirmation = chat.pendingApproval
  271. expect(confirmation?.message).toContain('cached input')
  272. await client.changed(snapshot([answer()], 's', { pendingApproval: null }))
  273. expect(chat.pendingApproval).toEqual(confirmation)
  274. await chat.respondToApproval(confirmation!.id, 'once')
  275. expect(wire.request).toHaveBeenCalledWith('config.set', expect.objectContaining({
  276. key: 'model', confirm_expensive_model: true,
  277. }))
  278. expect(chat.pendingApproval).toBeUndefined()
  279. wire.request.mockClear()
  280. await chat.send('确认后继续')
  281. expect(wire.request).toHaveBeenCalledWith('prompt.submit', expect.objectContaining({ text: '确认后继续' }), expect.anything(), expect.anything())
  282. expect(wire.request.mock.calls.some(([method, params]) => method === 'config.set' && params.key === 'model')).toBe(false)
  283. })
  284. it('keeps native approvals separate and dismisses a superseded model confirmation', async () => {
  285. const client = await open()
  286. const first = { id: 'model-b', provider: 'provider', name: 'B' }
  287. const second = { id: 'model-c', provider: 'provider', name: 'C' }
  288. wire.request.mockImplementation(async (method, params) => method === 'config.set' && params.key === 'model'
  289. ? { confirm_required: true, confirm_message: `Confirm ${params.value}` } : {})
  290. await chat.setModel(first)
  291. const staleId = chat.pendingApproval!.id
  292. await chat.setModel(second)
  293. const confirmation = chat.pendingApproval!
  294. expect(confirmation.id).not.toBe(staleId)
  295. await client.changed(snapshot([], 's', { pendingApproval: { request_id: 'tool-approval', message: '允许工具' } }))
  296. expect(chat.pendingApproval?.id).toBe(confirmation.id)
  297. wire.request.mockClear()
  298. await chat.respondToApproval(staleId, 'once')
  299. expect(wire.request).not.toHaveBeenCalled()
  300. await chat.respondToApproval(confirmation.id, 'deny')
  301. expect(chat.pendingApproval?.id).toBe('tool-approval')
  302. await chat.respondToApproval('tool-approval', 'once')
  303. expect(wire.request).toHaveBeenCalledWith('approval.respond', expect.objectContaining({ choice: 'once' }))
  304. })
  305. it('renders only canonical messages and ignores raw upstream bodies and completions', async () => {
  306. const client = await open()
  307. await client.changed(snapshot([answer('前半', { status: 'streaming' })], 's', { running: true }))
  308. emit('message.delta', { text: '不应追加' })
  309. emit('message.interim', { text: '不应重复' })
  310. emit('message.complete', { text: '不应替换' })
  311. expect(chat.messages.map((m) => m.content)).toEqual(['前半'])
  312. expect(chat.isStreaming).toBe(true)
  313. await client.changed(snapshot([answer('完整正文', { revision: 2 })], 's', { cursor: 2 }))
  314. expect(chat.messages.map((m) => m.content)).toEqual(['完整正文'])
  315. expect(chat.isStreaming).toBe(false)
  316. expect(api.messages).not.toHaveBeenCalled()
  317. })
  318. it('keeps checkpoint state when a usage update replaces the route during outbox persistence', async () => {
  319. const client = await open()
  320. const original = ScopedCache.prototype.set
  321. let entered!: () => void, release!: () => void
  322. const started = new Promise<void>(resolve => { entered = resolve })
  323. const gate = new Promise<void>(resolve => { release = resolve })
  324. const spy = vi.spyOn(ScopedCache.prototype, 'set').mockImplementation(async function(this: ScopedCache<unknown>, scope, key, value, durable) {
  325. if ((this as unknown as { namespace: string }).namespace === 'ordinary-outbox-v2') {
  326. entered()
  327. await gate
  328. }
  329. await original.call(this, scope, key, value, durable)
  330. })
  331. const saving = client.changed(snapshot([answer('正在处理', { status: 'streaming' })], 's', {
  332. running: true, liveStatus: '正在思考', pendingApproval: { request_id: 'approval-1', message: '请确认' },
  333. }))
  334. try {
  335. await started
  336. wire.event?.({ type: 'session.usage', session_id: 's', profile: 'p', payload: { input_tokens: 7 } })
  337. release()
  338. await saving
  339. expect(chat.messages.map(m => m.content)).toEqual(['正在处理'])
  340. expect(chat.isStreaming).toBe(true)
  341. expect(chat.pendingApproval?.id).toBe('approval-1')
  342. expect(chat.contextUsage?.inputTokens).toBe(7)
  343. } finally {
  344. release()
  345. await saving.catch(() => {})
  346. spy.mockRestore()
  347. }
  348. })
  349. it('does not materialize a resume snapshot beside the canonical reply', async () => {
  350. wire.request.mockResolvedValue({
  351. session_id: 'runtime',
  352. stored_session_id: 's',
  353. running: true,
  354. inflight: { assistant: '旧恢复快照' },
  355. })
  356. const client = await open()
  357. await client.changed(snapshot([answer('当前正文')]))
  358. expect(chat.messages.map((m) => m.content)).toEqual(['当前正文'])
  359. expect(chat.messages[0]!.id).toBe('canonical-answer')
  360. })
  361. it('restores tools and pending interactions from the canonical snapshot', async () => {
  362. const client = await open()
  363. await client.changed(
  364. snapshot(
  365. [
  366. answer('', {
  367. status: 'streaming',
  368. tool_calls: [{ id: 'tool', name: 'terminal', status: 'running' }],
  369. }),
  370. ],
  371. 's',
  372. {
  373. running: true,
  374. pendingApproval: { request_id: 'approval', message: '允许执行' },
  375. pendingClarification: { request_id: 'clarify', question: '请选择' },
  376. },
  377. ),
  378. )
  379. expect(chat.messages[0]!.toolCalls?.[0]?.id).toBe('tool')
  380. expect(chat.pendingApproval?.id).toBe('approval')
  381. expect(chat.pendingClarification?.id).toBe('clarify')
  382. await client.changed(
  383. snapshot([answer()], 's', { cursor: 2, pendingApproval: null, pendingClarification: null }),
  384. )
  385. expect(chat.pendingApproval).toBeUndefined()
  386. expect(chat.pendingClarification).toBeUndefined()
  387. })
  388. it('rejects a closed session callback after switching to another conversation', async () => {
  389. const first = await open()
  390. chat.sessions.push(session('second'))
  391. wire.request.mockResolvedValue({ session_id: 'runtime-2', stored_session_id: 'second' })
  392. const second = await open('second')
  393. await second.changed(snapshot([answer('第二个会话')], 'second'))
  394. await first.changed(snapshot([answer('旧回调')]))
  395. expect(chat.activeSessionId).toBe('second')
  396. expect(chat.messages[0]!.content).toBe('第二个会话')
  397. })
  398. it('does not let a late submit receipt re-open a completed turn or duplicate the user', async () => {
  399. const client = await open()
  400. let accept!: (value: any) => void
  401. wire.request.mockImplementation((method: string) =>
  402. method === 'prompt.submit'
  403. ? new Promise((resolve) => {
  404. accept = resolve
  405. })
  406. : Promise.resolve({}),
  407. )
  408. const sending = chat.send('问题')
  409. await vi.waitFor(() => expect(accept).toBeTypeOf('function'))
  410. const local = chat.messages.find((m) => m.role === 'user')!
  411. await client.changed(
  412. snapshot(
  413. [
  414. {
  415. id: 'canonical-user',
  416. seq: 1,
  417. revision: 2,
  418. source_message_id: 'persisted-user',
  419. client_message_id: local.clientMessageId,
  420. role: 'user',
  421. content: '问题',
  422. status: 'complete',
  423. },
  424. answer(),
  425. ],
  426. 's',
  427. { cursor: 3, running: false },
  428. ),
  429. )
  430. accept({ status: 'streaming' })
  431. await sending
  432. expect(chat.messages.filter((m) => m.role === 'user')).toHaveLength(1)
  433. expect(chat.messages[0]!.stage).toBe('settled')
  434. expect(chat.isStreaming).toBe(false)
  435. })
  436. it('keeps a pending local input during a new generation replacement', async () => {
  437. const client = await open()
  438. let reject!: (error: Error) => void
  439. wire.request.mockImplementation((method: string) =>
  440. method === 'prompt.submit'
  441. ? new Promise((_resolve, r) => {
  442. reject = r
  443. })
  444. : Promise.resolve({}),
  445. )
  446. const sending = chat.send('待确认').catch(() => {})
  447. await vi.waitFor(() => expect(reject).toBeTypeOf('function'))
  448. const local = chat.messages[0]!.id
  449. await client.changed(snapshot([answer('历史重建')], 's', { epoch: 'rebuilt' }))
  450. expect(chat.messages.some((m) => m.id === local)).toBe(true)
  451. reject(new Error('connection lost'))
  452. await sending
  453. })
  454. it('uses transcript pagination without requesting legacy message pages', async () => {
  455. const client = await open()
  456. await client.changed(snapshot([answer()], 's', { hasOlder: true }))
  457. await chat.loadOlder()
  458. expect(client.loadOlder).toHaveBeenCalledOnce()
  459. expect(api.messages).not.toHaveBeenCalled()
  460. })
  461. it('requires v2 and never falls back to legacy history for owned chat', async () => {
  462. wire.supported = false
  463. await expect(chat.selectSession('s', 'p')).rejects.toThrow('升级服务端')
  464. expect(api.messages).not.toHaveBeenCalled()
  465. })
  466. it('keeps read-only native history outside the ordinary transcript', async () => {
  467. chat.sessions = [session('s', false)]
  468. await chat.selectSession('s', 'p')
  469. expect(api.messages).toHaveBeenCalledOnce()
  470. expect(wire.request).not.toHaveBeenCalled()
  471. })
  472. })