diff --git a/CHANGELOG.md b/CHANGELOG.md index 25f47361e16..b422e773df7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:`. 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. diff --git a/src/agents/subagent-announce-delivery.test.ts b/src/agents/subagent-announce-delivery.test.ts index a595b4b4f02..53ab083581d 100644 --- a/src/agents/subagent-announce-delivery.test.ts +++ b/src/agents/subagent-announce-delivery.test.ts @@ -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?: { diff --git a/src/agents/subagent-announce-delivery.ts b/src/agents/subagent-announce-delivery.ts index 4c969f00ce1..4ff309f0b2a 100644 --- a/src/agents/subagent-announce-delivery.ts +++ b/src/agents/subagent-announce-delivery.ts @@ -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( diff --git a/src/agents/subagent-spawn.thread-binding.test.ts b/src/agents/subagent-spawn.thread-binding.test.ts index 84efe9ef0e1..80aef248f02 100644 --- a/src/agents/subagent-spawn.thread-binding.test.ts +++ b/src/agents/subagent-spawn.thread-binding.test.ts @@ -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", diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 060ae817dd8..762353f2395 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -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,