chatLifecycle.test.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  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(['error', 'run.failed'])('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('keeps a successful terminal turn active until file output tools finish', () => {
  34. let current = state()
  35. for (const [event, payload] of [
  36. ['message.start', {}],
  37. ['tool.start', { tool_id: 'file', name: 'write_file' }],
  38. ['message.complete', { text: '文件准备中', status: 'complete' }],
  39. ['tool.generating', { tool_id: 'file', name: 'write_file' }],
  40. ] as Array<[string, any]>) {
  41. current = applyChatEvent(current, { type: event, payload })
  42. }
  43. expect(current.isStreaming).toBe(true)
  44. expect(current.messages.flatMap(message => message.toolCalls ?? []).map(tool => tool.status)).toEqual(['running'])
  45. expect(current.messages.some(message => message.isStreaming)).toBe(false)
  46. current = applyChatEvent(current, { type: 'tool.completed', payload: { tool_id: 'file', name: 'write_file', result: { ok: true } } })
  47. expect(current.isStreaming).toBe(false)
  48. expect(current.messages.flatMap(message => message.toolCalls ?? []).map(tool => tool.status)).toEqual(['completed'])
  49. })
  50. it('reattaches a file tool that is first reported after the terminal message', () => {
  51. let current = state()
  52. current = applyChatEvent(current, { type: 'message.start', payload: {} })
  53. current = applyChatEvent(current, { type: 'message.complete', payload: { text: '正在准备文件', status: 'complete' } })
  54. expect(current.isStreaming).toBe(false)
  55. current = applyChatEvent(current, { type: 'tool.started', payload: { tool_id: 'late-file', name: 'write_file' } })
  56. expect(current.isStreaming).toBe(true)
  57. expect(current.messages.at(-1)?.toolCalls?.map(tool => tool.status)).toEqual(['running'])
  58. current = applyChatEvent(current, { type: 'tool.completed', payload: { tool_id: 'late-file', name: 'write_file', result: { ok: true } } })
  59. expect(current.isStreaming).toBe(false)
  60. })
  61. it('recognizes persisted event status aliases and nonzero tool exit codes', () => {
  62. const message = normalizeChatMessage({ id: '1', role: 'assistant', tool_calls: [
  63. { id: 'empty', status: 'tool.complete' }, { id: 'failed', status: 'tool.complete', result: { exit_code: 1, error: null } },
  64. ] }, 's')
  65. expect(message.toolCalls?.map(tool => tool.status)).toEqual(['completed', 'failed'])
  66. const rows = chatMessagesToUi([message, normalizeChatMessage({ id: '2', role: 'tool', tool_call_id: 'empty', content: '{"exit_code":1}' }, 's')])
  67. expect(rows[0]!.tools?.every(tool => tool.status === 'error')).toBe(true)
  68. })
  69. it('replaces stale live projections by identified completed turns while retaining real repeated sends and active tail', () => {
  70. const first = user('1'), second = user('2')
  71. const stale: ChatMessage = { id: 'stream:p:s:1:x', sessionId: 's', role: 'assistant', content: '/work', timestamp: 2, stage: 'streaming', isStreaming: true }
  72. const final = normalizeChatMessage({ id: '10', role: 'assistant', content: '/work', timestamp: 2 }, 's')
  73. const active = { ...stale, id: 'stream:p:s:2:y', timestamp: 4 }
  74. const result = reconcileChatHistory([first, stale, second, active], [first, final], true)
  75. expect(result.map(message => message.id)).toEqual(['1', '2', '10', active.id])
  76. expect(result.at(-1)?.isStreaming).toBe(true)
  77. })
  78. it('restores a canonical tool row whose text was overwritten by a live final answer', () => {
  79. const call = normalizeChatMessage({ id: '1018', role: 'assistant', content: '', tool_calls: [{ id: 'call-2', function: { name: 'terminal' } }] }, 's')
  80. const final = normalizeChatMessage({ id: '1020', role: 'assistant', content: '给你图片', timestamp: 3 }, 's')
  81. const result = reconcileChatHistory([user('1'), { ...call, content: '给你图片' }], [user('1'), call, final], false)
  82. expect(result.filter(message => message.content === '给你图片')).toHaveLength(1)
  83. expect(result.find(message => message.id === '1018')?.content).toBe('')
  84. })
  85. it('keeps an active turn when a newer user message is queued after it', () => {
  86. const live: ChatMessage = { id: 'stream:p:s:1:x', role: 'assistant', sessionId: 's', content: '当前输出', timestamp: 2, stage: 'streaming', isStreaming: true }
  87. const interim = normalizeChatMessage({ id: '10', role: 'assistant', content: '之前的阶段性说明', timestamp: 1.5 }, 's')
  88. expect(reconcileChatHistory([user('1'), live, { ...user('queue'), timestamp: 3 }], [user('1'), interim], true))
  89. .toContainEqual(live)
  90. })
  91. it('counts a stable tool ID once when persisted history overlaps a live projection', () => {
  92. const live = normalizeChatMessage({ id: 'stream:p:s:1:x', role: 'assistant', status: 'streaming', tool_calls: [{ id: 'call', name: 'terminal', result: 'done' }] }, 's')
  93. const stored = normalizeChatMessage({ id: '10', role: 'assistant', tool_calls: [{ id: 'call', function: { name: 'terminal' } }] }, 's')
  94. const tools = chatMessagesToUi([user('1'), live, stored]).flatMap(message => message.tools ?? [])
  95. expect(tools).toHaveLength(1)
  96. expect(tools[0]).toMatchObject({ id: 'call', status: 'success', output: 'done' })
  97. })
  98. })