diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e03fd614de..dd034d56c46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/subagent-announce-delivery.test.ts b/src/agents/subagent-announce-delivery.test.ts index 54dfca750a0..3f8d3da6584 100644 --- a/src/agents/subagent-announce-delivery.test.ts +++ b/src/agents/subagent-announce-delivery.test.ts @@ -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({ diff --git a/src/agents/subagent-announce-delivery.ts b/src/agents/subagent-announce-delivery.ts index f89a2159af3..1658ca83c8d 100644 --- a/src/agents/subagent-announce-delivery.ts +++ b/src/agents/subagent-announce-delivery.ts @@ -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), }; } } diff --git a/src/agents/subagent-announce-dispatch.ts b/src/agents/subagent-announce-dispatch.ts index 66377a145e9..58be10d7c20 100644 --- a/src/agents/subagent-announce-dispatch.ts +++ b/src/agents/subagent-announce-dispatch.ts @@ -2,6 +2,7 @@ export type SubagentDeliveryPath = | "queued" | "steered" | "direct" + | "direct-fallback" | "direct-thread-fallback" | "none";