chatStore.test.ts 4.1 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
  1. import { describe, expect, it } from 'vitest'
  2. import type { ChatMessage, ChatRouteState, RpcEventFrame } from '@shared/types'
  3. import { applyChatEvent, mergeChatMessages } from '@/utils/messageReducer'
  4. function message(id: string, content = '相同内容', clientMessageId?: string): ChatMessage {
  5. return {
  6. id, sessionId: 'session-1', profile: 'yaoyao', role: 'user', content,
  7. clientMessageId, timestamp: 1, stage: 'settled',
  8. }
  9. }
  10. function state(): ChatRouteState {
  11. return {
  12. route: { profile: 'yaoyao', sessionId: 'session-1' }, messages: [], historySynced: true,
  13. hasMoreBefore: false, loadedMessageCount: 0, messageTotal: 0, isLoadingHistory: false,
  14. isStreaming: false, isQueued: false, generation: 1,
  15. }
  16. }
  17. function event(type: string, payload: Record<string, unknown>, sessionId = 'session-1'): RpcEventFrame['params'] {
  18. return { type, session_id: sessionId, profile: 'yaoyao', payload: payload as never }
  19. }
  20. describe('chat message reducer', () => {
  21. it('preserves repeated user text when server identities differ', () => {
  22. const merged = mergeChatMessages([message('one')], [message('two')])
  23. expect(merged).toHaveLength(2)
  24. expect(merged.map(item => item.content)).toEqual(['相同内容', '相同内容'])
  25. })
  26. it('reconciles an optimistic row only by client message identity', () => {
  27. const optimistic = { ...message('local', 'hello', 'client-1'), stage: 'pending' as const }
  28. const persisted = { ...message('server-1', 'hello', 'client-1'), serverMessageId: 'server-1' }
  29. const merged = mergeChatMessages([optimistic], [persisted])
  30. expect(merged).toHaveLength(1)
  31. expect(merged[0].id).toBe('server-1')
  32. expect(merged[0].stage).toBe('settled')
  33. })
  34. it('ignores events attributed to another route', () => {
  35. const original = state()
  36. const reduced = applyChatEvent(original, event('message.delta', { delta: 'wrong' }, 'session-2'))
  37. expect(reduced).toBe(original)
  38. expect(reduced.messages).toEqual([])
  39. })
  40. it('uses terminal authoritative output to repair missed deltas', () => {
  41. let current = applyChatEvent(state(), event('message.start', { message_id: 'assistant-1' }))
  42. current = applyChatEvent(current, event('message.delta', { message_id: 'assistant-1', delta: '少量' }))
  43. current = applyChatEvent(current, event('message.complete', {
  44. message_id: 'assistant-1', text: '完整最终回答', authoritative_output: true,
  45. usage: { input: 10, output: 20, total: 30 },
  46. }))
  47. expect(current.messages).toHaveLength(1)
  48. expect(current.messages[0].content).toBe('完整最终回答')
  49. expect(current.messages[0].stage).toBe('settled')
  50. expect(current.usage?.totalTokens).toBe(30)
  51. })
  52. it('treats message.complete status=error as a failed terminal row', () => {
  53. let current = applyChatEvent(state(), event('message.start', { message_id: 'assistant-1' }))
  54. current = applyChatEvent(current, event('message.complete', {
  55. message_id: 'assistant-1', status: 'error', text: '工具执行失败', error: 'permission denied',
  56. }))
  57. expect(current.messages[0]).toMatchObject({ stage: 'failed', isStreaming: false })
  58. expect(current.error).toContain('permission denied')
  59. })
  60. it('maps the current 9119 context usage field names', () => {
  61. const current = applyChatEvent(state(), event('session.usage', {
  62. input: 10, output: 20, total: 30, context_used: 12_500, context_max: 114_688, context_percent: 10.9,
  63. }))
  64. expect(current.usage).toMatchObject({ contextTokens: 12_500, contextLimit: 114_688, percentUsed: 10.9 })
  65. })
  66. it('tracks approval and clarification independently', () => {
  67. let current = applyChatEvent(state(), event('approval.request', { request_id: 'approval-1', message: '允许吗?' }))
  68. current = applyChatEvent(current, event('clarify.request', { request_id: 'clarify-1', question: '选哪个?' }))
  69. expect(current.pendingApproval?.id).toBe('approval-1')
  70. expect(current.pendingClarification?.id).toBe('clarify-1')
  71. current = applyChatEvent(current, event('approval.resolved', {}))
  72. expect(current.pendingApproval).toBeUndefined()
  73. expect(current.pendingClarification?.id).toBe('clarify-1')
  74. })
  75. })