chatStore.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  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.each(['append', 'snapshot', 'prepend'] as const)('repairs both cached echoes when history restores identity (%s)', position => {
  35. const local = { ...message('local', '查看服务器情况', 'client-1'), stage: 'accepted' as const }
  36. const oldHistory = { ...message('1155', '查看服务器情况'), serverMessageId: '1155', timestamp: 2 }
  37. const history = { ...oldHistory, clientMessageId: 'client-1' }
  38. const merged = mergeChatMessages([local, oldHistory], [history], position)
  39. expect(merged).toHaveLength(1)
  40. expect(merged[0]).toMatchObject({ id: '1155', clientMessageId: 'client-1', stage: 'settled', timestamp: 2 })
  41. })
  42. it('keeps persisted identity and delivery state when a peer receipt arrives after history', () => {
  43. const current = state()
  44. current.messages = [{ ...message('1155', '查看服务器情况', 'client-1'), serverMessageId: '1155' }]
  45. const updated = applyChatEvent(current, event('run.peer_user_message', {
  46. message_id: 'user:web:prompt:client-1', client_message_id: 'client-1', text: '查看服务器情况', status: 'accepted',
  47. }))
  48. expect(updated.messages).toHaveLength(1)
  49. expect(updated.messages[0]).toMatchObject({ id: '1155', serverMessageId: '1155', stage: 'settled' })
  50. })
  51. it('retains two separate identical submissions while reconciling both echoes', () => {
  52. const local = ['first', 'second'].map(id => ({ ...message(id, '查看服务器情况', id), stage: 'accepted' as const }))
  53. const history = local.map((row, index) => ({ ...row, id: String(index + 1), serverMessageId: String(index + 1), stage: 'settled' as const }))
  54. expect(mergeChatMessages(local, history, 'snapshot').map(row => [row.id, row.clientMessageId, row.stage]))
  55. .toEqual([['1', 'first', 'settled'], ['2', 'second', 'settled']])
  56. })
  57. it('keeps history and a realtime assistant reply in chronological order', () => {
  58. const optimistic = { ...message('local-user', '新问题', 'client-1'), timestamp: 30, stage: 'pending' as const }
  59. const older = { ...message('history-user', '旧问题'), timestamp: 10, sequence: 10 }
  60. const newer = {
  61. ...message('history-answer', '旧回答'), role: 'assistant' as const, timestamp: 20, sequence: 11,
  62. }
  63. const streamed = {
  64. ...message('stream-answer', '新回答'), role: 'assistant' as const, timestamp: 31, stage: 'streaming' as const, isStreaming: true,
  65. }
  66. const merged = mergeChatMessages([optimistic], [newer, older], 'snapshot')
  67. expect(mergeChatMessages(merged, [streamed]).map(item => item.id)).toEqual([
  68. 'history-user', 'history-answer', 'local-user', 'stream-answer',
  69. ])
  70. })
  71. it('replaces a cached iOS marker with an attachment-only normalized message', () => {
  72. const cached = message('ios-file', '[用户附加文件:车位.pdf]\n@file:`attachments/车位.pdf`')
  73. const normalized = {
  74. ...message('ios-file', ''),
  75. attachments: [{ id: 'ios-file:0', name: '车位.pdf', mimeType: 'application/pdf', size: 0, kind: 'pdf' as const, url: '/attachments/车位.pdf' }],
  76. }
  77. const merged = mergeChatMessages([cached], [normalized])
  78. expect(merged[0]).toMatchObject({ content: '', attachments: [expect.objectContaining({ name: '车位.pdf' })] })
  79. })
  80. it('ignores events attributed to another route', () => {
  81. const original = state()
  82. const reduced = applyChatEvent(original, event('message.delta', { delta: 'wrong' }, 'session-2'))
  83. expect(reduced).toBe(original)
  84. expect(reduced.messages).toEqual([])
  85. })
  86. it('uses terminal authoritative output to repair missed deltas', () => {
  87. let current = applyChatEvent(state(), event('message.start', { message_id: 'assistant-1' }))
  88. current = applyChatEvent(current, event('message.delta', { message_id: 'assistant-1', delta: '少量' }))
  89. current = applyChatEvent(current, event('message.complete', {
  90. message_id: 'assistant-1', text: '完整最终回答', authoritative_output: true,
  91. usage: { input: 10, output: 20, total: 30 },
  92. }))
  93. expect(current.messages).toHaveLength(1)
  94. expect(current.messages[0].content).toBe('完整最终回答')
  95. expect(current.messages[0].stage).toBe('settled')
  96. expect(current.usage?.totalTokens).toBe(30)
  97. })
  98. it('starts a new assistant row after a user prompt when an interrupted row is still marked streaming', () => {
  99. const interrupted = {
  100. ...message('assistant-old', '上一轮残留'), role: 'assistant' as const, timestamp: 20,
  101. stage: 'streaming' as const, isStreaming: true,
  102. }
  103. const prompt = { ...message('user-new', '再试试'), timestamp: 30, stage: 'accepted' as const }
  104. const initial = { ...state(), messages: [interrupted, prompt] }
  105. let current = applyChatEvent(initial, event('run.started', {}))
  106. current = applyChatEvent(current, event('message.delta', { delta: '新一轮回答' }))
  107. expect(current.messages).toHaveLength(3)
  108. expect(current.messages.map(item => item.content)).toEqual(['上一轮残留', '再试试', '新一轮回答'])
  109. expect(current.messages[0]).toMatchObject({ id: 'assistant-old', isStreaming: false })
  110. expect(current.messages[2]).toMatchObject({ role: 'assistant', isStreaming: true })
  111. })
  112. it('promotes a synthetic streaming row to a server message id without duplicating it', () => {
  113. const initial = { ...state(), messages: [{ ...message('user-new', '开始'), timestamp: 30 }] }
  114. let current = applyChatEvent(initial, event('run.started', {}))
  115. current = applyChatEvent(current, event('message.delta', { message_id: 'assistant-new', delta: '回答' }))
  116. expect(current.messages).toHaveLength(2)
  117. expect(current.messages[1]).toMatchObject({ id: 'assistant-new', content: '回答', isStreaming: true })
  118. })
  119. it('seals interim commentary so later streamed text stays at the bottom of the turn', () => {
  120. const initial = { ...state(), messages: [{ ...message('user-new', '检查项目'), timestamp: 100 }] }
  121. let current = applyChatEvent(initial, event('message.start', {}))
  122. current = applyChatEvent(current, event('message.delta', { text: '我先读取配置。', timestamp: 101 }))
  123. current = applyChatEvent(current, event('message.interim', { text: '我先读取配置。', timestamp: 102 }))
  124. current = applyChatEvent(current, event('tool.start', { tool_id: 'tool-1', name: 'read_file' }))
  125. current = applyChatEvent(current, event('tool.complete', { tool_id: 'tool-1', name: 'read_file', result: 'ok' }))
  126. current = applyChatEvent(current, event('message.delta', { text: '配置没有问题。', timestamp: 105 }))
  127. expect(current.messages.map(item => item.content)).toEqual(['检查项目', '我先读取配置。', '配置没有问题。'])
  128. expect(current.messages[1]).toMatchObject({ role: 'assistant', isStreaming: false })
  129. expect(current.messages[2]).toMatchObject({ role: 'assistant', isStreaming: true })
  130. expect(current.messages[2].toolCalls).toEqual([
  131. expect.objectContaining({ id: 'tool-1', status: 'completed' }),
  132. ])
  133. })
  134. it('preserves interim commentary after the final answer completes', () => {
  135. const initial = { ...state(), messages: [{ ...message('user-new', '执行'), timestamp: 100 }] }
  136. let current = applyChatEvent(initial, event('message.start', {}))
  137. current = applyChatEvent(current, event('message.delta', { text: '开始执行。' }))
  138. current = applyChatEvent(current, event('message.interim', { text: '开始执行。' }))
  139. current = applyChatEvent(current, event('message.delta', { text: '执行完成。' }))
  140. current = applyChatEvent(current, event('message.complete', { text: '执行完成。' }))
  141. expect(current.messages.map(item => item.content)).toEqual(['执行', '开始执行。', '执行完成。'])
  142. expect(current.messages.slice(1).every(item => item.isStreaming === false)).toBe(true)
  143. })
  144. it('settles an identical completion onto the sealed interim instead of duplicating it', () => {
  145. const initial = { ...state(), messages: [{ ...message('user-new', '执行'), timestamp: 100 }] }
  146. let current = applyChatEvent(initial, event('message.start', {}))
  147. current = applyChatEvent(current, event('message.delta', { text: '已经完成。' }))
  148. current = applyChatEvent(current, event('message.interim', { text: '已经完成。', already_streamed: true }))
  149. current = applyChatEvent(current, event('message.complete', { text: '已经完成。' }))
  150. expect(current.messages.map(item => item.content)).toEqual(['执行', '已经完成。'])
  151. expect(current.messages[1]).toMatchObject({ stage: 'settled', isStreaming: false })
  152. })
  153. it('treats message.complete status=error as a failed terminal row', () => {
  154. let current = applyChatEvent(state(), event('message.start', { message_id: 'assistant-1' }))
  155. current = applyChatEvent(current, event('message.complete', {
  156. message_id: 'assistant-1', status: 'error', text: '工具执行失败', error: 'permission denied',
  157. }))
  158. expect(current.messages[0]).toMatchObject({ stage: 'failed', isStreaming: false })
  159. expect(current.error).toContain('permission denied')
  160. })
  161. it('maps the current 9119 context usage field names', () => {
  162. const current = applyChatEvent(state(), event('session.usage', {
  163. input: 10, output: 20, total: 30, context_used: 12_500, context_max: 114_688, context_percent: 10.9,
  164. }))
  165. expect(current.usage).toMatchObject({ contextTokens: 12_500, contextLimit: 114_688, percentUsed: 10.9 })
  166. })
  167. it('retains the context gauge when completion only updates cumulative counters', () => {
  168. let current = applyChatEvent(state(), event('session.usage', { context_used: 12_500, context_max: 114_688 }))
  169. current = applyChatEvent(current, event('message.complete', { text: '完成', usage: { total_tokens: 1_900_000 } }))
  170. expect(current.usage).toMatchObject({ contextTokens: 12_500, contextLimit: 114_688, totalTokens: 1_900_000 })
  171. current = applyChatEvent(current, event('usage.update', { input_tokens: 10 }))
  172. expect(current.usage).toMatchObject({ contextTokens: 12_500, contextLimit: 114_688, inputTokens: 10 })
  173. current = applyChatEvent(current, event('context.update', { context_used: 0, context_percent: 0 }))
  174. expect(current.usage).toMatchObject({ contextTokens: 0, percentUsed: 0 })
  175. })
  176. it('tracks approval and clarification independently', () => {
  177. let current = applyChatEvent(state(), event('approval.request', { request_id: 'approval-1', message: '允许吗?' }))
  178. current = applyChatEvent(current, event('clarify.request', { request_id: 'clarify-1', question: '选哪个?' }))
  179. expect(current.pendingApproval?.id).toBe('approval-1')
  180. expect(current.pendingClarification?.id).toBe('clarify-1')
  181. current = applyChatEvent(current, event('approval.resolved', {}))
  182. expect(current.pendingApproval).toBeUndefined()
  183. expect(current.pendingClarification?.id).toBe('clarify-1')
  184. })
  185. })