openclaw - ✅(Solved) Fix image_generate tool result dispatched twice: duplicate Slack file uploads per turn [1 pull requests, 1 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#65103Fetched 2026-04-12 13:25:35
View on GitHub
Comments
0
Participants
1
Timeline
2
Reactions
0
Author
Participants
Timeline (top)
cross-referenced ×1referenced ×1

When an agent uses the image_generate tool in response to a Slack DM, the generated image is uploaded to Slack twice (two separate file objects with identical content) from two independent dispatch paths in the pi-embedded runner. Behavior reproduces on v2026.4.9 and, with different file names, on v2026.4.11-beta.1.

Same class also affects music_generate and video_generate since they use the identical tool-result shape.

Root Cause

image_generate tool result returns both:

// pi-embedded-Vw-lS5ti.js:6758 (v2026.4.9)
return {
  content: [{ type: "text", text: `Generated ... image.\nMEDIA:${path}` }],
  details: {
    media: { mediaUrls: savedImages.map(i => i.path) },
    paths: [...],
    ...
  }
};

The tool-result dispatcher (emitToolResultOutput in pi-embedded-Vw-lS5ti.js around line 28460 on v2026.4.9) then dispatches this result via two independent code paths in a single agent turn:

Fix Action

Fix / Workaround

When an agent uses the image_generate tool in response to a Slack DM, the generated image is uploaded to Slack twice (two separate file objects with identical content) from two independent dispatch paths in the pi-embedded runner. Behavior reproduces on v2026.4.9 and, with different file names, on v2026.4.11-beta.1.

The tool-result dispatcher (emitToolResultOutput in pi-embedded-Vw-lS5ti.js around line 28460 on v2026.4.9) then dispatches this result via two independent code paths in a single agent turn:

parseReplyDirectives extracts MEDIA: lines from the tool output text and produces mediaUrls. Then onToolResult dispatches that payload to the channel.

PR fix notes

PR #65110: fix(media): remove redundant MEDIA: lines from generate tool content

Description (problem / solution / changelog)

Summary

image_generate, music_generate, and video_generate tools return media via two paths simultaneously: inline MEDIA: lines in content[].text AND structured details.media.mediaUrls. Both paths dispatch independently, resulting in duplicate file uploads on Slack, Discord, and other channels.

Fixes #65103

Root cause (from @mjamiv's RCA in #65103)

The tool result contains both:

return {
  content: [{ type: "text", text: `Generated ... image.\nMEDIA:${path}` }],
  details: { media: { mediaUrls: [path] } }
};

Path 1: MEDIA: lines in content[].textemitToolOutputonToolResult → channel upload Path 2: details.media.mediaUrlsextractToolResultMediaArtifactqueuePendingToolMedia → channel upload

The dedupe gate in pi-embedded-subscribe.handlers.tools.ts:487-513 is gated on toolResultFormat === "plain", but the default is "markdown" (line 65), so emittedToolOutputMediaUrls stays [] and the filter is a no-op.

Fix

Remove the MEDIA: spread from content[].text in all three tools. Media delivery is now handled exclusively via details.media.mediaUrls — the structured path that queuePendingToolMedia consumes. The content text retains the human-readable generation summary (e.g., "Generated 2 images with openai/gpt-image-1.").

Files changed

FileChange
image-generate-tool.ts:673Remove ...savedImages.map(image => \MEDIA:${image.path}`)`
music-generate-tool.ts:444Remove ...savedTracks.map(track => \MEDIA:${track.path}`)`
video-generate-tool.ts:589Remove ...savedVideos.map(video => \MEDIA:${video.path}`)`
image-generate-tool.test.tsAssert MEDIA: absent, verify summary present
music-generate-background.test.tsUpdate expected result strings
video-generate-background.test.tsUpdate expected result strings

Why not fix the dedupe gate instead?

The reporter offered two options:

  • Option A: Fix the dedupe gate in pi-embedded-subscribe to work in markdown mode
  • Option B (chosen): Remove the redundant MEDIA: lines from tool output

Option B is cleaner because:

  1. details.media.mediaUrls is the canonical structured media delivery path
  2. The MEDIA: lines were a legacy mechanism from before structured media details existed
  3. Removing the source of duplication is more robust than relying on dedupe logic
  4. The content text is for the model (so it knows what was generated), not for channel delivery

Scope

  • 6 files: 3 tools + 3 test files
  • Production LOC: 3 lines removed (one per tool)
  • oxlint clean
  • Zero competing PRs

Credit to @mjamiv for the detailed dual-dispatch RCA with exact line numbers in #65103.

Changed files

  • src/agents/tools/image-generate-tool.test.ts (modified, +7/-6)
  • src/agents/tools/image-generate-tool.ts (modified, +4/-3)
  • src/agents/tools/music-generate-background.test.ts (modified, +4/-4)
  • src/agents/tools/music-generate-tool.ts (modified, +2/-1)
  • src/agents/tools/video-generate-background.test.ts (modified, +4/-4)
  • src/agents/tools/video-generate-tool.ts (modified, +2/-1)

Code Example

{ ts: T+0, file: "image-1---<uuid>.png", file_id: "F0ABC..." }
{ ts: T+2, file: "image-1---<uuid>.png", file_id: "F0XYZ..." }
{ ts: T+5, text: "<caption>" }

---

// pi-embedded-Vw-lS5ti.js:6758 (v2026.4.9)
return {
  content: [{ type: "text", text: `Generated ... image.\nMEDIA:${path}` }],
  details: {
    media: { mediaUrls: savedImages.map(i => i.path) },
    paths: [...],
    ...
  }
};

---

// pi-embedded-Vw-lS5ti.js:29346
const emitToolResultMessage = (toolName, message, result) => {
  if (!params.onToolResult) return;
  const { text: cleanedText, mediaUrls } = parseReplyDirectives(message);
  const filteredMediaUrls = filterToolResultMediaUrls(toolName, mediaUrls ?? [], result);
  if (!cleanedText && filteredMediaUrls.length === 0) return;
  params.onToolResult({ text: cleanedText, mediaUrls: filteredMediaUrls });
};

---

// pi-embedded-Vw-lS5ti.js:~28522
const mediaReply = extractToolResultMediaArtifact(result);
// reads result.details.media.mediaUrls
...
queuePendingToolMedia(ctx, { mediaUrls: pendingMediaUrls, ... });

---

if (ctx.params.toolResultFormat === "plain")
  emittedToolOutputMediaUrls = collectEmittedToolOutputMediaUrls(toolName, outputText, result);
ctx.emitToolOutput(toolName, meta, outputText, result);
...
const pendingMediaUrls = mediaReply.audioAsVoice || emittedToolOutputMediaUrls.length === 0
  ? mediaUrls
  : mediaUrls.filter((url) => !emittedToolOutputMediaUrls.includes(url));

---

return {
  content: [{ type: "text", text: `Generated ${n} image${s} with ${provider}/${model}.` }],
  details: {
    media: { mediaUrls: savedImages.map(i => i.path) },
    ...
  }
};
RAW_BUFFERClick to expand / collapse

Summary

When an agent uses the image_generate tool in response to a Slack DM, the generated image is uploaded to Slack twice (two separate file objects with identical content) from two independent dispatch paths in the pi-embedded runner. Behavior reproduces on v2026.4.9 and, with different file names, on v2026.4.11-beta.1.

Same class also affects music_generate and video_generate since they use the identical tool-result shape.

Reproduction

  1. Configure a Slack channel on the gateway and an image_generate provider (e.g., openai/gpt-image-1) via agents.defaults.imageGenerationModel.primary.
  2. From a Slack DM with the bot, send send me a picture of a goat (or any image-generation prompt).
  3. Observe Slack conversation history via conversations.history. The bot response contains two separate file uploads with the same locally-generated filename but different Slack file_id values:
{ ts: T+0, file: "image-1---<uuid>.png", file_id: "F0ABC..." }
{ ts: T+2, file: "image-1---<uuid>.png", file_id: "F0XYZ..." }
{ ts: T+5, text: "<caption>" }

The two uploads are 1–5 seconds apart; both are emitted by the same gateway turn.

Root cause

image_generate tool result returns both:

// pi-embedded-Vw-lS5ti.js:6758 (v2026.4.9)
return {
  content: [{ type: "text", text: `Generated ... image.\nMEDIA:${path}` }],
  details: {
    media: { mediaUrls: savedImages.map(i => i.path) },
    paths: [...],
    ...
  }
};

The tool-result dispatcher (emitToolResultOutput in pi-embedded-Vw-lS5ti.js around line 28460 on v2026.4.9) then dispatches this result via two independent code paths in a single agent turn:

Path 1 — emitToolOutput via emitToolResultMessage

// pi-embedded-Vw-lS5ti.js:29346
const emitToolResultMessage = (toolName, message, result) => {
  if (!params.onToolResult) return;
  const { text: cleanedText, mediaUrls } = parseReplyDirectives(message);
  const filteredMediaUrls = filterToolResultMediaUrls(toolName, mediaUrls ?? [], result);
  if (!cleanedText && filteredMediaUrls.length === 0) return;
  params.onToolResult({ text: cleanedText, mediaUrls: filteredMediaUrls });
};

parseReplyDirectives extracts MEDIA: lines from the tool output text and produces mediaUrls. Then onToolResult dispatches that payload to the channel.

Path 2 — queuePendingToolMedia via extractToolResultMediaArtifact

// pi-embedded-Vw-lS5ti.js:~28522
const mediaReply = extractToolResultMediaArtifact(result);
// reads result.details.media.mediaUrls
...
queuePendingToolMedia(ctx, { mediaUrls: pendingMediaUrls, ... });

The pending media is then merged into the next assistant block reply via consumePendingToolMediaIntoReply (line 29201) and dispatched through emitBlockReply → Slack.

Existing dedupe is incomplete

Lines 28516–28525 attempt dedupe:

if (ctx.params.toolResultFormat === "plain")
  emittedToolOutputMediaUrls = collectEmittedToolOutputMediaUrls(toolName, outputText, result);
ctx.emitToolOutput(toolName, meta, outputText, result);
...
const pendingMediaUrls = mediaReply.audioAsVoice || emittedToolOutputMediaUrls.length === 0
  ? mediaUrls
  : mediaUrls.filter((url) => !emittedToolOutputMediaUrls.includes(url));

Two issues with the dedupe:

  1. Format gate: the collectEmittedToolOutputMediaUrls call is gated on toolResultFormat === "plain". Any non-plain format leaves emittedToolOutputMediaUrls empty and the filter becomes a no-op — which applies to most real agent runs.
  2. Cross-path gap even with plain: the deduper only filters the extractToolResultMediaArtifactqueuePendingToolMedia path against what collectEmittedToolOutputMediaUrls scraped from the output text. The actual emitToolResultMessage dispatch still fires whether or not the dedupe filter fires, because the dedupe only affects the subsequent queue, not the current emit.

So both paths end up delivering the same file via different Slack file uploads (files.completeUploadExternal called twice with the same local buffer).

Suggested fix direction

Two independent directions, either would close the double-dispatch:

Option A — tool result should dispatch media once

In emitToolResultOutput, either:

  • Have emitToolResultMessage NOT inject filteredMediaUrls into the onToolResult payload when the result has details.media (let extractToolResultMediaArtifact + queuePendingToolMedia handle it), OR
  • Have extractToolResultMediaArtifact return undefined (or an empty result) when emitToolResultMessage already dispatched the same media URLs via the tool-output path.

Either path eliminates one of the two dispatches. Moving the format gate to cover both paths isn't enough — the current emit still fires regardless of format.

Option B — image_generate should pick one delivery style

In createImageGenerationTool (pi-embedded-Vw-lS5ti.js:~6660), emit either MEDIA: lines in content.text or details.media.mediaUrls, not both:

return {
  content: [{ type: "text", text: `Generated ${n} image${s} with ${provider}/${model}.` }],
  details: {
    media: { mediaUrls: savedImages.map(i => i.path) },
    ...
  }
};

(Drop the ...savedImages.map(image => MEDIA:${image.path}) lines from the content text.)

This is the tighter fix — the tool then returns structured media only, and the dispatcher's structured path delivers it. music_generate and video_generate should get the same treatment.

Environment

  • openclaw: v2026.4.9 (host) + v2026.4.11-beta.1 (sandbox, reproduces identically with renamed bundle files)
  • node: 22.22.1
  • Primary model: openai/gpt-5.4 (also reproduced under nvidia-nim/moonshotai/kimi-k2.5 and minimax/MiniMax-M2.7)
  • Channel: Slack (DM)
  • Tool: image_generate via openai/gpt-image-1

Local workaround

Operator-side workaround applied while this is pending: dedupe at the uploadSlackFile chokepoint in send-pJvSRc2i.js:287 via a short-window module-level Map keyed by (channelId, mediaUrl). Brutal but catches every upstream path without needing to trace them all. Happy to share the script if helpful — it's intentionally not a proposed PR because the structural fix belongs in emitToolResultOutput or the tool definition itself, not at the send chokepoint.

extent analysis

TL;DR

The most likely fix for the issue of duplicate image uploads to Slack is to modify the image_generate tool to return structured media only, either by removing the MEDIA: lines from the content.text or by having the dispatcher handle media URLs in a single path.

Guidance

  1. Identify the root cause: The issue is caused by the image_generate tool returning both content with MEDIA: lines and details.media with mediaUrls, leading to two independent dispatch paths in the emitToolResultOutput function.
  2. Choose a fix direction: Decide between modifying the emitToolResultOutput function to handle media URLs in a single path or changing the image_generate tool to return structured media only.
  3. Implement the fix: If choosing to modify the image_generate tool, remove the MEDIA: lines from the content.text and only return details.media with mediaUrls. If choosing to modify the emitToolResultOutput function, ensure that only one path dispatches the media URLs.
  4. Verify the fix: Test the modified code by sending an image-generation prompt to the bot and checking the Slack conversation history for duplicate file uploads.

Example

// Modified image_generate tool
return {
  content: [{ type: "text", text: `Generated ${n} image${s} with ${provider}/${model}.` }],
  details: {
    media: { mediaUrls: savedImages.map(i => i.path) },
    ...
  }
};

Notes

The provided local workaround, which dedupes at the uploadSlackFile chokepoint, may not be a permanent solution and should be removed once the structural fix is implemented.

Recommendation

Apply the fix by modifying the image_generate tool to return structured media only, as this is a tighter fix that eliminates the need for duplicate dispatches.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING