Prevent self-directed agent messages
This commit is contained in:
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 },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user