Skip to content

Commit 9487426

Browse files
movefasterclaude
andcommitted
fix(agents): deliver DM media completions directly when requester handoff fails
Background image_generate completions were silently dropped when the requester session's captured origin had no deliverable channel: the MCP-bridged claude-cli runtime collapses it to the internal "webchat" channel, so deliveryTarget.deliver was false and every direct-delivery fallback no-op'd. Backfill the completion origin from the requester session's persisted delivery context (deliveryContextFromSession), and deliver generated media directly for DM targets when the requester-agent handoff fails, not only on session-write-lock errors. Adds a regression test that supplies no deliverable origin in any param and asserts delivery routes to the backfilled target (negative-control verified: fails without the backfill). Full delivery suite: 102 passing. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
1 parent 4ea1b4f commit 9487426

2 files changed

Lines changed: 186 additions & 8 deletions

File tree

src/agents/subagent-announce-delivery.test.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
// Subagent announce delivery tests cover the last-mile routing used when child
22
// runs report progress or completion back to the requester session.
33
import { afterEach, describe, expect, it, vi } from "vitest";
4+
import {
5+
useTempSessionsFixture,
6+
writeSessionStoreForTest,
7+
} from "../config/sessions/test-helpers.js";
48
import { OutboundDeliveryError } from "../infra/outbound/deliver-types.js";
59
import {
610
testing as sessionBindingServiceTesting,
@@ -4115,6 +4119,57 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
41154119
expect(sendMessage).not.toHaveBeenCalled();
41164120
});
41174121

