Prevent self-directed agent messages

This commit is contained in:
ZacharyZhang-NY committed 2026-04-22 20:28:49 -04:00
1 parent 1a7eb8f1b3
commit a4af4a866a
6 files changed
+169

No files matched your search

@@ -155,6 +155,12 @@ function handleAgentNotification<K extends "notify_agent" | "send_message">({
);
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 },
+3
View File
@@ -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();
+7
View File
@@ -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",
@@ -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
+79
View File
@@ -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>): 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"]);
});
});
+72
View File
@@ -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);