workspacePresentation.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. import { afterEach, describe, expect, it, vi } from 'vitest'
  2. import { createUuid } from '@/utils/id'
  3. import { workspaceMessagesToUi } from '@/components/workspace/viewModels'
  4. afterEach(() => vi.unstubAllGlobals())
  5. describe('workspace chat uses the established presentation', () => {
  6. it('creates valid request ids on LAN HTTP without crypto.randomUUID', () => {
  7. vi.stubGlobal('crypto', { getRandomValues: (bytes: Uint8Array) => { for (let i=0;i<bytes.length;i++) bytes[i]=i; return bytes } })
  8. expect(createUuid()).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
  9. })
  10. it('preserves role identity, reasoning, files and tools for MessageTimeline', () => {
  11. const [message] = workspaceMessagesToUi([{id:'m',conversationId:'c',seq:1,role:'assistant',agentId:'a',agentName:'编辑',content:'[报告](/tmp/report.txt)',reasoning:'检查资料',status:'complete',createdAt:10,attachments:[{id:'f',name:'report.txt',mimeType:'text/plain',size:4,createdAt:10,sender:'agent',sourcePath:'/tmp/report.txt'}],tools:[{id:'t',name:'read_file',status:'tool.complete',result:'ok'}]}])
  12. expect(message).toMatchObject({profile:'a',author:'编辑',reasoning:'检查资料',status:'settled',content:'[报告](/api/app/files/f/download)'})
  13. expect(message?.tools?.[0]).toMatchObject({name:'read_file',status:'success',output:'ok'})
  14. expect(message?.attachments?.[0]?.url).toBe('/api/app/files/f/download')
  15. })
  16. it.each([
  17. ['complete', 'success'], ['failed', 'error'], ['interrupted', 'interrupted'], ['uncertain', 'running'], ['streaming', 'running'],
  18. ] as const)('renders stale tools in a %s message without reopening its trace', (status, expected) => {
  19. const [message] = workspaceMessagesToUi([{
  20. id: 'legacy', conversationId: 'c', seq: 1, role: 'assistant', content: '回复', reasoning: '',
  21. status, createdAt: 1, attachments: [], tools: [
  22. { id: '', name: 'tool_call', status: 'tool.generating' },
  23. { id: 'failed-tool', name: 'read_file', status: 'tool.complete', result: { error: '授权已结束' } },
  24. ],
  25. }])
  26. expect(message?.tools?.[0]?.status).toBe(expected)
  27. expect(message?.tools?.[1]?.status).toBe('error')
  28. })
  29. it('keeps peer direction outside the body without presenting it as a user message', () => {
  30. const communication = { direction: 'incoming' as const, peerId: 'b', peerName: '研究员', peerAvatar: '', peerKind: 'agent' as const, content: '当前为 v2' }
  31. const [message] = workspaceMessagesToUi([{ id: 'peer', conversationId: 'c', seq: 1, role: 'system', peerMessageId: 'p', communication, content: '来自 Bot「研究员」的回复:当前为 v2', reasoning: '', tools: [], attachments: [], status: 'complete', createdAt: 1 }])
  32. expect(message?.communication).toEqual(communication)
  33. expect(message?.content).toBe('当前为 v2')
  34. expect(message?.role).toBe('system')
  35. })
  36. it('preserves task references for the task-result notification', () => {
  37. const taskReference = { conversationId: 'team', taskId: 'task' }
  38. const [message] = workspaceMessagesToUi([{ id: 'result', conversationId: 'origin', seq: 1, role: 'system', taskReference, content: '任务已有结果', reasoning: '', tools: [], attachments: [], status: 'complete', createdAt: 1 }])
  39. expect(message?.taskReference).toEqual(taskReference)
  40. })
  41. })
  42. import { mount } from '@vue/test-utils'
  43. import { nextTick } from 'vue'
  44. import ConversationList from '@/components/workspace/ConversationList.vue'
  45. import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
  46. import TeamAvatar from '@/components/common/TeamAvatar.vue'
  47. import type { WorkspaceAgent, WorkspaceConversation } from '../../src/shared/workspace'
  48. it('renders Bot unread as a dot and treats explicit false as authoritative over legacy counts', async () => {
  49. const conversation: WorkspaceConversation = { id: 'dot', kind: 'direct', name: '未读测试', avatar: '', memberIds: [], instructions: '', administratorId: '', mode: 'host', autoReplyIds: [], maxReplyRounds: 1, archived: false, pinned: false, readSeq: 0, lastSeq: 999, unreadCount: 999, unread: true, preview: '完成', createdAt: 1, updatedAt: 1 }
  50. const wrapper = mount(ConversationList, { props: { conversations: [conversation], agents: [] } })
  51. expect(wrapper.find('[aria-label="未读"]').exists()).toBe(true)
  52. expect(wrapper.find('.sidebar-item__row b').exists()).toBe(false)
  53. await wrapper.setProps({ conversations: [{ ...conversation, unread: false }] })
  54. expect(wrapper.find('[aria-label="未读"]').exists()).toBe(false)
  55. await wrapper.setProps({ conversations: [{ ...conversation, unread: undefined }] })
  56. expect(wrapper.find('[aria-label="未读"]').exists()).toBe(true)
  57. wrapper.unmount()
  58. })
  59. it('composes real member avatars and refreshes them without changing group membership', async () => {
  60. const agents: WorkspaceAgent[] = ['first','second'].map((id,index) => ({id,name:id,avatar:`yaoyao-mascot:v1:${index ? 'square' : 'circle'}:377fe6:friendly`,instructions:'',nodeId:'local',profile:'default',archived:false,revision:1,createdAt:1,updatedAt:1}))
  61. const c: WorkspaceConversation = {id:'g',kind:'group',name:'群聊',avatar:'data:image/png;base64,AA==',memberIds:['second','first'],instructions:'',administratorId:'first',mode:'host',autoReplyIds:[],maxReplyRounds:1,archived:false,pinned:false,readSeq:0,lastSeq:0,preview:'',createdAt:Date.now(),updatedAt:Date.now(),lastMessageAt:Date.now()}
  62. const wrapper = mount(ConversationList, {props:{conversations:[c],agents}})
  63. expect(wrapper.getComponent(TeamAvatar).props('members')!.map((m: {name:string}) => m.name)).toEqual(['second','first'])
  64. expect(wrapper.findAll('.team-avatar__member')).toHaveLength(2)
  65. expect(wrapper.find('.team-avatar__image').exists()).toBe(false)
  66. expect(wrapper.find('.sidebar-item__row small').text()).toMatch(/\d{2}:\d{2}/)
  67. await wrapper.setProps({agents:agents.map(a=>a.id==='first'?{...a,avatar:'yaoyao-mascot:v1:triangle:d94b52:curious'}:a)})
  68. expect(wrapper.getComponent(TeamAvatar).props('members')![1]!.avatar).toBe('yaoyao-mascot:v1:triangle:d94b52:curious')
  69. await wrapper.setProps({conversations:[{...c,activeRunId:'run',activeAgentId:'second',activeRunStatus:'running'}]})
  70. expect(wrapper.findAll('.team-avatar__member.agent-avatar--working')).toHaveLength(1)
  71. expect(wrapper.find('.team-avatar__member--1').classes()).toContain('agent-avatar--working')
  72. await wrapper.setProps({conversations:[{...c,activeRunId:'run',activeAgentId:'second',activeRunStatus:'waiting'}]})
  73. expect(wrapper.findAll('.team-avatar__member.agent-avatar--waiting')).toHaveLength(1)
  74. await wrapper.setProps({conversations:[c]})
  75. expect(wrapper.findAll('.team-avatar__member.agent-avatar--working')).toHaveLength(0)
  76. expect(wrapper.findAll('.team-avatar__member.agent-avatar--waiting')).toHaveLength(0)
  77. await wrapper.setProps({conversations:[{...c,kind:'direct',memberIds:['first'],activeRunId:'direct-run',activeAgentId:'first',activeRunStatus:'running'}]})
  78. expect(wrapper.find('.sidebar-item__icon .agent-avatar').exists() || wrapper.find('.sidebar-item .agent-avatar').exists()).toBe(true)
  79. expect(wrapper.find('.sidebar-item .agent-avatar--working').exists()).toBe(true)
  80. await wrapper.setProps({conversations:[{...c,kind:'direct',memberIds:['first'],activeRunId:'direct-run',activeAgentId:'first',activeRunStatus:'waiting'}]})
  81. expect(wrapper.find('.sidebar-item .agent-avatar--waiting').exists()).toBe(true)
  82. wrapper.unmount()
  83. })
  84. it.each([
  85. ['direct', '编辑资料'],
  86. ['group', '群聊设置'],
  87. ] as const)('opens settings for a %s conversation from its actions menu', async (kind, label) => {
  88. const conversation: WorkspaceConversation = {
  89. id: kind,
  90. kind,
  91. name: kind === 'direct' ? '机器人' : '群聊',
  92. avatar: '',
  93. memberIds: [],
  94. instructions: '',
  95. administratorId: '',
  96. mode: 'host',
  97. autoReplyIds: [],
  98. maxReplyRounds: 1,
  99. archived: false,
  100. pinned: false,
  101. readSeq: 0,
  102. lastSeq: 0,
  103. preview: '',
  104. createdAt: 1,
  105. updatedAt: 1,
  106. }
  107. const wrapper = mount(ConversationList, { attachTo: document.body, props: { conversations: [conversation] } })
  108. wrapper.getComponent(ResourceSidebar).vm.$emit('more', conversation.id, new MouseEvent('click', { clientX: 100, clientY: 100 }))
  109. await nextTick()
  110. const menu = document.body.querySelector<HTMLElement>('[aria-label="聊天操作"]')!
  111. const view = [...menu.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === label)!
  112. view.click()
  113. await nextTick()
  114. expect(wrapper.emitted('settings')).toEqual([[conversation.id]])
  115. expect(wrapper.emitted('select')).toBeUndefined()
  116. expect(document.body.querySelector('[aria-label="聊天操作"]')).toBeNull()
  117. wrapper.unmount()
  118. })
  119. it('keeps inline thinking in the process trace and its media out of the message body', () => {
  120. const messages = workspaceMessagesToUi([{
  121. id: 'inline-thinking', conversationId: 'chat', seq: 1, role: 'assistant',
  122. content: '<think>![过程图](/tmp/process.png)</think>[报告](/tmp/report.pdf)',
  123. reasoning: '', status: 'complete', attachments: [], tools: [], createdAt: 1,
  124. }])
  125. expect(messages[0].content).toBe('[报告](/tmp/report.pdf)')
  126. expect(messages[0].reasoning).toBe('![过程图](/tmp/process.png)')
  127. })
  128. it('keeps a Bot busy across group replies until its final active conversation finishes', async () => {
  129. const base: WorkspaceConversation = { id: 'direct', kind: 'direct', name: 'Bot', avatar: '', memberIds: ['a'], instructions: '', administratorId: 'a', mode: 'host', autoReplyIds: [], maxReplyRounds: 1, archived: false, pinned: false, readSeq: 0, lastSeq: 0, preview: '', createdAt: 1, updatedAt: 1 }
  130. const group: WorkspaceConversation = { ...base, id: 'group', kind: 'group', memberIds: ['a', 'b'], activeRunId: 'g-run', activeAgentStates: { a: 'running', b: 'waiting' } }
  131. const other: WorkspaceConversation = { ...group, id: 'other', activeRunId: 'other-run', activeAgentStates: { a: 'waiting' } }
  132. const wrapper = mount(ConversationList, { props: { conversations: [base, group, other, { ...base, id: 'unrelated', memberIds: ['unrelated'] }] } })
  133. const bot = () => wrapper.get('[data-sidebar-id="direct"]')
  134. expect(bot().find('.agent-avatar--working').exists()).toBe(true)
  135. expect(bot().find('.presence--working').exists()).toBe(true)
  136. expect(wrapper.get('[data-sidebar-id="unrelated"]').find('.agent-avatar--working').exists()).toBe(false)
  137. await wrapper.setProps({ conversations: [base, { ...group, activeRunId: undefined, activeAgentStates: {}, avatarSignals: { a: { id: 'done', state: 'success', at: Date.now() } } }, other] })
  138. expect(bot().find('.agent-avatar--waiting').exists()).toBe(true)
  139. await wrapper.setProps({ conversations: [base, { ...other, activeAgentStates: { a: 'queued' } }] })
  140. expect(bot().find('.agent-avatar--loading').exists()).toBe(true)
  141. await wrapper.setProps({ conversations: [base] })
  142. expect(bot().find('.agent-avatar--idle').exists()).toBe(true)
  143. expect(bot().find('.presence--working').exists()).toBe(false)
  144. wrapper.unmount()
  145. })