From 904ca8c6489410473227c7396264098c7eb15f26 Mon Sep 17 00:00:00 2001 From: Zhicheng Han <43314240+hanzckernel@users.noreply.github.com> Date: Wed, 20 May 2026 04:21:57 +0200 Subject: [PATCH] feat(group-chat): add @all mention routing (#857) Add modular group-chat mention routing helpers for the reserved @all token, route it to every non-sender agent, and strip routing tokens before model input. Expose @all in mention autocomplete, highlight it in group messages, reserve literal all agent names, and cover boundary/partial-match regressions with tests. --- .../hermes/chat/MarkdownRenderer.vue | 6 +- .../hermes/group-chat/GroupChatInput.vue | 41 +++---- .../hermes/group-chat/GroupMessageItem.vue | 2 +- .../hermes/group-chat/mention-options.ts | 46 ++++++++ .../server/src/routes/hermes/group-chat.ts | 12 ++ .../hermes/group-chat/agent-clients.ts | 34 +++--- .../hermes/group-chat/mention-routing.ts | 103 ++++++++++++++++++ .../client/group-chat-mention-options.test.ts | 34 ++++++ .../client/markdown-special-mentions.test.ts | 70 ++++++++++++ .../server/group-chat-mention-routing.test.ts | 80 ++++++++++++++ 10 files changed, 386 insertions(+), 42 deletions(-) create mode 100644 packages/client/src/components/hermes/group-chat/mention-options.ts create mode 100644 packages/server/src/services/hermes/group-chat/mention-routing.ts create mode 100644 tests/client/group-chat-mention-options.test.ts create mode 100644 tests/client/markdown-special-mentions.test.ts create mode 100644 tests/server/group-chat-mention-routing.test.ts diff --git a/packages/client/src/components/hermes/chat/MarkdownRenderer.vue b/packages/client/src/components/hermes/chat/MarkdownRenderer.vue index 6ce54c9..e27bcdb 100644 --- a/packages/client/src/components/hermes/chat/MarkdownRenderer.vue +++ b/packages/client/src/components/hermes/chat/MarkdownRenderer.vue @@ -137,8 +137,10 @@ const renderedHtml = computed(() => { }) if (props.mentionNames && props.mentionNames.length > 0) { - const escaped = props.mentionNames.map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) - const re = new RegExp(`(?<=[\\s>]|^)@(${escaped.join('|')})(?=\\s|$)`, 'gi') + const escaped = [...props.mentionNames] + .sort((a, b) => b.length - a.length) + .map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + const re = new RegExp(`(?<=[\\s>({\\[<]|^)@(${escaped.join('|')})(?=[\\s.,!?;:,。!?;:)\\]}>]|<|$)`, 'gi') html = html.replace(re, '@$1') } return html diff --git a/packages/client/src/components/hermes/group-chat/GroupChatInput.vue b/packages/client/src/components/hermes/group-chat/GroupChatInput.vue index e7864eb..e9f3020 100644 --- a/packages/client/src/components/hermes/group-chat/GroupChatInput.vue +++ b/packages/client/src/components/hermes/group-chat/GroupChatInput.vue @@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n' import { NButton, NSwitch, NTooltip } from 'naive-ui' import { useGroupChatStore } from '@/stores/hermes/group-chat' import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility' +import { buildMentionOptions, type MentionOption } from './mention-options' import type { Attachment } from '@/stores/hermes/chat' const { t } = useI18n() @@ -74,10 +75,7 @@ const dropdownBottom = ref(0) const placement = ref<'bottom' | 'top'>('bottom') const activeIndex = ref(0) -const filteredAgents = computed(() => { - const query = mentionQuery.value.toLowerCase() - return store.agents.filter(a => a.name.toLowerCase().includes(query)) -}) +const filteredMentionOptions = computed(() => buildMentionOptions(store.agents, mentionQuery.value)) const canSend = computed(() => !!inputText.value.trim() || attachments.value.length > 0) @@ -146,7 +144,7 @@ function updateMentionState() { dropdownX.value = rect.left + mirrorRect.width - el.scrollLeft // Decide placement: if dropdown would go below viewport, flip upward - const estimatedHeight = Math.min(filteredAgents.value.length * 36 + 8, 240) + const estimatedHeight = Math.min(filteredMentionOptions.value.length * 36 + 8, 240) const spaceBelow = window.innerHeight - rect.top + el.scrollTop - 8 if (spaceBelow < estimatedHeight && rect.top - el.scrollTop - 8 > estimatedHeight) { placement.value = 'top' @@ -158,7 +156,7 @@ function updateMentionState() { dropdownBottom.value = window.innerHeight - dropdownY.value - mentionActive.value = filteredAgents.value.length > 0 + mentionActive.value = filteredMentionOptions.value.length > 0 } function selectMention(name: string) { @@ -187,22 +185,22 @@ function selectMention(name: string) { function handleKeydown(e: KeyboardEvent) { // Mention navigation — fully custom, no NDropdown interference - if (mentionActive.value && filteredAgents.value.length > 0) { + if (mentionActive.value && filteredMentionOptions.value.length > 0) { if (e.key === 'ArrowDown') { e.preventDefault() - activeIndex.value = (activeIndex.value + 1) % filteredAgents.value.length + activeIndex.value = (activeIndex.value + 1) % filteredMentionOptions.value.length scrollToActive() return } if (e.key === 'ArrowUp') { e.preventDefault() - activeIndex.value = (activeIndex.value - 1 + filteredAgents.value.length) % filteredAgents.value.length + activeIndex.value = (activeIndex.value - 1 + filteredMentionOptions.value.length) % filteredMentionOptions.value.length scrollToActive() return } if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault() - selectMention(filteredAgents.value[activeIndex.value].name) + selectMention(filteredMentionOptions.value[activeIndex.value].name) return } if (e.key === 'Escape') { @@ -242,8 +240,8 @@ function handleInput(e: Event) { } } -function handleMentionClick(name: string) { - selectMention(name) +function handleMentionClick(option: MentionOption) { + selectMention(option.name) } function handleMentionHover(index: number) { @@ -449,7 +447,7 @@ function isImage(type: string): boolean {
- @{{ agent.name }} - {{ agent.profile }} + {{ option.label }} + {{ option.description }}
@@ -743,6 +741,11 @@ function isImage(type: string): boolean { color: $text-muted; font-size: 12px; } + + &.mention-all-option .mention-name { + color: $accent-primary; + font-weight: 600; + } } /* ── Dropdown fade/scale animation (matching NDropdown) ── */ diff --git a/packages/client/src/components/hermes/group-chat/GroupMessageItem.vue b/packages/client/src/components/hermes/group-chat/GroupMessageItem.vue index 9154a4b..79721cb 100644 --- a/packages/client/src/components/hermes/group-chat/GroupMessageItem.vue +++ b/packages/client/src/components/hermes/group-chat/GroupMessageItem.vue @@ -56,7 +56,7 @@ const avatarSvg = computed(() => { return multiavatar(props.message.senderName || props.message.senderId) }) -const mentionNames = computed(() => props.agents.map(a => a.name).filter(Boolean)) +const mentionNames = computed(() => ['all', ...props.agents.map(a => a.name).filter(Boolean)]) const parsedThinking = computed(() => parseThinking(props.message.content || '', { streaming: !!props.message.isStreaming })) const hasReasoningField = computed(() => !!(props.message.reasoning && props.message.reasoning.length > 0)) const hasThinking = computed(() => hasReasoningField.value || parsedThinking.value.hasThinking) diff --git a/packages/client/src/components/hermes/group-chat/mention-options.ts b/packages/client/src/components/hermes/group-chat/mention-options.ts new file mode 100644 index 0000000..46ce4eb --- /dev/null +++ b/packages/client/src/components/hermes/group-chat/mention-options.ts @@ -0,0 +1,46 @@ +export type MentionOption = { + key: string + type: 'all' | 'agent' + name: string + label: string + description: string +} + +type MentionAgent = { + name: string + profile?: string +} + +function isReservedMentionName(name: string): boolean { + return name.trim().toLowerCase() === 'all' +} + +export function buildMentionOptions(agents: MentionAgent[], query: string): MentionOption[] { + const normalizedQuery = query.trim().toLowerCase() + const options: MentionOption[] = [] + + if (!normalizedQuery || 'all'.includes(normalizedQuery)) { + options.push({ + key: 'special:all', + type: 'all', + name: 'all', + label: '@all', + description: 'All agents', + }) + } + + for (const agent of agents) { + const agentName = agent.name || '' + if (isReservedMentionName(agentName)) continue + if (!agentName.toLowerCase().includes(normalizedQuery)) continue + options.push({ + key: `agent:${agentName}`, + type: 'agent', + name: agentName, + label: `@${agentName}`, + description: agent.profile || '', + }) + } + + return options +} diff --git a/packages/server/src/routes/hermes/group-chat.ts b/packages/server/src/routes/hermes/group-chat.ts index 170d3f8..b4b2b54 100644 --- a/packages/server/src/routes/hermes/group-chat.ts +++ b/packages/server/src/routes/hermes/group-chat.ts @@ -1,5 +1,6 @@ import Router from '@koa/router' import type { GroupChatServer } from '../../services/hermes/group-chat' +import { isReservedMentionName } from '../../services/hermes/group-chat/mention-routing' export const groupChatRoutes = new Router() @@ -45,6 +46,12 @@ groupChatRoutes.post('/api/hermes/group-chat/rooms', async (ctx) => { ctx.body = { error: 'name and inviteCode are required' } return } + const reservedAgent = (agents || []).find(a => isReservedMentionName(a.name || a.profile)) + if (reservedAgent) { + ctx.status = 400 + ctx.body = { error: '`all` is reserved for @all mentions' } + return + } const roomId = generateId() const storage = chatServer.getStorage() @@ -213,6 +220,11 @@ groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/agents', async (ctx) ctx.body = { error: 'profile is required' } return } + if (isReservedMentionName(name || profile)) { + ctx.status = 400 + ctx.body = { error: '`all` is reserved for @all mentions' } + return + } // Prevent duplicate agent in same room const existing = chatServer.getStorage().getRoomAgents(ctx.params.roomId) diff --git a/packages/server/src/services/hermes/group-chat/agent-clients.ts b/packages/server/src/services/hermes/group-chat/agent-clients.ts index bbfb828..d1bff07 100644 --- a/packages/server/src/services/hermes/group-chat/agent-clients.ts +++ b/packages/server/src/services/hermes/group-chat/agent-clients.ts @@ -5,6 +5,11 @@ import { updateUsage } from '../../../db/hermes/usage-store' import { AgentBridgeClient, type AgentBridgeMessage, type AgentBridgeOutput } from '../agent-bridge' import { convertContentBlocksForAgent, isContentBlockArray } from '../run-chat/content-blocks' import type { ContentBlock } from '../run-chat/types' +import { + isAllAgentsMentioned, + resolveMentionTargets, + stripMentionRoutingTokens, +} from './mention-routing' // ─── Types ──────────────────────────────────────────────────── @@ -302,20 +307,20 @@ class AgentClient { } } - // Keep the original mentions visible and add an explicit routing note. - // When a user mentions multiple agents, stripping only this agent's - // name can make the remaining input look like it was meant for - // someone else. - const routedPrefix = `群聊系统:这条消息已经提及你(${this.name}),请直接回复;即使消息同时提及其他成员,也不要因此输出空回复。` - const ownMentionPattern = new RegExp(`@${this.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'gi') + // Keep routing explicit while removing only the mention tokens that + // selected this agent. This avoids making @all look like an + // instruction for the model to fan out another routing cycle. + const routedPrefix = isAllAgentsMentioned(msg.content) + ? `群聊系统:这条消息通过 @all 提及所有 agent,你是其中之一,请直接回复。` + : `群聊系统:这条消息已经提及你(${this.name}),请直接回复;即使消息同时提及其他成员,也不要因此输出空回复。` const rawInput = msg.input || msg.content const input = isContentBlockArray(rawInput) ? rawInput.map((block) => { if (block.type !== 'text') return block - const text = String(block.text || msg.content).replace(ownMentionPattern, '').trim() + const text = stripMentionRoutingTokens(String(block.text || msg.content), this.name) return { ...block, text: `${routedPrefix}\n\n原始消息:${text || msg.content}` } }) - : `${routedPrefix}\n\n原始消息:${msg.content.replace(ownMentionPattern, '').trim() || msg.content}` + : `${routedPrefix}\n\n原始消息:${stripMentionRoutingTokens(msg.content, this.name) || msg.content}` const bridgeInput: AgentBridgeMessage = isContentBlockArray(input) ? await convertContentBlocksForAgent(input) : input @@ -815,12 +820,7 @@ export class AgentClients { */ async processMentions(roomId: string, msg: MentionMessage): Promise { const agents = this.getAgents(roomId) - const senderName = msg.senderName.toLowerCase() - - const mentioned = agents.filter(a => ( - a.name.toLowerCase() !== senderName && - isAgentMentioned(msg.content, a.name) - )) + const mentioned = resolveMentionTargets(agents, msg.content, msg.senderId) if (mentioned.length === 0) return logger.debug(`[AgentClients] ${mentioned.map(a => a.name).join(', ')} mentioned by ${msg.senderName}`) @@ -886,9 +886,3 @@ export class AgentClients { function nextMentionDepth(msg: MentionMessage): number { return Math.max(0, msg.mentionDepth || 0) + 1 } - -function isAgentMentioned(content: string, agentName: string): boolean { - const escaped = agentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const pattern = new RegExp(`@${escaped}(?=$|\\s|[.,!?;:,。!?;:])`, 'i') - return pattern.test(content) -} diff --git a/packages/server/src/services/hermes/group-chat/mention-routing.ts b/packages/server/src/services/hermes/group-chat/mention-routing.ts new file mode 100644 index 0000000..f5d2e95 --- /dev/null +++ b/packages/server/src/services/hermes/group-chat/mention-routing.ts @@ -0,0 +1,103 @@ +export const ALL_AGENTS_MENTION = 'all' + +type MentionableAgent = { + name: string + id?: string + agentId?: string +} + +type MentionRange = { + start: number + end: number +} + +const BEFORE_BOUNDARY = new Set(['(', '[', '{', '<']) +const AFTER_BOUNDARY = new Set(['.', ',', '!', '?', ';', ':', ',', '。', '!', '?', ';', ':', ')', ']', '}', '>']) + +export function escapeMentionName(name: string): string { + return name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +export function isReservedMentionName(name: string): boolean { + return name.trim().toLowerCase() === ALL_AGENTS_MENTION +} + +function isBeforeBoundary(char: string | undefined): boolean { + return char === undefined || /\s/.test(char) || BEFORE_BOUNDARY.has(char) +} + +function isAfterBoundary(char: string | undefined): boolean { + return char === undefined || /\s/.test(char) || AFTER_BOUNDARY.has(char) +} + +function findMentionRanges(content: string, mentionName: string): MentionRange[] { + if (!content || !mentionName) return [] + + const contentLower = content.toLowerCase() + const mentionLower = mentionName.toLowerCase() + const ranges: MentionRange[] = [] + let fromIndex = 0 + + while (fromIndex < content.length) { + const atIndex = contentLower.indexOf(`@${mentionLower}`, fromIndex) + if (atIndex === -1) break + + const start = atIndex + const end = atIndex + mentionName.length + 1 + if (isBeforeBoundary(content[start - 1]) && isAfterBoundary(content[end])) { + ranges.push({ start, end }) + } + fromIndex = atIndex + 1 + } + + return ranges +} + +export function isAgentMentioned(content: string, agentName: string): boolean { + return findMentionRanges(content, agentName).length > 0 +} + +export function isAllAgentsMentioned(content: string): boolean { + return isAgentMentioned(content, ALL_AGENTS_MENTION) +} + +function isSenderAgent(agent: MentionableAgent, senderId: string): boolean { + return Boolean(senderId && (agent.id === senderId || agent.agentId === senderId)) +} + +export function resolveMentionTargets( + agents: T[], + content: string, + senderId: string, +): T[] { + const candidates = agents.filter((agent) => !isSenderAgent(agent, senderId)) + + if (isAllAgentsMentioned(content)) { + return candidates + } + + return candidates.filter((agent) => isAgentMentioned(content, agent.name)) +} + +export function stripMentionRoutingTokens(content: string, ownAgentName: string): string { + const rangesByKey = new Map() + for (const range of [ + ...findMentionRanges(content, ALL_AGENTS_MENTION), + ...findMentionRanges(content, ownAgentName), + ]) { + rangesByKey.set(`${range.start}:${range.end}`, range) + } + + const ranges = [...rangesByKey.values()].sort((a, b) => b.start - a.start) + + let result = content + for (const range of ranges) { + result = `${result.slice(0, range.start)}${result.slice(range.end)}` + } + + return result + .replace(/^[\s,,::;;.!?。!?]+/, '') + .replace(/[\s,,::;;]+$/g, '') + .replace(/[ \t]{2,}/g, ' ') + .trim() +} diff --git a/tests/client/group-chat-mention-options.test.ts b/tests/client/group-chat-mention-options.test.ts new file mode 100644 index 0000000..8126aa5 --- /dev/null +++ b/tests/client/group-chat-mention-options.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { buildMentionOptions } from '@/components/hermes/group-chat/mention-options' + +describe('group chat mention options', () => { + const agents = [ + { name: 'Alice', profile: 'alice-profile' }, + { name: 'Bob', profile: 'bob-profile' }, + { name: 'all', profile: 'literal-all-agent' }, + ] + + it('offers @all before agent mentions when the mention query is empty', () => { + expect(buildMentionOptions(agents, '').map(option => option.key)).toEqual([ + 'special:all', + 'agent:Alice', + 'agent:Bob', + ]) + }) + + it('keeps @all reserved when filtering by all and hides a literal all agent', () => { + expect(buildMentionOptions(agents, 'all')).toEqual([ + { + key: 'special:all', + type: 'all', + name: 'all', + label: '@all', + description: 'All agents', + }, + ]) + }) + + it('filters normal agent mentions without showing @all for unrelated queries', () => { + expect(buildMentionOptions(agents, 'bo').map(option => option.key)).toEqual(['agent:Bob']) + }) +}) diff --git a/tests/client/markdown-special-mentions.test.ts b/tests/client/markdown-special-mentions.test.ts new file mode 100644 index 0000000..f4bec91 --- /dev/null +++ b/tests/client/markdown-special-mentions.test.ts @@ -0,0 +1,70 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('naive-ui', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useMessage: () => ({ + error: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + }), + } +}) + +vi.mock('@/api/hermes/download', () => ({ + downloadFile: vi.fn(), + getDownloadUrl: vi.fn((path: string) => `/download?path=${encodeURIComponent(path)}`), +})) + +vi.mock('@/components/hermes/chat/mermaidRenderer', () => ({ + renderMermaidDiagram: vi.fn(), +})) + +import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue' + +describe('MarkdownRenderer special mentions', () => { + it('highlights @all as a mention when provided by group chat', () => { + const wrapper = mount(MarkdownRenderer, { + props: { + content: '@all, please compare options', + mentionNames: ['all', 'Alice'], + }, + }) + + expect(wrapper.find('.mention-highlight').text()).toBe('@all') + }) + + it('highlights @all at the end of rendered paragraphs and after opening punctuation', () => { + for (const content of ['@all', 'please compare @all', '(@all)']) { + const wrapper = mount(MarkdownRenderer, { + props: { + content, + mentionNames: ['all', 'Alice'], + }, + }) + + expect(wrapper.find('.mention-highlight').text()).toBe('@all') + } + }) + + it('does not highlight @alligator as @all', () => { + const wrapper = mount(MarkdownRenderer, { + props: { + content: '@alligator should stay plain', + mentionNames: ['all'], + }, + }) + + expect(wrapper.find('.mention-highlight').exists()).toBe(false) + }) +}) diff --git a/tests/server/group-chat-mention-routing.test.ts b/tests/server/group-chat-mention-routing.test.ts new file mode 100644 index 0000000..81c9bc8 --- /dev/null +++ b/tests/server/group-chat-mention-routing.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { + isAllAgentsMentioned, + isAgentMentioned, + isReservedMentionName, + resolveMentionTargets, + stripMentionRoutingTokens, +} from '../../packages/server/src/services/hermes/group-chat/mention-routing' + +type TestAgent = { name: string; id?: string; agentId?: string; profile?: string } + +const agents: TestAgent[] = [ + { name: 'Alice', id: 'socket-alice', agentId: 'agent-alice' }, + { name: 'Bob', id: 'socket-bob', agentId: 'agent-bob' }, + { name: 'Regex.Bot', id: 'socket-regex', agentId: 'agent-regex' }, +] + +describe('group chat mention routing', () => { + it('reserves @all so it cannot be confused with a literal agent name', () => { + expect(isReservedMentionName('all')).toBe(true) + expect(isReservedMentionName(' ALL ')).toBe(true) + expect(isReservedMentionName('Alice')).toBe(false) + }) + + it('recognizes @all as a standalone mention with safe boundaries', () => { + expect(isAllAgentsMentioned('@all please compare notes')).toBe(true) + expect(isAllAgentsMentioned('please compare notes @ALL')).toBe(true) + expect(isAllAgentsMentioned('@all, compare notes')).toBe(true) + expect(isAllAgentsMentioned('email user@all.example')).toBe(false) + expect(isAllAgentsMentioned('@alligator should not notify everyone')).toBe(false) + expect(isAllAgentsMentioned('prefix@all should not notify everyone')).toBe(false) + }) + + it('keeps exact agent mentions boundary-aware and regex-safe', () => { + expect(isAgentMentioned('@Regex.Bot please review', 'Regex.Bot')).toBe(true) + expect(isAgentMentioned('@RegexxBot should not match', 'Regex.Bot')).toBe(false) + expect(isAgentMentioned('@Alice, please review', 'Alice')).toBe(true) + expect(isAgentMentioned('mailto@Alice.example', 'Alice')).toBe(false) + }) + + it('routes @all to every room agent except the sender identity', () => { + expect(resolveMentionTargets(agents, '@all summarize the options', 'socket-alice').map(a => a.name)).toEqual(['Bob', 'Regex.Bot']) + }) + + it('keeps same-name human senders routable because sender exclusion uses identity, not display name', () => { + const sameNameAgents: TestAgent[] = [ + { name: 'test', id: 'socket-agent-test', agentId: 'agent-test' }, + { name: 'tt', id: 'socket-agent-tt', agentId: 'agent-tt' }, + ] + + expect(resolveMentionTargets(sameNameAgents, '@all can you talk to me?', 'human-test-user').map(a => a.name)).toEqual(['test', 'tt']) + expect(resolveMentionTargets(sameNameAgents, '@test why no response?', 'human-test-user').map(a => a.name)).toEqual(['test']) + }) + + it('still excludes an agent from routing to itself when the sender identity matches that agent', () => { + const sameNameAgents: TestAgent[] = [ + { name: 'test', id: 'socket-agent-test', agentId: 'agent-test' }, + { name: 'tt', id: 'socket-agent-tt', agentId: 'agent-tt' }, + ] + + expect(resolveMentionTargets(sameNameAgents, '@all compare plans', 'socket-agent-test').map(a => a.name)).toEqual(['tt']) + expect(resolveMentionTargets(sameNameAgents, '@all compare plans', 'agent-test').map(a => a.name)).toEqual(['tt']) + expect(resolveMentionTargets(sameNameAgents, '@test check yourself', 'socket-agent-test').map(a => a.name)).toEqual([]) + }) + + it('routes explicit mentions without treating partial @all text as broadcast', () => { + expect(resolveMentionTargets(agents, '@Bob and @Regex.Bot compare plans', 'socket-alice').map(a => a.name)).toEqual(['Bob', 'Regex.Bot']) + expect(resolveMentionTargets(agents, '@alligator and @Bob compare plans', 'socket-alice').map(a => a.name)).toEqual(['Bob']) + }) + + it('dedupes mixed @all and explicit mentions', () => { + expect(resolveMentionTargets(agents, '@all @Bob compare plans', 'socket-alice').map(a => a.name)).toEqual(['Bob', 'Regex.Bot']) + }) + + it('strips the broadcast token and this agent mention before routing to the model', () => { + expect(stripMentionRoutingTokens('@all @Bob please review', 'Bob')).toBe('please review') + expect(stripMentionRoutingTokens('@ALL, @Regex.Bot: please review', 'Regex.Bot')).toBe('please review') + expect(stripMentionRoutingTokens('@all please review', 'all')).toBe('please review') + }) +})