workspaceTranscript.test.ts 6.2 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
  1. import { describe, expect, it } from 'vitest'
  2. import { foldDetail, WorkspaceTranscriptStore, type WorkspaceDetail } from '@/components/workspace/transcriptStore'
  3. import type { WorkspaceConversation, WorkspaceEvent, WorkspaceMessage } from '@shared/workspace'
  4. const conversation: WorkspaceConversation = { id: 'c', kind: 'direct', name: 'Bot', avatar: '', memberIds: ['a'], instructions: '', administratorId: 'a', mode: 'host', autoReplyIds: [], maxReplyRounds: 3, archived: false, pinned: false, readSeq: 0, lastSeq: 3, preview: '', createdAt: 1, updatedAt: 1 }
  5. const message = (id: string, seq: number, content = id): WorkspaceMessage => ({ id, seq, conversationId: 'c', role: 'assistant', content, reasoning: '', status: 'complete', attachments: [], tools: [], createdAt: seq })
  6. const detail = (): WorkspaceDetail => ({ conversation, messages: [message('old', 1), message('live', 2)], cursor: 10, hasOlder: true, run: null, interactions: [], context: null })
  7. const event = (seq: number, value: WorkspaceMessage): WorkspaceEvent => ({ seq, type: 'message.changed', conversationId: value.conversationId, data: value })
  8. describe('Bot transcript cache', () => {
  9. it('keeps independent clients ordered after snapshots, pin changes and new activity', () => {
  10. const rows: WorkspaceConversation[] = [
  11. { ...conversation, id: 'tie-b', lastMessageAt: 200 },
  12. { ...conversation, id: 'zero', lastMessageAt: 0, createdAt: 5000 },
  13. { ...conversation, id: 'old', lastMessageAt: 100, updatedAt: 99999 },
  14. { ...conversation, id: 'pin', pinned: true, lastMessageAt: 50 },
  15. { ...conversation, id: 'new', lastMessageAt: 400 },
  16. { ...conversation, id: 'tie-a', lastMessageAt: 200 },
  17. { ...conversation, id: 'fallback', lastMessageAt: undefined, createdAt: 300 },
  18. ]
  19. const before = rows.map(c => c.id)
  20. const clients = [new WorkspaceTranscriptStore(), new WorkspaceTranscriptStore()]
  21. clients[0]!.hydrate({ agents: [], conversations: rows, details: [], cursor: 0 })
  22. clients[1]!.hydrate({ agents: [], conversations: [...rows].reverse(), details: [], cursor: 0 })
  23. const ordered = (expected: string[]) => clients.forEach(client => expect(client.conversations.map(c => c.id)).toEqual(expected))
  24. ordered(['pin', 'new', 'fallback', 'tie-a', 'tie-b', 'old', 'zero'])
  25. expect(rows.map(c => c.id)).toEqual(before)
  26. const change = (seq: number, id: string, patch: Partial<WorkspaceConversation>) => clients.forEach(client => {
  27. const current = client.conversations.find(c => c.id === id) ?? { ...conversation, id }
  28. client.apply({ seq, type: 'conversation.changed', conversationId: id, data: { ...current, ...patch } })
  29. })
  30. change(1, 'old', { lastMessageAt: 500 })
  31. ordered(['pin', 'old', 'new', 'fallback', 'tie-a', 'tie-b', 'zero'])
  32. change(2, 'tie-b', { pinned: true })
  33. ordered(['tie-b', 'pin', 'old', 'new', 'fallback', 'tie-a', 'zero'])
  34. change(3, 'pin', { pinned: false })
  35. change(4, 'new-2', { lastMessageAt: 400 })
  36. ordered(['tie-b', 'old', 'new', 'new-2', 'fallback', 'tie-a', 'pin', 'zero'])
  37. change(3, 'pin', { pinned: true })
  38. ordered(['tie-b', 'old', 'new', 'new-2', 'fallback', 'tie-a', 'pin', 'zero'])
  39. clients[0]!.conversations = [...clients[1]!.conversations].reverse()
  40. ordered(['tie-b', 'old', 'new', 'new-2', 'fallback', 'tie-a', 'pin', 'zero'])
  41. })
  42. it('returns a warm transcript without losing its older pages and preserves settled identities on a live update', () => {
  43. const store = new WorkspaceTranscriptStore(), initial = detail()
  44. store.hydrate({ agents: [], conversations: [conversation], details: [initial], cursor: 10 })
  45. expect(store.get('c')).toBe(initial)
  46. store.apply(event(11, { ...message('live', 2, '正在生成'), status: 'streaming' }))
  47. expect(store.get('c')!.messages[0]).toBe(initial.messages[0])
  48. expect(store.get('c')!.messages[1]!.content).toBe('正在生成')
  49. expect(store.get('c')!.hasOlder).toBe(true)
  50. })
  51. it('replays events received during a cold detail request instead of rolling back new text', () => {
  52. const store = new WorkspaceTranscriptStore(), token = store.beginRead()
  53. store.apply(event(12, message('live', 2, '更新后的内容')))
  54. const current = store.finishRead(token, detail())
  55. expect(current.messages.at(-1)!.content).toBe('更新后的内容')
  56. })
  57. it('does not apply pre-snapshot replay over a newer snapshot', () => {
  58. const store = new WorkspaceTranscriptStore(), token = store.beginRead()
  59. store.apply(event(9, message('live', 2, '旧事件')))
  60. expect(store.finishRead(token, detail()).messages.at(-1)!.content).toBe('live')
  61. })
  62. it('removes hidden messages, ignores duplicate events, and evicts deleted conversations', () => {
  63. const store = new WorkspaceTranscriptStore()
  64. store.hydrate({ agents: [], conversations: [conversation], details: [detail()], cursor: 10 })
  65. store.apply(event(11, { ...message('live', 2), visible: false }))
  66. store.apply(event(11, message('live', 2)))
  67. expect(store.get('c')!.messages.map(m => m.id)).toEqual(['old'])
  68. store.apply({ seq: 12, type: 'conversation.deleted', conversationId: 'c', data: { id: 'c' } })
  69. expect(store.get('c')).toBeUndefined()
  70. expect(store.conversations).toEqual([])
  71. })
  72. it('does not resurrect a conversation deleted while its detail request was in flight', () => {
  73. const store = new WorkspaceTranscriptStore(), token = store.beginRead()
  74. store.apply({ seq: 12, type: 'conversation.deleted', conversationId: 'c', data: { id: 'c' } })
  75. expect(() => store.finishRead(token, detail())).toThrow('已被删除')
  76. expect(store.get('c')).toBeUndefined()
  77. })
  78. it('keeps background tasks separate and settles approval cards directly from events', () => {
  79. const initial = detail()
  80. expect(foldDetail(initial, event(11, { ...message('other', 3), conversationTaskId: 'other' }))).toBe(initial)
  81. const pending = foldDetail(initial, { seq: 12, conversationId: 'c', type: 'interaction.changed', data: { id: 'approval', kind: 'approval', choices: [], message: '允许?' } })
  82. expect(pending.interactions).toHaveLength(1)
  83. expect(foldDetail(pending, { seq: 13, conversationId: 'c', type: 'interaction.changed', data: { id: 'approval', resolved: true } }).interactions).toHaveLength(0)
  84. })
  85. })