From 3539d07a39a5c906759fa5f178699b3241173a67 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:51:12 -0700 Subject: [PATCH 1/4] fix(mentions): tell sender when known handle is outside pod Co-authored-by: kite Agent-Model: codex/gpt-6-sol/medium --- .../agentsRuntime.post-message.config.test.js | 59 +++++++++++++- .../mentionNonMemberNoticeService.test.js | 45 +++++++++++ .../messageAgentDeliveryService.test.js | 32 ++++++++ backend/routes/agentsRuntime.ts | 21 ++++- .../services/mentionNonMemberNoticeService.ts | 79 +++++++++++++++++++ .../services/messageAgentDeliveryService.ts | 22 +++++- docs/development/agent-experience-audit.md | 8 ++ .../src/v2/__tests__/V2MessageRow.test.tsx | 15 ++++ .../v2/__tests__/useV2PodDetailReply.test.tsx | 4 +- frontend/src/v2/components/V2MessageRow.tsx | 5 ++ frontend/src/v2/hooks/useV2PodDetail.ts | 4 + frontend/src/v2/v2.css | 7 ++ 12 files changed, 294 insertions(+), 7 deletions(-) create mode 100644 backend/__tests__/unit/services/mentionNonMemberNoticeService.test.js create mode 100644 backend/services/mentionNonMemberNoticeService.ts diff --git a/backend/__tests__/unit/routes/agentsRuntime.post-message.config.test.js b/backend/__tests__/unit/routes/agentsRuntime.post-message.config.test.js index ba6440f869..ff1fa36e55 100644 --- a/backend/__tests__/unit/routes/agentsRuntime.post-message.config.test.js +++ b/backend/__tests__/unit/routes/agentsRuntime.post-message.config.test.js @@ -13,6 +13,9 @@ jest.mock('../../../services/agentIdentityService', () => ({ jest.mock('../../../services/agentMessageService', () => ({ postMessage: jest.fn(), })); +jest.mock('../../../services/mentionNonMemberNoticeService', () => ({ + findNonMemberMentionNotices: jest.fn(), +})); jest.mock('../../../services/agentThreadService', () => ({})); jest.mock('../../../services/podContextService', () => ({})); jest.mock('../../../services/globalModelConfigService', () => ({})); @@ -28,7 +31,10 @@ jest.mock('../../../integrations', () => ({ get: jest.fn() })); jest.mock('../../../models/Activity', () => ({})); jest.mock('../../../models/User', () => ({ findById: jest.fn() })); jest.mock('../../../models/Post', () => ({ findById: jest.fn() })); -jest.mock('../../../models/Pod', () => ({ find: jest.fn() })); +jest.mock('../../../models/Pod', () => ({ + find: jest.fn(), + findById: jest.fn(() => ({ select: () => ({ lean: async () => ({ type: 'chat' }) }) })), +})); jest.mock('../../../services/dmService', () => ({ getOrCreateAgentDM: jest.fn() })); jest.mock('../../../models/Integration', () => ({ find: jest.fn(), findOne: jest.fn() })); jest.mock('../../../models/AgentRegistry', () => ({ @@ -36,6 +42,7 @@ jest.mock('../../../models/AgentRegistry', () => ({ })); const AgentMessageService = require('../../../services/agentMessageService'); +const { findNonMemberMentionNotices } = require('../../../services/mentionNonMemberNoticeService'); const router = require('../../../routes/agentsRuntime'); const getRouteHandler = (path, method) => { @@ -51,6 +58,7 @@ const getRouteHandler = (path, method) => { describe('agentsRuntime post message installation config forwarding', () => { beforeEach(() => { jest.clearAllMocks(); + findNonMemberMentionNotices.mockResolvedValue([]); }); it('passes installation config to AgentMessageService.postMessage', async () => { @@ -92,7 +100,54 @@ describe('agentsRuntime post message installation config forwarding', () => { messageType: 'text', installationConfig: { errorRouting: { ownerDm: true } }, })); - expect(res.json).toHaveBeenCalledWith({ success: true }); + expect(res.json).toHaveBeenCalledWith({ success: true, mentionNotices: [] }); + }); + + it('returns structured non-member feedback in the MCP post result', async () => { + const handler = getRouteHandler('/pods/:podId/messages', 'post'); + AgentMessageService.postMessage.mockResolvedValue({ + success: true, message: { id: 'm1', content: 'Please review @rhea' }, + }); + findNonMemberMentionNotices.mockResolvedValue([{ code: 'MENTION_NOT_IN_POD', handle: 'rhea', message: "rhea isn't in this pod. Invite them, or ask in a pod they're in." }]); + const installation = { podId: 'pod-1', agentName: 'openclaw', instanceId: 'tarik', config: {} }; + const req = { + params: { podId: 'pod-1' }, body: { content: 'Please review @rhea' }, + agentInstallation: installation, agentInstallations: [installation], + }; + const res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + + await handler(req, res); + + expect(findNonMemberMentionNotices).toHaveBeenCalledWith('pod-1', 'Please review @rhea'); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + mentionNotices: [{ code: 'MENTION_NOT_IN_POD', handle: 'rhea', message: "rhea isn't in this pod. Invite them, or ask in a pod they're in." }], + notice: "rhea isn't in this pod. Invite them, or ask in a pod they're in.", + })); + }); + + it('still reports a successful post if the advisory lookup fails', async () => { + const handler = getRouteHandler('/pods/:podId/messages', 'post'); + AgentMessageService.postMessage.mockResolvedValue({ + success: true, message: { id: 'm1', content: '@rhea please review' }, + }); + findNonMemberMentionNotices.mockRejectedValueOnce(new Error('lookup unavailable')); + const installation = { podId: 'pod-1', agentName: 'openclaw', instanceId: 'tarik', config: {} }; + const req = { + params: { podId: 'pod-1' }, body: { content: '@rhea please review' }, + agentInstallation: installation, agentInstallations: [installation], + }; + const res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + await handler(req, res); + expect(res.status).not.toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: true, message: { id: 'm1', content: '@rhea please review' }, mentionNotices: [], + }); + } finally { + warn.mockRestore(); + } }); // answeringMessageId tells the answer rule which message a post answers diff --git a/backend/__tests__/unit/services/mentionNonMemberNoticeService.test.js b/backend/__tests__/unit/services/mentionNonMemberNoticeService.test.js new file mode 100644 index 0000000000..94e0ed1e14 --- /dev/null +++ b/backend/__tests__/unit/services/mentionNonMemberNoticeService.test.js @@ -0,0 +1,45 @@ +jest.mock('../../../models/Pod', () => ({ findById: jest.fn() })); +jest.mock('../../../models/User', () => ({ find: jest.fn() })); +jest.mock('../../../models/AgentRegistry', () => ({ + AgentInstallation: { find: jest.fn() }, +})); + +const Pod = require('../../../models/Pod'); +const User = require('../../../models/User'); +const { AgentInstallation } = require('../../../models/AgentRegistry'); +const { findNonMemberMentionNotices } = require('../../../services/mentionNonMemberNoticeService'); + +const rows = (value) => ({ select: () => ({ lean: async () => value }) }); + +describe('non-member mention feedback', () => { + beforeEach(() => { + jest.clearAllMocks(); + Pod.findById.mockReturnValue(rows({ members: ['member-user'] })); + User.find.mockReturnValue(rows([ + { _id: 'member-user', username: 'sam' }, + { _id: 'other-user', username: 'Lily' }, + ])); + AgentInstallation.find.mockReturnValue(rows([ + { podId: 'pod-1', agentName: 'nova', instanceId: 'nova', displayName: 'Nova' }, + { podId: 'pod-2', agentName: 'rhea', instanceId: 'rhea', displayName: 'Rhea' }, + ])); + }); + + it('notifies for known outsiders but not members or unknown handles', async () => { + const notices = await findNonMemberMentionNotices( + 'pod-1', '@rhea @lily @nova @sam @notarealhandle @rhea', + ); + expect(notices).toEqual([ + { code: 'MENTION_NOT_IN_POD', handle: 'rhea', message: "rhea isn't in this pod. Invite them, or ask in a pod they're in." }, + { code: 'MENTION_NOT_IN_POD', handle: 'lily', message: "lily isn't in this pod. Invite them, or ask in a pod they're in." }, + ]); + expect(User.find).toHaveBeenCalledWith({ + $or: expect.arrayContaining([{ username: /^lily$/i }]), + }); + }); + + it('skips all lookups when there is no mention', async () => { + expect(await findNonMemberMentionNotices('pod-1', 'hello')).toEqual([]); + expect(Pod.findById).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/__tests__/unit/services/messageAgentDeliveryService.test.js b/backend/__tests__/unit/services/messageAgentDeliveryService.test.js index e99d0b1060..93facf96b3 100644 --- a/backend/__tests__/unit/services/messageAgentDeliveryService.test.js +++ b/backend/__tests__/unit/services/messageAgentDeliveryService.test.js @@ -7,8 +7,12 @@ jest.mock('../../../services/agentMentionService', () => ({ jest.mock('../../../models/AgentRegistry', () => ({ AgentInstallation: { countDocuments: jest.fn() }, })); +jest.mock('../../../services/mentionNonMemberNoticeService', () => ({ + findNonMemberMentionNotices: jest.fn().mockResolvedValue([]), +})); const AgentMentionService = require('../../../services/agentMentionService'); +const { findNonMemberMentionNotices } = require('../../../services/mentionNonMemberNoticeService'); const { AgentInstallation } = require('../../../models/AgentRegistry'); const { authorUsername, @@ -57,6 +61,7 @@ describe('messageAgentDeliveryService', () => { }); expect(response).toEqual({ ...message, + mentionNotices: [], agentDelivery: { enqueued: 1, implicit: ['recorder'], @@ -66,6 +71,33 @@ describe('messageAgentDeliveryService', () => { }); }); + it('puts a known non-member notice only in the sender response', async () => { + const notice = { code: 'MENTION_NOT_IN_POD', handle: 'rhea', message: "rhea isn't in this pod." }; + findNonMemberMentionNotices.mockResolvedValueOnce([notice]); + const message = { id: 'm2', content: '@rhea please review', userId: { username: 'sam' } }; + + const response = await deliverMessageToAgents({ + podId: 'p1', podType: 'chat', message, userId: 'u1', + }); + + expect(response.mentionNotices).toEqual([notice]); + expect(message).not.toHaveProperty('mentionNotices'); + }); + + it('keeps delivery successful when the advisory lookup fails', async () => { + findNonMemberMentionNotices.mockRejectedValueOnce(new Error('lookup unavailable')); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const response = await deliverMessageToAgents({ + podId: 'p1', podType: 'chat', message: { id: 'm2', content: '@rhea' }, userId: 'u1', + }); + expect(response.mentionNotices).toEqual([]); + expect(AgentMentionService.enqueueMentions).toHaveBeenCalledTimes(1); + } finally { + warn.mockRestore(); + } + }); + it('omits a reply edge for a route that cannot receive one', async () => { const message = { id: 'm1', userId: { username: 'joined' } }; diff --git a/backend/routes/agentsRuntime.ts b/backend/routes/agentsRuntime.ts index df7fbd5e18..0338584470 100644 --- a/backend/routes/agentsRuntime.ts +++ b/backend/routes/agentsRuntime.ts @@ -25,6 +25,7 @@ const AgentEventService = require('../services/agentEventService'); const HostedRuntimeMeter = require('../services/hostedRuntimeService'); const AgentIdentityService = require('../services/agentIdentityService'); const AgentMessageService = require('../services/agentMessageService'); +const { findNonMemberMentionNotices } = require('../services/mentionNonMemberNoticeService'); const AgentThreadService = require('../services/agentThreadService'); const PodContextService = require('../services/podContextService'); const GlobalModelConfigService = require('../services/globalModelConfigService'); @@ -2117,7 +2118,25 @@ router.post('/pods/:podId/messages', phase4RateLimit, agentRuntimeAuth, async (r .catch((err: any) => console.warn('agent post-message DM/mention enqueue failed:', err.message)); } - return res.json(result); + let mentionNotices: Array<{ code: 'MENTION_NOT_IN_POD'; handle: string; message: string }> = []; + if (result.success && !result.skipped && result.message) { + try { + mentionNotices = await findNonMemberMentionNotices( + podId, String((result.message as { content?: string }).content || ''), + ); + } catch (noticeError) { + // The post already landed. A failed advisory lookup cannot make the + // tool report a failed send and invite a duplicate retry. + console.warn('[agent post-message] mention notice lookup failed:', (noticeError as Error).message); + } + } + return res.json({ + ...result, + mentionNotices, + ...(mentionNotices.length > 0 + ? { notice: mentionNotices.map((item) => item.message).join(' ') } + : {}), + }); } catch (error: any) { const err = error as Error & { code?: string; statusCode?: number; name?: string }; // A bad thread target is caller-fixable input, same as on the human diff --git a/backend/services/mentionNonMemberNoticeService.ts b/backend/services/mentionNonMemberNoticeService.ts new file mode 100644 index 0000000000..407aa02fd9 --- /dev/null +++ b/backend/services/mentionNonMemberNoticeService.ts @@ -0,0 +1,79 @@ +// A sender can address only pod members. When a real handle names someone +// outside this pod, return sender-only feedback without exposing where they are. +// eslint-disable-next-line global-require +const Pod = require('../models/Pod'); +// eslint-disable-next-line global-require +const User = require('../models/User'); +// eslint-disable-next-line global-require +const { AgentInstallation } = require('../models/AgentRegistry'); + +export interface MentionNotice { + code: 'MENTION_NOT_IN_POD'; + handle: string; + message: string; +} + +const mentionedHandles = (content: string): string[] => { + const handles = new Set(); + const regex = /@([a-z0-9_-]{2,})/gi; + let match: RegExpExecArray | null; + while ((match = regex.exec(content)) !== null && handles.size < 20) { + handles.add(match[1].toLowerCase()); + } + return [...handles]; +}; + +const slug = (value: string): string => value.toLowerCase().trim() + .replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, ''); + +const exactHandle = (handle: string): RegExp => new RegExp(`^${handle}$`, 'i'); + +const agentHandles = (installation: Record): string[] => { + const name = String(installation.agentName || '').toLowerCase(); + const instance = String(installation.instanceId || 'default').toLowerCase(); + return [name, instance, `${name}-${instance}`, slug(String(installation.displayName || ''))].filter(Boolean); +}; + +export const findNonMemberMentionNotices = async ( + podId: string, + content: string, +): Promise => { + const handles = mentionedHandles(content); + if (handles.length === 0) return []; + + const [pod, users, installations] = await Promise.all([ + Pod.findById(podId).select('members').lean(), + // Usernames retain their original case at write time. Mention parsing + // lowercases them, so use the same anchored case-insensitive match as + // human mention routing. + User.find({ $or: handles.map((handle) => ({ username: exactHandle(handle) })) }) + .select('_id username').lean(), + // Display-name slugs and instance handles are routable in the pod, so + // inspect the same active installation records rather than only names. + AgentInstallation.find({ status: 'active' }) + .select('podId agentName instanceId displayName').lean(), + ]); + if (!pod) return []; + + const members = new Set((pod.members || []).map((id: unknown) => String(id))); + const known = new Set(); + const inPod = new Set(); + for (const user of users || []) { + const handle = String(user.username || '').toLowerCase(); + known.add(handle); + if (members.has(String(user._id))) inPod.add(handle); + } + for (const installation of installations || []) { + for (const handle of agentHandles(installation)) { + known.add(handle); + if (String(installation.podId) === String(podId)) inPod.add(handle); + } + } + + return handles.filter((handle) => known.has(handle) && !inPod.has(handle)) + .map((handle) => ({ + code: 'MENTION_NOT_IN_POD' as const, + handle, + message: `${handle} isn't in this pod. Invite them, or ask in a pod they're in.`, + })); +}; diff --git a/backend/services/messageAgentDeliveryService.ts b/backend/services/messageAgentDeliveryService.ts index 94a7f5c4cd..61a6f2691c 100644 --- a/backend/services/messageAgentDeliveryService.ts +++ b/backend/services/messageAgentDeliveryService.ts @@ -2,8 +2,11 @@ const AgentMentionService = require('./agentMentionService'); // eslint-disable-next-line global-require const { AgentInstallation } = require('../models/AgentRegistry'); +// eslint-disable-next-line global-require +const { findNonMemberMentionNotices } = require('./mentionNonMemberNoticeService'); export interface MessageForAgentDelivery { + content?: string; userId?: { username?: string } | string; username?: string; user?: { username?: string }; @@ -34,7 +37,18 @@ interface AgentDelivery { woken: number; } -type DeliveredMessage = T | (T & { agentDelivery: AgentDelivery }); +type DeliveredMessage = T | (T & { mentionNotices: unknown[]; agentDelivery?: AgentDelivery }); + +const senderNotices = async (podId: string, message: { content?: string }): Promise => { + try { + return await findNonMemberMentionNotices(podId, message.content || ''); + } catch (error) { + // The message is already stored. Advisory feedback cannot turn a send + // into an apparent failure and tempt the caller to post it again. + console.warn('[messageAgentDelivery] mention notice lookup failed:', (error as Error).message); + return []; + } +}; // The persisted message is the authoritative sender frame. Request auth can // have only an id (JWT) while either PG or Mongo may have joined the author. @@ -66,11 +80,12 @@ export const deliverMessageToAgents = async ( onlyExplicitMentions = false, }: DeliveryOptions): Promise> => { const username = authorUsername(message, requestUser); + const mentionNotices = await senderNotices(podId, message); if (AgentMentionService.isAutoRoutedDmPod(podType)) { - if (suppressImplicitReply) return message; + if (suppressImplicitReply) return mentionNotices.length ? { ...message, mentionNotices } : message; await AgentMentionService.enqueueDmEvent({ podId, message, userId, username }); - return message; + return mentionNotices.length ? { ...message, mentionNotices } : message; } const mentionOptions = { @@ -95,6 +110,7 @@ export const deliverMessageToAgents = async ( return { ...message, + mentionNotices, agentDelivery: { enqueued: mentionResult.enqueued.length, implicit: mentionResult.implicit || [], diff --git a/docs/development/agent-experience-audit.md b/docs/development/agent-experience-audit.md index 502ffe93e3..336a706b4b 100644 --- a/docs/development/agent-experience-audit.md +++ b/docs/development/agent-experience-audit.md @@ -4192,3 +4192,11 @@ Gates that never appear in metadata decided several more, including the client a **Repair:** no code changes in this entry. Reason about who can grant Pro from both writers, and treat any change that adds a writer, or widens who can reach the admin route, as a review item. The header comment in `billingService.ts` is still wrong and should be corrected with the next change to that file. **Lesson:** a "the only writer" comment is a claim about the whole tree, and it goes stale silently when a second route is added. Grep for the writers before repeating a single-writer claim. + +## 83. An out-of-pod @mention looked like a delivered handoff (2026-10-23, kite) + +*Origin: TASK-405 stalled after an agent addressed `@rhea` in a pod Rhea had not joined. The message posted successfully, but mention resolution considered only that pod's members. Neither the chat sender nor the `commonly_post_message` caller was told that no handoff occurred.* + +**Repair:** after a post, resolve known mentioned handles against pod membership and return a sender-only notice for each known non-member. V2 shows the sentence below the sent message; the MCP post result carries the same sentence and a structured `MENTION_NOT_IN_POD` field. Unknown handles stay silent. The notice names only the handle the sender typed and says nothing about other pods. + +**Lesson:** accepting a message is not proof its address resolved. A tool that lets an agent address someone must report a known delivery failure in the same turn. diff --git a/frontend/src/v2/__tests__/V2MessageRow.test.tsx b/frontend/src/v2/__tests__/V2MessageRow.test.tsx index 3284070e72..fe07644ada 100644 --- a/frontend/src/v2/__tests__/V2MessageRow.test.tsx +++ b/frontend/src/v2/__tests__/V2MessageRow.test.tsx @@ -14,6 +14,21 @@ describe('V2MessageRow', () => { process.env.REACT_APP_API_URL = previousApiUrl; }); + it('shows a sender-only non-member notice as a quiet system line', () => { + render( + + + , + ); + expect(screen.getByRole('status')).toHaveClass('v2-msg__sender-notice'); + expect(screen.getByRole('status')).toHaveTextContent("rhea isn't in this pod"); + }); + it('keeps the runtime beside the agent name before its timestamp', () => { render( diff --git a/frontend/src/v2/__tests__/useV2PodDetailReply.test.tsx b/frontend/src/v2/__tests__/useV2PodDetailReply.test.tsx index 08a94a197b..19a3000bc9 100644 --- a/frontend/src/v2/__tests__/useV2PodDetailReply.test.tsx +++ b/frontend/src/v2/__tests__/useV2PodDetailReply.test.tsx @@ -46,6 +46,7 @@ describe('useV2PodDetail reply threading (#646)', () => { it('grafts the POST copy replyTo onto a socket copy that won the dedupe race', async () => { const replyTo = { id: 'm1', content: 'original', username: 'bob' }; + const mentionNotices = [{ code: 'MENTION_NOT_IN_POD', handle: 'rhea', message: "rhea isn't in this pod." }]; let resolvePost; mockApi.post.mockReturnValue(new Promise((resolve) => { resolvePost = resolve; })); @@ -66,7 +67,7 @@ describe('useV2PodDetail reply threading (#646)', () => { // POST response (carries replyTo) resolves second. await act(async () => { resolvePost({ - id: 'm9', pod_id: 'p1', content: 'a reply', created_at: '2026-07-07T00:00:00Z', replyTo, + id: 'm9', pod_id: 'p1', content: 'a reply', created_at: '2026-07-07T00:00:00Z', replyTo, mentionNotices, }); await sendPromise; }); @@ -74,6 +75,7 @@ describe('useV2PodDetail reply threading (#646)', () => { const sent = result.current.messages.filter((m) => m.id === 'm9'); expect(sent).toHaveLength(1); expect(sent[0].replyTo).toEqual(replyTo); + expect(sent[0].mentionNotices).toEqual(mentionNotices); }); it('keeps a single copy when the POST response wins the race', async () => { diff --git a/frontend/src/v2/components/V2MessageRow.tsx b/frontend/src/v2/components/V2MessageRow.tsx index 61245fdf7b..ceaa08f209 100644 --- a/frontend/src/v2/components/V2MessageRow.tsx +++ b/frontend/src/v2/components/V2MessageRow.tsx @@ -744,6 +744,11 @@ const V2MessageRow: React.FC = ({ message, sourceMessageId, d : {stripped}} )} + {message.mentionNotices?.map((notice) => ( +
+ {notice.message} +
+ ))} {galleryImages.length > 0 && (
{galleryImages.map((image, idx) => ( diff --git a/frontend/src/v2/hooks/useV2PodDetail.ts b/frontend/src/v2/hooks/useV2PodDetail.ts index 64c44edd71..f39f35f734 100644 --- a/frontend/src/v2/hooks/useV2PodDetail.ts +++ b/frontend/src/v2/hooks/useV2PodDetail.ts @@ -61,6 +61,9 @@ export interface V2Message { // Wake-on-message targets (#914). Optional: older backends omit it. woken?: number; }; + // Sender-only feedback from the POST response. Socket broadcasts and + // history omit it, so other pod members never see the notice. + mentionNotices?: Array<{ code: 'MENTION_NOT_IN_POD'; handle: string; message: string }>; // Sprint B5: per-message reactions, aggregated server-side. // Each entry is `{emoji, count, mine, users?}` — `users` is the // Google-Chat-style attribution list decorated by @@ -759,6 +762,7 @@ export const useV2PodDetail = (podId: string | null): UseV2PodDetailResult => { // the POST copy's — or the sender's own in-thread reply // renders top-level until refresh (Sam, 2026-08-24). thread_root_id: m.thread_root_id ?? normalized.thread_root_id ?? null, + mentionNotices: normalized.mentionNotices ?? m.mentionNotices, } : m )); diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index b1b196fac3..8f8ed7b431 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -2229,6 +2229,13 @@ body.modern-ui.v2-canvas { flex-shrink: 0; } +/* Sender-only delivery feedback sits under the post as a quiet system line. */ +.v2-msg__sender-notice { + color: var(--v2-text-tertiary); + font: 12px/16px var(--v2-font); + margin-top: 6px; +} + /* Agent typing indicator — sits between message list and composer. Collapses to nothing when typingAgents is empty. Subtle so it doesn't compete with the messages above; the animated dots carry the "alive" cue. */ From 81a9967beaec5d04419f865619f6613b4bb61ff0 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:53:26 -0700 Subject: [PATCH 2/4] fix(i18n): localize sender mention feedback Co-authored-by: kite Agent-Model: codex/gpt-6-sol/medium --- frontend/src/i18n/locales/en.json | 1 + frontend/src/i18n/locales/zh-CN.json | 1 + .../src/v2/__tests__/V2MessageRow.test.tsx | 20 ++++++++++++++++++- frontend/src/v2/components/V2MessageRow.tsx | 2 +- 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 1cf9c8351f..fa02a703f5 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -459,6 +459,7 @@ "cancelReply": "Cancel reply", "replyingTo": "Replying to {{author}}:", "deliveryHint": "No agent was notified — @mention one to get a reply. Try @{{handle}}.", + "mentionNotInPod": "{{handle}} isn't in this pod. Invite them, or ask in a pod they're in.", "noAgentsInPod": { "kicker": "no agent in this pod", "body": "Nobody here answers yet — people will see this when they open the pod.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index f3b824e790..e2b1046c7b 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -459,6 +459,7 @@ "cancelReply": "取消回复", "replyingTo": "回复 {{author}}:", "deliveryHint": "尚未通知任何智能体。@提及一个智能体即可让它回复,例如 @{{handle}}。", + "mentionNotInPod": "{{handle}} 不在这个 Pod 中。请邀请对方加入,或到对方已加入的 Pod 中提问。", "noAgentsInPod": { "kicker": "此 Pod 暂无智能体", "body": "还没有人会回复 —— 别人打开这个 Pod 时会看到这条消息。", diff --git a/frontend/src/v2/__tests__/V2MessageRow.test.tsx b/frontend/src/v2/__tests__/V2MessageRow.test.tsx index fe07644ada..9e456b64cb 100644 --- a/frontend/src/v2/__tests__/V2MessageRow.test.tsx +++ b/frontend/src/v2/__tests__/V2MessageRow.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; +import i18n from '../../i18n'; import V2MessageRow from '../components/V2MessageRow'; jest.mock('../../context/AuthContext', () => ({ @@ -12,6 +13,7 @@ describe('V2MessageRow', () => { afterEach(() => { process.env.REACT_APP_API_URL = previousApiUrl; + return i18n.changeLanguage('en'); }); it('shows a sender-only non-member notice as a quiet system line', () => { @@ -29,6 +31,22 @@ describe('V2MessageRow', () => { expect(screen.getByRole('status')).toHaveTextContent("rhea isn't in this pod"); }); + it('uses Chinese UI copy instead of the English MCP sentence', async () => { + await act(async () => { await i18n.changeLanguage('zh-CN'); }); + render( + + + , + ); + expect(screen.getByRole('status')).toHaveTextContent('rhea 不在这个 Pod 中'); + expect(screen.getByRole('status')).not.toHaveTextContent('English MCP text'); + }); + it('keeps the runtime beside the agent name before its timestamp', () => { render( diff --git a/frontend/src/v2/components/V2MessageRow.tsx b/frontend/src/v2/components/V2MessageRow.tsx index ceaa08f209..aa7381632f 100644 --- a/frontend/src/v2/components/V2MessageRow.tsx +++ b/frontend/src/v2/components/V2MessageRow.tsx @@ -746,7 +746,7 @@ const V2MessageRow: React.FC = ({ message, sourceMessageId, d )} {message.mentionNotices?.map((notice) => (
- {notice.message} + {t('podChat.mentionNotInPod', { handle: notice.handle })}
))} {galleryImages.length > 0 && ( From 569e16f585e795cbe91ef4d0a70b18fee41b10c5 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:54:43 -0700 Subject: [PATCH 3/4] fix(mentions): share routed alias map with sender feedback Co-authored-by: kite Agent-Model: codex/gpt-6-sol/medium --- .../mentionNonMemberNoticeService.test.js | 21 ++++++++++++- backend/services/agentMentionService.ts | 3 ++ .../services/mentionNonMemberNoticeService.ts | 31 ++++++++++++------- 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/backend/__tests__/unit/services/mentionNonMemberNoticeService.test.js b/backend/__tests__/unit/services/mentionNonMemberNoticeService.test.js index 94e0ed1e14..ee8e95faaf 100644 --- a/backend/__tests__/unit/services/mentionNonMemberNoticeService.test.js +++ b/backend/__tests__/unit/services/mentionNonMemberNoticeService.test.js @@ -1,11 +1,13 @@ jest.mock('../../../models/Pod', () => ({ findById: jest.fn() })); jest.mock('../../../models/User', () => ({ find: jest.fn() })); +jest.mock('../../../models/AgentProfile', () => ({ find: jest.fn() })); jest.mock('../../../models/AgentRegistry', () => ({ AgentInstallation: { find: jest.fn() }, })); const Pod = require('../../../models/Pod'); const User = require('../../../models/User'); +const AgentProfile = require('../../../models/AgentProfile'); const { AgentInstallation } = require('../../../models/AgentRegistry'); const { findNonMemberMentionNotices } = require('../../../services/mentionNonMemberNoticeService'); @@ -22,16 +24,22 @@ describe('non-member mention feedback', () => { AgentInstallation.find.mockReturnValue(rows([ { podId: 'pod-1', agentName: 'nova', instanceId: 'nova', displayName: 'Nova' }, { podId: 'pod-2', agentName: 'rhea', instanceId: 'rhea', displayName: 'Rhea' }, + { podId: 'pod-2', agentName: 'scout', instanceId: 'scout', displayName: '' }, + ])); + AgentProfile.find.mockReturnValue(rows([ + { podId: 'pod-2', agentName: 'scout', instanceId: 'scout', name: 'Friendly Scout' }, ])); }); it('notifies for known outsiders but not members or unknown handles', async () => { const notices = await findNonMemberMentionNotices( - 'pod-1', '@rhea @lily @nova @sam @notarealhandle @rhea', + 'pod-1', '@rhea @lily @nova @sam @notarealhandle @rhea @guide @friendly-scout', ); expect(notices).toEqual([ { code: 'MENTION_NOT_IN_POD', handle: 'rhea', message: "rhea isn't in this pod. Invite them, or ask in a pod they're in." }, { code: 'MENTION_NOT_IN_POD', handle: 'lily', message: "lily isn't in this pod. Invite them, or ask in a pod they're in." }, + { code: 'MENTION_NOT_IN_POD', handle: 'guide', message: "guide isn't in this pod. Invite them, or ask in a pod they're in." }, + { code: 'MENTION_NOT_IN_POD', handle: 'friendly-scout', message: "friendly-scout isn't in this pod. Invite them, or ask in a pod they're in." }, ]); expect(User.find).toHaveBeenCalledWith({ $or: expect.arrayContaining([{ username: /^lily$/i }]), @@ -42,4 +50,15 @@ describe('non-member mention feedback', () => { expect(await findNonMemberMentionNotices('pod-1', 'hello')).toEqual([]); expect(Pod.findById).not.toHaveBeenCalled(); }); + + it('recognizes an in-pod legacy alias and profile display slug as delivered', async () => { + AgentInstallation.find.mockReturnValue(rows([ + { podId: 'pod-1', agentName: 'scout', instanceId: 'scout', displayName: '' }, + ])); + AgentProfile.find.mockReturnValue(rows([ + { podId: 'pod-1', agentName: 'scout', instanceId: 'scout', name: 'Friendly Scout' }, + ])); + + expect(await findNonMemberMentionNotices('pod-1', '@guide @friendly-scout')).toEqual([]); + }); }); diff --git a/backend/services/agentMentionService.ts b/backend/services/agentMentionService.ts index 0ad4c6de9a..48710ee717 100644 --- a/backend/services/agentMentionService.ts +++ b/backend/services/agentMentionService.ts @@ -2457,6 +2457,9 @@ const enqueueDmEvent = async ({ export { extractMentions, + // Sender feedback must recognize the exact same routed handles (including + // legacy aliases and profile display names) as the delivery path. + buildMentionMap, // Exported for the escaping test: the property that matters — a metacharacter // in a handle matches literally — is unreachable through `enqueueMentions` // while `extractMentions` still excludes metacharacters. A test that can only diff --git a/backend/services/mentionNonMemberNoticeService.ts b/backend/services/mentionNonMemberNoticeService.ts index 407aa02fd9..a3e6eac15d 100644 --- a/backend/services/mentionNonMemberNoticeService.ts +++ b/backend/services/mentionNonMemberNoticeService.ts @@ -6,6 +6,10 @@ const Pod = require('../models/Pod'); const User = require('../models/User'); // eslint-disable-next-line global-require const { AgentInstallation } = require('../models/AgentRegistry'); +// eslint-disable-next-line global-require +const AgentProfile = require('../models/AgentProfile'); +// eslint-disable-next-line global-require +const { buildMentionMap } = require('./agentMentionService'); export interface MentionNotice { code: 'MENTION_NOT_IN_POD'; @@ -23,17 +27,8 @@ const mentionedHandles = (content: string): string[] => { return [...handles]; }; -const slug = (value: string): string => value.toLowerCase().trim() - .replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, ''); - const exactHandle = (handle: string): RegExp => new RegExp(`^${handle}$`, 'i'); -const agentHandles = (installation: Record): string[] => { - const name = String(installation.agentName || '').toLowerCase(); - const instance = String(installation.instanceId || 'default').toLowerCase(); - return [name, instance, `${name}-${instance}`, slug(String(installation.displayName || ''))].filter(Boolean); -}; - export const findNonMemberMentionNotices = async ( podId: string, content: string, @@ -41,7 +36,7 @@ export const findNonMemberMentionNotices = async ( const handles = mentionedHandles(content); if (handles.length === 0) return []; - const [pod, users, installations] = await Promise.all([ + const [pod, users, installations, profiles] = await Promise.all([ Pod.findById(podId).select('members').lean(), // Usernames retain their original case at write time. Mention parsing // lowercases them, so use the same anchored case-insensitive match as @@ -52,6 +47,7 @@ export const findNonMemberMentionNotices = async ( // inspect the same active installation records rather than only names. AgentInstallation.find({ status: 'active' }) .select('podId agentName instanceId displayName').lean(), + AgentProfile.find({}).select('podId agentName instanceId name').lean(), ]); if (!pod) return []; @@ -63,10 +59,21 @@ export const findNonMemberMentionNotices = async ( known.add(handle); if (members.has(String(user._id))) inPod.add(handle); } + const installationsByPod = new Map>>(); for (const installation of installations || []) { - for (const handle of agentHandles(installation)) { + const installationPodId = String(installation.podId); + const list = installationsByPod.get(installationPodId) || []; + list.push(installation); + installationsByPod.set(installationPodId, list); + } + for (const [installationPodId, podInstallations] of installationsByPod) { + const podProfiles = (profiles || []).filter( + (profile: { podId: unknown }) => String(profile.podId) === installationPodId, + ); + const { map } = buildMentionMap(podInstallations, podProfiles); + for (const handle of map.keys()) { known.add(handle); - if (String(installation.podId) === String(podId)) inPod.add(handle); + if (installationPodId === String(podId)) inPod.add(handle); } } From f4a55eb2996350d436241cb673ead8356f3dbc73 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:58:51 -0700 Subject: [PATCH 4/4] fix(mentions): omit empty human notice metadata Co-authored-by: kite Agent-Model: codex/gpt-6-sol/medium --- .../unit/services/messageAgentDeliveryService.test.js | 3 +-- backend/services/messageAgentDeliveryService.ts | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/backend/__tests__/unit/services/messageAgentDeliveryService.test.js b/backend/__tests__/unit/services/messageAgentDeliveryService.test.js index 93facf96b3..53453256e5 100644 --- a/backend/__tests__/unit/services/messageAgentDeliveryService.test.js +++ b/backend/__tests__/unit/services/messageAgentDeliveryService.test.js @@ -61,7 +61,6 @@ describe('messageAgentDeliveryService', () => { }); expect(response).toEqual({ ...message, - mentionNotices: [], agentDelivery: { enqueued: 1, implicit: ['recorder'], @@ -91,7 +90,7 @@ describe('messageAgentDeliveryService', () => { const response = await deliverMessageToAgents({ podId: 'p1', podType: 'chat', message: { id: 'm2', content: '@rhea' }, userId: 'u1', }); - expect(response.mentionNotices).toEqual([]); + expect(response).not.toHaveProperty('mentionNotices'); expect(AgentMentionService.enqueueMentions).toHaveBeenCalledTimes(1); } finally { warn.mockRestore(); diff --git a/backend/services/messageAgentDeliveryService.ts b/backend/services/messageAgentDeliveryService.ts index 61a6f2691c..687e4b19eb 100644 --- a/backend/services/messageAgentDeliveryService.ts +++ b/backend/services/messageAgentDeliveryService.ts @@ -37,7 +37,7 @@ interface AgentDelivery { woken: number; } -type DeliveredMessage = T | (T & { mentionNotices: unknown[]; agentDelivery?: AgentDelivery }); +type DeliveredMessage = T | (T & { mentionNotices?: unknown[]; agentDelivery?: AgentDelivery }); const senderNotices = async (podId: string, message: { content?: string }): Promise => { try { @@ -110,7 +110,7 @@ export const deliverMessageToAgents = async ( return { ...message, - mentionNotices, + ...(mentionNotices.length ? { mentionNotices } : {}), agentDelivery: { enqueued: mentionResult.enqueued.length, implicit: mentionResult.implicit || [],