4122+
it("directly delivers DM media completions when the requester handoff fails", async () => {
4123+
// Companion to the Slack-channel case above. For a *direct-message* target a
4124+
// failed requester-agent handoff must NOT drop the user-requested media —
4125+
// regression test for background image_generate completions that were
4126+
// silently lost when the requester session could not be woken
4127+
// (no_active_run on the MCP-bridged CLI runtime), leaving the user with
4128+
// nothing until they pinged again.
4129+
const callGateway = vi.fn(async () => {
4130+
throw new Error("requester handoff exploded after dispatch");
4131+
}) as unknown as typeof runtimeCallGateway;
4132+
const sendMessage = createSendMessageMock();
4133+
4134+
const result = await deliverDiscordDirectMessageCompletion({
4135+
callGateway,
4136+
sendMessage,
4137+
sourceTool: "image_generate",
4138+
internalEvents: [
4139+
{
4140+
type: "task_completion",
4141+
source: "image_generation",
4142+
childSessionKey: "image_generate:task-dm-error",
4143+
childSessionId: "task-dm-error",
4144+
announceType: "image generation task",
4145+
taskLabel: "errored handoff dm image",
4146+
status: "ok",
4147+
statusLabel: "completed successfully",
4148+
result: "Generated 1 image.\nMEDIA:/tmp/generated-dm-error.png",
4149+
mediaUrls: ["/tmp/generated-dm-error.png"],
4150+
replyInstruction:
4151+
"Tell the user the image is ready and send it through the message tool.",
4152+
},
4153+
],
4154+
});
4155+
4156+
expectRecordFields(result, {
4157+
delivered: true,
4158+
path: "direct",
4159+
});
4160+
expect(callGateway).toHaveBeenCalledTimes(1);
4161+
expect(sendMessage).toHaveBeenCalledWith(
4162+
expect.objectContaining({
4163+
channel: "discord",
4164+
accountId: "acct-1",
4165+
to: "dm:U123",
4166+
content: "The generated image is ready.",
4167+
mediaUrls: ["/tmp/generated-dm-error.png"],
4168+
idempotencyKey: "announce-dm-fallback-empty:generated-media-direct",
4169+
}),
4170+
);
4171+
});
4172+
41184173
it("runs inactive isolated cron media completions through the requester agent first", async () => {
41194174
const callGateway = createGatewayMock({
41204175
result: {
@@ -4811,3 +4866,95 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
48114866
});
48124867
});
48134868
});
4869+
4870+
describe("deliverSubagentAnnouncement origin backfill from session store", () => {
4871+
// Scoped temp session store so we can seed the requester session's persisted
4872+
// delivery context (lastChannel/lastTo) without touching the real store.
4873+
const sessionsFixture = useTempSessionsFixture("openclaw-announce-backfill-");
4874+
4875+
it("backfills an empty completion origin from the requester session's stored delivery context", async () => {
4876+
// Regression test for the real background-task drop: the claude-cli /
4877+
// MCP-bridged runtime captures an EMPTY (or internal "webchat") completion
4878+
// origin, so the captured origin carries no deliverable channel. Without
4879+
// backfilling the requester session's persisted lastChannel/lastTo, the
4880+
// media completion has no external target and is silently dropped (the user
4881+
// gets nothing until they ping again).
4882+
//
4883+
// This test supplies NO deliverable origin in any param — completion/direct/
4884+
// session origins are all omitted. Delivery can therefore only succeed if
4885+
// deliveryContextFromSession(entry) rescues the Telegram target from the
4886+
// session store. Without the backfill the announce handoff returns no
4887+
// visible output and the result is a `visible_reply_missing` drop with no
4888+
// sendMessage call.
4889+
const requesterSessionKey = "agent:main:telegram:75597985";
4890+
writeSessionStoreForTest(sessionsFixture.storePath(), {
4891+
[requesterSessionKey]: {
4892+
sessionId: "requester-session-telegram",
4893+
lastChannel: "telegram",
4894+
lastTo: "75597985",
4895+
lastAccountId: "bot-1",
4896+
},
4897+
});
4898+
4899+
// Handoff succeeds (does not throw) but produces no visible output — the
4900+
// `private-final` case where the woken turn never calls the message tool.
4901+
const callGateway = createGatewayMock({ result: { payloads: [] } });
4902+
const sendMessage = createSendMessageMock();
4903+
testing.setDepsForTest({
4904+
callGateway,
4905+
getRequesterSessionActivity: () => ({
4906+
sessionId: "requester-session-telegram",
4907+
isActive: false,
4908+
}),
4909+
getRuntimeConfig: () => ({ session: { store: sessionsFixture.storePath() } }) as never,
4910+
sendMessage,
4911+
});
4912+
4913+
const result = await deliverSubagentAnnouncement({
4914+
requesterSessionKey,
4915+
targetRequesterSessionKey: requesterSessionKey,
4916+
triggerMessage: "child done",
4917+
steerMessage: "child done",
4918+
// Deliberately no requesterOrigin / requesterSessionOrigin /
4919+
// completionDirectOrigin / directOrigin: the session-store backfill is the
4920+
// only possible source of a deliverable Telegram target.
4921+
requesterIsSubagent: false,
4922+
expectsCompletionMessage: true,
4923+
bestEffortDeliver: true,
4924+
directIdempotencyKey: "announce-telegram-backfill",
4925+
sourceTool: "image_generate",
4926+
internalEvents: [
4927+
{
4928+
type: "task_completion",
4929+
source: "image_generation",
4930+
childSessionKey: "image_generate:task-backfill",
4931+
childSessionId: "task-backfill",
4932+
announceType: "image generation task",
4933+
taskLabel: "backfill origin image",
4934+
status: "ok",
4935+
statusLabel: "completed successfully",
4936+
result: "Generated 1 image.\nMEDIA:/tmp/generated-backfill.png",
4937+
mediaUrls: ["/tmp/generated-backfill.png"],
4938+
replyInstruction:
4939+
"Tell the user the image is ready and send it through the message tool.",
4940+
},
4941+
],
4942+
});
4943+
4944+
expectRecordFields(result, {
4945+
delivered: true,
4946+
path: "direct",
4947+
});
4948+
// The Telegram target (channel/to/accountId) was resolved purely from the
4949+
// backfilled session entry — that is the behaviour under test.
4950+
expect(sendMessage).toHaveBeenCalledWith(
4951+
expect.objectContaining({
4952+
channel: "telegram",
4953+
accountId: "bot-1",
4954+
to: "75597985",
4955+
mediaUrls: ["/tmp/generated-backfill.png"],
4956+
idempotencyKey: "announce-telegram-backfill:generated-media-direct",
4957+
}),
4958+
);
4959+
});
4960+
});

