fix: include extension channels in subagent announce delivery path (#56348)

* fix: include extension channels in subagent announce delivery path

* test: cover extension announce delivery routes
This commit is contained in:
Tyler Yust 2026-03-28 21:15:23 +09:00 committed by GitHub
parent 107969c725
commit 41cf93efff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 100 additions and 19 deletions

View File

@ -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

View File

@ -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,

View File

@ -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<string, unknown> };
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<string, unknown> };
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<string, unknown>;
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;