From d47e637a45b340bc8ac495b7d96abe49d66af3a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 19 Apr 2026 01:21:26 -0400 Subject: [PATCH] fix(messages): company channel fans out to every active agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Company" channel fell through to `listTopLevelAgentIds`, so a broadcast on the most ambient-sounding channel in the product only reached agents with `reports_to IS NULL`. The label said company; the semantics said board-only. The rest of the org never knew a message existed. Add `listCompanyBroadcastAgentIds` — returns every non-terminated, non-paused, non-pending agent for the company — and wire it through the `company` case in `resolveMessageWakeTargets`. Board-only fan-outs are still reachable: the `incident` channel keeps the broader pingable set (paused-by-budget included) and direct DMs to top-level agents work as before. The prompt doc catches up to the runtime: company is explicitly framed as all-hands, with board-only guidance redirected to DMs or incidents. The message-dispatch unit test asserts the new fan-out. --- src/main/database-base.ts | 1 + src/main/database/agents-hierarchy.ts | 19 +++++++++++++++++-- src/main/message-dispatch.ts | 6 +++++- .../prompt-builder/api-docs/messages.en.md | 3 ++- tests/unit/message-dispatch.test.ts | 6 +++--- 5 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/main/database-base.ts b/src/main/database-base.ts index 6fe17338..7c540697 100644 --- a/src/main/database-base.ts +++ b/src/main/database-base.ts @@ -253,6 +253,7 @@ export class AppDatabaseBase { countActiveRunsForAgent(agentId: string, companyId: string) { return this.agents.countActiveRunsForAgent(agentId, companyId); } listDepartmentAgentIds(companyId: string, department: string) { return this.agents.listDepartmentAgentIds(companyId, department); } listTopLevelAgentIds(companyId: string) { return this.agents.listTopLevelAgentIds(companyId); } + listCompanyBroadcastAgentIds(companyId: string) { return this.agents.listCompanyBroadcastAgentIds(companyId); } listIncidentPingableAgentIds(companyId: string) { return this.agents.listIncidentPingableAgentIds(companyId); } findAgentName(agentId: string) { return this.agents.findAgentName(agentId); } listAgentNamesByIds(companyId: string, agentIds: readonly string[]) { return this.agents.listAgentNamesByIds(companyId, agentIds); } diff --git a/src/main/database/agents-hierarchy.ts b/src/main/database/agents-hierarchy.ts index a18ceb04..2cf5dc13 100644 --- a/src/main/database/agents-hierarchy.ts +++ b/src/main/database/agents-hierarchy.ts @@ -286,8 +286,8 @@ export function buildAgentHierarchyMethods( /** * Returns ids of non-terminated agents that have no manager (top of the - * reporting tree). Used by `resolveMessageWakeTargets` for "company" - * channel broadcast. + * reporting tree). Used for board-only fan-outs where only top-level + * leadership should be notified. */ listTopLevelAgentIds(companyId: string): string[] { return readAll<{ id: string }>( @@ -296,6 +296,21 @@ export function buildAgentHierarchyMethods( ).map((row) => row.id); }, + /** + * Returns ids of every agent that is eligible to receive a routine + * company-wide broadcast. Paused, pending-approval, and terminated + * agents are excluded — they should not be woken by normal chatter; + * only incidents reach the broader pingable set. Used by + * `resolveMessageWakeTargets` for the "company" channel so the + * semantic matches Slack (all-hands) rather than board-only. + */ + listCompanyBroadcastAgentIds(companyId: string): string[] { + return readAll<{ id: string }>( + "select id from agents where company_id = ? and status not in ('terminated', 'paused', 'pending_approval')", + companyId, + ).map((row) => row.id); + }, + /** * Returns ids of agents that can receive incident pings: everybody not * terminated or still pending approval. diff --git a/src/main/message-dispatch.ts b/src/main/message-dispatch.ts index 2cb1302f..5dcdfbb4 100644 --- a/src/main/message-dispatch.ts +++ b/src/main/message-dispatch.ts @@ -231,7 +231,11 @@ export function resolveMessageWakeTargets( } if (input.channel === "company") { - for (const agentId of db.listTopLevelAgentIds(input.companyId)) { + // Company channel is Slack-semantics: every active agent in the + // workspace is a listener. Board-only fan-outs use the dedicated + // `incident` channel; the previous top-level-only fan-out made the + // "company" label a misnomer that silently dropped ICs. + for (const agentId of db.listCompanyBroadcastAgentIds(input.companyId)) { if (agentId !== input.fromAgentId) wakeTargets.add(agentId); } } diff --git a/src/main/prompt-builder/api-docs/messages.en.md b/src/main/prompt-builder/api-docs/messages.en.md index 4c53d957..eeab3203 100644 --- a/src/main/prompt-builder/api-docs/messages.en.md +++ b/src/main/prompt-builder/api-docs/messages.en.md @@ -17,7 +17,8 @@ channelTargetId rules: - channel "incident": set channelTargetId to null Direct messages WAKE the recipient immediately. Use this when you need another agent to act. Department messages wake all members of that department. -Company-wide messages wake the CEO. Incident messages are visible company-wide. +Company-wide messages wake every active agent in the company — treat this channel like an all-hands broadcast, not a CEO inbox. For board-only announcements, DM the top-level agents directly or open an incident. +Incident messages are visible company-wide and pull the broader pingable set (including paused-by-budget agents). Example — send urgent message to a peer: curl -s -X POST "$AGENT_COMPANY_API_URL/api/companies/{{companyId}}/messages" \ -H "Authorization: Bearer $AGENT_COMPANY_API_KEY" \ diff --git a/tests/unit/message-dispatch.test.ts b/tests/unit/message-dispatch.test.ts index 096d2d60..e92a4a55 100644 --- a/tests/unit/message-dispatch.test.ts +++ b/tests/unit/message-dispatch.test.ts @@ -125,7 +125,7 @@ function createScaffold(db: AppDatabase) { } describe("message dispatch", () => { - it("wakes top-level active agents for company messages and active agents for incident messages", async () => { + it("wakes every active agent for company channel broadcasts (Slack-style all-hands)", async () => { const profileDir = await createTempDir("agentcompany-message-dispatch-"); const db = new AppDatabase(profileDir); db.init(); @@ -141,12 +141,12 @@ describe("message dispatch", () => { fromAgentId: HUMAN_SENDER_ID, fromHumanUser: true, channel: "company", - body: "Board broadcast", + body: "All-hands broadcast", }); expect(companyResult.ok).toBe(true); if (!companyResult.ok) throw new Error("Expected company message dispatch to succeed."); - expect(companyResult.data.wakeTargets).toEqual([ceoId]); + expect(new Set(companyResult.data.wakeTargets)).toEqual(new Set([ceoId, ctoId, cmoId])); const incidentResult = dispatchAgentMessage({ db,