diff --git a/backend/__tests__/unit/models/recentContentByAuthor.pgmem.test.js b/backend/__tests__/unit/models/recentContentByAuthor.pgmem.test.js new file mode 100644 index 0000000000..bc8e2cf704 --- /dev/null +++ b/backend/__tests__/unit/models/recentContentByAuthor.pgmem.test.js @@ -0,0 +1,84 @@ +/** + * `recentContentByAuthor`, EXECUTED against pg-mem. The handoff cue + * (agentMentionService) asks it whether an agent recently @mentioned another; + * the service suite mocks it, so only this file proves the WHERE clause + * discriminates by pod, author, window and row type. + */ + +const { newDb } = require('pg-mem'); + +const mockDb = newDb(); +const mockPool = new (mockDb.adapters.createPg().Pool)(); + +jest.mock('../../../config/db-pg', () => ({ pool: mockPool })); + +const PGMessage = require('../../../models/pg/Message'); + +const POD = 'aaaaaaaaaaaaaaaaaaaaaa01'; +const OTHER_POD = 'aaaaaaaaaaaaaaaaaaaaaa02'; +const CODEX = 'cccccccccccccccccccccc01'; +const CLAUDE = 'cccccccccccccccccccccc02'; +const NOW = new Date('2026-10-20T20:00:00Z'); +const minutesAgo = (n) => new Date(NOW.getTime() - n * 60 * 1000); +const SINCE = minutesAgo(30); + +const insert = (podId, userId, content, createdAt, type = 'text') => mockPool.query( + 'INSERT INTO messages (pod_id, user_id, content, message_type, created_at) VALUES ($1, $2, $3, $4, $5)', + [podId, userId, content, type, createdAt], +); + +beforeAll(async () => { + await mockPool.query(`CREATE TABLE messages ( + id SERIAL PRIMARY KEY, + pod_id VARCHAR(24) NOT NULL, + user_id VARCHAR(24) NOT NULL, + content TEXT NOT NULL, + message_type VARCHAR(20) DEFAULT 'text' NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + )`); + await insert(POD, CODEX, 'older than the window @claude', minutesAgo(45)); + await insert(POD, CODEX, 'first in the window', minutesAgo(20)); + await insert(OTHER_POD, CODEX, 'another pod @claude', minutesAgo(10)); + await insert(POD, CLAUDE, 'another author @codex', minutesAgo(8)); + await insert(POD, CODEX, 'joined the pod', minutesAgo(6), 'system'); + await insert(POD, CODEX, 'done, @claude', minutesAgo(5)); +}); + +describe('recentContentByAuthor', () => { + test("returns this author's rows in this pod inside the window, newest first", async () => { + await expect(PGMessage.recentContentByAuthor(POD, CODEX, SINCE)) + .resolves.toEqual(['done, @claude', 'first in the window']); + }); + + test("another author's rows are not this author's", async () => { + await expect(PGMessage.recentContentByAuthor(POD, CLAUDE, SINCE)).resolves.toEqual(['another author @codex']); + }); + + test('another pod is never read', async () => { + await expect(PGMessage.recentContentByAuthor(OTHER_POD, CLAUDE, SINCE)).resolves.toEqual([]); + await expect(PGMessage.recentContentByAuthor(OTHER_POD, CODEX, SINCE)).resolves.toEqual(['another pod @claude']); + }); + + test('rows before the window are not returned', async () => { + const all = await PGMessage.recentContentByAuthor(POD, CODEX, minutesAgo(60)); + expect(all).toContain('older than the window @claude'); + await expect(PGMessage.recentContentByAuthor(POD, CODEX, SINCE)) + .resolves.not.toContain('older than the window @claude'); + }); + + test('system rows are not what the author said', async () => { + await expect(PGMessage.recentContentByAuthor(POD, CODEX, minutesAgo(60))) + .resolves.not.toContain('joined the pod'); + }); + + test('the row cap keeps the newest', async () => { + await expect(PGMessage.recentContentByAuthor(POD, CODEX, minutesAgo(60), 1)).resolves.toEqual(['done, @claude']); + }); + + test('accepts ObjectId-like values and refuses blanks', async () => { + await expect(PGMessage.recentContentByAuthor({ toString: () => POD }, { toString: () => CLAUDE }, SINCE)) + .resolves.toEqual(['another author @codex']); + await expect(PGMessage.recentContentByAuthor('', CODEX, SINCE)).resolves.toEqual([]); + await expect(PGMessage.recentContentByAuthor(POD, null, SINCE)).resolves.toEqual([]); + }); +}); diff --git a/backend/__tests__/unit/services/agentMentionService.handoffCue.test.js b/backend/__tests__/unit/services/agentMentionService.handoffCue.test.js new file mode 100644 index 0000000000..fdeb3b23e0 --- /dev/null +++ b/backend/__tests__/unit/services/agentMentionService.handoffCue.test.js @@ -0,0 +1,164 @@ +/** + * The handoff cue (C14 call rehearsal, 2026-10-20). Claude handed a change to + * Codex and promised to review it; Codex posted the result without @mentioning + * Claude, so Claude never woke and the review never came. When one agent + * @mentions another, the target's event now says who handed it over and to + * @mention them back with the result: only on the FIRST handoff of a chain, + * so the result coming back (and the thanks after it) carries no cue. + */ + +jest.mock('../../../services/agentEventService', () => ({ enqueue: jest.fn() })); +jest.mock('../../../models/AgentRegistry', () => ({ + AgentInstallation: { find: jest.fn(), findOne: jest.fn() }, +})); +jest.mock('../../../models/AgentProfile', () => ({ find: jest.fn() })); +jest.mock('../../../models/Pod', () => ({ findById: jest.fn(), find: jest.fn() })); +jest.mock('../../../models/User', () => ({ find: jest.fn(), findById: jest.fn(), findOne: jest.fn() })); +jest.mock('../../../services/chatSummarizerService', () => ({ + constructor: { getLatestPodSummary: jest.fn() }, + summarizePodMessages: jest.fn(), +})); +jest.mock('../../../models/AgentEvent', () => ({ countDocuments: jest.fn() })); +jest.mock('../../../services/welcomeWakeService', () => ({ maybeFireWelcomeWake: jest.fn() })); +jest.mock('../../../models/pg/Message', () => ({ recentContentByAuthor: jest.fn() })); + +const AgentMentionService = require('../../../services/agentMentionService'); +const AgentEventService = require('../../../services/agentEventService'); +const { AgentInstallation } = require('../../../models/AgentRegistry'); +const AgentProfile = require('../../../models/AgentProfile'); +const Pod = require('../../../models/Pod'); +const User = require('../../../models/User'); +const AgentEvent = require('../../../models/AgentEvent'); +const PGMessage = require('../../../models/pg/Message'); + +const POD = 'pod-c14'; +// Two own agents in one pod, as on the call: each is its own agentName with +// the default instance, so each answers to its bare name. +const AGENTS = { + 'laruiss-claude': { _id: 'u-claude', agentName: 'laruiss-claude', instanceId: 'default' }, + 'laruiss-codex': { _id: 'u-codex', agentName: 'laruiss-codex', instanceId: 'default' }, +}; +const HUMAN = { _id: 'u-laruiss', isBot: false, username: 'laruiss' }; + +// What each agent said in the pod, by user id (what recentContentByAuthor reads). +let posted; + +beforeEach(() => { + jest.clearAllMocks(); + posted = {}; + AgentEvent.countDocuments.mockResolvedValue(0); + Pod.findById.mockReturnValue({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue({ _id: POD, type: 'chat', contacts: {} }), + }); + AgentInstallation.find.mockReturnValue({ + lean: jest.fn().mockResolvedValue(Object.values(AGENTS).map(({ agentName, instanceId }) => ( + { agentName, instanceId, displayName: agentName } + ))), + }); + AgentProfile.find.mockReturnValue({ lean: jest.fn().mockResolvedValue([]) }); + User.findOne.mockImplementation(({ username }) => ({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue(AGENTS[username] ? { _id: AGENTS[username]._id } : null), + })); + PGMessage.recentContentByAuthor.mockImplementation(async (_podId, userId) => posted[userId] || []); +}); + +const as = (sender) => { + User.findById.mockReturnValue({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue(sender.isBot === false ? sender : { + _id: sender._id, + isBot: true, + username: sender.agentName, + botMetadata: { agentName: sender.agentName, instanceId: sender.instanceId }, + }), + }); +}; + +// Post as `sender`: record it in the pod, run the mention fan-out, and return +// the content of the event each mentioned agent received. +const post = async (sender, content) => { + as(sender); + AgentEventService.enqueue.mockClear(); + const result = await AgentMentionService.enqueueMentions({ + podId: POD, + message: { content, id: `m-${Math.random()}` }, + userId: sender._id, + username: sender.username || sender.agentName, + }); + posted[sender._id] = [content, ...(posted[sender._id] || [])]; + const events = {}; + AgentEventService.enqueue.mock.calls.forEach(([event]) => { events[event.agentName] = event.payload.content; }); + return { result, events }; +}; + +const CUE = /handed this to you/; + +describe('handoff cue', () => { + test('the first agent-to-agent handoff tells the target to @mention the sender with the result', async () => { + const { events } = await post(AGENTS['laruiss-claude'], '@laruiss-codex add a size prop to DsfrButton'); + expect(events['laruiss-codex']).toMatch( + '[@laruiss-claude handed this to you. When it\'s done, post the result and @mention @laruiss-claude', + ); + }); + + test('a two-agent chain ends: the result coming back and the thanks after it carry no cue', async () => { + const handoff = await post(AGENTS['laruiss-claude'], '@laruiss-codex add a size prop to DsfrButton'); + expect(handoff.events['laruiss-codex']).toMatch(CUE); + + const result = await post(AGENTS['laruiss-codex'], 'Done: feat(DsfrButton): add size prop. @laruiss-claude'); + expect(result.result.enqueued).toEqual(['laruiss-claude']); + expect(result.events['laruiss-claude']).not.toMatch(CUE); + + const thanks = await post(AGENTS['laruiss-claude'], 'Reviewed, looks right. Thanks @laruiss-codex'); + expect(thanks.result.enqueued).toEqual(['laruiss-codex']); + expect(thanks.events['laruiss-codex']).not.toMatch(CUE); + }); + + test('a person mentioning an agent gets no handoff cue', async () => { + const { events } = await post(HUMAN, '@laruiss-claude review the PR'); + expect(events['laruiss-claude']).toBeDefined(); + expect(events['laruiss-claude']).not.toMatch(CUE); + }); + + test('a mention back by another handle for the sender still counts as the answer', async () => { + // The target answered with the sender's `-` key, not + // its bare name; both resolve to the sender in this pod. + posted['u-codex'] = ['all done @laruiss-claude-default']; + const { events } = await post(AGENTS['laruiss-claude'], 'Thanks @laruiss-codex'); + expect(events['laruiss-codex']).not.toMatch(CUE); + }); + + test('a longer handle that starts with the sender\'s is not a mention of the sender', async () => { + posted['u-codex'] = ['pinging @laruiss-claude-bot about something else']; + const { events } = await post(AGENTS['laruiss-claude'], '@laruiss-codex next task'); + expect(events['laruiss-codex']).toMatch(CUE); + }); + + test('a sender that is not installed in the pod cannot be woken by a mention, so it is not named', async () => { + const outsider = { _id: 'u-out', agentName: 'roaming-bot', instanceId: 'default' }; + const { events } = await post(outsider, '@laruiss-codex take this'); + expect(events['laruiss-codex']).toBeDefined(); + expect(events['laruiss-codex']).not.toMatch(CUE); + }); + + test('a failed check means no cue, and the mention is still delivered', async () => { + PGMessage.recentContentByAuthor.mockRejectedValue(new Error('pg down')); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const { result, events } = await post(AGENTS['laruiss-claude'], '@laruiss-codex take this'); + expect(result.enqueued).toEqual(['laruiss-codex']); + expect(events['laruiss-codex']).not.toMatch(CUE); + expect(warn).toHaveBeenCalledWith('[handoff-cue] check failed (no cue):', 'pg down'); + warn.mockRestore(); + }); + + test('the answer check reads the TARGET\'s messages in this pod within 30 minutes', async () => { + await post(AGENTS['laruiss-claude'], '@laruiss-codex take this'); + expect(User.findOne).toHaveBeenCalledWith({ username: 'laruiss-codex', isBot: true }); + const [podId, userId, since] = PGMessage.recentContentByAuthor.mock.calls[0]; + expect([podId, userId]).toEqual([POD, 'u-codex']); + expect(Date.now() - since.getTime()).toBeGreaterThanOrEqual(30 * 60 * 1000 - 1000); + expect(Date.now() - since.getTime()).toBeLessThanOrEqual(30 * 60 * 1000 + 1000); + }); +}); diff --git a/backend/models/pg/Message.ts b/backend/models/pg/Message.ts index 435310ea22..f320d16f29 100644 --- a/backend/models/pg/Message.ts +++ b/backend/models/pg/Message.ts @@ -320,6 +320,24 @@ class Message { return result.rows.length > 0; } + /** + * What one author posted in a pod since `since`, newest first: content only, + * system rows excluded, at most `limit` rows. The handoff cue + * (agentMentionService) reads it to ask whether an agent recently + * @mentioned another, in which case a mention back is an answer rather + * than a new handoff. + */ + static async recentContentByAuthor(podId: unknown, userId: unknown, since: Date, limit = 20): Promise { + if (!podId || !userId) return []; + const result = await (pool as PgPool).query( + `SELECT content FROM messages + WHERE pod_id = $1 AND user_id = $2 AND created_at > $3 AND message_type != 'system' + ORDER BY id DESC LIMIT $4`, + [(podId as { toString(): string }).toString(), (userId as { toString(): string }).toString(), since, limit], + ); + return (result.rows as Array<{ content?: unknown }>).map((row) => String(row.content || '')); + } + static async update(id: string, content: string): Promise { const query = ` UPDATE messages diff --git a/backend/services/agentMentionService.ts b/backend/services/agentMentionService.ts index fb272f8cff..b093a601b6 100644 --- a/backend/services/agentMentionService.ts +++ b/backend/services/agentMentionService.ts @@ -23,7 +23,9 @@ const { maybeFireWelcomeWake } = require('./welcomeWakeService') as { maybeFireWelcomeWake: (opts: Record) => Promise; }; // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports -const { resolveAgentDisplayLabel } = require('./agentIdentityService') as { +const { resolveAgentDisplayLabel, buildAgentUsername } = require('./agentIdentityService') as { + // The bot username an agent is issued; also a key the mention map resolves. + buildAgentUsername: (agentType: string, instanceId: string) => string; resolveAgentDisplayLabel: ( user: { username?: string; botMetadata?: { displayName?: string; instanceId?: string; agentName?: string } } | null | undefined, fallback?: string, @@ -610,6 +612,23 @@ const formatPodContextFrame = (podId: string): string => `in a mentions filter the human pulls; nothing pushes. Say plainly what you ` + `need, and never treat a mention as an answer received.]`; +// An agent only wakes on an @mention. When one agent hands work to another +// and the second posts its result without @mentioning the first, the first +// never sees it, and a promised review silently drops (C14 call rehearsal, +// 2026-10-20). The handed-over agent is told, inline, to mention the one who +// handed it over. Only the first handoff in a chain is cued: a mention that +// answers a recent mention from its target is a result, and cueing it again +// is how "done @x" → "thanks @y" → … would loop. +const HANDOFF_ANSWER_WINDOW_MS = 30 * 60 * 1000; +const formatHandoffCue = (handle: string): string => `[@${handle} handed this to you. When it's done, post the ` + + `result and @mention @${handle}: an agent only wakes on an @mention, so without it they never see your result.]`; +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +// Whole-handle match: `@aria` in "thanks @aria." but not in "@aria-dev". +const mentionsHandle = (content: string, handle: string): boolean => new RegExp( + `(^|[^A-Za-z0-9_-])@${escapeRegExp(handle)}(?![A-Za-z0-9_-])`, + 'i', +).test(content); + // Cross-runtime consultation cue. Companion to the pod-context frame // above; same rationale (inline cue beats structured metadata per // ADR-012 §9 + pod-context-cue precedent + smoke 2026-05-20 evidence @@ -877,13 +896,15 @@ const isCollaborativePod = ( // unconditional and was missing from this table until the wake path was // routed through here — the table is the thing people read to decide // what a given event type carries, so it is kept exhaustive: -// chat.mention + non-specialist + collab → [Pod] [Author] [CollabPod] [Collab] [Reply] body -// chat.mention + non-specialist + solo → [Pod] [Author] [Collab] [Reply] body -// chat.mention + specialist → [Pod] [Author] [Reply] body -// thread.mention + non-specialist → [Pod] [Author] [Collab] body -// thread.mention + specialist → [Pod] [Author] body +// chat.mention + non-specialist + collab → [Pod] [Author] [CollabPod] [Collab] [Reply] [Handoff?] body +// chat.mention + non-specialist + solo → [Pod] [Author] [Collab] [Reply] [Handoff?] body +// chat.mention + specialist → [Pod] [Author] [Reply] [Handoff?] body +// thread.mention + non-specialist → [Pod] [Author] [Collab] [Handoff?] body +// thread.mention + specialist → [Pod] [Author] [Handoff?] body // message.posted + non-specialist → [Pod] [Author] [Collab] [Wake] body // message.posted + specialist → [Pod] [Author] [Wake] body +// [Handoff?] is present only when an agent hands work to another agent for +// the first time in a chain (formatHandoffCue); never on message.posted. const buildContentForTarget = ( podId: string, rawContent: string, @@ -895,6 +916,9 @@ const buildContentForTarget = ( // pass it today, so requiring it costs nothing now and turns a future // omission into a compile error instead of a silent "unknown". author: { username?: string; createdAt?: unknown; messageId?: string }, + // The handle of the agent that handed this over, when this mention is the + // first handoff in an agent-to-agent chain (see formatHandoffCue). + handoffFrom?: string, ): string => { const frames: string[] = [formatPodContextFrame(podId)]; // First after pod context, and unconditional: every event type and @@ -914,6 +938,9 @@ const buildContentForTarget = ( if (eventType === 'chat.mention') { frames.push(formatMentionReplyCue(podId)); } + if (handoffFrom && eventType !== 'message.posted') { + frames.push(formatHandoffCue(handoffFrom)); + } // Last before the body on purpose: this is the frame that tells the // agent why it is awake at all, and proximity to the body is the one // ordering lever we have. @@ -1638,6 +1665,61 @@ const enqueueMentions = async ({ return false; }; + // The handoff cue (formatHandoffCue): an agent handing work to another + // agent tells it to @mention the sender back with the result. + // + // The sender's handles are every key in this pod's mention map that + // resolves to the sender, so "did the target mention the sender" catches + // `@aria`, `@openclaw-aria` and a display-name slug alike. The cue names the + // canonical one (the bot username) when it resolves here. + const senderHandles = senderAgentName + ? Array.from(mentionMap.entries()) + .filter(([key, target]) => key !== 'default' + && target.agentName.toLowerCase() === senderAgentName + && (target.instanceId || 'default') === senderInstanceId) + .map(([key]) => key) + : []; + const senderCanonicalHandle = senderAgentName + ? buildAgentUsername(senderAgentName, senderInstanceId || 'default') + : ''; + const cueHandle = senderHandles.includes(senderCanonicalHandle) ? senderCanonicalHandle : senderHandles[0]; + + // Skipped when this mention answers one: the target @mentioned the sender + // in this pod within HANDOFF_ANSWER_WINDOW_MS, so the sender is reporting + // back, not handing over. That keeps the cue on the first handoff of a + // chain, so a chain ends once the result is back. A chain that keeps + // going anyway still meets the two guards that already bound bot-to-bot + // mentions: the #508 dampener above (more than MENTION_LOOP_MAX mentions of + // one bot in one pod within MENTION_LOOP_WINDOW_MS are dropped, every + // runtime) and the wrapper's cascade governor (cli/src/lib/enforcement.js, + // CASCADE_DEFAULTS). Any failure here means no cue, which is the behaviour + // before the cue existed. + const handoffFrom = async (target: MentionTarget): Promise => { + if (!cueHandle || isSelfMention(target)) return undefined; + try { + const targetUser = await User.findOne({ + username: buildAgentUsername(target.agentName, target.instanceId || 'default'), + isBot: true, + }).select('_id').lean() as { _id?: unknown } | null; + if (targetUser?._id) { + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + const PGMessage = require('../models/pg/Message') as { + recentContentByAuthor: (podId: unknown, userId: unknown, since: Date) => Promise; + }; + const recent = await PGMessage.recentContentByAuthor( + podId, targetUser._id, new Date(Date.now() - HANDOFF_ANSWER_WINDOW_MS), + ); + if (recent.some((content) => senderHandles.some((handle) => mentionsHandle(content, handle)))) { + return undefined; + } + } + return cueHandle; + } catch (error) { + console.warn('[handoff-cue] check failed (no cue):', (error as Error).message); + return undefined; + } + }; + await Promise.all( rawMentions.map(async (raw) => { const normalized = raw.toLowerCase(); @@ -1680,7 +1762,10 @@ const enqueueMentions = async ({ messageId: message?._id || message?.id ? String(message?._id || message?.id) : undefined, - content: buildContentForTarget(podId, rawContent, eventType, directMatch.agentName, collaborativePod, authorFrame), + content: buildContentForTarget( + podId, rawContent, eventType, directMatch.agentName, collaborativePod, authorFrame, + await handoffFrom(directMatch), + ), userId, username, mentions: rawMentions, @@ -1728,7 +1813,10 @@ const enqueueMentions = async ({ messageId: message?._id || message?.id ? String(message?._id || message?.id) : undefined, - content: buildContentForTarget(podId, rawContent, eventType, resolved.agentName, collaborativePod, authorFrame), + content: buildContentForTarget( + podId, rawContent, eventType, resolved.agentName, collaborativePod, authorFrame, + await handoffFrom({ agentName: resolved.agentName, instanceId: resolved.instanceId }), + ), userId, username, mentions: rawMentions, @@ -1799,7 +1887,10 @@ const enqueueMentions = async ({ messageId: message?._id || message?.id ? String(message?._id || message?.id) : undefined, - content: buildContentForTarget(podId, rawContent, eventType, agentType, collaborativePod, authorFrame), + content: buildContentForTarget( + podId, rawContent, eventType, agentType, collaborativePod, authorFrame, + await handoffFrom({ agentName: agentType, instanceId: match.instanceId || 'default' }), + ), userId, username, mentions: rawMentions, diff --git a/scripts/verify-moltbot-tool-contract.js b/scripts/verify-moltbot-tool-contract.js index ed50346ae5..32da19c2c0 100644 --- a/scripts/verify-moltbot-tool-contract.js +++ b/scripts/verify-moltbot-tool-contract.js @@ -120,6 +120,7 @@ const MENTION_CUES = [ 'formatCollaborativePodCue', 'formatConsultationCue', 'formatMentionReplyCue', + 'formatHandoffCue', ]; /**