From ef920f2f39b4caefa8e76280ecea18d126ad01e8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 3 Mar 2026 01:05:52 +0000 Subject: [PATCH] refactor(channels): dedupe monitor message test flows --- .../monitor/message-handler.process.test.ts | 106 +++--- src/slack/monitor.tool-result.test.ts | 335 ++++++++---------- ...t-message-context.audio-transcript.test.ts | 211 +++++------ ...captures-media-path-image-messages.test.ts | 42 +-- 4 files changed, 318 insertions(+), 376 deletions(-) diff --git a/src/discord/monitor/message-handler.process.test.ts b/src/discord/monitor/message-handler.process.test.ts index c453a003b17..4d0e14e8e83 100644 --- a/src/discord/monitor/message-handler.process.test.ts +++ b/src/discord/monitor/message-handler.process.test.ts @@ -119,6 +119,13 @@ vi.mock("../../config/sessions.js", () => ({ const { processDiscordMessage } = await import("./message-handler.process.js"); const createBaseContext = createBaseDiscordMessageContext; +const BASE_CHANNEL_ROUTE = { + agentId: "main", + channel: "discord", + accountId: "default", + sessionKey: "agent:main:discord:channel:c1", + mainSessionKey: "agent:main:main", +} as const; function mockDispatchSingleBlockReply(payload: { text: string; isReasoning?: boolean }) { dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { @@ -127,6 +134,10 @@ function mockDispatchSingleBlockReply(payload: { text: string; isReasoning?: boo }); } +function createNoQueuedDispatchResult() { + return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } }; +} + async function processStreamOffDiscordMessage() { const ctx = await createBaseContext({ discordConfig: { streamMode: "off" } }); // oxlint-disable-next-line typescript/no-explicit-any @@ -144,10 +155,7 @@ beforeEach(() => { recordInboundSession.mockClear(); readSessionUpdatedAt.mockClear(); resolveStorePath.mockClear(); - dispatchInboundMessage.mockResolvedValue({ - queuedFinal: false, - counts: { final: 0, tool: 0, block: 0 }, - }); + dispatchInboundMessage.mockResolvedValue(createNoQueuedDispatchResult()); recordInboundSession.mockResolvedValue(undefined); readSessionUpdatedAt.mockReturnValue(undefined); resolveStorePath.mockReturnValue("/tmp/openclaw-discord-process-test-sessions.json"); @@ -193,6 +201,28 @@ async function runInPartialStreamMode(): Promise { await runProcessDiscordMessage(ctx); } +function getReactionEmojis(): string[] { + return ( + sendMocks.reactMessageDiscord.mock.calls as unknown as Array<[unknown, unknown, string]> + ).map((call) => call[2]); +} + +function createMockDraftStreamForTest() { + const draftStream = createMockDraftStream(); + createDiscordDraftStream.mockReturnValueOnce(draftStream); + return draftStream; +} + +function expectSinglePreviewEdit() { + expect(editMessageDiscord).toHaveBeenCalledWith( + "c1", + "preview-1", + { content: "Hello\nWorld" }, + { rest: {} }, + ); + expect(deliverDiscordReply).not.toHaveBeenCalled(); +} + describe("processDiscordMessage ack reactions", () => { it("skips ack reactions for group-mentions when mentions are not required", async () => { const ctx = await createBaseContext({ @@ -245,7 +275,7 @@ describe("processDiscordMessage ack reactions", () => { dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { await params?.replyOptions?.onReasoningStream?.(); await params?.replyOptions?.onToolStart?.({ name: "exec" }); - return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } }; + return createNoQueuedDispatchResult(); }); const ctx = await createBaseContext(); @@ -253,9 +283,7 @@ describe("processDiscordMessage ack reactions", () => { // oxlint-disable-next-line typescript/no-explicit-any await processDiscordMessage(ctx as any); - const emojis = ( - sendMocks.reactMessageDiscord.mock.calls as unknown as Array<[unknown, unknown, string]> - ).map((call) => call[2]); + const emojis = getReactionEmojis(); expect(emojis).toContain("👀"); expect(emojis).toContain(DEFAULT_EMOJIS.done); expect(emojis).not.toContain(DEFAULT_EMOJIS.thinking); @@ -270,7 +298,7 @@ describe("processDiscordMessage ack reactions", () => { }); dispatchInboundMessage.mockImplementationOnce(async () => { await dispatchGate; - return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } }; + return createNoQueuedDispatchResult(); }); const ctx = await createBaseContext(); @@ -293,7 +321,7 @@ describe("processDiscordMessage ack reactions", () => { it("applies status reaction emoji/timing overrides from config", async () => { dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { await params?.replyOptions?.onReasoningStream?.(); - return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } }; + return createNoQueuedDispatchResult(); }); const ctx = await createBaseContext({ @@ -312,9 +340,7 @@ describe("processDiscordMessage ack reactions", () => { // oxlint-disable-next-line typescript/no-explicit-any await processDiscordMessage(ctx as any); - const emojis = ( - sendMocks.reactMessageDiscord.mock.calls as unknown as Array<[unknown, unknown, string]> - ).map((call) => call[2]); + const emojis = getReactionEmojis(); expect(emojis).toContain("🟦"); expect(emojis).toContain("🏁"); }); @@ -347,13 +373,7 @@ describe("processDiscordMessage session routing", () => { it("stores group lastRoute with channel target", async () => { const ctx = await createBaseContext({ baseSessionKey: "agent:main:discord:channel:c1", - route: { - agentId: "main", - channel: "discord", - accountId: "default", - sessionKey: "agent:main:discord:channel:c1", - mainSessionKey: "agent:main:main", - }, + route: BASE_CHANNEL_ROUTE, }); // oxlint-disable-next-line typescript/no-explicit-any @@ -389,13 +409,7 @@ describe("processDiscordMessage session routing", () => { threadChannel: { id: "thread-1", name: "subagent-thread" }, boundSessionKey: "agent:main:subagent:child", threadBindings, - route: { - agentId: "main", - channel: "discord", - accountId: "default", - sessionKey: "agent:main:discord:channel:c1", - mainSessionKey: "agent:main:main", - }, + route: BASE_CHANNEL_ROUTE, }); // oxlint-disable-next-line typescript/no-explicit-any @@ -446,26 +460,12 @@ describe("processDiscordMessage draft streaming", () => { it("finalizes via preview edit when final fits one chunk", async () => { await runSingleChunkFinalScenario({ streamMode: "partial", maxLinesPerMessage: 5 }); - - expect(editMessageDiscord).toHaveBeenCalledWith( - "c1", - "preview-1", - { content: "Hello\nWorld" }, - { rest: {} }, - ); - expect(deliverDiscordReply).not.toHaveBeenCalled(); + expectSinglePreviewEdit(); }); it("accepts streaming=true alias for partial preview mode", async () => { await runSingleChunkFinalScenario({ streaming: true, maxLinesPerMessage: 5 }); - - expect(editMessageDiscord).toHaveBeenCalledWith( - "c1", - "preview-1", - { content: "Hello\nWorld" }, - { rest: {} }, - ); - expect(deliverDiscordReply).not.toHaveBeenCalled(); + expectSinglePreviewEdit(); }); it("falls back to standard send when final needs multiple chunks", async () => { @@ -508,12 +508,11 @@ describe("processDiscordMessage draft streaming", () => { }); it("streams block previews using draft chunking", async () => { - const draftStream = createMockDraftStream(); - createDiscordDraftStream.mockReturnValueOnce(draftStream); + const draftStream = createMockDraftStreamForTest(); dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { await params?.replyOptions?.onPartialReply?.({ text: "HelloWorld" }); - return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } }; + return createNoQueuedDispatchResult(); }); const ctx = await createBlockModeContext(); @@ -526,13 +525,12 @@ describe("processDiscordMessage draft streaming", () => { }); it("forces new preview messages on assistant boundaries in block mode", async () => { - const draftStream = createMockDraftStream(); - createDiscordDraftStream.mockReturnValueOnce(draftStream); + const draftStream = createMockDraftStreamForTest(); dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { await params?.replyOptions?.onPartialReply?.({ text: "Hello" }); await params?.replyOptions?.onAssistantMessageStart?.(); - return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } }; + return createNoQueuedDispatchResult(); }); const ctx = await createBlockModeContext(); @@ -544,14 +542,13 @@ describe("processDiscordMessage draft streaming", () => { }); it("strips reasoning tags from partial stream updates", async () => { - const draftStream = createMockDraftStream(); - createDiscordDraftStream.mockReturnValueOnce(draftStream); + const draftStream = createMockDraftStreamForTest(); dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { await params?.replyOptions?.onPartialReply?.({ text: "Let me think about this\nThe answer is 42", }); - return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } }; + return createNoQueuedDispatchResult(); }); await runInPartialStreamMode(); @@ -563,14 +560,13 @@ describe("processDiscordMessage draft streaming", () => { }); it("skips pure-reasoning partial updates without updating draft", async () => { - const draftStream = createMockDraftStream(); - createDiscordDraftStream.mockReturnValueOnce(draftStream); + const draftStream = createMockDraftStreamForTest(); dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { await params?.replyOptions?.onPartialReply?.({ text: "Reasoning:\nThe user asked about X so I need to consider Y", }); - return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } }; + return createNoQueuedDispatchResult(); }); await runInPartialStreamMode(); diff --git a/src/slack/monitor.tool-result.test.ts b/src/slack/monitor.tool-result.test.ts index 210ce81a91b..53eb45918f9 100644 --- a/src/slack/monitor.tool-result.test.ts +++ b/src/slack/monitor.tool-result.test.ts @@ -106,6 +106,50 @@ describe("monitorSlackProvider tool results", () => { }); } + async function runChannelMessageEvent( + text: string, + overrides: Partial = {}, + ): Promise { + await runSlackMessageOnce(monitorSlackProvider, { + event: makeSlackMessageEvent({ + text, + channel_type: "channel", + ...overrides, + }), + }); + } + + function setHistoryCaptureConfig(channels: Record) { + slackTestState.config = { + messages: { ackReactionScope: "group-mentions" }, + channels: { + slack: { + historyLimit: 5, + dm: { enabled: true, policy: "open", allowFrom: ["*"] }, + channels, + }, + }, + }; + } + + function captureReplyContexts>() { + const contexts: T[] = []; + replyMock.mockImplementation(async (ctx: unknown) => { + contexts.push((ctx ?? {}) as T); + return undefined; + }); + return contexts; + } + + async function runMonitoredSlackMessages(events: SlackMessageEvent[]) { + const { controller, run } = startSlackMonitor(monitorSlackProvider); + const handler = await getSlackHandlerOrThrow("message"); + for (const event of events) { + await handler({ event }); + } + await stopSlackMonitor({ controller, run }); + } + function setPairingOnlyDirectMessages() { const currentConfig = slackTestState.config as { channels?: { slack?: Record }; @@ -122,6 +166,61 @@ describe("monitorSlackProvider tool results", () => { }; } + function setOpenChannelDirectMessages(params?: { + bindings?: Array>; + groupPolicy?: "open"; + includeAckReactionConfig?: boolean; + replyToMode?: "off" | "all" | "first"; + threadInheritParent?: boolean; + }) { + const slackChannelConfig: Record = { + dm: { enabled: true, policy: "open", allowFrom: ["*"] }, + channels: { C1: { allow: true, requireMention: false } }, + ...(params?.groupPolicy ? { groupPolicy: params.groupPolicy } : {}), + ...(params?.replyToMode ? { replyToMode: params.replyToMode } : {}), + ...(params?.threadInheritParent ? { thread: { inheritParent: true } } : {}), + }; + slackTestState.config = { + messages: params?.includeAckReactionConfig + ? { + responsePrefix: "PFX", + ackReaction: "👀", + ackReactionScope: "group-mentions", + } + : { responsePrefix: "PFX" }, + channels: { slack: slackChannelConfig }, + ...(params?.bindings ? { bindings: params.bindings } : {}), + }; + } + + function getFirstReplySessionCtx(): { + SessionKey?: string; + ParentSessionKey?: string; + ThreadStarterBody?: string; + ThreadLabel?: string; + } { + return (replyMock.mock.calls[0]?.[0] ?? {}) as { + SessionKey?: string; + ParentSessionKey?: string; + ThreadStarterBody?: string; + ThreadLabel?: string; + }; + } + + function expectSingleSendWithThread(threadTs: string | undefined) { + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs }); + } + + async function runDefaultMessageAndExpectSentText(expectedText: string) { + replyMock.mockResolvedValue({ text: expectedText.replace(/^PFX /, "") }); + await runSlackMessageOnce(monitorSlackProvider, { + event: makeSlackMessageEvent(), + }); + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock.mock.calls[0][1]).toBe(expectedText); + } + it("skips socket startup when Slack channel is disabled", async () => { slackTestState.config = { channels: { @@ -149,14 +248,7 @@ describe("monitorSlackProvider tool results", () => { }); it("skips tool summaries with responsePrefix", async () => { - replyMock.mockResolvedValue({ text: "final reply" }); - - await runSlackMessageOnce(monitorSlackProvider, { - event: makeSlackMessageEvent(), - }); - - expect(sendMock).toHaveBeenCalledTimes(1); - expect(sendMock.mock.calls[0][1]).toBe("PFX final reply"); + await runDefaultMessageAndExpectSentText("PFX final reply"); }); it("drops events with mismatched api_app_id", async () => { @@ -213,127 +305,56 @@ describe("monitorSlackProvider tool results", () => { }, }; - replyMock.mockResolvedValue({ text: "final reply" }); - - await runSlackMessageOnce(monitorSlackProvider, { - event: makeSlackMessageEvent(), - }); - - expect(sendMock).toHaveBeenCalledTimes(1); - expect(sendMock.mock.calls[0][1]).toBe("final reply"); + await runDefaultMessageAndExpectSentText("final reply"); }); it("preserves RawBody without injecting processed room history", async () => { - slackTestState.config = { - messages: { ackReactionScope: "group-mentions" }, - channels: { - slack: { - historyLimit: 5, - dm: { enabled: true, policy: "open", allowFrom: ["*"] }, - channels: { "*": { requireMention: false } }, - }, - }, - }; - - let capturedCtx: { Body?: string; RawBody?: string; CommandBody?: string } = {}; - replyMock.mockImplementation(async (ctx: unknown) => { - capturedCtx = ctx ?? {}; - return undefined; - }); - - const { controller, run } = startSlackMonitor(monitorSlackProvider); - const handler = await getSlackHandlerOrThrow("message"); - - await handler({ - event: { - type: "message", - user: "U1", - text: "first", - ts: "123", - channel: "C1", - channel_type: "channel", - }, - }); - - await handler({ - event: { - type: "message", - user: "U2", - text: "second", - ts: "124", - channel: "C1", - channel_type: "channel", - }, - }); - - await stopSlackMonitor({ controller, run }); + setHistoryCaptureConfig({ "*": { requireMention: false } }); + const capturedCtx = captureReplyContexts<{ + Body?: string; + RawBody?: string; + CommandBody?: string; + }>(); + await runMonitoredSlackMessages([ + makeSlackMessageEvent({ user: "U1", text: "first", ts: "123", channel_type: "channel" }), + makeSlackMessageEvent({ user: "U2", text: "second", ts: "124", channel_type: "channel" }), + ]); expect(replyMock).toHaveBeenCalledTimes(2); - expect(capturedCtx.Body).not.toContain(HISTORY_CONTEXT_MARKER); - expect(capturedCtx.Body).not.toContain(CURRENT_MESSAGE_MARKER); - expect(capturedCtx.Body).not.toContain("first"); - expect(capturedCtx.RawBody).toBe("second"); - expect(capturedCtx.CommandBody).toBe("second"); + const latestCtx = capturedCtx.at(-1) ?? {}; + expect(latestCtx.Body).not.toContain(HISTORY_CONTEXT_MARKER); + expect(latestCtx.Body).not.toContain(CURRENT_MESSAGE_MARKER); + expect(latestCtx.Body).not.toContain("first"); + expect(latestCtx.RawBody).toBe("second"); + expect(latestCtx.CommandBody).toBe("second"); }); it("scopes thread history to the thread by default", async () => { - slackTestState.config = { - messages: { ackReactionScope: "group-mentions" }, - channels: { - slack: { - historyLimit: 5, - dm: { enabled: true, policy: "open", allowFrom: ["*"] }, - channels: { C1: { allow: true, requireMention: true } }, - }, - }, - }; - - const capturedCtx: Array<{ Body?: string }> = []; - replyMock.mockImplementation(async (ctx: unknown) => { - capturedCtx.push(ctx ?? {}); - return undefined; - }); - - const { controller, run } = startSlackMonitor(monitorSlackProvider); - const handler = await getSlackHandlerOrThrow("message"); - - await handler({ - event: { - type: "message", + setHistoryCaptureConfig({ C1: { allow: true, requireMention: true } }); + const capturedCtx = captureReplyContexts<{ Body?: string }>(); + await runMonitoredSlackMessages([ + makeSlackMessageEvent({ user: "U1", text: "thread-a-one", ts: "200", thread_ts: "100", - channel: "C1", channel_type: "channel", - }, - }); - - await handler({ - event: { - type: "message", + }), + makeSlackMessageEvent({ user: "U1", text: "<@bot-user> thread-a-two", ts: "201", thread_ts: "100", - channel: "C1", channel_type: "channel", - }, - }); - - await handler({ - event: { - type: "message", + }), + makeSlackMessageEvent({ user: "U2", text: "<@bot-user> thread-b-one", ts: "301", thread_ts: "300", - channel: "C1", channel_type: "channel", - }, - }); - - await stopSlackMonitor({ controller, run }); + }), + ]); expect(replyMock).toHaveBeenCalledTimes(2); expect(capturedCtx[0]?.Body).toContain("thread-a-one"); @@ -438,13 +459,7 @@ describe("monitorSlackProvider tool results", () => { it("treats control commands as mentions for group bypass", async () => { replyMock.mockResolvedValue({ text: "ok" }); - - await runSlackMessageOnce(monitorSlackProvider, { - event: makeSlackMessageEvent({ - text: "/elevated off", - channel_type: "channel", - }), - }); + await runChannelMessageEvent("/elevated off"); expect(replyMock).toHaveBeenCalledTimes(1); expect(firstReplyCtx().WasMentioned).toBe(true); @@ -452,25 +467,14 @@ describe("monitorSlackProvider tool results", () => { it("threads replies when incoming message is in a thread", async () => { replyMock.mockResolvedValue({ text: "thread reply" }); - slackTestState.config = { - messages: { - responsePrefix: "PFX", - ackReaction: "👀", - ackReactionScope: "group-mentions", - }, - channels: { - slack: { - dm: { enabled: true, policy: "open", allowFrom: ["*"] }, - groupPolicy: "open", - replyToMode: "off", - channels: { C1: { allow: true, requireMention: false } }, - }, - }, - }; + setOpenChannelDirectMessages({ + includeAckReactionConfig: true, + groupPolicy: "open", + replyToMode: "off", + }); await runChannelThreadReplyEvent(); - expect(sendMock).toHaveBeenCalledTimes(1); - expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "111.222" }); + expectSingleSendWithThread("111.222"); }); it("ignores replyToId directive when replyToMode is off", async () => { @@ -497,8 +501,7 @@ describe("monitorSlackProvider tool results", () => { }), }); - expect(sendMock).toHaveBeenCalledTimes(1); - expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: undefined }); + expectSingleSendWithThread(undefined); }); it("keeps replyToId directive threading when replyToMode is all", async () => { @@ -511,8 +514,7 @@ describe("monitorSlackProvider tool results", () => { }), }); - expect(sendMock).toHaveBeenCalledTimes(1); - expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "555" }); + expectSingleSendWithThread("555"); }); it("reacts to mention-gated room messages when ackReaction is enabled", async () => { @@ -581,8 +583,7 @@ describe("monitorSlackProvider tool results", () => { setDirectMessageReplyMode("all"); await runDirectMessageEvent("123"); - expect(sendMock).toHaveBeenCalledTimes(1); - expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "123" }); + expectSingleSendWithThread("123"); }); it("treats parent_user_id as a thread reply even when thread_ts matches ts", async () => { @@ -596,27 +597,14 @@ describe("monitorSlackProvider tool results", () => { }); expect(replyMock).toHaveBeenCalledTimes(1); - const ctx = replyMock.mock.calls[0]?.[0] as { - SessionKey?: string; - ParentSessionKey?: string; - }; + const ctx = getFirstReplySessionCtx(); expect(ctx.SessionKey).toBe("agent:main:main:thread:123"); expect(ctx.ParentSessionKey).toBeUndefined(); }); it("keeps thread parent inheritance opt-in", async () => { replyMock.mockResolvedValue({ text: "thread reply" }); - - slackTestState.config = { - messages: { responsePrefix: "PFX" }, - channels: { - slack: { - dm: { enabled: true, policy: "open", allowFrom: ["*"] }, - channels: { C1: { allow: true, requireMention: false } }, - thread: { inheritParent: true }, - }, - }, - }; + setOpenChannelDirectMessages({ threadInheritParent: true }); await runSlackMessageOnce(monitorSlackProvider, { event: makeSlackMessageEvent({ @@ -626,10 +614,7 @@ describe("monitorSlackProvider tool results", () => { }); expect(replyMock).toHaveBeenCalledTimes(1); - const ctx = replyMock.mock.calls[0]?.[0] as { - SessionKey?: string; - ParentSessionKey?: string; - }; + const ctx = getFirstReplySessionCtx(); expect(ctx.SessionKey).toBe("agent:main:slack:channel:c1:thread:111.222"); expect(ctx.ParentSessionKey).toBe("agent:main:slack:channel:c1"); }); @@ -649,25 +634,12 @@ describe("monitorSlackProvider tool results", () => { }); } - slackTestState.config = { - messages: { responsePrefix: "PFX" }, - channels: { - slack: { - dm: { enabled: true, policy: "open", allowFrom: ["*"] }, - channels: { C1: { allow: true, requireMention: false } }, - }, - }, - }; + setOpenChannelDirectMessages(); await runChannelThreadReplyEvent(); expect(replyMock).toHaveBeenCalledTimes(1); - const ctx = replyMock.mock.calls[0]?.[0] as { - SessionKey?: string; - ParentSessionKey?: string; - ThreadStarterBody?: string; - ThreadLabel?: string; - }; + const ctx = getFirstReplySessionCtx(); expect(ctx.SessionKey).toBe("agent:main:slack:channel:c1:thread:111.222"); expect(ctx.ParentSessionKey).toBeUndefined(); expect(ctx.ThreadStarterBody).toContain("starter message"); @@ -676,16 +648,9 @@ describe("monitorSlackProvider tool results", () => { it("scopes thread session keys to the routed agent", async () => { replyMock.mockResolvedValue({ text: "ok" }); - slackTestState.config = { - messages: { responsePrefix: "PFX" }, - channels: { - slack: { - dm: { enabled: true, policy: "open", allowFrom: ["*"] }, - channels: { C1: { allow: true, requireMention: false } }, - }, - }, + setOpenChannelDirectMessages({ bindings: [{ agentId: "support", match: { channel: "slack", teamId: "T1" } }], - }; + }); const client = getSlackClient(); if (client?.auth?.test) { @@ -703,10 +668,7 @@ describe("monitorSlackProvider tool results", () => { await runChannelThreadReplyEvent(); expect(replyMock).toHaveBeenCalledTimes(1); - const ctx = replyMock.mock.calls[0]?.[0] as { - SessionKey?: string; - ParentSessionKey?: string; - }; + const ctx = getFirstReplySessionCtx(); expect(ctx.SessionKey).toBe("agent:support:slack:channel:c1:thread:111.222"); expect(ctx.ParentSessionKey).toBeUndefined(); }); @@ -716,8 +678,7 @@ describe("monitorSlackProvider tool results", () => { setDirectMessageReplyMode("off"); await runDirectMessageEvent("789"); - expect(sendMock).toHaveBeenCalledTimes(1); - expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: undefined }); + expectSingleSendWithThread(undefined); }); it("threads first reply when replyToMode is first and message is not threaded", async () => { @@ -725,8 +686,6 @@ describe("monitorSlackProvider tool results", () => { setDirectMessageReplyMode("first"); await runDirectMessageEvent("789"); - expect(sendMock).toHaveBeenCalledTimes(1); - // First reply starts a thread under the incoming message - expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "789" }); + expectSingleSendWithThread("789"); }); }); diff --git a/src/telegram/bot-message-context.audio-transcript.test.ts b/src/telegram/bot-message-context.audio-transcript.test.ts index f5df61cc9a0..1cd0e15df31 100644 --- a/src/telegram/bot-message-context.audio-transcript.test.ts +++ b/src/telegram/bot-message-context.audio-transcript.test.ts @@ -2,141 +2,152 @@ import { describe, expect, it, vi } from "vitest"; import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js"; const transcribeFirstAudioMock = vi.fn(); +const DEFAULT_MODEL = "anthropic/claude-opus-4-5"; +const DEFAULT_WORKSPACE = "/tmp/openclaw"; +const DEFAULT_MENTION_PATTERN = "\\bbot\\b"; vi.mock("../media-understanding/audio-preflight.js", () => ({ transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args), })); +async function buildGroupVoiceContext(params: { + messageId: number; + chatId: number; + title: string; + date: number; + fromId: number; + firstName: string; + fileId: string; + mediaPath: string; + groupDisableAudioPreflight?: boolean; + topicDisableAudioPreflight?: boolean; +}) { + const groupConfig = { + requireMention: true, + ...(params.groupDisableAudioPreflight === undefined + ? {} + : { disableAudioPreflight: params.groupDisableAudioPreflight }), + }; + const topicConfig = + params.topicDisableAudioPreflight === undefined + ? undefined + : { disableAudioPreflight: params.topicDisableAudioPreflight }; + + return buildTelegramMessageContextForTest({ + message: { + message_id: params.messageId, + chat: { id: params.chatId, type: "supergroup", title: params.title }, + date: params.date, + text: undefined, + from: { id: params.fromId, first_name: params.firstName }, + voice: { file_id: params.fileId }, + }, + allMedia: [{ path: params.mediaPath, contentType: "audio/ogg" }], + options: { forceWasMentioned: true }, + cfg: { + agents: { defaults: { model: DEFAULT_MODEL, workspace: DEFAULT_WORKSPACE } }, + channels: { telegram: {} }, + messages: { groupChat: { mentionPatterns: [DEFAULT_MENTION_PATTERN] } }, + }, + resolveGroupActivation: () => true, + resolveGroupRequireMention: () => true, + resolveTelegramGroupConfig: () => ({ + groupConfig, + topicConfig, + }), + }); +} + +function expectTranscriptRendered( + ctx: Awaited>, + transcript: string, +) { + expect(ctx).not.toBeNull(); + expect(ctx?.ctxPayload?.BodyForAgent).toBe(transcript); + expect(ctx?.ctxPayload?.Body).toContain(transcript); + expect(ctx?.ctxPayload?.Body).not.toContain(""); +} + +function expectAudioPlaceholderRendered(ctx: Awaited>) { + expect(ctx).not.toBeNull(); + expect(ctx?.ctxPayload?.Body).toContain(""); +} + describe("buildTelegramMessageContext audio transcript body", () => { it("uses preflight transcript as BodyForAgent for mention-gated group voice messages", async () => { transcribeFirstAudioMock.mockResolvedValueOnce("hey bot please help"); - const ctx = await buildTelegramMessageContextForTest({ - message: { - message_id: 1, - chat: { id: -1001234567890, type: "supergroup", title: "Test Group" }, - date: 1700000000, - text: undefined, - from: { id: 42, first_name: "Alice" }, - voice: { file_id: "voice-1" }, - }, - allMedia: [{ path: "/tmp/voice.ogg", contentType: "audio/ogg" }], - options: { forceWasMentioned: true }, - cfg: { - agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } }, - channels: { telegram: {} }, - messages: { groupChat: { mentionPatterns: ["\\bbot\\b"] } }, - }, - resolveGroupActivation: () => true, - resolveGroupRequireMention: () => true, - resolveTelegramGroupConfig: () => ({ - groupConfig: { requireMention: true }, - topicConfig: undefined, - }), + const ctx = await buildGroupVoiceContext({ + messageId: 1, + chatId: -1001234567890, + title: "Test Group", + date: 1700000000, + fromId: 42, + firstName: "Alice", + fileId: "voice-1", + mediaPath: "/tmp/voice.ogg", }); - expect(ctx).not.toBeNull(); expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1); - expect(ctx?.ctxPayload?.BodyForAgent).toBe("hey bot please help"); - expect(ctx?.ctxPayload?.Body).toContain("hey bot please help"); - expect(ctx?.ctxPayload?.Body).not.toContain(""); + expectTranscriptRendered(ctx, "hey bot please help"); }); it("skips preflight transcription when disableAudioPreflight is true", async () => { transcribeFirstAudioMock.mockClear(); - const ctx = await buildTelegramMessageContextForTest({ - message: { - message_id: 2, - chat: { id: -1001234567891, type: "supergroup", title: "Test Group 2" }, - date: 1700000100, - text: undefined, - from: { id: 43, first_name: "Bob" }, - voice: { file_id: "voice-2" }, - }, - allMedia: [{ path: "/tmp/voice2.ogg", contentType: "audio/ogg" }], - options: { forceWasMentioned: true }, - cfg: { - agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } }, - channels: { telegram: {} }, - messages: { groupChat: { mentionPatterns: ["\\bbot\\b"] } }, - }, - resolveGroupActivation: () => true, - resolveGroupRequireMention: () => true, - resolveTelegramGroupConfig: () => ({ - groupConfig: { requireMention: true, disableAudioPreflight: true }, - topicConfig: undefined, - }), + const ctx = await buildGroupVoiceContext({ + messageId: 2, + chatId: -1001234567891, + title: "Test Group 2", + date: 1700000100, + fromId: 43, + firstName: "Bob", + fileId: "voice-2", + mediaPath: "/tmp/voice2.ogg", + groupDisableAudioPreflight: true, }); - expect(ctx).not.toBeNull(); expect(transcribeFirstAudioMock).not.toHaveBeenCalled(); - expect(ctx?.ctxPayload?.Body).toContain(""); + expectAudioPlaceholderRendered(ctx); }); it("uses topic disableAudioPreflight=false to override group disableAudioPreflight=true", async () => { transcribeFirstAudioMock.mockResolvedValueOnce("topic override transcript"); - const ctx = await buildTelegramMessageContextForTest({ - message: { - message_id: 3, - chat: { id: -1001234567892, type: "supergroup", title: "Test Group 3" }, - date: 1700000200, - text: undefined, - from: { id: 44, first_name: "Cara" }, - voice: { file_id: "voice-3" }, - }, - allMedia: [{ path: "/tmp/voice3.ogg", contentType: "audio/ogg" }], - options: { forceWasMentioned: true }, - cfg: { - agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } }, - channels: { telegram: {} }, - messages: { groupChat: { mentionPatterns: ["\\bbot\\b"] } }, - }, - resolveGroupActivation: () => true, - resolveGroupRequireMention: () => true, - resolveTelegramGroupConfig: () => ({ - groupConfig: { requireMention: true, disableAudioPreflight: true }, - topicConfig: { disableAudioPreflight: false }, - }), + const ctx = await buildGroupVoiceContext({ + messageId: 3, + chatId: -1001234567892, + title: "Test Group 3", + date: 1700000200, + fromId: 44, + firstName: "Cara", + fileId: "voice-3", + mediaPath: "/tmp/voice3.ogg", + groupDisableAudioPreflight: true, + topicDisableAudioPreflight: false, }); - expect(ctx).not.toBeNull(); expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1); - expect(ctx?.ctxPayload?.BodyForAgent).toBe("topic override transcript"); - expect(ctx?.ctxPayload?.Body).toContain("topic override transcript"); - expect(ctx?.ctxPayload?.Body).not.toContain(""); + expectTranscriptRendered(ctx, "topic override transcript"); }); it("uses topic disableAudioPreflight=true to override group disableAudioPreflight=false", async () => { transcribeFirstAudioMock.mockClear(); - const ctx = await buildTelegramMessageContextForTest({ - message: { - message_id: 4, - chat: { id: -1001234567893, type: "supergroup", title: "Test Group 4" }, - date: 1700000300, - text: undefined, - from: { id: 45, first_name: "Dan" }, - voice: { file_id: "voice-4" }, - }, - allMedia: [{ path: "/tmp/voice4.ogg", contentType: "audio/ogg" }], - options: { forceWasMentioned: true }, - cfg: { - agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } }, - channels: { telegram: {} }, - messages: { groupChat: { mentionPatterns: ["\\bbot\\b"] } }, - }, - resolveGroupActivation: () => true, - resolveGroupRequireMention: () => true, - resolveTelegramGroupConfig: () => ({ - groupConfig: { requireMention: true, disableAudioPreflight: false }, - topicConfig: { disableAudioPreflight: true }, - }), + const ctx = await buildGroupVoiceContext({ + messageId: 4, + chatId: -1001234567893, + title: "Test Group 4", + date: 1700000300, + fromId: 45, + firstName: "Dan", + fileId: "voice-4", + mediaPath: "/tmp/voice4.ogg", + groupDisableAudioPreflight: false, + topicDisableAudioPreflight: true, }); - expect(ctx).not.toBeNull(); expect(transcribeFirstAudioMock).not.toHaveBeenCalled(); - expect(ctx?.ctxPayload?.Body).toContain(""); + expectAudioPlaceholderRendered(ctx); }); }); diff --git a/src/web/monitor-inbox.captures-media-path-image-messages.test.ts b/src/web/monitor-inbox.captures-media-path-image-messages.test.ts index 23c7003cae3..0913fb34103 100644 --- a/src/web/monitor-inbox.captures-media-path-image-messages.test.ts +++ b/src/web/monitor-inbox.captures-media-path-image-messages.test.ts @@ -32,7 +32,7 @@ describe("web monitor inbox", () => { const sock = getSock(); sock.ev.emit("messages.upsert", upsert); await new Promise((resolve) => setImmediate(resolve)); - return { onMessage, listener }; + return { onMessage, listener, sock }; } function expectSingleGroupMessage( @@ -44,10 +44,7 @@ describe("web monitor inbox", () => { } it("captures media path for image messages", async () => { - const onMessage = vi.fn(); - const listener = await openMonitor(onMessage); - const sock = getSock(); - const upsert = { + const { onMessage, listener, sock } = await runSingleUpsertAndCapture({ type: "notify", messages: [ { @@ -56,10 +53,7 @@ describe("web monitor inbox", () => { messageTimestamp: 1_700_000_100, }, ], - }; - - sock.ev.emit("messages.upsert", upsert); - await new Promise((resolve) => setImmediate(resolve)); + }); expect(onMessage).toHaveBeenCalledWith( expect.objectContaining({ @@ -116,10 +110,7 @@ describe("web monitor inbox", () => { const logPath = path.join(os.tmpdir(), `openclaw-log-test-${crypto.randomUUID()}.log`); setLoggerOverride({ level: "trace", file: logPath }); - const onMessage = vi.fn(); - const listener = await openMonitor(onMessage); - const sock = getSock(); - const upsert = { + const { listener } = await runSingleUpsertAndCapture({ type: "notify", messages: [ { @@ -129,10 +120,7 @@ describe("web monitor inbox", () => { pushName: "Tester", }, ], - }; - - sock.ev.emit("messages.upsert", upsert); - await new Promise((resolve) => setImmediate(resolve)); + }); await vi.waitFor( () => { @@ -147,10 +135,7 @@ describe("web monitor inbox", () => { }); it("includes participant when marking group messages read", async () => { - const onMessage = vi.fn(); - const listener = await openMonitor(onMessage); - const sock = getSock(); - const upsert = { + const { listener, sock } = await runSingleUpsertAndCapture({ type: "notify", messages: [ { @@ -163,10 +148,7 @@ describe("web monitor inbox", () => { message: { conversation: "group ping" }, }, ], - }; - - sock.ev.emit("messages.upsert", upsert); - await new Promise((resolve) => setImmediate(resolve)); + }); expect(sock.readMessages).toHaveBeenCalledWith([ { @@ -180,10 +162,7 @@ describe("web monitor inbox", () => { }); it("passes through group messages with participant metadata", async () => { - const onMessage = vi.fn(); - const listener = await openMonitor(onMessage); - const sock = getSock(); - const upsert = { + const { onMessage, listener } = await runSingleUpsertAndCapture({ type: "notify", messages: [ { @@ -203,10 +182,7 @@ describe("web monitor inbox", () => { messageTimestamp: 1_700_000_000, }, ], - }; - - sock.ev.emit("messages.upsert", upsert); - await new Promise((resolve) => setImmediate(resolve)); + }); expect(onMessage).toHaveBeenCalledWith( expect.objectContaining({