import { mount } from '@vue/test-utils' import { nextTick } from 'vue' import { describe, expect, it } from 'vitest' import type { GroupAgent, GroupMessage, GroupRoomSummary } from '@shared/types' import CreateGroupDialog from '@/components/groups/CreateGroupDialog.vue' import GroupManager from '@/components/groups/GroupManager.vue' import { groupMessageToUi, roomSidebarItem } from '@/components/workspace/viewModels' const profiles = (...names: string[]) => names.map(name => ({ id: `local|${name}`, profile: name, displayName: name, nodeId: 'local', nodeLabel: '当前 Hermes', })) function agent(id: string, profile: string, displayName: string, isHost: boolean, replyWithoutMention: boolean): GroupAgent { return { id, roomId: 'room-1', profile, nodeId: 'local', nodeLabel: '', displayName, description: `${displayName} 的职责`, storedSessionId: null, lastContextMessageSeq: 0, enabled: true, replyWithoutMention, isHost, model: null, provider: null, reasoningEffort: null, fastMode: null, createdAt: 1, updatedAt: 1, status: 'idle', } } describe('group host controls', () => { it('resolves a local team avatar by display name when an old room stored an internal Profile alias', () => { const room = { id: 'room-1', name: '头像团队', cwd: '', instructions: '', avatar: '', createdAt: 1, updatedAt: 1, archived: false, agentCount: 2, lastMessage: null, unreadCount: 0, activeRunCount: 0, maxReplyRounds: 3, orchestrationMode: 'free', avatarMembers: [ { profile: 'legacy:default', nodeId: 'local', displayName: '丫头' }, { profile: 'default', nodeId: 'remote-node', displayName: '远端丫头' }, ], } satisfies GroupRoomSummary const item = roomSidebarItem(room, { default: 'data:image/png;base64,LOCAL==' }, { 丫头: 'data:image/png;base64,LOCAL==' }) expect(item.avatarMembers).toEqual([ { name: '丫头', avatar: 'data:image/png;base64,LOCAL==' }, { name: '远端丫头', avatar: undefined }, ]) }) it('keeps same-name Profiles distinct across paired Hermes nodes', async () => { const local = profiles('default')[0]! const remote = { id: '11111111-1111-4111-8111-111111111111|default', profile: 'default', displayName: '远端夭夭', nodeId: '11111111-1111-4111-8111-111111111111', nodeLabel: 'MacBook Pro', } const wrapper = mount(CreateGroupDialog, { attachTo: document.body, props: { open: true, profiles: [local, remote], hostEnabled: true }, global: { stubs: { teleport: true } }, }) await wrapper.get('input[placeholder="例如:产品评审"]').setValue('跨节点群') await wrapper.findAll('.agent-picker > button')[1]!.trigger('click') await wrapper.get('.solid-button').trigger('click') expect(wrapper.emitted('create')?.[0]?.[0]).toMatchObject({ members: [local, remote], hostProfile: local.id, }) wrapper.unmount() }) it('adds an Agent from a paired node to an existing group', async () => { const local = agent('agent-local', 'default', '本机 Agent', true, true) const remote = { id: '11111111-1111-4111-8111-111111111111|reviewer', profile: 'reviewer', displayName: '远端评审', nodeId: '11111111-1111-4111-8111-111111111111', nodeLabel: '工作室 Mac', } const wrapper = mount(GroupManager, { props: { room: { id: 'room-1', name: '群聊', memberIds: [local.id], replyRounds: 3 }, agents: [local], availableProfiles: [remote], }, }) await wrapper.get('select[aria-label="选择要添加的 Agent"]').setValue(remote.id) expect(wrapper.emitted('addAgent')?.at(-1)).toEqual([remote.id]) wrapper.unmount() }) it('keeps one independent host selection when creating a v5 room', async () => { const wrapper = mount(CreateGroupDialog, { attachTo: document.body, props: { open: true, profiles: profiles('yaoyao', 'yaoer'), hostEnabled: true }, global: { stubs: { teleport: true } }, }) await nextTick() await wrapper.get('input[placeholder="例如:产品评审"]').setValue('主持人验收') const profileButtons = wrapper.findAll('.agent-picker > button') await profileButtons[1]!.trigger('click') const host = wrapper.get('select[aria-label="主持人"]') await host.setValue('local|yaoer') await profileButtons[1]!.trigger('click') expect(host.element.value).toBe('local|yaoyao') await profileButtons[1]!.trigger('click') await host.setValue('local|yaoer') expect(wrapper.get('input[aria-label="所有成员无需 @ 也回复"]').element.checked).toBe(true) await wrapper.get('.solid-button').trigger('click') expect(wrapper.emitted('create')?.[0]?.[0]).toEqual({ name: '主持人验收', members: profiles('yaoyao', 'yaoer'), hostProfile: 'local|yaoer', autoReply: true, replyRounds: 3, }) wrapper.unmount() }) it('keeps the legacy v4 create UI and payload free of host fields', async () => { const wrapper = mount(CreateGroupDialog, { attachTo: document.body, props: { open: true, profiles: profiles('yaoyao'), hostEnabled: false }, global: { stubs: { teleport: true } }, }) await nextTick() expect(wrapper.find('select[aria-label="主持人"]').exists()).toBe(false) expect(wrapper.find('input[aria-label="启用自动回复"]').exists()).toBe(true) await wrapper.get('input[placeholder="例如:产品评审"]').setValue('v4 房间') await wrapper.get('.solid-button').trigger('click') expect(wrapper.emitted('create')?.[0]?.[0]).not.toHaveProperty('hostProfile') expect(wrapper.emitted('create')?.[0]?.[0]).not.toHaveProperty('orchestrationMode') expect(wrapper.emitted('create')?.[0]?.[0]).not.toHaveProperty('instructions') wrapper.unmount() }) it('offers host flow only when the server advertises the capability', async () => { const wrapper = mount(CreateGroupDialog, { attachTo: document.body, props: { open: true, profiles: profiles('yaoyao', 'yaoer'), hostEnabled: true, hostFlowEnabled: true, roomInstructionsEnabled: true, }, global: { stubs: { teleport: true } }, }) await wrapper.get('input[placeholder="例如:产品评审"]').setValue('顺序协作') await wrapper.get('textarea').setValue('先核对事实,再输出中文结论。') await wrapper.get('select[aria-label="协作模式"]').setValue('host') await wrapper.get('.solid-button').trigger('click') expect(wrapper.emitted('create')?.[0]?.[0]).toMatchObject({ name: '顺序协作', instructions: '先核对事实,再输出中文结论。', orchestrationMode: 'host', }) wrapper.unmount() }) it('shows and submits an automatic team avatar from selected Agents', async () => { const choices = profiles('yaoyao', 'yaoer').map((profile, index) => ({ ...profile, ...(index === 0 ? { avatar: 'data:image/png;base64,AA==' } : {}), })) const wrapper = mount(CreateGroupDialog, { attachTo: document.body, props: { open: true, profiles: choices, avatarEnabled: true }, global: { stubs: { teleport: true } }, }) await wrapper.get('input[placeholder="例如:产品评审"]').setValue('头像团队') await wrapper.findAll('.agent-picker > button')[1]!.trigger('click') expect(wrapper.findAll('.avatar-picker .team-avatar__member')).toHaveLength(2) expect(wrapper.get('.avatar-picker').text()).toContain('自动组合已选 Agent 的头像') await wrapper.get('.solid-button').trigger('click') expect(wrapper.emitted('create')?.[0]?.[0]).toMatchObject({ name: '头像团队', avatar: '', members: choices, }) wrapper.unmount() }) it('emits one host promotion and explains the independent auto-reply setting', async () => { const first = agent('agent-1', 'yaoyao', '夭夭', true, true) const second = agent('agent-2', 'yaoer', '瑶儿', false, false) const wrapper = mount(GroupManager, { attachTo: document.body, props: { room: { id: 'room-1', name: '群聊', memberIds: [first.id, second.id], replyRounds: 3 }, agents: [first, second], hostEnabled: true, }, }) const selector = wrapper.get('select[aria-label="主持人"]') expect(selector.element.value).toBe('agent-1') expect(wrapper.findAll('.host-badge')).toHaveLength(1) await selector.setValue('agent-2') expect(wrapper.emitted('updateAgent')?.[0]).toEqual(['agent-2', { isHost: true }]) await wrapper.setProps({ agents: [{ ...first, isHost: false }, { ...second, isHost: true }] }) await wrapper.get('button[aria-label="设置瑶儿"]').trigger('click') await nextTick() const dialog = document.querySelector('[role="dialog"][aria-label="瑶儿 Agent 设置"]') expect(dialog?.textContent).toContain('主持人始终处理用户无 @ 消息') expect(dialog?.querySelector('input[aria-label="无需 @ 也回复"]')).not.toBeNull() wrapper.unmount() }) it('blocks removing a host when no enabled replacement exists and shows manager errors', () => { const first = agent('agent-1', 'yaoyao', '夭夭', true, true) const second = { ...agent('agent-2', 'yaoer', '瑶儿', false, false), enabled: false } const wrapper = mount(GroupManager, { props: { room: { id: 'room-1', name: '群聊', memberIds: [first.id, second.id], replyRounds: 3 }, agents: [first, second], hostEnabled: true, managerError: '需要另一位已启用成员', }, }) const removeHost = wrapper.get('button[aria-label="移除夭夭"]') expect(removeHost.element.disabled).toBe(true) expect(removeHost.attributes('title')).toContain('已启用成员') expect(wrapper.get('[role="alert"]').text()).toBe('需要另一位已启用成员') }) it('emits a room update when switching collaboration mode', async () => { const first = agent('agent-1', 'yaoyao', '夭夭', true, false) const wrapper = mount(GroupManager, { props: { room: { id: 'room-1', name: '群聊', memberIds: [first.id], replyRounds: 3, orchestrationMode: 'free' }, agents: [first], hostEnabled: true, hostFlowEnabled: true, }, }) await wrapper.get('select[aria-label="协作模式"]').setValue('host') expect(wrapper.emitted('updateRoom')?.[0]?.[0]).toEqual({ name: '群聊', replyRounds: 3, orchestrationMode: 'host', }) }) it('edits room instructions when the capability is available', async () => { const first = agent('agent-1', 'yaoyao', '夭夭', true, false) const wrapper = mount(GroupManager, { props: { room: { id: 'room-1', name: '群聊', memberIds: [first.id], replyRounds: 3, orchestrationMode: 'free', instructions: '旧规则', }, agents: [first], hostEnabled: true, hostFlowEnabled: true, roomInstructionsEnabled: true, }, }) const instructions = wrapper.get('textarea[aria-label="团队说明"]') await instructions.setValue('新规则\n输出中文') await instructions.trigger('change') expect(wrapper.emitted('updateRoom')?.at(-1)?.[0]).toMatchObject({ instructions: '新规则\n输出中文', }) }) it('only allows remote Agents to rename themselves within the room', async () => { const local = agent('agent-1', 'yaoyao', '夭夭', false, false) const remote = { ...agent('agent-2', 'yaoer', '远端夭夭', false, false), nodeId: '11111111-1111-4111-8111-111111111111', nodeLabel: '工作室 Mac', } const wrapper = mount(GroupManager, { props: { room: { id: 'room-1', name: '群聊', memberIds: [local.id, remote.id], replyRounds: 3 }, agents: [local, remote], remoteServerAddresses: { [remote.nodeId]: '192.168.1.20:9119' }, }, global: { stubs: { teleport: true } }, }) await wrapper.get('button[aria-label="设置远端夭夭"]').trigger('click') const remoteName = wrapper.get('input[aria-label="群内名称"]') await remoteName.setValue('群内审查员') await wrapper.get('.save-agent').trigger('click') expect(wrapper.emitted('updateAgent')?.at(-1)).toEqual([ remote.id, { displayName: '群内审查员' }, ]) expect(wrapper.text()).toContain('192.168.1.20:9119') await wrapper.get('button[aria-label="设置夭夭"]').trigger('click') expect(wrapper.find('input[aria-label="群内名称"]').exists()).toBe(false) }) it('uses the synced remote Agent name and marks its topic message', () => { const remote = { ...agent('agent-remote', 'reviewer', '群内审查员', false, false), nodeId: '11111111-1111-4111-8111-111111111111', nodeLabel: '工作室 Mac', } const message: GroupMessage = { seq: 1, id: 'message-1', roomId: 'room-1', senderKind: 'agent', senderId: remote.id, senderName: '旧名称', rootMessageId: 'message-1', content: '已完成', reasoning: '', toolState: [], status: 'completed', error: '', createdAt: 1, updatedAt: 1, } expect(groupMessageToUi(message, [remote])).toMatchObject({ author: '群内审查员', isRemoteAgent: true, }) }) })