Give every 1:1 its own direct conversation instead of piling into #general
Agents could only post to named public channels - the messaging tool even excluded direct spaces - so assignments, reports, and questions all crowded into general. Now ChatService.ensureDirectSpace finds or creates the private two-member conversation for any agent/employee pair; send_message takes to="Name" for 1:1 traffic (exactly one of to/channel), read_messages reads a conversation by teammate name, and task assignments land in the manager's DM with the assignee. In a direct conversation the counterpart agent wakes on every message without needing an @mention - same social contract as any messenger - still bounded by the autonomous-turn budget, and wake task briefings tell agents to reply with to= in kind. Covered by six new direct-message contract tests; the assignment-thread test now encodes the DM routing contract.
This commit is contained in:
1 parent
f13ef48a3e
commit
cab7b01831
6 files changed
+375
-51
No files matched your search
@@ -0,0 +1,158 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { ChatService } from "../../src/main/services/chat-service";
|
||||
import { dispatchAgentTool, type AgentToolContext } from "../../src/main/services/agent-tools";
|
||||
|
||||
function call(name: string, args: Record<string, unknown>) {
|
||||
return { id: `call_${randomUUID()}`, name, arguments: args };
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
connection: DatabaseConnection;
|
||||
organization: OrganizationService;
|
||||
chat: ChatService;
|
||||
orgId: string;
|
||||
employeeId: string;
|
||||
ceoId: string;
|
||||
danaId: string;
|
||||
ctx: AgentToolContext;
|
||||
}
|
||||
|
||||
function harness(options: { enableProvider: boolean }): Harness {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-dm-${randomUUID()}.db`));
|
||||
const organization = new OrganizationService(connection.db);
|
||||
let orgId = "";
|
||||
let ceoId = "";
|
||||
let danaId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({ organizationName: "OrbitOps", employeeName: "Yang", mission: "m", goal: "g" });
|
||||
const providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id;
|
||||
if (options.enableProvider) {
|
||||
connection.db.prepare("update providers set enabled = 1 where id = ? and org_id = ?").run(providerId, orgId);
|
||||
}
|
||||
ceoId = organization.createAgent({
|
||||
orgId, name: "CEO", role: "CEO", department: "Executive",
|
||||
modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 25000, managerAgentId: null,
|
||||
});
|
||||
danaId = organization.createAgent({
|
||||
orgId, name: "Dana", role: "Engineer", department: "Engineering",
|
||||
modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 1000, managerAgentId: ceoId,
|
||||
});
|
||||
});
|
||||
const employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
const chat = new ChatService(connection.db);
|
||||
return {
|
||||
connection,
|
||||
organization,
|
||||
chat,
|
||||
orgId,
|
||||
employeeId,
|
||||
ceoId,
|
||||
danaId,
|
||||
ctx: {
|
||||
orgId,
|
||||
agent: { id: ceoId, modelProviderId: null, modelId: null },
|
||||
taskProjectId: null,
|
||||
taskGoalId: null,
|
||||
workspace: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function directSpaces(h: Harness): Array<{ id: string }> {
|
||||
return h.connection.db.prepare("select id from chat_spaces where org_id = ? and space_type = 'direct'")
|
||||
.all(h.orgId) as Array<{ id: string }>;
|
||||
}
|
||||
|
||||
describe("agent direct messages", () => {
|
||||
test("a DM gets its own conversation and later messages reuse it", () => {
|
||||
const h = harness({ enableProvider: false });
|
||||
const deps = { db: h.connection.db, organization: h.organization, chat: h.chat };
|
||||
|
||||
const first = dispatchAgentTool(deps, h.ctx, call("send_message", { to: "Dana", body: "How is the booking API going?" }));
|
||||
expect(first).toContain("direct message to Dana");
|
||||
const threadId = /thread_id="([^"]+)"/.exec(first)?.[1];
|
||||
expect(threadId).toBeTruthy();
|
||||
|
||||
dispatchAgentTool(deps, h.ctx, call("send_message", { to: "Dana", body: "Also, ping me when the spec lands.", thread_id: threadId }));
|
||||
|
||||
const spaces = directSpaces(h);
|
||||
expect(spaces).toHaveLength(1);
|
||||
const members = h.connection.db.prepare("select principal_type, principal_id from chat_memberships where space_id = ?")
|
||||
.all(spaces[0].id) as Array<{ principal_type: string; principal_id: string }>;
|
||||
expect(members).toHaveLength(2);
|
||||
expect(members.map((m) => m.principal_id).sort()).toEqual([h.ceoId, h.danaId].sort());
|
||||
|
||||
const messages = h.connection.db.prepare("select space_id from chat_messages where org_id = ?").all(h.orgId) as Array<{ space_id: string }>;
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages.every((m) => m.space_id === spaces[0].id)).toBe(true);
|
||||
});
|
||||
|
||||
test("a direct message wakes the counterpart agent without an @mention", () => {
|
||||
const h = harness({ enableProvider: true });
|
||||
const deps = { db: h.connection.db, organization: h.organization, chat: h.chat };
|
||||
|
||||
dispatchAgentTool(deps, h.ctx, call("send_message", { to: "Dana", body: "Take a look at the failing deploy when you can." }));
|
||||
|
||||
const runs = h.connection.db.prepare(
|
||||
"select count(*) as count from task_runs where org_id = ? and agent_id = ? and trigger = 'chat_mention'",
|
||||
).get(h.orgId, h.danaId) as { count: number };
|
||||
expect(runs.count).toBe(1);
|
||||
});
|
||||
|
||||
test("an employee DM wakes the agent like any messenger would", () => {
|
||||
const h = harness({ enableProvider: true });
|
||||
const spaceId = h.chat.ensureDirectSpace(h.orgId, { type: "employee", id: h.employeeId }, { type: "agent", id: h.danaId });
|
||||
h.chat.sendMessage({
|
||||
orgId: h.orgId,
|
||||
spaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: h.employeeId,
|
||||
bodyMd: "Dana, what is the launch status?",
|
||||
attachmentFileIds: [],
|
||||
});
|
||||
const runs = h.connection.db.prepare(
|
||||
"select count(*) as count from task_runs where org_id = ? and agent_id = ? and trigger = 'chat_mention'",
|
||||
).get(h.orgId, h.danaId) as { count: number };
|
||||
expect(runs.count).toBe(1);
|
||||
});
|
||||
|
||||
test("task assignments go to the manager's direct conversation with the assignee", () => {
|
||||
const h = harness({ enableProvider: true });
|
||||
const deps = { db: h.connection.db, organization: h.organization, chat: h.chat };
|
||||
|
||||
const result = dispatchAgentTool(deps, h.ctx, call("create_task", {
|
||||
title: "Ship booking API",
|
||||
description: "Design and ship the booking API v1.",
|
||||
assignee_agent_name: "Dana",
|
||||
}));
|
||||
expect(result).toContain("assigned to Dana");
|
||||
|
||||
const spaces = directSpaces(h);
|
||||
expect(spaces).toHaveLength(1);
|
||||
const assignment = h.connection.db.prepare("select space_id, body_md from chat_messages where org_id = ?").get(h.orgId) as { space_id: string; body_md: string };
|
||||
expect(assignment.space_id).toBe(spaces[0].id);
|
||||
expect(assignment.body_md).toContain("take task_id=");
|
||||
});
|
||||
|
||||
test("send_message requires exactly one destination", () => {
|
||||
const h = harness({ enableProvider: false });
|
||||
const deps = { db: h.connection.db, organization: h.organization, chat: h.chat };
|
||||
expect(dispatchAgentTool(deps, h.ctx, call("send_message", { body: "hello" }))).toContain("ERROR");
|
||||
expect(dispatchAgentTool(deps, h.ctx, call("send_message", { to: "Dana", channel: "general", body: "hello" }))).toContain("ERROR");
|
||||
});
|
||||
|
||||
test("read_messages can read a direct conversation by teammate name", () => {
|
||||
const h = harness({ enableProvider: false });
|
||||
const deps = { db: h.connection.db, organization: h.organization, chat: h.chat };
|
||||
dispatchAgentTool(deps, h.ctx, call("send_message", { to: "Dana", body: "Morning - priorities for today are in the doc." }));
|
||||
const readBack = dispatchAgentTool(deps, h.ctx, call("read_messages", { with: "Dana" }));
|
||||
expect(readBack).toContain("priorities for today");
|
||||
expect(readBack).toContain("[CEO]");
|
||||
});
|
||||
});
|
||||
@@ -186,12 +186,17 @@ describe("agent run loop (real tool dispatch)", () => {
|
||||
|
||||
const alex = connection.db.prepare("select id from agents where org_id = ? and name = 'Alex Rivera'").get(orgId) as { id: string };
|
||||
const assignment = connection.db.prepare(`
|
||||
select m.body_md, m.thread_id, s.name as space_name
|
||||
select m.body_md, m.thread_id, s.id as space_id, s.space_type
|
||||
from chat_messages m
|
||||
join chat_spaces s on s.id = m.space_id
|
||||
where m.org_id = ?
|
||||
`).get(orgId) as { body_md: string; thread_id: string; space_name: string } | undefined;
|
||||
expect(assignment).toMatchObject({ space_name: "engineering" });
|
||||
`).get(orgId) as { body_md: string; thread_id: string; space_id: string; space_type: string } | undefined;
|
||||
// Assignments are 1:1 hand-offs, so they live in the manager's direct
|
||||
// conversation with the assignee instead of a shared channel.
|
||||
expect(assignment).toMatchObject({ space_type: "direct" });
|
||||
const dmMembers = connection.db.prepare("select principal_id from chat_memberships where space_id = ?")
|
||||
.all(assignment?.space_id ?? "") as Array<{ principal_id: string }>;
|
||||
expect(dmMembers.map((m) => m.principal_id).sort()).toEqual([ceoId, alex.id].sort());
|
||||
expect(assignment?.body_md).toContain(`@${canonicalMentionHandle("Alex Rivera")}`);
|
||||
expect(assignment?.body_md).toContain("Build the booking API");
|
||||
|
||||
|
||||
Reference in new issue
Block a user