From 76a0abc768c71ccd48d57288f46c1ad389ccf13e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 23:49:00 +0100 Subject: [PATCH] fix(agents): keep queued announces session-only without route --- CHANGELOG.md | 3 + src/agents/subagent-announce-delivery.test.ts | 134 ++++++++++++++++++ src/agents/subagent-announce-delivery.ts | 18 ++- 3 files changed, 150 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5404d930a5..8c4c0185e1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,9 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/subagents: keep queued subagent announces session-only when the + requester has no external channel target, avoiding ambiguous multi-channel + delivery failures. Fixes #59201. Thanks @larrylhollan. - Gateway/subagents: keep direct-loopback backend RPCs authenticated with the shared gateway token/password off stale CLI paired-device scope baselines, so internal calls no longer hit `scope-upgrade` pairing prompts while remote, diff --git a/src/agents/subagent-announce-delivery.test.ts b/src/agents/subagent-announce-delivery.test.ts index 3f8d3da6584..f36513a6048 100644 --- a/src/agents/subagent-announce-delivery.test.ts +++ b/src/agents/subagent-announce-delivery.test.ts @@ -10,8 +10,10 @@ import { sendMessage as runtimeSendMessage, } from "./subagent-announce-delivery.runtime.js"; import { resolveAnnounceOrigin } from "./subagent-announce-origin.js"; +import { resetAnnounceQueuesForTests } from "./subagent-announce-queue.js"; afterEach(() => { + resetAnnounceQueuesForTests(); __testing.setDepsForTest(); }); @@ -226,6 +228,138 @@ describe("resolveAnnounceOrigin threaded route targets", () => { }); }); +describe("deliverSubagentAnnouncement queued delivery", () => { + async function deliverQueuedAnnouncement(params: { + requesterOrigin?: { + channel?: string; + to?: string; + accountId?: string; + threadId?: string | number; + }; + }) { + const callGateway = createGatewayMock(); + let activityChecks = 0; + __testing.setDepsForTest({ + callGateway, + getRequesterSessionActivity: () => ({ + sessionId: "paperclip-session", + isActive: activityChecks++ === 0, + }), + loadConfig: () => + ({ + messages: { + queue: { + mode: "followup", + debounceMs: 0, + }, + }, + }) as never, + }); + + const result = await deliverSubagentAnnouncement({ + requesterSessionKey: "agent:eng:paperclip:issue:123", + targetRequesterSessionKey: "agent:eng:paperclip:issue:123", + triggerMessage: "child done", + steerMessage: "child done", + requesterOrigin: params.requesterOrigin, + requesterIsSubagent: false, + expectsCompletionMessage: false, + directIdempotencyKey: "announce-no-external-route", + }); + + expect(result).toEqual( + expect.objectContaining({ + delivered: true, + path: "queued", + }), + ); + await vi.waitFor(() => expect(callGateway).toHaveBeenCalledTimes(1)); + return callGateway; + } + + it("keeps queued announces with no external route session-only", async () => { + const callGateway = await deliverQueuedAnnouncement({}); + + expect(callGateway).toHaveBeenCalledWith( + expect.objectContaining({ + method: "agent", + params: expect.objectContaining({ + sessionKey: "agent:eng:paperclip:issue:123", + deliver: false, + channel: undefined, + accountId: undefined, + to: undefined, + threadId: undefined, + }), + }), + ); + }); + + it("keeps queued announces with channel-only origins session-only", async () => { + const callGateway = await deliverQueuedAnnouncement({ + requesterOrigin: { + channel: "slack", + }, + }); + + expect(callGateway).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + deliver: false, + channel: undefined, + to: undefined, + }), + }), + ); + }); + + it("keeps queued announces with internal origins session-only", async () => { + const callGateway = await deliverQueuedAnnouncement({ + requesterOrigin: { + channel: "webchat", + to: "internal:room", + accountId: "acct-1", + threadId: "thread-1", + }, + }); + + expect(callGateway).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + deliver: false, + channel: undefined, + accountId: undefined, + to: undefined, + threadId: undefined, + }), + }), + ); + }); + + it("preserves queued external route fields when channel and target are present", async () => { + const callGateway = await deliverQueuedAnnouncement({ + requesterOrigin: { + channel: "slack", + to: "channel:C123", + accountId: "acct-1", + threadId: "171.222", + }, + }); + + expect(callGateway).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + deliver: true, + channel: "slack", + accountId: "acct-1", + to: "channel:C123", + threadId: "171.222", + }), + }), + ); + }); +}); + describe("deliverSubagentAnnouncement completion delivery", () => { it("keeps completion announces session-internal while preserving route context for active requesters", async () => { const callGateway = createGatewayMock(); diff --git a/src/agents/subagent-announce-delivery.ts b/src/agents/subagent-announce-delivery.ts index 62b51be56f9..8202777faf6 100644 --- a/src/agents/subagent-announce-delivery.ts +++ b/src/agents/subagent-announce-delivery.ts @@ -356,6 +356,14 @@ async function sendAnnounce(item: AnnounceQueueItem) { const origin = item.origin; const threadId = origin?.threadId != null && origin.threadId !== "" ? String(origin.threadId) : undefined; + const deliveryTarget = !requesterIsSubagent + ? resolveExternalBestEffortDeliveryTarget({ + channel: origin?.channel, + to: origin?.to, + accountId: origin?.accountId, + threadId, + }) + : { deliver: false }; const idempotencyKey = buildAnnounceIdempotencyKey( resolveQueueAnnounceId({ announceId: item.announceId, @@ -368,11 +376,11 @@ async function sendAnnounce(item: AnnounceQueueItem) { params: { sessionKey: item.sessionKey, message: item.prompt, - channel: requesterIsSubagent ? undefined : origin?.channel, - accountId: requesterIsSubagent ? undefined : origin?.accountId, - to: requesterIsSubagent ? undefined : origin?.to, - threadId: requesterIsSubagent ? undefined : threadId, - deliver: !requesterIsSubagent, + channel: deliveryTarget.deliver ? deliveryTarget.channel : undefined, + accountId: deliveryTarget.deliver ? deliveryTarget.accountId : undefined, + to: deliveryTarget.deliver ? deliveryTarget.to : undefined, + threadId: deliveryTarget.deliver ? deliveryTarget.threadId : undefined, + deliver: deliveryTarget.deliver, internalEvents: item.internalEvents, inputProvenance: { kind: "inter_session",