Skip to content

Commit 43e6c92

Browse files
committed
perf(auto-reply): extract followup delivery seam
1 parent 8183e2d commit 43e6c92

4 files changed

Lines changed: 323 additions & 220 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { OpenClawConfig } from "../../config/config.js";
3+
import { resolveFollowupDeliveryPayloads } from "./followup-delivery.js";
4+
5+
const baseConfig = {} as OpenClawConfig;
6+
7+
describe("resolveFollowupDeliveryPayloads", () => {
8+
it("drops heartbeat ack payloads without media", () => {
9+
expect(
10+
resolveFollowupDeliveryPayloads({
11+
cfg: baseConfig,
12+
payloads: [{ text: "HEARTBEAT_OK" }],
13+
}),
14+
).toEqual([]);
15+
});
16+
17+
it("keeps media payloads when stripping heartbeat ack text", () => {
18+
expect(
19+
resolveFollowupDeliveryPayloads({
20+
cfg: baseConfig,
21+
payloads: [{ text: "HEARTBEAT_OK", mediaUrl: "/tmp/image.png" }],
22+
}),
23+
).toEqual([{ text: "", mediaUrl: "/tmp/image.png" }]);
24+
});
25+
26+
it("drops text payloads already sent via messaging tool", () => {
27+
expect(
28+
resolveFollowupDeliveryPayloads({
29+
cfg: baseConfig,
30+
payloads: [{ text: "hello world!" }],
31+
sentTexts: ["hello world!"],
32+
}),
33+
).toEqual([]);
34+
});
35+
36+
it("drops media payloads already sent via messaging tool", () => {
37+
expect(
38+
resolveFollowupDeliveryPayloads({
39+
cfg: baseConfig,
40+
payloads: [{ mediaUrl: "/tmp/img.png" }],
41+
sentMediaUrls: ["/tmp/img.png"],
42+
}),
43+
).toEqual([{ mediaUrl: undefined, mediaUrls: undefined }]);
44+
});
45+
46+
it("suppresses replies when a messaging tool already sent to the same provider and target", () => {
47+
expect(
48+
resolveFollowupDeliveryPayloads({
49+
cfg: baseConfig,
50+
payloads: [{ text: "hello world!" }],
51+
messageProvider: "slack",
52+
originatingTo: "channel:C1",
53+
sentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }],
54+
}),
55+
).toEqual([]);
56+
});
57+
58+
it("suppresses replies when originating channel resolves the provider", () => {
59+
expect(
60+
resolveFollowupDeliveryPayloads({
61+
cfg: baseConfig,
62+
payloads: [{ text: "hello world!" }],
63+
messageProvider: "heartbeat",
64+
originatingChannel: "telegram",
65+
originatingTo: "268300329",
66+
sentTargets: [{ tool: "telegram", provider: "telegram", to: "268300329" }],
67+
}),
68+
).toEqual([]);
69+
});
70+
71+
it("does not suppress replies when account differs", () => {
72+
expect(
73+
resolveFollowupDeliveryPayloads({
74+
cfg: baseConfig,
75+
payloads: [{ text: "hello world!" }],
76+
messageProvider: "heartbeat",
77+
originatingChannel: "telegram",
78+
originatingTo: "268300329",
79+
originatingAccountId: "personal",
80+
sentTargets: [
81+
{ tool: "telegram", provider: "telegram", to: "268300329", accountId: "work" },
82+
],
83+
}),
84+
).toEqual([{ text: "hello world!" }]);
85+
});
86+
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
2+
import type { MessagingToolSend } from "../../agents/pi-embedded-runner.js";
3+
import type { OpenClawConfig } from "../../config/config.js";
4+
import { stripHeartbeatToken } from "../heartbeat.js";
5+
import type { OriginatingChannelType } from "../templating.js";
6+
import type { ReplyPayload } from "../types.js";
7+
import {
8+
resolveOriginAccountId,
9+
resolveOriginMessageProvider,
10+
resolveOriginMessageTo,
11+
} from "./origin-routing.js";
12+
import {
13+
applyReplyThreading,
14+
filterMessagingToolDuplicates,
15+
filterMessagingToolMediaDuplicates,
16+
shouldSuppressMessagingToolReplies,
17+
} from "./reply-payloads.js";
18+
import { resolveReplyToMode } from "./reply-threading.js";
19+
20+
export function resolveFollowupDeliveryPayloads(params: {
21+
cfg: OpenClawConfig;
22+
payloads: ReplyPayload[];
23+
messageProvider?: string;
24+
originatingAccountId?: string;
25+
originatingChannel?: string;
26+
originatingChatType?: string | null;
27+
originatingTo?: string;
28+
sentMediaUrls?: string[];
29+
sentTargets?: MessagingToolSend[];
30+
sentTexts?: string[];
31+
}): ReplyPayload[] {
32+
const replyToChannel = resolveOriginMessageProvider({
33+
originatingChannel: params.originatingChannel,
34+
provider: params.messageProvider,
35+
}) as OriginatingChannelType | undefined;
36+
const replyToMode = resolveReplyToMode(
37+
params.cfg,
38+
replyToChannel,
39+
params.originatingAccountId,
40+
params.originatingChatType,
41+
);
42+
const sanitizedPayloads = params.payloads.flatMap((payload) => {
43+
const text = payload.text;
44+
if (!text || !text.includes("HEARTBEAT_OK")) {
45+
return [payload];
46+
}
47+
const stripped = stripHeartbeatToken(text, { mode: "message" });
48+
const hasMedia = resolveSendableOutboundReplyParts(payload).hasMedia;
49+
if (stripped.shouldSkip && !hasMedia) {
50+
return [];
51+
}
52+
return [{ ...payload, text: stripped.text }];
53+
});
54+
const replyTaggedPayloads = applyReplyThreading({
55+
payloads: sanitizedPayloads,
56+
replyToMode,
57+
replyToChannel,
58+
});
59+
const dedupedPayloads = filterMessagingToolDuplicates({
60+
payloads: replyTaggedPayloads,
61+
sentTexts: params.sentTexts ?? [],
62+
});
63+
const mediaFilteredPayloads = filterMessagingToolMediaDuplicates({
64+
payloads: dedupedPayloads,
65+
sentMediaUrls: params.sentMediaUrls ?? [],
66+
});
67+
const suppressMessagingToolReplies = shouldSuppressMessagingToolReplies({
68+
messageProvider: replyToChannel,
69+
messagingToolSentTargets: params.sentTargets,
70+
originatingTo: resolveOriginMessageTo({
71+
originatingTo: params.originatingTo,
72+
}),
73+
accountId: resolveOriginAccountId({
74+
originatingAccountId: params.originatingAccountId,
75+
}),
76+
});
77+
return suppressMessagingToolReplies ? [] : mediaFilteredPayloads;
78+
}

0 commit comments

Comments
 (0)