Feishu: wire inbound message debounce (openclaw#31548) thanks @bertonhan

Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check (fails on unrelated pre-existing lint in ui/src/ui/views/agents-utils.ts and src/pairing/pairing-store.ts)
- pnpm test:macmini (previous run passed before rebase)

Co-authored-by: bertonhan <60309291+bertonhan@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Berton
2026-03-03 07:10:47 +08:00
committed by GitHub
parent 8f3eb0f7b4
commit 3b3e47e15d
4 changed files with 615 additions and 19 deletions

View File

@@ -143,6 +143,7 @@ Docs: https://docs.openclaw.ai
- macOS/PeekabooBridge: add compatibility socket symlinks for legacy `clawdbot`, `clawdis`, and `moltbot` Application Support socket paths so pre-rename clients can still connect. (#6033) Thanks @lumpinif and @vincentkoc.
- Feishu/Duplicate replies: suppress same-target reply dispatch when message-tool sends use generic provider metadata (`provider: "message"`) and normalize `lark`/`feishu` provider aliases during duplicate-target checks, preventing double-delivery in Feishu sessions. (#31526)
- Feishu/Plugin sdk compatibility: add safe webhook default fallbacks when loading Feishu monitor state so mixed-version installs no longer crash if older `openclaw/plugin-sdk` builds omit webhook default constants. (#31606)
- Feishu/Inbound debounce: debounce rapid same-chat sender bursts into one ordered dispatch turn, skip already-processed retries when composing merged text, and preserve bot-mention intent across merged entries to reduce duplicate or late inbound handling. (#31548)
- BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound `message_id` selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204.
- Docker/Image health checks: add Dockerfile `HEALTHCHECK` that probes gateway `GET /healthz` so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc.
- Docker/Sandbox bootstrap hardening: make `OPENCLAW_SANDBOX` opt-in parsing explicit (`1|true|yes|on`), support custom Docker socket paths via `OPENCLAW_DOCKER_SOCKET`, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to `off` when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc.

View File

@@ -1,11 +1,16 @@
import os from "node:os";
import path from "node:path";
import { createDedupeCache, createPersistentDedupe } from "openclaw/plugin-sdk";
import {
createDedupeCache,
createPersistentDedupe,
readJsonFileWithFallback,
} from "openclaw/plugin-sdk";
// Persistent TTL: 24 hours — survives restarts & WebSocket reconnects.
const DEDUP_TTL_MS = 24 * 60 * 60 * 1000;
const MEMORY_MAX_SIZE = 1_000;
const FILE_MAX_ENTRIES = 10_000;
type PersistentDedupeData = Record<string, number>;
const memoryDedupe = createDedupeCache({ ttlMs: DEDUP_TTL_MS, maxSize: MEMORY_MAX_SIZE });
@@ -40,6 +45,14 @@ export function tryRecordMessage(messageId: string): boolean {
return !memoryDedupe.check(messageId);
}
export function hasRecordedMessage(messageId: string): boolean {
const trimmed = messageId.trim();
if (!trimmed) {
return false;
}
return memoryDedupe.peek(trimmed);
}
export async function tryRecordMessagePersistent(
messageId: string,
namespace = "global",
@@ -52,3 +65,27 @@ export async function tryRecordMessagePersistent(
},
});
}
export async function hasRecordedMessagePersistent(
messageId: string,
namespace = "global",
log?: (...args: unknown[]) => void,
): Promise<boolean> {
const trimmed = messageId.trim();
if (!trimmed) {
return false;
}
const now = Date.now();
const filePath = resolveNamespaceFilePath(namespace);
try {
const { value } = await readJsonFileWithFallback<PersistentDedupeData>(filePath, {});
const seenAt = value[trimmed];
if (typeof seenAt !== "number" || !Number.isFinite(seenAt)) {
return false;
}
return DEDUP_TTL_MS <= 0 || now - seenAt < DEDUP_TTL_MS;
} catch (error) {
log?.(`feishu-dedup: persistent peek failed: ${String(error)}`);
return false;
}
}

View File

@@ -3,12 +3,25 @@ import * as Lark from "@larksuiteoapi/node-sdk";
import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk";
import { resolveFeishuAccount } from "./accounts.js";
import { raceWithTimeoutAndAbort } from "./async.js";
import { handleFeishuMessage, type FeishuMessageEvent, type FeishuBotAddedEvent } from "./bot.js";
import {
handleFeishuMessage,
parseFeishuMessageEvent,
type FeishuMessageEvent,
type FeishuBotAddedEvent,
} from "./bot.js";
import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js";
import { createEventDispatcher } from "./client.js";
import {
hasRecordedMessage,
hasRecordedMessagePersistent,
tryRecordMessage,
tryRecordMessagePersistent,
} from "./dedup.js";
import { isMentionForwardRequest } from "./mention.js";
import { fetchBotOpenIdForMonitor } from "./monitor.startup.js";
import { botOpenIds } from "./monitor.state.js";
import { monitorWebhook, monitorWebSocket } from "./monitor.transport.js";
import { getFeishuRuntime } from "./runtime.js";
import { getMessageFeishu } from "./send.js";
import type { ResolvedFeishuAccount } from "./types.js";
@@ -120,33 +133,238 @@ type RegisterEventHandlersContext = {
fireAndForget?: boolean;
};
function mergeFeishuDebounceMentions(
entries: FeishuMessageEvent[],
): FeishuMessageEvent["message"]["mentions"] | undefined {
const merged = new Map<string, NonNullable<FeishuMessageEvent["message"]["mentions"]>[number]>();
for (const entry of entries) {
for (const mention of entry.message.mentions ?? []) {
const stableId =
mention.id.open_id?.trim() || mention.id.user_id?.trim() || mention.id.union_id?.trim();
const mentionName = mention.name?.trim();
const mentionKey = mention.key?.trim();
const fallback =
mentionName && mentionKey ? `${mentionName}|${mentionKey}` : mentionName || mentionKey;
const key = stableId || fallback;
if (!key || merged.has(key)) {
continue;
}
merged.set(key, mention);
}
}
if (merged.size === 0) {
return undefined;
}
return Array.from(merged.values());
}
function dedupeFeishuDebounceEntriesByMessageId(
entries: FeishuMessageEvent[],
): FeishuMessageEvent[] {
const seen = new Set<string>();
const deduped: FeishuMessageEvent[] = [];
for (const entry of entries) {
const messageId = entry.message.message_id?.trim();
if (!messageId) {
deduped.push(entry);
continue;
}
if (seen.has(messageId)) {
continue;
}
seen.add(messageId);
deduped.push(entry);
}
return deduped;
}
function resolveFeishuDebounceMentions(params: {
entries: FeishuMessageEvent[];
botOpenId?: string;
}): FeishuMessageEvent["message"]["mentions"] | undefined {
const { entries, botOpenId } = params;
if (entries.length === 0) {
return undefined;
}
for (let index = entries.length - 1; index >= 0; index -= 1) {
const entry = entries[index];
if (isMentionForwardRequest(entry, botOpenId)) {
// Keep mention-forward semantics scoped to a single source message.
return mergeFeishuDebounceMentions([entry]);
}
}
const merged = mergeFeishuDebounceMentions(entries);
if (!merged) {
return undefined;
}
const normalizedBotOpenId = botOpenId?.trim();
if (!normalizedBotOpenId) {
return undefined;
}
const botMentions = merged.filter(
(mention) => mention.id.open_id?.trim() === normalizedBotOpenId,
);
return botMentions.length > 0 ? botMentions : undefined;
}
function registerEventHandlers(
eventDispatcher: Lark.EventDispatcher,
context: RegisterEventHandlersContext,
): void {
const { cfg, accountId, runtime, chatHistories, fireAndForget } = context;
const core = getFeishuRuntime();
const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
cfg,
channel: "feishu",
});
const log = runtime?.log ?? console.log;
const error = runtime?.error ?? console.error;
const dispatchFeishuMessage = async (event: FeishuMessageEvent) => {
await handleFeishuMessage({
cfg,
event,
botOpenId: botOpenIds.get(accountId),
runtime,
chatHistories,
accountId,
});
};
const resolveSenderDebounceId = (event: FeishuMessageEvent): string | undefined => {
const senderId =
event.sender.sender_id.open_id?.trim() || event.sender.sender_id.user_id?.trim();
return senderId || undefined;
};
const resolveDebounceText = (event: FeishuMessageEvent): string => {
const botOpenId = botOpenIds.get(accountId);
const parsed = parseFeishuMessageEvent(event, botOpenId);
return parsed.content.trim();
};
const recordSuppressedMessageIds = async (
entries: FeishuMessageEvent[],
dispatchMessageId?: string,
) => {
const keepMessageId = dispatchMessageId?.trim();
const suppressedIds = new Set(
entries
.map((entry) => entry.message.message_id?.trim())
.filter((id): id is string => Boolean(id) && (!keepMessageId || id !== keepMessageId)),
);
if (suppressedIds.size === 0) {
return;
}
for (const messageId of suppressedIds) {
// Keep in-memory dedupe in sync with handleFeishuMessage's keying.
tryRecordMessage(`${accountId}:${messageId}`);
try {
await tryRecordMessagePersistent(messageId, accountId, log);
} catch (err) {
error(
`feishu[${accountId}]: failed to record merged dedupe id ${messageId}: ${String(err)}`,
);
}
}
};
const isMessageAlreadyProcessed = async (entry: FeishuMessageEvent): Promise<boolean> => {
const messageId = entry.message.message_id?.trim();
if (!messageId) {
return false;
}
const memoryKey = `${accountId}:${messageId}`;
if (hasRecordedMessage(memoryKey)) {
return true;
}
return hasRecordedMessagePersistent(messageId, accountId, log);
};
const inboundDebouncer = core.channel.debounce.createInboundDebouncer<FeishuMessageEvent>({
debounceMs: inboundDebounceMs,
buildKey: (event) => {
const chatId = event.message.chat_id?.trim();
const senderId = resolveSenderDebounceId(event);
if (!chatId || !senderId) {
return null;
}
const rootId = event.message.root_id?.trim();
const threadKey = rootId ? `thread:${rootId}` : "chat";
return `feishu:${accountId}:${chatId}:${threadKey}:${senderId}`;
},
shouldDebounce: (event) => {
if (event.message.message_type !== "text") {
return false;
}
const text = resolveDebounceText(event);
if (!text) {
return false;
}
return !core.channel.text.hasControlCommand(text, cfg);
},
onFlush: async (entries) => {
const last = entries.at(-1);
if (!last) {
return;
}
if (entries.length === 1) {
await dispatchFeishuMessage(last);
return;
}
const dedupedEntries = dedupeFeishuDebounceEntriesByMessageId(entries);
const freshEntries: FeishuMessageEvent[] = [];
for (const entry of dedupedEntries) {
if (!(await isMessageAlreadyProcessed(entry))) {
freshEntries.push(entry);
}
}
const dispatchEntry = freshEntries.at(-1);
if (!dispatchEntry) {
return;
}
await recordSuppressedMessageIds(dedupedEntries, dispatchEntry.message.message_id);
const combinedText = freshEntries
.map((entry) => resolveDebounceText(entry))
.filter(Boolean)
.join("\n");
const mergedMentions = resolveFeishuDebounceMentions({
entries: freshEntries,
botOpenId: botOpenIds.get(accountId),
});
if (!combinedText.trim()) {
await dispatchFeishuMessage({
...dispatchEntry,
message: {
...dispatchEntry.message,
mentions: mergedMentions ?? dispatchEntry.message.mentions,
},
});
return;
}
await dispatchFeishuMessage({
...dispatchEntry,
message: {
...dispatchEntry.message,
message_type: "text",
content: JSON.stringify({ text: combinedText }),
mentions: mergedMentions ?? dispatchEntry.message.mentions,
},
});
},
onError: (err) => {
error(`feishu[${accountId}]: inbound debounce flush failed: ${String(err)}`);
},
});
eventDispatcher.register({
"im.message.receive_v1": async (data) => {
try {
const processMessage = async () => {
const event = data as unknown as FeishuMessageEvent;
const promise = handleFeishuMessage({
cfg,
event,
botOpenId: botOpenIds.get(accountId),
runtime,
chatHistories,
accountId,
await inboundDebouncer.enqueue(event);
};
if (fireAndForget) {
void processMessage().catch((err) => {
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
});
if (fireAndForget) {
promise.catch((err) => {
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
});
} else {
await promise;
}
return;
}
try {
await processMessage();
} catch (err) {
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
}

View File

@@ -1,6 +1,40 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { hasControlCommand } from "../../../src/auto-reply/command-detection.js";
import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "../../../src/auto-reply/inbound-debounce.js";
import { parseFeishuMessageEvent, type FeishuMessageEvent } from "./bot.js";
import * as dedup from "./dedup.js";
import { monitorSingleAccount } from "./monitor.account.js";
import { resolveReactionSyntheticEvent, type FeishuReactionCreatedEvent } from "./monitor.js";
import { setFeishuRuntime } from "./runtime.js";
import type { ResolvedFeishuAccount } from "./types.js";
const handleFeishuMessageMock = vi.hoisted(() => vi.fn(async (_params: { event?: unknown }) => {}));
const createEventDispatcherMock = vi.hoisted(() => vi.fn());
const monitorWebSocketMock = vi.hoisted(() => vi.fn(async () => {}));
const monitorWebhookMock = vi.hoisted(() => vi.fn(async () => {}));
let handlers: Record<string, (data: unknown) => Promise<void>> = {};
vi.mock("./client.js", () => ({
createEventDispatcher: createEventDispatcherMock,
}));
vi.mock("./bot.js", async () => {
const actual = await vi.importActual<typeof import("./bot.js")>("./bot.js");
return {
...actual,
handleFeishuMessage: handleFeishuMessageMock,
};
});
vi.mock("./monitor.transport.js", () => ({
monitorWebSocket: monitorWebSocketMock,
monitorWebhook: monitorWebhookMock,
}));
const cfg = {} as ClawdbotConfig;
@@ -16,6 +50,100 @@ function makeReactionEvent(
};
}
type FeishuMention = NonNullable<FeishuMessageEvent["message"]["mentions"]>[number];
function buildDebounceConfig(): ClawdbotConfig {
return {
messages: {
inbound: {
debounceMs: 0,
byChannel: {
feishu: 20,
},
},
},
channels: {
feishu: {
enabled: true,
},
},
} as ClawdbotConfig;
}
function buildDebounceAccount(): ResolvedFeishuAccount {
return {
accountId: "default",
enabled: true,
configured: true,
appId: "cli_test",
appSecret: "secret_test",
domain: "feishu",
config: {
enabled: true,
connectionMode: "websocket",
},
} as ResolvedFeishuAccount;
}
function createTextEvent(params: {
messageId: string;
text: string;
senderId?: string;
mentions?: FeishuMention[];
}): FeishuMessageEvent {
const senderId = params.senderId ?? "ou_sender";
return {
sender: {
sender_id: { open_id: senderId },
sender_type: "user",
},
message: {
message_id: params.messageId,
chat_id: "oc_group_1",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: params.text }),
mentions: params.mentions,
},
};
}
async function setupDebounceMonitor(): Promise<(data: unknown) => Promise<void>> {
const register = vi.fn((registered: Record<string, (data: unknown) => Promise<void>>) => {
handlers = registered;
});
createEventDispatcherMock.mockReturnValue({ register });
await monitorSingleAccount({
cfg: buildDebounceConfig(),
account: buildDebounceAccount(),
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
} as RuntimeEnv,
botOpenIdSource: { kind: "prefetched", botOpenId: "ou_bot" },
});
const onMessage = handlers["im.message.receive_v1"];
if (!onMessage) {
throw new Error("missing im.message.receive_v1 handler");
}
return onMessage;
}
function getFirstDispatchedEvent(): FeishuMessageEvent {
const firstCall = handleFeishuMessageMock.mock.calls[0];
if (!firstCall) {
throw new Error("missing dispatch call");
}
const firstParams = firstCall[0] as { event?: FeishuMessageEvent } | undefined;
if (!firstParams?.event) {
throw new Error("missing dispatched event payload");
}
return firstParams.event;
}
describe("resolveReactionSyntheticEvent", () => {
it("filters app self-reactions", async () => {
const event = makeReactionEvent({ operator_type: "app" });
@@ -233,3 +361,215 @@ describe("resolveReactionSyntheticEvent", () => {
);
});
});
describe("Feishu inbound debounce regressions", () => {
beforeEach(() => {
vi.useFakeTimers();
handlers = {};
handleFeishuMessageMock.mockClear();
setFeishuRuntime({
channel: {
debounce: {
createInboundDebouncer,
resolveInboundDebounceMs,
},
text: {
hasControlCommand,
},
},
} as unknown as PluginRuntime);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("keeps bot mention when per-message mention keys collide across non-forward messages", async () => {
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
const onMessage = await setupDebounceMonitor();
await onMessage(
createTextEvent({
messageId: "om_1",
text: "first",
mentions: [
{
key: "@_user_1",
id: { open_id: "ou_user_a" },
name: "user-a",
},
],
}),
);
await Promise.resolve();
await Promise.resolve();
await onMessage(
createTextEvent({
messageId: "om_2",
text: "@bot second",
mentions: [
{
key: "@_user_1",
id: { open_id: "ou_bot" },
name: "bot",
},
],
}),
);
await Promise.resolve();
await Promise.resolve();
await vi.advanceTimersByTimeAsync(25);
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
const dispatched = getFirstDispatchedEvent();
const mergedMentions = dispatched.message.mentions ?? [];
expect(mergedMentions.some((mention) => mention.id.open_id === "ou_bot")).toBe(true);
expect(mergedMentions.some((mention) => mention.id.open_id === "ou_user_a")).toBe(false);
});
it("does not synthesize mention-forward intent across separate messages", async () => {
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
const onMessage = await setupDebounceMonitor();
await onMessage(
createTextEvent({
messageId: "om_user_mention",
text: "@alice first",
mentions: [
{
key: "@_user_1",
id: { open_id: "ou_alice" },
name: "alice",
},
],
}),
);
await Promise.resolve();
await Promise.resolve();
await onMessage(
createTextEvent({
messageId: "om_bot_mention",
text: "@bot second",
mentions: [
{
key: "@_user_1",
id: { open_id: "ou_bot" },
name: "bot",
},
],
}),
);
await Promise.resolve();
await Promise.resolve();
await vi.advanceTimersByTimeAsync(25);
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
const dispatched = getFirstDispatchedEvent();
const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
expect(parsed.mentionedBot).toBe(true);
expect(parsed.mentionTargets).toBeUndefined();
const mergedMentions = dispatched.message.mentions ?? [];
expect(mergedMentions.every((mention) => mention.id.open_id === "ou_bot")).toBe(true);
});
it("preserves bot mention signal when the latest merged message has no mentions", async () => {
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
const onMessage = await setupDebounceMonitor();
await onMessage(
createTextEvent({
messageId: "om_bot_first",
text: "@bot first",
mentions: [
{
key: "@_user_1",
id: { open_id: "ou_bot" },
name: "bot",
},
],
}),
);
await Promise.resolve();
await Promise.resolve();
await onMessage(
createTextEvent({
messageId: "om_plain_second",
text: "plain follow-up",
}),
);
await Promise.resolve();
await Promise.resolve();
await vi.advanceTimersByTimeAsync(25);
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
const dispatched = getFirstDispatchedEvent();
const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
expect(parsed.mentionedBot).toBe(true);
});
it("excludes previously processed retries from combined debounce text", async () => {
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old"));
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
async (messageId) => messageId === "om_old",
);
const onMessage = await setupDebounceMonitor();
await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
await Promise.resolve();
await Promise.resolve();
await onMessage(createTextEvent({ messageId: "om_new_1", text: "first" }));
await Promise.resolve();
await Promise.resolve();
await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
await Promise.resolve();
await Promise.resolve();
await onMessage(createTextEvent({ messageId: "om_new_2", text: "second" }));
await Promise.resolve();
await Promise.resolve();
await vi.advanceTimersByTimeAsync(25);
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
const dispatched = getFirstDispatchedEvent();
expect(dispatched.message.message_id).toBe("om_new_2");
const combined = JSON.parse(dispatched.message.content) as { text?: string };
expect(combined.text).toBe("first\nsecond");
});
it("uses latest fresh message id when debounce batch ends with stale retry", async () => {
const recordSpy = vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old"));
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
async (messageId) => messageId === "om_old",
);
const onMessage = await setupDebounceMonitor();
await onMessage(createTextEvent({ messageId: "om_new", text: "fresh" }));
await Promise.resolve();
await Promise.resolve();
await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
await Promise.resolve();
await Promise.resolve();
await vi.advanceTimersByTimeAsync(25);
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
const dispatched = getFirstDispatchedEvent();
expect(dispatched.message.message_id).toBe("om_new");
const combined = JSON.parse(dispatched.message.content) as { text?: string };
expect(combined.text).toBe("fresh");
expect(recordSpy).toHaveBeenCalledWith("default:om_old");
expect(recordSpy).not.toHaveBeenCalledWith("default:om_new");
});
});