diff --git a/backend/__tests__/unit/services/agentMentionService.test.js b/backend/__tests__/unit/services/agentMentionService.test.js index 679da64726..b006766fba 100644 --- a/backend/__tests__/unit/services/agentMentionService.test.js +++ b/backend/__tests__/unit/services/agentMentionService.test.js @@ -754,6 +754,72 @@ describe('AgentMentionService', () => { }); }); + // Humans are addressed by handle, and only by handle (TASK-070a). + // + // Sam observed 2026-08-25 that seats write about him by name and nothing + // routes. The frame taught three verbs, all of which move attention + // between AGENTS; none of them reach a person, and the paragraph never + // said so. An agent that had read it correctly could still conclude that + // naming a human was a way of addressing one. + // + // The control matters more than usual here. This frame already contains + // the word "human" twice (the token-misattribution clause) and the + // literal "@" many times, so a loose assertion stays green on a frame + // that has lost the routing rule entirely. + describe('human-handle cue', () => { + const frame = async () => { + setupForAgent({ agentName: 'openclaw', instanceId: 'nova', displayName: 'Nova' }); + await AgentMentionService.enqueueMentions({ + podId: 'pod-human-1', + message: { content: 'Hi @nova', id: 'msg-human-1' }, + userId: 'user-1', + username: 'sam', + }); + return lastPayload().payload.content; + }; + + test('tells the agent to @mention a handle when it needs a human', async () => { + expect(await frame()).toContain('@mention their handle'); + }); + + test('states that a bare name reaches no one', async () => { + // The failure is silent — nothing errors, the message posts, and no + // attention routes — so the cue has to name the outcome, not just + // prescribe the handle. Without this an agent reads the rule as a + // style preference it may skip when the name reads more naturally. + const content = await frame(); + expect(content).toContain('A bare name reaches no one'); + expect(content).toContain('matched on the literal @handle'); + }); + + test('states the handle is necessary and not sufficient', async () => { + // Without this the cue teaches, by contrast with "reaches no one", + // that the handle DOES notify. It does not: humans get no AgentEvent + // row, so the ceiling is a pull surface. An agent that believes it + // has notified Sam stops working and waits — a new false model, and + // harder to spot than the old one because the message now looks + // correctly addressed. + const content = await frame(); + expect(content).toContain('necessary and not sufficient'); + expect(content).toContain('nothing pushes'); + }); + + test('control: the pre-TASK-070 frame does not satisfy the assertions above', () => { + // Verbatim from the clauses that shipped before this change, and they + // are the ones most likely to keep a sloppy assertion green: both + // mention humans, and the second is entirely about @handles. + const beforeTaskO70 = 'Never post through an operator\'s CLI profile (`commonly pod send`) ' + + 'or a human user\'s token — that misattributes your words to a human. ' + + 'replyToMessageId quotes and ADDRESSES a message — its author is pinged. ' + + 'Rule of thumb: if your message answers one person, reply.'; + expect(beforeTaskO70).not.toContain('@mention their handle'); + expect(beforeTaskO70).not.toContain('A bare name reaches no one'); + expect(beforeTaskO70).not.toContain('matched on the literal @handle'); + expect(beforeTaskO70).not.toContain('necessary and not sufficient'); + expect(beforeTaskO70).not.toContain('nothing pushes'); + }); + }); + // The overflow rule, and the qualifier without which it is harmful. // // @sprint-review (57706) traced it to `effectiveFollowerIds`, whose diff --git a/backend/services/agentMentionService.ts b/backend/services/agentMentionService.ts index 5995269af0..927faee881 100644 --- a/backend/services/agentMentionService.ts +++ b/backend/services/agentMentionService.ts @@ -554,7 +554,42 @@ const formatPodContextFrame = (podId: string): string => // below for the hole it left open. `Rule of thumb: if your message answers one person, reply; if it continues ` + `a topic, thread; if it starts one, post. A reply inside a thread is allowed ` + - `and still addresses its author.]`; + `and still addresses its author. ` + + // Humans are addressed by handle, and ONLY by handle (TASK-070a, Sam + // observed 2026-08-25). Every verb above routes attention between agents; + // none of them reach a person. A human's attention is matched on the + // literal `@handle` — activityService's `mentions` filter tests + // `content.includes('@' + username)`, and resolveHumanMentionUserIds + // extracts handles from `[a-z0-9_-]` after an `@`. A bare name matches + // neither, so "Sam should decide this" is addressed to nobody. + // + // This is the human-facing twin of the gap ADR-018 D6.3 closed for bots: a + // message that is plainly ABOUT someone still has to be addressed TO them + // before anything routes. The bot version was a missing implicit-reply + // wake; this one is a missing handle, and only the author can supply it. + // + // Deliberately teaches the escape and not a heuristic. Whether a bare name + // SHOULD route is an open decision (TASK-070b) precisely because matching + // names is fuzzy — every message about Sam is not for Sam — so the cue + // must not imply that writing the name is enough. + // + // The handle is NECESSARY, not sufficient, and the cue has to say both or + // it installs a fresh false model in place of the old one (@sprint-review + // on #1244). Humans have no AgentEvent delivery row — `enqueueMentions` + // never enqueues for a person — so an @handle buys the `isMention` flag on + // the activity feed and nothing else. Even the thread-follow half is + // narrower than it reads: `resolveHumanMentionUserIds` is called only + // inside `if (threadRootId)` (:1743), so a plain channel post gets the flag + // alone. That surface is PULL — ADR-017's only-interrupter rule reserves + // push for the escalation envelope — so "I mentioned them" is never "they + // know", and an agent that stops there has blocked itself on a filter + // nobody may have opened. + `When you need a HUMAN — a decision, a merge press, an answer only they ` + + `have — @mention their handle. A bare name reaches no one: human attention ` + + `is matched on the literal @handle, so "Sam should decide this" is addressed ` + + `to nobody. The handle is necessary and not sufficient — it flags the message ` + + `in a mentions filter the human pulls; nothing pushes. Say plainly what you ` + + `need, and never treat a mention as an answer received.]`; // Cross-runtime consultation cue. Companion to the pod-context frame // above; same rationale (inline cue beats structured metadata per