refactor: share reply payload threading/dedupe
This commit is contained in:
@@ -7,7 +7,6 @@ import {
|
|||||||
queueEmbeddedPiMessage,
|
queueEmbeddedPiMessage,
|
||||||
runEmbeddedPiAgent,
|
runEmbeddedPiAgent,
|
||||||
} from "../../agents/pi-embedded.js";
|
} from "../../agents/pi-embedded.js";
|
||||||
import { isMessagingToolDuplicate } from "../../agents/pi-embedded-helpers.js";
|
|
||||||
import { hasNonzeroUsage } from "../../agents/usage.js";
|
import { hasNonzeroUsage } from "../../agents/usage.js";
|
||||||
import {
|
import {
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
@@ -31,7 +30,12 @@ import {
|
|||||||
type QueueSettings,
|
type QueueSettings,
|
||||||
scheduleFollowupDrain,
|
scheduleFollowupDrain,
|
||||||
} from "./queue.js";
|
} from "./queue.js";
|
||||||
import { extractReplyToTag } from "./reply-tags.js";
|
import {
|
||||||
|
applyReplyTagsToPayload,
|
||||||
|
applyReplyThreading,
|
||||||
|
filterMessagingToolDuplicates,
|
||||||
|
isRenderablePayload,
|
||||||
|
} from "./reply-payloads.js";
|
||||||
import {
|
import {
|
||||||
createReplyToModeFilter,
|
createReplyToModeFilter,
|
||||||
resolveReplyToMode,
|
resolveReplyToMode,
|
||||||
@@ -321,21 +325,25 @@ export async function runReplyAgent(params: {
|
|||||||
if (stripped.shouldSkip && !hasMedia) return;
|
if (stripped.shouldSkip && !hasMedia) return;
|
||||||
text = stripped.text;
|
text = stripped.text;
|
||||||
}
|
}
|
||||||
const tagResult = extractReplyToTag(
|
const taggedPayload = applyReplyTagsToPayload(
|
||||||
text,
|
{
|
||||||
|
text,
|
||||||
|
mediaUrls: payload.mediaUrls,
|
||||||
|
mediaUrl: payload.mediaUrls?.[0],
|
||||||
|
},
|
||||||
sessionCtx.MessageSid,
|
sessionCtx.MessageSid,
|
||||||
);
|
);
|
||||||
const cleaned = tagResult.cleaned || undefined;
|
if (!isRenderablePayload(taggedPayload)) return;
|
||||||
const hasMedia = (payload.mediaUrls?.length ?? 0) > 0;
|
const hasMedia =
|
||||||
if (!cleaned && !hasMedia) return;
|
Boolean(taggedPayload.mediaUrl) ||
|
||||||
if (cleaned?.trim() === SILENT_REPLY_TOKEN && !hasMedia)
|
(taggedPayload.mediaUrls?.length ?? 0) > 0;
|
||||||
|
if (
|
||||||
|
taggedPayload.text?.trim() === SILENT_REPLY_TOKEN &&
|
||||||
|
!hasMedia
|
||||||
|
)
|
||||||
return;
|
return;
|
||||||
const blockPayload: ReplyPayload = applyReplyToMode({
|
const blockPayload: ReplyPayload =
|
||||||
text: cleaned,
|
applyReplyToMode(taggedPayload);
|
||||||
mediaUrls: payload.mediaUrls,
|
|
||||||
mediaUrl: payload.mediaUrls?.[0],
|
|
||||||
replyToId: tagResult.replyToId,
|
|
||||||
});
|
|
||||||
const payloadKey = buildPayloadKey(blockPayload);
|
const payloadKey = buildPayloadKey(blockPayload);
|
||||||
if (
|
if (
|
||||||
streamedPayloadKeys.has(payloadKey) ||
|
streamedPayloadKeys.has(payloadKey) ||
|
||||||
@@ -345,7 +353,7 @@ export async function runReplyAgent(params: {
|
|||||||
}
|
}
|
||||||
pendingStreamedPayloadKeys.add(payloadKey);
|
pendingStreamedPayloadKeys.add(payloadKey);
|
||||||
const task = (async () => {
|
const task = (async () => {
|
||||||
await typingSignals.signalTextDelta(cleaned);
|
await typingSignals.signalTextDelta(taggedPayload.text);
|
||||||
await opts.onBlockReply?.(blockPayload);
|
await opts.onBlockReply?.(blockPayload);
|
||||||
})()
|
})()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -507,41 +515,21 @@ export async function runReplyAgent(params: {
|
|||||||
return [{ ...payload, text: stripped.text }];
|
return [{ ...payload, text: stripped.text }];
|
||||||
});
|
});
|
||||||
|
|
||||||
const replyTaggedPayloads: ReplyPayload[] = sanitizedPayloads
|
const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({
|
||||||
.map((payload) => {
|
payloads: sanitizedPayloads,
|
||||||
const { cleaned, replyToId } = extractReplyToTag(
|
applyReplyToMode,
|
||||||
payload.text,
|
currentMessageId: sessionCtx.MessageSid,
|
||||||
sessionCtx.MessageSid,
|
});
|
||||||
);
|
|
||||||
return {
|
|
||||||
...payload,
|
|
||||||
text: cleaned ? cleaned : undefined,
|
|
||||||
replyToId: replyToId ?? payload.replyToId,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(
|
|
||||||
(payload) =>
|
|
||||||
payload.text ||
|
|
||||||
payload.mediaUrl ||
|
|
||||||
(payload.mediaUrls && payload.mediaUrls.length > 0),
|
|
||||||
)
|
|
||||||
.map(applyReplyToMode);
|
|
||||||
|
|
||||||
// Drop final payloads if block streaming is enabled and we already streamed
|
// Drop final payloads if block streaming is enabled and we already streamed
|
||||||
// block replies. Tool-sent duplicates are filtered below.
|
// block replies. Tool-sent duplicates are filtered below.
|
||||||
const shouldDropFinalPayloads =
|
const shouldDropFinalPayloads =
|
||||||
blockStreamingEnabled && didStreamBlockReply;
|
blockStreamingEnabled && didStreamBlockReply;
|
||||||
const messagingToolSentTexts = runResult.messagingToolSentTexts ?? [];
|
const messagingToolSentTexts = runResult.messagingToolSentTexts ?? [];
|
||||||
const dedupedPayloads =
|
const dedupedPayloads = filterMessagingToolDuplicates({
|
||||||
messagingToolSentTexts.length > 0
|
payloads: replyTaggedPayloads,
|
||||||
? replyTaggedPayloads.filter(
|
sentTexts: messagingToolSentTexts,
|
||||||
(payload) =>
|
});
|
||||||
!isMessagingToolDuplicate(
|
|
||||||
payload.text ?? "",
|
|
||||||
messagingToolSentTexts,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: replyTaggedPayloads;
|
|
||||||
const filteredPayloads = shouldDropFinalPayloads
|
const filteredPayloads = shouldDropFinalPayloads
|
||||||
? []
|
? []
|
||||||
: blockStreamingEnabled
|
: blockStreamingEnabled
|
||||||
|
|||||||
98
src/auto-reply/reply/followup-runner.messaging-tools.test.ts
Normal file
98
src/auto-reply/reply/followup-runner.messaging-tools.test.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { FollowupRun } from "./queue.js";
|
||||||
|
import { createMockTypingController } from "./test-helpers.js";
|
||||||
|
|
||||||
|
const runEmbeddedPiAgentMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../agents/model-fallback.js", () => ({
|
||||||
|
runWithModelFallback: async ({
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
run,
|
||||||
|
}: {
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
run: (provider: string, model: string) => Promise<unknown>;
|
||||||
|
}) => ({
|
||||||
|
result: await run(provider, model),
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../agents/pi-embedded.js", () => ({
|
||||||
|
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createFollowupRunner } from "./followup-runner.js";
|
||||||
|
|
||||||
|
const baseQueuedRun = (): FollowupRun =>
|
||||||
|
({
|
||||||
|
prompt: "hello",
|
||||||
|
summaryLine: "hello",
|
||||||
|
enqueuedAt: Date.now(),
|
||||||
|
run: {
|
||||||
|
sessionId: "session",
|
||||||
|
sessionKey: "main",
|
||||||
|
messageProvider: "whatsapp",
|
||||||
|
sessionFile: "/tmp/session.jsonl",
|
||||||
|
workspaceDir: "/tmp",
|
||||||
|
config: {},
|
||||||
|
skillsSnapshot: {},
|
||||||
|
provider: "anthropic",
|
||||||
|
model: "claude",
|
||||||
|
thinkLevel: "low",
|
||||||
|
verboseLevel: "off",
|
||||||
|
elevatedLevel: "off",
|
||||||
|
bashElevated: {
|
||||||
|
enabled: false,
|
||||||
|
allowed: false,
|
||||||
|
defaultLevel: "off",
|
||||||
|
},
|
||||||
|
timeoutMs: 1_000,
|
||||||
|
blockReplyBreak: "message_end",
|
||||||
|
},
|
||||||
|
}) as FollowupRun;
|
||||||
|
|
||||||
|
describe("createFollowupRunner messaging tool dedupe", () => {
|
||||||
|
it("drops payloads already sent via messaging tool", async () => {
|
||||||
|
const onBlockReply = vi.fn(async () => {});
|
||||||
|
runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "hello world!" }],
|
||||||
|
messagingToolSentTexts: ["hello world!"],
|
||||||
|
meta: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const runner = createFollowupRunner({
|
||||||
|
opts: { onBlockReply },
|
||||||
|
typing: createMockTypingController(),
|
||||||
|
typingMode: "instant",
|
||||||
|
defaultModel: "anthropic/claude-opus-4-5",
|
||||||
|
});
|
||||||
|
|
||||||
|
await runner(baseQueuedRun());
|
||||||
|
|
||||||
|
expect(onBlockReply).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delivers payloads when not duplicates", async () => {
|
||||||
|
const onBlockReply = vi.fn(async () => {});
|
||||||
|
runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "hello world!" }],
|
||||||
|
messagingToolSentTexts: ["different message"],
|
||||||
|
meta: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const runner = createFollowupRunner({
|
||||||
|
opts: { onBlockReply },
|
||||||
|
typing: createMockTypingController(),
|
||||||
|
typingMode: "instant",
|
||||||
|
defaultModel: "anthropic/claude-opus-4-5",
|
||||||
|
});
|
||||||
|
|
||||||
|
await runner(baseQueuedRun());
|
||||||
|
|
||||||
|
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,7 +14,10 @@ import type { OriginatingChannelType } from "../templating.js";
|
|||||||
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||||
import type { FollowupRun } from "./queue.js";
|
import type { FollowupRun } from "./queue.js";
|
||||||
import { extractReplyToTag } from "./reply-tags.js";
|
import {
|
||||||
|
applyReplyThreading,
|
||||||
|
filterMessagingToolDuplicates,
|
||||||
|
} from "./reply-payloads.js";
|
||||||
import {
|
import {
|
||||||
createReplyToModeFilter,
|
createReplyToModeFilter,
|
||||||
resolveReplyToMode,
|
resolveReplyToMode,
|
||||||
@@ -193,24 +196,17 @@ export function createFollowupRunner(params: {
|
|||||||
resolveReplyToMode(queued.run.config, replyToChannel),
|
resolveReplyToMode(queued.run.config, replyToChannel),
|
||||||
);
|
);
|
||||||
|
|
||||||
const replyTaggedPayloads: ReplyPayload[] = sanitizedPayloads
|
const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({
|
||||||
.map((payload) => {
|
payloads: sanitizedPayloads,
|
||||||
const { cleaned, replyToId } = extractReplyToTag(payload.text);
|
applyReplyToMode,
|
||||||
return {
|
});
|
||||||
...payload,
|
|
||||||
text: cleaned ? cleaned : undefined,
|
|
||||||
replyToId: replyToId ?? payload.replyToId,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(
|
|
||||||
(payload) =>
|
|
||||||
payload.text ||
|
|
||||||
payload.mediaUrl ||
|
|
||||||
(payload.mediaUrls && payload.mediaUrls.length > 0),
|
|
||||||
)
|
|
||||||
.map(applyReplyToMode);
|
|
||||||
|
|
||||||
if (replyTaggedPayloads.length === 0) return;
|
const dedupedPayloads = filterMessagingToolDuplicates({
|
||||||
|
payloads: replyTaggedPayloads,
|
||||||
|
sentTexts: runResult.messagingToolSentTexts ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dedupedPayloads.length === 0) return;
|
||||||
|
|
||||||
if (autoCompactionCompleted) {
|
if (autoCompactionCompleted) {
|
||||||
const count = await incrementCompactionCount({
|
const count = await incrementCompactionCount({
|
||||||
@@ -275,7 +271,7 @@ export function createFollowupRunner(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendFollowupPayloads(replyTaggedPayloads, queued);
|
await sendFollowupPayloads(dedupedPayloads, queued);
|
||||||
} finally {
|
} finally {
|
||||||
typing.markRunComplete();
|
typing.markRunComplete();
|
||||||
}
|
}
|
||||||
|
|||||||
52
src/auto-reply/reply/reply-payloads.ts
Normal file
52
src/auto-reply/reply/reply-payloads.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { isMessagingToolDuplicate } from "../../agents/pi-embedded-helpers.js";
|
||||||
|
import type { ReplyPayload } from "../types.js";
|
||||||
|
import { extractReplyToTag } from "./reply-tags.js";
|
||||||
|
|
||||||
|
export type ReplyToModeFilter = (payload: ReplyPayload) => ReplyPayload;
|
||||||
|
|
||||||
|
export function applyReplyTagsToPayload(
|
||||||
|
payload: ReplyPayload,
|
||||||
|
currentMessageId?: string,
|
||||||
|
): ReplyPayload {
|
||||||
|
if (typeof payload.text !== "string") return payload;
|
||||||
|
const { cleaned, replyToId } = extractReplyToTag(
|
||||||
|
payload.text,
|
||||||
|
currentMessageId,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
text: cleaned ? cleaned : undefined,
|
||||||
|
replyToId: replyToId ?? payload.replyToId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRenderablePayload(payload: ReplyPayload): boolean {
|
||||||
|
return Boolean(
|
||||||
|
payload.text ||
|
||||||
|
payload.mediaUrl ||
|
||||||
|
(payload.mediaUrls && payload.mediaUrls.length > 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyReplyThreading(params: {
|
||||||
|
payloads: ReplyPayload[];
|
||||||
|
applyReplyToMode: ReplyToModeFilter;
|
||||||
|
currentMessageId?: string;
|
||||||
|
}): ReplyPayload[] {
|
||||||
|
const { payloads, applyReplyToMode, currentMessageId } = params;
|
||||||
|
return payloads
|
||||||
|
.map((payload) => applyReplyTagsToPayload(payload, currentMessageId))
|
||||||
|
.filter(isRenderablePayload)
|
||||||
|
.map(applyReplyToMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterMessagingToolDuplicates(params: {
|
||||||
|
payloads: ReplyPayload[];
|
||||||
|
sentTexts: string[];
|
||||||
|
}): ReplyPayload[] {
|
||||||
|
const { payloads, sentTexts } = params;
|
||||||
|
if (sentTexts.length === 0) return payloads;
|
||||||
|
return payloads.filter(
|
||||||
|
(payload) => !isMessagingToolDuplicate(payload.text ?? "", sentTexts),
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user