diff --git a/AgentCompany-New/src/main/services/approval-service.ts b/AgentCompany-New/src/main/services/approval-service.ts index 43b0166b..f211a942 100644 --- a/AgentCompany-New/src/main/services/approval-service.ts +++ b/AgentCompany-New/src/main/services/approval-service.ts @@ -4,6 +4,11 @@ import { APPROVAL_STATE } from "../../shared/domain"; import { createId } from "../../shared/ids"; import { AuditService } from "./audit-service"; +interface ApprovalAuditContext { + sourceMessageId?: string; + sourceThreadId?: string; +} + export class ApprovalService { private readonly audit: AuditService; @@ -11,8 +16,14 @@ export class ApprovalService { this.audit = new AuditService(db); } - create(input: CreateApprovalInput): string { + create(input: CreateApprovalInput, context: ApprovalAuditContext = {}): string { this.assertRequesterBelongsToOrg(input); + const kind = input.kind.trim(); + const title = input.title.trim(); + const details = input.details.trim(); + if (!kind) throw new Error("Approval kind is required."); + if (!title) throw new Error("Approval title is required."); + if (!details) throw new Error("Approval details are required."); const id = createId("app"); const now = new Date().toISOString(); this.db.prepare(` @@ -23,9 +34,9 @@ export class ApprovalService { `).run( id, input.orgId, - input.kind.trim(), - input.title.trim(), - input.details.trim(), + kind, + title, + details, input.risk, input.requestedByType, input.requestedById, @@ -41,7 +52,7 @@ export class ApprovalService { actorType: input.requestedByType, actorId: input.requestedById, action: "requested", - payload: { kind: input.kind, risk: input.risk }, + payload: { kind, risk: input.risk, ...context }, }); return id; } diff --git a/AgentCompany-New/src/main/services/chat-service.ts b/AgentCompany-New/src/main/services/chat-service.ts index b1be169a..8a89078e 100644 --- a/AgentCompany-New/src/main/services/chat-service.ts +++ b/AgentCompany-New/src/main/services/chat-service.ts @@ -1,6 +1,8 @@ import type { DatabaseSync } from "node:sqlite"; import type { CreateChatSpaceInput, MarkChatSpaceReadInput, SendMessageInput } from "../../shared/commands"; +import { RISK_LEVEL } from "../../shared/domain"; import { createId } from "../../shared/ids"; +import { ApprovalService } from "./approval-service"; import { AuditService } from "./audit-service"; import { canonicalMentionHandle } from "./mention-handles"; @@ -41,9 +43,11 @@ interface ResolvedMention { export class ChatService { private readonly audit: AuditService; + private readonly approvals: ApprovalService; constructor(private readonly db: DatabaseSync) { this.audit = new AuditService(db); + this.approvals = new ApprovalService(db); } createSpace(input: CreateChatSpaceInput): string { @@ -88,6 +92,7 @@ export class ChatService { if (input.threadId) this.requireThread(input.orgId, input.spaceId, input.threadId); const slash = parseSlashInvocation(body); if (slash && !ALLOWED_SLASH_COMMANDS.has(slash.command)) throw new Error(`Slash command /${slash.command} is unavailable.`); + if (slash) validateSlashInvocation(slash); const attachments = this.requireAttachments(input.orgId, input.attachmentFileIds ?? []); const mentions = this.resolveMentions(input.orgId, input.spaceId, body); @@ -119,15 +124,17 @@ export class ChatService { this.db.prepare("update chat_threads set updated_at = ? where id = ? and org_id = ?").run(now, input.threadId, input.orgId); } this.recordMentions(id, mentions); - if (slash) this.recordSlashInvocation(id, slash, now); + const slashResultRef = slash ? this.executeSlashCommand(input, space, sender, slash, id, threadId) : null; + if (slash) this.recordSlashInvocation(id, slash, slashResultRef, now); this.recordAttachments(id, attachments); if (input.senderId) { + const presenceStatus = slash?.command === "escalate" ? "waiting_approval" : "active"; 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(input.senderType, input.senderId, "active", "", now); + `).run(input.senderType, input.senderId, presenceStatus, "", now); } this.audit.record({ orgId: input.orgId, @@ -200,11 +207,32 @@ export class ChatService { return { type: target.type, id: target.id }; } - private recordSlashInvocation(messageId: string, slash: SlashInvocation, now: string): void { + private executeSlashCommand( + input: SendMessageInput, + space: SpaceRow, + sender: PrincipalRef | null, + slash: SlashInvocation, + messageId: string, + threadId: string, + ): string | null { + if (slash.command !== "escalate") return null; + const requester = approvalRequester(input.senderType, sender); + return this.approvals.create({ + orgId: input.orgId, + kind: "chat_escalation", + title: `Escalate ${spaceName(this.db, space.id)}`, + details: slash.args.trim(), + risk: RISK_LEVEL.HIGH, + requestedByType: requester.type, + requestedById: requester.id, + }, { sourceMessageId: messageId, sourceThreadId: threadId }); + } + + 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) values (?, ?, ?, ?, ?, ?) - `).run(createId("slash"), messageId, slash.command, JSON.stringify({ raw: slash.args }), null, now); + `).run(createId("slash"), messageId, slash.command, JSON.stringify({ raw: slash.args }), resultRef, now); } private recordAttachments(messageId: string, attachments: AttachmentCandidate[]): void { @@ -301,6 +329,11 @@ interface SlashInvocation { args: string; } +interface ApprovalRequester { + type: "employee" | "agent" | "system"; + id: string | null; +} + const ALLOWED_SLASH_COMMANDS = new Set([ "agent", "handoff", @@ -318,6 +351,25 @@ function parseSlashInvocation(body: string): SlashInvocation | null { return { command: match[1].toLowerCase(), args: match[2] ?? "" }; } +function validateSlashInvocation(slash: SlashInvocation): void { + if (slash.command === "escalate" && slash.args.trim().length === 0) { + throw new Error("Slash command /escalate requires details."); + } +} + +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."); + if (senderType === "agent") return { type: "agent", id: sender.id }; + return { type: "employee", id: sender.id }; +} + +function spaceName(db: DatabaseSync, spaceId: string): string { + const row = db.prepare("select name from chat_spaces where id = ?").get(spaceId) as { name: string } | undefined; + if (!row) throw new Error("Chat space was not found."); + return row.name; +} + function createThreadTitle(body: string): string { const firstLine = body.split(/\r?\n/, 1)[0].trim(); return firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine; diff --git a/AgentCompany-New/src/renderer/src/pages/ChatPage.tsx b/AgentCompany-New/src/renderer/src/pages/ChatPage.tsx index 1ad648a1..7166f8f7 100644 --- a/AgentCompany-New/src/renderer/src/pages/ChatPage.tsx +++ b/AgentCompany-New/src/renderer/src/pages/ChatPage.tsx @@ -130,6 +130,7 @@ export function ChatPage({ snapshot, actions }: PageProps) { const createSpace = (event: FormEvent) => { event.preventDefault(); const form = new FormData(event.currentTarget); + const existingSpaceIds = new Set(spaces.map((space) => space.id)); void actions.run(() => window.agentCompany.createChatSpace({ orgId, spaceType: value(form, "spaceType") as SpaceType, @@ -138,7 +139,14 @@ export function ChatPage({ snapshot, actions }: PageProps) { visibility: value(form, "visibility") as Visibility, memberEmployeeIds: [employee.id, ...form.getAll("memberEmployeeIds").map(String)].filter(Boolean), memberAgentIds: form.getAll("memberAgentIds").map(String).filter(Boolean), - })).then((result) => { if (result) setCreateOpen(false); }); + })).then((result) => { + if (!result) throw new Error("Chat space creation returned no snapshot."); + const created = result.chatSpaces.find((space) => space.orgId === orgId && !existingSpaceIds.has(space.id)); + if (!created) throw new Error("Created chat space is missing from snapshot."); + setSelectedSpaceId(created.id); + setOpenThreadId(null); + setCreateOpen(false); + }); }; const decideApproval = (approvalId: string, state: "approved" | "declined") => diff --git a/AgentCompany-New/tests/e2e/desktop.spec.ts b/AgentCompany-New/tests/e2e/desktop.spec.ts index 502f84ae..2a235872 100644 --- a/AgentCompany-New/tests/e2e/desktop.spec.ts +++ b/AgentCompany-New/tests/e2e/desktop.spec.ts @@ -57,10 +57,12 @@ test("onboards, hires, and creates work in the Electron shell", async () => { await page.getByLabel("Topic").fill("Release coordination"); await page.locator(".modal").getByRole("button", { name: "Create" }).click(); await expect(page.getByText("release").first()).toBeVisible(); - await page.getByRole("textbox", { name: "Message" }).fill("/handoff @Board review release plan"); + await page.getByRole("textbox", { name: "Message" }).fill("/escalate @Board review release plan"); await page.getByRole("button", { name: "Send" }).click(); await expect(page.getByText("@Board").first()).toBeVisible(); - await expect(page.getByText("/handoff").first()).toBeVisible(); + await expect(page.getByText("/escalate").first()).toBeVisible(); + await page.getByRole("button", { name: "Approvals" }).click(); + await expect(page.getByText("Escalate release").first()).toBeVisible(); await page.getByRole("button", { name: "Social" }).click(); await page.getByRole("button", { name: "Connect account" }).click(); diff --git a/AgentCompany-New/tests/unit/domain-services.test.ts b/AgentCompany-New/tests/unit/domain-services.test.ts index aca4cc3d..85616593 100644 --- a/AgentCompany-New/tests/unit/domain-services.test.ts +++ b/AgentCompany-New/tests/unit/domain-services.test.ts @@ -246,6 +246,55 @@ describe("domain services", () => { connection.close(); }); + test("creates approval and waiting presence from chat escalation", () => { + 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 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; + spaceId = chat.createSpace({ + orgId, + spaceType: "project", + name: "release", + topic: "Release coordination", + visibility: "workspace", + }); + chat.sendMessage({ + orgId, + spaceId, + threadId: null, + senderType: "employee", + senderId: employeeId, + bodyMd: "/escalate @Board freeze deployment", + attachmentFileIds: [], + }); + }); + + const state = snapshot.load(); + const approval = state.approvals.find((item) => item.kind === "chat_escalation"); + if (!approval) throw new Error("Chat escalation approval was not created."); + expect(approval.title).toBe("Escalate release"); + expect(approval.details).toBe("@Board freeze deployment"); + expect(approval.risk).toBe("high"); + expect(state.chatSlashInvocations[0]?.resultRef).toBe(approval.id); + expect(state.chatPresence[0]).toMatchObject({ principalId: employeeId, status: "waiting_approval" }); + const audit = state.auditEvents.find((event) => event.entityId === approval.id && event.action === "requested"); + if (!audit) throw new Error("Chat escalation audit event was not created."); + expect(audit.payloadJson).toContain(state.chatMessages[0]!.id); + connection.close(); + }); + test("rejects chat writes outside membership and prevents partial invalid messages", () => { const connection = createConnection(); const organization = new OrganizationService(connection.db); @@ -327,6 +376,15 @@ 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: "/escalate", + attachmentFileIds: [], + }))).toThrow("Slash command /escalate requires details."); expect(() => connection.transaction(() => chat.sendMessage({ orgId, spaceId: workspaceSpaceId,