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
@@ -3,30 +3,39 @@ import type { JsonRecord } from "./provider-http";
|
|||||||
|
|
||||||
export function readMessagesTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string {
|
export function readMessagesTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string {
|
||||||
const channel = optionalString(args.channel);
|
const channel = optionalString(args.channel);
|
||||||
|
const withTeammate = optionalString(args.with);
|
||||||
const limit = Math.min(Math.max(Number(args.limit) || 10, 1), 30);
|
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) {
|
if (channel) {
|
||||||
const space = deps.db.prepare(
|
const space = deps.db.prepare(
|
||||||
"select id from chat_spaces where org_id = ? and lower(name) = lower(?) and space_type <> 'direct' limit 1",
|
"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;
|
).get(ctx.orgId, channel) as { id: string } | undefined;
|
||||||
if (!space) throw new Error(`No channel named "${channel}".`);
|
if (!space) throw new Error(`No channel named "${channel}".`);
|
||||||
const rows = deps.db.prepare(`
|
const rows = recentMessagesIn(deps, ctx.orgId, space.id, limit);
|
||||||
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 }>;
|
|
||||||
if (rows.length === 0) return `No recent messages in #${channel}.`;
|
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(`
|
const rows = deps.db.prepare(`
|
||||||
select m.body_md,
|
select m.body_md,
|
||||||
coalesce(a.name, e.display_name, m.sender_type) as sender_name,
|
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
|
from chat_mentions cm
|
||||||
join chat_messages m on m.id = cm.message_id
|
join chat_messages m on m.id = cm.message_id
|
||||||
join chat_spaces s on s.id = m.space_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
|
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
|
order by m.created_at desc
|
||||||
limit ?
|
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.";
|
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 {
|
function optionalString(value: unknown): string | null {
|
||||||
|
|||||||
@@ -94,12 +94,13 @@ export const AGENT_TOOLS: ToolDefinition[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "send_message",
|
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: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
properties: {
|
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." },
|
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." },
|
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: {
|
attachment_file_ids: {
|
||||||
@@ -108,17 +109,18 @@ export const AGENT_TOOLS: ToolDefinition[] = [
|
|||||||
description: "Approved file object ids to attach to this chat message.",
|
description: "Approved file object ids to attach to this chat message.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: ["channel", "body"],
|
required: ["body"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "read_messages",
|
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: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
properties: {
|
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)." },
|
limit: { type: "number", description: "Number of recent messages (default 10, max 30)." },
|
||||||
},
|
},
|
||||||
required: [],
|
required: [],
|
||||||
@@ -244,18 +246,21 @@ function writeDeliverableTool(deps: AgentToolDeps, ctx: AgentToolContext, args:
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sendMessageTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string {
|
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 body = requireString(args.body, "body");
|
||||||
const threadId = optionalString(args.thread_id);
|
const threadId = optionalString(args.thread_id);
|
||||||
const attachmentFileIds = optionalStringArray(args.attachment_file_ids, "attachment_file_ids");
|
const attachmentFileIds = optionalStringArray(args.attachment_file_ids, "attachment_file_ids");
|
||||||
const space = deps.db.prepare(
|
if (Boolean(channel) === Boolean(to)) throw new Error('Pass exactly one of "to" (direct message) or "channel".');
|
||||||
"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;
|
const destination = to
|
||||||
if (!space) throw new Error(`No channel named "${channel}".`);
|
? directSpaceWith(deps, ctx, to)
|
||||||
|
: channelSpace(deps.db, ctx.orgId, channel!);
|
||||||
|
|
||||||
const sent = withSavepoint(deps.db, "agent_send_message", () => {
|
const sent = withSavepoint(deps.db, "agent_send_message", () => {
|
||||||
const messageId = deps.chat.sendMessage({
|
const messageId = deps.chat.sendMessage({
|
||||||
orgId: ctx.orgId,
|
orgId: ctx.orgId,
|
||||||
spaceId: space.id,
|
spaceId: destination.spaceId,
|
||||||
threadId,
|
threadId,
|
||||||
senderType: "agent",
|
senderType: "agent",
|
||||||
senderId: ctx.agent.id,
|
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 };
|
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 { 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,
|
ctx: AgentToolContext,
|
||||||
input: { taskId: string; title: string; description: string; assignee: RunnableAssignmentAgent },
|
input: { taskId: string; title: string; description: string; assignee: RunnableAssignmentAgent },
|
||||||
): string {
|
): 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 = [
|
const body = [
|
||||||
`@${canonicalMentionHandle(input.assignee.name)} take task_id=${input.taskId}: ${input.title}.`,
|
`@${canonicalMentionHandle(input.assignee.name)} take task_id=${input.taskId}: ${input.title}.`,
|
||||||
input.description,
|
input.description,
|
||||||
].join(" ");
|
].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({
|
const messageId = deps.chat.sendMessage({
|
||||||
orgId: ctx.orgId,
|
orgId: ctx.orgId,
|
||||||
spaceId: space.id,
|
spaceId,
|
||||||
threadId: null,
|
threadId: null,
|
||||||
senderType: "agent",
|
senderType: "agent",
|
||||||
senderId: ctx.agent.id,
|
senderId: ctx.agent.id,
|
||||||
@@ -418,12 +479,6 @@ function postAssignmentMessage(
|
|||||||
return row.thread_id;
|
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<T>(db: DatabaseSync, name: string, operation: () => T): T {
|
function withSavepoint<T>(db: DatabaseSync, name: string, operation: () => T): T {
|
||||||
db.exec(`savepoint ${name}`);
|
db.exec(`savepoint ${name}`);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -39,11 +39,12 @@ export class ChatAgentWakeService {
|
|||||||
const agent = this.requireRunnableAgent(input.orgId, agentId, "Agent mention");
|
const agent = this.requireRunnableAgent(input.orgId, agentId, "Agent mention");
|
||||||
const workspace = this.workspaceForAgent(input.orgId, agent.workspace_id);
|
const workspace = this.workspaceForAgent(input.orgId, agent.workspace_id);
|
||||||
const from = this.principalName(input.orgId, input.senderType, input.senderId);
|
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({
|
const taskId = this.organization.createTask({
|
||||||
orgId: input.orgId,
|
orgId: input.orgId,
|
||||||
title: `Reply to ${from} in #${channel}`,
|
title: direct ? `Reply to ${from}` : `Reply to ${from} in #${space.name}`,
|
||||||
description: this.mentionTaskDescription(input, from, channel),
|
description: this.mentionTaskDescription(input, from, space.name, direct),
|
||||||
status: TASK_STATUS.BACKLOG,
|
status: TASK_STATUS.BACKLOG,
|
||||||
assigneeAgentId: agent.id,
|
assigneeAgentId: agent.id,
|
||||||
risk: RISK_LEVEL.LOW,
|
risk: RISK_LEVEL.LOW,
|
||||||
@@ -193,13 +194,17 @@ export class ChatAgentWakeService {
|
|||||||
return `Chat mention: ${title.length > 64 ? `${title.slice(0, 61)}...` : title}`;
|
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 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 [
|
return [
|
||||||
`${from} addressed you in #${channel}:`,
|
where,
|
||||||
`"${said.length > 400 ? `${said.slice(0, 397)}...` : said}"`,
|
`"${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");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,10 +220,11 @@ export class ChatAgentWakeService {
|
|||||||
return senderType === "board" ? "The board" : "A teammate";
|
return senderType === "board" ? "The board" : "A teammate";
|
||||||
}
|
}
|
||||||
|
|
||||||
private channelName(orgId: string, spaceId: string): string {
|
private spaceRow(orgId: string, spaceId: string): { name: string; space_type: string } {
|
||||||
const row = this.db.prepare("select name from chat_spaces where id = ? and org_id = ?").get(spaceId, orgId) as { name: string } | undefined;
|
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.");
|
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 {
|
private taskDescription(input: SendMessageInput, slash: SlashInvocation, messageId: string, threadId: string): string {
|
||||||
|
|||||||
@@ -89,6 +89,34 @@ export class ChatService {
|
|||||||
return id;
|
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 {
|
sendMessage(input: SendMessageInput): string {
|
||||||
const body = input.bodyMd.trim();
|
const body = input.bodyMd.trim();
|
||||||
if (!body) throw new Error("Message body is required.");
|
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) this.slashRouter.recordInvocation(id, slash, slashResultRef, now);
|
||||||
if (!slash || slash.command !== "agent") {
|
if (!slash || slash.command !== "agent") {
|
||||||
this.wakeAgentMentions(input, mentions, id, threadId, now);
|
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") {
|
if (input.senderType === "employee" || input.senderType === "board") {
|
||||||
this.wakeThreadParticipants(input, mentions, id, threadId, now);
|
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
|
* 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
|
* 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;
|
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 {
|
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 = ?")
|
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;
|
.get(spaceId, orgId) as SpaceRow | undefined;
|
||||||
|
|||||||
@@ -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 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(`
|
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
|
from chat_messages m
|
||||||
join chat_spaces s on s.id = m.space_id
|
join chat_spaces s on s.id = m.space_id
|
||||||
where m.org_id = ?
|
where m.org_id = ?
|
||||||
`).get(orgId) as { body_md: string; thread_id: string; space_name: string } | undefined;
|
`).get(orgId) as { body_md: string; thread_id: string; space_id: string; space_type: string } | undefined;
|
||||||
expect(assignment).toMatchObject({ space_name: "engineering" });
|
// 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(`@${canonicalMentionHandle("Alex Rivera")}`);
|
||||||
expect(assignment?.body_md).toContain("Build the booking API");
|
expect(assignment?.body_md).toContain("Build the booking API");
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user