mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 12:50:42 +00:00
fix: deliver subagent completions via external requester route
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export type SubagentDeliveryPath =
|
||||
| "queued"
|
||||
| "steered"
|
||||
| "direct"
|
||||
| "direct-fallback"
|
||||
| "direct-thread-fallback"
|
||||
| "none";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user