refactor: unify reply dispatch across providers

This commit is contained in:
Peter Steinberger
2026-01-05 19:43:54 +01:00
parent bfe7f5f126
commit c75b2a7067
17 changed files with 953 additions and 476 deletions

View File

@@ -0,0 +1,151 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { monitorDiscordProvider } from "./monitor.js";
const sendMock = vi.fn();
const replyMock = vi.fn();
const updateLastRouteMock = vi.fn();
let config: Record<string, unknown> = {};
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => config,
};
});
vi.mock("../auto-reply/reply.js", () => ({
getReplyFromConfig: (...args: unknown[]) => replyMock(...args),
}));
vi.mock("./send.js", () => ({
sendMessageDiscord: (...args: unknown[]) => sendMock(...args),
}));
vi.mock("../config/sessions.js", () => ({
resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"),
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
resolveSessionKey: vi.fn(),
}));
vi.mock("discord.js", () => {
const handlers = new Map<string, Set<(...args: unknown[]) => void>>();
let lastClient: Client | null = null;
class Client {
user = { id: "bot-id", tag: "bot#1" };
constructor() {
lastClient = this;
}
on(event: string, handler: (...args: unknown[]) => void) {
if (!handlers.has(event)) handlers.set(event, new Set());
handlers.get(event)?.add(handler);
}
once(event: string, handler: (...args: unknown[]) => void) {
this.on(event, handler);
}
off(event: string, handler: (...args: unknown[]) => void) {
handlers.get(event)?.delete(handler);
}
emit(event: string, ...args: unknown[]) {
for (const handler of handlers.get(event) ?? []) {
void handler(...args);
}
}
login = vi.fn().mockResolvedValue(undefined);
destroy = vi.fn().mockResolvedValue(undefined);
}
return {
Client,
__getLastClient: () => lastClient,
Events: {
ClientReady: "ready",
Error: "error",
MessageCreate: "messageCreate",
MessageReactionAdd: "reactionAdd",
MessageReactionRemove: "reactionRemove",
},
ChannelType: {
DM: "dm",
GroupDM: "group_dm",
GuildText: "guild_text",
},
MessageType: {
Default: "default",
ChatInputCommand: "chat_command",
ContextMenuCommand: "context_command",
},
GatewayIntentBits: {},
Partials: {},
};
});
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
async function waitForClient() {
const discord = (await import("discord.js")) as unknown as {
__getLastClient: () => { emit: (...args: unknown[]) => void } | null;
};
for (let i = 0; i < 10; i += 1) {
const client = discord.__getLastClient();
if (client) return client;
await flush();
}
return null;
}
beforeEach(() => {
config = {
messages: { responsePrefix: "PFX" },
discord: { dm: { enabled: true } },
routing: { allowFrom: [] },
};
sendMock.mockReset().mockResolvedValue(undefined);
replyMock.mockReset();
updateLastRouteMock.mockReset();
});
describe("monitorDiscordProvider tool results", () => {
it("sends tool summaries with responsePrefix", async () => {
replyMock.mockImplementation(async (_ctx, opts) => {
await opts?.onToolResult?.({ text: "tool update" });
return { text: "final reply" };
});
const controller = new AbortController();
const run = monitorDiscordProvider({
token: "token",
abortSignal: controller.signal,
});
const discord = await import("discord.js");
const client = await waitForClient();
if (!client) throw new Error("Discord client not created");
client.emit(discord.Events.MessageCreate, {
id: "m1",
content: "hello",
author: { id: "u1", bot: false, username: "Ada" },
channelId: "c1",
channel: {
type: discord.ChannelType.DM,
isSendable: () => false,
},
guild: undefined,
mentions: { has: () => false },
attachments: { first: () => undefined },
type: discord.MessageType.Default,
createdTimestamp: Date.now(),
});
await flush();
controller.abort();
await run;
expect(sendMock).toHaveBeenCalledTimes(2);
expect(sendMock.mock.calls[0][1]).toBe("PFX tool update");
expect(sendMock.mock.calls[1][1]).toBe("PFX final reply");
});
});

View File

@@ -18,6 +18,7 @@ import {
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { hasControlCommand } from "../auto-reply/command-detection.js";
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
import { getReplyFromConfig } from "../auto-reply/reply.js";
import type { ReplyPayload } from "../auto-reply/types.js";
import type {
@@ -532,39 +533,36 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
}
let didSendReply = false;
let blockSendChain: Promise<void> = Promise.resolve();
const sendBlockReply = (payload: ReplyPayload) => {
if (
!payload?.text &&
!payload?.mediaUrl &&
!(payload?.mediaUrls?.length ?? 0)
) {
return;
}
blockSendChain = blockSendChain
.then(async () => {
await deliverReplies({
replies: [payload],
target: replyTarget,
token,
runtime,
replyToMode,
textLimit,
});
didSendReply = true;
})
.catch((err) => {
runtime.error?.(
danger(`discord block reply failed: ${String(err)}`),
);
const dispatcher = createReplyDispatcher({
responsePrefix: cfg.messages?.responsePrefix,
deliver: async (payload) => {
await deliverReplies({
replies: [payload],
target: replyTarget,
token,
runtime,
replyToMode,
textLimit,
});
};
didSendReply = true;
},
onError: (err, info) => {
runtime.error?.(
danger(`discord ${info.kind} reply failed: ${String(err)}`),
);
},
});
const replyResult = await getReplyFromConfig(
ctxPayload,
{
onReplyStart: () => sendTyping(message),
onBlockReply: sendBlockReply,
onToolResult: (payload) => {
dispatcher.sendToolResult(payload);
},
onBlockReply: (payload) => {
dispatcher.sendBlockReply(payload);
},
},
cfg,
);
@@ -573,8 +571,12 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
? replyResult
: [replyResult]
: [];
await blockSendChain;
if (replies.length === 0) {
let queuedFinal = false;
for (const reply of replies) {
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
}
await dispatcher.waitForIdle();
if (!queuedFinal) {
if (
isGuildMessage &&
shouldClearHistory &&
@@ -585,19 +587,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
}
return;
}
await deliverReplies({
replies,
target: replyTarget,
token,
runtime,
replyToMode,
textLimit,
});
didSendReply = true;
if (shouldLogVerbose()) {
const finalCount = dispatcher.getQueuedCounts().final;
logVerbose(
`discord: delivered ${replies.length} reply${replies.length === 1 ? "" : "ies"} to ${replyTarget}`,
`discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
);
}
if (