From 8923a2bf0a6060cccfae0b8c1c403b61817da4ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 26 Jun 2026 19:41:56 -0400 Subject: [PATCH] Clarify agent mention reply readiness --- .../src/main/services/agent-tools.ts | 68 ++++++++++++++----- .../main/services/chat-agent-wake-service.ts | 8 +-- .../src/main/services/chat-service.ts | 2 +- .../tests/unit/agent-messaging.test.ts | 42 +++++++++++- 4 files changed, 96 insertions(+), 24 deletions(-) diff --git a/AgentCompany-New/src/main/services/agent-tools.ts b/AgentCompany-New/src/main/services/agent-tools.ts index 28c2f896..866a06f0 100644 --- a/AgentCompany-New/src/main/services/agent-tools.ts +++ b/AgentCompany-New/src/main/services/agent-tools.ts @@ -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(); + 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]}`; 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 08df6211..ae1713e9 100644 --- a/AgentCompany-New/src/main/services/chat-agent-wake-service.ts +++ b/AgentCompany-New/src/main/services/chat-agent-wake-service.ts @@ -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; } diff --git a/AgentCompany-New/src/main/services/chat-service.ts b/AgentCompany-New/src/main/services/chat-service.ts index 7ae52bd5..fe782598 100644 --- a/AgentCompany-New/src/main/services/chat-service.ts +++ b/AgentCompany-New/src/main/services/chat-service.ts @@ -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. } } } diff --git a/AgentCompany-New/tests/unit/agent-messaging.test.ts b/AgentCompany-New/tests/unit/agent-messaging.test.ts index b31f4188..8424e55c 100644 --- a/AgentCompany-New/tests/unit/agent-messaging.test.ts +++ b/AgentCompany-New/tests/unit/agent-messaging.test.ts @@ -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(); + }); });