* fix(hooks): include guildId and channelName in message_received metadata The message_received hook (both plugin and internal) already exposes sender identity fields (senderId, senderName, senderUsername, senderE164) but omits the guild/channel context. Plugins that track per-channel activity receive NULL values for channel identification. Add guildId (ctx.GroupSpace) and channelName (ctx.GroupChannel) to the metadata block in both the plugin hook and internal hook dispatch paths. These properties are already populated by channel providers (e.g. Discord sets GroupSpace to the guild ID and GroupChannel to #channel-name) and used elsewhere in the codebase (channels/conversation-label.ts). * test: cover guild/channel hook metadata propagation (#26115) (thanks @davidrudduck) --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
595 lines
19 KiB
TypeScript
595 lines
19 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { OpenClawConfig } from "../../config/config.js";
|
|
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
|
|
import type { MsgContext } from "../templating.js";
|
|
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
|
import type { ReplyDispatcher } from "./reply-dispatcher.js";
|
|
import { buildTestCtx } from "./test-ctx.js";
|
|
|
|
type AbortResult = { handled: boolean; aborted: boolean; stoppedSubagents?: number };
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
routeReply: vi.fn(async (_params: unknown) => ({ ok: true, messageId: "mock" })),
|
|
tryFastAbortFromMessage: vi.fn<() => Promise<AbortResult>>(async () => ({
|
|
handled: false,
|
|
aborted: false,
|
|
})),
|
|
}));
|
|
const diagnosticMocks = vi.hoisted(() => ({
|
|
logMessageQueued: vi.fn(),
|
|
logMessageProcessed: vi.fn(),
|
|
logSessionStateChange: vi.fn(),
|
|
}));
|
|
const hookMocks = vi.hoisted(() => ({
|
|
runner: {
|
|
hasHooks: vi.fn(() => false),
|
|
runMessageReceived: vi.fn(async () => {}),
|
|
},
|
|
}));
|
|
const internalHookMocks = vi.hoisted(() => ({
|
|
createInternalHookEvent: vi.fn(),
|
|
triggerInternalHook: vi.fn(async () => {}),
|
|
}));
|
|
|
|
vi.mock("./route-reply.js", () => ({
|
|
isRoutableChannel: (channel: string | undefined) =>
|
|
Boolean(
|
|
channel &&
|
|
["telegram", "slack", "discord", "signal", "imessage", "whatsapp"].includes(channel),
|
|
),
|
|
routeReply: mocks.routeReply,
|
|
}));
|
|
|
|
vi.mock("./abort.js", () => ({
|
|
tryFastAbortFromMessage: mocks.tryFastAbortFromMessage,
|
|
formatAbortReplyText: (stoppedSubagents?: number) => {
|
|
if (typeof stoppedSubagents !== "number" || stoppedSubagents <= 0) {
|
|
return "⚙️ Agent was aborted.";
|
|
}
|
|
const label = stoppedSubagents === 1 ? "sub-agent" : "sub-agents";
|
|
return `⚙️ Agent was aborted. Stopped ${stoppedSubagents} ${label}.`;
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../logging/diagnostic.js", () => ({
|
|
logMessageQueued: diagnosticMocks.logMessageQueued,
|
|
logMessageProcessed: diagnosticMocks.logMessageProcessed,
|
|
logSessionStateChange: diagnosticMocks.logSessionStateChange,
|
|
}));
|
|
|
|
vi.mock("../../plugins/hook-runner-global.js", () => ({
|
|
getGlobalHookRunner: () => hookMocks.runner,
|
|
}));
|
|
vi.mock("../../hooks/internal-hooks.js", () => ({
|
|
createInternalHookEvent: internalHookMocks.createInternalHookEvent,
|
|
triggerInternalHook: internalHookMocks.triggerInternalHook,
|
|
}));
|
|
|
|
const { dispatchReplyFromConfig } = await import("./dispatch-from-config.js");
|
|
const { resetInboundDedupe } = await import("./inbound-dedupe.js");
|
|
|
|
const noAbortResult = { handled: false, aborted: false } as const;
|
|
const emptyConfig = {} as OpenClawConfig;
|
|
type DispatchReplyArgs = Parameters<typeof dispatchReplyFromConfig>[0];
|
|
|
|
function createDispatcher(): ReplyDispatcher {
|
|
return {
|
|
sendToolResult: vi.fn(() => true),
|
|
sendBlockReply: vi.fn(() => true),
|
|
sendFinalReply: vi.fn(() => true),
|
|
waitForIdle: vi.fn(async () => {}),
|
|
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
|
markComplete: vi.fn(),
|
|
};
|
|
}
|
|
|
|
function setNoAbort() {
|
|
mocks.tryFastAbortFromMessage.mockResolvedValue(noAbortResult);
|
|
}
|
|
|
|
function firstToolResultPayload(dispatcher: ReplyDispatcher): ReplyPayload | undefined {
|
|
return (dispatcher.sendToolResult as ReturnType<typeof vi.fn>).mock.calls[0]?.[0] as
|
|
| ReplyPayload
|
|
| undefined;
|
|
}
|
|
|
|
async function dispatchTwiceWithFreshDispatchers(params: Omit<DispatchReplyArgs, "dispatcher">) {
|
|
await dispatchReplyFromConfig({
|
|
...params,
|
|
dispatcher: createDispatcher(),
|
|
});
|
|
await dispatchReplyFromConfig({
|
|
...params,
|
|
dispatcher: createDispatcher(),
|
|
});
|
|
}
|
|
|
|
describe("dispatchReplyFromConfig", () => {
|
|
beforeEach(() => {
|
|
resetInboundDedupe();
|
|
diagnosticMocks.logMessageQueued.mockClear();
|
|
diagnosticMocks.logMessageProcessed.mockClear();
|
|
diagnosticMocks.logSessionStateChange.mockClear();
|
|
hookMocks.runner.hasHooks.mockClear();
|
|
hookMocks.runner.hasHooks.mockReturnValue(false);
|
|
hookMocks.runner.runMessageReceived.mockClear();
|
|
internalHookMocks.createInternalHookEvent.mockClear();
|
|
internalHookMocks.createInternalHookEvent.mockImplementation(createInternalHookEventPayload);
|
|
internalHookMocks.triggerInternalHook.mockClear();
|
|
});
|
|
it("does not route when Provider matches OriginatingChannel (even if Surface is missing)", async () => {
|
|
setNoAbort();
|
|
mocks.routeReply.mockClear();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "slack",
|
|
Surface: undefined,
|
|
OriginatingChannel: "slack",
|
|
OriginatingTo: "channel:C123",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
_opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => ({ text: "hi" }) satisfies ReplyPayload;
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(mocks.routeReply).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("routes when OriginatingChannel differs from Provider", async () => {
|
|
setNoAbort();
|
|
mocks.routeReply.mockClear();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "slack",
|
|
AccountId: "acc-1",
|
|
MessageThreadId: 123,
|
|
OriginatingChannel: "telegram",
|
|
OriginatingTo: "telegram:999",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
_opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => ({ text: "hi" }) satisfies ReplyPayload;
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
expect(mocks.routeReply).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
channel: "telegram",
|
|
to: "telegram:999",
|
|
accountId: "acc-1",
|
|
threadId: 123,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("routes media-only tool results when summaries are suppressed", async () => {
|
|
setNoAbort();
|
|
mocks.routeReply.mockClear();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "slack",
|
|
ChatType: "group",
|
|
AccountId: "acc-1",
|
|
OriginatingChannel: "telegram",
|
|
OriginatingTo: "telegram:999",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
expect(opts?.onToolResult).toBeDefined();
|
|
await opts?.onToolResult?.({
|
|
text: "NO_REPLY",
|
|
mediaUrls: ["https://example.com/tts-routed.opus"],
|
|
});
|
|
return undefined;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
expect(mocks.routeReply).toHaveBeenCalledTimes(1);
|
|
const routed = mocks.routeReply.mock.calls[0]?.[0] as { payload?: ReplyPayload } | undefined;
|
|
expect(routed?.payload?.mediaUrls).toEqual(["https://example.com/tts-routed.opus"]);
|
|
expect(routed?.payload?.text).toBeUndefined();
|
|
});
|
|
|
|
it("provides onToolResult in DM sessions", async () => {
|
|
setNoAbort();
|
|
mocks.routeReply.mockClear();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "direct",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
expect(opts?.onToolResult).toBeDefined();
|
|
expect(typeof opts?.onToolResult).toBe("function");
|
|
return { text: "hi" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("suppresses group tool summaries but still forwards tool media", async () => {
|
|
setNoAbort();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "group",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
expect(opts?.onToolResult).toBeDefined();
|
|
await opts?.onToolResult?.({ text: "🔧 exec: ls" });
|
|
await opts?.onToolResult?.({
|
|
text: "NO_REPLY",
|
|
mediaUrls: ["https://example.com/tts-group.opus"],
|
|
});
|
|
return { text: "hi" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1);
|
|
const sent = firstToolResultPayload(dispatcher);
|
|
expect(sent?.mediaUrls).toEqual(["https://example.com/tts-group.opus"]);
|
|
expect(sent?.text).toBeUndefined();
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("sends tool results via dispatcher in DM sessions", async () => {
|
|
setNoAbort();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "direct",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
// Simulate tool result emission
|
|
await opts?.onToolResult?.({ text: "🔧 exec: ls" });
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
expect(dispatcher.sendToolResult).toHaveBeenCalledWith(
|
|
expect.objectContaining({ text: "🔧 exec: ls" }),
|
|
);
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("suppresses native tool summaries but still forwards tool media", async () => {
|
|
setNoAbort();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "direct",
|
|
CommandSource: "native",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
expect(opts?.onToolResult).toBeDefined();
|
|
await opts?.onToolResult?.({ text: "🔧 tools/sessions_send" });
|
|
await opts?.onToolResult?.({
|
|
mediaUrl: "https://example.com/tts-native.opus",
|
|
});
|
|
return { text: "hi" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1);
|
|
const sent = firstToolResultPayload(dispatcher);
|
|
expect(sent?.mediaUrl).toBe("https://example.com/tts-native.opus");
|
|
expect(sent?.text).toBeUndefined();
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("fast-aborts without calling the reply resolver", async () => {
|
|
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
|
handled: true,
|
|
aborted: true,
|
|
});
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
Body: "/stop",
|
|
});
|
|
const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload);
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(replyResolver).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({
|
|
text: "⚙️ Agent was aborted.",
|
|
});
|
|
});
|
|
|
|
it("fast-abort reply includes stopped subagent count when provided", async () => {
|
|
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
|
handled: true,
|
|
aborted: true,
|
|
stoppedSubagents: 2,
|
|
});
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
Body: "/stop",
|
|
});
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyResolver: vi.fn(async () => ({ text: "hi" }) as ReplyPayload),
|
|
});
|
|
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({
|
|
text: "⚙️ Agent was aborted. Stopped 2 sub-agents.",
|
|
});
|
|
});
|
|
|
|
it("deduplicates inbound messages by MessageSid and origin", async () => {
|
|
setNoAbort();
|
|
const cfg = emptyConfig;
|
|
const ctx = buildTestCtx({
|
|
Provider: "whatsapp",
|
|
OriginatingChannel: "whatsapp",
|
|
OriginatingTo: "whatsapp:+15555550123",
|
|
MessageSid: "msg-1",
|
|
});
|
|
const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload);
|
|
|
|
await dispatchTwiceWithFreshDispatchers({
|
|
ctx,
|
|
cfg,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("emits message_received hook with originating channel metadata", async () => {
|
|
setNoAbort();
|
|
hookMocks.runner.hasHooks.mockReturnValue(true);
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "slack",
|
|
Surface: "slack",
|
|
OriginatingChannel: "Telegram",
|
|
OriginatingTo: "telegram:999",
|
|
CommandBody: "/search hello",
|
|
RawBody: "raw text",
|
|
Body: "body text",
|
|
Timestamp: 1710000000000,
|
|
MessageSidFull: "sid-full",
|
|
SenderId: "user-1",
|
|
SenderName: "Alice",
|
|
SenderUsername: "alice",
|
|
SenderE164: "+15555550123",
|
|
AccountId: "acc-1",
|
|
GroupSpace: "guild-123",
|
|
GroupChannel: "alerts",
|
|
});
|
|
|
|
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(hookMocks.runner.runMessageReceived).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
from: ctx.From,
|
|
content: "/search hello",
|
|
timestamp: 1710000000000,
|
|
metadata: expect.objectContaining({
|
|
originatingChannel: "Telegram",
|
|
originatingTo: "telegram:999",
|
|
messageId: "sid-full",
|
|
senderId: "user-1",
|
|
senderName: "Alice",
|
|
senderUsername: "alice",
|
|
senderE164: "+15555550123",
|
|
guildId: "guild-123",
|
|
channelName: "alerts",
|
|
}),
|
|
}),
|
|
expect.objectContaining({
|
|
channelId: "telegram",
|
|
accountId: "acc-1",
|
|
conversationId: "telegram:999",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("emits internal message:received hook when a session key is available", async () => {
|
|
setNoAbort();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
SessionKey: "agent:main:main",
|
|
CommandBody: "/help",
|
|
MessageSid: "msg-42",
|
|
GroupSpace: "guild-456",
|
|
GroupChannel: "ops-room",
|
|
});
|
|
|
|
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledWith(
|
|
"message",
|
|
"received",
|
|
"agent:main:main",
|
|
expect.objectContaining({
|
|
from: ctx.From,
|
|
content: "/help",
|
|
channelId: "telegram",
|
|
messageId: "msg-42",
|
|
metadata: expect.objectContaining({
|
|
guildId: "guild-456",
|
|
channelName: "ops-room",
|
|
}),
|
|
}),
|
|
);
|
|
expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("skips internal message:received hook when session key is unavailable", async () => {
|
|
setNoAbort();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
CommandBody: "/help",
|
|
});
|
|
(ctx as MsgContext).SessionKey = undefined;
|
|
|
|
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(internalHookMocks.createInternalHookEvent).not.toHaveBeenCalled();
|
|
expect(internalHookMocks.triggerInternalHook).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("emits diagnostics when enabled", async () => {
|
|
setNoAbort();
|
|
const cfg = { diagnostics: { enabled: true } } as OpenClawConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "slack",
|
|
Surface: "slack",
|
|
SessionKey: "agent:main:main",
|
|
MessageSid: "msg-1",
|
|
To: "slack:C123",
|
|
});
|
|
|
|
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(diagnosticMocks.logMessageQueued).toHaveBeenCalledTimes(1);
|
|
expect(diagnosticMocks.logSessionStateChange).toHaveBeenCalledWith({
|
|
sessionKey: "agent:main:main",
|
|
state: "processing",
|
|
reason: "message_start",
|
|
});
|
|
expect(diagnosticMocks.logMessageProcessed).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
channel: "slack",
|
|
outcome: "completed",
|
|
sessionKey: "agent:main:main",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("marks diagnostics skipped for duplicate inbound messages", async () => {
|
|
setNoAbort();
|
|
const cfg = { diagnostics: { enabled: true } } as OpenClawConfig;
|
|
const ctx = buildTestCtx({
|
|
Provider: "whatsapp",
|
|
OriginatingChannel: "whatsapp",
|
|
OriginatingTo: "whatsapp:+15555550123",
|
|
MessageSid: "msg-dup",
|
|
});
|
|
const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload);
|
|
|
|
await dispatchTwiceWithFreshDispatchers({
|
|
ctx,
|
|
cfg,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(diagnosticMocks.logMessageProcessed).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
channel: "whatsapp",
|
|
outcome: "skipped",
|
|
reason: "duplicate",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("suppresses isReasoning payloads from final replies (WhatsApp channel)", async () => {
|
|
setNoAbort();
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({ Provider: "whatsapp" });
|
|
const replyResolver = async () =>
|
|
[
|
|
{ text: "Reasoning:\n_thinking..._", isReasoning: true },
|
|
{ text: "The answer is 42" },
|
|
] satisfies ReplyPayload[];
|
|
await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher, replyResolver });
|
|
const finalCalls = (dispatcher.sendFinalReply as ReturnType<typeof vi.fn>).mock.calls;
|
|
expect(finalCalls).toHaveLength(1);
|
|
expect(finalCalls[0][0]).toMatchObject({ text: "The answer is 42" });
|
|
});
|
|
|
|
it("suppresses isReasoning payloads from block replies (generic dispatch path)", async () => {
|
|
setNoAbort();
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({ Provider: "whatsapp" });
|
|
const blockReplySentTexts: string[] = [];
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
): Promise<ReplyPayload> => {
|
|
// Simulate block reply with reasoning payload
|
|
await opts?.onBlockReply?.({ text: "Reasoning:\n_thinking..._", isReasoning: true });
|
|
await opts?.onBlockReply?.({ text: "The answer is 42" });
|
|
return { text: "The answer is 42" };
|
|
};
|
|
// Capture what actually gets dispatched as block replies
|
|
(dispatcher.sendBlockReply as ReturnType<typeof vi.fn>).mockImplementation(
|
|
(payload: ReplyPayload) => {
|
|
if (payload.text) {
|
|
blockReplySentTexts.push(payload.text);
|
|
}
|
|
return true;
|
|
},
|
|
);
|
|
await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher, replyResolver });
|
|
expect(blockReplySentTexts).not.toContain("Reasoning:\n_thinking..._");
|
|
expect(blockReplySentTexts).toContain("The answer is 42");
|
|
});
|
|
});
|