fix(agents): keep queued announces session-only without route

This commit is contained in:
Peter Steinberger
2026-04-25 23:49:00 +01:00
parent 496d90c3b5
commit 76a0abc768
3 changed files with 150 additions and 5 deletions

View File

@@ -62,6 +62,9 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents/subagents: keep queued subagent announces session-only when the
requester has no external channel target, avoiding ambiguous multi-channel
delivery failures. Fixes #59201. Thanks @larrylhollan.
- Gateway/subagents: keep direct-loopback backend RPCs authenticated with the
shared gateway token/password off stale CLI paired-device scope baselines, so
internal calls no longer hit `scope-upgrade` pairing prompts while remote,

View File

@@ -10,8 +10,10 @@ import {
sendMessage as runtimeSendMessage,
} from "./subagent-announce-delivery.runtime.js";
import { resolveAnnounceOrigin } from "./subagent-announce-origin.js";
import { resetAnnounceQueuesForTests } from "./subagent-announce-queue.js";
afterEach(() => {
resetAnnounceQueuesForTests();
__testing.setDepsForTest();
});
@@ -226,6 +228,138 @@ describe("resolveAnnounceOrigin threaded route targets", () => {
});
});
describe("deliverSubagentAnnouncement queued delivery", () => {
async function deliverQueuedAnnouncement(params: {
requesterOrigin?: {
channel?: string;
to?: string;
accountId?: string;
threadId?: string | number;
};
}) {
const callGateway = createGatewayMock();
let activityChecks = 0;
__testing.setDepsForTest({
callGateway,
getRequesterSessionActivity: () => ({
sessionId: "paperclip-session",
isActive: activityChecks++ === 0,
}),
loadConfig: () =>
({
messages: {
queue: {
mode: "followup",
debounceMs: 0,
},
},
}) as never,
});
const result = await deliverSubagentAnnouncement({
requesterSessionKey: "agent:eng:paperclip:issue:123",
targetRequesterSessionKey: "agent:eng:paperclip:issue:123",
triggerMessage: "child done",
steerMessage: "child done",
requesterOrigin: params.requesterOrigin,
requesterIsSubagent: false,
expectsCompletionMessage: false,
directIdempotencyKey: "announce-no-external-route",
});
expect(result).toEqual(
expect.objectContaining({
delivered: true,
path: "queued",
}),
);
await vi.waitFor(() => expect(callGateway).toHaveBeenCalledTimes(1));
return callGateway;
}
it("keeps queued announces with no external route session-only", async () => {
const callGateway = await deliverQueuedAnnouncement({});
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
method: "agent",
params: expect.objectContaining({
sessionKey: "agent:eng:paperclip:issue:123",
deliver: false,
channel: undefined,
accountId: undefined,
to: undefined,
threadId: undefined,
}),
}),
);
});
it("keeps queued announces with channel-only origins session-only", async () => {
const callGateway = await deliverQueuedAnnouncement({
requesterOrigin: {
channel: "slack",
},
});
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({
deliver: false,
channel: undefined,
to: undefined,
}),
}),
);
});
it("keeps queued announces with internal origins session-only", async () => {
const callGateway = await deliverQueuedAnnouncement({
requesterOrigin: {
channel: "webchat",
to: "internal:room",
accountId: "acct-1",
threadId: "thread-1",
},
});
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({
deliver: false,
channel: undefined,
accountId: undefined,
to: undefined,
threadId: undefined,
}),
}),
);
});
it("preserves queued external route fields when channel and target are present", async () => {
const callGateway = await deliverQueuedAnnouncement({
requesterOrigin: {
channel: "slack",
to: "channel:C123",
accountId: "acct-1",
threadId: "171.222",
},
});
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({
deliver: true,
channel: "slack",
accountId: "acct-1",
to: "channel:C123",
threadId: "171.222",
}),
}),
);
});
});
describe("deliverSubagentAnnouncement completion delivery", () => {
it("keeps completion announces session-internal while preserving route context for active requesters", async () => {
const callGateway = createGatewayMock();

View File

@@ -356,6 +356,14 @@ async function sendAnnounce(item: AnnounceQueueItem) {
const origin = item.origin;
const threadId =
origin?.threadId != null && origin.threadId !== "" ? String(origin.threadId) : undefined;
const deliveryTarget = !requesterIsSubagent
? resolveExternalBestEffortDeliveryTarget({
channel: origin?.channel,
to: origin?.to,
accountId: origin?.accountId,
threadId,
})
: { deliver: false };
const idempotencyKey = buildAnnounceIdempotencyKey(
resolveQueueAnnounceId({
announceId: item.announceId,
@@ -368,11 +376,11 @@ async function sendAnnounce(item: AnnounceQueueItem) {
params: {
sessionKey: item.sessionKey,
message: item.prompt,
channel: requesterIsSubagent ? undefined : origin?.channel,
accountId: requesterIsSubagent ? undefined : origin?.accountId,
to: requesterIsSubagent ? undefined : origin?.to,
threadId: requesterIsSubagent ? undefined : threadId,
deliver: !requesterIsSubagent,
channel: deliveryTarget.deliver ? deliveryTarget.channel : undefined,
accountId: deliveryTarget.deliver ? deliveryTarget.accountId : undefined,
to: deliveryTarget.deliver ? deliveryTarget.to : undefined,
threadId: deliveryTarget.deliver ? deliveryTarget.threadId : undefined,
deliver: deliveryTarget.deliver,
internalEvents: item.internalEvents,
inputProvenance: {
kind: "inter_session",