Clarify agent mention reply readiness
This commit is contained in:
1 parent
91991f7c4a
commit
8923a2bf0a
4 files changed
+96
-24
No files matched your search
@@ -236,17 +236,20 @@ function sendMessageTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonR
|
||||
"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,
|
||||
threadId,
|
||||
senderType: "agent",
|
||||
senderId: ctx.agent.id,
|
||||
bodyMd: body,
|
||||
attachmentFileIds,
|
||||
const sent = withSavepoint(deps.db, "agent_send_message", () => {
|
||||
const messageId = deps.chat.sendMessage({
|
||||
orgId: ctx.orgId,
|
||||
spaceId: space.id,
|
||||
threadId,
|
||||
senderType: "agent",
|
||||
senderId: ctx.agent.id,
|
||||
bodyMd: body,
|
||||
attachmentFileIds,
|
||||
});
|
||||
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 };
|
||||
});
|
||||
const row = deps.db.prepare("select thread_id from chat_messages where id = ?").get(messageId) as { thread_id: string };
|
||||
return describeSend(deps.db, ctx.agent.id, channel, space.id, messageId, row.thread_id);
|
||||
return describeSend(deps.db, ctx.agent.id, channel, space.id, sent.messageId, sent.threadId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -259,25 +262,45 @@ function describeSend(db: DatabaseSync, selfAgentId: string, channel: string, sp
|
||||
const addressed = mentionedTeammates(db, selfAgentId, messageId);
|
||||
const present = channelTeammates(db, selfAgentId, spaceId, addressed);
|
||||
const lines = [`Your message is up in #${channel}.`];
|
||||
if (addressed.length > 0) lines.push(`${joinNames(addressed)} can see it and reply here.`);
|
||||
const replyReady = addressed.filter((teammate) => teammate.canReply).map((teammate) => teammate.name);
|
||||
const mentionedOnly = addressed.filter((teammate) => !teammate.canReply).map((teammate) => teammate.name);
|
||||
if (replyReady.length > 0) lines.push(`${joinNames(replyReady)} can see it and reply here.`);
|
||||
if (mentionedOnly.length > 0) lines.push(`${joinNames(mentionedOnly)} was mentioned in this thread.`);
|
||||
if (present.length > 0) lines.push(`Also in the room: ${joinNames(present)}.`);
|
||||
lines.push(`To stay in this conversation, reply with thread_id="${threadId}".`);
|
||||
return lines.join(" ");
|
||||
}
|
||||
|
||||
function mentionedTeammates(db: DatabaseSync, selfAgentId: string, messageId: string): string[] {
|
||||
interface MentionedTeammate {
|
||||
name: string;
|
||||
canReply: boolean;
|
||||
}
|
||||
|
||||
function mentionedTeammates(db: DatabaseSync, selfAgentId: string, messageId: string): MentionedTeammate[] {
|
||||
const wakePattern = `%"sourceMessageId":"${messageId}"%`;
|
||||
const rows = db.prepare(`
|
||||
select coalesce(a.name, e.display_name) as name
|
||||
select coalesce(a.name, e.display_name) as name,
|
||||
case
|
||||
when m.target_type = 'employee' then 1
|
||||
when exists (
|
||||
select 1
|
||||
from task_runs r
|
||||
where r.agent_id = m.target_id
|
||||
and r.trigger = 'chat_mention'
|
||||
and r.secret_scope_json like ?
|
||||
) then 1
|
||||
else 0
|
||||
end as can_reply
|
||||
from chat_mentions m
|
||||
left join agents a on a.id = m.target_id and m.target_type = 'agent'
|
||||
left join employees e on e.id = m.target_id and m.target_type = 'employee'
|
||||
where m.message_id = ? and m.target_type in ('agent', 'employee') and m.target_id <> ?
|
||||
`).all(messageId, selfAgentId) as Array<{ name: string | null }>;
|
||||
return dedupeNames(rows);
|
||||
`).all(wakePattern, messageId, selfAgentId) as Array<{ name: string | null; can_reply: number }>;
|
||||
return dedupeTeammates(rows);
|
||||
}
|
||||
|
||||
function channelTeammates(db: DatabaseSync, selfAgentId: string, spaceId: string, addressed: string[]): string[] {
|
||||
const alreadyNamed = new Set(addressed);
|
||||
function channelTeammates(db: DatabaseSync, selfAgentId: string, spaceId: string, addressed: MentionedTeammate[]): string[] {
|
||||
const alreadyNamed = new Set(addressed.map((teammate) => teammate.name));
|
||||
const rows = db.prepare(`
|
||||
select coalesce(a.name, e.display_name) as name
|
||||
from chat_memberships cm
|
||||
@@ -294,6 +317,17 @@ function dedupeNames(rows: Array<{ name: string | null }>): string[] {
|
||||
return [...names];
|
||||
}
|
||||
|
||||
function dedupeTeammates(rows: Array<{ name: string | null; can_reply: number }>): MentionedTeammate[] {
|
||||
const teammates = new Map<string, MentionedTeammate>();
|
||||
for (const row of rows) {
|
||||
if (!row.name) continue;
|
||||
const existing = teammates.get(row.name);
|
||||
const canReply = row.can_reply === 1;
|
||||
teammates.set(row.name, { name: row.name, canReply: canReply || existing?.canReply === true });
|
||||
}
|
||||
return [...teammates.values()];
|
||||
}
|
||||
|
||||
function joinNames(names: string[]): string {
|
||||
if (names.length === 1) return names[0];
|
||||
if (names.length === 2) return `${names[0]} and ${names[1]}`;
|
||||
|
||||
@@ -36,7 +36,7 @@ export class ChatAgentWakeService {
|
||||
}
|
||||
|
||||
wakeFromMention(input: SendMessageInput, agentId: string, messageId: string, threadId: string, now: string): AgentWakeResult {
|
||||
const agent = this.requireRunnableAgent(input.orgId, agentId);
|
||||
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);
|
||||
@@ -60,7 +60,7 @@ export class ChatAgentWakeService {
|
||||
|
||||
wakeFromSlash(input: SendMessageInput, mentions: ResolvedMention[], slash: SlashInvocation, messageId: string, threadId: string, now: string): AgentWakeResult {
|
||||
const targetId = this.requireAgentMention(mentions);
|
||||
const agent = this.requireRunnableAgent(input.orgId, targetId);
|
||||
const agent = this.requireRunnableAgent(input.orgId, targetId, "Slash command /agent");
|
||||
const workspace = this.workspaceForAgent(input.orgId, agent.workspace_id);
|
||||
const taskId = this.organization.createTask({
|
||||
orgId: input.orgId,
|
||||
@@ -86,7 +86,7 @@ export class ChatAgentWakeService {
|
||||
return target.id;
|
||||
}
|
||||
|
||||
private requireRunnableAgent(orgId: string, agentId: string): RunnableAgentRow {
|
||||
private requireRunnableAgent(orgId: string, agentId: string, label: string): RunnableAgentRow {
|
||||
const agent = this.db.prepare(`
|
||||
select a.id, a.name, a.model_provider_id, a.workspace_id
|
||||
from agents a
|
||||
@@ -97,7 +97,7 @@ export class ChatAgentWakeService {
|
||||
and p.enabled = 1
|
||||
limit 1
|
||||
`).get(agentId, orgId) as RunnableAgentRow | undefined;
|
||||
if (!agent) throw new Error("Slash command /agent requires an active agent with an enabled provider.");
|
||||
if (!agent) throw new Error(`${label} requires an active agent with an enabled provider.`);
|
||||
return agent;
|
||||
}
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@ export class ChatService {
|
||||
try {
|
||||
this.agentWake.wakeFromMention(input, mention.target.id, messageId, threadId, now);
|
||||
} catch {
|
||||
// Agent may lack an enabled provider or service identity; skip silently.
|
||||
// Mentions remain delivery metadata even when the target has no runtime.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,10 @@ describe("agent messaging", () => {
|
||||
const organization = new OrganizationService(connection.db);
|
||||
let orgId = "";
|
||||
let ceoId = "";
|
||||
let providerId = "";
|
||||
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;
|
||||
providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id;
|
||||
ceoId = organization.createAgent({
|
||||
orgId, name: "CEO", role: "CEO", department: "Executive",
|
||||
modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 25000, managerAgentId: null,
|
||||
@@ -52,9 +53,46 @@ describe("agent messaging", () => {
|
||||
modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: ceoId,
|
||||
}));
|
||||
const addressed = dispatchAgentTool(deps, ctx, call("send_message", { channel: "engineering", body: "@Dana can you take the booking API?" }));
|
||||
expect(addressed).toContain("Dana can see it and reply here");
|
||||
expect(addressed).toContain("Dana was mentioned in this thread");
|
||||
expect(addressed).not.toContain("Dana can see it and reply here");
|
||||
expect(addressed).not.toContain("message_id=");
|
||||
expect(connection.db.prepare("select count(*) as count from task_runs where org_id = ? and trigger = 'chat_mention'")
|
||||
.get(orgId)).toEqual({ count: 0 });
|
||||
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("reports reply readiness when a mention wakes a teammate run", () => {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-msg-${randomUUID()}.db`));
|
||||
const organization = new OrganizationService(connection.db);
|
||||
let orgId = "";
|
||||
let ceoId = "";
|
||||
let danaId = "";
|
||||
let providerId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({ organizationName: "OrbitOps", employeeName: "Yang", mission: "m", goal: "g" });
|
||||
providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id;
|
||||
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 deps = { db: connection.db, organization, chat: new ChatService(connection.db) };
|
||||
const ctx: AgentToolContext = {
|
||||
orgId, agent: { id: ceoId, modelProviderId: null, modelId: null },
|
||||
taskProjectId: null, taskGoalId: null, workspace: null,
|
||||
};
|
||||
|
||||
const result = dispatchAgentTool(deps, ctx, call("send_message", { channel: "engineering", body: "@Dana can you take this?" }));
|
||||
|
||||
expect(result).toContain("Dana can see it and reply here");
|
||||
expect(connection.db.prepare("select count(*) as count from task_runs where org_id = ? and agent_id = ? and trigger = 'chat_mention'")
|
||||
.get(orgId, danaId)).toEqual({ count: 1 });
|
||||
connection.close();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user