toolTimeline.test.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  1. import { describe, expect, it } from 'vitest'
  2. import { chatMessagesToUi } from '@/components/workspace/viewModels'
  3. import { normalizeChatMessage } from '@/utils/normalize'
  4. import { buildMessageTimelineRows } from '@/utils/turnTrace'
  5. describe('ordinary chat tool timeline', () => {
  6. it('folds persisted function calls and their result rows into one expandable tool', () => {
  7. const user = normalizeChatMessage({ id: 'user-row', role: 'user', content: 'MEDIA:/tmp/keep-raw.png', timestamp: 0 }, 'session-1', 'default')
  8. const call = normalizeChatMessage({
  9. id: 'assistant-call', role: 'assistant', content: '', reasoning_content: '先检查目录', tool_calls: JSON.stringify([{
  10. id: 'call-1', type: 'function', function: { name: 'terminal', arguments: '{"command":"pwd"}' },
  11. }]), timestamp: 1,
  12. }, 'session-1', 'default')
  13. const result = normalizeChatMessage({
  14. id: 'tool-result', role: 'tool', tool_call_id: 'call-1', tool_name: 'terminal',
  15. content: '{"output":"/tmp/work","exit_code":0,"error":null}', timestamp: 2,
  16. }, 'session-1', 'default')
  17. const system = normalizeChatMessage({ id: 'system-row', role: 'system', content: 'transport detail', timestamp: 3 }, 'session-1', 'default')
  18. const timeline = chatMessagesToUi([user, call, result, system])
  19. expect(timeline).toHaveLength(2)
  20. expect(timeline[0]).toMatchObject({ role: 'user', content: 'MEDIA:/tmp/keep-raw.png' })
  21. expect(timeline[1].role).toBe('assistant')
  22. expect(timeline[1].reasoning).toBe('先检查目录')
  23. expect(timeline[1].tools).toEqual([expect.objectContaining({
  24. id: 'call-1', name: 'terminal', status: 'success', input: '{"command":"pwd"}', output: { output: '/tmp/work', exit_code: 0, error: null },
  25. })])
  26. })
  27. it('renders asynchronous delegation completion as a timeline event, not a user message', () => {
  28. const notice = normalizeChatMessage({
  29. id: 'delegation', role: 'user', content: '[ASYNC DELEGATION BATCH COMPLETE]',
  30. display_kind: 'async_delegation_complete',
  31. display_metadata: JSON.stringify({ task_count: 2, completed_count: 2, failed_count: 0, duration_seconds: 71 }),
  32. timestamp: 1,
  33. }, 'session-1', 'default')
  34. const [timeline] = chatMessagesToUi([notice])
  35. expect(timeline).toMatchObject({
  36. role: 'system', timelineKind: 'delegation-complete',
  37. timelineMetadata: { task_count: 2, completed_count: 2 },
  38. })
  39. })
  40. it('classifies background process exits as collapsed system timeline events', () => {
  41. const notice = normalizeChatMessage({
  42. id: 'background-process', role: 'user',
  43. content: '[IMPORTANT: Background process proc_6be40e6c3864 exited (exit code 143, SIGTERM).\nCommand: ./run_mac.sh\nOutput:\nmodel loaded',
  44. timestamp: 1,
  45. }, 'session-1', 'default')
  46. const [timeline] = chatMessagesToUi([notice])
  47. expect(timeline).toMatchObject({
  48. id: 'background-process', role: 'system', timelineKind: 'background-process',
  49. timelineMetadata: { process_id: 'proc_6be40e6c3864', exit_code: 143, signal: 'SIGTERM' },
  50. })
  51. })
  52. it('classifies a persisted context compaction envelope as a system timeline event', () => {
  53. const compacted = normalizeChatMessage({
  54. id: 'compacted', role: 'user', content: '[CONTEXT COMPACTION — REFERENCE ONLY]\n\n## Historical Task Snapshot', timestamp: 1,
  55. }, 'session-1', 'default')
  56. const [timeline] = chatMessagesToUi([compacted])
  57. expect(timeline).toMatchObject({
  58. id: 'compacted', role: 'system', timelineKind: 'system', timelineMetadata: { eventKind: 'compaction' },
  59. })
  60. })
  61. it('groups one turn of reasoning and tools before its visible assistant reply', () => {
  62. const rows = buildMessageTimelineRows([
  63. { id: 'user', role: 'user', content: '检查一下' },
  64. { id: 'thinking', role: 'assistant', content: '', reasoning: '先读取配置', tools: [{ id: 'read', name: 'read_file', status: 'success', input: { path: 'a' }, output: 'ok' }] },
  65. { id: 'tool-two', role: 'assistant', content: '', reasoning: '再执行命令', tools: [{ id: 'run', name: 'terminal', status: 'running', input: { command: 'pwd' } }] },
  66. { id: 'answer', role: 'assistant', content: '检查完成', reasoning: '组织答案' },
  67. ])
  68. expect(rows.map(row => [row.kind, row.id])).toEqual([
  69. ['message', 'message:user'],
  70. ['trace', 'trace:thinking'],
  71. ['message', 'message:answer'],
  72. ])
  73. const trace = rows[1]
  74. expect(trace?.kind === 'trace' ? trace.entries.map(entry => entry.type) : []).toEqual(['reasoning', 'tool', 'reasoning', 'tool', 'reasoning'])
  75. expect(trace?.kind === 'trace' ? trace.status : '').toBe('running')
  76. expect(rows[2]?.kind === 'message' ? rows[2].message : undefined).toMatchObject({ content: '检查完成', reasoning: undefined, tools: undefined })
  77. })
  78. it('keeps a later streaming trace below sealed interim commentary', () => {
  79. const rows = buildMessageTimelineRows([
  80. { id: 'user', role: 'user', content: '检查项目' },
  81. { id: 'interim', role: 'assistant', content: '我先读取配置。', status: 'settled' },
  82. {
  83. id: 'streaming', role: 'assistant', content: '配置没有问题。', status: 'streaming',
  84. tools: [{ id: 'read', name: 'read_file', status: 'running' }],
  85. },
  86. ])
  87. expect(rows.map(row => [row.kind, row.id])).toEqual([
  88. ['message', 'message:user'],
  89. ['message', 'message:interim'],
  90. ['trace', 'trace:streaming'],
  91. ['message', 'message:streaming'],
  92. ])
  93. })
  94. it('does not merge assistant traces across agents or system events', () => {
  95. const rows = buildMessageTimelineRows([
  96. { id: 'a', role: 'assistant', author: '甲', content: '', reasoning: '甲思考' },
  97. { id: 'b', role: 'assistant', author: '乙', content: '', reasoning: '乙思考' },
  98. { id: 'system', role: 'system', content: '系统信息' },
  99. { id: 'c', role: 'assistant', author: '乙', content: '', reasoning: '新回合' },
  100. ])
  101. expect(rows.map(row => row.id)).toEqual(['trace:a', 'trace:b', 'message:system', 'trace:c'])
  102. })
  103. })