Skip to content

Commit 6c8974f

Browse files
committed
fix: harden async media completion delivery
1 parent 349ce00 commit 6c8974f

8 files changed

Lines changed: 138 additions & 8 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ Docs: https://docs.openclaw.ai
109109
- Active Memory: give timeout partial transcript recovery enough abort-settle headroom so temporary recall summaries are returned before cleanup. Thanks @vincentkoc.
110110
- Gateway/chat: clear the active reply-run guard before draining queued same-session follow-up turns, so sequential `chat.send` calls no longer trip `ReplyRunAlreadyActiveError` every other request. Fixes #77485. Thanks @bws14email.
111111
- Agents/media: avoid sending generated image, video, and music attachments twice when streamed reply text arrives before the final `MEDIA:` directive.
112+
- Agents/media: tell async music and video completion agents when normal final replies are private, and send completion fallbacks directly to message-tool-only group/channel routes when the completion agent still only writes a private final reply, so generated media does not disappear behind the delivery contract.
112113
- CLI/sessions: cap `openclaw sessions` output to the newest 100 rows by default and add `--limit <n|all>` plus JSON pagination metadata, so repeated machine polling of large session stores cannot fan out into unbounded per-row enrichment/output work. Fixes #77500. Thanks @Kaotic3.
113114
- Doctor/config: restore legacy group chat config migrations for `routing.allowFrom`, `routing.groupChat.*`, and `channels.telegram.requireMention` so upgrades keep WhatsApp, Telegram, and iMessage group mention gates and history settings instead of leaving configs invalid or silently blocked. Thanks @scoootscooob.
114115
- CLI/update: make package-update follow-up processes write completion results and exit explicitly, so Windows packaged upgrades do not hang after the new package finishes post-core plugin work. Thanks @vincentkoc.

docs/automation/tasks.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ Not every agent run creates a task. Heartbeat turns and normal interactive chat
102102
<Accordion title="Notify defaults for cron and media">
103103
Main-session cron tasks use `silent` notify policy by default — they create records for tracking but do not generate notifications. Isolated cron tasks also default to `silent` but are more visible because they run in their own session.
104104

105-
Session-backed `music_generate` and `video_generate` runs also use `silent` notify policy. They still create task records, but completion is handed back to the original agent session as an internal wake so the agent can write the follow-up message and attach the finished media itself. Group/channel completions follow the normal visible-reply policy, so the agent uses the message tool when source delivery requires it.
105+
Session-backed `music_generate` and `video_generate` runs also use `silent` notify policy. They still create task records, but completion is handed back to the original agent session as an internal wake so the agent can write the follow-up message and attach the finished media itself. Group/channel completions follow the normal visible-reply policy, so the agent uses the message tool when source delivery requires it. If the completion agent fails to produce message-tool delivery evidence in a tool-only route, OpenClaw sends the completion fallback directly to the original channel instead of leaving the media private.
106106

107107
</Accordion>
108108
<Accordion title="Concurrent video_generate guardrail">

docs/tools/media-overview.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,9 @@ id immediately, and tracks the job in the task ledger. The agent continues
9393
responding to other messages while the job runs. When the provider finishes,
9494
OpenClaw wakes the agent with the generated media paths so it can tell the
9595
user and, when required by source-delivery policy, relay the result through
96-
the message tool.
96+
the message tool. For message-tool-only group/channel routes, OpenClaw treats
97+
missing message-tool delivery evidence as a failed completion attempt and sends
98+
the generated media fallback directly to the original channel.
9799

98100
## Speech-to-text and Voice Call
99101

docs/tools/music-generation.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ For session-backed agent runs, OpenClaw starts music generation as a
1616
background task, tracks it in the task ledger, then wakes the agent again
1717
when the track is ready so the agent can tell the user and attach the
1818
finished audio. In group/channel chats that use message-tool-only visible
19-
delivery, the agent relays the result through the message tool.
19+
delivery, the agent relays the result through the message tool. If the
20+
completion agent writes only a private final reply, OpenClaw falls back to a
21+
direct channel send with the generated media. The completion wake explicitly
22+
warns the agent that normal final replies are private in those routes.
2023

2124
<Note>
2225
The built-in shared tool only appears when at least one music-generation

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1202,7 +1202,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
12021202
expect(sendMessage).not.toHaveBeenCalled();
12031203
});
12041204

