groupHost.test.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. import { mount } from '@vue/test-utils'
  2. import { nextTick } from 'vue'
  3. import { describe, expect, it } from 'vitest'
  4. import type { GroupAgent } from '@shared/types'
  5. import CreateGroupDialog from '@/components/groups/CreateGroupDialog.vue'
  6. import GroupManager from '@/components/groups/GroupManager.vue'
  7. const profiles = (...names: string[]) => names.map(name => ({
  8. id: `local|${name}`, profile: name, displayName: name, nodeId: 'local', nodeLabel: '当前 Hermes',
  9. }))
  10. function agent(id: string, profile: string, displayName: string, isHost: boolean, replyWithoutMention: boolean): GroupAgent {
  11. return {
  12. id, roomId: 'room-1', profile, nodeId: 'local', nodeLabel: '', displayName, description: `${displayName} 的职责`, storedSessionId: null,
  13. lastContextMessageSeq: 0, enabled: true, replyWithoutMention, isHost,
  14. model: null, provider: null, reasoningEffort: null, fastMode: null,
  15. createdAt: 1, updatedAt: 1, status: 'idle',
  16. }
  17. }
  18. describe('group host controls', () => {
  19. it('keeps same-name Profiles distinct across paired Hermes nodes', async () => {
  20. const local = profiles('default')[0]!
  21. const remote = {
  22. id: '11111111-1111-4111-8111-111111111111|default',
  23. profile: 'default',
  24. displayName: '远端夭夭',
  25. nodeId: '11111111-1111-4111-8111-111111111111',
  26. nodeLabel: 'MacBook Pro',
  27. }
  28. const wrapper = mount(CreateGroupDialog, {
  29. attachTo: document.body,
  30. props: { open: true, profiles: [local, remote], hostEnabled: true },
  31. global: { stubs: { teleport: true } },
  32. })
  33. await wrapper.get('input[placeholder="例如:产品评审"]').setValue('跨节点群')
  34. await wrapper.findAll('.agent-picker > button')[1]!.trigger('click')
  35. await wrapper.get('.solid-button').trigger('click')
  36. expect(wrapper.emitted('create')?.[0]?.[0]).toMatchObject({
  37. members: [local, remote],
  38. hostProfile: local.id,
  39. })
  40. wrapper.unmount()
  41. })
  42. it('keeps one independent host selection when creating a v5 room', async () => {
  43. const wrapper = mount(CreateGroupDialog, {
  44. attachTo: document.body,
  45. props: { open: true, profiles: profiles('yaoyao', 'yaoer'), hostEnabled: true },
  46. global: { stubs: { teleport: true } },
  47. })
  48. await nextTick()
  49. await wrapper.get('input[placeholder="例如:产品评审"]').setValue('主持人验收')
  50. const profileButtons = wrapper.findAll('.agent-picker > button')
  51. await profileButtons[1]!.trigger('click')
  52. const host = wrapper.get<HTMLSelectElement>('select[aria-label="主持人"]')
  53. await host.setValue('local|yaoer')
  54. await profileButtons[1]!.trigger('click')
  55. expect(host.element.value).toBe('local|yaoyao')
  56. await profileButtons[1]!.trigger('click')
  57. await host.setValue('local|yaoer')
  58. expect(wrapper.get<HTMLInputElement>('input[aria-label="所有成员无需 @ 也回复"]').element.checked).toBe(true)
  59. await wrapper.get('.solid-button').trigger('click')
  60. expect(wrapper.emitted('create')?.[0]?.[0]).toEqual({
  61. name: '主持人验收', members: profiles('yaoyao', 'yaoer'), hostProfile: 'local|yaoer', autoReply: true, replyRounds: 3,
  62. })
  63. wrapper.unmount()
  64. })
  65. it('keeps the legacy v4 create UI and payload free of host fields', async () => {
  66. const wrapper = mount(CreateGroupDialog, {
  67. attachTo: document.body,
  68. props: { open: true, profiles: profiles('yaoyao'), hostEnabled: false },
  69. global: { stubs: { teleport: true } },
  70. })
  71. await nextTick()
  72. expect(wrapper.find('select[aria-label="主持人"]').exists()).toBe(false)
  73. expect(wrapper.find('input[aria-label="启用自动回复"]').exists()).toBe(true)
  74. await wrapper.get('input[placeholder="例如:产品评审"]').setValue('v4 房间')
  75. await wrapper.get('.solid-button').trigger('click')
  76. expect(wrapper.emitted('create')?.[0]?.[0]).not.toHaveProperty('hostProfile')
  77. expect(wrapper.emitted('create')?.[0]?.[0]).not.toHaveProperty('orchestrationMode')
  78. expect(wrapper.emitted('create')?.[0]?.[0]).not.toHaveProperty('instructions')
  79. wrapper.unmount()
  80. })
  81. it('offers host flow only when the server advertises the capability', async () => {
  82. const wrapper = mount(CreateGroupDialog, {
  83. attachTo: document.body,
  84. props: {
  85. open: true,
  86. profiles: profiles('yaoyao', 'yaoer'),
  87. hostEnabled: true,
  88. hostFlowEnabled: true,
  89. roomInstructionsEnabled: true,
  90. },
  91. global: { stubs: { teleport: true } },
  92. })
  93. await wrapper.get('input[placeholder="例如:产品评审"]').setValue('顺序协作')
  94. await wrapper.get<HTMLTextAreaElement>('textarea').setValue('先核对事实,再输出中文结论。')
  95. await wrapper.get<HTMLSelectElement>('select[aria-label="协作模式"]').setValue('host')
  96. await wrapper.get('.solid-button').trigger('click')
  97. expect(wrapper.emitted('create')?.[0]?.[0]).toMatchObject({
  98. name: '顺序协作',
  99. instructions: '先核对事实,再输出中文结论。',
  100. orchestrationMode: 'host',
  101. })
  102. wrapper.unmount()
  103. })
  104. it('emits one host promotion and explains the independent auto-reply setting', async () => {
  105. const first = agent('agent-1', 'yaoyao', '夭夭', true, true)
  106. const second = agent('agent-2', 'yaoer', '瑶儿', false, false)
  107. const wrapper = mount(GroupManager, {
  108. attachTo: document.body,
  109. props: {
  110. room: { id: 'room-1', name: '群聊', memberIds: [first.id, second.id], replyRounds: 3 },
  111. agents: [first, second],
  112. hostEnabled: true,
  113. },
  114. })
  115. const selector = wrapper.get<HTMLSelectElement>('select[aria-label="主持人"]')
  116. expect(selector.element.value).toBe('agent-1')
  117. expect(wrapper.findAll('.host-badge')).toHaveLength(1)
  118. await selector.setValue('agent-2')
  119. expect(wrapper.emitted('updateAgent')?.[0]).toEqual(['agent-2', { isHost: true }])
  120. await wrapper.setProps({ agents: [{ ...first, isHost: false }, { ...second, isHost: true }] })
  121. await wrapper.get('button[aria-label="设置瑶儿"]').trigger('click')
  122. await nextTick()
  123. const dialog = document.querySelector('[role="dialog"][aria-label="瑶儿 Agent 设置"]')
  124. expect(dialog?.textContent).toContain('主持人始终处理用户无 @ 消息')
  125. expect(dialog?.querySelector('input[aria-label="无需 @ 也回复"]')).not.toBeNull()
  126. wrapper.unmount()
  127. })
  128. it('blocks removing a host when no enabled replacement exists and shows manager errors', () => {
  129. const first = agent('agent-1', 'yaoyao', '夭夭', true, true)
  130. const second = { ...agent('agent-2', 'yaoer', '瑶儿', false, false), enabled: false }
  131. const wrapper = mount(GroupManager, {
  132. props: {
  133. room: { id: 'room-1', name: '群聊', memberIds: [first.id, second.id], replyRounds: 3 },
  134. agents: [first, second],
  135. hostEnabled: true,
  136. managerError: '需要另一位已启用成员',
  137. },
  138. })
  139. const removeHost = wrapper.get<HTMLButtonElement>('button[aria-label="移除夭夭"]')
  140. expect(removeHost.element.disabled).toBe(true)
  141. expect(removeHost.attributes('title')).toContain('已启用成员')
  142. expect(wrapper.get('[role="alert"]').text()).toBe('需要另一位已启用成员')
  143. })
  144. it('emits a room update when switching collaboration mode', async () => {
  145. const first = agent('agent-1', 'yaoyao', '夭夭', true, false)
  146. const wrapper = mount(GroupManager, {
  147. props: {
  148. room: { id: 'room-1', name: '群聊', memberIds: [first.id], replyRounds: 3, orchestrationMode: 'free' },
  149. agents: [first],
  150. hostEnabled: true,
  151. hostFlowEnabled: true,
  152. },
  153. })
  154. await wrapper.get<HTMLSelectElement>('select[aria-label="协作模式"]').setValue('host')
  155. expect(wrapper.emitted('updateRoom')?.[0]?.[0]).toEqual({
  156. name: '群聊',
  157. replyRounds: 3,
  158. orchestrationMode: 'host',
  159. })
  160. })
  161. it('edits room instructions when the capability is available', async () => {
  162. const first = agent('agent-1', 'yaoyao', '夭夭', true, false)
  163. const wrapper = mount(GroupManager, {
  164. props: {
  165. room: {
  166. id: 'room-1', name: '群聊', memberIds: [first.id], replyRounds: 3,
  167. orchestrationMode: 'free', instructions: '旧规则',
  168. },
  169. agents: [first],
  170. hostEnabled: true,
  171. hostFlowEnabled: true,
  172. roomInstructionsEnabled: true,
  173. },
  174. })
  175. const instructions = wrapper.get<HTMLTextAreaElement>('textarea[aria-label="房间说明"]')
  176. await instructions.setValue('新规则\n输出中文')
  177. await instructions.trigger('change')
  178. expect(wrapper.emitted('updateRoom')?.at(-1)?.[0]).toMatchObject({
  179. instructions: '新规则\n输出中文',
  180. })
  181. })
  182. })