diff --git a/AgentCompany-New/src/main/services/chat-service.ts b/AgentCompany-New/src/main/services/chat-service.ts index 8c6a9efd..fd7eabe9 100644 --- a/AgentCompany-New/src/main/services/chat-service.ts +++ b/AgentCompany-New/src/main/services/chat-service.ts @@ -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, + ): 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."); diff --git a/AgentCompany-New/tests/unit/chat-governance.test.ts b/AgentCompany-New/tests/unit/chat-governance.test.ts index ff1dbc3e..d44df1e6 100644 --- a/AgentCompany-New/tests/unit/chat-governance.test.ts +++ b/AgentCompany-New/tests/unit/chat-governance.test.ts @@ -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,