diff --git a/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts b/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts index 6c278d0bce4..e1939049d8f 100644 --- a/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts @@ -1,9 +1,11 @@ -import type { RuntimeEnv, RuntimeLogger } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { setMatrixRuntime } from "../../runtime.js"; import type { MatrixClient } from "../sdk.js"; -import { createMatrixRoomMessageHandler } from "./handler.js"; -import { EventType, type MatrixRawEvent } from "./types.js"; +import { + createMatrixHandlerTestHarness, + createMatrixTextMessageEvent, +} from "./handler.test-helpers.js"; +import type { MatrixRawEvent } from "./types.js"; describe("createMatrixRoomMessageHandler inbound body formatting", () => { beforeEach(() => { @@ -26,117 +28,34 @@ describe("createMatrixRoomMessageHandler inbound body formatting", () => { }); it("records thread metadata for group thread messages", async () => { - const recordInboundSession = vi.fn(async () => {}); - const finalizeInboundContext = vi.fn((ctx) => ctx); - - const handler = createMatrixRoomMessageHandler({ - client: { - getUserId: async () => "@bot:example.org", - getEvent: async () => ({ - event_id: "$thread-root", - sender: "@alice:example.org", - type: EventType.RoomMessage, - origin_server_ts: Date.now(), - content: { - msgtype: "m.text", - body: "Root topic", - }, - }), - } as never, - core: { - channel: { - pairing: { - readAllowFromStore: async () => [] as string[], - upsertPairingRequest: async () => ({ code: "ABCDEFGH", created: false }), - }, - commands: { - shouldHandleTextCommands: () => false, - }, - text: { - hasControlCommand: () => false, - resolveMarkdownTableMode: () => "preserve", - }, - routing: { - resolveAgentRoute: () => ({ - agentId: "ops", - channel: "matrix", - accountId: "ops", - sessionKey: "agent:ops:main", - mainSessionKey: "agent:ops:main", - matchedBy: "binding.account", + const { handler, finalizeInboundContext, recordInboundSession } = + createMatrixHandlerTestHarness({ + client: { + getEvent: async () => + createMatrixTextMessageEvent({ + eventId: "$thread-root", + sender: "@alice:example.org", + body: "Root topic", }), - }, - session: { - resolveStorePath: () => "/tmp/session-store", - readSessionUpdatedAt: () => undefined, - recordInboundSession, - }, - reply: { - resolveEnvelopeFormatOptions: () => ({}), - formatAgentEnvelope: ({ body }: { body: string }) => body, - finalizeInboundContext, - createReplyDispatcherWithTyping: () => ({ - dispatcher: {}, - replyOptions: {}, - markDispatchIdle: () => {}, - }), - resolveHumanDelayConfig: () => undefined, - dispatchReplyFromConfig: async () => ({ - queuedFinal: false, - counts: { final: 0, block: 0, tool: 0 }, - }), - }, - reactions: { - shouldAckReaction: () => false, - }, }, - } as never, - cfg: {} as never, - accountId: "ops", - runtime: { - error: () => {}, - } as RuntimeEnv, - logger: { - info: () => {}, - warn: () => {}, - error: () => {}, - } as RuntimeLogger, - logVerboseMessage: () => {}, - allowFrom: [], - mentionRegexes: [], - groupPolicy: "open", - replyToMode: "off", - threadReplies: "inbound", - dmEnabled: true, - dmPolicy: "open", - textLimit: 8_000, - mediaMaxBytes: 10_000_000, - startupMs: 0, - startupGraceMs: 0, - directTracker: { - isDirectMessage: async () => false, - }, - getRoomInfo: async () => ({ altAliases: [] }), - getMemberDisplayName: async (_roomId, userId) => - userId === "@alice:example.org" ? "Alice" : "sender", - }); + isDirectMessage: false, + getMemberDisplayName: async (_roomId, userId) => + userId === "@alice:example.org" ? "Alice" : "sender", + }); - await handler("!room:example.org", { - type: EventType.RoomMessage, - sender: "@user:example.org", - event_id: "$reply1", - origin_server_ts: Date.now(), - content: { - msgtype: "m.text", + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$reply1", body: "follow up", - "m.relates_to": { + relatesTo: { rel_type: "m.thread", event_id: "$thread-root", "m.in_reply_to": { event_id: "$thread-root" }, }, - "m.mentions": { room: true }, - }, - } as MatrixRawEvent); + mentions: { room: true }, + }), + ); expect(finalizeInboundContext).toHaveBeenCalledWith( expect.objectContaining({ @@ -152,123 +71,47 @@ describe("createMatrixRoomMessageHandler inbound body formatting", () => { }); it("records formatted poll results for inbound poll response events", async () => { - const recordInboundSession = vi.fn(async () => {}); - const finalizeInboundContext = vi.fn((ctx) => ctx); - - const handler = createMatrixRoomMessageHandler({ - client: { - getUserId: async () => "@bot:example.org", - getEvent: async () => ({ - event_id: "$poll", - sender: "@bot:example.org", - type: "m.poll.start", - origin_server_ts: 1, - content: { - "m.poll.start": { - question: { "m.text": "Lunch?" }, - kind: "m.poll.disclosed", - max_selections: 1, - answers: [ - { id: "a1", "m.text": "Pizza" }, - { id: "a2", "m.text": "Sushi" }, - ], - }, - }, - }), - getRelations: async () => ({ - events: [ - { - type: "m.poll.response", - event_id: "$vote1", - sender: "@user:example.org", - origin_server_ts: 2, - content: { - "m.poll.response": { answers: ["a1"] }, - "m.relates_to": { rel_type: "m.reference", event_id: "$poll" }, + const { handler, finalizeInboundContext, recordInboundSession } = + createMatrixHandlerTestHarness({ + client: { + getEvent: async () => ({ + event_id: "$poll", + sender: "@bot:example.org", + type: "m.poll.start", + origin_server_ts: 1, + content: { + "m.poll.start": { + question: { "m.text": "Lunch?" }, + kind: "m.poll.disclosed", + max_selections: 1, + answers: [ + { id: "a1", "m.text": "Pizza" }, + { id: "a2", "m.text": "Sushi" }, + ], }, }, - ], - nextBatch: null, - prevBatch: null, - }), - } as unknown as MatrixClient, - core: { - channel: { - pairing: { - readAllowFromStore: async () => [] as string[], - upsertPairingRequest: async () => ({ code: "ABCDEFGH", created: false }), - }, - commands: { - shouldHandleTextCommands: () => false, - }, - text: { - hasControlCommand: () => false, - resolveMarkdownTableMode: () => "preserve", - }, - routing: { - resolveAgentRoute: () => ({ - agentId: "ops", - channel: "matrix", - accountId: "ops", - sessionKey: "agent:ops:main", - mainSessionKey: "agent:ops:main", - matchedBy: "binding.account", - }), - }, - session: { - resolveStorePath: () => "/tmp/session-store", - readSessionUpdatedAt: () => undefined, - recordInboundSession, - }, - reply: { - resolveEnvelopeFormatOptions: () => ({}), - formatAgentEnvelope: ({ body }: { body: string }) => body, - finalizeInboundContext, - createReplyDispatcherWithTyping: () => ({ - dispatcher: {}, - replyOptions: {}, - markDispatchIdle: () => {}, - }), - resolveHumanDelayConfig: () => undefined, - dispatchReplyFromConfig: async () => ({ - queuedFinal: false, - counts: { final: 0, block: 0, tool: 0 }, - }), - }, - reactions: { - shouldAckReaction: () => false, - }, - }, - } as never, - cfg: {} as never, - accountId: "ops", - runtime: { - error: () => {}, - } as RuntimeEnv, - logger: { - info: () => {}, - warn: () => {}, - error: () => {}, - } as RuntimeLogger, - logVerboseMessage: () => {}, - allowFrom: [], - mentionRegexes: [], - groupPolicy: "open", - replyToMode: "off", - threadReplies: "inbound", - dmEnabled: true, - dmPolicy: "open", - textLimit: 8_000, - mediaMaxBytes: 10_000_000, - startupMs: 0, - startupGraceMs: 0, - directTracker: { - isDirectMessage: async () => true, - }, - getRoomInfo: async () => ({ altAliases: [] }), - getMemberDisplayName: async (_roomId, userId) => - userId === "@bot:example.org" ? "Bot" : "sender", - }); + }), + getRelations: async () => ({ + events: [ + { + type: "m.poll.response", + event_id: "$vote1", + sender: "@user:example.org", + origin_server_ts: 2, + content: { + "m.poll.response": { answers: ["a1"] }, + "m.relates_to": { rel_type: "m.reference", event_id: "$poll" }, + }, + }, + ], + nextBatch: null, + prevBatch: null, + }), + } as unknown as Partial, + isDirectMessage: true, + getMemberDisplayName: async (_roomId, userId) => + userId === "@bot:example.org" ? "Bot" : "sender", + }); await handler("!room:example.org", { type: "m.poll.response", diff --git a/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts new file mode 100644 index 00000000000..4996a72ff3e --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts @@ -0,0 +1,219 @@ +import type { RuntimeEnv, RuntimeLogger } from "openclaw/plugin-sdk/matrix"; +import { vi } from "vitest"; +import type { MatrixRoomConfig, ReplyToMode } from "../../types.js"; +import type { MatrixClient } from "../sdk.js"; +import { createMatrixRoomMessageHandler, type MatrixMonitorHandlerParams } from "./handler.js"; +import { EventType, type MatrixRawEvent, type RoomMessageEventContent } from "./types.js"; + +const DEFAULT_ROUTE = { + agentId: "ops", + channel: "matrix", + accountId: "ops", + sessionKey: "agent:ops:main", + mainSessionKey: "agent:ops:main", + matchedBy: "binding.account" as const, +}; + +type MatrixHandlerTestHarnessOptions = { + accountId?: string; + cfg?: unknown; + client?: Partial; + runtime?: RuntimeEnv; + logger?: RuntimeLogger; + logVerboseMessage?: (message: string) => void; + allowFrom?: string[]; + groupAllowFrom?: string[]; + roomsConfig?: Record; + mentionRegexes?: MatrixMonitorHandlerParams["mentionRegexes"]; + groupPolicy?: "open" | "allowlist" | "disabled"; + replyToMode?: ReplyToMode; + threadReplies?: "off" | "inbound" | "always"; + dmEnabled?: boolean; + dmPolicy?: "pairing" | "allowlist" | "open" | "disabled"; + textLimit?: number; + mediaMaxBytes?: number; + startupMs?: number; + startupGraceMs?: number; + isDirectMessage?: boolean; + readAllowFromStore?: MatrixMonitorHandlerParams["core"]["channel"]["pairing"]["readAllowFromStore"]; + upsertPairingRequest?: MatrixMonitorHandlerParams["core"]["channel"]["pairing"]["upsertPairingRequest"]; + buildPairingReply?: () => string; + shouldHandleTextCommands?: () => boolean; + hasControlCommand?: () => boolean; + resolveMarkdownTableMode?: () => string; + resolveAgentRoute?: () => typeof DEFAULT_ROUTE; + resolveStorePath?: () => string; + readSessionUpdatedAt?: () => number | undefined; + recordInboundSession?: (...args: unknown[]) => Promise; + resolveEnvelopeFormatOptions?: () => Record; + formatAgentEnvelope?: ({ body }: { body: string }) => string; + finalizeInboundContext?: (ctx: unknown) => unknown; + createReplyDispatcherWithTyping?: () => { + dispatcher: Record; + replyOptions: Record; + markDispatchIdle: () => void; + }; + resolveHumanDelayConfig?: () => undefined; + dispatchReplyFromConfig?: () => Promise<{ + queuedFinal: boolean; + counts: { final: number; block: number; tool: number }; + }>; + shouldAckReaction?: () => boolean; + enqueueSystemEvent?: (...args: unknown[]) => void; + getRoomInfo?: MatrixMonitorHandlerParams["getRoomInfo"]; + getMemberDisplayName?: MatrixMonitorHandlerParams["getMemberDisplayName"]; +}; + +export function createMatrixHandlerTestHarness(options: MatrixHandlerTestHarnessOptions = {}) { + const readAllowFromStore = options.readAllowFromStore ?? vi.fn(async () => [] as string[]); + const upsertPairingRequest = + options.upsertPairingRequest ?? vi.fn(async () => ({ code: "ABCDEFGH", created: false })); + const resolveAgentRoute = options.resolveAgentRoute ?? vi.fn(() => DEFAULT_ROUTE); + const recordInboundSession = options.recordInboundSession ?? vi.fn(async () => {}); + const finalizeInboundContext = options.finalizeInboundContext ?? vi.fn((ctx) => ctx); + const dispatchReplyFromConfig = + options.dispatchReplyFromConfig ?? + (async () => ({ + queuedFinal: false, + counts: { final: 0, block: 0, tool: 0 }, + })); + const enqueueSystemEvent = options.enqueueSystemEvent ?? vi.fn(); + + const handler = createMatrixRoomMessageHandler({ + client: { + getUserId: async () => "@bot:example.org", + getEvent: async () => ({ sender: "@bot:example.org" }), + ...options.client, + } as never, + core: { + channel: { + pairing: { + readAllowFromStore, + upsertPairingRequest, + buildPairingReply: options.buildPairingReply ?? (() => "pairing"), + }, + commands: { + shouldHandleTextCommands: options.shouldHandleTextCommands ?? (() => false), + }, + text: { + hasControlCommand: options.hasControlCommand ?? (() => false), + resolveMarkdownTableMode: options.resolveMarkdownTableMode ?? (() => "preserve"), + }, + routing: { + resolveAgentRoute, + }, + session: { + resolveStorePath: options.resolveStorePath ?? (() => "/tmp/session-store"), + readSessionUpdatedAt: options.readSessionUpdatedAt ?? (() => undefined), + recordInboundSession, + }, + reply: { + resolveEnvelopeFormatOptions: options.resolveEnvelopeFormatOptions ?? (() => ({})), + formatAgentEnvelope: options.formatAgentEnvelope ?? (({ body }) => body), + finalizeInboundContext, + createReplyDispatcherWithTyping: + options.createReplyDispatcherWithTyping ?? + (() => ({ + dispatcher: {}, + replyOptions: {}, + markDispatchIdle: () => {}, + })), + resolveHumanDelayConfig: options.resolveHumanDelayConfig ?? (() => undefined), + dispatchReplyFromConfig, + }, + reactions: { + shouldAckReaction: options.shouldAckReaction ?? (() => false), + }, + }, + system: { + enqueueSystemEvent, + }, + } as never, + cfg: (options.cfg ?? {}) as never, + accountId: options.accountId ?? "ops", + runtime: (options.runtime ?? + ({ + error: () => {}, + } as RuntimeEnv)) as RuntimeEnv, + logger: (options.logger ?? + ({ + info: () => {}, + warn: () => {}, + error: () => {}, + } as RuntimeLogger)) as RuntimeLogger, + logVerboseMessage: options.logVerboseMessage ?? (() => {}), + allowFrom: options.allowFrom ?? [], + groupAllowFrom: options.groupAllowFrom ?? [], + roomsConfig: options.roomsConfig, + mentionRegexes: options.mentionRegexes ?? [], + groupPolicy: options.groupPolicy ?? "open", + replyToMode: options.replyToMode ?? "off", + threadReplies: options.threadReplies ?? "inbound", + dmEnabled: options.dmEnabled ?? true, + dmPolicy: options.dmPolicy ?? "open", + textLimit: options.textLimit ?? 8_000, + mediaMaxBytes: options.mediaMaxBytes ?? 10_000_000, + startupMs: options.startupMs ?? 0, + startupGraceMs: options.startupGraceMs ?? 0, + directTracker: { + isDirectMessage: async () => options.isDirectMessage ?? true, + }, + getRoomInfo: options.getRoomInfo ?? (async () => ({ altAliases: [] })), + getMemberDisplayName: options.getMemberDisplayName ?? (async () => "sender"), + }); + + return { + dispatchReplyFromConfig, + enqueueSystemEvent, + finalizeInboundContext, + handler, + readAllowFromStore, + recordInboundSession, + resolveAgentRoute, + upsertPairingRequest, + }; +} + +export function createMatrixTextMessageEvent(params: { + eventId: string; + sender?: string; + body: string; + originServerTs?: number; + relatesTo?: RoomMessageEventContent["m.relates_to"]; + mentions?: RoomMessageEventContent["m.mentions"]; +}): MatrixRawEvent { + return { + type: EventType.RoomMessage, + sender: params.sender ?? "@user:example.org", + event_id: params.eventId, + origin_server_ts: params.originServerTs ?? Date.now(), + content: { + msgtype: "m.text", + body: params.body, + ...(params.relatesTo ? { "m.relates_to": params.relatesTo } : {}), + ...(params.mentions ? { "m.mentions": params.mentions } : {}), + }, + } as MatrixRawEvent; +} + +export function createMatrixReactionEvent(params: { + eventId: string; + targetEventId: string; + key: string; + sender?: string; + originServerTs?: number; +}): MatrixRawEvent { + return { + type: EventType.Reaction, + sender: params.sender ?? "@user:example.org", + event_id: params.eventId, + origin_server_ts: params.originServerTs ?? Date.now(), + content: { + "m.relates_to": { + rel_type: "m.annotation", + event_id: params.targetEventId, + key: params.key, + }, + }, + } as MatrixRawEvent; +} diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index 7b6247d6f61..bbe962bed7c 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -3,8 +3,12 @@ import { __testing as sessionBindingTesting, registerSessionBindingAdapter, } from "../../../../../src/infra/outbound/session-binding-service.js"; -import { createMatrixRoomMessageHandler } from "./handler.js"; -import { EventType, type MatrixRawEvent } from "./types.js"; +import { + createMatrixHandlerTestHarness, + createMatrixReactionEvent, + createMatrixTextMessageEvent, +} from "./handler.test-helpers.js"; +import type { MatrixRawEvent } from "./types.js"; const sendMessageMatrixMock = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => ({ messageId: "evt", roomId: "!room" })), @@ -30,149 +34,47 @@ function createReactionHarness(params?: { isDirectMessage?: boolean; senderName?: string; }) { - const readAllowFromStore = vi.fn(async () => params?.storeAllowFrom ?? []); - const upsertPairingRequest = vi.fn(async () => ({ code: "ABCDEFGH", created: false })); - const resolveAgentRoute = vi.fn(() => ({ - agentId: "ops", - channel: "matrix", - accountId: "ops", - sessionKey: "agent:ops:main", - mainSessionKey: "agent:ops:main", - matchedBy: "binding.account", - })); - const enqueueSystemEvent = vi.fn(); - - const handler = createMatrixRoomMessageHandler({ + return createMatrixHandlerTestHarness({ + cfg: params?.cfg, + dmPolicy: params?.dmPolicy, + allowFrom: params?.allowFrom, + readAllowFromStore: vi.fn(async () => params?.storeAllowFrom ?? []), client: { - getUserId: async () => "@bot:example.org", getEvent: async () => ({ sender: params?.targetSender ?? "@bot:example.org" }), - } as never, - core: { - channel: { - pairing: { - readAllowFromStore, - upsertPairingRequest, - buildPairingReply: () => "pairing", - }, - commands: { - shouldHandleTextCommands: () => false, - }, - text: { - hasControlCommand: () => false, - }, - routing: { - resolveAgentRoute, - }, - }, - system: { - enqueueSystemEvent, - }, - } as never, - cfg: (params?.cfg ?? {}) as never, - accountId: "ops", - runtime: { - error: () => {}, - } as never, - logger: { - info: () => {}, - warn: () => {}, - } as never, - logVerboseMessage: () => {}, - allowFrom: params?.allowFrom ?? [], - mentionRegexes: [], - groupPolicy: "open", - replyToMode: "off", - threadReplies: "inbound", - dmEnabled: true, - dmPolicy: params?.dmPolicy ?? "open", - textLimit: 8_000, - mediaMaxBytes: 10_000_000, - startupMs: 0, - startupGraceMs: 0, - directTracker: { - isDirectMessage: async () => params?.isDirectMessage ?? true, }, - getRoomInfo: async () => ({ altAliases: [] }), + isDirectMessage: params?.isDirectMessage, getMemberDisplayName: async () => params?.senderName ?? "sender", }); - - return { - handler, - enqueueSystemEvent, - readAllowFromStore, - resolveAgentRoute, - upsertPairingRequest, - }; } describe("matrix monitor handler pairing account scope", () => { it("caches account-scoped allowFrom store reads on hot path", async () => { const readAllowFromStore = vi.fn(async () => [] as string[]); - const upsertPairingRequest = vi.fn(async () => ({ code: "ABCDEFGH", created: false })); sendMessageMatrixMock.mockClear(); - const handler = createMatrixRoomMessageHandler({ - client: { - getUserId: async () => "@bot:example.org", - } as never, - core: { - channel: { - pairing: { - readAllowFromStore, - upsertPairingRequest, - buildPairingReply: () => "pairing", - }, - }, - } as never, - cfg: {} as never, - accountId: "ops", - runtime: {} as never, - logger: { - info: () => {}, - warn: () => {}, - } as never, - logVerboseMessage: () => {}, - allowFrom: [], - mentionRegexes: [], - groupPolicy: "open", - replyToMode: "off", - threadReplies: "inbound", - dmEnabled: true, + const { handler } = createMatrixHandlerTestHarness({ + readAllowFromStore, dmPolicy: "pairing", - textLimit: 8_000, - mediaMaxBytes: 10_000_000, - startupMs: 0, - startupGraceMs: 0, - directTracker: { - isDirectMessage: async () => true, - }, - getRoomInfo: async () => ({ altAliases: [] }), - getMemberDisplayName: async () => "sender", + buildPairingReply: () => "pairing", }); - await handler("!room:example.org", { - type: EventType.RoomMessage, - sender: "@user:example.org", - event_id: "$event1", - origin_server_ts: Date.now(), - content: { - msgtype: "m.text", + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$event1", body: "hello", - "m.mentions": { room: true }, - }, - } as MatrixRawEvent); + mentions: { room: true }, + }), + ); - await handler("!room:example.org", { - type: EventType.RoomMessage, - sender: "@user:example.org", - event_id: "$event2", - origin_server_ts: Date.now(), - content: { - msgtype: "m.text", + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$event2", body: "hello again", - "m.mentions": { room: true }, - }, - } as MatrixRawEvent); + mentions: { room: true }, + }), + ); expect(readAllowFromStore).toHaveBeenCalledTimes(1); }); @@ -182,60 +84,22 @@ describe("matrix monitor handler pairing account scope", () => { vi.setSystemTime(new Date("2026-03-01T10:00:00.000Z")); try { const readAllowFromStore = vi.fn(async () => [] as string[]); - const upsertPairingRequest = vi.fn(async () => ({ code: "ABCDEFGH", created: false })); sendMessageMatrixMock.mockClear(); - const handler = createMatrixRoomMessageHandler({ - client: { - getUserId: async () => "@bot:example.org", - } as never, - core: { - channel: { - pairing: { - readAllowFromStore, - upsertPairingRequest, - buildPairingReply: () => "Pairing code: ABCDEFGH", - }, - }, - } as never, - cfg: {} as never, - accountId: "ops", - runtime: {} as never, - logger: { - info: () => {}, - warn: () => {}, - } as never, - logVerboseMessage: () => {}, - allowFrom: [], - mentionRegexes: [], - groupPolicy: "open", - replyToMode: "off", - threadReplies: "inbound", - dmEnabled: true, + const { handler } = createMatrixHandlerTestHarness({ + readAllowFromStore, dmPolicy: "pairing", - textLimit: 8_000, - mediaMaxBytes: 10_000_000, - startupMs: 0, - startupGraceMs: 0, - directTracker: { - isDirectMessage: async () => true, - }, - getRoomInfo: async () => ({ altAliases: [] }), + buildPairingReply: () => "Pairing code: ABCDEFGH", + isDirectMessage: true, getMemberDisplayName: async () => "sender", }); const makeEvent = (id: string): MatrixRawEvent => - ({ - type: EventType.RoomMessage, - sender: "@user:example.org", - event_id: id, - origin_server_ts: Date.now(), - content: { - msgtype: "m.text", - body: "hello", - "m.mentions": { room: true }, - }, - }) as MatrixRawEvent; + createMatrixTextMessageEvent({ + eventId: id, + body: "hello", + mentions: { room: true }, + }); await handler("!room:example.org", makeEvent("$event1")); await handler("!room:example.org", makeEvent("$event2")); @@ -256,55 +120,22 @@ describe("matrix monitor handler pairing account scope", () => { const readAllowFromStore = vi.fn(async () => [] as string[]); const upsertPairingRequest = vi.fn(async () => ({ code: "ABCDEFGH", created: false })); - const handler = createMatrixRoomMessageHandler({ - client: { - getUserId: async () => "@bot:example.org", - } as never, - core: { - channel: { - pairing: { - readAllowFromStore, - upsertPairingRequest, - }, - }, - } as never, - cfg: {} as never, - accountId: "ops", - runtime: {} as never, - logger: { - info: () => {}, - warn: () => {}, - } as never, - logVerboseMessage: () => {}, - allowFrom: [], - mentionRegexes: [], - groupPolicy: "open", - replyToMode: "off", - threadReplies: "inbound", - dmEnabled: true, + const { handler } = createMatrixHandlerTestHarness({ + readAllowFromStore, + upsertPairingRequest, dmPolicy: "pairing", - textLimit: 8_000, - mediaMaxBytes: 10_000_000, - startupMs: 0, - startupGraceMs: 0, - directTracker: { - isDirectMessage: async () => true, - }, - getRoomInfo: async () => ({ altAliases: [] }), + isDirectMessage: true, getMemberDisplayName: async () => "sender", }); - await handler("!room:example.org", { - type: EventType.RoomMessage, - sender: "@user:example.org", - event_id: "$event1", - origin_server_ts: Date.now(), - content: { - msgtype: "m.text", + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$event1", body: "hello", - "m.mentions": { room: true }, - }, - } as MatrixRawEvent); + mentions: { room: true }, + }), + ); expect(readAllowFromStore).toHaveBeenCalledWith({ channel: "matrix", @@ -329,66 +160,20 @@ describe("matrix monitor handler pairing account scope", () => { matchedBy: "binding.account", })); - const handler = createMatrixRoomMessageHandler({ - client: { - getUserId: async () => "@bot:example.org", - } as never, - core: { - channel: { - pairing: { - readAllowFromStore: async () => [] as string[], - upsertPairingRequest: async () => ({ code: "ABCDEFGH", created: false }), - }, - commands: { - shouldHandleTextCommands: () => false, - }, - text: { - hasControlCommand: () => false, - }, - routing: { - resolveAgentRoute, - }, - }, - } as never, - cfg: {} as never, - accountId: "ops", - runtime: { - error: () => {}, - } as never, - logger: { - info: () => {}, - warn: () => {}, - } as never, - logVerboseMessage: () => {}, - allowFrom: [], - mentionRegexes: [], - groupPolicy: "open", - replyToMode: "off", - threadReplies: "inbound", - dmEnabled: true, - dmPolicy: "open", - textLimit: 8_000, - mediaMaxBytes: 10_000_000, - startupMs: 0, - startupGraceMs: 0, - directTracker: { - isDirectMessage: async () => true, - }, - getRoomInfo: async () => ({ altAliases: [] }), + const { handler } = createMatrixHandlerTestHarness({ + resolveAgentRoute, + isDirectMessage: true, getMemberDisplayName: async () => "sender", }); - await handler("!room:example.org", { - type: EventType.RoomMessage, - sender: "@user:example.org", - event_id: "$event2", - origin_server_ts: Date.now(), - content: { - msgtype: "m.text", + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$event2", body: "hello", - "m.mentions": { room: true }, - }, - } as MatrixRawEvent); + mentions: { room: true }, + }), + ); expect(resolveAgentRoute).toHaveBeenCalledWith( expect.objectContaining({ @@ -399,116 +184,34 @@ describe("matrix monitor handler pairing account scope", () => { }); it("records thread starter context for inbound thread replies", async () => { - const recordInboundSession = vi.fn(async () => {}); - const finalizeInboundContext = vi.fn((ctx) => ctx); - - const handler = createMatrixRoomMessageHandler({ - client: { - getUserId: async () => "@bot:example.org", - getEvent: async () => ({ - event_id: "$root", - sender: "@alice:example.org", - type: EventType.RoomMessage, - origin_server_ts: Date.now(), - content: { - msgtype: "m.text", - body: "Root topic", - }, - }), - } as never, - core: { - channel: { - pairing: { - readAllowFromStore: async () => [] as string[], - upsertPairingRequest: async () => ({ code: "ABCDEFGH", created: false }), - }, - commands: { - shouldHandleTextCommands: () => false, - }, - text: { - hasControlCommand: () => false, - resolveMarkdownTableMode: () => "preserve", - }, - routing: { - resolveAgentRoute: () => ({ - agentId: "ops", - channel: "matrix", - accountId: "ops", - sessionKey: "agent:ops:main", - mainSessionKey: "agent:ops:main", - matchedBy: "binding.account", + const { handler, finalizeInboundContext, recordInboundSession } = + createMatrixHandlerTestHarness({ + client: { + getEvent: async () => + createMatrixTextMessageEvent({ + eventId: "$root", + sender: "@alice:example.org", + body: "Root topic", }), - }, - session: { - resolveStorePath: () => "/tmp/session-store", - readSessionUpdatedAt: () => undefined, - recordInboundSession, - }, - reply: { - resolveEnvelopeFormatOptions: () => ({}), - formatAgentEnvelope: ({ body }: { body: string }) => body, - finalizeInboundContext, - createReplyDispatcherWithTyping: () => ({ - dispatcher: {}, - replyOptions: {}, - markDispatchIdle: () => {}, - }), - resolveHumanDelayConfig: () => undefined, - dispatchReplyFromConfig: async () => ({ - queuedFinal: false, - counts: { final: 0, block: 0, tool: 0 }, - }), - }, - reactions: { - shouldAckReaction: () => false, - }, }, - } as never, - cfg: {} as never, - accountId: "ops", - runtime: { - error: () => {}, - } as never, - logger: { - info: () => {}, - warn: () => {}, - } as never, - logVerboseMessage: () => {}, - allowFrom: [], - mentionRegexes: [], - groupPolicy: "open", - replyToMode: "off", - threadReplies: "inbound", - dmEnabled: true, - dmPolicy: "open", - textLimit: 8_000, - mediaMaxBytes: 10_000_000, - startupMs: 0, - startupGraceMs: 0, - directTracker: { - isDirectMessage: async () => false, - }, - getRoomInfo: async () => ({ altAliases: [] }), - getMemberDisplayName: async (_roomId, userId) => - userId === "@alice:example.org" ? "Alice" : "sender", - }); + isDirectMessage: false, + getMemberDisplayName: async (_roomId, userId) => + userId === "@alice:example.org" ? "Alice" : "sender", + }); - await handler("!room:example.org", { - type: EventType.RoomMessage, - sender: "@user:example.org", - event_id: "$reply1", - origin_server_ts: Date.now(), - content: { - msgtype: "m.text", + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$reply1", body: "follow up", - "m.relates_to": { + relatesTo: { rel_type: "m.thread", event_id: "$root", "m.in_reply_to": { event_id: "$root" }, }, - "m.mentions": { room: true }, - }, - } as MatrixRawEvent); + mentions: { room: true }, + }), + ); expect(finalizeInboundContext).toHaveBeenCalledWith( expect.objectContaining({ @@ -549,114 +252,33 @@ describe("matrix monitor handler pairing account scope", () => { : null, touch: vi.fn(), }); - const recordInboundSession = vi.fn(async () => {}); - - const handler = createMatrixRoomMessageHandler({ + const { handler, recordInboundSession } = createMatrixHandlerTestHarness({ client: { - getUserId: async () => "@bot:example.org", - getEvent: async () => ({ - event_id: "$root", - sender: "@alice:example.org", - type: EventType.RoomMessage, - origin_server_ts: Date.now(), - content: { - msgtype: "m.text", + getEvent: async () => + createMatrixTextMessageEvent({ + eventId: "$root", + sender: "@alice:example.org", body: "Root topic", - }, - }), - } as never, - core: { - channel: { - pairing: { - readAllowFromStore: async () => [] as string[], - upsertPairingRequest: async () => ({ code: "ABCDEFGH", created: false }), - }, - commands: { - shouldHandleTextCommands: () => false, - }, - text: { - hasControlCommand: () => false, - resolveMarkdownTableMode: () => "preserve", - }, - routing: { - resolveAgentRoute: () => ({ - agentId: "ops", - channel: "matrix", - accountId: "ops", - sessionKey: "agent:ops:main", - mainSessionKey: "agent:ops:main", - matchedBy: "binding.account", - }), - }, - session: { - resolveStorePath: () => "/tmp/session-store", - readSessionUpdatedAt: () => undefined, - recordInboundSession, - }, - reply: { - resolveEnvelopeFormatOptions: () => ({}), - formatAgentEnvelope: ({ body }: { body: string }) => body, - finalizeInboundContext: (ctx: unknown) => ctx, - createReplyDispatcherWithTyping: () => ({ - dispatcher: {}, - replyOptions: {}, - markDispatchIdle: () => {}, - }), - resolveHumanDelayConfig: () => undefined, - dispatchReplyFromConfig: async () => ({ - queuedFinal: false, - counts: { final: 0, block: 0, tool: 0 }, - }), - }, - reactions: { - shouldAckReaction: () => false, - }, - }, - } as never, - cfg: {} as never, - accountId: "ops", - runtime: { - error: () => {}, - } as never, - logger: { - info: () => {}, - warn: () => {}, - } as never, - logVerboseMessage: () => {}, - allowFrom: [], - mentionRegexes: [], - groupPolicy: "open", - replyToMode: "off", - threadReplies: "inbound", - dmEnabled: true, - dmPolicy: "open", - textLimit: 8_000, - mediaMaxBytes: 10_000_000, - startupMs: 0, - startupGraceMs: 0, - directTracker: { - isDirectMessage: async () => false, + }), }, - getRoomInfo: async () => ({ altAliases: [] }), + isDirectMessage: false, + finalizeInboundContext: (ctx: unknown) => ctx, getMemberDisplayName: async () => "sender", }); - await handler("!room:example", { - type: EventType.RoomMessage, - sender: "@user:example.org", - event_id: "$reply1", - origin_server_ts: Date.now(), - content: { - msgtype: "m.text", + await handler( + "!room:example", + createMatrixTextMessageEvent({ + eventId: "$reply1", body: "follow up", - "m.relates_to": { + relatesTo: { rel_type: "m.thread", event_id: "$root", "m.in_reply_to": { event_id: "$root" }, }, - "m.mentions": { room: true }, - }, - } as MatrixRawEvent); + mentions: { room: true }, + }), + ); expect(recordInboundSession).toHaveBeenCalledWith( expect.objectContaining({ @@ -770,19 +392,14 @@ describe("matrix monitor handler pairing account scope", () => { it("enqueues system events for reactions on bot-authored messages", async () => { const { handler, enqueueSystemEvent, resolveAgentRoute } = createReactionHarness(); - await handler("!room:example.org", { - type: EventType.Reaction, - sender: "@user:example.org", - event_id: "$reaction1", - origin_server_ts: Date.now(), - content: { - "m.relates_to": { - rel_type: "m.annotation", - event_id: "$msg1", - key: "👍", - }, - }, - } as MatrixRawEvent); + await handler( + "!room:example.org", + createMatrixReactionEvent({ + eventId: "$reaction1", + targetEventId: "$msg1", + key: "👍", + }), + ); expect(resolveAgentRoute).toHaveBeenCalledWith( expect.objectContaining({ @@ -804,19 +421,14 @@ describe("matrix monitor handler pairing account scope", () => { targetSender: "@other:example.org", }); - await handler("!room:example.org", { - type: EventType.Reaction, - sender: "@user:example.org", - event_id: "$reaction2", - origin_server_ts: Date.now(), - content: { - "m.relates_to": { - rel_type: "m.annotation", - event_id: "$msg2", - key: "👀", - }, - }, - } as MatrixRawEvent); + await handler( + "!room:example.org", + createMatrixReactionEvent({ + eventId: "$reaction2", + targetEventId: "$msg2", + key: "👀", + }), + ); expect(enqueueSystemEvent).not.toHaveBeenCalled(); expect(resolveAgentRoute).not.toHaveBeenCalled(); @@ -827,19 +439,14 @@ describe("matrix monitor handler pairing account scope", () => { dmPolicy: "pairing", }); - await handler("!room:example.org", { - type: EventType.Reaction, - sender: "@user:example.org", - event_id: "$reaction3", - origin_server_ts: Date.now(), - content: { - "m.relates_to": { - rel_type: "m.annotation", - event_id: "$msg3", - key: "🔥", - }, - }, - } as MatrixRawEvent); + await handler( + "!room:example.org", + createMatrixReactionEvent({ + eventId: "$reaction3", + targetEventId: "$msg3", + key: "🔥", + }), + ); expect(upsertPairingRequest).not.toHaveBeenCalled(); expect(enqueueSystemEvent).not.toHaveBeenCalled(); @@ -861,19 +468,14 @@ describe("matrix monitor handler pairing account scope", () => { }, }); - await handler("!room:example.org", { - type: EventType.Reaction, - sender: "@user:example.org", - event_id: "$reaction4", - origin_server_ts: Date.now(), - content: { - "m.relates_to": { - rel_type: "m.annotation", - event_id: "$msg4", - key: "✅", - }, - }, - } as MatrixRawEvent); + await handler( + "!room:example.org", + createMatrixReactionEvent({ + eventId: "$reaction4", + targetEventId: "$msg4", + key: "✅", + }), + ); expect(enqueueSystemEvent).not.toHaveBeenCalled(); });