diff --git a/CHANGELOG.md b/CHANGELOG.md index bd7bba84195..72024111942 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,6 +116,7 @@ Docs: https://docs.openclaw.ai - Discord/replies: preserve leading indentation when stripping inline reply tags so reply-tagged plain text and fenced code blocks keep their formatting. (#55960) Thanks @Nanako0129. - Daemon/status: surface immediate gateway close reasons from lightweight probes and prefer those concrete auth or pairing failures over generic timeouts in `openclaw daemon status`. (#56282) Thanks @mbelinky. - Agents/failover: classify HTTP 410 errors as retryable timeouts by default while still preserving explicit session-expired, billing, and auth signals from the payload. (#55201) thanks @nikus-pan. +- Agents/subagents: restore completion announce delivery for extension channels like BlueBubbles. (#56348) ## 2026.3.24 diff --git a/src/agents/subagent-announce-delivery.ts b/src/agents/subagent-announce-delivery.ts index 087a54ae875..d87514446e7 100644 --- a/src/agents/subagent-announce-delivery.ts +++ b/src/agents/subagent-announce-delivery.ts @@ -21,11 +21,7 @@ import { normalizeDeliveryContext, resolveConversationDeliveryTarget, } from "../utils/delivery-context.js"; -import { - INTERNAL_MESSAGE_CHANNEL, - isDeliverableMessageChannel, - isInternalMessageChannel, -} from "../utils/message-channel.js"; +import { INTERNAL_MESSAGE_CHANNEL, isInternalMessageChannel } from "../utils/message-channel.js"; import { buildAnnounceIdempotencyKey, resolveQueueAnnounceId } from "./announce-idempotency.js"; import type { AgentInternalEvent } from "./internal-events.js"; import { isEmbeddedPiRunActive, queueEmbeddedPiMessage } from "./pi-embedded.js"; @@ -263,7 +259,10 @@ export async function resolveSubagentCompletionOrigin(params: { }, ); const hookOrigin = normalizeDeliveryContext(result?.origin); - if (!hookOrigin || (hookOrigin.channel && !isDeliverableMessageChannel(hookOrigin.channel))) { + if (!hookOrigin) { + return requesterOrigin; + } + if (hookOrigin.channel && isInternalMessageChannel(hookOrigin.channel)) { return requesterOrigin; } return mergeDeliveryContext(hookOrigin, requesterOrigin); @@ -459,19 +458,17 @@ async function sendSubagentAnnounceDirectly(params: { params.expectsCompletionMessage && completionDirectOrigin ? completionDirectOrigin : directOrigin; - const directChannelRaw = + const directChannel = typeof effectiveDirectOrigin?.channel === "string" ? effectiveDirectOrigin.channel.trim() : ""; - const directChannel = - directChannelRaw && isDeliverableMessageChannel(directChannelRaw) ? directChannelRaw : ""; const directTo = typeof effectiveDirectOrigin?.to === "string" ? effectiveDirectOrigin.to.trim() : ""; - const hasDeliverableDirectTarget = - !params.requesterIsSubagent && Boolean(directChannel) && Boolean(directTo); const shouldDeliverExternally = !params.requesterIsSubagent && - (!params.expectsCompletionMessage || hasDeliverableDirectTarget); + Boolean(directChannel) && + Boolean(directTo) && + !isInternalMessageChannel(directChannel); const threadId = effectiveDirectOrigin?.threadId != null && effectiveDirectOrigin.threadId !== "" @@ -497,10 +494,10 @@ async function sendSubagentAnnounceDirectly(params: { deliver: shouldDeliverExternally, bestEffortDeliver: params.bestEffortDeliver, internalEvents: params.internalEvents, - channel: shouldDeliverExternally ? directChannel : undefined, - accountId: shouldDeliverExternally ? effectiveDirectOrigin?.accountId : undefined, - to: shouldDeliverExternally ? directTo : undefined, - threadId: shouldDeliverExternally ? threadId : undefined, + channel: !params.requesterIsSubagent ? directChannel || undefined : undefined, + accountId: !params.requesterIsSubagent ? effectiveDirectOrigin?.accountId : undefined, + to: !params.requesterIsSubagent ? directTo || undefined : undefined, + threadId: !params.requesterIsSubagent ? threadId : undefined, inputProvenance: { kind: "inter_session", sourceSessionKey: params.sourceSessionKey, diff --git a/src/agents/subagent-announce.format.e2e.test.ts b/src/agents/subagent-announce.format.e2e.test.ts index 9f5ff25ead9..c75c583ee92 100644 --- a/src/agents/subagent-announce.format.e2e.test.ts +++ b/src/agents/subagent-announce.format.e2e.test.ts @@ -610,6 +610,27 @@ describe("subagent announce formatting", () => { expect(msg).not.toContain("✅ Subagent"); }); + it("keeps completion delivery enabled for extension channels captured from requester origin", async () => { + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct-completion-bluebubbles", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { channel: "bluebubbles", to: "+1234567890", accountId: "acct-bb" }, + ...defaultOutcomeAnnounce, + expectsCompletionMessage: true, + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).not.toHaveBeenCalled(); + expect(agentSpy).toHaveBeenCalledTimes(1); + const call = agentSpy.mock.calls[0]?.[0] as { params?: Record }; + expect(call?.params?.deliver).toBe(true); + expect(call?.params?.channel).toBe("bluebubbles"); + expect(call?.params?.to).toBe("+1234567890"); + expect(call?.params?.accountId).toBe("acct-bb"); + }); + it("keeps direct completion announce delivery immediate even when sibling counters are non-zero", async () => { sessionStore = { "agent:main:subagent:test": { @@ -1351,6 +1372,41 @@ describe("subagent announce formatting", () => { } }); + it("uses hook-provided extension channel targets for completion delivery", async () => { + hasSubagentDeliveryTargetHook = true; + subagentDeliveryTargetHookMock.mockResolvedValueOnce({ + origin: { + channel: "bluebubbles", + accountId: "acct-bb", + to: "+1234567890", + }, + }); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct-hook-bluebubbles", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { + channel: "discord", + to: "channel:12345", + accountId: "acct-1", + }, + ...defaultOutcomeAnnounce, + expectsCompletionMessage: true, + spawnMode: "session", + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).not.toHaveBeenCalled(); + expect(agentSpy).toHaveBeenCalledTimes(1); + const call = agentSpy.mock.calls[0]?.[0] as { params?: Record }; + expect(call?.params?.deliver).toBe(true); + expect(call?.params?.channel).toBe("bluebubbles"); + expect(call?.params?.to).toBe("+1234567890"); + expect(call?.params?.accountId).toBe("acct-bb"); + }); + it.each([ { name: "delivery-target hook returns no override", @@ -1358,7 +1414,7 @@ describe("subagent announce formatting", () => { hookResult: undefined, }, { - name: "delivery-target hook returns non-deliverable channel", + name: "delivery-target hook returns internal channel", childRunId: "run-direct-thread-multi-no-origin", hookResult: { origin: { @@ -1963,6 +2019,33 @@ describe("subagent announce formatting", () => { expect(call?.expectFinal).toBe(true); }); + it("keeps direct announce delivery enabled for extension channels", async () => { + embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); + embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct-bluebubbles", + requesterSessionKey: "agent:main:main", + requesterOrigin: { channel: "bluebubbles", accountId: "acct-bb", to: "+1234567890" }, + requesterDisplayKey: "main", + ...defaultOutcomeAnnounce, + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).not.toHaveBeenCalled(); + expect(agentSpy).toHaveBeenCalledTimes(1); + const call = agentSpy.mock.calls[0]?.[0] as { + params?: Record; + expectFinal?: boolean; + }; + expect(call?.params?.deliver).toBe(true); + expect(call?.params?.channel).toBe("bluebubbles"); + expect(call?.params?.to).toBe("+1234567890"); + expect(call?.params?.accountId).toBe("acct-bb"); + expect(call?.expectFinal).toBe(true); + }); + it("injects direct announce into requester subagent session as a user-turn agent call", async () => { embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); @@ -2817,7 +2900,7 @@ describe("subagent announce formatting", () => { }, }, expectedSessionKey: "agent:main:main", - expectedDeliver: true, + expectedDeliver: false, expectedChannel: "discord", }, { @@ -2839,7 +2922,7 @@ describe("subagent announce formatting", () => { }, }, expectedSessionKey: "agent:main:main", - expectedDeliver: true, + expectedDeliver: false, expectedChannel: "discord", }, ] as const;