diff --git a/src/agents/subagent-announce.format.test.ts b/src/agents/subagent-announce.format.test.ts index 4fc052d3b..5fc72d662 100644 --- a/src/agents/subagent-announce.format.test.ts +++ b/src/agents/subagent-announce.format.test.ts @@ -191,4 +191,85 @@ describe("subagent announce formatting", () => { expect(call?.params?.to).toBe("+1555"); expect(call?.params?.accountId).toBe("kev"); }); + + it("uses requester accountId for direct announce when not queued", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); + embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct", + requesterSessionKey: "agent:main:main", + requesterChannel: "whatsapp", + requesterAccountId: "acct-123", + requesterDisplayKey: "main", + task: "do thing", + timeoutMs: 1000, + cleanup: "keep", + waitForCompletion: false, + startedAt: 10, + endedAt: 20, + outcome: { status: "ok" }, + }); + + expect(didAnnounce).toBe(true); + const call = agentSpy.mock.calls[0]?.[0] as { params?: Record }; + expect(call?.params?.channel).toBe("whatsapp"); + expect(call?.params?.accountId).toBe("acct-123"); + }); + + it("splits collect-mode announces when accountId differs", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true); + embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); + sessionStore = { + "agent:main:main": { + sessionId: "session-789", + lastChannel: "whatsapp", + lastTo: "+1555", + queueMode: "collect", + queueDebounceMs: 0, + }, + }; + + await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-a", + requesterSessionKey: "main", + requesterAccountId: "acct-a", + requesterDisplayKey: "main", + task: "do thing", + timeoutMs: 1000, + cleanup: "keep", + waitForCompletion: false, + startedAt: 10, + endedAt: 20, + outcome: { status: "ok" }, + }); + + await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-b", + requesterSessionKey: "main", + requesterAccountId: "acct-b", + requesterDisplayKey: "main", + task: "do thing", + timeoutMs: 1000, + cleanup: "keep", + waitForCompletion: false, + startedAt: 10, + endedAt: 20, + outcome: { status: "ok" }, + }); + + await new Promise((r) => setTimeout(r, 5)); + + const accountIds = agentSpy.mock.calls.map( + (call) => (call[0] as { params?: Record }).params?.accountId, + ); + expect(accountIds).toContain("acct-a"); + expect(accountIds).toContain("acct-b"); + expect(agentSpy).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index c7801610f..0c3453d40 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -351,6 +351,19 @@ function loadRequesterSessionEntry(requesterSessionKey: string) { return { cfg, entry, canonicalKey }; } +function resolveAnnounceOrigin(params: { + channel?: string; + to?: string; + accountId?: string; + fallbackAccountId?: string; +}) { + return normalizeDeliveryContext({ + channel: params.channel, + to: params.to, + accountId: params.accountId ?? params.fallbackAccountId, + }); +} + async function maybeQueueSubagentAnnounce(params: { requesterSessionKey: string; triggerMessage: string; @@ -381,10 +394,11 @@ async function maybeQueueSubagentAnnounce(params: { queueSettings.mode === "steer-backlog" || queueSettings.mode === "interrupt"; if (isActive && (shouldFollowup || queueSettings.mode === "steer")) { - const origin = normalizeDeliveryContext({ + const origin = resolveAnnounceOrigin({ channel: entry?.lastChannel, to: entry?.lastTo, - accountId: entry?.lastAccountId ?? params.requesterAccountId, + accountId: entry?.lastAccountId, + fallbackAccountId: params.requesterAccountId, }); enqueueAnnounce( canonicalKey, @@ -624,7 +638,7 @@ export async function runSubagentAnnounceFlow(params: { } // Send to main agent - it will respond in its own voice - const directOrigin = normalizeDeliveryContext({ + const directOrigin = resolveAnnounceOrigin({ channel: params.requesterChannel, accountId: params.requesterAccountId, }); diff --git a/src/commands/agent.delivery.test.ts b/src/commands/agent.delivery.test.ts index 8f5aecd2a..34d004c60 100644 --- a/src/commands/agent.delivery.test.ts +++ b/src/commands/agent.delivery.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CliDeps } from "../cli/deps.js"; import type { ClawdbotConfig } from "../config/config.js"; @@ -25,6 +25,11 @@ vi.mock("../infra/outbound/targets.js", () => ({ })); describe("deliverAgentCommandResult", () => { + beforeEach(() => { + mocks.deliverOutboundPayloads.mockClear(); + mocks.resolveOutboundTarget.mockClear(); + }); + it("prefers explicit accountId for outbound delivery", async () => { const cfg = {} as ClawdbotConfig; const deps = {} as CliDeps; @@ -61,4 +66,39 @@ describe("deliverAgentCommandResult", () => { expect.objectContaining({ accountId: "kev" }), ); }); + + it("falls back to session accountId for implicit delivery", async () => { + const cfg = {} as ClawdbotConfig; + const deps = {} as CliDeps; + const runtime = { + log: vi.fn(), + error: vi.fn(), + } as unknown as RuntimeEnv; + const sessionEntry = { + lastAccountId: "legacy", + } as SessionEntry; + const result = { + payloads: [{ text: "hi" }], + meta: {}, + }; + + const { deliverAgentCommandResult } = await import("./agent/delivery.js"); + await deliverAgentCommandResult({ + cfg, + deps, + runtime, + opts: { + message: "hello", + deliver: true, + channel: "whatsapp", + }, + sessionEntry, + result, + payloads: result.payloads, + }); + + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ accountId: "legacy" }), + ); + }); });