From 885e997ef6d61710675926e64e49f503496b2d60 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 8 Jun 2026 21:14:45 +0000 Subject: [PATCH] Harden structured agent mention dispatch Co-Authored-By: Paperclip --- server/src/__tests__/issues-service.test.ts | 36 +++++++++++- .../normalize-agent-mention-token.test.ts | 41 ------------- server/src/services/issues.ts | 57 ++----------------- 3 files changed, 40 insertions(+), 94 deletions(-) delete mode 100644 server/src/__tests__/normalize-agent-mention-token.test.ts diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index a74b438c0f6..6569815cb63 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -36,7 +36,7 @@ import { ISSUE_LIST_MAX_LIMIT, issueService, } from "../services/issues.ts"; -import { buildProjectMentionHref, MAX_ISSUE_REQUEST_DEPTH } from "@paperclipai/shared"; +import { buildAgentMentionHref, buildProjectMentionHref, MAX_ISSUE_REQUEST_DEPTH } from "@paperclipai/shared"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -345,6 +345,40 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { }); }); + it("resolves only structured same-company agent mentions", async () => { + const companyId = await seedAssignableAgentCompany(); + const otherCompanyId = await seedAssignableAgentCompany(); + const localAgentId = randomUUID(); + const foreignAgentId = randomUUID(); + + await db.insert(agents).values([ + agentRow(companyId, { id: localAgentId, name: "LocalAgent" }), + agentRow(otherCompanyId, { id: foreignAgentId, name: "ForeignAgent" }), + ]); + + const mentions = await svc.findMentionedAgents( + companyId, + [ + `hello [@LocalAgent](${buildAgentMentionHref(localAgentId)})`, + `and [@ForeignAgent](${buildAgentMentionHref(foreignAgentId)})`, + ].join(" "), + ); + + expect(mentions).toEqual([localAgentId]); + }); + + it("does not wake agents from raw @name text without a structured mention", async () => { + const companyId = await seedAssignableAgentCompany(); + const localAgentId = randomUUID(); + + await db.insert(agents).values([ + agentRow(companyId, { id: localAgentId, name: "LocalAgent" }), + ]); + + await expect(svc.findMentionedAgents(companyId, "@LocalAgent please inspect this")) + .resolves.toEqual([]); + }); + it("returns issues an agent participated in across the supported signals", async () => { const companyId = randomUUID(); const agentId = randomUUID(); diff --git a/server/src/__tests__/normalize-agent-mention-token.test.ts b/server/src/__tests__/normalize-agent-mention-token.test.ts deleted file mode 100644 index b8a33d1d875..00000000000 --- a/server/src/__tests__/normalize-agent-mention-token.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { normalizeAgentMentionToken } from "../services/issues.ts"; - -describe("normalizeAgentMentionToken", () => { - it("decodes hex numeric entities such as space ( )", () => { - expect(normalizeAgentMentionToken("Baba ")).toBe("Baba"); - }); - - it("decodes decimal numeric entities", () => { - expect(normalizeAgentMentionToken("Baba ")).toBe("Baba"); - }); - - it("decodes common named whitespace entities", () => { - expect(normalizeAgentMentionToken("Baba ")).toBe("Baba"); - }); - - // Mid-token entity (review asked for this shape); we decode &→&, not strip to "Baba" (that broke M&M). - it("decodes a named entity in the middle of the token", () => { - expect(normalizeAgentMentionToken("Ba&ba")).toBe("Ba&ba"); - }); - - it("decodes & so agent names with ampersands still match", () => { - expect(normalizeAgentMentionToken("M&M")).toBe("M&M"); - }); - - it("decodes additional named entities used in rich text (e.g. ©)", () => { - expect(normalizeAgentMentionToken("Agent©Name")).toBe("Agent©Name"); - }); - - it("leaves unknown semicolon-terminated named references unchanged", () => { - expect(normalizeAgentMentionToken("Baba¬arealentity;")).toBe("Baba¬arealentity;"); - }); - - it("returns plain names unchanged", () => { - expect(normalizeAgentMentionToken("Baba")).toBe("Baba"); - }); - - it("trims after decoding entities", () => { - expect(normalizeAgentMentionToken("Baba ")).toBe("Baba"); - }); -}); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index e4c01818ee7..d422f4d9281 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1006,41 +1006,6 @@ function shouldIncludePluginOperationIssues(filters: IssueFilters | undefined) { ); } -/** Named entities commonly emitted in saved issue bodies; unknown `&name;` sequences are left unchanged. */ -const WELL_KNOWN_NAMED_HTML_ENTITIES: Readonly> = { - amp: "&", - apos: "'", - copy: "\u00A9", - gt: ">", - lt: "<", - nbsp: "\u00A0", - quot: '"', - ensp: "\u2002", - emsp: "\u2003", - thinsp: "\u2009", -}; - -function decodeNumericHtmlEntity(digits: string, radix: 16 | 10): string | null { - const n = Number.parseInt(digits, radix); - if (Number.isNaN(n) || n < 0 || n > 0x10ffff) return null; - try { - return String.fromCodePoint(n); - } catch { - return null; - } -} - -/** Decodes HTML character references in a raw @mention capture so UI-encoded bodies match agent names. */ -export function normalizeAgentMentionToken(raw: string): string { - let s = raw.replace(/&#x([0-9a-fA-F]+);/gi, (full, hex: string) => decodeNumericHtmlEntity(hex, 16) ?? full); - s = s.replace(/&#([0-9]+);/g, (full, dec: string) => decodeNumericHtmlEntity(dec, 10) ?? full); - s = s.replace(/&([a-z][a-z0-9]*);/gi, (full, name: string) => { - const decoded = WELL_KNOWN_NAMED_HTML_ENTITIES[name.toLowerCase()]; - return decoded !== undefined ? decoded : full; - }); - return s.trim(); -} - export function deriveIssueUserContext( issue: IssueUserContextInput, userId: string, @@ -6063,25 +6028,13 @@ export function issueService(db: Db) { }), findMentionedAgents: async (companyId: string, body: string) => { - const re = /\B@([^\s@,!?.]+)/g; - const tokens = new Set(); - let m: RegExpExecArray | null; - while ((m = re.exec(body)) !== null) { - const normalized = normalizeAgentMentionToken(m[1]); - if (normalized) tokens.add(normalized.toLowerCase()); - } - const explicitAgentMentionIds = extractAgentMentionIds(body); - if (tokens.size === 0 && explicitAgentMentionIds.length === 0) return []; - const rows = await db.select({ id: agents.id, name: agents.name }) + if (explicitAgentMentionIds.length === 0) return []; + + const rows = await db.select({ id: agents.id }) .from(agents).where(eq(agents.companyId, companyId)); - const resolved = new Set(explicitAgentMentionIds); - for (const agent of rows) { - if (tokens.has(agent.name.toLowerCase())) { - resolved.add(agent.id); - } - } - return [...resolved]; + const companyAgentIds = new Set(rows.map((agent) => agent.id)); + return explicitAgentMentionIds.filter((agentId) => companyAgentIds.has(agentId)); }, findMentionedProjectIds: async (