Skip to content

Commit 04c5bbf

Browse files
committed
fix(reply): dedupe block-streamed media
1 parent 98a9976 commit 04c5bbf

6 files changed

Lines changed: 131 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ Docs: https://docs.openclaw.ai
8080
- Config/recovery: skip whole-file last-known-good rollback when invalidity is scoped to `plugins.entries.*`, preserving unrelated user settings during plugin schema or host-version skew. Fixes #71289. Thanks @jalehman.
8181
- Agents/tools: keep resolved reply-run configs from being overwritten by stale runtime snapshots, and let empty web runtime metadata fall back to configured provider auto-detection so standard and queued turns expose the same tool set. Fixes #71355. Thanks @c-g14.
8282
- Agents/TTS: pass the resolved shared config into the `tts` tool, so tool-triggered speech uses configured providers and voices instead of falling back to a fresh config load.
83+
- Reply media: strip `MEDIA:` attachments from final replies when the same media already went out through block streaming, preventing duplicate Telegram voice notes and files. Fixes #65468. Thanks @aurora-openclaw.
8384
- Agents/TTS: preserve voice media when a tool-generated reply is paired with an exact `NO_REPLY` sentinel, stripping the sentinel text instead of dropping the audio payload. Fixes #66092.
8485
- Compaction: honor explicit `agents.defaults.compaction.keepRecentTokens` for manual `/compact`, re-distill safeguard summaries instead of snowballing previous summaries, and enable safeguard summary quality checks by default. Fixes #71357. Thanks @WhiteGiverMa.
8586
- Sessions: honor configured `session.maintenance` settings during load-time maintenance instead of falling back to default entry caps. Fixes #71356. Thanks @comolago.

