groupHost.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. import { mount } from '@vue/test-utils'
  2. import { nextTick } from 'vue'
  3. import { describe, expect, it } from 'vitest'
  4. import type { GroupAgent, GroupMessage, GroupRoomSummary } from '@shared/types'
  5. import CreateGroupDialog from '@/components/groups/CreateGroupDialog.vue'
  6. import GroupManager from '@/components/groups/GroupManager.vue'
  7. import { groupMessageToUi, roomSidebarItem } from '@/components/workspace/viewModels'
  8. const profiles = (...names: string[]) => names.map(name => ({
  9. id: `local|${name}`, profile: name, displayName: name, nodeId: 'local', nodeLabel: '当前 Hermes',
  10. }))
  11. function agent(id: string, profile: string, displayName: string, isHost: boolean, replyWithoutMention: boolean): GroupAgent {
  12. return {
  13. id, roomId: 'room-1', profile, nodeId: 'local', nodeLabel: '', displayName, description: `${displayName} 的职责`, storedSessionId: null,
  14. lastContextMessageSeq: 0, enabled: true, replyWithoutMention, isHost,
  15. model: null, provider: null, reasoningEffort: null, fastMode: null,
  16. createdAt: 1, updatedAt: 1, status: 'idle',
  17. }
  18. }
  19. describe('group host controls', () => {
  20. it('resolves a local team avatar by display name when an old room stored an internal Profile alias', () => {
  21. const room = {
  22. id: 'room-1', name: '头像团队', cwd: '', instructions: '', avatar: '', createdAt: 1, updatedAt: 1,
  23. archived: false, agentCount: 2, lastMessage: null, unreadCount: 0, activeRunCount: 0, maxReplyRounds: 3, orchestrationMode: 'free',
  24. avatarMembers: [
  25. { profile: 'legacy:default', nodeId: 'local', displayName: '丫头' },
  26. { profile: 'default', nodeId: 'remote-node', displayName: '远端丫头' },
  27. ],
  28. } satisfies GroupRoomSummary
  29. const item = roomSidebarItem(room, { default: 'data:image/png;base64,LOCAL==' }, { 丫头: 'data:image/png;base64,LOCAL==' })
  30. expect(item.avatarMembers).toEqual([
  31. { name: '丫头', avatar: 'data:image/png;base64,LOCAL==' },
  32. { name: '远端丫头', avatar: undefined },
  33. ])
  34. })
  35. it('keeps same-name Profiles distinct across paired Hermes nodes', async () => {
  36. const local = profiles('default')[0]!
  37. const remote = {
  38. id: '11111111-1111-4111-8111-111111111111|default',
  39. profile: 'default',
  40. displayName: '远端夭夭',
  41. nodeId: '11111111-1111-4111-8111-111111111111',
  42. nodeLabel: 'MacBook Pro',
  43. }
  44. const wrapper = mount(CreateGroupDialog, {
  45. attachTo: document.body,
  46. props: { open: true, profiles: [local, remote], hostEnabled: true },
  47. global: { stubs: { teleport: true } },
  48. })
  49. await wrapper.get('input[placeholder="例如:产品评审"]').setValue('跨节点群')
  50. await wrapper.findAll('.agent-picker > button')[1]!.trigger('click')
  51. await wrapper.get('.solid-button').trigger('click')
  52. expect(wrapper.emitted('create')?.[0]?.[0]).toMatchObject({
  53. members: [local, remote],
  54. hostProfile: local.id,
  55. })
  56. wrapper.unmount()
  57. })
  58. it('adds an Agent from a paired node to an existing group', async () => {
  59. const local = agent('agent-local', 'default', '本机 Agent', true, true)
  60. const remote = {
  61. id: '11111111-1111-4111-8111-111111111111|reviewer',
  62. profile: 'reviewer',
  63. displayName: '远端评审',
  64. nodeId: '11111111-1111-4111-8111-111111111111',
  65. nodeLabel: '工作室 Mac',
  66. }
  67. const wrapper = mount(GroupManager, {
  68. props: {
  69. room: { id: 'room-1', name: '群聊', memberIds: [local.id], replyRounds: 3 },
  70. agents: [local],
  71. availableProfiles: [remote],
  72. },
  73. })
  74. await wrapper.get<HTMLSelectElement>('select[aria-label="选择要添加的 Agent"]').setValue(remote.id)
  75. expect(wrapper.emitted('addAgent')?.at(-1)).toEqual([remote.id])
  76. wrapper.unmount()
  77. })
  78. it('keeps one independent host selection when creating a v5 room', async () => {
  79. const wrapper = mount(CreateGroupDialog, {
  80. attachTo: document.body,
  81. props: { open: true, profiles: profiles('yaoyao', 'yaoer'), hostEnabled: true },
  82. global: { stubs: { teleport: true } },
  83. })
  84. await nextTick()
  85. await wrapper.get('input[placeholder="例如:产品评审"]').setValue('主持人验收')
  86. const profileButtons = wrapper.findAll('.agent-picker > button')
  87. await profileButtons[1]!.trigger('click')
  88. const host = wrapper.get<HTMLSelectElement>('select[aria-label="主持人"]')
  89. await host.setValue('local|yaoer')
  90. await profileButtons[1]!.trigger('click')
  91. expect(host.element.value).toBe('local|yaoyao')
  92. await profileButtons[1]!.trigger('click')
  93. await host.setValue('local|yaoer')
  94. expect(wrapper.get<HTMLInputElement>('input[aria-label="所有成员无需 @ 也回复"]').element.checked).toBe(true)
  95. await wrapper.get('.solid-button').trigger('click')
  96. expect(wrapper.emitted('create')?.[0]?.[0]).toEqual({
  97. name: '主持人验收', members: profiles('yaoyao', 'yaoer'), hostProfile: 'local|yaoer', autoReply: true, replyRounds: 3,
  98. })
  99. wrapper.unmount()
  100. })
  101. it('keeps the legacy v4 create UI and payload free of host fields', async () => {
  102. const wrapper = mount(CreateGroupDialog, {
  103. attachTo: document.body,
  104. props: { open: true, profiles: profiles('yaoyao'), hostEnabled: false },
  105. global: { stubs: { teleport: true } },
  106. })
  107. await nextTick()
  108. expect(wrapper.find('select[aria-label="主持人"]').exists()).toBe(false)
  109. expect(wrapper.find('input[aria-label="启用自动回复"]').exists()).toBe(true)
  110. await wrapper.get('input[placeholder="例如:产品评审"]').setValue('v4 房间')
  111. await wrapper.get('.solid-button').trigger('click')
  112. expect(wrapper.emitted('create')?.[0]?.[0]).not.toHaveProperty('hostProfile')
  113. expect(wrapper.emitted('create')?.[0]?.[0]).not.toHaveProperty('orchestrationMode')
  114. expect(wrapper.emitted('create')?.[0]?.[0]).not.toHaveProperty('instructions')
  115. wrapper.unmount()
  116. })
  117. it('offers host flow only when the server advertises the capability', async () => {
  118. const wrapper = mount(CreateGroupDialog, {
  119. attachTo: document.body,
  120. props: {
  121. open: true,
  122. profiles: profiles('yaoyao', 'yaoer'),
  123. hostEnabled: true,
  124. hostFlowEnabled: true,
  125. roomInstructionsEnabled: true,
  126. },
  127. global: { stubs: { teleport: true } },
  128. })
  129. await wrapper.get('input[placeholder="例如:产品评审"]').setValue('顺序协作')
  130. await wrapper.get<HTMLTextAreaElement>('textarea').setValue('先核对事实,再输出中文结论。')
  131. await wrapper.get<HTMLSelectElement>('select[aria-label="协作模式"]').setValue('host')
  132. await wrapper.get('.solid-button').trigger('click')
  133. expect(wrapper.emitted('create')?.[0]?.[0]).toMatchObject({
  134. name: '顺序协作',
  135. instructions: '先核对事实,再输出中文结论。',
  136. orchestrationMode: 'host',
  137. })
  138. wrapper.unmount()
  139. })
  140. it('shows and submits an automatic team avatar from selected Agents', async () => {
  141. const choices = profiles('yaoyao', 'yaoer').map((profile, index) => ({
  142. ...profile,
  143. ...(index === 0 ? { avatar: 'data:image/png;base64,AA==' } : {}),
  144. }))
  145. const wrapper = mount(CreateGroupDialog, {
  146. attachTo: document.body,
  147. props: { open: true, profiles: choices, avatarEnabled: true },
  148. global: { stubs: { teleport: true } },
  149. })
  150. await wrapper.get('input[placeholder="例如:产品评审"]').setValue('头像团队')
  151. await wrapper.findAll('.agent-picker > button')[1]!.trigger('click')
  152. expect(wrapper.findAll('.avatar-picker .team-avatar__member')).toHaveLength(2)
  153. expect(wrapper.get('.avatar-picker').text()).toContain('自动组合已选 Agent 的头像')
  154. await wrapper.get('.solid-button').trigger('click')
  155. expect(wrapper.emitted('create')?.[0]?.[0]).toMatchObject({
  156. name: '头像团队',
  157. avatar: '',
  158. members: choices,
  159. })
  160. wrapper.unmount()
  161. })
  162. it('emits one host promotion and explains the independent auto-reply setting', async () => {
  163. const first = agent('agent-1', 'yaoyao', '夭夭', true, true)
  164. const second = agent('agent-2', 'yaoer', '瑶儿', false, false)
  165. const wrapper = mount(GroupManager, {
  166. attachTo: document.body,
  167. props: {
  168. room: { id: 'room-1', name: '群聊', memberIds: [first.id, second.id], replyRounds: 3 },
  169. agents: [first, second],
  170. hostEnabled: true,
  171. },
  172. })
  173. const selector = wrapper.get<HTMLSelectElement>('select[aria-label="主持人"]')
  174. expect(selector.element.value).toBe('agent-1')
  175. expect(wrapper.findAll('.host-badge')).toHaveLength(1)
  176. await selector.setValue('agent-2')
  177. expect(wrapper.emitted('updateAgent')?.[0]).toEqual(['agent-2', { isHost: true }])
  178. await wrapper.setProps({ agents: [{ ...first, isHost: false }, { ...second, isHost: true }] })
  179. await wrapper.get('button[aria-label="设置瑶儿"]').trigger('click')
  180. await nextTick()
  181. const dialog = document.querySelector('[role="dialog"][aria-label="瑶儿 Agent 设置"]')
  182. expect(dialog?.textContent).toContain('主持人始终处理用户无 @ 消息')
  183. expect(dialog?.querySelector('input[aria-label="无需 @ 也回复"]')).not.toBeNull()
  184. wrapper.unmount()
  185. })
  186. it('blocks removing a host when no enabled replacement exists and shows manager errors', () => {
  187. const first = agent('agent-1', 'yaoyao', '夭夭', true, true)
  188. const second = { ...agent('agent-2', 'yaoer', '瑶儿', false, false), enabled: false }
  189. const wrapper = mount(GroupManager, {
  190. props: {
  191. room: { id: 'room-1', name: '群聊', memberIds: [first.id, second.id], replyRounds: 3 },
  192. agents: [first, second],
  193. hostEnabled: true,
  194. managerError: '需要另一位已启用成员',
  195. },
  196. })
  197. const removeHost = wrapper.get<HTMLButtonElement>('button[aria-label="移除夭夭"]')
  198. expect(removeHost.element.disabled).toBe(true)
  199. expect(removeHost.attributes('title')).toContain('已启用成员')
  200. expect(wrapper.get('[role="alert"]').text()).toBe('需要另一位已启用成员')
  201. })
  202. it('emits a room update when switching collaboration mode', async () => {
  203. const first = agent('agent-1', 'yaoyao', '夭夭', true, false)
  204. const wrapper = mount(GroupManager, {
  205. props: {
  206. room: { id: 'room-1', name: '群聊', memberIds: [first.id], replyRounds: 3, orchestrationMode: 'free' },
  207. agents: [first],
  208. hostEnabled: true,
  209. hostFlowEnabled: true,
  210. },
  211. })
  212. await wrapper.get<HTMLSelectElement>('select[aria-label="协作模式"]').setValue('host')
  213. expect(wrapper.emitted('updateRoom')?.[0]?.[0]).toEqual({
  214. name: '群聊',
  215. replyRounds: 3,
  216. orchestrationMode: 'host',
  217. })
  218. })
  219. it('edits room instructions when the capability is available', async () => {
  220. const first = agent('agent-1', 'yaoyao', '夭夭', true, false)
  221. const wrapper = mount(GroupManager, {
  222. props: {
  223. room: {
  224. id: 'room-1', name: '群聊', memberIds: [first.id], replyRounds: 3,
  225. orchestrationMode: 'free', instructions: '旧规则',
  226. },
  227. agents: [first],
  228. hostEnabled: true,
  229. hostFlowEnabled: true,
  230. roomInstructionsEnabled: true,
  231. },
  232. })
  233. const instructions = wrapper.get<HTMLTextAreaElement>('textarea[aria-label="团队说明"]')
  234. await instructions.setValue('新规则\n输出中文')
  235. await instructions.trigger('change')
  236. expect(wrapper.emitted('updateRoom')?.at(-1)?.[0]).toMatchObject({
  237. instructions: '新规则\n输出中文',
  238. })
  239. })
  240. it('only allows remote Agents to rename themselves within the room', async () => {
  241. const local = agent('agent-1', 'yaoyao', '夭夭', false, false)
  242. const remote = {
  243. ...agent('agent-2', 'yaoer', '远端夭夭', false, false),
  244. nodeId: '11111111-1111-4111-8111-111111111111',
  245. nodeLabel: '工作室 Mac',
  246. }
  247. const wrapper = mount(GroupManager, {
  248. props: {
  249. room: { id: 'room-1', name: '群聊', memberIds: [local.id, remote.id], replyRounds: 3 },
  250. agents: [local, remote],
  251. remoteServerAddresses: { [remote.nodeId]: '192.168.1.20:9119' },
  252. },
  253. global: { stubs: { teleport: true } },
  254. })
  255. await wrapper.get('button[aria-label="设置远端夭夭"]').trigger('click')
  256. const remoteName = wrapper.get<HTMLInputElement>('input[aria-label="群内名称"]')
  257. await remoteName.setValue('群内审查员')
  258. await wrapper.get('.save-agent').trigger('click')
  259. expect(wrapper.emitted('updateAgent')?.at(-1)).toEqual([
  260. remote.id,
  261. { displayName: '群内审查员' },
  262. ])
  263. expect(wrapper.text()).toContain('192.168.1.20:9119')
  264. await wrapper.get('button[aria-label="设置夭夭"]').trigger('click')
  265. expect(wrapper.find('input[aria-label="群内名称"]').exists()).toBe(false)
  266. })
  267. it('uses the synced remote Agent name and marks its topic message', () => {
  268. const remote = {
  269. ...agent('agent-remote', 'reviewer', '群内审查员', false, false),
  270. nodeId: '11111111-1111-4111-8111-111111111111',
  271. nodeLabel: '工作室 Mac',
  272. }
  273. const message: GroupMessage = {
  274. seq: 1,
  275. id: 'message-1',
  276. roomId: 'room-1',
  277. senderKind: 'agent',
  278. senderId: remote.id,
  279. senderName: '旧名称',
  280. rootMessageId: 'message-1',
  281. content: '已完成',
  282. reasoning: '',
  283. toolState: [],
  284. status: 'completed',
  285. error: '',
  286. createdAt: 1,
  287. updatedAt: 1,
  288. }
  289. expect(groupMessageToUi(message, [remote])).toMatchObject({
  290. author: '群内审查员',
  291. isRemoteAgent: true,
  292. })
  293. })
  294. })