chatLifecycle.test.ts 5.5 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
  1. import { describe, expect, it } from 'vitest'
  2. import type { ChatMessage, ChatRouteState } from '@shared/types'
  3. import { applyChatEvent, settleChatMessages } from '@/utils/messageReducer'
  4. import { normalizeChatMessage } from '@/utils/normalize'
  5. import { reconcileChatHistory } from '@/utils/chatHistory'
  6. import { chatMessagesToUi } from '@/components/workspace/viewModels'
  7. import { buildMessageTimelineRows } from '@/utils/turnTrace'
  8. function state(): ChatRouteState { return { route: { sessionId: 's', profile: 'p' }, messages: [], isStreaming: false, isQueued: false,
  9. historySynced: true, hasMoreBefore: false, loadedMessageCount: 0, messageTotal: 0, isLoadingHistory: false, generation: 1 } }
  10. const user = (id: string): ChatMessage => ({ id, sessionId: 's', role: 'user', content: 'pwd', timestamp: 1, stage: 'settled' })
  11. describe('ordinary tool lifecycle and authoritative history', () => {
  12. it('counts one invocation across generating/start/complete in legacy history presentation', () => {
  13. let current = state()
  14. for (const [type, payload] of [
  15. ['message.start', {}], ['tool.generating', { name: 'terminal' }],
  16. ['tool.start', { tool_id: 'call-1', name: 'terminal', args: { command: 'pwd' } }],
  17. ['tool.complete', { tool_id: 'call-1', name: 'terminal', result: { output: '/work', exit_code: 0 } }],
  18. ['message.complete', { text: '/work', status: 'complete' }],
  19. ] as Array<[string, any]>) {
  20. current = applyChatEvent(current, { type, payload })
  21. }
  22. expect(current.messages.flatMap(message => message.toolCalls ?? [])).toHaveLength(1)
  23. expect(buildMessageTimelineRows(chatMessagesToUi(current.messages)).filter(row => row.kind === 'trace').map(row => row.status)).toEqual(['success'])
  24. })
  25. it.each(['message.complete', 'error'])('settles every unfinished tool on %s without inventing success', type => {
  26. let current = state()
  27. for (const [event, payload] of [['message.start', {}], ['tool.start', { tool_id: 'call', name: 'terminal' }], ['message.interim', { text: '稍等' }], [type, { text: '结束' }]] as Array<[string, any]>)
  28. current = applyChatEvent(current, { type: event, payload })
  29. expect(current.messages.some(message => message.isStreaming)).toBe(false)
  30. expect(current.messages.flatMap(message => message.toolCalls ?? []).map(tool => tool.status)).toEqual(['interrupted'])
  31. expect(settleChatMessages(current.messages)).toEqual(current.messages)
  32. })
  33. it('recognizes persisted event status aliases and nonzero tool exit codes', () => {
  34. const message = normalizeChatMessage({ id: '1', role: 'assistant', tool_calls: [
  35. { id: 'empty', status: 'tool.complete' }, { id: 'failed', status: 'tool.complete', result: { exit_code: 1, error: null } },
  36. ] }, 's')
  37. expect(message.toolCalls?.map(tool => tool.status)).toEqual(['completed', 'failed'])
  38. const rows = chatMessagesToUi([message, normalizeChatMessage({ id: '2', role: 'tool', tool_call_id: 'empty', content: '{"exit_code":1}' }, 's')])
  39. expect(rows[0]!.tools?.every(tool => tool.status === 'error')).toBe(true)
  40. })
  41. it('replaces stale live projections by identified completed turns while retaining real repeated sends and active tail', () => {
  42. const first = user('1'), second = user('2')
  43. const stale: ChatMessage = { id: 'stream:p:s:1:x', sessionId: 's', role: 'assistant', content: '/work', timestamp: 2, stage: 'streaming', isStreaming: true }
  44. const final = normalizeChatMessage({ id: '10', role: 'assistant', content: '/work', timestamp: 2 }, 's')
  45. const active = { ...stale, id: 'stream:p:s:2:y', timestamp: 4 }
  46. const result = reconcileChatHistory([first, stale, second, active], [first, final], true)
  47. expect(result.map(message => message.id)).toEqual(['1', '2', '10', active.id])
  48. expect(result.at(-1)?.isStreaming).toBe(true)
  49. })
  50. it('restores a canonical tool row whose text was overwritten by a live final answer', () => {
  51. const call = normalizeChatMessage({ id: '1018', role: 'assistant', content: '', tool_calls: [{ id: 'call-2', function: { name: 'terminal' } }] }, 's')
  52. const final = normalizeChatMessage({ id: '1020', role: 'assistant', content: '给你图片', timestamp: 3 }, 's')
  53. const result = reconcileChatHistory([user('1'), { ...call, content: '给你图片' }], [user('1'), call, final], false)
  54. expect(result.filter(message => message.content === '给你图片')).toHaveLength(1)
  55. expect(result.find(message => message.id === '1018')?.content).toBe('')
  56. })
  57. it('keeps an active turn when a newer user message is queued after it', () => {
  58. const live: ChatMessage = { id: 'stream:p:s:1:x', role: 'assistant', sessionId: 's', content: '当前输出', timestamp: 2, stage: 'streaming', isStreaming: true }
  59. const interim = normalizeChatMessage({ id: '10', role: 'assistant', content: '之前的阶段性说明', timestamp: 1.5 }, 's')
  60. expect(reconcileChatHistory([user('1'), live, { ...user('queue'), timestamp: 3 }], [user('1'), interim], true))
  61. .toContainEqual(live)
  62. })
  63. it('counts a stable tool ID once when persisted history overlaps a live projection', () => {
  64. const live = normalizeChatMessage({ id: 'stream:p:s:1:x', role: 'assistant', status: 'streaming', tool_calls: [{ id: 'call', name: 'terminal', result: 'done' }] }, 's')
  65. const stored = normalizeChatMessage({ id: '10', role: 'assistant', tool_calls: [{ id: 'call', function: { name: 'terminal' } }] }, 's')
  66. const tools = chatMessagesToUi([user('1'), live, stored]).flatMap(message => message.tools ?? [])
  67. expect(tools).toHaveLength(1)
  68. expect(tools[0]).toMatchObject({ id: 'call', status: 'success', output: 'done' })
  69. })
  70. })