fix(announce-delivery): backfill effectiveDirectOrigin.to from requester session entry

When a media-generation task is created off the direct-reply path (heartbeat,
cron, subagent spawn), `agentTo` is undefined and the persisted
`requesterOrigin` lacks `to`. Every downstream `Boolean(channel && to)` gate
then short-circuits, so the generated artifact is never delivered even though
the artifact exists on disk and `task_runs.status` is later marked failed with
`completion delivery failed after successful generation`.

The requester session entry already carries `lastTo`/`lastChannel`/
`lastAccountId` and is loaded in the same function further down. Merge that
context back into `effectiveDirectOrigin` before the deliverability decision,
as the existing comment at the same site already promises.

Fixes #86034 (Hypothesis A). Hypothesis B (wake-false skips direct fallback)
remains a separate follow-up - see issue thread for details.
This commit is contained in:
wanglu241
2026-06-04 01:35:59 +08:00
committed by Ayaan Zaidi
parent 5d48a2ec54
commit e5f3df6538
2 changed files with 96 additions and 7 deletions
@@ -1,5 +1,8 @@
// Subagent announce delivery tests cover the last-mile routing used when child
// runs report progress or completion back to the requester session.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { OutboundDeliveryError } from "../infra/outbound/deliver-types.js";
import {
@@ -4849,3 +4852,82 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
});
});
});
describe("deliverSubagentAnnouncement requester session backfill (issue #86034)", () => {
// Regression: image_generate launched from a non-direct-reply turn (heartbeat,
// cron, subagent spawn) supplies a completion origin missing `to`. The
// already-loaded requester session entry carries `lastTo`/`lastChannel`, so
// effectiveDirectOrigin must backfill from it before the deliverability gate,
// otherwise generated media is silently dropped.
it("backfills to/channel/accountId from the requester session entry when completion origin is missing them", async () => {
const agentId = `backfill-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const sessionKey = `agent:${agentId}:telegram:5866004662`;
const storeTemplate = path.join(
os.tmpdir(),
`openclaw-86034-session-${agentId}-{agentId}.json`,
);
const storePath = storeTemplate.replaceAll("{agentId}", agentId);
await fs.writeFile(
storePath,
JSON.stringify(
{
[sessionKey]: {
sessionId: "telegram-session-1",
updatedAt: Date.now(),
channel: "telegram",
lastChannel: "telegram",
lastTo: "5866004662",
lastAccountId: "bot-1",
},
},
null,
2,
),
"utf-8",
);
const dispatchGatewayMethodInProcess = createInProcessGatewayMock({
result: { payloads: [{ text: "requester voice completion" }] },
});
testing.setDepsForTest({
dispatchGatewayMethodInProcess,
getRequesterSessionActivity: () => ({
sessionId: "telegram-session-1",
isActive: false,
}),
getRuntimeConfig: () => ({ session: { store: storeTemplate } }) as never,
});
const result = await deliverSubagentAnnouncement({
requesterSessionKey: sessionKey,
targetRequesterSessionKey: sessionKey,
triggerMessage: "image done",
steerMessage: "image done",
// Origin carries channel/accountId but NOT `to` — simulates an
// image_generate task created off the direct-reply path.
requesterOrigin: { channel: "telegram", accountId: "bot-1" },
requesterSessionOrigin: { channel: "telegram", accountId: "bot-1" },
completionDirectOrigin: { channel: "telegram", accountId: "bot-1" },
directOrigin: { channel: "telegram", accountId: "bot-1" },
requesterIsSubagent: false,
expectsCompletionMessage: true,
bestEffortDeliver: true,
directIdempotencyKey: "announce-86034-backfill",
sourceTool: "image_generate",
});
expectRecordFields(result, {
delivered: true,
path: "direct",
});
// The deliverability decision must see the backfilled `to`.
expectInProcessAgentParams(dispatchGatewayMethodInProcess, {
deliver: true,
channel: "telegram",
accountId: "bot-1",
to: "5866004662",
});
await fs.rm(storePath, { force: true });
});
});
+14 -7
View File
@@ -28,7 +28,11 @@ import {
import { deriveSessionChatTypeFromKey } from "../sessions/session-chat-type-shared.js";
import { isCronRunSessionKey, isCronSessionKey } from "../sessions/session-key-utils.js";
import { isNonTerminalAgentRunStatus } from "../shared/agent-run-status.js";
import { mergeDeliveryContext, normalizeDeliveryContext } from "../utils/delivery-context.js";
import {
deliveryContextFromSession,
mergeDeliveryContext,
normalizeDeliveryContext,
} from "../utils/delivery-context.js";
import {
INTERNAL_MESSAGE_CHANNEL,
isDeliverableMessageChannel,
@@ -1224,15 +1228,19 @@ async function sendSubagentAnnounceDirectly(params: {
const completionDirectOrigin = normalizeDeliveryContext(params.completionDirectOrigin);
const directOrigin = normalizeDeliveryContext(params.directOrigin);
const requesterSessionOrigin = normalizeDeliveryContext(params.requesterSessionOrigin);
// Merge completionDirectOrigin with directOrigin so that missing fields
// (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 requesterEntry = loadRequesterSessionEntry(params.targetRequesterSessionKey).entry;
// Backfill missing fields (channel, to, accountId) from the requester
// session entry's lastChannel/lastTo so a completion origin that carries
// a channel but not a `to` (e.g. heartbeat, cron, subagent spawn paths
// where `agentTo` is undefined) still resolves an external delivery
// target. Without this, every Boolean(channel && to) gate downstream
// short-circuits and generated media is silently dropped.
const requesterSessionDeliveryFallback = deliveryContextFromSession(requesterEntry);
const externalCompletionDirectOrigin =
stripNonDeliverableChannelForCompletionOrigin(completionDirectOrigin);
const completionExternalFallbackOrigin = mergeDeliveryContext(
directOrigin,
requesterSessionOrigin,
mergeDeliveryContext(requesterSessionOrigin, requesterSessionDeliveryFallback),
);
const effectiveDirectOrigin = params.expectsCompletionMessage
? mergeDeliveryContext(externalCompletionDirectOrigin, completionExternalFallbackOrigin)
@@ -1240,7 +1248,6 @@ async function sendSubagentAnnounceDirectly(params: {
const sessionOnlyOrigin = effectiveDirectOrigin?.channel
? effectiveDirectOrigin
: requesterSessionOrigin;
const requesterEntry = loadRequesterSessionEntry(params.targetRequesterSessionKey).entry;
const deliveryTarget = !params.requesterIsSubagent
? resolveExternalBestEffortDeliveryTarget({
channel: effectiveDirectOrigin?.channel,