groupHost.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  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 { TEAM_PRESETS } from '@/components/groups/teamPresets'
  8. import { groupMessageToUi, roomSidebarItem } from '@/components/workspace/viewModels'
  9. import { fallbackTeamAnimalAvatar } from '@/utils/teamAvatar'
  10. const profiles = (...names: string[]) => names.map(name => ({
  11. id: `local|${name}`, profile: name, displayName: name, nodeId: 'local', nodeLabel: '当前 Hermes',
  12. }))
  13. function agent(id: string, profile: string, displayName: string, isHost: boolean, replyWithoutMention: boolean): GroupAgent {
  14. return {
  15. id, roomId: 'room-1', profile, nodeId: 'local', nodeLabel: '', displayName, description: `${displayName} 的职责`, storedSessionId: null,
  16. lastContextMessageSeq: 0, enabled: true, replyWithoutMention, isHost,
  17. model: null, provider: null, reasoningEffort: null, fastMode: null,
  18. createdAt: 1, updatedAt: 1, status: 'idle',
  19. }
  20. }
  21. describe('group host controls', () => {
  22. it('uses the shared stable animal mapping for legacy empty avatars', () => {
  23. expect(fallbackTeamAnimalAvatar('22222222-2222-4222-8222-222222222222').id).toBe('bear')
  24. })
  25. it('provides office and development presets with unique roles within the Agent limit', () => {
  26. expect(TEAM_PRESETS.map(preset => preset.name)).toEqual([
  27. '信息收集团队', '产品设计团队', '软件开发团队', '文案书写团队',
  28. '项目管理团队', '数据分析团队', '会议协作团队', '运维保障团队',
  29. ])
  30. for (const preset of TEAM_PRESETS) {
  31. expect(preset.roles.length).toBeGreaterThanOrEqual(4)
  32. expect(preset.roles.length).toBeLessThanOrEqual(8)
  33. expect(new Set(preset.roles.map(role => role.name)).size).toBe(preset.roles.length)
  34. expect(preset.roles.filter(role => role.host)).toHaveLength(1)
  35. }
  36. })
  37. it('maps preset roles onto existing Agents and submits their role descriptions', async () => {
  38. const choices = profiles('yaoyao', 'yaoer', 'reviewer', 'writer')
  39. const wrapper = mount(CreateGroupDialog, {
  40. attachTo: document.body,
  41. props: {
  42. open: true,
  43. profiles: choices,
  44. hostEnabled: true,
  45. hostFlowEnabled: true,
  46. roomInstructionsEnabled: true,
  47. },
  48. global: { stubs: { teleport: true } },
  49. })
  50. await wrapper.get('button[aria-label^="信息收集团队"]').trigger('click')
  51. expect(wrapper.get<HTMLInputElement>('input[placeholder="例如:产品评审"]').element.value).toBe('信息收集团队')
  52. expect(wrapper.findAll('.role-mapping select')).toHaveLength(4)
  53. await wrapper.get<HTMLSelectElement>('select[aria-label="信息检索对应的机器人"]').setValue('local|yaoyao')
  54. await wrapper.get<HTMLSelectElement>('select[aria-label="信息检索对应的机器人"]').setValue('local|reviewer')
  55. await wrapper.get('.solid-button').trigger('click')
  56. const payload = wrapper.emitted('create')?.[0]?.[0] as { members: Array<{ profile: string; displayName: string; description: string }>; hostProfile: string; autoReply: boolean; orchestrationMode: string; instructions: string }
  57. expect(payload.members.map(member => [member.profile, member.displayName])).toEqual([
  58. ['yaoer', '调研负责人'],
  59. ['reviewer', '信息检索'],
  60. ['yaoyao', '事实核验'],
  61. ['writer', '资料整理'],
  62. ])
  63. expect(payload.members.every(member => member.description.length > 0)).toBe(true)
  64. expect(payload.hostProfile).toBe('local|yaoer')
  65. expect(payload.autoReply).toBe(false)
  66. expect(payload.orchestrationMode).toBe('host')
  67. expect(payload.instructions).toContain('交叉核验')
  68. wrapper.unmount()
  69. })
  70. it('disables presets when the current Agent count is insufficient', async () => {
  71. const wrapper = mount(CreateGroupDialog, {
  72. attachTo: document.body,
  73. props: { open: true, profiles: profiles('yaoyao', 'yaoer', 'reviewer', 'writer') },
  74. global: { stubs: { teleport: true } },
  75. })
  76. const softwarePreset = wrapper.get<HTMLButtonElement>('button[aria-label^="软件开发团队"]')
  77. expect(softwarePreset.element.disabled).toBe(true)
  78. expect(softwarePreset.text()).toContain('还缺 1 人')
  79. await softwarePreset.trigger('click')
  80. expect(wrapper.find('.role-mapping').exists()).toBe(false)
  81. wrapper.unmount()
  82. })
  83. it('resolves a local team avatar by display name when an old room stored an internal Profile alias', () => {
  84. const room = {
  85. id: 'room-1', name: '头像团队', cwd: '', instructions: '', avatar: '', createdAt: 1, updatedAt: 1,
  86. archived: false, agentCount: 2, lastMessage: null, unreadCount: 0, activeRunCount: 0, maxReplyRounds: 3, orchestrationMode: 'free',
  87. avatarMembers: [
  88. { profile: 'legacy:default', nodeId: 'local', displayName: '丫头' },
  89. { profile: 'default', nodeId: 'remote-node', displayName: '远端丫头' },
  90. ],
  91. } satisfies GroupRoomSummary
  92. const item = roomSidebarItem(room, { default: 'data:image/png;base64,LOCAL==' }, { 丫头: 'data:image/png;base64,LOCAL==' })
  93. expect(item.avatarFallbackKey).toBe(room.id)
  94. expect(item.avatarMembers).toEqual([
  95. { name: '丫头', avatar: 'data:image/png;base64,LOCAL==' },
  96. { name: '远端丫头', avatar: undefined },
  97. ])
  98. })
  99. it('keeps same-name Profiles distinct across paired Hermes nodes', async () => {
  100. const local = profiles('default')[0]!
  101. const remote = {
  102. id: '11111111-1111-4111-8111-111111111111|default',
  103. profile: 'default',
  104. displayName: '远端夭夭',
  105. nodeId: '11111111-1111-4111-8111-111111111111',
  106. nodeLabel: 'MacBook Pro',
  107. }
  108. const wrapper = mount(CreateGroupDialog, {
  109. attachTo: document.body,
  110. props: { open: true, profiles: [local, remote], hostEnabled: true },
  111. global: { stubs: { teleport: true } },
  112. })
  113. await wrapper.get('input[placeholder="例如:产品评审"]').setValue('跨节点群')
  114. await wrapper.findAll('.agent-picker > button')[1]!.trigger('click')
  115. await wrapper.get('.solid-button').trigger('click')
  116. expect(wrapper.emitted('create')?.[0]?.[0]).toMatchObject({
  117. members: [local, remote],
  118. hostProfile: local.id,
  119. })
  120. wrapper.unmount()
  121. })
  122. it('adds an Agent from a paired node to an existing group', async () => {
  123. const local = agent('agent-local', 'default', '本机机器人', true, true)
  124. const remote = {
  125. id: '11111111-1111-4111-8111-111111111111|reviewer',
  126. profile: 'reviewer',
  127. displayName: '远端评审',
  128. nodeId: '11111111-1111-4111-8111-111111111111',
  129. nodeLabel: '工作室 Mac',
  130. }
  131. const wrapper = mount(GroupManager, {
  132. props: {
  133. room: { id: 'room-1', name: '群聊', memberIds: [local.id], replyRounds: 3 },
  134. agents: [local],
  135. availableProfiles: [remote],
  136. },
  137. })
  138. await wrapper.get<HTMLSelectElement>('select[aria-label="选择要添加的机器人"]').setValue(remote.id)
  139. expect(wrapper.emitted('addAgent')?.at(-1)).toEqual([remote.id])
  140. wrapper.unmount()
  141. })
  142. it('keeps one independent host selection when creating a v5 room', async () => {
  143. const wrapper = mount(CreateGroupDialog, {
  144. attachTo: document.body,
  145. props: { open: true, profiles: profiles('yaoyao', 'yaoer'), hostEnabled: true },
  146. global: { stubs: { teleport: true } },
  147. })
  148. await nextTick()
  149. await wrapper.get('input[placeholder="例如:产品评审"]').setValue('管理员验收')
  150. const profileButtons = wrapper.findAll('.agent-picker > button')
  151. await profileButtons[1]!.trigger('click')
  152. const host = wrapper.get<HTMLSelectElement>('select[aria-label="管理员"]')
  153. await host.setValue('local|yaoer')
  154. await profileButtons[1]!.trigger('click')
  155. expect(host.element.value).toBe('local|yaoyao')
  156. await profileButtons[1]!.trigger('click')
  157. await host.setValue('local|yaoer')
  158. expect(wrapper.get<HTMLInputElement>('input[aria-label="所有成员无需 @ 也回复"]').element.checked).toBe(true)
  159. await wrapper.get('.solid-button').trigger('click')
  160. expect(wrapper.emitted('create')?.[0]?.[0]).toEqual({
  161. name: '管理员验收', members: profiles('yaoyao', 'yaoer'), hostProfile: 'local|yaoer', autoReply: true, replyRounds: 3,
  162. })
  163. wrapper.unmount()
  164. })
  165. it('keeps the legacy v4 create UI and payload free of host fields', async () => {
  166. const wrapper = mount(CreateGroupDialog, {
  167. attachTo: document.body,
  168. props: { open: true, profiles: profiles('yaoyao'), hostEnabled: false },
  169. global: { stubs: { teleport: true } },
  170. })
  171. await nextTick()
  172. expect(wrapper.find('select[aria-label="管理员"]').exists()).toBe(false)
  173. expect(wrapper.find('input[aria-label="启用自动回复"]').exists()).toBe(true)
  174. await wrapper.get('input[placeholder="例如:产品评审"]').setValue('v4 房间')
  175. await wrapper.get('.solid-button').trigger('click')
  176. expect(wrapper.emitted('create')?.[0]?.[0]).not.toHaveProperty('hostProfile')
  177. expect(wrapper.emitted('create')?.[0]?.[0]).not.toHaveProperty('orchestrationMode')
  178. expect(wrapper.emitted('create')?.[0]?.[0]).not.toHaveProperty('instructions')
  179. wrapper.unmount()
  180. })
  181. it('offers host flow only when the server advertises the capability', async () => {
  182. const wrapper = mount(CreateGroupDialog, {
  183. attachTo: document.body,
  184. props: {
  185. open: true,
  186. profiles: profiles('yaoyao', 'yaoer'),
  187. hostEnabled: true,
  188. hostFlowEnabled: true,
  189. roomInstructionsEnabled: true,
  190. },
  191. global: { stubs: { teleport: true } },
  192. })
  193. await wrapper.get('input[placeholder="例如:产品评审"]').setValue('顺序协作')
  194. await wrapper.get<HTMLTextAreaElement>('textarea').setValue('先核对事实,再输出中文结论。')
  195. await wrapper.get<HTMLSelectElement>('select[aria-label="协作模式"]').setValue('host')
  196. await wrapper.get('.solid-button').trigger('click')
  197. expect(wrapper.emitted('create')?.[0]?.[0]).toMatchObject({
  198. name: '顺序协作',
  199. instructions: '先核对事实,再输出中文结论。',
  200. orchestrationMode: 'host',
  201. })
  202. wrapper.unmount()
  203. })
  204. it('builds a team avatar from member mascots without persisting a legacy animal', async () => {
  205. const choices = profiles('yaoyao', 'yaoer').map((profile, index) => ({
  206. ...profile,
  207. ...(index === 0 ? { avatar: 'data:image/png;base64,AA==' } : {}),
  208. }))
  209. const wrapper = mount(CreateGroupDialog, {
  210. attachTo: document.body,
  211. props: { open: true, profiles: choices, avatarEnabled: true },
  212. global: { stubs: { teleport: true } },
  213. })
  214. await wrapper.get('input[placeholder="例如:产品评审"]').setValue('头像团队')
  215. await wrapper.findAll('.agent-picker > button')[1]!.trigger('click')
  216. expect(wrapper.findAll('.avatar-picker .team-avatar__member')).toHaveLength(choices.length)
  217. expect(wrapper.get('.avatar-picker').text()).toContain('由成员的动态角色自动组成')
  218. expect(wrapper.findAll('.avatar-picker .agent-avatar--image')).toHaveLength(1)
  219. await wrapper.get('.solid-button').trigger('click')
  220. expect(wrapper.emitted('create')?.[0]?.[0]).toMatchObject({
  221. name: '头像团队',
  222. members: choices,
  223. })
  224. expect((wrapper.emitted('create')?.[0]?.[0] as { avatar?: string }).avatar).toBeUndefined()
  225. wrapper.unmount()
  226. })
  227. it('emits one host promotion and explains the independent auto-reply setting', async () => {
  228. const first = agent('agent-1', 'yaoyao', '夭夭', true, true)
  229. const second = agent('agent-2', 'yaoer', '瑶儿', false, false)
  230. const wrapper = mount(GroupManager, {
  231. attachTo: document.body,
  232. props: {
  233. room: { id: 'room-1', name: '群聊', memberIds: [first.id, second.id], replyRounds: 3 },
  234. agents: [first, second],
  235. hostEnabled: true,
  236. },
  237. })
  238. const selector = wrapper.get<HTMLSelectElement>('select[aria-label="管理员"]')
  239. expect(selector.element.value).toBe('agent-1')
  240. expect(wrapper.findAll('.host-badge')).toHaveLength(1)
  241. await selector.setValue('agent-2')
  242. expect(wrapper.emitted('updateAgent')?.[0]).toEqual(['agent-2', { isHost: true }])
  243. await wrapper.setProps({ agents: [{ ...first, isHost: false }, { ...second, isHost: true }] })
  244. await wrapper.get('button[aria-label="设置瑶儿"]').trigger('click')
  245. await nextTick()
  246. const dialog = document.querySelector('[role="dialog"][aria-label="瑶儿机器人设置"]')
  247. expect(dialog?.textContent).toContain('管理员始终处理用户无 @ 消息')
  248. expect(dialog?.querySelector('input[aria-label="无需 @ 也回复"]')).not.toBeNull()
  249. wrapper.unmount()
  250. })
  251. it('blocks removing a host when no enabled replacement exists and shows manager errors', () => {
  252. const first = agent('agent-1', 'yaoyao', '夭夭', true, true)
  253. const second = { ...agent('agent-2', 'yaoer', '瑶儿', false, false), enabled: false }
  254. const wrapper = mount(GroupManager, {
  255. props: {
  256. room: { id: 'room-1', name: '群聊', memberIds: [first.id, second.id], replyRounds: 3 },
  257. agents: [first, second],
  258. hostEnabled: true,
  259. managerError: '需要另一位已启用成员',
  260. },
  261. })
  262. const removeHost = wrapper.get<HTMLButtonElement>('button[aria-label="移除夭夭"]')
  263. expect(removeHost.element.disabled).toBe(true)
  264. expect(removeHost.attributes('title')).toContain('已启用成员')
  265. expect(wrapper.get('[role="alert"]').text()).toBe('需要另一位已启用成员')
  266. })
  267. it('emits a room update when switching collaboration mode', async () => {
  268. const first = agent('agent-1', 'yaoyao', '夭夭', true, false)
  269. const wrapper = mount(GroupManager, {
  270. props: {
  271. room: { id: 'room-1', name: '群聊', memberIds: [first.id], replyRounds: 3, orchestrationMode: 'free' },
  272. agents: [first],
  273. hostEnabled: true,
  274. hostFlowEnabled: true,
  275. },
  276. })
  277. await wrapper.get<HTMLSelectElement>('select[aria-label="协作模式"]').setValue('host')
  278. expect(wrapper.emitted('updateRoom')?.[0]?.[0]).toEqual({
  279. name: '群聊',
  280. replyRounds: 3,
  281. orchestrationMode: 'host',
  282. })
  283. })
  284. it('edits room instructions when the capability is available', async () => {
  285. const first = agent('agent-1', 'yaoyao', '夭夭', true, false)
  286. const wrapper = mount(GroupManager, {
  287. props: {
  288. room: {
  289. id: 'room-1', name: '群聊', memberIds: [first.id], replyRounds: 3,
  290. orchestrationMode: 'free', instructions: '旧规则',
  291. },
  292. agents: [first],
  293. hostEnabled: true,
  294. hostFlowEnabled: true,
  295. roomInstructionsEnabled: true,
  296. },
  297. })
  298. const instructions = wrapper.get<HTMLTextAreaElement>('textarea[aria-label="团队说明"]')
  299. await instructions.setValue('新规则\n输出中文')
  300. await instructions.trigger('change')
  301. expect(wrapper.emitted('updateRoom')?.at(-1)?.[0]).toMatchObject({
  302. instructions: '新规则\n输出中文',
  303. })
  304. })
  305. it('only allows remote Agents to rename themselves within the room', async () => {
  306. const local = agent('agent-1', 'yaoyao', '夭夭', false, false)
  307. const remote = {
  308. ...agent('agent-2', 'yaoer', '远端夭夭', false, false),
  309. nodeId: '11111111-1111-4111-8111-111111111111',
  310. nodeLabel: '工作室 Mac',
  311. }
  312. const wrapper = mount(GroupManager, {
  313. props: {
  314. room: { id: 'room-1', name: '群聊', memberIds: [local.id, remote.id], replyRounds: 3 },
  315. agents: [local, remote],
  316. remoteServerAddresses: { [remote.nodeId]: '192.168.1.20:9119' },
  317. },
  318. global: { stubs: { teleport: true } },
  319. })
  320. await wrapper.get('button[aria-label="设置远端夭夭"]').trigger('click')
  321. const remoteName = wrapper.get<HTMLInputElement>('input[aria-label="群内名称"]')
  322. await remoteName.setValue('群内审查员')
  323. await wrapper.get('.save-agent').trigger('click')
  324. expect(wrapper.emitted('updateAgent')?.at(-1)).toEqual([
  325. remote.id,
  326. { displayName: '群内审查员' },
  327. ])
  328. expect(wrapper.text()).toContain('192.168.1.20:9119')
  329. await wrapper.get('button[aria-label="设置夭夭"]').trigger('click')
  330. expect(wrapper.find('input[aria-label="群内名称"]').exists()).toBe(false)
  331. })
  332. it('uses the synced remote Agent name and marks its topic message', () => {
  333. const remote = {
  334. ...agent('agent-remote', 'reviewer', '群内审查员', false, false),
  335. nodeId: '11111111-1111-4111-8111-111111111111',
  336. nodeLabel: '工作室 Mac',
  337. }
  338. const message: GroupMessage = {
  339. seq: 1,
  340. id: 'message-1',
  341. roomId: 'room-1',
  342. senderKind: 'agent',
  343. senderId: remote.id,
  344. senderName: '旧名称',
  345. rootMessageId: 'message-1',
  346. content: '已完成',
  347. reasoning: '',
  348. toolState: [],
  349. status: 'completed',
  350. error: '',
  351. createdAt: 1,
  352. updatedAt: 1,
  353. }
  354. expect(groupMessageToUi(message, [remote])).toMatchObject({
  355. author: '群内审查员',
  356. isRemoteAgent: true,
  357. })
  358. })
  359. })