diff --git a/AgentCompany-New/src/main/services/chat-service.ts b/AgentCompany-New/src/main/services/chat-service.ts index 2a7d0822..8c6a9efd 100644 --- a/AgentCompany-New/src/main/services/chat-service.ts +++ b/AgentCompany-New/src/main/services/chat-service.ts @@ -1,6 +1,7 @@ import type { DatabaseSync } from "node:sqlite"; import type { CreateChatSpaceInput, DeleteMessageInput, EditMessageInput, MarkChatSpaceReadInput, SendMessageInput } from "../../shared/commands"; import { + APPROVAL_STATE, CHAT_THREAD_BOARD_STATE, FILE_SCAN_STATE, RISK_LEVEL, @@ -230,6 +231,9 @@ export class ChatService { now, }); } + if (slash.command === "approve" || slash.command === "deny") { + return this.decideApprovalFromSlash(input, sender, slash); + } if (slash.command !== "escalate") return null; const requester = approvalRequester(input.senderType, sender); return this.approvals.create({ @@ -243,6 +247,18 @@ export class ChatService { }, { sourceMessageId: messageId, sourceThreadId: threadId, sourceEntityType: "chat_message", sourceEntityId: messageId }); } + 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); + this.approvals.decide({ + orgId: input.orgId, + approvalId, + state: slash.command === "approve" ? APPROVAL_STATE.APPROVED : APPROVAL_STATE.DECLINED, + decidedByEmployeeId: sender.id, + }); + return approvalId; + } + private recordSlashInvocation(messageId: string, slash: SlashInvocation, resultRef: string | null, now: string): void { this.db.prepare(` insert into chat_slash_invocations(id, message_id, command, args_json, result_ref, created_at) @@ -408,9 +424,16 @@ function validateSlashInvocation(slash: SlashInvocation): void { if (slash.command === "escalate" && slash.args.trim().length === 0) { throw new Error("Slash command /escalate requires details."); } + if (slash.command === "approve" || slash.command === "deny") parseApprovalDecisionTarget(slash); if (slash.command === "board") parseBoardCommand(slash.args); } +function parseApprovalDecisionTarget(slash: SlashInvocation): string { + const approvalId = slash.args.trim().split(/\s+/, 1)[0] ?? ""; + if (!approvalId) throw new Error(`Slash command /${slash.command} requires an approval id.`); + return approvalId; +} + 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 1edf58b7..ff1dbc3e 100644 --- a/AgentCompany-New/tests/unit/chat-governance.test.ts +++ b/AgentCompany-New/tests/unit/chat-governance.test.ts @@ -3,9 +3,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; import { DatabaseConnection } from "../../src/main/database/connection"; +import { ApprovalService } from "../../src/main/services/approval-service"; import { ChatService } from "../../src/main/services/chat-service"; import { OrganizationService } from "../../src/main/services/organization-service"; import { SnapshotService } from "../../src/main/services/snapshot-service"; +import { RISK_LEVEL } from "../../src/shared/domain"; function createConnection(): DatabaseConnection { return new DatabaseConnection(join(tmpdir(), `agentcompany-new-chat-${randomUUID()}.db`)); @@ -140,6 +142,79 @@ describe("chat governance", () => { connection.close(); }); + test("decides approvals from chat slash commands", () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const approvals = new ApprovalService(connection.db); + const chat = new ChatService(connection.db); + const snapshot = new SnapshotService(connection.db); + let orgId = ""; + let employeeId = ""; + let spaceId = ""; + let approvalId = ""; + let declinedApprovalId = ""; + + 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; + spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" }); + approvalId = approvals.create({ + orgId, + kind: "release_gate", + title: "Release gate", + details: "Review the release window.", + risk: RISK_LEVEL.HIGH, + requestedByType: "employee", + requestedById: employeeId, + }); + declinedApprovalId = approvals.create({ + orgId, + kind: "release_gate", + title: "Rollback gate", + details: "Review rollback window.", + risk: RISK_LEVEL.HIGH, + requestedByType: "employee", + requestedById: employeeId, + }); + chat.sendMessage({ + orgId, + spaceId, + threadId: null, + senderType: "employee", + senderId: employeeId, + bodyMd: `/approve ${approvalId} release window cleared`, + attachmentFileIds: [], + }); + chat.sendMessage({ + orgId, + spaceId, + threadId: null, + senderType: "employee", + senderId: employeeId, + bodyMd: `/deny ${declinedApprovalId} rollback window blocked`, + attachmentFileIds: [], + }); + }); + + const state = snapshot.load(); + expect(state.approvals.find((item) => item.id === approvalId)?.state).toBe("approved"); + expect(state.approvals.find((item) => item.id === declinedApprovalId)?.state).toBe("declined"); + expect(state.chatSlashInvocations).toEqual(expect.arrayContaining([ + expect.objectContaining({ command: "approve", resultRef: approvalId }), + expect.objectContaining({ command: "deny", resultRef: declinedApprovalId }), + ])); + expect(state.auditEvents).toEqual(expect.arrayContaining([ + expect.objectContaining({ entityType: "approval", entityId: approvalId, action: "approved" }), + expect.objectContaining({ entityType: "approval", entityId: declinedApprovalId, action: "declined" }), + ])); + connection.close(); + }); + test("records board interventions and enforces thread control", () => { const connection = createConnection(); const organization = new OrganizationService(connection.db); @@ -357,6 +432,15 @@ describe("chat governance", () => { bodyMd: "/escalate", attachmentFileIds: [], }))).toThrow("Slash command /escalate requires details."); + expect(() => connection.transaction(() => chat.sendMessage({ + orgId, + spaceId: workspaceSpaceId, + threadId: null, + senderType: "employee", + senderId: employeeId, + bodyMd: "/approve", + attachmentFileIds: [], + }))).toThrow("Slash command /approve requires an approval id."); expect(() => connection.transaction(() => chat.sendMessage({ orgId, spaceId: workspaceSpaceId,