mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user