src/auto-reply/reply/agent-runner-execution.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,7 @@ describe("runAgentTurnWithFallback", () => {
691691
didStream: vi.fn(() => false),
692692
isAborted: vi.fn(() => false),
693693
hasSentPayload: vi.fn(() => false),
694+
getSentMediaUrls: vi.fn(() => []),
694695
};
695696
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => {
696697
const result = { payloads: [], meta: {} };

src/auto-reply/reply/agent-runner-payloads.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,77 @@ describe("buildReplyPayloads media filter integration", () => {
201201
await expectSameTargetRepliesSuppressed({ provider: "lark", to: "ou_abc123" });
202202
});
203203

204+
it("strips media already sent by the block pipeline after normalizing both paths", async () => {
205+
const normalizeMediaPaths = async (payload: { mediaUrl?: string; mediaUrls?: string[] }) => {
206+
const rewrite = (value?: string) =>
207+
value === "file:///tmp/voice.ogg" ? "file:///tmp/outbound/voice.ogg" : value;
208+
return {
209+
...payload,
210+
mediaUrl: rewrite(payload.mediaUrl),
211+
mediaUrls: payload.mediaUrls?.map((value) => rewrite(value) ?? value),
212+
};
213+
};
214+
const pipeline: Parameters<typeof buildReplyPayloads>[0]["blockReplyPipeline"] = {
215+
didStream: () => false,
216+
isAborted: () => false,
217+
hasSentPayload: () => false,
218+
enqueue: () => {},
219+
flush: async () => {},
220+
stop: () => {},
221+
hasBuffered: () => false,
222+
getSentMediaUrls: () => ["file:///tmp/voice.ogg"],
223+
};
224+
225+
const { replyPayloads } = await buildReplyPayloads({
226+
...baseParams,
227+
blockStreamingEnabled: true,
228+
blockReplyPipeline: pipeline,
229+
normalizeMediaPaths,
230+
payloads: [{ text: "caption", mediaUrl: "file:///tmp/voice.ogg" }],
231+
});
232+
233+
expect(replyPayloads).toHaveLength(1);
234+
expect(replyPayloads[0]).toMatchObject({
235+
text: "caption",
236+
mediaUrl: undefined,
237+
mediaUrls: undefined,
238+
});
239+
});
240+
241+
it("suppresses already-sent text plus media before stripping block-sent media", async () => {
242+
const sentKey = JSON.stringify({
243+
text: "caption",
244+
mediaList: ["file:///tmp/outbound/voice.ogg"],
245+
});
246+
const pipeline: Parameters<typeof buildReplyPayloads>[0]["blockReplyPipeline"] = {
247+
didStream: () => false,
248+
isAborted: () => false,
249+
hasSentPayload: (payload) =>
250+
JSON.stringify({
251+
text: (payload.text ?? "").trim(),
252+
mediaList: [
253+
...(payload.mediaUrl ? [payload.mediaUrl] : []),
254+
...(payload.mediaUrls ?? []),
255+
],
256+
}) === sentKey,
257+
enqueue: () => {},
258+
flush: async () => {},
259+
stop: () => {},
260+
hasBuffered: () => false,
261+
getSentMediaUrls: () => ["file:///tmp/outbound/voice.ogg"],
262+
};
263+
264+
const { replyPayloads } = await buildReplyPayloads({
265+
...baseParams,
266+
blockStreamingEnabled: true,
267+
blockReplyPipeline: pipeline,
268+
normalizeMediaPaths: async (payload) => payload,
269+
payloads: [{ text: "caption", mediaUrl: "file:///tmp/outbound/voice.ogg" }],
270+
});
271+
272+
expect(replyPayloads).toHaveLength(0);
273+
});
274+
204275
it("drops all final payloads when block pipeline streamed successfully", async () => {
205276
const pipeline: Parameters<typeof buildReplyPayloads>[0]["blockReplyPipeline"] = {
206277
didStream: () => true,
@@ -210,6 +281,7 @@ describe("buildReplyPayloads media filter integration", () => {
210281
flush: async () => {},
211282
stop: () => {},
212283
hasBuffered: () => false,
284+
getSentMediaUrls: () => [],
213285
};
214286
// shouldDropFinalPayloads short-circuits to [] when the pipeline streamed
215287
// without aborting, so hasSentPayload is never reached.
@@ -233,6 +305,7 @@ describe("buildReplyPayloads media filter integration", () => {
233305
flush: async () => {},
234306
stop: () => {},
235307
hasBuffered: () => false,
308+
getSentMediaUrls: () => [],
236309
};
237310

238311
const { replyPayloads } = await buildReplyPayloads({

src/auto-reply/reply/agent-runner-payloads.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,11 @@ async function normalizeReplyPayloadMedia(params: {
4747
}
4848

4949
async function normalizeSentMediaUrlsForDedupe(params: {
50-
sentMediaUrls: string[];
50+
sentMediaUrls: readonly string[];
5151
normalizeMediaPaths?: (payload: ReplyPayload) => Promise<ReplyPayload>;
5252
}): Promise<string[]> {
5353
if (params.sentMediaUrls.length === 0 || !params.normalizeMediaPaths) {
54-
return params.sentMediaUrls;
54+
return [...params.sentMediaUrls];
5555
}
5656

5757
const normalizedUrls: string[] = [];
@@ -222,8 +222,7 @@ export async function buildReplyPayloads(params: {
222222
: mediaFilteredPayloads;
223223
const isDirectlySentBlockPayload = (payload: ReplyPayload) =>
224224
Boolean(params.directlySentBlockKeys?.has(createBlockReplyContentKey(payload)));
225-
// Filter out payloads already sent via pipeline or directly during tool flush.
226-
const filteredPayloads = shouldDropFinalPayloads
225+
const contentSuppressedPayloads = shouldDropFinalPayloads
227226
? dedupedPayloads.filter((payload) => payload.isError)
228227
: params.blockStreamingEnabled
229228
? dedupedPayloads.filter(
@@ -236,6 +235,21 @@ export async function buildReplyPayloads(params: {
236235
(payload) => !params.directlySentBlockKeys!.has(createBlockReplyContentKey(payload)),
237236
)
238237
: dedupedPayloads;
238+
const blockSentMediaUrls = params.blockStreamingEnabled
239+
? await normalizeSentMediaUrlsForDedupe({
240+
sentMediaUrls: params.blockReplyPipeline?.getSentMediaUrls() ?? [],
241+
normalizeMediaPaths: params.normalizeMediaPaths,
242+
})
243+
: [];
244+
const filteredPayloads =
245+
blockSentMediaUrls.length > 0
246+
? (
247+
dedupeRuntime ?? (await loadReplyPayloadsDedupeRuntime())
248+
).filterMessagingToolMediaDuplicates({
249+
payloads: contentSuppressedPayloads,
250+
sentMediaUrls: blockSentMediaUrls,
251+
})
252+
: contentSuppressedPayloads;
239253
const replyPayloads = suppressMessagingToolReplies ? [] : filteredPayloads;
240254

241255
return {

src/auto-reply/reply/block-reply-pipeline.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,38 @@ describe("createBlockReplyPipeline dedup with threading", () => {
7878
expect(pipeline.hasSentPayload({ text: "response text", replyToId: "other-id" })).toBe(true);
7979
});
8080

81+
it("tracks media URLs delivered via block replies", async () => {
82+
const pipeline = createBlockReplyPipeline({
83+
onBlockReply: async () => {},
84+
timeoutMs: 5000,
85+
});
86+
87+
expect(pipeline.getSentMediaUrls()).toEqual([]);
88+
89+
pipeline.enqueue({ text: "caption", mediaUrl: "file:///a.ogg" });
90+
pipeline.enqueue({ mediaUrls: ["file:///b.ogg", "file:///c.ogg"] });
91+
await pipeline.flush({ force: true });
92+
93+
expect(pipeline.getSentMediaUrls()).toEqual([
94+
"file:///a.ogg",
95+
"file:///b.ogg",
96+
"file:///c.ogg",
97+
]);
98+
});
99+
100+
it("does not track media when text-only blocks are delivered", async () => {
101+
const pipeline = createBlockReplyPipeline({
102+
onBlockReply: async () => {},
103+
timeoutMs: 5000,
104+
});
105+
106+
pipeline.enqueue({ text: "hello" });
107+
pipeline.enqueue({ text: "world" });
108+
await pipeline.flush({ force: true });
109+
110+
expect(pipeline.getSentMediaUrls()).toEqual([]);
111+
});
112+
81113
it("does not coalesce logical assistant blocks across assistantMessageIndex boundaries", async () => {
82114
const sent: string[] = [];
83115
const pipeline = createBlockReplyPipeline({

src/auto-reply/reply/block-reply-pipeline.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export type BlockReplyPipeline = {
1313
didStream: () => boolean;
1414
isAborted: () => boolean;
1515
hasSentPayload: (payload: ReplyPayload) => boolean;
16+
getSentMediaUrls: () => readonly string[];
1617
};
1718

1819
export type BlockReplyBuffer = {
@@ -86,6 +87,7 @@ export function createBlockReplyPipeline(params: {
8687
const { onBlockReply, timeoutMs, coalescing, buffer } = params;
8788
const sentKeys = new Set<string>();
8889
const sentContentKeys = new Set<string>();
90+
const sentMediaUrls = new Set<string>();
8991
const pendingKeys = new Set<string>();
9092
const seenKeys = new Set<string>();
9193
const bufferedKeys = new Set<string>();
@@ -149,6 +151,9 @@ export function createBlockReplyPipeline(params: {
149151
sentKeys.add(payloadKey);
150152
sentContentKeys.add(contentKey);
151153
const reply = resolveSendableOutboundReplyParts(payload);
154+
for (const mediaUrl of reply.mediaUrls) {
155+
sentMediaUrls.add(mediaUrl);
156+
}
152157
if (!reply.hasMedia && reply.trimmedText) {
153158
streamedTextFragments.push(reply.trimmedText);
154159
}
@@ -284,5 +289,6 @@ export function createBlockReplyPipeline(params: {
284289
const normalize = (text: string) => text.replace(/\s+/g, "");
285290
return normalize(streamedTextFragments.join("")) === normalize(reply.trimmedText);
286291
},
292+
getSentMediaUrls: () => Array.from(sentMediaUrls),
287293
};
288294
}

0 commit comments

Comments
 (0)