diff --git a/server/src/__tests__/normalize-agent-mention-token.test.ts b/server/src/__tests__/normalize-agent-mention-token.test.ts new file mode 100644 index 00000000..b8a33d1d --- /dev/null +++ b/server/src/__tests__/normalize-agent-mention-token.test.ts @@ -0,0 +1,41 @@ +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 5ae7c0c3..a728cfe0 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -233,6 +233,41 @@ function unreadForUserCondition(companyId: string, userId: string) { `; } +/** 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, @@ -1517,11 +1552,13 @@ export function issueService(db: Db) { const re = /\B@([^\s@,!?.]+)/g; const tokens = new Set(); let m: RegExpExecArray | null; - while ((m = re.exec(body)) !== null) tokens.add(m[1].toLowerCase()); + 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 }) .from(agents).where(eq(agents.companyId, companyId)); const resolved = new Set(explicitAgentMentionIds);