chatStore.test.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. import { describe, expect, it } from 'vitest'
  2. import type { ChatMessage, ChatRouteState, RpcEventFrame } from '@shared/types'
  3. import { applyChatEvent, mergeChatMessages } from '@/utils/messageReducer'
  4. import { estimateConversationTokens } from '@/utils/contextUsage'
  5. function message(id: string, content = '相同内容', clientMessageId?: string): ChatMessage {
  6. return {
  7. id, sessionId: 'session-1', profile: 'yaoyao', role: 'user', content,
  8. clientMessageId, timestamp: 1, stage: 'settled',
  9. }
  10. }
  11. function state(): ChatRouteState {
  12. return {
  13. route: { profile: 'yaoyao', sessionId: 'session-1' }, messages: [], historySynced: true,
  14. hasMoreBefore: false, loadedMessageCount: 0, messageTotal: 0, isLoadingHistory: false,
  15. isStreaming: false, isQueued: false, generation: 1,
  16. }
  17. }
  18. function event(type: string, payload: Record<string, unknown>, sessionId = 'session-1'): RpcEventFrame['params'] {
  19. return { type, session_id: sessionId, profile: 'yaoyao', payload: payload as never }
  20. }
  21. describe('chat message reducer', () => {
  22. it('estimates visible conversation tokens when Hermes has no live gauge', () => {
  23. expect(estimateConversationTokens([
  24. message('one', '你好,Hermes!'),
  25. { ...message('two', 'The quick brown fox'), role: 'assistant', reasoning: '检查上下文' },
  26. ])).toBeGreaterThan(0)
  27. })
  28. it('preserves repeated user text when server identities differ', () => {
  29. const merged = mergeChatMessages([message('one')], [message('two')])
  30. expect(merged).toHaveLength(2)
  31. expect(merged.map(item => item.content)).toEqual(['相同内容', '相同内容'])
  32. })
  33. it('reconciles an optimistic row only by client message identity', () => {
  34. const optimistic = { ...message('local', 'hello', 'client-1'), stage: 'pending' as const }
  35. const persisted = { ...message('server-1', 'hello', 'client-1'), serverMessageId: 'server-1' }
  36. const merged = mergeChatMessages([optimistic], [persisted])
  37. expect(merged).toHaveLength(1)
  38. expect(merged[0].id).toBe('server-1')
  39. expect(merged[0].stage).toBe('settled')
  40. })
  41. it('keeps history and a realtime assistant reply in chronological order', () => {
  42. const optimistic = { ...message('local-user', '新问题', 'client-1'), timestamp: 30, stage: 'pending' as const }
  43. const older = { ...message('history-user', '旧问题'), timestamp: 10, sequence: 10 }
  44. const newer = {
  45. ...message('history-answer', '旧回答'), role: 'assistant' as const, timestamp: 20, sequence: 11,
  46. }
  47. const streamed = {
  48. ...message('stream-answer', '新回答'), role: 'assistant' as const, timestamp: 31, stage: 'streaming' as const, isStreaming: true,
  49. }
  50. const merged = mergeChatMessages([optimistic], [newer, older], 'snapshot')
  51. expect(mergeChatMessages(merged, [streamed]).map(item => item.id)).toEqual([
  52. 'history-user', 'history-answer', 'local-user', 'stream-answer',
  53. ])
  54. })
  55. it('replaces a cached iOS marker with an attachment-only normalized message', () => {
  56. const cached = message('ios-file', '[用户附加文件:车位.pdf]\n@file:`attachments/车位.pdf`')
  57. const normalized = {
  58. ...message('ios-file', ''),
  59. attachments: [{ id: 'ios-file:0', name: '车位.pdf', mimeType: 'application/pdf', size: 0, kind: 'pdf' as const, url: '/attachments/车位.pdf' }],
  60. }
  61. const merged = mergeChatMessages([cached], [normalized])
  62. expect(merged[0]).toMatchObject({ content: '', attachments: [expect.objectContaining({ name: '车位.pdf' })] })
  63. })
  64. it('ignores events attributed to another route', () => {
  65. const original = state()
  66. const reduced = applyChatEvent(original, event('message.delta', { delta: 'wrong' }, 'session-2'))
  67. expect(reduced).toBe(original)
  68. expect(reduced.messages).toEqual([])
  69. })
  70. it('uses terminal authoritative output to repair missed deltas', () => {
  71. let current = applyChatEvent(state(), event('message.start', { message_id: 'assistant-1' }))
  72. current = applyChatEvent(current, event('message.delta', { message_id: 'assistant-1', delta: '少量' }))
  73. current = applyChatEvent(current, event('message.complete', {
  74. message_id: 'assistant-1', text: '完整最终回答', authoritative_output: true,
  75. usage: { input: 10, output: 20, total: 30 },
  76. }))
  77. expect(current.messages).toHaveLength(1)
  78. expect(current.messages[0].content).toBe('完整最终回答')
  79. expect(current.messages[0].stage).toBe('settled')
  80. expect(current.usage?.totalTokens).toBe(30)
  81. })
  82. it('starts a new assistant row after a user prompt when an interrupted row is still marked streaming', () => {
  83. const interrupted = {
  84. ...message('assistant-old', '上一轮残留'), role: 'assistant' as const, timestamp: 20,
  85. stage: 'streaming' as const, isStreaming: true,
  86. }
  87. const prompt = { ...message('user-new', '再试试'), timestamp: 30, stage: 'accepted' as const }
  88. const initial = { ...state(), messages: [interrupted, prompt] }
  89. let current = applyChatEvent(initial, event('run.started', {}))
  90. current = applyChatEvent(current, event('message.delta', { delta: '新一轮回答' }))
  91. expect(current.messages).toHaveLength(3)
  92. expect(current.messages.map(item => item.content)).toEqual(['上一轮残留', '再试试', '新一轮回答'])
  93. expect(current.messages[0]).toMatchObject({ id: 'assistant-old', isStreaming: false })
  94. expect(current.messages[2]).toMatchObject({ role: 'assistant', isStreaming: true })
  95. })
  96. it('promotes a synthetic streaming row to a server message id without duplicating it', () => {
  97. const initial = { ...state(), messages: [{ ...message('user-new', '开始'), timestamp: 30 }] }
  98. let current = applyChatEvent(initial, event('run.started', {}))
  99. current = applyChatEvent(current, event('message.delta', { message_id: 'assistant-new', delta: '回答' }))
  100. expect(current.messages).toHaveLength(2)
  101. expect(current.messages[1]).toMatchObject({ id: 'assistant-new', content: '回答', isStreaming: true })
  102. })
  103. it('treats message.complete status=error as a failed terminal row', () => {
  104. let current = applyChatEvent(state(), event('message.start', { message_id: 'assistant-1' }))
  105. current = applyChatEvent(current, event('message.complete', {
  106. message_id: 'assistant-1', status: 'error', text: '工具执行失败', error: 'permission denied',
  107. }))
  108. expect(current.messages[0]).toMatchObject({ stage: 'failed', isStreaming: false })
  109. expect(current.error).toContain('permission denied')
  110. })
  111. it('maps the current 9119 context usage field names', () => {
  112. const current = applyChatEvent(state(), event('session.usage', {
  113. input: 10, output: 20, total: 30, context_used: 12_500, context_max: 114_688, context_percent: 10.9,
  114. }))
  115. expect(current.usage).toMatchObject({ contextTokens: 12_500, contextLimit: 114_688, percentUsed: 10.9 })
  116. })
  117. it('tracks approval and clarification independently', () => {
  118. let current = applyChatEvent(state(), event('approval.request', { request_id: 'approval-1', message: '允许吗?' }))
  119. current = applyChatEvent(current, event('clarify.request', { request_id: 'clarify-1', question: '选哪个?' }))
  120. expect(current.pendingApproval?.id).toBe('approval-1')
  121. expect(current.pendingClarification?.id).toBe('clarify-1')
  122. current = applyChatEvent(current, event('approval.resolved', {}))
  123. expect(current.pendingApproval).toBeUndefined()
  124. expect(current.pendingClarification?.id).toBe('clarify-1')
  125. })
  126. })