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..ee8e95faaf --- /dev/null +++ b/backend/__tests__/unit/services/mentionNonMemberNoticeService.test.js @@ -0,0 +1,64 @@ +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'); + +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' }, + { 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 @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 }]), + }); + }); + + it('skips all lookups when there is no mention', async () => { + 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/__tests__/unit/services/messageAgentDeliveryService.test.js b/backend/__tests__/unit/services/messageAgentDeliveryService.test.js index e99d0b1060..53453256e5 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, @@ -66,6 +70,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).not.toHaveProperty('mentionNotices'); + 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/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 new file mode 100644 index 0000000000..a3e6eac15d --- /dev/null +++ b/backend/services/mentionNonMemberNoticeService.ts @@ -0,0 +1,86 @@ +// 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'); +// 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'; + 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 exactHandle = (handle: string): RegExp => new RegExp(`^${handle}$`, 'i'); + +export const findNonMemberMentionNotices = async ( + podId: string, + content: string, +): Promise => { + const handles = mentionedHandles(content); + if (handles.length === 0) return []; + + 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 + // 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(), + AgentProfile.find({}).select('podId agentName instanceId name').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); + } + const installationsByPod = new Map>>(); + for (const installation of installations || []) { + 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 (installationPodId === 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..687e4b19eb 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.length ? { 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/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 3284070e72..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,38 @@ 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', () => { + render( + + + , + ); + expect(screen.getByRole('status')).toHaveClass('v2-msg__sender-notice'); + 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', () => { 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..aa7381632f 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) => ( +
+ {t('podChat.mentionNotInPod', { handle: notice.handle })} +
+ ))} {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. */