openclaw/src/auto-reply/reply/agent-runner-payloads.test.ts
Mathias Nagler e8a162d3d8
fix(mattermost): prevent duplicate messages when block streaming + threading are active (#41362)
* fix(mattermost): prevent duplicate messages when block streaming + threading are active

Remove replyToId from createBlockReplyPayloadKey so identical content is
deduplicated regardless of threading target. Add explicit threading dock
to the Mattermost plugin with resolveReplyToMode reading from config
(default "all"), and add replyToMode to the Mattermost config schema.

Fixes #41219

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(mattermost): address PR review — per-account replyToMode and test clarity

Read replyToMode from the merged per-account config via
resolveMattermostAccount so account-level overrides are honored in
multi-account setups. Add replyToMode to MattermostAccountConfig type.
Rename misleading test to clarify it exercises shouldDropFinalPayloads
short-circuit, not payload key dedup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Replies: keep block-pipeline reply targets distinct

* Tests: cover block reply target-aware dedupe

* Update CHANGELOG.md

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-12 03:15:17 -04:00

239 lines
8.3 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { buildReplyPayloads } from "./agent-runner-payloads.js";
const baseParams = {
isHeartbeat: false,
didLogHeartbeatStrip: false,
blockStreamingEnabled: false,
blockReplyPipeline: null,
replyToMode: "off" as const,
};
describe("buildReplyPayloads media filter integration", () => {
it("strips media URL from payload when in messagingToolSentMediaUrls", async () => {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
payloads: [{ text: "hello", mediaUrl: "file:///tmp/photo.jpg" }],
messagingToolSentMediaUrls: ["file:///tmp/photo.jpg"],
});
expect(replyPayloads).toHaveLength(1);
expect(replyPayloads[0].mediaUrl).toBeUndefined();
});
it("preserves media URL when not in messagingToolSentMediaUrls", async () => {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
payloads: [{ text: "hello", mediaUrl: "file:///tmp/photo.jpg" }],
messagingToolSentMediaUrls: ["file:///tmp/other.jpg"],
});
expect(replyPayloads).toHaveLength(1);
expect(replyPayloads[0].mediaUrl).toBe("file:///tmp/photo.jpg");
});
it("normalizes sent media URLs before deduping normalized reply media", async () => {
const normalizeMediaPaths = async (payload: { mediaUrl?: string; mediaUrls?: string[] }) => {
const normalizeMedia = (value?: string) =>
value === "./out/photo.jpg" ? "/tmp/workspace/out/photo.jpg" : value;
return {
...payload,
mediaUrl: normalizeMedia(payload.mediaUrl),
mediaUrls: payload.mediaUrls?.map((value) => normalizeMedia(value) ?? value),
};
};
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
payloads: [{ text: "hello", mediaUrl: "./out/photo.jpg" }],
messagingToolSentMediaUrls: ["./out/photo.jpg"],
normalizeMediaPaths,
});
expect(replyPayloads).toHaveLength(1);
expect(replyPayloads[0]).toMatchObject({
text: "hello",
mediaUrl: undefined,
mediaUrls: undefined,
});
});
it("drops only invalid media when reply media normalization fails", async () => {
const normalizeMediaPaths = async (payload: { mediaUrl?: string }) => {
if (payload.mediaUrl === "./bad.png") {
throw new Error("Path escapes sandbox root");
}
return payload;
};
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
payloads: [
{ text: "keep text", mediaUrl: "./bad.png", audioAsVoice: true },
{ text: "keep second" },
],
normalizeMediaPaths,
});
expect(replyPayloads).toHaveLength(2);
expect(replyPayloads[0]).toMatchObject({
text: "keep text",
mediaUrl: undefined,
mediaUrls: undefined,
audioAsVoice: false,
});
expect(replyPayloads[1]).toMatchObject({
text: "keep second",
});
});
it("applies media filter after text filter", async () => {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
payloads: [{ text: "hello world!", mediaUrl: "file:///tmp/photo.jpg" }],
messagingToolSentTexts: ["hello world!"],
messagingToolSentMediaUrls: ["file:///tmp/photo.jpg"],
});
// Text filter removes the payload entirely (text matched), so nothing remains.
expect(replyPayloads).toHaveLength(0);
});
it("does not dedupe text for cross-target messaging sends", async () => {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
payloads: [{ text: "hello world!" }],
messageProvider: "telegram",
originatingTo: "telegram:123",
messagingToolSentTexts: ["hello world!"],
messagingToolSentTargets: [{ tool: "discord", provider: "discord", to: "channel:C1" }],
});
expect(replyPayloads).toHaveLength(1);
expect(replyPayloads[0]?.text).toBe("hello world!");
});
it("does not dedupe media for cross-target messaging sends", async () => {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
payloads: [{ text: "photo", mediaUrl: "file:///tmp/photo.jpg" }],
messageProvider: "telegram",
originatingTo: "telegram:123",
messagingToolSentMediaUrls: ["file:///tmp/photo.jpg"],
messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }],
});
expect(replyPayloads).toHaveLength(1);
expect(replyPayloads[0]?.mediaUrl).toBe("file:///tmp/photo.jpg");
});
it("suppresses same-target replies when messageProvider is synthetic but originatingChannel is set", async () => {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
payloads: [{ text: "hello world!" }],
messageProvider: "heartbeat",
originatingChannel: "telegram",
originatingTo: "268300329",
messagingToolSentTexts: ["different message"],
messagingToolSentTargets: [{ tool: "telegram", provider: "telegram", to: "268300329" }],
});
expect(replyPayloads).toHaveLength(0);
});
it("suppresses same-target replies when message tool target provider is generic", async () => {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
payloads: [{ text: "hello world!" }],
messageProvider: "heartbeat",
originatingChannel: "feishu",
originatingTo: "ou_abc123",
messagingToolSentTexts: ["different message"],
messagingToolSentTargets: [{ tool: "message", provider: "message", to: "ou_abc123" }],
});
expect(replyPayloads).toHaveLength(0);
});
it("suppresses same-target replies when target provider is channel alias", async () => {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
payloads: [{ text: "hello world!" }],
messageProvider: "heartbeat",
originatingChannel: "feishu",
originatingTo: "ou_abc123",
messagingToolSentTexts: ["different message"],
messagingToolSentTargets: [{ tool: "message", provider: "lark", to: "ou_abc123" }],
});
expect(replyPayloads).toHaveLength(0);
});
it("drops all final payloads when block pipeline streamed successfully", async () => {
const pipeline: Parameters<typeof buildReplyPayloads>[0]["blockReplyPipeline"] = {
didStream: () => true,
isAborted: () => false,
hasSentPayload: () => false,
enqueue: () => {},
flush: async () => {},
stop: () => {},
hasBuffered: () => false,
};
// shouldDropFinalPayloads short-circuits to [] when the pipeline streamed
// without aborting, so hasSentPayload is never reached.
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
blockStreamingEnabled: true,
blockReplyPipeline: pipeline,
replyToMode: "all",
payloads: [{ text: "response", replyToId: "post-123" }],
});
expect(replyPayloads).toHaveLength(0);
});
it("deduplicates final payloads against directly sent block keys regardless of replyToId", async () => {
// When block streaming is not active but directlySentBlockKeys has entries
// (e.g. from pre-tool flush), the key should match even if replyToId differs.
const { createBlockReplyContentKey } = await import("./block-reply-pipeline.js");
const directlySentBlockKeys = new Set<string>();
directlySentBlockKeys.add(
createBlockReplyContentKey({ text: "response", replyToId: "post-1" }),
);
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
blockStreamingEnabled: false,
blockReplyPipeline: null,
directlySentBlockKeys,
replyToMode: "off",
payloads: [{ text: "response" }],
});
expect(replyPayloads).toHaveLength(0);
});
it("does not suppress same-target replies when accountId differs", async () => {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
payloads: [{ text: "hello world!" }],
messageProvider: "heartbeat",
originatingChannel: "telegram",
originatingTo: "268300329",
accountId: "personal",
messagingToolSentTexts: ["different message"],
messagingToolSentTargets: [
{
tool: "telegram",
provider: "telegram",
to: "268300329",
accountId: "work",
},
],
});
expect(replyPayloads).toHaveLength(1);
expect(replyPayloads[0]?.text).toBe("hello world!");
});
});