Route collaboration slash commands

This commit is contained in:
ZacharyZhang-NY committed 2026-05-23 23:21:00 -04:00
1 parent 7c978c6a10
commit c43f29299f
2 files changed
+255 -1

No files matched your search

@@ -144,7 +144,7 @@ export class ChatService {
this.db.prepare("update chat_threads set updated_at = ? where id = ? and org_id = ?").run(now, input.threadId, input.orgId);
}
recordChatMentions(this.db, id, mentions);
const slashResultRef = slash ? this.executeSlashCommand(input, space, sender, mentions, slash, id, threadId, now) : null;
const slashResultRef = slash ? this.executeSlashCommand(input, space, sender, mentions, attachments, slash, id, threadId, now) : null;
if (slash) this.recordSlashInvocation(id, slash, slashResultRef, now);
this.handoffs.record({
orgId: input.orgId,
@@ -214,6 +214,7 @@ export class ChatService {
space: SpaceRow,
sender: PrincipalRef | null,
mentions: ResolvedMention[],
attachments: FileHandoffAttachment[],
slash: SlashInvocation,
messageId: string,
threadId: string,
@@ -234,6 +235,11 @@ export class ChatService {
if (slash.command === "approve" || slash.command === "deny") {
return this.decideApprovalFromSlash(input, sender, slash);
}
if (slash.command === "agent") return this.routeAgentSlash(input, mentions, slash, messageId, now);
if (slash.command === "skill") return this.routeSkillSlash(input, slash, messageId);
if (slash.command === "file") return this.routeFileSlash(input, attachments, slash, messageId);
if (slash.command === "runbook") return this.routeRunbookSlash(input, attachments, slash, messageId);
if (slash.command === "handoff") return this.routeHandoffSlash(input, attachments, messageId);
if (slash.command !== "escalate") return null;
const requester = approvalRequester(input.senderType, sender);
return this.approvals.create({
@@ -247,6 +253,101 @@ export class ChatService {
}, { sourceMessageId: messageId, sourceThreadId: threadId, sourceEntityType: "chat_message", sourceEntityId: messageId });
}
private routeAgentSlash(
input: SendMessageInput,
mentions: ResolvedMention[],
slash: SlashInvocation,
messageId: string,
now: string,
): string {
const target = mentions.find((mention) => mention.target.type === "agent")?.target;
if (!target || target.type !== "agent") throw new Error("Slash command /agent requires an agent mention.");
this.db.prepare(`
insert into chat_presence(principal_type, principal_id, status, status_text, last_seen_at)
values (?, ?, ?, ?, ?)
on conflict(principal_type, principal_id)
do update set status = excluded.status, status_text = excluded.status_text, last_seen_at = excluded.last_seen_at
`).run("agent", target.id, "thinking", slash.args.trim().slice(0, 160), now);
this.recordSlashRoute(input, messageId, slash.command, target.id, { targetType: "agent", targetId: target.id });
return target.id;
}
private routeSkillSlash(input: SendMessageInput, slash: SlashInvocation, messageId: string): string {
const target = firstToken(slash.args);
if (!target) throw new Error("Slash command /skill requires a skill slug.");
const row = this.db.prepare(`
select id from skill_definitions
where org_id = ?
and (lower(slug) = lower(?) or lower(name) = lower(?))
limit 1
`).get(input.orgId, target, target.replace(/-/g, " ")) as { id: string } | undefined;
if (!row) throw new Error("Slash command /skill target was not found.");
this.recordSlashRoute(input, messageId, slash.command, row.id, { skillId: row.id, target });
return row.id;
}
private routeFileSlash(
input: SendMessageInput,
attachments: FileHandoffAttachment[],
slash: SlashInvocation,
messageId: string,
): string {
const fileId = attachments[0]?.fileId ?? this.resolveFileSlashTarget(input.orgId, slash);
this.recordSlashRoute(input, messageId, slash.command, fileId, { fileId });
return fileId;
}
private routeRunbookSlash(
input: SendMessageInput,
attachments: FileHandoffAttachment[],
slash: SlashInvocation,
messageId: string,
): string {
if (!slash.args.trim() && attachments.length === 0) throw new Error("Slash command /runbook requires details or a file attachment.");
const resultRef = attachments[0]?.fileId ?? messageId;
this.recordSlashRoute(input, messageId, slash.command, resultRef, { resultRef, attachmentCount: attachments.length });
return resultRef;
}
private routeHandoffSlash(input: SendMessageInput, attachments: FileHandoffAttachment[], messageId: string): string {
const fileId = attachments[0]?.fileId;
if (!fileId) throw new Error("Slash command /handoff requires a file attachment.");
this.recordSlashRoute(input, messageId, "handoff", fileId, { fileId, attachmentCount: attachments.length });
return fileId;
}
private resolveFileSlashTarget(orgId: string, slash: SlashInvocation): string {
const target = firstToken(slash.args);
if (!target) throw new Error("Slash command /file requires a file attachment or file reference.");
const row = this.db.prepare(`
select id from file_objects
where org_id = ?
and (id = ? or name = ?)
order by created_at desc
limit 1
`).get(orgId, target, target) as { id: string } | undefined;
if (!row) throw new Error("Slash command /file target was not found.");
return row.id;
}
private recordSlashRoute(
input: SendMessageInput,
messageId: string,
command: string,
resultRef: string,
route: Record<string, unknown>,
): void {
this.audit.record({
orgId: input.orgId,
entityType: "chat_message",
entityId: messageId,
actorType: input.senderType,
actorId: input.senderId,
action: "slash_routed",
payload: { command, resultRef, route },
});
}
private decideApprovalFromSlash(input: SendMessageInput, sender: PrincipalRef | null, slash: SlashInvocation): string {
if (!sender || sender.type !== "employee") throw new Error("Approval decision requires employee sender.");
const approvalId = parseApprovalDecisionTarget(slash);
@@ -434,6 +535,10 @@ function parseApprovalDecisionTarget(slash: SlashInvocation): string {
return approvalId;
}
function firstToken(value: string): string {
return value.trim().split(/\s+/, 1)[0] ?? "";
}
function approvalRequester(senderType: SendMessageInput["senderType"], sender: PrincipalRef | null): ApprovalRequester {
if (senderType === "system") return { type: "system", id: null };
if (!sender) throw new Error("Approval requester is missing.");
@@ -215,6 +215,119 @@ describe("chat governance", () => {
connection.close();
});
test("routes collaboration slash commands to concrete domain targets", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
const chat = new ChatService(connection.db);
const snapshot = new SnapshotService(connection.db);
let orgId = "";
let employeeId = "";
let agentId = "";
let spaceId = "";
let skillId = "";
let fileId = "";
let agentMessageId = "";
let skillMessageId = "";
let fileMessageId = "";
let runbookMessageId = "";
let handoffMessageId = "";
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",
role: "Release reviewer",
department: "Engineering",
modelProviderId: null,
modelId: null,
budgetCents: 10000,
managerAgentId: null,
});
spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" });
skillId = "skd_release_gate";
fileId = "fil_release_notes";
const now = new Date().toISOString();
connection.db.prepare(`
insert into skill_definitions(id, org_id, slug, name, owner_type, status, created_at)
values (?, ?, ?, ?, ?, ?, ?)
`).run(skillId, orgId, "release-gate", "Release Gate", "system", "active", now);
connection.db.prepare(`
insert into file_objects(id, org_id, name, kind, size_bytes, sha256, storage_path, owner_type, owner_id, created_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(fileId, orgId, "release-notes.md", "md", 24, "8f3a", "artifacts/release-notes.md", "employee", employeeId, now);
agentMessageId = chat.sendMessage({
orgId,
spaceId,
threadId: null,
senderType: "employee",
senderId: employeeId,
bodyMd: "/agent @Ada review the gate",
attachmentFileIds: [],
});
skillMessageId = chat.sendMessage({
orgId,
spaceId,
threadId: null,
senderType: "employee",
senderId: employeeId,
bodyMd: "/skill release-gate apply release checks",
attachmentFileIds: [],
});
fileMessageId = chat.sendMessage({
orgId,
spaceId,
threadId: null,
senderType: "employee",
senderId: employeeId,
bodyMd: "/file release-notes.md",
attachmentFileIds: [],
});
runbookMessageId = chat.sendMessage({
orgId,
spaceId,
threadId: null,
senderType: "employee",
senderId: employeeId,
bodyMd: "/runbook release rollback steps",
attachmentFileIds: [],
});
handoffMessageId = chat.sendMessage({
orgId,
spaceId,
threadId: null,
senderType: "employee",
senderId: employeeId,
bodyMd: "/handoff @Ada release notes",
attachmentFileIds: [fileId],
});
});
const state = snapshot.load();
const slashByMessage = new Map(state.chatSlashInvocations.map((item) => [item.messageId, item]));
expect(slashByMessage.get(agentMessageId)).toMatchObject({ command: "agent", resultRef: agentId });
expect(slashByMessage.get(skillMessageId)).toMatchObject({ command: "skill", resultRef: skillId });
expect(slashByMessage.get(fileMessageId)).toMatchObject({ command: "file", resultRef: fileId });
expect(slashByMessage.get(runbookMessageId)).toMatchObject({ command: "runbook", resultRef: runbookMessageId });
expect(slashByMessage.get(handoffMessageId)).toMatchObject({ command: "handoff", resultRef: fileId });
expect(state.chatPresence).toContainEqual(expect.objectContaining({ principalType: "agent", principalId: agentId, status: "thinking" }));
expect(state.fileHandoffRecords).toContainEqual(expect.objectContaining({
messageId: handoffMessageId,
fileId,
targetPrincipalType: "agent",
targetPrincipalId: agentId,
policyDecision: "approved_for_handoff",
}));
expect(state.auditEvents.filter((event) => event.action === "slash_routed")).toHaveLength(5);
connection.close();
});
test("records board interventions and enforces thread control", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
@@ -441,6 +554,42 @@ describe("chat governance", () => {
bodyMd: "/approve",
attachmentFileIds: [],
}))).toThrow("Slash command /approve requires an approval id.");
expect(() => connection.transaction(() => chat.sendMessage({
orgId,
spaceId: workspaceSpaceId,
threadId: null,
senderType: "employee",
senderId: employeeId,
bodyMd: "/agent wake release checks",
attachmentFileIds: [],
}))).toThrow("Slash command /agent requires an agent mention.");
expect(() => connection.transaction(() => chat.sendMessage({
orgId,
spaceId: workspaceSpaceId,
threadId: null,
senderType: "employee",
senderId: employeeId,
bodyMd: "/handoff @Ada release notes",
attachmentFileIds: [],
}))).toThrow("Slash command /handoff requires a file attachment.");
expect(() => connection.transaction(() => chat.sendMessage({
orgId,
spaceId: workspaceSpaceId,
threadId: null,
senderType: "employee",
senderId: employeeId,
bodyMd: "/file missing.md",
attachmentFileIds: [],
}))).toThrow("Slash command /file target was not found.");
expect(() => connection.transaction(() => chat.sendMessage({
orgId,
spaceId: workspaceSpaceId,
threadId: null,
senderType: "employee",
senderId: employeeId,
bodyMd: "/skill missing-skill",
attachmentFileIds: [],
}))).toThrow("Slash command /skill target was not found.");
expect(() => connection.transaction(() => chat.sendMessage({
orgId,
spaceId: workspaceSpaceId,