diff --git a/src/auto-reply/reply/agent-runner.media-paths.test.ts b/src/auto-reply/reply/agent-runner.media-paths.test.ts index 600a5ce4e5a..1cdce77351b 100644 --- a/src/auto-reply/reply/agent-runner.media-paths.test.ts +++ b/src/auto-reply/reply/agent-runner.media-paths.test.ts @@ -17,6 +17,7 @@ const enqueueFollowupRunMock = vi.fn(); const scheduleFollowupDrainMock = vi.fn(); const refreshQueuedFollowupSessionMock = vi.fn(); const resolveOutboundAttachmentFromUrlMock = vi.fn(); +const createReplyMediaPathNormalizerRuntimeMock = vi.fn(); vi.mock("../../agents/model-fallback.js", () => ({ runWithModelFallback: (params: { @@ -52,6 +53,18 @@ vi.mock("../../media/outbound-attachment.js", () => ({ resolveOutboundAttachmentFromUrlMock(...args), })); +// Spy on the .runtime import path used by agent-runner-execution.ts so we can assert +// that the fix prevents a second normalizer from being created inside runAgentTurnWithFallback. +vi.mock("./reply-media-paths.runtime.js", async (importOriginal) => { + const mod = await importOriginal(); + return { + createReplyMediaPathNormalizer: (...args: Parameters) => { + createReplyMediaPathNormalizerRuntimeMock(...args); + return mod.createReplyMediaPathNormalizer(...args); + }, + }; +}); + let runReplyAgent: typeof import("./agent-runner.js").runReplyAgent; describe("runReplyAgent media path normalization", () => { @@ -73,6 +86,7 @@ describe("runReplyAgent media path normalization", () => { scheduleFollowupDrainMock.mockReset(); refreshQueuedFollowupSessionMock.mockReset(); resolveOutboundAttachmentFromUrlMock.mockReset(); + createReplyMediaPathNormalizerRuntimeMock.mockReset(); vi.stubEnv("OPENCLAW_TEST_FAST", "1"); resolveOutboundAttachmentFromUrlMock.mockImplementation(async (mediaUrl: string) => ({ path: path.join("/tmp/outbound-media", path.basename(mediaUrl)), @@ -160,4 +174,69 @@ describe("runReplyAgent media path normalization", () => { }), ); }); + + 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(); + }); });