src/agents/subagent-announce-delivery.ts

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ import {
2828
import { deriveSessionChatTypeFromKey } from "../sessions/session-chat-type-shared.js";
2929
import { isCronRunSessionKey, isCronSessionKey } from "../sessions/session-key-utils.js";
3030
import { isNonTerminalAgentRunStatus } from "../shared/agent-run-status.js";
31-
import { mergeDeliveryContext, normalizeDeliveryContext } from "../utils/delivery-context.js";
31+
import {
32+
deliveryContextFromSession,
33+
mergeDeliveryContext,
34+
normalizeDeliveryContext,
35+
} from "../utils/delivery-context.js";
3236
import {
3337
INTERNAL_MESSAGE_CHANNEL,
3438
isDeliverableMessageChannel,
@@ -1228,19 +1232,29 @@ async function sendSubagentAnnounceDirectly(params: {
12281232
// (channel, to, accountId) fall back to the originating session's
12291233
// lastChannel / lastTo. Without this, a completion origin that carries a
12301234
// channel but not a `to` would prevent external delivery.
1235+
const requesterEntry = loadRequesterSessionEntry(params.targetRequesterSessionKey).entry;
1236+
// Last-resort fallback: the requester session's persisted delivery context
1237+
// (lastChannel / lastTo / origin). This rescues completions whose captured
1238+
// origin never carried a deliverable channel — e.g. background tasks spawned
1239+
// from an MCP-bridged CLI runtime, where the tool context lacks the live
1240+
// inbound channel and the origin collapses to the internal "webchat" channel.
1241+
// Without this, the completion wake runs but the agent's reply is dropped.
1242+
const requesterStoredOrigin =
1243+
params.expectsCompletionMessage && !params.requesterIsSubagent
1244+
? deliveryContextFromSession(requesterEntry)
1245+
: undefined;
12311246
const externalCompletionDirectOrigin =
12321247
stripNonDeliverableChannelForCompletionOrigin(completionDirectOrigin);
12331248
const completionExternalFallbackOrigin = mergeDeliveryContext(
1234-
directOrigin,
1235-
requesterSessionOrigin,
1249+
mergeDeliveryContext(directOrigin, requesterSessionOrigin),
1250+
requesterStoredOrigin,
12361251
);
12371252
const effectiveDirectOrigin = params.expectsCompletionMessage
12381253
? mergeDeliveryContext(externalCompletionDirectOrigin, completionExternalFallbackOrigin)
12391254
: directOrigin;
12401255
const sessionOnlyOrigin = effectiveDirectOrigin?.channel
12411256
? effectiveDirectOrigin
1242-
: requesterSessionOrigin;
1243-
const requesterEntry = loadRequesterSessionEntry(params.targetRequesterSessionKey).entry;
1257+
: (requesterSessionOrigin ?? requesterStoredOrigin);
12441258
const deliveryTarget = !params.requesterIsSubagent
12451259
? resolveExternalBestEffortDeliveryTarget({
12461260
channel: effectiveDirectOrigin?.channel,
@@ -1476,14 +1490,31 @@ async function sendSubagentAnnounceDirectly(params: {
14761490
return textDelivery;
14771491
}
14781492
}
1493+
// Generated media is the user-requested result, so when the
1494+
// requester-agent handoff fails we deliver it directly rather than
1495+
// dropping it — but only for cases where direct delivery is unambiguously
1496+
// correct:
1497+
// * write-lock failures (any target): the lock is transient and the
1498+
// media already exists, so a direct send is the safe recovery.
1499+
// * direct-message targets (any non-permanent handoff failure): the user
1500+
// asked for this media in a DM; there is no channel/threading context
1501+
// that requires the requester agent to mediate. This is the path that
1502+
// was silently dropping background image_generate completions when the
1503+
// requester session could not be woken (e.g. no_active_run on the
1504+
// MCP-bridged CLI runtime), leaving the user with nothing until they
1505+
// pinged again.
1506+
// Channel/group completions intentionally fall through to the visible
1507+
// throw below so the requester run can re-deliver with proper context.
1508+
// tryGeneratedMediaDirectDelivery() no-ops when the session is genuinely
1509+
// active/wakeable or there is no deliverable target.
14791510
if (
1480-
activeRequesterWakeFailed &&
14811511
agentMediatedCompletion &&
14821512
expectedMediaUrls.length > 0 &&
1483-
isSessionWriteLockAnnounceAgentError(err)
1513+
(isSessionWriteLockAnnounceAgentError(err) ||
1514+
isDirectMessageDeliveryTarget(deliveryTarget, canonicalRequesterSessionKey))
14841515
) {
14851516
const generatedMediaDelivery = await tryGeneratedMediaDirectDelivery();
1486-
if (generatedMediaDelivery) {
1517+
if (generatedMediaDelivery?.delivered) {
14871518
return generatedMediaDelivery;
14881519
}
14891520
}

0 commit comments

Comments
 (0)