refactor(channels): dedupe monitor message test flows

This commit is contained in:
Peter Steinberger
2026-03-03 01:05:52 +00:00
parent 57e1534df8
commit ef920f2f39
4 changed files with 318 additions and 376 deletions

View File

@@ -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<void> {
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: "<thinking>Let me think about this</thinking>\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();

View File

@@ -106,6 +106,50 @@ describe("monitorSlackProvider tool results", () => {
});
}
async function runChannelMessageEvent(
text: string,
overrides: Partial<SlackMessageEvent> = {},
): Promise<void> {
await runSlackMessageOnce(monitorSlackProvider, {
event: makeSlackMessageEvent({
text,
channel_type: "channel",
...overrides,
}),
});
}
function setHistoryCaptureConfig(channels: Record<string, unknown>) {
slackTestState.config = {
messages: { ackReactionScope: "group-mentions" },
channels: {
slack: {
historyLimit: 5,
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
channels,
},
},
};
}
function captureReplyContexts<T extends Record<string, unknown>>() {
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<string, unknown> };
@@ -122,6 +166,61 @@ describe("monitorSlackProvider tool results", () => {
};
}
function setOpenChannelDirectMessages(params?: {
bindings?: Array<Record<string, unknown>>;
groupPolicy?: "open";
includeAckReactionConfig?: boolean;
replyToMode?: "off" | "all" | "first";
threadInheritParent?: boolean;
}) {
const slackChannelConfig: Record<string, unknown> = {
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");
});
});

View File

@@ -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<ReturnType<typeof buildGroupVoiceContext>>,
transcript: string,
) {
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.BodyForAgent).toBe(transcript);
expect(ctx?.ctxPayload?.Body).toContain(transcript);
expect(ctx?.ctxPayload?.Body).not.toContain("<media:audio>");
}
function expectAudioPlaceholderRendered(ctx: Awaited<ReturnType<typeof buildGroupVoiceContext>>) {
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.Body).toContain("<media:audio>");
}
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("<media:audio>");
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("<media:audio>");
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("<media:audio>");
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("<media:audio>");
expectAudioPlaceholderRendered(ctx);
});
});

View File

@@ -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({