From a4af4a866a55586bb0d88448c9d81b62f2454d16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 22 Apr 2026 20:28:49 -0400 Subject: [PATCH] Prevent self-directed agent messages --- .../automation/automation-action-handlers.ts | 6 ++ src/main/database/messages.ts | 3 + src/main/messaging/dispatch.ts | 7 ++ .../components/communication/channel-utils.ts | 2 + tests/unit/channel-utils.test.ts | 79 +++++++++++++++++++ tests/unit/message-dispatch.test.ts | 72 +++++++++++++++++ 6 files changed, 169 insertions(+) create mode 100644 tests/unit/channel-utils.test.ts diff --git a/src/main/automation/automation-action-handlers.ts b/src/main/automation/automation-action-handlers.ts index 7c6eb76c..47b173b3 100644 --- a/src/main/automation/automation-action-handlers.ts +++ b/src/main/automation/automation-action-handlers.ts @@ -155,6 +155,12 @@ function handleAgentNotification({ ); return; } + if (channel === "direct" && toAgentId === fromAgentId) { + logger.info( + `[automation] Skipping rule "${rule.name}" - direct messages require two distinct participants`, + ); + return; + } const dispatchResult = dispatchAgentMessage( { db, emitEvent, publishDomainChanged, wakeAgentIfPossible }, diff --git a/src/main/database/messages.ts b/src/main/database/messages.ts index fb624c08..73243bcb 100644 --- a/src/main/database/messages.ts +++ b/src/main/database/messages.ts @@ -61,6 +61,9 @@ export function buildMessageMethods( if (channel === "direct" && !input.toAgentId) { throw new Error("Direct messages require a recipient."); } + if (channel === "direct" && input.toAgentId === input.fromAgentId) { + throw new Error("Direct messages require two distinct participants."); + } const senderKind = input.senderKind ?? (input.fromAgentId === BOARD_READER_ID ? "human" : "agent"); const id = randomUUID(); diff --git a/src/main/messaging/dispatch.ts b/src/main/messaging/dispatch.ts index 025aeadb..e637d8ed 100644 --- a/src/main/messaging/dispatch.ts +++ b/src/main/messaging/dispatch.ts @@ -53,6 +53,7 @@ export interface NormalizedAgentMessageInput { export interface MessageValidationError { code: | "DIRECT_MESSAGE_RECIPIENT_REQUIRED" + | "SELF_DIRECT_MESSAGE_FORBIDDEN" | "NON_DIRECT_MESSAGE_RECIPIENT_FORBIDDEN" | "INVALID_COMPANY_REFERENCE" | "CHANNEL_TARGET_REQUIRED" @@ -147,6 +148,12 @@ export function validateAgentMessageInput( message: "Direct messages require a recipient agent.", }; } + if (input.channel === "direct" && input.toAgentId === input.fromAgentId) { + return { + code: "SELF_DIRECT_MESSAGE_FORBIDDEN", + message: "Direct messages require two distinct participants.", + }; + } if (input.channel !== "direct" && input.toAgentId) { return { code: "NON_DIRECT_MESSAGE_RECIPIENT_FORBIDDEN", diff --git a/src/renderer/components/communication/channel-utils.ts b/src/renderer/components/communication/channel-utils.ts index 208d605f..19c21935 100644 --- a/src/renderer/components/communication/channel-utils.ts +++ b/src/renderer/components/communication/channel-utils.ts @@ -64,6 +64,7 @@ export function deriveChannels(messages: AgentMessageRecord[], agents: AgentReco switch (msg.channel) { case "direct": { const pair = resolveDirectThreadPair(msg, byId) + if (pair && pair[0] === pair[1]) continue if (!pair) { key = "direct-broadcast"; label = "Broadcast DMs" } else { key = `direct-${pair.join("-")}` @@ -114,6 +115,7 @@ export function getChannelMessages(messages: AgentMessageRecord[], channel: Chan if (!msg.toAgentId || channel.participantIds.length < 2) return false const pair = resolveDirectThreadPair(msg, byId) if (!pair) return false + if (pair[0] === pair[1]) return false return pair[0] === channel.participantIds[0] && pair[1] === channel.participantIds[1] } return false diff --git a/tests/unit/channel-utils.test.ts b/tests/unit/channel-utils.test.ts new file mode 100644 index 00000000..ade2d3e5 --- /dev/null +++ b/tests/unit/channel-utils.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import type { AgentMessageRecord, AgentRecord } from "../../src/shared/types"; +import { deriveChannels, getChannelMessages } from "../../src/renderer/components/communication/channel-utils"; + +function agent(id: string, name: string): AgentRecord { + return { + id, + companyId: "company-1", + name, + role: name, + title: name, + department: null, + status: "active", + reportsTo: null, + connectorId: "openai-codex", + workspaceId: null, + model: null, + capabilities: "", + budgetMonthlyUsd: 0, + spentMonthlyUsd: 0, + metadataJson: "{}", + heartbeatEnabled: false, + heartbeatIntervalSec: 300, + lastHeartbeatAt: null, + createdAt: "2026-04-22T00:00:00.000Z", + updatedAt: "2026-04-22T00:00:00.000Z", + }; +} + +function message(input: Partial): AgentMessageRecord { + return { + id: input.id ?? "msg-1", + companyId: "company-1", + fromAgentId: input.fromAgentId ?? "agent-ceo", + toAgentId: input.toAgentId ?? "agent-cto", + senderKind: input.senderKind ?? "agent", + channel: input.channel ?? "direct", + channelTargetId: input.channelTargetId ?? null, + subject: input.subject ?? "Subject", + body: input.body ?? "Body", + priority: input.priority ?? "normal", + parentMessageId: input.parentMessageId ?? null, + attachmentsJson: input.attachmentsJson ?? "[]", + createdAt: input.createdAt ?? "2026-04-22T00:00:00.000Z", + readAt: input.readAt ?? null, + }; +} + +describe("communication channel utilities", () => { + it("drops legacy direct messages whose sender and recipient are the same agent", () => { + const ceo = agent("agent-ceo", "CEO"); + const selfMessage = message({ + id: "self-direct", + fromAgentId: ceo.id, + toAgentId: ceo.id, + }); + + const channels = deriveChannels([selfMessage], [ceo]); + + expect(channels.map((channel) => channel.label)).not.toContain("CEO ↔ CEO"); + expect(channels.some((channel) => channel.kind === "message" && channel.channel === "direct")).toBe(false); + }); + + it("keeps normal direct conversations visible", () => { + const ceo = agent("agent-ceo", "CEO"); + const cto = agent("agent-cto", "CTO"); + const direct = message({ + id: "ceo-cto", + fromAgentId: ceo.id, + toAgentId: cto.id, + }); + + const channels = deriveChannels([direct], [ceo, cto]); + const dm = channels.find((channel) => channel.kind === "message" && channel.channel === "direct"); + + expect(dm?.label).toBe("CEO ↔ CTO"); + expect(dm ? getChannelMessages([direct], dm).map((entry) => entry.id) : []).toEqual(["ceo-cto"]); + }); +}); diff --git a/tests/unit/message-dispatch.test.ts b/tests/unit/message-dispatch.test.ts index c2a36691..ad85c21c 100644 --- a/tests/unit/message-dispatch.test.ts +++ b/tests/unit/message-dispatch.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { AppDatabase } from "../../src/main/database"; import { createThreadIntegration } from "../../src/main/thread-integration"; import { dispatchAgentMessage, HUMAN_SENDER_ID } from "../../src/main/messaging/dispatch"; +import { executeAutomationAction } from "../../src/main/automation/automation-actions"; const tempDirs: string[] = []; @@ -270,6 +271,77 @@ describe("message dispatch", () => { db.close(); }); + it("rejects direct messages addressed to the sender", async () => { + const profileDir = await createTempDir("agentcompany-message-self-direct-"); + const db = new AppDatabase(profileDir); + db.init(); + + const { companyId, ceoId } = createScaffold(db); + + const result = dispatchAgentMessage({ + db, + }, { + companyId, + fromAgentId: ceoId, + channel: "direct", + toAgentId: ceoId, + subject: "self", + body: "This should not create a CEO to CEO channel.", + }); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected self-DM dispatch to fail."); + expect(result.error.code).toBe("SELF_DIRECT_MESSAGE_FORBIDDEN"); + expect(() => db.core.messages.sendAgentMessage({ + companyId, + fromAgentId: ceoId, + toAgentId: ceoId, + subject: "self", + body: "Direct DB writers share the same invariant.", + })).toThrow("Direct messages require two distinct participants."); + + db.close(); + }); + + it("skips automation notifications that would address the sender", async () => { + const profileDir = await createTempDir("agentcompany-automation-self-direct-"); + const db = new AppDatabase(profileDir); + db.init(); + + const { companyId, ceoId } = createScaffold(db); + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + + executeAutomationAction( + { + db, + logger, + emitEvent: vi.fn(), + publishDomainChanged: vi.fn(), + wakeAgentIfPossible: vi.fn(), + }, + { + handleTaskCreated: vi.fn(), + handleApprovalResolved: vi.fn(), + handleTaskStatusChange: vi.fn(), + }, + { + id: "rule-self", + name: "Self notification", + action: "notify_agent", + actionConfigJson: "{}", + targetDepartment: null, + }, + { companyId, agentId: ceoId }, + ); + + expect(db.core.messages.listAgentMessages(companyId, { channel: "direct" })).toHaveLength(0); + expect(logger.info).toHaveBeenCalledWith( + "[automation] Skipping rule \"Self notification\" - direct messages require two distinct participants", + ); + + db.close(); + }); + it("resolves only user_reply wait states for company-wide human messages, but scoped replies resolve thread wait states", async () => { const profileDir = await createTempDir("agentcompany-message-wait-"); const db = new AppDatabase(profileDir);