workspaceTranscript.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. import { describe, expect, it } from 'vitest'
  2. import { foldDetail, WorkspaceTranscriptStore, type WorkspaceDetail } from '@/components/workspace/transcriptStore'
  3. import { workspaceHasUnfinishedOutput, type WorkspaceConversation, type WorkspaceEvent, type 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('retains unselected transcripts as stale after a partial snapshot and accepts subsequent patches', () => {
  10. const store = new WorkspaceTranscriptStore(), initial = detail()
  11. initial.messages[1] = { ...initial.messages[1]!, revision: 1 }
  12. store.hydrate({ agents: [], conversations: [conversation], details: [initial], cursor: 10 })
  13. store.hydrate({ agents: [], conversations: [conversation], details: [], partialDetails: true, cursor: 11 })
  14. expect(store.get('c')!.messages).toEqual(initial.messages)
  15. expect(store.staleDetails.has('c:')).toBe(true)
  16. store.apply({ seq: 12, type: 'message.patch', conversationId: 'c', data: { id: 'live', conversationId: 'c', baseRevision: 1, revision: 2, contentAppend: ' updated', reasoningAppend: '' } })
  17. expect(store.get('c')!.messages[1]!.content).toBe('live updated')
  18. store.hydrate({ agents: [], conversations: [], details: [], partialDetails: true, cursor: 13 })
  19. expect(store.get('c')).toBeUndefined()
  20. })
  21. it.each([undefined, null, { agents: [], conversations: [], details: [] }, { cursor: 0 }])('keeps the last transcript when a snapshot response is incomplete', snapshot => {
  22. const store = new WorkspaceTranscriptStore(), initial = detail()
  23. store.hydrate({ agents: [], conversations: [conversation], details: [initial], cursor: 10 })
  24. expect(() => store.hydrate(snapshot as any)).toThrow('Bot 会话数据尚未就绪')
  25. expect(store.cursor).toBe(10)
  26. expect(store.conversations).toEqual([conversation])
  27. expect(store.get('c')).toBe(initial)
  28. })
  29. it('keeps independent clients ordered after snapshots, pin changes and new activity', () => {
  30. const rows: WorkspaceConversation[] = [
  31. { ...conversation, id: 'tie-b', lastMessageAt: 200 },
  32. { ...conversation, id: 'zero', lastMessageAt: 0, createdAt: 5000 },
  33. { ...conversation, id: 'old', lastMessageAt: 100, updatedAt: 99999 },
  34. { ...conversation, id: 'pin', pinned: true, lastMessageAt: 50 },
  35. { ...conversation, id: 'new', lastMessageAt: 400 },
  36. { ...conversation, id: 'tie-a', lastMessageAt: 200 },
  37. { ...conversation, id: 'fallback', lastMessageAt: undefined, createdAt: 300 },
  38. ]
  39. const before = rows.map(c => c.id)
  40. const clients = [new WorkspaceTranscriptStore(), new WorkspaceTranscriptStore()]
  41. clients[0]!.hydrate({ agents: [], conversations: rows, details: [], cursor: 0 })
  42. clients[1]!.hydrate({ agents: [], conversations: [...rows].reverse(), details: [], cursor: 0 })
  43. const ordered = (expected: string[]) => clients.forEach(client => expect(client.conversations.map(c => c.id)).toEqual(expected))
  44. ordered(['pin', 'new', 'fallback', 'tie-a', 'tie-b', 'old', 'zero'])
  45. expect(rows.map(c => c.id)).toEqual(before)
  46. const change = (seq: number, id: string, patch: Partial<WorkspaceConversation>) => clients.forEach(client => {
  47. const current = client.conversations.find(c => c.id === id) ?? { ...conversation, id }
  48. client.apply({ seq, type: 'conversation.changed', conversationId: id, data: { ...current, ...patch } })
  49. })
  50. change(1, 'old', { lastMessageAt: 500 })
  51. ordered(['pin', 'old', 'new', 'fallback', 'tie-a', 'tie-b', 'zero'])
  52. change(2, 'tie-b', { pinned: true })
  53. ordered(['tie-b', 'pin', 'old', 'new', 'fallback', 'tie-a', 'zero'])
  54. change(3, 'pin', { pinned: false })
  55. change(4, 'new-2', { lastMessageAt: 400 })
  56. ordered(['tie-b', 'old', 'new', 'new-2', 'fallback', 'tie-a', 'pin', 'zero'])
  57. change(3, 'pin', { pinned: true })
  58. ordered(['tie-b', 'old', 'new', 'new-2', 'fallback', 'tie-a', 'pin', 'zero'])
  59. clients[0]!.conversations = [...clients[1]!.conversations].reverse()
  60. ordered(['tie-b', 'old', 'new', 'new-2', 'fallback', 'tie-a', 'pin', 'zero'])
  61. })
  62. it('returns a warm transcript without losing its older pages and preserves settled identities on a live update', () => {
  63. const store = new WorkspaceTranscriptStore(), initial = detail()
  64. store.hydrate({ agents: [], conversations: [conversation], details: [initial], cursor: 10 })
  65. expect(store.get('c')).toBe(initial)
  66. store.apply(event(11, { ...message('live', 2, '正在生成'), status: 'streaming' }))
  67. expect(store.get('c')!.messages[0]).toBe(initial.messages[0])
  68. expect(store.get('c')!.messages[1]!.content).toBe('正在生成')
  69. expect(store.get('c')!.hasOlder).toBe(true)
  70. })
  71. it('replays events received during a cold detail request instead of rolling back new text', () => {
  72. const store = new WorkspaceTranscriptStore(), token = store.beginRead()
  73. store.apply(event(12, message('live', 2, '更新后的内容')))
  74. const current = store.finishRead(token, detail())
  75. expect(current.messages.at(-1)!.content).toBe('更新后的内容')
  76. })
  77. it('keeps run output active for every executing file-tool phase', () => {
  78. for (const status of ['tool.start', 'tool.started', 'tool.progress', 'tool.generating', 'running']) {
  79. expect(workspaceHasUnfinishedOutput([
  80. { ...message('file', 3, '文件准备中'), status: 'streaming', tools: [{ id: 'file-tool', status }] },
  81. ])).toBe(true)
  82. }
  83. expect(workspaceHasUnfinishedOutput([
  84. { ...message('file', 3, '文件已输出'), status: 'complete', tools: [{ id: 'file-tool', status: 'tool.completed' }] },
  85. ])).toBe(false)
  86. expect(workspaceHasUnfinishedOutput([
  87. { ...message('hidden', 3, '文件准备中'), status: 'complete', visible: false, tools: [{ id: 'file-tool', status: 'tool.generating' }] },
  88. ])).toBe(false)
  89. })
  90. it.each(['complete', 'failed', 'interrupted'] as const)('does not revive %s history with stale tool events', status => {
  91. const old = { ...message('old-tool', 1), status, tools: [{ id: '', status: 'tool.generating' }] }
  92. const latest = message('latest-reply', 2)
  93. expect(workspaceHasUnfinishedOutput([old, latest])).toBe(false)
  94. for (const active of ['queued', 'streaming', 'uncertain'] as const) {
  95. expect(workspaceHasUnfinishedOutput([old, { ...latest, status: active }])).toBe(true)
  96. }
  97. })
  98. it('does not apply pre-snapshot replay over a newer snapshot', () => {
  99. const store = new WorkspaceTranscriptStore(), token = store.beginRead()
  100. store.apply(event(9, message('live', 2, '旧事件')))
  101. expect(store.finishRead(token, detail()).messages.at(-1)!.content).toBe('live')
  102. })
  103. it('removes hidden messages, ignores duplicate events, and evicts deleted conversations', () => {
  104. const store = new WorkspaceTranscriptStore()
  105. store.hydrate({ agents: [], conversations: [conversation], details: [detail()], cursor: 10 })
  106. store.apply(event(11, { ...message('live', 2), visible: false }))
  107. store.apply(event(11, message('live', 2)))
  108. expect(store.get('c')!.messages.map(m => m.id)).toEqual(['old'])
  109. store.apply({ seq: 12, type: 'conversation.deleted', conversationId: 'c', data: { id: 'c' } })
  110. expect(store.get('c')).toBeUndefined()
  111. expect(store.conversations).toEqual([])
  112. })
  113. it('does not resurrect a conversation deleted while its detail request was in flight', () => {
  114. const store = new WorkspaceTranscriptStore(), token = store.beginRead()
  115. store.apply({ seq: 12, type: 'conversation.deleted', conversationId: 'c', data: { id: 'c' } })
  116. expect(() => store.finishRead(token, detail())).toThrow('已被删除')
  117. expect(store.get('c')).toBeUndefined()
  118. })
  119. it('keeps background tasks separate and settles approval cards directly from events', () => {
  120. const initial = detail()
  121. expect(foldDetail(initial, event(11, { ...message('other', 3), conversationTaskId: 'other' }))).toBe(initial)
  122. const pending = foldDetail(initial, { seq: 12, conversationId: 'c', type: 'interaction.changed', data: { id: 'approval', kind: 'approval', choices: [], message: '允许?' } })
  123. expect(pending.interactions).toHaveLength(1)
  124. expect(foldDetail(pending, { seq: 13, conversationId: 'c', type: 'interaction.changed', data: { id: 'approval', resolved: true } }).interactions).toHaveLength(0)
  125. })
  126. })
  127. it('merges send receipts without advancing replay or regressing newer events', () => {
  128. const store = new WorkspaceTranscriptStore()
  129. store.hydrate({ agents: [], conversations: [conversation], details: [detail()], cursor: 10 })
  130. const run = { id: 'r', conversationId: 'c', messageId: 'sent', status: 'queued' as const, mentionIds: [], round: 0, createdAt: 1, updatedAt: 1 }
  131. const receipt = { run, message: message('sent', 3, 'accepted'), conversation, cursor: 15 }
  132. const pending = store.beginRead()
  133. store.acceptReceipt(receipt)
  134. expect(store.cursor).toBe(10)
  135. store.apply(event(12, message('sent', 3, 'stale')))
  136. expect(store.get('c')!.messages.at(-1)!.content).toBe('accepted')
  137. store.apply(event(16, message('sent', 3, 'newer')))
  138. store.acceptReceipt(receipt)
  139. expect(store.get('c')!.messages.at(-1)!.content).toBe('newer')
  140. expect(store.get('c')!.messages.filter(m => m.id === 'sent')).toHaveLength(1)
  141. expect(store.finishRead(pending, detail()).messages.at(-1)!.content).toBe('newer')
  142. expect(store.cursor).toBe(16)
  143. })
  144. it('normalizes revision patches before replay and detects a gap without advancing the cursor', () => {
  145. const store = new WorkspaceTranscriptStore()
  146. store.hydrate({ agents: [], conversations: [conversation], details: [{ ...detail(), messages: [{ ...message('live', 2, '初始'), status: 'streaming', revision: 1 }] }], cursor: 10 })
  147. const patch = (seq: number, baseRevision: number, text: string): WorkspaceEvent => ({ seq, type: 'message.patch', conversationId: 'c',
  148. data: { id: 'live', conversationId: 'c', baseRevision, revision: baseRevision + 1, contentAppend: text, reasoningAppend: '思考' } })
  149. store.apply(patch(11, 1, '🙂'))
  150. store.apply(patch(11, 1, '🙂'))
  151. expect(store.get('c')!.messages[0]!.content).toBe('初始🙂')
  152. expect(() => store.apply(patch(12, 3, '丢失基线'))).toThrow()
  153. expect(store.cursor).toBe(11)
  154. store.apply(patch(12, 2, 'e\u0301'))
  155. expect(store.get('c')!.messages[0]!.content).toBe('初始🙂e\u0301')
  156. })
  157. it('preserves paged history across hydrate, removes hidden rows and rejects older reads', () => {
  158. const store = new WorkspaceTranscriptStore()
  159. store.hydrate({ agents: [], conversations: [conversation], details: [detail()], cursor: 10 })
  160. const read = store.beginRead()
  161. const updated = { ...detail(), conversation: { ...conversation, name: '新版' },
  162. messages: [{ ...message('live', 2, '新内容'), revision: 2 }], hiddenMessageIds: ['old'], cursor: 20 }
  163. store.hydrate({ agents: [], conversations: [updated.conversation], details: [updated], cursor: 20 })
  164. const merged = store.finishRead(read, { ...detail(), cursor: 11 })
  165. expect(merged.conversation.name).toBe('新版')
  166. expect(merged.messages.map(m => m.content)).toEqual(['新内容'])
  167. })
  168. it('does not resurrect a previous account through pending reads on logout', () => {
  169. const store = new WorkspaceTranscriptStore()
  170. store.hydrate({ agents: [], conversations: [conversation], details: [detail()], cursor: 10 })
  171. store.beginRead(); store.apply(event(11, message('private', 3)))
  172. store.hydrate({ agents: [], conversations: [], details: [], cursor: 0 })
  173. expect(store.conversations).toEqual([]); expect(store.details.size).toBe(0)
  174. })