fix: preserve requester route for subagent completion delivery (#72806)

* fix: preserve requester route for subagent completion delivery

* fix(agents): preserve requester subagent completion routes

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
sfuminya
2026-04-27 21:17:14 +08:00
committed by GitHub
parent dfd9dbe4e1
commit 2c57d70a10
5 changed files with 114 additions and 12 deletions

View File

@@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai
- Local models: default custom providers with only `baseUrl` to the Chat Completions adapter and trust loopback model requests automatically, so local OpenAI-compatible proxies receive `/v1/chat/completions` without timing out. Fixes #40024. Thanks @parachuteshe.
- Channels/message tool: surface Discord, Slack, and Mattermost `user:`/`channel:` target syntax in the shared message target schema and Discord ambiguity errors, so DM sends by numeric id stop burning retries before finding `user:<id>`. Fixes #72401. Thanks @garyd9, @hclsys, and @praveen9354.
- Agents/tools: scope tool-loop detection history to the active run when available, so scheduled heartbeat cycles no longer inherit stale repeated-call counts from previous runs. Fixes #40144. Thanks @mattbrown319.
- Agents/subagents: preserve requester delivery for completion announces when a child agent is bound to a different channel account while keeping same-channel thread completions routed to the child thread. Thanks @sfuminya.
- Control UI: show loading, reload, and retry states when a lazy dashboard panel cannot load after an upgrade, so the Logs tab no longer appears blank on stale browser bundles. Fixes #72450. Thanks @sobergou.
- Gateway/plugins: start the Gateway in degraded mode when a single plugin entry has invalid schema config, and let `openclaw doctor --fix` quarantine that plugin config instead of crash-looping every channel. Fixes #62976 and #70371. Thanks @Doraemon-Claw and @pksidekyk.
- Agents/plugins: skip malformed plugin tools with missing schema objects and report plugin diagnostics, so one broken tool no longer crashes Anthropic agent runs. Fixes #69423. Thanks @jmnickels.

View File

@@ -1,9 +1,14 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
__testing as sessionBindingServiceTesting,
registerSessionBindingAdapter,
} from "../infra/outbound/session-binding-service.js";
import type { AgentInternalEvent } from "./internal-events.js";
import {
__testing,
deliverSubagentAnnouncement,
extractThreadCompletionFallbackText,
resolveSubagentCompletionOrigin,
} from "./subagent-announce-delivery.js";
import {
callGateway as runtimeCallGateway,
@@ -14,6 +19,7 @@ import { resetAnnounceQueuesForTests } from "./subagent-announce-queue.js";
afterEach(() => {
resetAnnounceQueuesForTests();
sessionBindingServiceTesting.resetSessionBindingAdaptersForTests();
__testing.setDepsForTest();
});
@@ -270,6 +276,77 @@ describe("resolveAnnounceOrigin threaded route targets", () => {
});
});
describe("resolveSubagentCompletionOrigin", () => {
it("resolves bound completion delivery from the requester session, not the child session", async () => {
registerSessionBindingAdapter({
channel: "discord",
accountId: "bot-alpha",
listBySession: (targetSessionKey: string) => {
if (targetSessionKey === "agent:worker:subagent:child") {
return [
{
bindingId: "discord:bot-alpha:child-window",
targetSessionKey,
targetKind: "subagent",
conversation: {
channel: "discord",
accountId: "bot-alpha",
conversationId: "child-window",
},
status: "active",
boundAt: 1,
},
];
}
return [];
},
resolveByConversation: () => null,
});
registerSessionBindingAdapter({
channel: "discord",
accountId: "acct-1",
listBySession: (targetSessionKey: string) => {
if (targetSessionKey === "agent:main:main") {
return [
{
bindingId: "discord:acct-1:parent-main",
targetSessionKey,
targetKind: "session",
conversation: {
channel: "discord",
accountId: "acct-1",
conversationId: "parent-main",
},
status: "active",
boundAt: 1,
},
];
}
return [];
},
resolveByConversation: () => null,
});
const origin = await resolveSubagentCompletionOrigin({
childSessionKey: "agent:worker:subagent:child",
requesterSessionKey: "agent:main:main",
requesterOrigin: {
channel: "discord",
accountId: "acct-1",
to: "channel:parent-main",
},
spawnMode: "session",
expectsCompletionMessage: true,
});
expect(origin).toEqual({
channel: "discord",
accountId: "acct-1",
to: "channel:parent-main",
});
});
});
describe("deliverSubagentAnnouncement queued delivery", () => {
async function deliverQueuedAnnouncement(params: {
requesterOrigin?: {

View File

@@ -306,11 +306,29 @@ export async function resolveSubagentCompletionOrigin(params: {
const requesterConversation: ConversationRef | undefined =
channel && conversationId ? { channel, accountId, conversationId } : undefined;
const route = createBoundDeliveryRouter().resolveDestination({
const router = createBoundDeliveryRouter();
const childRoute = router.resolveDestination({
eventKind: "task_completion",
targetSessionKey: params.childSessionKey,
requester: requesterConversation,
failClosed: false,
failClosed: true,
});
if (childRoute.mode === "bound" && childRoute.binding) {
return mergeDeliveryContext(
resolveBoundConversationOrigin({
bindingConversation: childRoute.binding.conversation,
requesterConversation,
requesterOrigin,
}),
requesterOrigin,
);
}
const route = router.resolveDestination({
eventKind: "task_completion",
targetSessionKey: params.requesterSessionKey,
requester: requesterConversation,
failClosed: true,
});
if (route.mode === "bound" && route.binding) {
return mergeDeliveryContext(

View File

@@ -163,9 +163,8 @@ describe("spawnSubagentDirect thread binding delivery", () => {
expect.objectContaining({
requesterOrigin: {
channel: "matrix",
accountId: "bot-alpha",
accountId: "bot-beta",
to: `room:${boundRoom}`,
threadId: "$thread-root",
},
expectsCompletionMessage: false,
spawnMode: "session",

View File

@@ -725,7 +725,15 @@ export async function spawnSubagentDirect(
};
}
const targetAgentId = requestedAgentId ? normalizeAgentId(requestedAgentId) : requesterAgentId;
const requesterOrigin = resolveRequesterOriginForChild({
const requesterOrigin = normalizeDeliveryContext({
channel: ctx.agentChannel,
accountId: ctx.agentAccountId,
to: ctx.agentTo,
...(ctx.agentThreadId != null && ctx.agentThreadId !== ""
? { threadId: ctx.agentThreadId }
: {}),
});
let childSessionOrigin = resolveRequesterOriginForChild({
cfg,
targetAgentId,
requesterAgentId,
@@ -736,7 +744,6 @@ export async function spawnSubagentDirect(
requesterGroupSpace: ctx.agentGroupSpace,
requesterMemberRoleIds: ctx.agentMemberRoleIds,
});
let childSessionOrigin = requesterOrigin;
if (targetAgentId !== requesterAgentId) {
const allowAgents =
resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ??
@@ -892,10 +899,10 @@ export async function spawnSubagentDirect(
mode: spawnMode,
requesterSessionKey: requesterInternalKey,
requester: {
channel: requesterOrigin?.channel,
accountId: requesterOrigin?.accountId,
to: requesterOrigin?.to,
threadId: requesterOrigin?.threadId,
channel: childSessionOrigin?.channel,
accountId: childSessionOrigin?.accountId,
to: childSessionOrigin?.to,
threadId: childSessionOrigin?.threadId,
},
});
if (bindResult.status === "error") {
@@ -917,7 +924,7 @@ export async function spawnSubagentDirect(
threadBindingReady = true;
hasBoundThreadDeliveryOrigin = hasRoutableDeliveryOrigin(bindResult.deliveryOrigin);
childSessionOrigin =
mergeDeliveryContext(bindResult.deliveryOrigin, requesterOrigin) ?? childSessionOrigin;
mergeDeliveryContext(bindResult.deliveryOrigin, childSessionOrigin) ?? childSessionOrigin;
}
const mountPathHint = sanitizeMountPathHint(params.attachMountPath);
@@ -1152,7 +1159,7 @@ export async function spawnSubagentDirect(
childSessionKey,
controllerSessionKey: requesterInternalKey,
requesterSessionKey: requesterInternalKey,
requesterOrigin: childSessionOrigin,
requesterOrigin,
requesterDisplayKey,
task,
cleanup,