fix: deliver subagent completions via external requester route

This commit is contained in:
Peter Steinberger
2026-04-25 12:19:38 +01:00
parent 924271385b
commit 67436918f3
4 changed files with 195 additions and 14 deletions

View File

@@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai
- Browser/Linux: fall back to headless mode for local managed profiles on hosts without a display server, while preserving explicit per-profile headed overrides and reporting the headless source. (#60953) Thanks @rrpsantos.
- Telegram: remove the startup persisted-offset `getUpdates` preflight so polling restarts do not self-conflict before the runner starts. Fixes #69304. (#69779) Thanks @chinar-amrutkar.
- Telegram: keep the polling stall watchdog active even when grammY reports the runner as not running while its task is still pending, so a rebuilt transport cannot leave `getUpdates` silent until a manual gateway restart. Fixes #69064. Thanks @LDLoeb.
- Subagents: fall back to direct completion delivery when the parent announce turn finishes without a visible payload, so child results still reach channel-backed requester sessions.
- Browser/Playwright: ignore benign already-handled route races during guarded navigation so browser-page tasks no longer fail when Playwright tears down a route mid-flight. (#68708) Thanks @Steady-ai.
- Browser/CLI: lazy-load browser command groups and plugin runtime services so `openclaw browser --help` can render without loading the full browser automation stack. Fixes #65400. (#65460, #66640) Thanks @pandego and @Tianworld.
- Browser/downloads: seed managed Chrome profiles with OpenClaw download prefs and capture unmanaged click-triggered downloads under the guarded downloads directory, while explicit download waiters still own their target file. (#64558) Thanks @Pearcekieser.

View File

@@ -113,6 +113,58 @@ async function deliverDiscordDirectMessageCompletion(params: {
});
}
async function deliverSlackChannelAnnouncement(params: {
callGateway: typeof runtimeCallGateway;
isActive: boolean;
sessionId: string;
expectsCompletionMessage: boolean;
directIdempotencyKey: string;
completionDirectOrigin?: {
channel?: string;
to?: string;
accountId?: string;
threadId?: string | number;
};
queueEmbeddedPiMessage?: (sessionId: string, message: string) => boolean;
sendMessage?: typeof runtimeSendMessage;
internalEvents?: AgentInternalEvent[];
}) {
const origin = {
channel: "slack",
to: "channel:C123",
accountId: "acct-1",
} as const;
__testing.setDepsForTest({
callGateway: params.callGateway,
getRequesterSessionActivity: () => ({
sessionId: params.sessionId,
isActive: params.isActive,
}),
loadConfig: () => ({}) as never,
...(params.queueEmbeddedPiMessage
? { queueEmbeddedPiMessage: params.queueEmbeddedPiMessage }
: {}),
...(params.sendMessage ? { sendMessage: params.sendMessage } : {}),
});
return deliverSubagentAnnouncement({
requesterSessionKey: "agent:main:slack:channel:C123",
targetRequesterSessionKey: "agent:main:slack:channel:C123",
triggerMessage: "child done",
steerMessage: "child done",
requesterOrigin: origin,
requesterSessionOrigin: origin,
completionDirectOrigin: params.completionDirectOrigin ?? origin,
directOrigin: origin,
requesterIsSubagent: false,
expectsCompletionMessage: params.expectsCompletionMessage,
bestEffortDeliver: true,
directIdempotencyKey: params.directIdempotencyKey,
internalEvents: params.internalEvents,
});
}
describe("resolveAnnounceOrigin threaded route targets", () => {
it("preserves stored thread ids when requester origin omits one for the same chat", () => {
expect(
@@ -399,7 +451,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
expect(result).toEqual(
expect.objectContaining({
delivered: true,
path: "direct-thread-fallback",
path: "direct-fallback",
}),
);
expect(callGateway).toHaveBeenCalledWith(
@@ -428,6 +480,106 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
);
});
it("uses a direct channel fallback when announce-agent returns no visible output", async () => {
const callGateway = createGatewayMock({
result: {
payloads: [],
},
});
const sendMessage = createSendMessageMock();
const result = await deliverSlackChannelAnnouncement({
callGateway,
sendMessage,
sessionId: "requester-session-channel",
isActive: false,
expectsCompletionMessage: true,
directIdempotencyKey: "announce-channel-fallback-empty",
internalEvents: [
{
type: "task_completion",
source: "subagent",
childSessionKey: "agent:worker:subagent:child",
childSessionId: "child-session-id",
announceType: "subagent task",
taskLabel: "channel completion smoke",
status: "ok",
statusLabel: "completed successfully",
result: "child completion output",
replyInstruction: "Summarize the result.",
},
],
});
expect(result).toEqual(
expect.objectContaining({
delivered: true,
path: "direct-fallback",
}),
);
expect(callGateway).toHaveBeenCalled();
expect(sendMessage).toHaveBeenCalledWith(
expect.objectContaining({
channel: "slack",
accountId: "acct-1",
to: "channel:C123",
threadId: undefined,
content: "child completion output",
requesterSessionKey: "agent:main:slack:channel:C123",
bestEffort: true,
idempotencyKey: "announce-channel-fallback-empty",
}),
);
});
it("falls back to the external requester route when completion origin is internal", async () => {
const callGateway = createGatewayMock({
result: {
payloads: [{ text: "child completion output" }],
},
});
const result = await deliverSlackChannelAnnouncement({
callGateway,
sessionId: "requester-session-channel",
isActive: false,
expectsCompletionMessage: true,
directIdempotencyKey: "announce-channel-internal-origin",
completionDirectOrigin: {
channel: "webchat",
},
internalEvents: [
{
type: "task_completion",
source: "subagent",
childSessionKey: "agent:worker:subagent:child",
childSessionId: "child-session-id",
announceType: "subagent task",
taskLabel: "channel completion smoke",
status: "ok",
statusLabel: "completed successfully",
result: "child completion output",
replyInstruction: "Summarize the result.",
},
],
});
expect(result).toEqual(
expect.objectContaining({
delivered: true,
path: "direct",
}),
);
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({
deliver: true,
channel: "slack",
accountId: "acct-1",
to: "channel:C123",
}),
}),
);
});
it("keeps direct external delivery for non-completion announces", async () => {
const callGateway = createGatewayMock();
await deliverSlackThreadAnnouncement({

View File

@@ -11,6 +11,7 @@ import {
} from "../utils/delivery-context.js";
import {
INTERNAL_MESSAGE_CHANNEL,
isDeliverableMessageChannel,
isGatewayMessageChannel,
isInternalMessageChannel,
normalizeMessageChannel,
@@ -548,7 +549,7 @@ function hasVisibleGatewayAgentPayload(response: unknown): boolean {
});
}
async function sendThreadCompletionFallback(params: {
async function sendCompletionFallback(params: {
cfg: OpenClawConfig;
channel?: string;
to?: string;
@@ -567,7 +568,9 @@ async function sendThreadCompletionFallback(params: {
return false;
}
await runAnnounceDeliveryWithRetry({
operation: "completion direct thread fallback send",
operation: params.threadId
? "completion direct thread fallback send"
: "completion direct fallback send",
signal: params.signal,
run: async () =>
await subagentAnnounceDeliveryDeps.sendMessage({
@@ -586,6 +589,25 @@ async function sendThreadCompletionFallback(params: {
return true;
}
function resolveCompletionFallbackPath(threadId: string | undefined) {
return threadId ? ("direct-thread-fallback" as const) : ("direct-fallback" as const);
}
function stripNonDeliverableChannelForCompletionOrigin(
context?: DeliveryContext,
): DeliveryContext | undefined {
const normalized = normalizeDeliveryContext(context);
if (!normalized?.channel) {
return normalized;
}
const channel = normalizeMessageChannel(normalized.channel);
if (!channel || isDeliverableMessageChannel(channel)) {
return normalized;
}
const { channel: _channel, ...rest } = normalized;
return normalizeDeliveryContext(rest);
}
async function sendSubagentAnnounceDirectly(params: {
targetRequesterSessionKey: string;
triggerMessage: string;
@@ -622,10 +644,15 @@ async function sendSubagentAnnounceDirectly(params: {
// (channel, to, accountId) fall back to the originating session's
// lastChannel / lastTo. Without this, a completion origin that carries a
// channel but not a `to` would prevent external delivery.
const effectiveDirectOrigin =
params.expectsCompletionMessage && completionDirectOrigin
? mergeDeliveryContext(completionDirectOrigin, directOrigin)
: directOrigin;
const externalCompletionDirectOrigin =
stripNonDeliverableChannelForCompletionOrigin(completionDirectOrigin);
const completionExternalFallbackOrigin = mergeDeliveryContext(
directOrigin,
requesterSessionOrigin,
);
const effectiveDirectOrigin = params.expectsCompletionMessage
? mergeDeliveryContext(externalCompletionDirectOrigin, completionExternalFallbackOrigin)
: directOrigin;
const sessionOnlyOrigin = effectiveDirectOrigin?.channel
? effectiveDirectOrigin
: requesterSessionOrigin;
@@ -673,7 +700,7 @@ async function sendSubagentAnnounceDirectly(params: {
path: "none",
};
}
const threadCompletionFallbackText =
const completionFallbackText =
params.expectsCompletionMessage && deliveryTarget.deliver
? extractThreadCompletionFallbackText(params.internalEvents)
: "";
@@ -722,13 +749,13 @@ async function sendSubagentAnnounceDirectly(params: {
}),
});
} catch (err) {
const didFallback = await sendThreadCompletionFallback({
const didFallback = await sendCompletionFallback({
cfg,
channel: deliveryTarget.channel,
to: deliveryTarget.to,
accountId: deliveryTarget.accountId,
threadId: deliveryTarget.threadId,
content: threadCompletionFallbackText,
content: deliveryTarget.threadId ? completionFallbackText : "",
requesterSessionKey: canonicalRequesterSessionKey,
bestEffortDeliver: params.bestEffortDeliver,
idempotencyKey: params.directIdempotencyKey,
@@ -743,14 +770,14 @@ async function sendSubagentAnnounceDirectly(params: {
throw err;
}
if (threadCompletionFallbackText && !hasVisibleGatewayAgentPayload(directAnnounceResponse)) {
const didFallback = await sendThreadCompletionFallback({
if (completionFallbackText && !hasVisibleGatewayAgentPayload(directAnnounceResponse)) {
const didFallback = await sendCompletionFallback({
cfg,
channel: deliveryTarget.channel,
to: deliveryTarget.to,
accountId: deliveryTarget.accountId,
threadId: deliveryTarget.threadId,
content: threadCompletionFallbackText,
content: completionFallbackText,
requesterSessionKey: canonicalRequesterSessionKey,
bestEffortDeliver: params.bestEffortDeliver,
idempotencyKey: params.directIdempotencyKey,
@@ -759,7 +786,7 @@ async function sendSubagentAnnounceDirectly(params: {
if (didFallback) {
return {
delivered: true,
path: "direct-thread-fallback",
path: resolveCompletionFallbackPath(deliveryTarget.threadId),
};
}
}

View File

@@ -2,6 +2,7 @@ export type SubagentDeliveryPath =
| "queued"
| "steered"
| "direct"
| "direct-fallback"
| "direct-thread-fallback"
| "none";