Skip to content

fix(agent-runner): share media-path normalizer to prevent duplicate WhatsApp media reply (#68056)#68111

Merged
steipete merged 4 commits into
openclaw:mainfrom
ayeshakhalid192007-dev:fix/whatsapp-duplicate-media-reply-68056
Apr 22, 2026
Merged

fix(agent-runner): share media-path normalizer to prevent duplicate WhatsApp media reply (#68056)#68111
steipete merged 4 commits into
openclaw:mainfrom
ayeshakhalid192007-dev:fix/whatsapp-duplicate-media-reply-68056

Conversation

@ayeshakhalid192007-dev

Copy link
Copy Markdown
Contributor

Summary

Fixes #68056 — a single WhatsApp media reply is sent twice when blockStreaming is disabled.

Root Cause

createReplyMediaPathNormalizer was called in two separate places, each producing an independent closure with its own persistedMediaBySource cache:

  • agent-runner-execution.ts — inside runAgentTurnWithFallback, used by the block-reply delivery handler
  • agent-runner.ts — at the call site, used by buildReplyPayloads

When block streaming is off but onBlockReply is provided, reply-delivery.ts sends media immediately via sendDirectBlockReply (by design — media cannot be reconstructed from final text). Both delivery paths then called resolveOutboundAttachmentFromUrl for the same source, each cache-missing and producing a separate UUID outbound file and a separate WhatsApp send.

This matches the reporter's instrumentation finding: "The two call sites create separate normalizer instances whose persistedMediaBySource caches are not shared."

Changes

  • agent-runner-execution.tsrunAgentTurnWithFallback now accepts an optional normalizeMediaPaths param. When supplied it is used directly; otherwise a local normalizer is created (preserves backward-compat for any callers that do not supply one).
  • agent-runner.ts — passes normalizeReplyMediaPaths (already created at the call site) into runAgentTurnWithFallback, so both the block-reply delivery handler and buildReplyPayloads share one closure and one persistedMediaBySource cache.

Why This Is Safe

Purely additive — an optional parameter with a ?? fallback. All callers that do not pass normalizeMediaPaths behave identically to before. The only behavioural change is that the two delivery paths now share the same normalizer cache, which is the intended behaviour.

Testing

Added regression test in agent-runner.media-paths.test.ts: mocks the .runtime import path used exclusively by agent-runner-execution.ts and asserts that createReplyMediaPathNormalizer from that path is never called after the fix (because the injected normalizer from agent-runner.ts is used instead).

Local CI results:

  • auto-reply-reply test suite: 1073/1073 passed
  • pnpm test:bundled: 103/103 passed
  • pnpm check / pnpm build / pnpm test:contracts: pre-existing failures present identically on main — not introduced by this PR

Fixes #68056

@greptile-apps

greptile-apps Bot commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Fixes a duplicate WhatsApp media send bug (#68056) by sharing a single createReplyMediaPathNormalizer closure between agent-runner.ts and agent-runner-execution.ts, so both the block-reply delivery handler and buildReplyPayloads operate against the same persistedMediaBySource cache.

The fix is purely additive: runAgentTurnWithFallback accepts an optional normalizeMediaPaths param with a ?? fallback that preserves the existing behaviour for any callers that do not supply one.

Confidence Score: 5/5

Safe to merge — the change is additive and backward-compatible, with a correct regression test covering the root cause.

The fix is minimal and correct: one optional parameter with a ?? fallback preserves all existing callers. The only finding is a P2 suggestion to strengthen the test by verifying the end-to-end duplicate-send behaviour, not just the structural absence of a second normalizer. No logic errors, security concerns, or correctness regressions found.

No files require special attention.

Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/auto-reply/reply/agent-runner.media-paths.test.ts
Line: 178-242

Comment:
**Test proves no second construction, but not cache-sharing behavior**

The regression test asserts `createReplyMediaPathNormalizerRuntimeMock` is never called, which correctly proves the root-cause fix (no second independent cache is created). However, because `runEmbeddedPiAgentMock` returns `payloads: []`, the shared normalizer is never actually invoked, so no end-to-end guarantee that one cache-hit prevents a duplicate `resolveOutboundAttachmentFromUrl` call.

Consider returning a media payload (e.g. `{ text: "MEDIA:./out/chart.png" }`) and asserting `resolveOutboundAttachmentFromUrlMock` is called exactly once, which would directly verify the duplicate-send behaviour is gone rather than only proving the structural fix.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "test(agent-runner): regression — createR..." | Re-trigger Greptile

Comment on lines 178 to 242
it("does not create a second normalizer inside runAgentTurnWithFallback when onBlockReply is provided", async () => {
// Regression test for openclaw/openclaw#68056.
// Before the fix, runAgentTurnWithFallback always called createReplyMediaPathNormalizer
// from reply-media-paths.runtime.js to build its own normalizer instance — separate from
// the one agent-runner.ts created and passed to buildReplyPayloads. Two separate
// persistedMediaBySource caches meant the same source could be persisted twice (two UUID
// outbound files, two WhatsApp sends).
//
// After the fix, agent-runner.ts passes its normalizer into runAgentTurnWithFallback, so
// the .runtime import path is never called from inside that function.
runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [],
meta: {
agentMeta: {
sessionId: "session",
provider: "anthropic",
model: "claude",
},
},
});

await runReplyAgent({
commandBody: "generate chart",
followupRun: createMockFollowupRun({
prompt: "generate chart",
run: {
agentId: "main",
agentDir: "/tmp/agent",
messageProvider: "whatsapp",
workspaceDir: "/tmp/workspace",
},
}) as unknown as FollowupRun,
queueKey: "main",
resolvedQueue: { mode: "interrupt" } as QueueSettings,
shouldSteer: false,
shouldFollowup: false,
isActive: false,
isStreaming: false,
typing: createMockTypingController(),
sessionCtx: {
Provider: "whatsapp",
Surface: "whatsapp",
To: "chat-1",
OriginatingTo: "chat-1",
AccountId: "default",
MessageSid: "msg-1",
} as unknown as TemplateContext,
defaultModel: "anthropic/claude",
resolvedVerboseLevel: "off",
isNewSession: false,
blockStreamingEnabled: false,
resolvedBlockStreamingBreak: "message_end",
shouldInjectGroupIntro: false,
typingMode: "instant",
opts: {
onBlockReply: vi.fn(),
},
});

// The .runtime import is only used by agent-runner-execution.ts. After the fix,
// runAgentTurnWithFallback receives the normalizer from the caller and never
// calls createReplyMediaPathNormalizer itself.
expect(createReplyMediaPathNormalizerRuntimeMock).not.toHaveBeenCalled();
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Test proves no second construction, but not cache-sharing behavior

The regression test asserts createReplyMediaPathNormalizerRuntimeMock is never called, which correctly proves the root-cause fix (no second independent cache is created). However, because runEmbeddedPiAgentMock returns payloads: [], the shared normalizer is never actually invoked, so no end-to-end guarantee that one cache-hit prevents a duplicate resolveOutboundAttachmentFromUrl call.

Consider returning a media payload (e.g. { text: "MEDIA:./out/chart.png" }) and asserting resolveOutboundAttachmentFromUrlMock is called exactly once, which would directly verify the duplicate-send behaviour is gone rather than only proving the structural fix.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/auto-reply/reply/agent-runner.media-paths.test.ts
Line: 178-242

Comment:
**Test proves no second construction, but not cache-sharing behavior**

The regression test asserts `createReplyMediaPathNormalizerRuntimeMock` is never called, which correctly proves the root-cause fix (no second independent cache is created). However, because `runEmbeddedPiAgentMock` returns `payloads: []`, the shared normalizer is never actually invoked, so no end-to-end guarantee that one cache-hit prevents a duplicate `resolveOutboundAttachmentFromUrl` call.

Consider returning a media payload (e.g. `{ text: "MEDIA:./out/chart.png" }`) and asserting `resolveOutboundAttachmentFromUrlMock` is called exactly once, which would directly verify the duplicate-send behaviour is gone rather than only proving the structural fix.

How can I resolve this? If you propose a fix, please make it concise.

@steipete
steipete force-pushed the fix/whatsapp-duplicate-media-reply-68056 branch from 511e12d to fc5d913 Compare April 22, 2026 18:01
@steipete
steipete force-pushed the fix/whatsapp-duplicate-media-reply-68056 branch from fc5d913 to b7b1d1a Compare April 22, 2026 18:02
@steipete
steipete merged commit 510a8f9 into openclaw:main Apr 22, 2026
10 checks passed
@steipete

Copy link
Copy Markdown
Contributor

Landed via rebase onto main.

  • Scoped gate: pnpm check:changed passed after final rebase (101 files, 1143 tests).
  • Focused reply-media suite: pnpm test src/auto-reply/reply/agent-runner.media-paths.test.ts src/auto-reply/reply/reply-media-paths.test.ts src/auto-reply/reply/reply-delivery.test.ts src/auto-reply/reply/agent-runner-payloads.test.ts src/auto-reply/reply/agent-runner-execution.test.ts passed (73 tests).
  • Other local gates: pnpm check, pnpm check:test-types, pnpm build, pnpm check:docs, pnpm format:check passed.
  • Full pnpm test post-merge is blocked by current-main failures outside this PR surface:
    • test/extension-package-tsc-boundary.test.ts: Telegram PluginHookMessageSendingEvent typing for replyToId.
    • src/hooks/message-hook-mappers.test.ts: expected payload missing top-level threadId.

Landed commit: 510a8f9ebc65f11f2298585b9c05d78636f5a40f.

Thanks @ayeshakhalid192007-dev!

medikoo pushed a commit to medikoo/openclaw that referenced this pull request Apr 24, 2026
ogt-redknie pushed a commit to ogt-redknie/OPENX that referenced this pull request May 2, 2026
zhonghe0615 pushed a commit to zhonghe0615/openclaw that referenced this pull request May 7, 2026
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
globalcaos pushed a commit to globalcaos/tinkerclaw that referenced this pull request May 13, 2026
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 24, 2026
jameslcowan pushed a commit to jameslcowan/openclaw that referenced this pull request Jun 2, 2026
sablehead pushed a commit to sablehead/openclaw that referenced this pull request Jun 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Single WhatsApp media reply is sent twice in 2026.4.15

2 participants