From bd63d42aa70687f6d5a3d6f2011d1ad9efedc63d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 26 Jun 2026 18:25:14 -0400 Subject: [PATCH] Extract slash-command routing out of ChatService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatService was 542 lines, mixing message delivery with the dispatch of nine slash commands. Move the command cluster — /board, /approve, /deny, /agent, /skill, /file, /runbook, /handoff, /escalate — into a ChatSlashRouter that owns the approval and board-intervention services it needs. ChatService keeps message delivery and delegates two calls. Behaviour identical: chat-service 376 lines, router 196, full suite and e2e green; the slash-routing governance test exercises every branch. --- .../src/main/services/chat-service.ts | 177 +--------------- .../src/main/services/chat-slash-router.ts | 196 ++++++++++++++++++ 2 files changed, 201 insertions(+), 172 deletions(-) create mode 100644 AgentCompany-New/src/main/services/chat-slash-router.ts diff --git a/AgentCompany-New/src/main/services/chat-service.ts b/AgentCompany-New/src/main/services/chat-service.ts index 66ee0a0c..7ae52bd5 100644 --- a/AgentCompany-New/src/main/services/chat-service.ts +++ b/AgentCompany-New/src/main/services/chat-service.ts @@ -1,29 +1,20 @@ 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, ROLE_KEY, } from "../../shared/domain"; import { createId } from "../../shared/ids"; -import { ApprovalService } from "./approval-service"; import { AuditService } from "./audit-service"; -import { BoardInterventionService } from "./board-intervention-service"; import { ALLOWED_SLASH_COMMANDS, - approvalRequester, createThreadTitle, - firstToken, - parseApprovalDecisionTarget, parseSlashInvocation, retentionPolicy, - spaceName, validateSlashInvocation, type FileAttachmentRow, type PrincipalRef, - type SlashInvocation, type SpaceRow, type ThreadRow, } from "./chat-service-internal"; @@ -33,6 +24,7 @@ import { ChatMessageMutationService } from "./chat-message-mutation-service"; import { recordChatMentions, resolveChatMentions, type ResolvedMention } from "./chat-mentions"; import { FileHandoffService, type FileHandoffAttachment } from "./file-handoff-service"; import { ChatAgentWakeService } from "./chat-agent-wake-service"; +import { ChatSlashRouter } from "./chat-slash-router"; const SPACE_TYPES = new Set(["company", "division", "department", "project", "pod", "ad_hoc", "direct"]); const SPACE_VISIBILITIES = new Set(["workspace", "restricted"]); @@ -49,21 +41,19 @@ const MAX_AUTONOMOUS_THREAD_AGENT_TURNS = 16; export class ChatService { private readonly audit: AuditService; - private readonly approvals: ApprovalService; - private readonly boardInterventions: BoardInterventionService; private readonly handoffs: FileHandoffService; private readonly messageMutations: ChatMessageMutationService; private readonly identity: IdentityAccessService; private readonly agentWake: ChatAgentWakeService; + private readonly slashRouter: ChatSlashRouter; constructor(private readonly db: DatabaseSync) { this.audit = new AuditService(db); - this.approvals = new ApprovalService(db); - this.boardInterventions = new BoardInterventionService(db); this.handoffs = new FileHandoffService(db); this.messageMutations = new ChatMessageMutationService(db); this.identity = new IdentityAccessService(db); this.agentWake = new ChatAgentWakeService(db); + this.slashRouter = new ChatSlashRouter(db, this.audit, this.agentWake); } createSpace(input: CreateChatSpaceInput): string { @@ -146,8 +136,8 @@ 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, attachments, slash, id, threadId, now) : null; - if (slash) this.recordSlashInvocation(id, slash, slashResultRef, now); + const slashResultRef = slash ? this.slashRouter.execute(input, space, sender, mentions, attachments, slash, id, threadId, now) : null; + if (slash) this.slashRouter.recordInvocation(id, slash, slashResultRef, now); if (!slash || slash.command !== "agent") { this.wakeAgentMentions(input, mentions, id, threadId, now); } @@ -214,163 +204,6 @@ export class ChatService { this.messageMutations.deleteMessage(input); } - private executeSlashCommand( - input: SendMessageInput, - space: SpaceRow, - sender: PrincipalRef | null, - mentions: ResolvedMention[], - attachments: FileHandoffAttachment[], - slash: SlashInvocation, - messageId: string, - threadId: string, - now: string, - ): string | null { - if (slash.command === "board") { - return this.boardInterventions.record({ - messageInput: input, - sender, - mentions, - args: slash.args, - messageId, - threadId, - spaceId: space.id, - now, - }); - } - if (slash.command === "approve" || slash.command === "deny") { - return this.decideApprovalFromSlash(input, sender, slash); - } - if (slash.command === "agent") return this.routeAgentSlash(input, mentions, slash, messageId, threadId, 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({ - 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, sourceEntityType: "chat_message", sourceEntityId: messageId }); - } - - private routeAgentSlash( - input: SendMessageInput, - mentions: ResolvedMention[], - slash: SlashInvocation, - messageId: string, - threadId: string, - now: string, - ): string { - const wake = this.agentWake.wakeFromSlash(input, mentions, slash, messageId, threadId, now); - this.recordSlashRoute(input, messageId, slash.command, wake.targetId, { - targetType: "agent", - targetId: wake.targetId, - taskId: wake.taskId, - runId: wake.runId, - }); - return wake.targetId; - } - - 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); - 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) - values (?, ?, ?, ?, ?, ?) - `).run(createId("slash"), messageId, slash.command, JSON.stringify({ raw: slash.args }), resultRef, now); - } - private wakeAgentMentions(input: SendMessageInput, mentions: ResolvedMention[], messageId: string, threadId: string, now: string): void { const agentMentions = mentions.filter( (m) => m.target.type === "agent" && !(input.senderType === "agent" && input.senderId === m.target.id), diff --git a/AgentCompany-New/src/main/services/chat-slash-router.ts b/AgentCompany-New/src/main/services/chat-slash-router.ts new file mode 100644 index 00000000..9d2eb8a4 --- /dev/null +++ b/AgentCompany-New/src/main/services/chat-slash-router.ts @@ -0,0 +1,196 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { SendMessageInput } from "../../shared/commands"; +import { APPROVAL_STATE, RISK_LEVEL } from "../../shared/domain"; +import { createId } from "../../shared/ids"; +import { ApprovalService } from "./approval-service"; +import type { AuditService } from "./audit-service"; +import { BoardInterventionService } from "./board-intervention-service"; +import type { ChatAgentWakeService } from "./chat-agent-wake-service"; +import { + approvalRequester, + firstToken, + parseApprovalDecisionTarget, + spaceName, + type PrincipalRef, + type SlashInvocation, + type SpaceRow, +} from "./chat-service-internal"; +import type { ResolvedMention } from "./chat-mentions"; +import type { FileHandoffAttachment } from "./file-handoff-service"; + +/** + * Routes the slash commands a chat message can carry — /board, /approve, + * /deny, /agent, /skill, /file, /runbook, /handoff, /escalate — to the right + * domain action and records the routing. Lifted out of ChatService so message + * delivery and command dispatch each stay a readable, single-purpose unit. + */ +export class ChatSlashRouter { + private readonly approvals: ApprovalService; + private readonly boardInterventions: BoardInterventionService; + + constructor( + private readonly db: DatabaseSync, + private readonly audit: AuditService, + private readonly agentWake: ChatAgentWakeService, + ) { + this.approvals = new ApprovalService(db); + this.boardInterventions = new BoardInterventionService(db); + } + + execute( + input: SendMessageInput, + space: SpaceRow, + sender: PrincipalRef | null, + mentions: ResolvedMention[], + attachments: FileHandoffAttachment[], + slash: SlashInvocation, + messageId: string, + threadId: string, + now: string, + ): string | null { + if (slash.command === "board") { + return this.boardInterventions.record({ + messageInput: input, + sender, + mentions, + args: slash.args, + messageId, + threadId, + spaceId: space.id, + now, + }); + } + if (slash.command === "approve" || slash.command === "deny") { + return this.decideApprovalFromSlash(input, sender, slash); + } + if (slash.command === "agent") return this.routeAgentSlash(input, mentions, slash, messageId, threadId, 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({ + 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, sourceEntityType: "chat_message", sourceEntityId: messageId }); + } + + recordInvocation(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 }), resultRef, now); + } + + private routeAgentSlash( + input: SendMessageInput, + mentions: ResolvedMention[], + slash: SlashInvocation, + messageId: string, + threadId: string, + now: string, + ): string { + const wake = this.agentWake.wakeFromSlash(input, mentions, slash, messageId, threadId, now); + this.recordSlashRoute(input, messageId, slash.command, wake.targetId, { + targetType: "agent", + targetId: wake.targetId, + taskId: wake.taskId, + runId: wake.runId, + }); + return wake.targetId; + } + + 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); + this.approvals.decide({ + orgId: input.orgId, + approvalId, + state: slash.command === "approve" ? APPROVAL_STATE.APPROVED : APPROVAL_STATE.DECLINED, + decidedByEmployeeId: sender.id, + }); + return approvalId; + } +}