diff --git a/AgentCompany-New/src/main/services/chat-service.ts b/AgentCompany-New/src/main/services/chat-service.ts index 160a7301..b1be169a 100644 --- a/AgentCompany-New/src/main/services/chat-service.ts +++ b/AgentCompany-New/src/main/services/chat-service.ts @@ -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(["company", "department", "project", "direct"]); const SPACE_VISIBILITIES = new Set(["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(); 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 { + return new Set([token.toLowerCase(), canonicalMentionHandle(token)].filter((handle) => handle.length > 0)); +} diff --git a/AgentCompany-New/src/main/services/mention-handles.ts b/AgentCompany-New/src/main/services/mention-handles.ts new file mode 100644 index 00000000..2629c359 --- /dev/null +++ b/AgentCompany-New/src/main/services/mention-handles.ts @@ -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."); + } +} diff --git a/AgentCompany-New/src/main/services/organization-service.ts b/AgentCompany-New/src/main/services/organization-service.ts index 68c49eaf..358aa327 100644 --- a/AgentCompany-New/src/main/services/organization-service.ts +++ b/AgentCompany-New/src/main/services/organization-service.ts @@ -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(` diff --git a/AgentCompany-New/tests/unit/domain-services.test.ts b/AgentCompany-New/tests/unit/domain-services.test.ts index dac7f107..226714f1 100644 --- a/AgentCompany-New/tests/unit/domain-services.test.ts +++ b/AgentCompany-New/tests/unit/domain-services.test.ts @@ -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);