From cab7b01831f1b216e3055bdd17e8dbd87ffef0b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 6 Jul 2026 22:49:31 -0400 Subject: [PATCH] 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. --- .../src/main/services/agent-read-messages.ts | 62 +++++-- .../src/main/services/agent-tools.ts | 103 +++++++++--- .../main/services/chat-agent-wake-service.ts | 24 ++- .../src/main/services/chat-service.ts | 68 ++++++++ .../tests/unit/agent-direct-messages.test.ts | 158 ++++++++++++++++++ .../tests/unit/agent-run-loop.test.ts | 11 +- 6 files changed, 375 insertions(+), 51 deletions(-) create mode 100644 AgentCompany-New/tests/unit/agent-direct-messages.test.ts diff --git a/AgentCompany-New/src/main/services/agent-read-messages.ts b/AgentCompany-New/src/main/services/agent-read-messages.ts index 9814a21e..f6d7d385 100644 --- a/AgentCompany-New/src/main/services/agent-read-messages.ts +++ b/AgentCompany-New/src/main/services/agent-read-messages.ts @@ -3,30 +3,39 @@ import type { JsonRecord } from "./provider-http"; export function readMessagesTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string { const channel = optionalString(args.channel); + const withTeammate = optionalString(args.with); const limit = Math.min(Math.max(Number(args.limit) || 10, 1), 30); + if (channel && withTeammate) throw new Error('Pass either "channel" or "with", not both.'); + if (withTeammate) { + const counterpart = deps.db.prepare(` + select id, name, 'agent' as kind from agents where org_id = ? and lower(name) = lower(?) and lifecycle = 'active' + union all + select id, display_name as name, 'employee' as kind from employees where org_id = ? and lower(display_name) = lower(?) + limit 1 + `).get(ctx.orgId, withTeammate, ctx.orgId, withTeammate) as { id: string; name: string; kind: "agent" | "employee" } | undefined; + if (!counterpart) throw new Error(`No teammate named "${withTeammate}".`); + const spaceId = deps.chat.ensureDirectSpace( + ctx.orgId, + { type: "agent", id: ctx.agent.id }, + { type: counterpart.kind, id: counterpart.id }, + ); + const rows = recentMessagesIn(deps, ctx.orgId, spaceId, limit); + if (rows.length === 0) return `No messages yet in your direct conversation with ${counterpart.name}.`; + return rows.map((m) => `[${m.sender_name}] (thread=${m.thread_id}): ${m.body_md}`).join("\n"); + } if (channel) { const space = deps.db.prepare( "select id from chat_spaces where org_id = ? and lower(name) = lower(?) and space_type <> 'direct' limit 1", ).get(ctx.orgId, channel) as { id: string } | undefined; if (!space) throw new Error(`No channel named "${channel}".`); - const rows = deps.db.prepare(` - select m.body_md, - coalesce(a.name, e.display_name, m.sender_type) as sender_name, - m.thread_id - from chat_messages m - left join agents a on a.id = m.sender_id and m.sender_type = 'agent' - left join employees e on e.id = m.sender_id and m.sender_type = 'employee' - where m.space_id = ? and m.org_id = ? and m.deleted_at is null - order by m.created_at desc - limit ? - `).all(space.id, ctx.orgId, limit) as Array<{ body_md: string; sender_name: string; thread_id: string }>; + const rows = recentMessagesIn(deps, ctx.orgId, space.id, limit); if (rows.length === 0) return `No recent messages in #${channel}.`; - return rows.reverse().map((m) => `[${m.sender_name}] (thread=${m.thread_id}): ${m.body_md}`).join("\n"); + return rows.map((m) => `[${m.sender_name}] (thread=${m.thread_id}): ${m.body_md}`).join("\n"); } const rows = deps.db.prepare(` select m.body_md, coalesce(a.name, e.display_name, m.sender_type) as sender_name, - m.thread_id, s.name as channel_name + m.thread_id, s.name as channel_name, s.space_type from chat_mentions cm join chat_messages m on m.id = cm.message_id join chat_spaces s on s.id = m.space_id @@ -35,9 +44,32 @@ export function readMessagesTool(deps: AgentToolDeps, ctx: AgentToolContext, arg where cm.target_type = 'agent' and cm.target_id = ? and m.org_id = ? and m.deleted_at is null order by m.created_at desc limit ? - `).all(ctx.agent.id, ctx.orgId, limit) as Array<{ body_md: string; sender_name: string; thread_id: string; channel_name: string }>; + `).all(ctx.agent.id, ctx.orgId, limit) as Array<{ body_md: string; sender_name: string; thread_id: string; channel_name: string; space_type: string }>; if (rows.length === 0) return "No recent mentions."; - return rows.reverse().map((m) => `[${m.sender_name} in #${m.channel_name}] (thread=${m.thread_id}): ${m.body_md}`).join("\n"); + return rows.reverse().map((m) => { + const where = m.space_type === "direct" ? "in your direct conversation" : `in #${m.channel_name}`; + return `[${m.sender_name} ${where}] (thread=${m.thread_id}): ${m.body_md}`; + }).join("\n"); +} + +function recentMessagesIn( + deps: AgentToolDeps, + orgId: string, + spaceId: string, + limit: number, +): Array<{ body_md: string; sender_name: string; thread_id: string }> { + const rows = deps.db.prepare(` + select m.body_md, + coalesce(a.name, e.display_name, m.sender_type) as sender_name, + m.thread_id + from chat_messages m + left join agents a on a.id = m.sender_id and m.sender_type = 'agent' + left join employees e on e.id = m.sender_id and m.sender_type = 'employee' + where m.space_id = ? and m.org_id = ? and m.deleted_at is null + order by m.created_at desc + limit ? + `).all(spaceId, orgId, limit) as Array<{ body_md: string; sender_name: string; thread_id: string }>; + return rows.reverse(); } function optionalString(value: unknown): string | null { diff --git a/AgentCompany-New/src/main/services/agent-tools.ts b/AgentCompany-New/src/main/services/agent-tools.ts index 83bb2b2a..a8aad93e 100644 --- a/AgentCompany-New/src/main/services/agent-tools.ts +++ b/AgentCompany-New/src/main/services/agent-tools.ts @@ -94,12 +94,13 @@ export const AGENT_TOOLS: ToolDefinition[] = [ }, { name: "send_message", - description: "Talk to your teammates in a channel — ask, answer, decide together, or hand off work, the way colleagues do. @mention the people you are addressing, attach deliverable file ids with attachment_file_ids, and use /handoff @Name when passing a file. Use the channel that matches the work: 'engineering' for engineering, 'product' for product/design, otherwise 'general'. Reply inside a thread by passing its thread_id to keep one conversation together.", + description: "Talk to your teammates the way colleagues do. For anything meant for one person — task updates, questions, reports to your manager, hand-offs — send a direct message with `to` so each conversation stays separate. Use a `channel` only for genuinely team-wide topics: 'engineering' for engineering, 'product' for product/design, 'general' for company-wide. Pass exactly one of `to` or `channel`. @mention people you address in channels, attach deliverable file ids with attachment_file_ids, and use /handoff @Name when passing a file. Reply inside a thread by passing its thread_id to keep one conversation together.", parameters: { type: "object", additionalProperties: false, properties: { - channel: { type: "string", description: "Channel: 'engineering', 'product', or 'general'." }, + to: { type: "string", description: "Teammate name for a direct 1:1 message, e.g. 'CEO' or 'Ava Patel'. Preferred for anything addressed to one person." }, + channel: { type: "string", description: "Channel for team-wide topics: 'engineering', 'product', or 'general'." }, body: { type: "string", description: "The message. Use @mentions for teammates and slash commands such as /handoff @Name when passing files." }, thread_id: { type: "string", description: "Reply inside an existing thread — pass the thread_id a prior send_message returned. Omit to start a new thread." }, attachment_file_ids: { @@ -108,17 +109,18 @@ export const AGENT_TOOLS: ToolDefinition[] = [ description: "Approved file object ids to attach to this chat message.", }, }, - required: ["channel", "body"], + required: ["body"], }, }, { name: "read_messages", - description: "Read recent messages from a channel or your direct mentions. Check what your teammates have been saying before you respond or start work.", + description: "Read recent messages from a channel, a direct conversation, or your direct mentions. Check what your teammates have been saying before you respond or start work.", parameters: { type: "object", additionalProperties: false, properties: { - channel: { type: "string", description: "Channel name: 'engineering', 'product', 'general'. Omit to read your recent @mentions across all channels." }, + channel: { type: "string", description: "Channel name: 'engineering', 'product', 'general'." }, + with: { type: "string", description: "Teammate name to read your direct conversation with, e.g. 'CEO'." }, limit: { type: "number", description: "Number of recent messages (default 10, max 30)." }, }, required: [], @@ -244,18 +246,21 @@ function writeDeliverableTool(deps: AgentToolDeps, ctx: AgentToolContext, args: } function sendMessageTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string { - const channel = requireString(args.channel, "channel"); + const channel = optionalString(args.channel); + const to = optionalString(args.to); const body = requireString(args.body, "body"); const threadId = optionalString(args.thread_id); const attachmentFileIds = optionalStringArray(args.attachment_file_ids, "attachment_file_ids"); - const space = deps.db.prepare( - "select id from chat_spaces where org_id = ? and lower(name) = lower(?) and space_type <> 'direct' limit 1", - ).get(ctx.orgId, channel) as { id: string } | undefined; - if (!space) throw new Error(`No channel named "${channel}".`); + if (Boolean(channel) === Boolean(to)) throw new Error('Pass exactly one of "to" (direct message) or "channel".'); + + const destination = to + ? directSpaceWith(deps, ctx, to) + : channelSpace(deps.db, ctx.orgId, channel!); + const sent = withSavepoint(deps.db, "agent_send_message", () => { const messageId = deps.chat.sendMessage({ orgId: ctx.orgId, - spaceId: space.id, + spaceId: destination.spaceId, threadId, senderType: "agent", senderId: ctx.agent.id, @@ -265,7 +270,63 @@ function sendMessageTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonR const row = deps.db.prepare("select thread_id from chat_messages where id = ?").get(messageId) as { thread_id: string }; return { messageId, threadId: row.thread_id }; }); - return describeSend(deps.db, ctx.agent.id, channel, space.id, sent.messageId, sent.threadId); + if (destination.kind === "direct") { + return [ + `Your direct message to ${destination.counterpartName} is delivered; they can see it and reply in your conversation.`, + `To stay in this conversation, reply with to="${destination.counterpartName}" and thread_id="${sent.threadId}".`, + ].join(" "); + } + return describeSend(deps.db, ctx.agent.id, destination.label, destination.spaceId, sent.messageId, sent.threadId); +} + +interface ChannelDestination { + kind: "channel"; + spaceId: string; + label: string; +} + +interface DirectDestination { + kind: "direct"; + spaceId: string; + counterpartName: string; +} + +function channelSpace(db: DatabaseSync, orgId: string, channel: string): ChannelDestination { + const space = db.prepare( + "select id from chat_spaces where org_id = ? and lower(name) = lower(?) and space_type <> 'direct' limit 1", + ).get(orgId, channel) as { id: string } | undefined; + if (!space) throw new Error(`No channel named "${channel}".`); + return { kind: "channel", spaceId: space.id, label: channel }; +} + +function directSpaceWith(deps: AgentToolDeps, ctx: AgentToolContext, teammateName: string): DirectDestination { + const counterpart = resolveTeammateByName(deps.db, ctx.orgId, teammateName); + if (counterpart.type === "agent" && counterpart.id === ctx.agent.id) { + throw new Error("You cannot open a direct conversation with yourself."); + } + const spaceId = deps.chat.ensureDirectSpace( + ctx.orgId, + { type: "agent", id: ctx.agent.id }, + { type: counterpart.type, id: counterpart.id }, + ); + return { kind: "direct", spaceId, counterpartName: counterpart.name }; +} + +function resolveTeammateByName( + db: DatabaseSync, + orgId: string, + name: string, +): { type: "agent" | "employee"; id: string; name: string } { + const trimmed = name.trim(); + const agent = db.prepare( + "select id, name from agents where org_id = ? and lower(name) = lower(?) and lifecycle = 'active' limit 1", + ).get(orgId, trimmed) as { id: string; name: string } | undefined; + if (agent) return { type: "agent", id: agent.id, name: agent.name }; + const employee = db.prepare( + "select id, display_name from employees where org_id = ? and lower(display_name) = lower(?) limit 1", + ).get(orgId, trimmed) as { id: string; display_name: string } | undefined; + if (employee) return { type: "employee", id: employee.id, name: employee.display_name }; + throw new Error(`No teammate named "${name}".`); } /** @@ -396,18 +457,18 @@ function postAssignmentMessage( ctx: AgentToolContext, input: { taskId: string; title: string; description: string; assignee: RunnableAssignmentAgent }, ): string { - const channel = assignmentChannel(input.assignee.department); + const spaceId = deps.chat.ensureDirectSpace( + ctx.orgId, + { type: "agent", id: ctx.agent.id }, + { type: "agent", id: input.assignee.id }, + ); const body = [ `@${canonicalMentionHandle(input.assignee.name)} take task_id=${input.taskId}: ${input.title}.`, input.description, ].join(" "); - const space = deps.db.prepare( - "select id from chat_spaces where org_id = ? and lower(name) = lower(?) and space_type <> 'direct' limit 1", - ).get(ctx.orgId, channel) as { id: string } | undefined; - if (!space) throw new Error(`No channel named "${channel}".`); const messageId = deps.chat.sendMessage({ orgId: ctx.orgId, - spaceId: space.id, + spaceId, threadId: null, senderType: "agent", senderId: ctx.agent.id, @@ -418,12 +479,6 @@ function postAssignmentMessage( return row.thread_id; } -function assignmentChannel(department: string): string { - const normalized = department.trim().toLowerCase(); - if (normalized === "engineering" || normalized === "product") return normalized; - return "general"; -} - function withSavepoint(db: DatabaseSync, name: string, operation: () => T): T { db.exec(`savepoint ${name}`); try { diff --git a/AgentCompany-New/src/main/services/chat-agent-wake-service.ts b/AgentCompany-New/src/main/services/chat-agent-wake-service.ts index ae1713e9..31a243ab 100644 --- a/AgentCompany-New/src/main/services/chat-agent-wake-service.ts +++ b/AgentCompany-New/src/main/services/chat-agent-wake-service.ts @@ -39,11 +39,12 @@ export class ChatAgentWakeService { const agent = this.requireRunnableAgent(input.orgId, agentId, "Agent mention"); const workspace = this.workspaceForAgent(input.orgId, agent.workspace_id); const from = this.principalName(input.orgId, input.senderType, input.senderId); - const channel = this.channelName(input.orgId, input.spaceId); + const space = this.spaceRow(input.orgId, input.spaceId); + const direct = space.space_type === "direct"; const taskId = this.organization.createTask({ orgId: input.orgId, - title: `Reply to ${from} in #${channel}`, - description: this.mentionTaskDescription(input, from, channel), + title: direct ? `Reply to ${from}` : `Reply to ${from} in #${space.name}`, + description: this.mentionTaskDescription(input, from, space.name, direct), status: TASK_STATUS.BACKLOG, assigneeAgentId: agent.id, risk: RISK_LEVEL.LOW, @@ -193,13 +194,17 @@ export class ChatAgentWakeService { return `Chat mention: ${title.length > 64 ? `${title.slice(0, 61)}...` : title}`; } - private mentionTaskDescription(input: SendMessageInput, from: string, channel: string): string { + private mentionTaskDescription(input: SendMessageInput, from: string, channel: string, direct: boolean): string { const said = input.bodyMd.trim(); + const where = direct ? `${from} messaged you directly:` : `${from} addressed you in #${channel}:`; + const how = direct + ? `Reply to ${from} in your direct conversation: call send_message with to="${from}" and this thread_id.` + : `Reply to ${from} in this thread.`; return [ - `${from} addressed you in #${channel}:`, + where, `"${said.length > 400 ? `${said.slice(0, 397)}...` : said}"`, "", - `Reply to ${from} in this thread. Answer what they asked, give your honest take, push back if you disagree, or hand work over — speak as yourself, the way a colleague would, not as a status report.`, + `${how} Answer what they asked, give your honest take, push back if you disagree, or hand work over — speak as yourself, the way a colleague would, not as a status report.`, ].join("\n"); } @@ -215,10 +220,11 @@ export class ChatAgentWakeService { return senderType === "board" ? "The board" : "A teammate"; } - private channelName(orgId: string, spaceId: string): string { - const row = this.db.prepare("select name from chat_spaces where id = ? and org_id = ?").get(spaceId, orgId) as { name: string } | undefined; + private spaceRow(orgId: string, spaceId: string): { name: string; space_type: string } { + const row = this.db.prepare("select name, space_type from chat_spaces where id = ? and org_id = ?") + .get(spaceId, orgId) as { name: string; space_type: string } | undefined; if (!row) throw new Error("Chat space was not found."); - return row.name; + return row; } private taskDescription(input: SendMessageInput, slash: SlashInvocation, messageId: string, threadId: string): string { diff --git a/AgentCompany-New/src/main/services/chat-service.ts b/AgentCompany-New/src/main/services/chat-service.ts index 1aed299b..aea32b71 100644 --- a/AgentCompany-New/src/main/services/chat-service.ts +++ b/AgentCompany-New/src/main/services/chat-service.ts @@ -89,6 +89,34 @@ export class ChatService { return id; } + /** + * Finds or creates the private direct conversation between two principals, + * so 1:1 traffic (task hand-offs, questions, reports) lives in its own + * WeChat-style conversation instead of piling into shared channels. + */ + ensureDirectSpace(orgId: string, a: PrincipalRef, b: PrincipalRef): string { + if (a.type === b.type && a.id === b.id) throw new Error("A direct conversation needs two different principals."); + const existing = this.db.prepare(` + select cs.id + from chat_spaces cs + where cs.org_id = ? and cs.space_type = 'direct' + and exists (select 1 from chat_memberships m where m.space_id = cs.id and m.principal_type = ? and m.principal_id = ?) + and exists (select 1 from chat_memberships m where m.space_id = cs.id and m.principal_type = ? and m.principal_id = ?) + and (select count(*) from chat_memberships m where m.space_id = cs.id) = 2 + limit 1 + `).get(orgId, a.type, a.id, b.type, b.id) as { id: string } | undefined; + if (existing) return existing.id; + return this.createSpace({ + orgId, + spaceType: "direct", + name: `${this.principalDisplayName(orgId, a)} · ${this.principalDisplayName(orgId, b)}`, + topic: "Direct conversation", + visibility: "restricted", + memberEmployeeIds: [a, b].filter((p) => p.type === "employee").map((p) => p.id), + memberAgentIds: [a, b].filter((p) => p.type === "agent").map((p) => p.id), + }); + } + sendMessage(input: SendMessageInput): string { const body = input.bodyMd.trim(); if (!body) throw new Error("Message body is required."); @@ -140,6 +168,7 @@ export class ChatService { if (slash) this.slashRouter.recordInvocation(id, slash, slashResultRef, now); if (!slash || slash.command !== "agent") { this.wakeAgentMentions(input, mentions, id, threadId, now); + if (space.space_type === "direct") this.wakeDirectCounterparts(input, mentions, id, threadId, now); } if (input.senderType === "employee" || input.senderType === "board") { this.wakeThreadParticipants(input, mentions, id, threadId, now); @@ -247,6 +276,31 @@ export class ChatService { } } + /** + * In a direct conversation the counterpart is addressed by definition, so + * agent members wake on every message without needing an explicit @mention + * — the same social contract as any messenger. Mentioned agents were + * already woken; the autonomous turn budget still bounds agent↔agent runs. + */ + private wakeDirectCounterparts(input: SendMessageInput, mentions: ResolvedMention[], messageId: string, threadId: string, now: string): void { + if (input.senderType === "agent" && this.autonomousThreadTurnsExhausted(input.orgId, threadId)) return; + const mentionedAgentIds = new Set(mentions.filter((m) => m.target.type === "agent").map((m) => m.target.id)); + const members = this.db.prepare(` + select principal_id as agent_id + from chat_memberships + where space_id = ? and principal_type = 'agent' + `).all(input.spaceId) as Array<{ agent_id: string }>; + for (const member of members) { + if (mentionedAgentIds.has(member.agent_id)) continue; + if (input.senderType === "agent" && input.senderId === member.agent_id) continue; + try { + this.agentWake.wakeFromMention(input, member.agent_id, messageId, threadId, now); + } catch { + // Agent may not have an enabled provider. + } + } + } + /** * True once a thread holds MAX_AUTONOMOUS_THREAD_AGENT_TURNS agent messages * since the last human or board message — the point past which agent↔agent @@ -266,6 +320,20 @@ export class ChatService { return row.turns >= MAX_AUTONOMOUS_THREAD_AGENT_TURNS; } + private principalDisplayName(orgId: string, principal: PrincipalRef): string { + if (principal.type === "agent") { + const row = this.db.prepare("select name from agents where id = ? and org_id = ?") + .get(principal.id, orgId) as { name: string } | undefined; + if (row) return row.name; + } + if (principal.type === "employee") { + const row = this.db.prepare("select display_name from employees where id = ? and org_id = ?") + .get(principal.id, orgId) as { display_name: string } | undefined; + if (row) return row.display_name; + } + throw new Error("Direct conversation principal was not found."); + } + private requireSpace(orgId: string, spaceId: string): SpaceRow { const space = this.db.prepare("select id, org_id, visibility, space_type from chat_spaces where id = ? and org_id = ?") .get(spaceId, orgId) as SpaceRow | undefined; diff --git a/AgentCompany-New/tests/unit/agent-direct-messages.test.ts b/AgentCompany-New/tests/unit/agent-direct-messages.test.ts new file mode 100644 index 00000000..3d2fc566 --- /dev/null +++ b/AgentCompany-New/tests/unit/agent-direct-messages.test.ts @@ -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) { + 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]"); + }); +}); diff --git a/AgentCompany-New/tests/unit/agent-run-loop.test.ts b/AgentCompany-New/tests/unit/agent-run-loop.test.ts index 42fd5493..892eeca9 100644 --- a/AgentCompany-New/tests/unit/agent-run-loop.test.ts +++ b/AgentCompany-New/tests/unit/agent-run-loop.test.ts @@ -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");