mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
Harden structured agent mention dispatch
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
c0b0257334
commit
885e997ef6
3 files changed
+40
-94
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, string>> = {
|
||||
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<string>();
|
||||
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<string>(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 (
|
||||
|
||||
Reference in new issue
Block a user