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,