





















11import { beforeEach, describe, expect, it, vi } from "vitest";
22import type { OpenClawConfig } from "../config/config.js";
33import { onDiagnosticEvent, resetDiagnosticEventsForTest } from "../infra/diagnostic-events.js";
4-import type { ReplyDispatcher } from "./reply/reply-dispatcher.js";
4+import type { ReplyDispatchBeforeDeliver, ReplyDispatcher } from "./reply/reply-dispatcher.js";
55import { buildTestCtx } from "./reply/test-ctx.js";
6677type DispatchReplyFromConfigFn =
@@ -119,6 +119,7 @@ describe("withReplyDispatcher", () => {
119119hoisted.getGlobalHookRunnerMock.mockReturnValue({
120120hasHooks: vi.fn(() => false),
121121runMessageSending: vi.fn(async () => undefined),
122+runReplyPayloadSending: vi.fn(async () => undefined),
122123});
123124});
124125@@ -309,6 +310,246 @@ describe("withReplyDispatcher", () => {
309310);
310311});
311312313+it("runs reply_payload_sending hooks before inbound dispatcher delivery", async () => {
314+const runReplyPayloadSending = vi.fn(async ({ payload }: { payload: { text?: string } }) => ({
315+payload: {
316+ ...payload,
317+text: `${payload.text ?? ""} + buttons`,
318+presentation: {
319+blocks: [
320+{
321+type: "buttons",
322+buttons: [{ label: "Proceed", value: "action:proceed" }],
323+},
324+],
325+},
326+},
327+}));
328+hoisted.getGlobalHookRunnerMock.mockReturnValue({
329+hasHooks: vi.fn((hookName?: string) => hookName === "reply_payload_sending"),
330+runMessageSending: vi.fn(async () => undefined),
331+ runReplyPayloadSending,
332+});
333+hoisted.createReplyDispatcherMock.mockReturnValueOnce(createDispatcher([]));
334+hoisted.dispatchReplyFromConfigMock.mockResolvedValueOnce({ text: "ok" });
335+336+await dispatchInboundMessageWithDispatcher({
337+ctx: buildTestCtx({ Surface: "telegram", SessionKey: "agent:test:session" }),
338+cfg: {} as OpenClawConfig,
339+dispatcherOptions: {
340+deliver: async () => undefined,
341+},
342+replyOptions: { runId: "run-123" },
343+replyResolver: async () => ({ text: "ok" }),
344+});
345+346+const dispatcherOptions = requireReplyDispatcherOptions();
347+if (!dispatcherOptions?.beforeDeliver) {
348+throw new Error("expected beforeDeliver hook");
349+}
350+351+const payload = await dispatcherOptions.beforeDeliver(
352+{ text: "original reply" },
353+{ kind: "final" },
354+);
355+356+expect(payload).toEqual({
357+text: "original reply + buttons",
358+presentation: {
359+blocks: [
360+{
361+type: "buttons",
362+buttons: [{ label: "Proceed", value: "action:proceed" }],
363+},
364+],
365+},
366+});
367+expect(runReplyPayloadSending).toHaveBeenCalledWith(
368+{
369+payload: { text: "original reply" },
370+kind: "final",
371+channel: "telegram",
372+sessionKey: "agent:test:session",
373+runId: "run-123",
374+},
375+{
376+channelId: "threads",
377+accountId: "acct-1",
378+conversationId: "conv-1",
379+runId: "run-123",
380+},
381+);
382+});
383+384+it("runs message_sending after reply_payload_sending for inbound dispatcher delivery", async () => {
385+const runReplyPayloadSending = vi.fn(async ({ payload }: { payload: { text?: string } }) => ({
386+payload: {
387+ ...payload,
388+text: `${payload.text ?? ""} + plugin`,
389+},
390+}));
391+const runMessageSending = vi.fn(async () => ({ content: "sanitized plugin reply" }));
392+hoisted.getGlobalHookRunnerMock.mockReturnValue({
393+hasHooks: vi.fn(
394+(hookName?: string) =>
395+hookName === "reply_payload_sending" || hookName === "message_sending",
396+),
397+ runMessageSending,
398+ runReplyPayloadSending,
399+});
400+hoisted.createReplyDispatcherMock.mockReturnValueOnce(createDispatcher([]));
401+hoisted.dispatchReplyFromConfigMock.mockResolvedValueOnce({ text: "ok" });
402+403+await dispatchInboundMessageWithDispatcher({
404+ctx: buildTestCtx({
405+Surface: "telegram",
406+SessionKey: "agent:test:session",
407+OriginatingTo: "telegram:chat-1",
408+}),
409+cfg: {} as OpenClawConfig,
410+dispatcherOptions: {
411+deliver: async () => undefined,
412+},
413+replyResolver: async () => ({ text: "ok" }),
414+});
415+416+const dispatcherOptions = requireReplyDispatcherOptions();
417+if (!dispatcherOptions?.beforeDeliver) {
418+throw new Error("expected beforeDeliver hook");
419+}
420+421+const payload = await dispatcherOptions.beforeDeliver(
422+{ text: "original reply" },
423+{ kind: "final" },
424+);
425+426+expect(payload).toEqual({ text: "sanitized plugin reply" });
427+expect(runReplyPayloadSending).toHaveBeenCalledWith(
428+expect.objectContaining({
429+payload: { text: "original reply" },
430+}),
431+expect.anything(),
432+);
433+expect(runMessageSending).toHaveBeenCalledWith(
434+{ content: "original reply + plugin", to: "telegram:chat-1" },
435+expect.objectContaining({ channelId: "threads" }),
436+);
437+});
438+439+it("suppresses inbound dispatcher delivery when reply_payload_sending empties the payload", async () => {
440+const runReplyPayloadSending = vi.fn(async ({ payload }: { payload: { text?: string } }) => ({
441+payload: {
442+ ...payload,
443+text: "",
444+},
445+}));
446+hoisted.getGlobalHookRunnerMock.mockReturnValue({
447+hasHooks: vi.fn((hookName?: string) => hookName === "reply_payload_sending"),
448+runMessageSending: vi.fn(async () => undefined),
449+ runReplyPayloadSending,
450+});
451+hoisted.createReplyDispatcherMock.mockReturnValueOnce(createDispatcher([]));
452+hoisted.dispatchReplyFromConfigMock.mockResolvedValueOnce({ text: "ok" });
453+454+await dispatchInboundMessageWithDispatcher({
455+ctx: buildTestCtx({ Surface: "telegram", SessionKey: "agent:test:session" }),
456+cfg: {} as OpenClawConfig,
457+dispatcherOptions: {
458+deliver: async () => undefined,
459+},
460+replyResolver: async () => ({ text: "ok" }),
461+});
462+463+const dispatcherOptions = requireReplyDispatcherOptions();
464+if (!dispatcherOptions?.beforeDeliver) {
465+throw new Error("expected beforeDeliver hook");
466+}
467+468+const payload = await dispatcherOptions.beforeDeliver(
469+{ text: "original reply" },
470+{ kind: "final" },
471+);
472+473+expect(payload).toBeNull();
474+});
475+476+it("installs reply_payload_sending hooks on prebuilt dispatchers", async () => {
477+const runReplyPayloadSending = vi.fn(async ({ payload }: { payload: { text?: string } }) => ({
478+payload: {
479+ ...payload,
480+text: `${payload.text ?? ""} + installed`,
481+},
482+}));
483+hoisted.getGlobalHookRunnerMock.mockReturnValue({
484+hasHooks: vi.fn((hookName?: string) => hookName === "reply_payload_sending"),
485+runMessageSending: vi.fn(async () => undefined),
486+ runReplyPayloadSending,
487+});
488+hoisted.dispatchReplyFromConfigMock.mockResolvedValueOnce({ text: "ok" });
489+const installedHooks: ReplyDispatchBeforeDeliver[] = [];
490+const dispatcher = {
491+ ...createDispatcher([]),
492+appendBeforeDeliver: vi.fn((hook: ReplyDispatchBeforeDeliver) => {
493+installedHooks.push(hook);
494+}),
495+};
496+497+await dispatchInboundMessage({
498+ctx: buildTestCtx({ Surface: "discord", SessionKey: "agent:test:session" }),
499+cfg: {} as OpenClawConfig,
500+ dispatcher,
501+replyOptions: { runId: "run-456" },
502+replyResolver: async () => ({ text: "ok" }),
503+});
504+505+expect(dispatcher.appendBeforeDeliver).toHaveBeenCalledTimes(1);
506+const installedHook = installedHooks[0];
507+if (!installedHook) {
508+throw new Error("expected installed beforeDeliver hook");
509+}
510+const payload = await installedHook({ text: "prebuilt reply" }, { kind: "final" });
511+512+expect(payload).toEqual({ text: "prebuilt reply + installed" });
513+expect(runReplyPayloadSending).toHaveBeenCalledWith(
514+{
515+payload: { text: "prebuilt reply" },
516+kind: "final",
517+channel: "discord",
518+sessionKey: "agent:test:session",
519+runId: "run-456",
520+},
521+{
522+accountId: "acct-1",
523+channelId: "threads",
524+conversationId: "conv-1",
525+runId: "run-456",
526+},
527+);
528+});
529+530+it("installs reply_payload_sending hooks before lazy plugin availability is known", async () => {
531+hoisted.getGlobalHookRunnerMock.mockReturnValue({
532+hasHooks: vi.fn(() => false),
533+runMessageSending: vi.fn(async () => undefined),
534+runReplyPayloadSending: vi.fn(async () => undefined),
535+});
536+hoisted.dispatchReplyFromConfigMock.mockResolvedValueOnce({ text: "ok" });
537+const dispatcher = {
538+ ...createDispatcher([]),
539+appendBeforeDeliver: vi.fn(),
540+};
541+542+await dispatchInboundMessage({
543+ctx: buildTestCtx({ Surface: "discord", SessionKey: "agent:test:session" }),
544+cfg: {} as OpenClawConfig,
545+ dispatcher,
546+replyOptions: { runId: "run-789" },
547+replyResolver: async () => ({ text: "ok" }),
548+});
549+550+expect(dispatcher.appendBeforeDeliver).toHaveBeenCalledTimes(1);
551+});
552+312553it("reconciles queuedFinal and counts after dispatcher-side cancellation", async () => {
313554const dispatcher = {
314555sendToolResult: () => true,
@@ -426,6 +667,67 @@ describe("withReplyDispatcher", () => {
426667expect(dispatcherOptions.silentReplyContext?.conversationType).toBe("direct");
427668});
428669670+it("composes custom beforeDeliver with reply_payload_sending hooks", async () => {
671+const customBeforeDeliver = vi.fn(async (payload: { text?: string }) => ({
672+text: `${payload.text ?? ""} [custom]`,
673+}));
674+const runMessageSending = vi.fn(async () => ({ content: "message hook" }));
675+const runReplyPayloadSending = vi.fn(async ({ payload }: { payload: { text?: string } }) => ({
676+payload: {
677+ ...payload,
678+text: `${payload.text ?? ""} [plugin]`,
679+},
680+}));
681+hoisted.getGlobalHookRunnerMock.mockReturnValue({
682+hasHooks: vi.fn(
683+(hookName?: string) =>
684+hookName === "message_sending" || hookName === "reply_payload_sending",
685+),
686+ runMessageSending,
687+ runReplyPayloadSending,
688+});
689+hoisted.createReplyDispatcherMock.mockReturnValueOnce(createDispatcher([]));
690+hoisted.dispatchReplyFromConfigMock.mockResolvedValueOnce({ text: "ok" });
691+692+await dispatchInboundMessageWithDispatcher({
693+ctx: buildTestCtx({ Surface: "telegram", SessionKey: "agent:test:session" }),
694+cfg: {} as OpenClawConfig,
695+dispatcherOptions: {
696+deliver: async () => undefined,
697+beforeDeliver: customBeforeDeliver,
698+},
699+replyResolver: async () => ({ text: "ok" }),
700+});
701+702+const dispatcherOptions = requireReplyDispatcherOptions();
703+if (!dispatcherOptions?.beforeDeliver) {
704+throw new Error("expected beforeDeliver hook");
705+}
706+707+const payload = await dispatcherOptions.beforeDeliver({ text: "original" }, { kind: "final" });
708+709+expect(customBeforeDeliver).toHaveBeenCalledTimes(1);
710+expect(customBeforeDeliver).toHaveBeenCalledWith({ text: "original" }, { kind: "final" });
711+expect(runMessageSending).not.toHaveBeenCalled();
712+expect(runReplyPayloadSending).toHaveBeenCalledTimes(1);
713+expect(runReplyPayloadSending).toHaveBeenCalledWith(
714+{
715+payload: { text: "original [custom]" },
716+kind: "final",
717+channel: "telegram",
718+sessionKey: "agent:test:session",
719+runId: undefined,
720+},
721+{
722+accountId: "acct-1",
723+channelId: "threads",
724+conversationId: "conv-1",
725+runId: undefined,
726+},
727+);
728+expect(payload).toEqual({ text: "original [custom] [plugin]" });
729+});
730+429731it("does not copy source conversation type onto cross-session native silent-reply targets", async () => {
430732hoisted.createReplyDispatcherWithTypingMock.mockReturnValueOnce({
431733dispatcher: createDispatcher([]),
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。