Implement typed chat mention resolution

This commit is contained in:
ZacharyZhang-NY committed 2026-05-23 15:32:17 -04:00
1 parent df9317ef4b
commit 0b19a152e6
4 files changed
+234 -7

No files matched your search

@@ -2,6 +2,7 @@ import type { DatabaseSync } from "node:sqlite";
import type { CreateChatSpaceInput, MarkChatSpaceReadInput, SendMessageInput } from "../../shared/commands";
import { createId } from "../../shared/ids";
import { AuditService } from "./audit-service";
import { canonicalMentionHandle } from "./mention-handles";
const SPACE_TYPES = new Set<CreateChatSpaceInput["spaceType"]>(["company", "department", "project", "direct"]);
const SPACE_VISIBILITIES = new Set<CreateChatSpaceInput["visibility"]>(["workspace", "restricted"]);
@@ -23,6 +24,21 @@ interface PrincipalRef {
id: string;
}
interface MentionTarget {
type: "employee" | "agent" | "board" | "org_group";
id: string;
}
interface MentionCandidate extends MentionTarget {
handle: string;
}
interface ResolvedMention {
target: MentionTarget;
offsetStart: number;
offsetEnd: number;
}
export class ChatService {
private readonly audit: AuditService;
@@ -73,6 +89,7 @@ export class ChatService {
const slash = parseSlashInvocation(body);
if (slash && !ALLOWED_SLASH_COMMANDS.has(slash.command)) throw new Error(`Slash command /${slash.command} is unavailable.`);
const attachments = this.requireAttachments(input.orgId, input.attachmentFileIds ?? []);
const mentions = this.resolveMentions(input.orgId, input.spaceId, body);
const id = createId("msg");
const threadId = input.threadId ?? createId("thread");
@@ -101,7 +118,7 @@ export class ChatService {
} else {
this.db.prepare("update chat_threads set updated_at = ? where id = ? and org_id = ?").run(now, input.threadId, input.orgId);
}
this.recordMentions(id, body);
this.recordMentions(id, mentions);
if (slash) this.recordSlashInvocation(id, slash, now);
this.recordAttachments(id, attachments);
if (input.senderId) {
@@ -147,21 +164,42 @@ export class ChatService {
`).run(input.orgId, input.employeeId, input.spaceId, latest?.id ?? null, now);
}
private recordMentions(messageId: string, body: string): void {
private resolveMentions(orgId: string, spaceId: string, body: string): ResolvedMention[] {
const mentions: ResolvedMention[] = [];
const seen = new Set<string>();
for (const match of body.matchAll(/(^|\s)@([A-Za-z][A-Za-z0-9_-]{1,63})/g)) {
const target = match[2];
const token = match[2];
const target = this.resolveMention(orgId, spaceId, token);
const offsetStart = match.index + match[1].length;
const key = `${target}:${offsetStart}`;
const key = `${target.type}:${target.id}:${offsetStart}`;
if (seen.has(key)) continue;
seen.add(key);
mentions.push({ target, offsetStart, offsetEnd: offsetStart + token.length + 1 });
}
return mentions;
}
private recordMentions(messageId: string, mentions: ResolvedMention[]): void {
for (const mention of mentions) {
this.db.prepare(`
insert into chat_mentions(message_id, target_type, target_id, offset_start, offset_end)
values (?, ?, ?, ?, ?)
`).run(messageId, "alias", target, offsetStart, offsetStart + target.length + 1);
`).run(messageId, mention.target.type, mention.target.id, mention.offsetStart, mention.offsetEnd);
}
}
private resolveMention(orgId: string, spaceId: string, token: string): MentionTarget {
const handles = mentionLookupHandles(token);
if (handles.has("board")) return { type: "board", id: orgId };
if (handles.has("oncall")) return { type: "org_group", id: `${orgId}:oncall` };
const candidates = mentionCandidates(this.db, orgId, spaceId).filter((candidate) => handles.has(candidate.handle));
const unique = new Map(candidates.map((candidate) => [`${candidate.type}:${candidate.id}`, candidate]));
if (unique.size === 0) throw new Error(`Mention @${token} could not be resolved.`);
if (unique.size > 1) throw new Error(`Mention @${token} is ambiguous.`);
const target = [...unique.values()][0];
return { type: target.type, id: target.id };
}
private recordSlashInvocation(messageId: string, slash: SlashInvocation, now: string): void {
this.db.prepare(`
insert into chat_slash_invocations(id, message_id, command, args_json, result_ref, created_at)
@@ -284,3 +322,37 @@ function createThreadTitle(body: string): string {
const firstLine = body.split(/\r?\n/, 1)[0].trim();
return firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine;
}
function mentionCandidates(db: DatabaseSync, orgId: string, spaceId: string): MentionCandidate[] {
const employees = db.prepare(`
select employees.id, employees.display_name
from employees
join chat_memberships memberships
on memberships.principal_type = 'employee'
and memberships.principal_id = employees.id
and memberships.space_id = ?
where employees.org_id = ?
`).all(spaceId, orgId) as Array<{ id: string; display_name: string }>;
const agents = db.prepare(`
select agents.id, agents.name
from agents
join chat_memberships memberships
on memberships.principal_type = 'agent'
and memberships.principal_id = agents.id
and memberships.space_id = ?
where agents.org_id = ?
`).all(spaceId, orgId) as Array<{ id: string; name: string }>;
return [
...employees.flatMap((employee) => principalMentionHandles("employee", employee.id, employee.display_name)),
...agents.flatMap((agent) => principalMentionHandles("agent", agent.id, agent.name)),
];
}
function principalMentionHandles(type: "employee" | "agent", id: string, name: string): MentionCandidate[] {
const handles = new Set([canonicalMentionHandle(name), id.toLowerCase()].filter((handle) => handle.length > 0));
return [...handles].map((handle) => ({ type, id, handle }));
}
function mentionLookupHandles(token: string): Set<string> {
return new Set([token.toLowerCase(), canonicalMentionHandle(token)].filter((handle) => handle.length > 0));
}
@@ -0,0 +1,11 @@
export const RESERVED_MENTION_HANDLES = new Set(["board", "oncall"]);
export function canonicalMentionHandle(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
}
export function assertUsableMentionName(value: string): void {
if (RESERVED_MENTION_HANDLES.has(canonicalMentionHandle(value))) {
throw new Error("Mention handle is reserved.");
}
}
@@ -5,6 +5,7 @@ import { createId } from "../../shared/ids";
import type { CreateAgentInput, CreateOrganizationInput, CreateTaskInput } from "../../shared/commands";
import { AuditService } from "./audit-service";
import { PolicyService } from "./policy-service";
import { assertUsableMentionName } from "./mention-handles";
export class OrganizationService {
private readonly audit: AuditService;
@@ -16,6 +17,7 @@ export class OrganizationService {
}
createOrganization(input: CreateOrganizationInput): string {
assertUsableMentionName(input.employeeName);
const orgId = createId("org");
const employeeId = createId("emp");
const now = new Date().toISOString();
@@ -127,6 +129,7 @@ export class OrganizationService {
}
createAgent(input: CreateAgentInput): string {
assertUsableMentionName(input.name);
const id = createId("agt");
const now = new Date().toISOString();
this.db.prepare(`
@@ -237,9 +237,10 @@ describe("domain services", () => {
expect(state.chatAttachments).toHaveLength(1);
expect(state.chatPresence).toHaveLength(1);
const slash = connection.db.prepare("select command from chat_slash_invocations").get() as { command: string };
const mention = connection.db.prepare("select target_id from chat_mentions").get() as { target_id: string };
const mention = connection.db.prepare("select target_type, target_id from chat_mentions").get() as { target_type: string; target_id: string };
expect(slash.command).toBe("handoff");
expect(mention.target_id).toBe("Board");
expect(mention.target_type).toBe("board");
expect(mention.target_id).toBe(orgId);
connection.close();
});
@@ -297,6 +298,15 @@ describe("domain services", () => {
bodyMd: "I should not be here.",
attachmentFileIds: [],
}))).toThrow("Chat membership is required.");
expect(() => connection.transaction(() => chat.sendMessage({
orgId,
spaceId: restrictedSpaceId,
threadId: null,
senderType: "employee",
senderId: employeeId,
bodyMd: "Restricted ping @Ada",
attachmentFileIds: [],
}))).toThrow("Mention @Ada could not be resolved.");
expect(() => connection.transaction(() => chat.sendMessage({
orgId,
spaceId: workspaceSpaceId,
@@ -315,12 +325,143 @@ describe("domain services", () => {
bodyMd: "Missing file",
attachmentFileIds: ["file_missing"],
}))).toThrow("Attachment file was not found.");
expect(() => connection.transaction(() => chat.sendMessage({
orgId,
spaceId: workspaceSpaceId,
threadId: null,
senderType: "employee",
senderId: employeeId,
bodyMd: "Unknown @Nobody",
attachmentFileIds: [],
}))).toThrow("Mention @Nobody could not be resolved.");
const count = connection.db.prepare("select count(*) as count from chat_messages").get() as { count: number };
expect(count.count).toBe(0);
connection.close();
});
test("rejects reserved mention handles for employees and agents", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
let orgId = "";
expect(() => connection.transaction(() => organization.createOrganization({
organizationName: "Acme Operations",
employeeName: "Board",
mission: "Coordinate agents with auditable approvals.",
goal: "Ship a verified control plane.",
}))).toThrow("Mention handle is reserved.");
connection.transaction(() => {
orgId = organization.createOrganization({
organizationName: "Acme Operations",
employeeName: "Dana Lee",
mission: "Coordinate agents with auditable approvals.",
goal: "Ship a verified control plane.",
});
});
expect(() => connection.transaction(() => organization.createAgent({
orgId,
name: "On Call",
role: "Escalation lead",
department: "Operations",
modelProviderId: null,
modelId: null,
budgetCents: 10000,
managerAgentId: null,
}))).toThrow("Mention handle is reserved.");
connection.close();
});
test("rejects ambiguous chat mentions", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
const chat = new ChatService(connection.db);
let orgId = "";
let employeeId = "";
let spaceId = "";
connection.transaction(() => {
orgId = organization.createOrganization({
organizationName: "Acme Operations",
employeeName: "Dana Lee",
mission: "Coordinate agents with auditable approvals.",
goal: "Ship a verified control plane.",
});
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
organization.createAgent({ orgId, name: "Ada", role: "Reviewer", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 10000, managerAgentId: null });
organization.createAgent({ orgId, name: "Ada", role: "Release lead", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 10000, managerAgentId: null });
spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" });
});
expect(() => connection.transaction(() => chat.sendMessage({
orgId,
spaceId,
threadId: null,
senderType: "employee",
senderId: employeeId,
bodyMd: "Please review @Ada",
attachmentFileIds: [],
}))).toThrow("Mention @Ada is ambiguous.");
const count = connection.db.prepare("select count(*) as count from chat_messages").get() as { count: number };
expect(count.count).toBe(0);
connection.close();
});
test("resolves chat mentions by display handle and principal id", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
const chat = new ChatService(connection.db);
let orgId = "";
let employeeId = "";
let agentId = "";
let spaceId = "";
connection.transaction(() => {
orgId = organization.createOrganization({
organizationName: "Acme Operations",
employeeName: "Dana Lee",
mission: "Coordinate agents with auditable approvals.",
goal: "Ship a verified control plane.",
});
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
agentId = organization.createAgent({
orgId,
name: "Ada Lovelace",
role: "Reviewer",
department: "Engineering",
modelProviderId: null,
modelId: null,
budgetCents: 10000,
managerAgentId: null,
});
spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" });
});
connection.transaction(() => {
chat.sendMessage({
orgId,
spaceId,
threadId: null,
senderType: "employee",
senderId: employeeId,
bodyMd: `Review @Ada-Lovelace and @${agentId}`,
attachmentFileIds: [],
});
});
const mentions = connection.db.prepare(`
select target_type, target_id
from chat_mentions
order by offset_start asc
`).all() as Array<{ target_type: string; target_id: string }>;
expect(mentions).toEqual([
{ target_type: "agent", target_id: agentId },
{ target_type: "agent", target_id: agentId },
]);
connection.close();
});
test("rejects replies to threads from another chat space", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);