1205-
it("requires message-tool delivery for generated media completions in default group routes", async () => {
1205+
it("falls back to direct send for generated media completions in default group routes", async () => {
12061206
const callGateway = createGatewayMock({
12071207
result: {
12081208
payloads: [
@@ -1241,8 +1241,8 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
12411241

12421242
expect(result).toEqual(
12431243
expect.objectContaining({
1244-
delivered: false,
1245-
path: "direct",
1244+
delivered: true,
1245+
path: "direct-fallback",
12461246
}),
12471247
);
12481248
expect(callGateway).toHaveBeenCalledWith(
@@ -1257,7 +1257,18 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
12571257
}),
12581258
}),
12591259
);
1260-
expect(sendMessage).not.toHaveBeenCalled();
1260+
expect(sendMessage).toHaveBeenCalledWith(
1261+
expect.objectContaining({
1262+
channel: "slack",
1263+
accountId: "acct-1",
1264+
to: "channel:C123",
1265+
threadId: undefined,
1266+
content: "Generated 1 track.\nMEDIA:/tmp/generated-night-drive.mp3",
1267+
requesterSessionKey: "agent:main:slack:channel:C123",
1268+
bestEffort: true,
1269+
idempotencyKey: "announce-channel-media-message-tool",
1270+
}),
1271+
);
12611272
});
12621273

12631274
it("uses a direct channel fallback when announce-agent returns no visible output", async () => {

src/agents/subagent-announce-delivery.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -885,7 +885,9 @@ async function sendSubagentAnnounceDirectly(params: {
885885
});
886886
const shouldDeliverAgentFinal = deliveryTarget.deliver && !requiresMessageToolDelivery;
887887
const completionFallbackText =
888-
params.expectsCompletionMessage && shouldDeliverAgentFinal && !agentMediatedCompletion
888+
params.expectsCompletionMessage &&
889+
deliveryTarget.deliver &&
890+
(!agentMediatedCompletion || requiresMessageToolDelivery)
889891
? extractThreadCompletionFallbackText(params.internalEvents)
890892
: "";
891893
const requesterActivity = resolveRequesterSessionActivity(canonicalRequesterSessionKey);
@@ -1070,6 +1072,24 @@ async function sendSubagentAnnounceDirectly(params: {
10701072
requiresMessageToolDelivery &&
10711073
!hasGatewayAgentMessagingToolDelivery(directAnnounceResponse)
10721074
) {
1075+
const didFallback = await sendCompletionFallback({
1076+
cfg,
1077+
channel: deliveryTarget.channel,
1078+
to: deliveryTarget.to,
1079+
accountId: deliveryTarget.accountId,
1080+
threadId: deliveryTarget.threadId,
1081+
content: completionFallbackText,
1082+
requesterSessionKey: canonicalRequesterSessionKey,
1083+
bestEffortDeliver: params.bestEffortDeliver,
1084+
idempotencyKey: params.directIdempotencyKey,
1085+
signal: params.signal,
1086+
});
1087+
if (didFallback) {
1088+
return {
1089+
delivered: true,
1090+
path: resolveCompletionFallbackPath(deliveryTarget.threadId),
1091+
};
1092+
}
10731093
return {
10741094
delivered: false,
10751095
path: "direct",

src/agents/tools/media-generate-background-shared.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import crypto from "node:crypto";
2+
import { SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js";
23
import type { OpenClawConfig } from "../../config/types.openclaw.js";
34
import { clearAgentRunContext, registerAgentRunContext } from "../../infra/agent-events.js";
45
import { formatErrorMessage } from "../../infra/errors.js";
56
import { createSubsystemLogger } from "../../logging/subsystem.js";
7+
import { deriveSessionChatTypeFromKey } from "../../sessions/session-chat-type-shared.js";
68
import {
79
completeTaskRunByRunId,
810
createRunningTaskRun,
@@ -222,8 +224,18 @@ function failMediaGenerationTaskRun(params: {
222224
function buildMediaGenerationReplyInstruction(params: {
223225
status: "ok" | "error";
224226
completionLabel: string;
227+
requiresMessageToolDelivery: boolean;
225228
}) {
226229
if (params.status === "ok") {
230+
if (params.requiresMessageToolDelivery) {
231+
return [
232+
`The ${params.completionLabel} is ready for the original channel/group chat.`,
233+
"This route requires message-tool delivery: the user will NOT see your normal assistant final reply.",
234+
'Call the message tool with action="send" to the original/current chat, put a short caption in the message, and attach the generated media paths from the result.',
235+
`After the message tool succeeds, reply only ${SILENT_REPLY_TOKEN}.`,
236+
"Do not put MEDIA: lines only in your final answer; that final answer is private in this chat.",
237+
].join(" ");
238+
}
227239
return `Tell the user the ${params.completionLabel} is ready. If visible source delivery requires the message tool, send it there with the generated media attached.`;
228240
}
229241
return [
@@ -233,6 +245,39 @@ function buildMediaGenerationReplyInstruction(params: {
233245
].join(" ");
234246
}
235247

248+
function inferMediaGenerationCompletionChatType(
249+
handle: MediaGenerationTaskHandle,
250+
): "direct" | "group" | "channel" | "unknown" {
251+
const sessionKeyChatType = deriveSessionChatTypeFromKey(handle.requesterSessionKey);
252+
if (sessionKeyChatType !== "unknown") {
253+
return sessionKeyChatType;
254+
}
255+
const to = handle.requesterOrigin?.to?.trim().toLowerCase();
256+
if (to?.startsWith("group:")) {
257+
return "group";
258+
}
259+
if (to?.startsWith("channel:")) {
260+
return "channel";
261+
}
262+
if (to?.startsWith("dm:") || to?.startsWith("direct:")) {
263+
return "direct";
264+
}
265+
return "unknown";
266+
}
267+
268+
function mediaGenerationCompletionRequiresMessageToolDelivery(params: {
269+
config?: OpenClawConfig;
270+
handle: MediaGenerationTaskHandle;
271+
}): boolean {
272+
const chatType = inferMediaGenerationCompletionChatType(params.handle);
273+
if (chatType === "group" || chatType === "channel") {
274+
const configuredMode =
275+
params.config?.messages?.groupChat?.visibleReplies ?? params.config?.messages?.visibleReplies;
276+
return configuredMode !== "automatic";
277+
}
278+
return params.config?.messages?.visibleReplies === "message_tool";
279+
}
280+
236281
async function wakeMediaGenerationTaskCompletion(params: {
237282
config?: OpenClawConfig;
238283
handle: MediaGenerationTaskHandle | null;
@@ -266,6 +311,10 @@ async function wakeMediaGenerationTaskCompletion(params: {
266311
replyInstruction: buildMediaGenerationReplyInstruction({
267312
status: params.status,
268313
completionLabel: params.completionLabel,
314+
requiresMessageToolDelivery: mediaGenerationCompletionRequiresMessageToolDelivery({
315+
config: params.config,
316+
handle: params.handle,
317+
}),
269318
}),
270319
},
271320
];

src/agents/tools/music-generate-background.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,50 @@ describe("music generate background helpers", () => {
9595
expect(announceDeliveryMocks.deliverSubagentAnnouncement).toHaveBeenCalled();
9696
});
9797

98+
it("warns channel completion agents that normal final replies are private", async () => {
99+
announceDeliveryMocks.deliverSubagentAnnouncement.mockResolvedValue({
100+
delivered: true,
101+
path: "direct",
102+
});
103+
const completion = createMediaCompletionFixture({
104+
runId: "tool:music_generate:abc",
105+
taskLabel: "night-drive synthwave",
106+
result: "Generated 1 track.\nMEDIA:/tmp/generated-night-drive.mp3",
107+
mediaUrls: ["/tmp/generated-night-drive.mp3"],
108+
});
109+
110+
await wakeMusicGenerationTaskCompletion({
111+
...completion,
112+
handle: {
113+
...completion.handle,
114+
requesterSessionKey: "agent:main:discord:channel:C123",
115+
},
116+
});
117+
118+
expect(announceDeliveryMocks.deliverSubagentAnnouncement).toHaveBeenCalledWith(
119+
expect.objectContaining({
120+
internalEvents: expect.arrayContaining([
121+
expect.objectContaining({
122+
replyInstruction: expect.stringContaining(
123+
"the user will NOT see your normal assistant final reply",
124+
),
125+
}),
126+
]),
127+
}),
128+
);
129+
expect(announceDeliveryMocks.deliverSubagentAnnouncement).toHaveBeenCalledWith(
130+
expect.objectContaining({
131+
internalEvents: expect.arrayContaining([
132+
expect.objectContaining({
133+
replyInstruction: expect.stringContaining(
134+
"Do not put MEDIA: lines only in your final answer",
135+
),
136+
}),
137+
]),
138+
}),
139+
);
140+
});
141+
98142
it("queues a completion event when direct send is enabled globally", async () => {
99143
taskDeliveryRuntimeMocks.sendMessage.mockResolvedValue({
100144
channel: "discord",

0 commit comments

Comments
 (0)