chatLifecycle.test.ts 5.8 KB

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