Implement typed chat mention resolution
This commit is contained in:
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);
|
||||
|
||||
Reference in new issue
Block a user