From 25149801189f9614c596fa18a54a285527035765 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 5 Jun 2026 08:49:35 -0700 Subject: [PATCH] feat(matrix): handle voice preflight and threads (#90415) * feat(matrix): handle voice preflight and threads Co-authored-by: Frank Dierolf Co-authored-by: marc.wilson * test(matrix): satisfy ci guards * fix(matrix): preserve thread relations on edits * chore: annotate deprecated compatibility aliases * fix(matrix): include poll thread roots in reads * test(matrix): enable audio preflight qa config * test(matrix): make voice preflight QA mention deterministic --------- Co-authored-by: Frank Dierolf Co-authored-by: marc.wilson --- docs/channels/matrix.md | 14 + extensions/bonjour/src/ciao.ts | 6 +- extensions/matrix/src/actions.ts | 1 + .../src/matrix/actions/messages.test.ts | 345 +++++++++++- .../matrix/src/matrix/actions/messages.ts | 163 +++++- extensions/matrix/src/matrix/media-text.ts | 2 +- .../monitor/handler.audio-preflight.test.ts | 497 ++++++++++++++++++ .../monitor/handler.group-history.test.ts | 66 +++ .../matrix/src/matrix/monitor/handler.ts | 341 ++++++++++-- .../matrix/monitor/preflight-audio.runtime.ts | 18 + .../matrix/monitor/preflight-audio.test.ts | 177 +++++++ .../src/matrix/monitor/preflight-audio.ts | 126 +++++ .../matrix/src/matrix/monitor/replies.test.ts | 11 +- .../matrix/src/matrix/monitor/replies.ts | 11 +- .../src/matrix/monitor/room-history.test.ts | 217 ++++++++ .../matrix/src/matrix/monitor/room-history.ts | 337 ++++++++++-- extensions/matrix/src/matrix/sdk.ts | 3 + .../src/matrix/sdk/event-helpers.test.ts | 53 ++ .../matrix/src/matrix/sdk/event-helpers.ts | 36 +- extensions/matrix/src/matrix/send.test.ts | 76 ++- extensions/matrix/src/matrix/send.ts | 35 +- .../matrix/src/matrix/send/formatting.ts | 10 +- extensions/matrix/src/tool-actions.ts | 2 + .../src/runners/contract/scenario-catalog.ts | 15 + .../contract/scenario-media-fixtures.ts | 8 + .../contract/scenario-runtime-media.ts | 75 +++ .../src/runners/contract/scenario-runtime.ts | 3 + .../src/runners/contract/scenario-types.ts | 1 + .../src/runners/contract/scenarios.test.ts | 130 ++++- .../qa-matrix/src/substrate/config.test.ts | 13 + extensions/qa-matrix/src/substrate/config.ts | 42 +- .../migrate/skill-selection-prompt.ts | 6 +- 32 files changed, 2712 insertions(+), 128 deletions(-) create mode 100644 extensions/matrix/src/matrix/monitor/handler.audio-preflight.test.ts create mode 100644 extensions/matrix/src/matrix/monitor/preflight-audio.runtime.ts create mode 100644 extensions/matrix/src/matrix/monitor/preflight-audio.test.ts create mode 100644 extensions/matrix/src/matrix/monitor/preflight-audio.ts diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 6a5158d3b939..7e88e1808464 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -232,6 +232,20 @@ Notes: - Tool-progress preview updates are enabled by default when Matrix preview streaming is active. Set `streaming.preview.toolProgress: false` to keep preview edits for answer text but leave tool progress on the normal delivery path. - Preview edits cost extra Matrix API calls. Leave `streaming: "off"` if you want the most conservative rate-limit profile. +## Voice messages + +Inbound Matrix voice notes are transcribed before the room mention gate. This lets a voice note that says the bot name trigger the agent in a `requireMention: true` room, and it gives the agent the transcript instead of only an audio attachment placeholder. + +Matrix uses the shared audio media provider configured under `tools.media.audio`, such as OpenAI `gpt-4o-mini-transcribe`. See [Media tools overview](/tools/media-overview) for provider setup and limits. + +Behavior details: + +- `m.audio` events and `m.file` events with an `audio/*` MIME type are eligible. +- In encrypted rooms, OpenClaw decrypts the attachment through the existing Matrix media path before transcription. +- The transcript is marked as machine-generated and untrusted in the agent prompt. +- The attachment is marked as already transcribed so downstream media tools do not transcribe the same voice note again. +- Set `tools.media.audio.enabled: false` to disable audio transcription globally. + ## Approval metadata Matrix native approval prompts are normal `m.room.message` events with OpenClaw-specific custom event content under `com.openclaw.approval`. Matrix permits custom event-content keys, so stock clients still render the text body while OpenClaw-aware clients can read the structured approval id, kind, state, available decisions, and exec/plugin details. diff --git a/extensions/bonjour/src/ciao.ts b/extensions/bonjour/src/ciao.ts index 14f94a6ff213..01cf823da803 100644 --- a/extensions/bonjour/src/ciao.ts +++ b/extensions/bonjour/src/ciao.ts @@ -56,7 +56,11 @@ export function classifyCiaoProcessError(reason: unknown): CiaoProcessErrorClass return null; } -/** Alternate export name for unhandled-rejection classification. */ +/** + * Backward-compatible alias for unhandled-rejection classification. + * + * @deprecated Use classifyCiaoProcessError. + */ export const classifyCiaoUnhandledRejection = classifyCiaoProcessError; /** Return whether a ciao unhandled rejection is known and ignorable. */ diff --git a/extensions/matrix/src/actions.ts b/extensions/matrix/src/actions.ts index 37227ecf6ef1..b1d04e0ab141 100644 --- a/extensions/matrix/src/actions.ts +++ b/extensions/matrix/src/actions.ts @@ -240,6 +240,7 @@ export const matrixMessageActions: ChannelMessageActionAdapter = { limit, before: readStringParam(params, "before"), after: readStringParam(params, "after"), + threadId: readStringParam(params, "threadId"), }); } diff --git a/extensions/matrix/src/matrix/actions/messages.test.ts b/extensions/matrix/src/matrix/actions/messages.test.ts index 981282cd7357..3944c29abce7 100644 --- a/extensions/matrix/src/matrix/actions/messages.test.ts +++ b/extensions/matrix/src/matrix/actions/messages.test.ts @@ -64,6 +64,7 @@ function createMessagesClient(params: { hydratedChunk?: Array>; pollRoot?: Record; pollRelations?: Array>; + threadRelations?: Array>; }) { const doRequest = vi.fn(async () => ({ chunk: params.chunk, @@ -72,11 +73,19 @@ function createMessagesClient(params: { })); const hydrateEvents = vi.fn( async (_roomId: string, _events: Array>) => - (params.hydratedChunk ?? params.chunk) as unknown, + (params.hydratedChunk ?? _events) as unknown, ); - const getEvent = vi.fn(async () => params.pollRoot ?? null); - const getRelations = vi.fn(async () => ({ - events: params.pollRelations ?? [], + const getEvent = vi.fn(async (_roomId: string, eventId: string) => { + if (params.pollRoot?.event_id === eventId) { + return params.pollRoot; + } + return null; + }); + const getRelations = vi.fn(async (_roomId: string, _eventId: string, relType: string) => ({ + events: + relType === "m.thread" + ? (params.threadRelations ?? params.pollRelations ?? []) + : (params.pollRelations ?? []), nextBatch: null, prevBatch: null, })); @@ -274,7 +283,7 @@ describe("matrix message actions", () => { expect(result.messages).toHaveLength(1); expectRecordFields(result.messages[0], { eventId: "$poll" }); expect(result.messages[0]?.body).toContain("[Poll]"); - expect(getEvent).toHaveBeenCalledTimes(1); + expect(getEvent).toHaveBeenCalledTimes(2); }); it("uses hydrated history events so encrypted poll entries can be read", async () => { @@ -304,4 +313,330 @@ describe("matrix message actions", () => { expect(result.messages).toHaveLength(1); expect(result.messages[0]?.eventId).toBe("$poll"); }); + + it("filters Matrix thread events out of main-room reads", async () => { + const { client } = createMessagesClient({ + chunk: [ + { + event_id: "$thread-reply", + sender: "@alice:example.org", + type: "m.room.message", + origin_server_ts: 20, + content: { + msgtype: "m.text", + body: "thread reply", + "m.relates_to": { rel_type: "m.thread", event_id: "$thread-root" }, + }, + }, + { + event_id: "$main", + sender: "@alice:example.org", + type: "m.room.message", + origin_server_ts: 10, + content: { + msgtype: "m.text", + body: "main room", + }, + }, + ], + }); + + const result = await readMatrixMessages("room:!room:example.org", { client }); + + expect(result.messages.map((message) => message.eventId)).toEqual(["$main"]); + }); + + it("filters threaded poll roots out of main-room reads", async () => { + const threadedPollRoot = createPollStartEvent(); + const threadedPollContent = threadedPollRoot.content as Record; + threadedPollRoot.content = { + ...threadedPollContent, + "m.relates_to": { rel_type: "m.thread", event_id: "$thread-root" }, + }; + const { client, getEvent } = createMessagesClient({ + chunk: [createPollResponseEvent()], + pollRoot: threadedPollRoot, + pollRelations: [createPollResponseEvent()], + }); + + const result = await readMatrixMessages("room:!room:example.org", { client }); + + expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$poll"); + expect(result.messages).toEqual([]); + }); + + it("uses the thread relations endpoint and includes the thread root once", async () => { + const { client, doRequest, getEvent, getRelations } = createMessagesClient({ + chunk: [], + pollRelations: [ + { + event_id: "$thread-reply", + sender: "@alice:example.org", + type: "m.room.message", + origin_server_ts: 20, + content: { + msgtype: "m.text", + body: "thread reply", + "m.relates_to": { rel_type: "m.thread", event_id: "$thread-root" }, + }, + }, + ], + pollRoot: { + event_id: "$thread-root", + sender: "@alice:example.org", + type: "m.room.message", + origin_server_ts: 10, + content: { + msgtype: "m.text", + body: "thread root", + }, + }, + }); + + const result = await readMatrixMessages("room:!room:example.org", { + client, + threadId: "$thread-root", + limit: 5, + }); + + expect(doRequest).not.toHaveBeenCalled(); + expect(getRelations).toHaveBeenCalledWith( + "!room:example.org", + "$thread-root", + "m.thread", + undefined, + { dir: "b", from: undefined, limit: 4 }, + ); + expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$thread-root"); + expect(result.messages.map((message) => message.eventId)).toEqual([ + "$thread-root", + "$thread-reply", + ]); + }); + + it("includes poll snapshots from threaded reads", async () => { + const { client, getEvent, getRelations } = createMessagesClient({ + chunk: [], + pollRoot: createPollStartEvent({ + includeDisclosedKind: true, + maxSelections: 1, + answers: [ + { id: "a1", "m.text": "Apple" }, + { id: "a2", "m.text": "Strawberry" }, + ], + }), + pollRelations: [createPollResponseEvent()], + }); + + const result = await readMatrixMessages("room:!room:example.org", { + client, + threadId: "$thread-root", + limit: 5, + }); + + expect(getRelations).toHaveBeenCalledWith( + "!room:example.org", + "$thread-root", + "m.thread", + undefined, + { dir: "b", from: undefined, limit: 5 }, + ); + expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$poll"); + expect(result.messages[0]?.body).toContain("1. Apple (1 vote)"); + }); + + it("includes poll roots when reading the thread they start", async () => { + const { client, getEvent, getRelations } = createMessagesClient({ + chunk: [], + pollRoot: createPollStartEvent({ + includeDisclosedKind: true, + maxSelections: 1, + answers: [ + { id: "a1", "m.text": "Apple" }, + { id: "a2", "m.text": "Strawberry" }, + ], + }), + pollRelations: [createPollResponseEvent()], + threadRelations: [ + { + event_id: "$thread-reply", + sender: "@alice:example.org", + type: "m.room.message", + origin_server_ts: 20, + content: { + msgtype: "m.text", + body: "thread reply", + "m.relates_to": { rel_type: "m.thread", event_id: "$poll" }, + }, + }, + ], + }); + + const result = await readMatrixMessages("room:!room:example.org", { + client, + threadId: "$poll", + limit: 5, + }); + + expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$poll"); + expect(getRelations).toHaveBeenCalledWith( + "!room:example.org", + "$poll", + "m.reference", + undefined, + { + from: undefined, + }, + ); + expect(getRelations).toHaveBeenCalledWith("!room:example.org", "$poll", "m.thread", undefined, { + dir: "b", + from: undefined, + limit: 4, + }); + expect(result.messages.map((message) => message.eventId)).toEqual(["$poll", "$thread-reply"]); + expect(result.messages[0]?.body).toContain("1. Apple (1 vote)"); + }); + + it("does not summarize non-start poll events as thread roots", async () => { + const { client, getRelations } = createMessagesClient({ + chunk: [], + pollRoot: createPollResponseEvent(), + threadRelations: [ + { + event_id: "$thread-reply", + sender: "@alice:example.org", + type: "m.room.message", + origin_server_ts: 20, + content: { + msgtype: "m.text", + body: "thread reply", + "m.relates_to": { rel_type: "m.thread", event_id: "$vote" }, + }, + }, + ], + }); + + const result = await readMatrixMessages("room:!room:example.org", { + client, + threadId: "$vote", + limit: 5, + }); + + expect(getRelations).toHaveBeenCalledWith("!room:example.org", "$vote", "m.thread", undefined, { + dir: "b", + from: undefined, + limit: 5, + }); + expect(result.messages.map((message) => message.eventId)).toEqual(["$thread-reply"]); + }); + + it("counts the thread root toward the requested first-page limit", async () => { + const { client, doRequest, getEvent, getRelations } = createMessagesClient({ + chunk: [], + pollRelations: [ + { + event_id: "$thread-reply", + sender: "@alice:example.org", + type: "m.room.message", + origin_server_ts: 20, + content: { + msgtype: "m.text", + body: "thread reply", + "m.relates_to": { rel_type: "m.thread", event_id: "$thread-root" }, + }, + }, + ], + pollRoot: { + event_id: "$thread-root", + sender: "@alice:example.org", + type: "m.room.message", + origin_server_ts: 10, + content: { + msgtype: "m.text", + body: "thread root", + }, + }, + }); + + const result = await readMatrixMessages("room:!room:example.org", { + client, + threadId: "$thread-root", + limit: 1, + }); + + expect(getRelations).toHaveBeenCalledWith( + "!room:example.org", + "$thread-root", + "m.thread", + undefined, + { dir: "b", from: undefined, limit: 1 }, + ); + expect(doRequest).not.toHaveBeenCalled(); + expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$thread-root"); + expect(result.messages.map((message) => message.eventId)).toEqual(["$thread-root"]); + expect(result.nextBatch).toEqual( + expect.stringContaining("openclaw.matrix.thread-relations-start:"), + ); + + const next = await readMatrixMessages("room:!room:example.org", { + client, + threadId: "$thread-root", + limit: 1, + before: result.nextBatch ?? undefined, + }); + + expect(getRelations).toHaveBeenLastCalledWith( + "!room:example.org", + "$thread-root", + "m.thread", + undefined, + { dir: "b", from: undefined, limit: 1 }, + ); + expect(next.messages.map((message) => message.eventId)).toEqual(["$thread-reply"]); + }); + + it("does not reserve first-page thread capacity for a redacted root", async () => { + const { client, doRequest, getEvent, getRelations } = createMessagesClient({ + chunk: [], + pollRelations: [ + { + event_id: "$thread-reply", + sender: "@alice:example.org", + type: "m.room.message", + origin_server_ts: 20, + content: { + msgtype: "m.text", + body: "thread reply", + "m.relates_to": { rel_type: "m.thread", event_id: "$thread-root" }, + }, + }, + ], + pollRoot: { + event_id: "$thread-root", + sender: "@alice:example.org", + type: "m.room.message", + origin_server_ts: 10, + unsigned: { redacted_because: {} }, + content: {}, + }, + }); + + const result = await readMatrixMessages("room:!room:example.org", { + client, + threadId: "$thread-root", + limit: 1, + }); + + expect(getRelations).toHaveBeenCalledWith( + "!room:example.org", + "$thread-root", + "m.thread", + undefined, + { dir: "b", from: undefined, limit: 1 }, + ); + expect(doRequest).not.toHaveBeenCalled(); + expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$thread-root"); + expect(result.messages.map((message) => message.eventId)).toEqual(["$thread-reply"]); + expect(result.nextBatch).toBeNull(); + }); }); diff --git a/extensions/matrix/src/matrix/actions/messages.ts b/extensions/matrix/src/matrix/actions/messages.ts index a511f3444112..2bea2da617ec 100644 --- a/extensions/matrix/src/matrix/actions/messages.ts +++ b/extensions/matrix/src/matrix/actions/messages.ts @@ -1,7 +1,7 @@ -// Matrix plugin module implements messages behavior. +import type { Direction } from "matrix-js-sdk/lib/models/event-timeline.js"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { fetchMatrixPollMessageSummary, resolveMatrixPollRootEventId } from "../poll-summary.js"; -import { isPollEventType } from "../poll-types.js"; +import { isPollEventType, isPollStartType } from "../poll-types.js"; import { editMessageMatrix, sendMessageMatrix } from "../send.js"; import { withResolvedRoomAction } from "./client.js"; import { resolveMatrixActionLimit } from "./limits.js"; @@ -13,6 +13,8 @@ import { type MatrixRawEvent, } from "./types.js"; +const MATRIX_THREAD_RELATIONS_START_CURSOR_PREFIX = "openclaw.matrix.thread-relations-start:"; + export async function sendMatrixMessage( to: string, content: string | undefined, @@ -77,6 +79,7 @@ export async function readMatrixMessages( limit?: number; before?: string; after?: string; + threadId?: string; } = {}, ): Promise<{ messages: MatrixMessageSummary[]; @@ -85,26 +88,67 @@ export async function readMatrixMessages( }> { return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => { const limit = resolveMatrixActionLimit(opts.limit, 20); - const token = normalizeOptionalString(opts.before) ?? normalizeOptionalString(opts.after); + const rawBefore = normalizeOptionalString(opts.before); + const rawAfter = normalizeOptionalString(opts.after); const dir = opts.after ? "f" : "b"; - // Room history is queried via the low-level endpoint for compatibility. - const res = (await client.doRequest( - "GET", - `/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`, - { - dir, - limit, - from: token, - }, - )) as { chunk: MatrixRawEvent[]; start?: string; end?: string }; - const hydratedChunk = await client.hydrateEvents(resolvedRoom, res.chunk); + const threadId = normalizeOptionalString(opts.threadId); + const isThreadRelationsStartCursor = threadId + ? isMatrixThreadRelationsStartCursor(rawBefore, threadId) + : false; + const token = isThreadRelationsStartCursor ? undefined : (rawBefore ?? rawAfter); + const includeThreadRoot = threadId !== undefined && !token && !isThreadRelationsStartCursor; + const threadRootSummary = + includeThreadRoot && threadId + ? await fetchDisplayableThreadRootSummary(client, resolvedRoom, threadId) + : undefined; + const rootCountsTowardLimit = threadRootSummary !== undefined; + const rootFillsThreadPage = rootCountsTowardLimit && limit === 1; + const relationLimit = rootCountsTowardLimit ? Math.max(limit - 1, 1) : limit; const seenPollRoots = new Set(); + const threadRootEventId = normalizeOptionalString(threadRootSummary?.eventId); + if (threadRootEventId) { + seenPollRoots.add(threadRootEventId); + } + const relationPage = + threadId && relationLimit > 0 + ? await client.getRelations(resolvedRoom, threadId, "m.thread", undefined, { + dir: dir as Direction, + from: token, + limit: relationLimit, + }) + : null; + // Flat room history uses the low-level endpoint for compatibility; threaded reads use + // the SDK relations helper so encrypted rooms get the SDK's event-type translation. + const flatPage = threadId + ? null + : ((await client.doRequest( + "GET", + `/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`, + { + dir, + limit, + from: token, + }, + )) as { chunk: MatrixRawEvent[]; start?: string; end?: string }); + const hydratedChunk = await client.hydrateEvents( + resolvedRoom, + relationPage ? (rootFillsThreadPage ? [] : relationPage.events) : (flatPage?.chunk ?? []), + ); const messages: MatrixMessageSummary[] = []; + if (threadRootSummary) { + messages.push(threadRootSummary); + } for (const event of hydratedChunk) { if (event.unsigned?.redacted_because) { continue; } + if (!threadId && isMatrixThreadEvent(event)) { + continue; + } if (event.type === EventType.RoomMessage) { + if (threadId && event.event_id === threadId) { + continue; + } messages.push(summarizeMatrixRawEvent(event)); continue; } @@ -115,16 +159,103 @@ export async function readMatrixMessages( if (!pollRootId || seenPollRoots.has(pollRootId)) { continue; } + if ( + !threadId && + (await isMatrixPollRootThreaded({ + client, + event, + pollRootId, + resolvedRoom, + })) + ) { + continue; + } seenPollRoots.add(pollRootId); const pollSummary = await fetchMatrixPollMessageSummary(client, resolvedRoom, event); if (pollSummary) { messages.push(pollSummary); } } + const nextBatch = + rootFillsThreadPage && threadId && relationPage?.events.length + ? encodeMatrixThreadRelationsStartCursor(threadId) + : (relationPage?.nextBatch ?? flatPage?.end ?? null); return { messages, - nextBatch: res.end ?? null, - prevBatch: res.start ?? null, + nextBatch, + prevBatch: relationPage?.prevBatch ?? flatPage?.start ?? null, }; }); } + +function encodeMatrixThreadRelationsStartCursor(threadId: string): string { + const payload = Buffer.from(JSON.stringify({ v: 1, threadId }), "utf8").toString("base64url"); + return `${MATRIX_THREAD_RELATIONS_START_CURSOR_PREFIX}${payload}`; +} + +function isMatrixThreadRelationsStartCursor(raw: string | undefined, threadId: string): boolean { + if (!raw?.startsWith(MATRIX_THREAD_RELATIONS_START_CURSOR_PREFIX)) { + return false; + } + const encoded = raw.slice(MATRIX_THREAD_RELATIONS_START_CURSOR_PREFIX.length); + try { + const decoded = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as { + v?: unknown; + threadId?: unknown; + }; + return decoded.v === 1 && decoded.threadId === threadId; + } catch { + return false; + } +} + +async function fetchDisplayableThreadRootSummary( + client: MatrixActionClientOpts["client"] & NonNullable, + resolvedRoom: string, + threadId: string, +): Promise { + const rawRootEvent = (await client + .getEvent(resolvedRoom, threadId) + .catch(() => null)) as MatrixRawEvent | null; + if (!rawRootEvent) { + return undefined; + } + const rootEvent = (await client.hydrateEvents(resolvedRoom, [rawRootEvent]))[0]; + if (!rootEvent || rootEvent.unsigned?.redacted_because) { + return undefined; + } + if (rootEvent.type === EventType.RoomMessage) { + return summarizeMatrixRawEvent(rootEvent); + } + if (isPollStartType(rootEvent.type)) { + return (await fetchMatrixPollMessageSummary(client, resolvedRoom, rootEvent)) ?? undefined; + } + return undefined; +} + +function isMatrixThreadEvent(event: MatrixRawEvent): boolean { + const relates = event.content?.["m.relates_to"]; + if (!relates || typeof relates !== "object") { + return false; + } + return (relates as { rel_type?: unknown }).rel_type === "m.thread"; +} + +async function isMatrixPollRootThreaded(params: { + client: MatrixActionClientOpts["client"] & NonNullable; + event: MatrixRawEvent; + pollRootId: string; + resolvedRoom: string; +}): Promise { + if (isMatrixThreadEvent(params.event)) { + return true; + } + const rootEvent = (await params.client + .getEvent(params.resolvedRoom, params.pollRootId) + .catch(() => null)) as MatrixRawEvent | null; + if (!rootEvent) { + return false; + } + const hydratedRoot = (await params.client.hydrateEvents(params.resolvedRoom, [rootEvent]))[0]; + return hydratedRoot ? isMatrixThreadEvent(hydratedRoot) : false; +} diff --git a/extensions/matrix/src/matrix/media-text.ts b/extensions/matrix/src/matrix/media-text.ts index b6fcd840689e..2b5790b7daf6 100644 --- a/extensions/matrix/src/matrix/media-text.ts +++ b/extensions/matrix/src/matrix/media-text.ts @@ -36,7 +36,7 @@ function formatMatrixAttachmentMarker(params: { return params.unavailable ? `[matrix ${label} unavailable]` : `[matrix ${label}]`; } -function isLikelyBareFilename(text: string): boolean { +export function isLikelyBareFilename(text: string): boolean { const trimmed = text.trim(); if (!trimmed || trimmed.includes("\n") || /\s/.test(trimmed)) { return false; diff --git a/extensions/matrix/src/matrix/monitor/handler.audio-preflight.test.ts b/extensions/matrix/src/matrix/monitor/handler.audio-preflight.test.ts new file mode 100644 index 000000000000..1e75747f8838 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/handler.audio-preflight.test.ts @@ -0,0 +1,497 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { installMatrixMonitorTestRuntime } from "../../test-runtime.js"; +import { MatrixMediaSizeLimitError } from "../media-errors.js"; +import { + createMatrixHandlerTestHarness, + createMatrixRoomMessageEvent, +} from "./handler.test-helpers.js"; + +const { downloadMatrixMediaMock, sendDurableMessageBatchMock, transcribeFirstAudioMock } = + vi.hoisted(() => ({ + downloadMatrixMediaMock: vi.fn(), + sendDurableMessageBatchMock: vi.fn(), + transcribeFirstAudioMock: vi.fn(), + })); + +vi.mock("./media.js", async () => { + const actual = await vi.importActual("./media.js"); + return { + ...actual, + downloadMatrixMedia: (...args: unknown[]) => downloadMatrixMediaMock(...args), + }; +}); + +vi.mock("./preflight-audio.runtime.js", () => ({ + sendDurableMessageBatch: sendDurableMessageBatchMock, + transcribeFirstAudio: transcribeFirstAudioMock, +})); + +function createAudioPreflightHarness( + overrides: Parameters[0] = {}, +) { + return createMatrixHandlerTestHarness({ + isDirectMessage: true, + shouldHandleTextCommands: () => true, + resolveMarkdownTableMode: () => "code", + resolveAgentRoute: () => ({ + agentId: "main", + accountId: "ops", + sessionKey: "agent:main:matrix:channel:!room:example.org", + mainSessionKey: "agent:main:main", + channel: "matrix", + matchedBy: "binding.account", + }), + resolveStorePath: () => "/tmp/openclaw-test-session.json", + readSessionUpdatedAt: () => 123, + getRoomInfo: async () => ({ + name: "Audio Room", + canonicalAlias: "#audio:example.org", + altAliases: [], + }), + getMemberDisplayName: async () => "Frank", + startupMs: Date.now() - 120_000, + startupGraceMs: 60_000, + textLimit: 4000, + mediaMaxBytes: 5 * 1024 * 1024, + replyToMode: "first", + ...overrides, + }); +} + +function createAudioEvent(content: Record) { + return createMatrixRoomMessageEvent({ + eventId: "$audio1", + sender: "@frank:matrix.example.org", + content: content as never, + }); +} + +function expectLatestInboundContext( + recordInboundSession: ReturnType["recordInboundSession"], +) { + const call = vi.mocked(recordInboundSession).mock.calls.at(-1)?.[0] as + | { ctx?: Record } + | undefined; + if (!call?.ctx) { + throw new Error("expected inbound session context"); + } + return call.ctx; +} + +describe("createMatrixRoomMessageHandler audio preflight", () => { + beforeEach(() => { + downloadMatrixMediaMock.mockReset(); + sendDurableMessageBatchMock.mockReset(); + transcribeFirstAudioMock.mockReset(); + installMatrixMonitorTestRuntime(); + }); + + it("transcribes inbound voice notes in DMs and surfaces the transcript as the agent body", async () => { + downloadMatrixMediaMock.mockResolvedValue({ + path: "/tmp/inbound/voice.ogg", + contentType: "audio/ogg", + placeholder: "[matrix audio attachment]", + }); + transcribeFirstAudioMock.mockResolvedValue("hello bot"); + const { handler, recordInboundSession } = createAudioPreflightHarness(); + + await handler( + "!room:example.org", + createAudioEvent({ + msgtype: "m.audio", + body: "voice.ogg", + url: "mxc://example/voice", + info: { mimetype: "audio/ogg", size: 12345 }, + }), + ); + + expect(transcribeFirstAudioMock).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: expect.objectContaining({ + MediaPaths: ["/tmp/inbound/voice.ogg"], + MediaTypes: ["audio/ogg"], + Provider: "matrix", + Surface: "matrix", + OriginatingChannel: "matrix", + OriginatingTo: "room:!room:example.org", + AccountId: "ops", + ChatType: "direct", + SessionKey: "agent:main:matrix:channel:!room:example.org", + }), + }), + ); + expect(expectLatestInboundContext(recordInboundSession)).toMatchObject({ + BodyForAgent: '[Audio transcript (machine-generated, untrusted)]: "hello bot"', + MediaTranscribedIndexes: [0], + MediaPath: "/tmp/inbound/voice.ogg", + MediaType: "audio/ogg", + }); + }); + + it("lets transcript-mentioned voice notes pass the requireMention room gate", async () => { + downloadMatrixMediaMock.mockResolvedValue({ + path: "/tmp/inbound/voice.ogg", + contentType: "audio/ogg", + placeholder: "[matrix audio attachment]", + }); + transcribeFirstAudioMock.mockResolvedValue("bot can you check this"); + const { handler, recordInboundSession } = createAudioPreflightHarness({ + isDirectMessage: false, + mentionRegexes: [/\bbot\b/i], + roomsConfig: { + "!room:example.org": { requireMention: true } as never, + }, + }); + + await handler( + "!room:example.org", + createAudioEvent({ + msgtype: "m.audio", + body: "voice.ogg", + url: "mxc://example/voice", + info: { mimetype: "audio/ogg", size: 12345 }, + }), + ); + + expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1); + expect(expectLatestInboundContext(recordInboundSession)).toMatchObject({ + BodyForAgent: expect.stringContaining("bot can you check this"), + WasMentioned: true, + }); + }); + + it("keeps non-filename audio fallback text while still surfacing the transcript", async () => { + downloadMatrixMediaMock.mockResolvedValue({ + path: "/tmp/inbound/voice.ogg", + contentType: "audio/ogg", + placeholder: "[matrix audio attachment]", + }); + transcribeFirstAudioMock.mockResolvedValue("hello bot from fallback audio"); + const { handler, recordInboundSession } = createAudioPreflightHarness(); + + await handler( + "!room:example.org", + createAudioEvent({ + msgtype: "m.audio", + body: "Voice message", + url: "mxc://example/voice", + info: { mimetype: "audio/ogg", size: 12345 }, + }), + ); + + expect(expectLatestInboundContext(recordInboundSession)).toMatchObject({ + BodyForAgent: + 'Voice message\n[Audio transcript (machine-generated, untrusted)]: "hello bot from fallback audio"', + MediaTranscribedIndexes: [0], + }); + }); + + it("echoes accepted preflight transcripts after the mention gate", async () => { + downloadMatrixMediaMock.mockResolvedValue({ + path: "/tmp/inbound/voice.ogg", + contentType: "audio/ogg", + placeholder: "[matrix audio attachment]", + }); + sendDurableMessageBatchMock.mockResolvedValue({ status: "sent", results: [] }); + transcribeFirstAudioMock.mockResolvedValue("hello bot"); + const { handler } = createAudioPreflightHarness({ + cfg: { + channels: { matrix: { dm: { allowFrom: ["*"] } } }, + tools: { media: { audio: { enabled: true, echoTranscript: true } } }, + }, + }); + + await handler( + "!room:example.org", + createAudioEvent({ + msgtype: "m.audio", + body: "voice.ogg", + url: "mxc://example/voice", + info: { mimetype: "audio/ogg", size: 12345 }, + }), + ); + + expect(sendDurableMessageBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "matrix", + to: "room:!room:example.org", + accountId: "ops", + payloads: [{ text: '📝 "hello bot"' }], + bestEffort: true, + durability: "best_effort", + }), + ); + }); + + it("drops transcript-unmentioned voice notes in requireMention rooms", async () => { + downloadMatrixMediaMock.mockResolvedValue({ + path: "/tmp/inbound/voice.ogg", + contentType: "audio/ogg", + placeholder: "[matrix audio attachment]", + }); + transcribeFirstAudioMock.mockResolvedValue("hello world"); + const { handler, recordInboundSession } = createAudioPreflightHarness({ + isDirectMessage: false, + historyLimit: 5, + mentionRegexes: [/\bbot\b/i], + roomsConfig: { + "!room:example.org": { requireMention: true } as never, + }, + }); + + await handler( + "!room:example.org", + createAudioEvent({ + msgtype: "m.audio", + body: "voice.ogg", + url: "mxc://example/voice", + info: { mimetype: "audio/ogg", size: 12345 }, + }), + ); + + expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1); + expect(recordInboundSession).not.toHaveBeenCalled(); + + await handler( + "!room:example.org", + createMatrixRoomMessageEvent({ + eventId: "$text-after-unmentioned-audio", + sender: "@frank:matrix.example.org", + content: { msgtype: "m.text", body: "bot what did I say before?" }, + }), + ); + + const followUpContext = expectLatestInboundContext(recordInboundSession); + const history = followUpContext.InboundHistory as Array<{ body?: string }> | undefined; + expect(history?.map((entryValue) => entryValue.body)).toContain( + '[Audio transcript (machine-generated, untrusted)]: "hello world"', + ); + }); + + it("does not preflight-download gated audio when audio transcription is disabled", async () => { + const { handler, recordInboundSession } = createAudioPreflightHarness({ + cfg: { + channels: { matrix: { dm: { allowFrom: ["*"] } } }, + tools: { media: { audio: { enabled: false } } }, + }, + isDirectMessage: false, + mentionRegexes: [/\bbot\b/i], + roomsConfig: { + "!room:example.org": { requireMention: true } as never, + }, + }); + + await handler( + "!room:example.org", + createAudioEvent({ + msgtype: "m.audio", + body: "voice.ogg", + url: "mxc://example/voice", + info: { mimetype: "audio/ogg", size: 12345 }, + }), + ); + + expect(downloadMatrixMediaMock).not.toHaveBeenCalled(); + expect(transcribeFirstAudioMock).not.toHaveBeenCalled(); + expect(recordInboundSession).not.toHaveBeenCalled(); + }); + + it("does not hold the room history ingress queue during slow audio preflight", async () => { + let releaseDownload: + | ((media: { path: string; contentType: string; placeholder: string }) => void) + | undefined; + downloadMatrixMediaMock.mockReturnValue( + new Promise((resolve) => { + releaseDownload = resolve; + }), + ); + transcribeFirstAudioMock.mockResolvedValue("bot voice request"); + const { handler, recordInboundSession } = createAudioPreflightHarness({ + isDirectMessage: false, + historyLimit: 5, + mentionRegexes: [/\bbot\b/i], + roomsConfig: { + "!room:example.org": { requireMention: true } as never, + }, + }); + + const slowAudio = handler( + "!room:example.org", + createAudioEvent({ + msgtype: "m.audio", + body: "voice.ogg", + url: "mxc://example/voice", + info: { mimetype: "audio/ogg", size: 12345 }, + }), + ); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + await handler( + "!room:example.org", + createMatrixRoomMessageEvent({ + eventId: "$text-after-audio", + sender: "@frank:matrix.example.org", + content: { msgtype: "m.text", body: "bot text after audio" }, + }), + ); + + expect(recordInboundSession).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: expect.objectContaining({ BodyForAgent: "bot text after audio" }), + }), + ); + + releaseDownload?.({ + path: "/tmp/inbound/voice.ogg", + contentType: "audio/ogg", + placeholder: "[matrix audio attachment]", + }); + await slowAudio; + + const voiceCall = vi + .mocked(recordInboundSession) + .mock.calls.map((call) => call[0] as { ctx?: Record }) + .find((call) => { + const bodyForAgent = call.ctx?.BodyForAgent; + return typeof bodyForAgent === "string" && bodyForAgent.includes("bot voice request"); + }); + const voiceHistory = voiceCall?.ctx?.InboundHistory as Array<{ body?: string }> | undefined; + expect(voiceHistory?.map((entry) => entry.body) ?? []).not.toContain("bot text after audio"); + }); + + it("keeps placeholder body when transcription fails", async () => { + downloadMatrixMediaMock.mockResolvedValue({ + path: "/tmp/inbound/voice.ogg", + contentType: "audio/ogg", + placeholder: "[matrix audio attachment]", + }); + transcribeFirstAudioMock.mockRejectedValue(new Error("STT down")); + const { handler, recordInboundSession } = createAudioPreflightHarness(); + + await handler( + "!room:example.org", + createAudioEvent({ + msgtype: "m.audio", + body: "voice.ogg", + url: "mxc://example/voice", + info: { mimetype: "audio/ogg", size: 12345 }, + }), + ); + + expect(expectLatestInboundContext(recordInboundSession)).toMatchObject({ + BodyForAgent: "[matrix audio attachment]", + MediaPath: "/tmp/inbound/voice.ogg", + }); + expect( + expectLatestInboundContext(recordInboundSession).MediaTranscribedIndexes, + ).toBeUndefined(); + }); + + it("does not invoke audio preflight for non-audio media", async () => { + downloadMatrixMediaMock.mockResolvedValue({ + path: "/tmp/inbound/photo.jpg", + contentType: "image/jpeg", + placeholder: "[matrix image attachment]", + }); + const { handler, recordInboundSession } = createAudioPreflightHarness(); + + await handler( + "!room:example.org", + createAudioEvent({ + msgtype: "m.image", + body: "photo.jpg", + url: "mxc://example/photo", + info: { mimetype: "image/jpeg", size: 12345 }, + }), + ); + + expect(transcribeFirstAudioMock).not.toHaveBeenCalled(); + expect(recordInboundSession).toHaveBeenCalled(); + }); + + it("transcribes encrypted voice notes after existing Matrix media decryption", async () => { + downloadMatrixMediaMock.mockResolvedValue({ + path: "/tmp/inbound/encrypted-voice.ogg", + contentType: "audio/ogg", + placeholder: "[matrix audio attachment]", + }); + transcribeFirstAudioMock.mockResolvedValue("encrypted hello"); + const { handler, recordInboundSession } = createAudioPreflightHarness(); + + await handler( + "!room:example.org", + createAudioEvent({ + msgtype: "m.audio", + body: "voice.ogg", + file: { + url: "mxc://example/encrypted-voice", + key: { kty: "oct", key_ops: ["encrypt"], alg: "A256CTR", k: "secret", ext: true }, + iv: "iv", + hashes: { sha256: "hash" }, + v: "v2", + }, + info: { mimetype: "audio/ogg", size: 12345 }, + }), + ); + + expect(downloadMatrixMediaMock).toHaveBeenCalledWith( + expect.objectContaining({ + mxcUrl: "mxc://example/encrypted-voice", + file: expect.objectContaining({ + url: "mxc://example/encrypted-voice", + key: expect.objectContaining({ alg: "A256CTR" }), + }), + }), + ); + expect(expectLatestInboundContext(recordInboundSession)).toMatchObject({ + BodyForAgent: '[Audio transcript (machine-generated, untrusted)]: "encrypted hello"', + MediaTranscribedIndexes: [0], + MediaPath: "/tmp/inbound/encrypted-voice.ogg", + }); + }); + + it("preserves the too-large placeholder when audio download exceeds the size limit", async () => { + downloadMatrixMediaMock.mockRejectedValue(new MatrixMediaSizeLimitError()); + const { handler, recordInboundSession } = createAudioPreflightHarness(); + + await handler( + "!room:example.org", + createAudioEvent({ + msgtype: "m.audio", + body: "big-voice.ogg", + url: "mxc://example/big-voice", + info: { mimetype: "audio/ogg", size: 10 * 1024 * 1024 }, + }), + ); + + expect(transcribeFirstAudioMock).not.toHaveBeenCalled(); + expect(expectLatestInboundContext(recordInboundSession)).toMatchObject({ + BodyForAgent: "[matrix audio attachment too large]", + }); + expect(expectLatestInboundContext(recordInboundSession).MediaPath).toBeUndefined(); + }); + + it("downloads audio only once across preflight and normal media handling", async () => { + downloadMatrixMediaMock.mockResolvedValue({ + path: "/tmp/inbound/voice.ogg", + contentType: "audio/ogg", + placeholder: "[matrix audio attachment]", + }); + transcribeFirstAudioMock.mockResolvedValue("hello bot"); + const { handler } = createAudioPreflightHarness(); + + await handler( + "!room:example.org", + createAudioEvent({ + msgtype: "m.audio", + body: "voice.ogg", + url: "mxc://example/voice", + info: { mimetype: "audio/ogg", size: 12345 }, + }), + ); + + expect(downloadMatrixMediaMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/handler.group-history.test.ts b/extensions/matrix/src/matrix/monitor/handler.group-history.test.ts index bf63b8f838c8..b4cc44fcc9c2 100644 --- a/extensions/matrix/src/matrix/monitor/handler.group-history.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.group-history.test.ts @@ -192,6 +192,72 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => { expect(history[0]?.body).toContain("msg A"); }); + it('keeps threaded messages in parent history when threadReplies is "off"', async () => { + const finalizeInboundContext = vi.fn((ctx: unknown) => ctx); + const { handler } = createMatrixHandlerTestHarness({ + historyLimit: 20, + groupPolicy: "open", + isDirectMessage: false, + threadReplies: "off", + finalizeInboundContext, + dispatchReplyFromConfig: async () => ({ + queuedFinal: true, + counts: { final: 1, block: 0, tool: 0 }, + }), + }); + + await handler( + DEFAULT_ROOM, + createMatrixRoomMessageEvent({ + eventId: "$thread-plain", + content: { + msgtype: "m.text", + body: "thread plain", + "m.relates_to": { rel_type: "m.thread", event_id: "$thread-root" }, + }, + }), + ); + await handler( + DEFAULT_ROOM, + makeRoomTriggerEvent({ eventId: "$main-trigger", body: "main trigger", ts: 2000 }), + ); + + expectSomeBodyContaining(inboundHistoryBodies(finalizeInboundContext, 0), "thread plain"); + }); + + it('keeps top-level room history flat when threadReplies is "always"', async () => { + const finalizeInboundContext = vi.fn((ctx: unknown) => ctx); + const { handler } = createMatrixHandlerTestHarness({ + historyLimit: 20, + groupPolicy: "open", + isDirectMessage: false, + threadReplies: "always", + finalizeInboundContext, + dispatchReplyFromConfig: async () => ({ + queuedFinal: true, + counts: { final: 1, block: 0, tool: 0 }, + }), + }); + + await handler( + DEFAULT_ROOM, + makeRoomPlainEvent({ eventId: "$top-level-plain", body: "top-level plain", ts: 1000 }), + ); + await handler( + DEFAULT_ROOM, + makeRoomTriggerEvent({ eventId: "$top-level-trigger", body: "main trigger", ts: 2000 }), + ); + + expectSomeBodyContaining(inboundHistoryBodies(finalizeInboundContext, 0), "top-level plain"); + + await handler( + DEFAULT_ROOM, + makeRoomTriggerEvent({ eventId: "$top-level-trigger-2", body: "main trigger 2", ts: 3000 }), + ); + + expectNoBodyContaining(inboundHistoryBodies(finalizeInboundContext, 1), "top-level plain"); + }); + it("multi-agent: each agent has an independent watermark", async () => { let currentAgentId = "agent_a"; const finalizeInboundContext = vi.fn((ctx: unknown) => ctx); diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index e14490e29bb6..dcce78c5de3c 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -63,6 +63,7 @@ import { formatMatrixMediaTooLargeText, formatMatrixMediaUnavailableText, formatMatrixMessageText, + isLikelyBareFilename, resolveMatrixMessageAttachment, resolveMatrixMessageBody, } from "../media-text.js"; @@ -90,10 +91,16 @@ import type { MatrixInboundEventDeduper } from "./inbound-dedupe.js"; import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js"; import { downloadMatrixMedia } from "./media.js"; import { resolveMentions, stripMatrixMentionPrefix } from "./mentions.js"; +import { + formatMatrixAudioTranscript, + isMatrixAudioContent, + resolveMatrixPreflightAudioTranscript, + sendMatrixPreflightAudioTranscriptEcho, +} from "./preflight-audio.js"; import { deliverMatrixReplies } from "./replies.js"; import { createMatrixReplyContextResolver } from "./reply-context.js"; import { createRoomHistoryTracker } from "./room-history.js"; -import type { HistoryEntry } from "./room-history.js"; +import type { HistoryEntry, ReservedHistorySlot } from "./room-history.js"; import { resolveMatrixRoomConfig } from "./rooms.js"; import { resolveMatrixInboundRoute } from "./route.js"; import { @@ -400,6 +407,46 @@ function resolveMatrixPendingHistoryText(params: { ); } +function isMatrixAudioMediaEnabled(cfg: CoreConfig): boolean { + const tools = cfg.tools as + | { + media?: { + audio?: { + enabled?: boolean; + }; + }; + } + | undefined; + return tools?.media?.audio?.enabled !== false; +} + +function shouldDeferMatrixAudioPreflightForRoomIngress(params: { + content: RoomMessageEventContent; + cfg: CoreConfig; +}): boolean { + if (!isMatrixAudioMediaEnabled(params.cfg)) { + return false; + } + const content = params.content; + const contentUrl = "url" in content && typeof content.url === "string" ? content.url : undefined; + const contentFile = + "file" in content && content.file && typeof content.file === "object" + ? content.file + : undefined; + const mediaUrl = contentUrl ?? contentFile?.url; + const contentInfo = + "info" in content && content.info && typeof content.info === "object" + ? (content.info as { mimetype?: string }) + : undefined; + return ( + mediaUrl?.startsWith("mxc://") === true && + isMatrixAudioContent({ + msgtype: typeof content.msgtype === "string" ? content.msgtype : undefined, + mimetype: contentInfo?.mimetype, + }) + ); +} + function resolveMatrixAllowBotsMode(value?: boolean | "mentions"): MatrixAllowBotsMode { if (value === true) { return "all"; @@ -679,17 +726,41 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam return { content, isDirectMessage, locationPayload, selfUserId }; }; const continueIngress = async (paramsLocal: { + audioPreflightMode?: "defer" | "run"; content: RoomMessageEventContent; isDirectMessage: boolean; locationPayload: MatrixLocationPayload | null; + reservedHistorySlot?: ReservedHistorySlot; selfUserId: string; }) => { let content = paramsLocal.content; const isDirectMessage = paramsLocal.isDirectMessage; const isRoom = !isDirectMessage; - const { locationPayload, selfUserId } = paramsLocal; - if (isRoom && groupPolicy === "disabled") { + const { audioPreflightMode, locationPayload, reservedHistorySlot, selfUserId } = + paramsLocal; + const messageId = event.event_id ?? ""; + const threadRootId = resolveMatrixThreadRootId({ event, content }); + const thread = resolveMatrixThreadRouting({ + isDirectMessage, + threadReplies, + dmThreadReplies, + messageId, + threadRootId, + }); + const historyThreadId = threadRootId ? thread.threadId : undefined; + let reservedHistorySlotConsumed = false; + const discardReservedHistorySlot = () => { + if (reservedHistorySlot && !reservedHistorySlotConsumed) { + roomHistoryTracker.discardPending(roomId, reservedHistorySlot, historyThreadId); + reservedHistorySlotConsumed = true; + } + }; + const commitInboundEventIfClaimedAndDiscardReserved = async () => { + discardReservedHistorySlot(); await commitInboundEventIfClaimed(); + }; + if (isRoom && groupPolicy === "disabled") { + await commitInboundEventIfClaimedAndDiscardReserved(); return undefined; } @@ -722,7 +793,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam logVerboseMessage( `matrix: drop configured bot sender=${senderId} (allowBots=false${isDirectMessage ? "" : `, ${roomMatchMeta}`})`, ); - await commitInboundEventIfClaimed(); + await commitInboundEventIfClaimedAndDiscardReserved(); return undefined; } const botLoopProtection: ChannelBotLoopProtectionFacts | undefined = @@ -744,18 +815,18 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam if (isRoom && roomConfig && !roomConfigInfo?.allowed) { logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`); - await commitInboundEventIfClaimed(); + await commitInboundEventIfClaimedAndDiscardReserved(); return undefined; } if (isRoom && groupPolicy === "allowlist") { if (!roomConfigInfo?.allowlistConfigured) { logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`); - await commitInboundEventIfClaimed(); + await commitInboundEventIfClaimedAndDiscardReserved(); return undefined; } if (!roomConfig) { logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`); - await commitInboundEventIfClaimed(); + await commitInboundEventIfClaimedAndDiscardReserved(); return undefined; } } @@ -811,7 +882,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam if (isDirectMessage) { if (!dmEnabled || dmPolicy === "disabled") { - await commitInboundEventIfClaimed(); + await commitInboundEventIfClaimedAndDiscardReserved(); return undefined; } const senderReason = messageIngress.senderAccess.reasonCode; @@ -851,20 +922,21 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam await commitInboundEventIfClaimed(); } catch (err) { logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`); + discardReservedHistorySlot(); return undefined; } } else { logVerboseMessage( `matrix pairing reminder suppressed sender=${senderId} (cooldown)`, ); - await commitInboundEventIfClaimed(); + await commitInboundEventIfClaimedAndDiscardReserved(); } } if (isReactionEvent || dmPolicy !== "pairing") { logVerboseMessage( `matrix: blocked ${isReactionEvent ? "reaction" : "dm"} sender ${senderId} (dmPolicy=${dmPolicy}, reason=${senderReason})`, ); - await commitInboundEventIfClaimed(); + await commitInboundEventIfClaimedAndDiscardReserved(); } return undefined; } @@ -874,7 +946,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam logVerboseMessage( `matrix: blocked sender ${senderId} (ingress=${ingressDecision.reasonCode}, ${roomMatchMeta})`, ); - await commitInboundEventIfClaimed(); + await commitInboundEventIfClaimedAndDiscardReserved(); return undefined; } if (isRoom) { @@ -897,7 +969,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam isDirectMessage, logVerboseMessage, }); - await commitInboundEventIfClaimed(); + await commitInboundEventIfClaimedAndDiscardReserved(); return undefined; } @@ -929,6 +1001,17 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam ? content.file : undefined; const mediaUrl = contentUrl ?? contentFile?.url; + const earlyContentInfo = + "info" in content && content.info && typeof content.info === "object" + ? (content.info as { mimetype?: string; size?: number }) + : undefined; + const earlyContentType = earlyContentInfo?.mimetype; + const earlyContentSize = + typeof earlyContentInfo?.size === "number" ? earlyContentInfo.size : undefined; + const earlyContentBody = typeof content.body === "string" ? content.body.trim() : ""; + const earlyContentFilename = + typeof content.filename === "string" ? content.filename.trim() : ""; + const earlyOriginalFilename = earlyContentFilename || earlyContentBody || undefined; const pendingHistoryText = resolveMatrixPendingHistoryText({ mentionPrecheckText, content, @@ -939,19 +1022,19 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam ? (await getPollSnapshot())?.text : ""; if (!mentionPrecheckText && !mediaUrl && !isPollEvent) { - await commitInboundEventIfClaimed(); + await commitInboundEventIfClaimedAndDiscardReserved(); return undefined; } - const messageId = event.event_id ?? ""; - const threadRootId = resolveMatrixThreadRootId({ event, content }); - const thread = resolveMatrixThreadRouting({ - isDirectMessage, - threadReplies, - dmThreadReplies, - messageId, - threadRootId, - }); + let preflightMedia: { + path: string; + contentType?: string; + placeholder: string; + } | null = null; + let preflightMediaDownloadFailed = false; + let preflightMediaSizeLimitExceeded = false; + let preflightAudioTranscript: string | undefined; + const { route: _route, configuredBinding: _configuredBinding, @@ -968,6 +1051,81 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam resolveAgentRoute: core.channel.routing.resolveAgentRoute, }); const hasExplicitSessionBinding = _configuredBinding !== null || _runtimeBindingId !== null; + const preflightAudioMediaUrl = mediaUrl?.startsWith("mxc://") ? mediaUrl : undefined; + const shouldRunMatrixAudioPreflight = + isMatrixAudioContent({ + msgtype: typeof content.msgtype === "string" ? content.msgtype : undefined, + mimetype: earlyContentType, + }) && + isMatrixAudioMediaEnabled(cfg) && + preflightAudioMediaUrl !== undefined; + if ( + shouldRunMatrixAudioPreflight && + audioPreflightMode === "defer" && + isRoom && + historyLimit > 0 && + !reservedHistorySlot + ) { + const reserved = roomHistoryTracker.reservePending( + _route.agentId, + roomId, + { + sender: senderId, + body: pendingHistoryText, + timestamp: eventTs ?? undefined, + messageId, + }, + historyThreadId, + ); + return { + deferredPrefix: { + ...paramsLocal, + audioPreflightMode: "run" as const, + reservedHistorySlot: reserved, + }, + } as const; + } + if (shouldRunMatrixAudioPreflight) { + try { + preflightMedia = await downloadMatrixMedia({ + client, + mxcUrl: preflightAudioMediaUrl, + contentType: earlyContentType, + sizeBytes: earlyContentSize, + maxBytes: mediaMaxBytes, + file: contentFile, + originalFilename: earlyOriginalFilename, + }); + } catch (err) { + preflightMediaDownloadFailed = true; + if (isMatrixMediaSizeLimitError(err)) { + preflightMediaSizeLimitExceeded = true; + } + const errorText = formatMatrixErrorMessage(err); + logVerboseMessage( + `matrix: media download failed room=${roomId} id=${event.event_id ?? "unknown"} type=${content.msgtype} error=${errorText}`, + ); + logger.warn("matrix media download failed", { + roomId, + eventId: event.event_id, + msgtype: content.msgtype, + encrypted: Boolean(contentFile), + error: errorText, + }); + } + if (preflightMedia) { + preflightAudioTranscript = await resolveMatrixPreflightAudioTranscript({ + mediaPath: preflightMedia.path, + mediaContentType: preflightMedia.contentType, + cfg, + accountId, + chatType: isDirectMessage ? "direct" : "channel", + originatingTo: `room:${roomId}`, + messageThreadId: thread.threadId, + sessionKey: _route.sessionKey, + }); + } + } const agentMentionRegexes = core.channel.mentions.buildMentionRegexes(cfg, _route.agentId, { provider: "matrix", conversationId: roomId, @@ -976,11 +1134,14 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam const selfDisplayName = content.formatted_body ? await getMemberDisplayName(roomId, selfUserId).catch(() => undefined) : undefined; + const mentionPrecheckTextWithTranscript = preflightAudioTranscript + ? [mentionPrecheckText, preflightAudioTranscript].filter(Boolean).join("\n").trim() + : mentionPrecheckText; const { wasMentioned, hasExplicitMention } = resolveMentions({ content, userId: selfUserId, displayName: selfDisplayName, - text: mentionPrecheckText, + text: mentionPrecheckTextWithTranscript, mentionRegexes: agentMentionRegexes, }); if ( @@ -992,7 +1153,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam logVerboseMessage( `matrix: drop configured bot sender=${senderId} (allowBots=mentions, missing mention, ${roomMatchMeta})`, ); - await commitInboundEventIfClaimed(); + await commitInboundEventIfClaimedAndDiscardReserved(); return undefined; } const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ @@ -1025,7 +1186,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam reason: "control command (unauthorized)", target: senderId, }); - await commitInboundEventIfClaimed(); + await commitInboundEventIfClaimedAndDiscardReserved(); return undefined; } const shouldRequireMention = isRoom @@ -1047,7 +1208,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam hasControlCommandInMessage; const canDetectMention = agentMentionRegexes.length > 0 || hasExplicitMention; if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) { - const pendingHistoryBody = pendingHistoryText || pendingHistoryPollText; + const pendingHistoryBody = preflightAudioTranscript + ? formatMatrixAudioTranscript(preflightAudioTranscript) + : pendingHistoryText || pendingHistoryPollText; if (historyLimit > 0 && pendingHistoryBody) { const pendingEntry: HistoryEntry = { sender: senderId, @@ -1055,16 +1218,36 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam timestamp: eventTs ?? undefined, messageId, }; - roomHistoryTracker.recordPending(roomId, pendingEntry); + if (reservedHistorySlot) { + roomHistoryTracker.finalizePending( + roomId, + reservedHistorySlot, + pendingEntry, + historyThreadId, + ); + reservedHistorySlotConsumed = true; + } else { + roomHistoryTracker.recordPending(roomId, pendingEntry, historyThreadId); + } } logger.info("skipping room message", { roomId, reason: "no-mention" }); await commitInboundEventIfClaimed(); return undefined; } + if (preflightAudioTranscript) { + await sendMatrixPreflightAudioTranscriptEcho({ + transcript: preflightAudioTranscript, + cfg, + accountId, + originatingTo: `room:${roomId}`, + messageThreadId: thread.threadId, + }); + } if (isPollEvent) { const pollSnapshot = await getPollSnapshot(); if (!pollSnapshot) { + discardReservedHistorySlot(); return undefined; } content = { @@ -1077,9 +1260,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam path: string; contentType?: string; placeholder: string; - } | null = null; - let mediaDownloadFailed = false; - let mediaSizeLimitExceeded = false; + } | null = preflightMedia; + let mediaDownloadFailed = preflightMediaDownloadFailed; + let mediaSizeLimitExceeded = preflightMediaSizeLimitExceeded; const finalContentUrl = "url" in content && typeof content.url === "string" ? content.url : undefined; const finalContentFile = @@ -1096,7 +1279,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam : undefined; const contentType = contentInfo?.mimetype; const contentSize = typeof contentInfo?.size === "number" ? contentInfo.size : undefined; - if (finalMediaUrl?.startsWith("mxc://")) { + if (!media && !mediaDownloadFailed && finalMediaUrl?.startsWith("mxc://")) { try { media = await downloadMatrixMedia({ client, @@ -1127,7 +1310,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam } const rawBody = locationPayload?.text ?? contentBody; - const bodyText = resolveMatrixInboundBodyText({ + let bodyText = resolveMatrixInboundBodyText({ rawBody, filename: typeof content.filename === "string" ? content.filename : undefined, mediaPlaceholder: media?.placeholder, @@ -1136,8 +1319,24 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam mediaDownloadFailed, mediaSizeLimitExceeded, }); + if ( + preflightMedia && + bodyText && + bodyText !== preflightMedia.placeholder && + isLikelyBareFilename(bodyText) + ) { + // Matrix voice clients commonly set body to the attachment filename. + bodyText = preflightMedia.placeholder; + } + if (preflightAudioTranscript) { + const transcriptBody = formatMatrixAudioTranscript(preflightAudioTranscript); + bodyText = + !bodyText || bodyText === media?.placeholder + ? transcriptBody + : `${bodyText}\n${transcriptBody}`; + } if (!bodyText) { - await commitInboundEventIfClaimed(); + await commitInboundEventIfClaimedAndDiscardReserved(); return undefined; } const commandBodyText = hasControlCommandInMessage ? commandCheckText : bodyText; @@ -1155,6 +1354,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam reason: "configured ACP binding unavailable", target: _configuredBinding.spec.conversationId, }); + discardReservedHistorySlot(); return undefined; } } @@ -1164,13 +1364,36 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam } const preparedTrigger = isRoom && historyLimit > 0 - ? roomHistoryTracker.prepareTrigger(_route.agentId, roomId, historyLimit, { - sender: senderName, - body: bodyText, - timestamp: eventTs ?? undefined, - messageId, - }) + ? reservedHistorySlot + ? roomHistoryTracker.prepareReservedTrigger( + _route.agentId, + roomId, + historyLimit, + reservedHistorySlot, + { + sender: senderName, + body: bodyText, + timestamp: eventTs ?? undefined, + messageId, + }, + historyThreadId, + ) + : roomHistoryTracker.prepareTrigger( + _route.agentId, + roomId, + historyLimit, + { + sender: senderName, + body: bodyText, + timestamp: eventTs ?? undefined, + messageId, + }, + historyThreadId, + ) : undefined; + if (reservedHistorySlot && preparedTrigger) { + reservedHistorySlotConsumed = true; + } const inboundHistory = preparedTrigger ? buildInboundHistoryFromEntries({ entries: preparedTrigger.history, @@ -1195,6 +1418,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam bodyText, commandBodyText, media, + preflightAudioTranscript, locationPayload, messageId, triggerSnapshot, @@ -1215,7 +1439,18 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam if (prefix.isDirectMessage) { return { deferredPrefix: prefix } as const; } - return { ingressResult: await continueIngress(prefix) } as const; + const result = await continueIngress({ + ...prefix, + audioPreflightMode: shouldDeferMatrixAudioPreflightForRoomIngress({ + content: prefix.content, + cfg, + }) + ? "defer" + : "run", + }); + return result && "deferredPrefix" in result + ? { deferredPrefix: result.deferredPrefix } + : { ingressResult: result }; }) : undefined; const resolvedIngressResult = @@ -1233,6 +1468,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam if (!resolvedIngressResult) { return; } + if ("deferredPrefix" in resolvedIngressResult) { + return; + } const { route: _route, @@ -1250,6 +1488,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam bodyText, commandBodyText, media, + preflightAudioTranscript, locationPayload, messageId, triggerSnapshot, @@ -1389,7 +1628,14 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam }, media: toInboundMediaFacts( media - ? [{ path: media.path, url: media.path, contentType: media.contentType }] + ? [ + { + path: media.path, + url: media.path, + contentType: media.contentType, + transcribed: preflightAudioTranscript !== undefined, + }, + ] : undefined, ), messageId, @@ -1865,6 +2111,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam textLimit, replyToMode, threadId: threadTarget, + replyToId: threadTarget ?? replyToEventId ?? undefined, accountId: _route.accountId, mediaLocalRoots, tableMode, @@ -1974,6 +2221,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam textLimit, replyToMode, threadId: threadTarget, + replyToId: threadTarget ?? replyToEventId ?? undefined, accountId: _route.accountId, mediaLocalRoots, tableMode, @@ -2041,6 +2289,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam textLimit, replyToMode, threadId: threadTarget, + replyToId: threadTarget ?? replyToEventId ?? undefined, accountId: _route.accountId, mediaLocalRoots, tableMode, @@ -2065,6 +2314,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam textLimit, replyToMode, threadId: threadTarget, + replyToId: threadTarget ?? replyToEventId ?? undefined, accountId: _route.accountId, mediaLocalRoots, tableMode, @@ -2096,6 +2346,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam textLimit, replyToMode, threadId: threadTarget, + replyToId: threadTarget ?? replyToEventId ?? undefined, accountId: _route.accountId, mediaLocalRoots, tableMode, @@ -2331,7 +2582,13 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam // Only advance to the snapshot position — messages added during async processing remain // visible for the next trigger. if (isRoom && triggerSnapshot) { - roomHistoryTracker.consumeHistory(_route.agentId, roomId, triggerSnapshot, messageId); + roomHistoryTracker.consumeHistory( + _route.agentId, + roomId, + triggerSnapshot, + messageId, + threadRootId ? thread.threadId : undefined, + ); } if (!hasFinalInboundReplyDispatch({ queuedFinal, counts })) { await commitInboundEventIfClaimed(); diff --git a/extensions/matrix/src/matrix/monitor/preflight-audio.runtime.ts b/extensions/matrix/src/matrix/monitor/preflight-audio.runtime.ts new file mode 100644 index 000000000000..880359696b4d --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/preflight-audio.runtime.ts @@ -0,0 +1,18 @@ +import { sendDurableMessageBatch as sendDurableMessageBatchImpl } from "openclaw/plugin-sdk/channel-outbound"; +import { transcribeFirstAudio as transcribeFirstAudioImpl } from "openclaw/plugin-sdk/media-runtime"; + +type TranscribeFirstAudio = typeof import("openclaw/plugin-sdk/media-runtime").transcribeFirstAudio; +type SendDurableMessageBatch = + typeof import("openclaw/plugin-sdk/channel-outbound").sendDurableMessageBatch; + +export async function transcribeFirstAudio( + ...args: Parameters +): ReturnType { + return await transcribeFirstAudioImpl(...args); +} + +export async function sendDurableMessageBatch( + ...args: Parameters +): ReturnType { + return await sendDurableMessageBatchImpl(...args); +} diff --git a/extensions/matrix/src/matrix/monitor/preflight-audio.test.ts b/extensions/matrix/src/matrix/monitor/preflight-audio.test.ts new file mode 100644 index 000000000000..6d4baa3138f5 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/preflight-audio.test.ts @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { sendDurableMessageBatchMock, transcribeFirstAudioMock } = vi.hoisted(() => ({ + sendDurableMessageBatchMock: vi.fn(), + transcribeFirstAudioMock: vi.fn(), +})); + +vi.mock("./preflight-audio.runtime.js", () => ({ + sendDurableMessageBatch: sendDurableMessageBatchMock, + transcribeFirstAudio: transcribeFirstAudioMock, +})); + +import { + formatMatrixAudioTranscript, + isMatrixAudioContent, + resolveMatrixPreflightAudioTranscript, + sendMatrixPreflightAudioTranscriptEcho, +} from "./preflight-audio.js"; + +const cfg = {} as import("openclaw/plugin-sdk/config-contracts").OpenClawConfig; + +describe("isMatrixAudioContent", () => { + it("accepts Matrix audio messages and audio files", () => { + expect(isMatrixAudioContent({ msgtype: "m.audio" })).toBe(true); + expect(isMatrixAudioContent({ msgtype: "m.file", mimetype: "audio/ogg" })).toBe(true); + expect(isMatrixAudioContent({ msgtype: "m.file", mimetype: "AUDIO/MP4" })).toBe(true); + }); + + it("rejects non-audio Matrix content", () => { + expect(isMatrixAudioContent({ msgtype: "m.image", mimetype: "image/png" })).toBe(false); + expect(isMatrixAudioContent({ msgtype: "m.file", mimetype: "application/pdf" })).toBe(false); + expect(isMatrixAudioContent({ mimetype: "audio/ogg" })).toBe(false); + }); +}); + +describe("formatMatrixAudioTranscript", () => { + it("wraps transcripts with untrusted machine-generated framing", () => { + expect(formatMatrixAudioTranscript('say "hi"\nthen go')).toBe( + `[Audio transcript (machine-generated, untrusted)]: ${JSON.stringify('say "hi"\nthen go')}`, + ); + }); +}); + +describe("resolveMatrixPreflightAudioTranscript", () => { + beforeEach(() => { + sendDurableMessageBatchMock.mockReset(); + transcribeFirstAudioMock.mockReset(); + }); + + it("passes the Matrix-local media path to shared audio preflight", async () => { + transcribeFirstAudioMock.mockResolvedValue("hello from voice"); + + const transcript = await resolveMatrixPreflightAudioTranscript({ + mediaPath: "/tmp/inbound/voice.ogg", + mediaContentType: "audio/ogg", + cfg, + accountId: "ops", + chatType: "channel", + originatingTo: "room:!room:example.org", + messageThreadId: "$thread", + sessionKey: "agent:main:matrix:channel:!room:example.org", + }); + + expect(transcribeFirstAudioMock).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: expect.objectContaining({ + MediaPaths: ["/tmp/inbound/voice.ogg"], + MediaTypes: ["audio/ogg"], + Provider: "matrix", + Surface: "matrix", + OriginatingChannel: "matrix", + OriginatingTo: "room:!room:example.org", + AccountId: "ops", + MessageThreadId: "$thread", + ChatType: "channel", + SessionKey: "agent:main:matrix:channel:!room:example.org", + }), + cfg, + }), + ); + expect(transcript).toBe("hello from voice"); + }); + + it("suppresses shared echo during pre-mention transcription", async () => { + const echoCfg = { + tools: { media: { audio: { echoTranscript: true, echoFormat: "echo: {transcript}" } } }, + } as import("openclaw/plugin-sdk/config-contracts").OpenClawConfig; + transcribeFirstAudioMock.mockResolvedValue("hello from voice"); + + await resolveMatrixPreflightAudioTranscript({ + mediaPath: "/tmp/inbound/voice.ogg", + mediaContentType: "audio/ogg", + cfg: echoCfg, + accountId: "ops", + chatType: "channel", + originatingTo: "room:!room:example.org", + sessionKey: "agent:main:matrix:channel:!room:example.org", + }); + + const callCfg = transcribeFirstAudioMock.mock.calls[0]?.[0]?.cfg as + | { tools?: { media?: { audio?: { echoTranscript?: unknown } } } } + | undefined; + expect(callCfg?.tools?.media?.audio?.echoTranscript).toBe(false); + }); + + it("swallows provider failures and aborts", async () => { + transcribeFirstAudioMock.mockRejectedValue(new Error("STT down")); + await expect( + resolveMatrixPreflightAudioTranscript({ + mediaPath: "/tmp/inbound/voice.ogg", + cfg, + accountId: "ops", + chatType: "direct", + originatingTo: "room:!dm:example.org", + sessionKey: "agent:main:matrix:direct:@frank:example.org", + }), + ).resolves.toBeUndefined(); + + const controller = new AbortController(); + controller.abort(); + transcribeFirstAudioMock.mockClear(); + await expect( + resolveMatrixPreflightAudioTranscript({ + mediaPath: "/tmp/inbound/voice.ogg", + cfg, + accountId: "ops", + chatType: "direct", + originatingTo: "room:!dm:example.org", + sessionKey: "agent:main:matrix:direct:@frank:example.org", + abortSignal: controller.signal, + }), + ).resolves.toBeUndefined(); + expect(transcribeFirstAudioMock).not.toHaveBeenCalled(); + }); +}); + +describe("sendMatrixPreflightAudioTranscriptEcho", () => { + beforeEach(() => { + sendDurableMessageBatchMock.mockReset(); + transcribeFirstAudioMock.mockReset(); + }); + + it("sends accepted Matrix preflight transcript echoes through durable delivery", async () => { + sendDurableMessageBatchMock.mockResolvedValue({ status: "sent", results: [] }); + await sendMatrixPreflightAudioTranscriptEcho({ + transcript: "hello bot", + cfg: { + tools: { media: { audio: { echoTranscript: true, echoFormat: "heard: {transcript}" } } }, + } as import("openclaw/plugin-sdk/config-contracts").OpenClawConfig, + accountId: "ops", + originatingTo: "room:!room:example.org", + messageThreadId: "$thread", + }); + + expect(sendDurableMessageBatchMock).toHaveBeenCalledWith({ + cfg: expect.any(Object), + channel: "matrix", + to: "room:!room:example.org", + accountId: "ops", + threadId: "$thread", + payloads: [{ text: "heard: hello bot" }], + bestEffort: true, + durability: "best_effort", + }); + }); + + it("does not echo when transcript echo is disabled", async () => { + await sendMatrixPreflightAudioTranscriptEcho({ + transcript: "hello bot", + cfg, + accountId: "ops", + originatingTo: "room:!room:example.org", + }); + + expect(sendDurableMessageBatchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/preflight-audio.ts b/extensions/matrix/src/matrix/monitor/preflight-audio.ts new file mode 100644 index 000000000000..af077952dfd0 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/preflight-audio.ts @@ -0,0 +1,126 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; + +type MatrixPreflightAudioRuntime = typeof import("./preflight-audio.runtime.js"); +const MATRIX_DEFAULT_ECHO_TRANSCRIPT_FORMAT = '📝 "{transcript}"'; + +let matrixPreflightAudioRuntimePromise: Promise | undefined; + +function loadMatrixPreflightAudioRuntime(): Promise { + matrixPreflightAudioRuntimePromise ??= import("./preflight-audio.runtime.js"); + return matrixPreflightAudioRuntimePromise; +} + +export function formatMatrixAudioTranscript(transcript: string): string { + return `[Audio transcript (machine-generated, untrusted)]: ${JSON.stringify(transcript)}`; +} + +function formatMatrixAudioTranscriptEcho(transcript: string, format: string): string { + return format.replace("{transcript}", transcript); +} + +function suppressMatrixPreflightAudioEcho(cfg: OpenClawConfig): OpenClawConfig { + const audio = cfg.tools?.media?.audio; + if (!audio?.echoTranscript) { + return cfg; + } + return { + ...cfg, + tools: { + ...cfg.tools, + media: { + ...cfg.tools?.media, + audio: { + ...audio, + echoTranscript: false, + }, + }, + }, + }; +} + +export function isMatrixAudioContent(params: { msgtype?: string; mimetype?: string }): boolean { + if (params.msgtype === "m.audio") { + return true; + } + if (params.msgtype === "m.file" && typeof params.mimetype === "string") { + return params.mimetype.toLowerCase().startsWith("audio/"); + } + return false; +} + +export async function resolveMatrixPreflightAudioTranscript(params: { + mediaPath: string; + mediaContentType?: string; + cfg: OpenClawConfig; + accountId: string; + chatType: "channel" | "direct"; + originatingTo: string; + messageThreadId?: string; + sessionKey: string; + abortSignal?: AbortSignal; +}): Promise { + if (params.abortSignal?.aborted) { + return undefined; + } + try { + const { transcribeFirstAudio } = await loadMatrixPreflightAudioRuntime(); + if (params.abortSignal?.aborted) { + return undefined; + } + const transcript = await transcribeFirstAudio({ + ctx: { + MediaPaths: [params.mediaPath], + MediaTypes: params.mediaContentType ? [params.mediaContentType] : undefined, + Provider: "matrix", + Surface: "matrix", + OriginatingChannel: "matrix", + OriginatingTo: params.originatingTo, + AccountId: params.accountId, + MessageThreadId: params.messageThreadId, + ChatType: params.chatType, + SessionKey: params.sessionKey, + }, + cfg: suppressMatrixPreflightAudioEcho(params.cfg), + }); + return params.abortSignal?.aborted ? undefined : transcript; + } catch (err) { + logVerbose(`matrix: audio preflight transcription failed: ${String(err)}`); + return undefined; + } +} + +export async function sendMatrixPreflightAudioTranscriptEcho(params: { + transcript: string; + cfg: OpenClawConfig; + accountId: string; + originatingTo: string; + messageThreadId?: string; +}): Promise { + const audio = params.cfg.tools?.media?.audio; + if (!audio?.echoTranscript) { + return; + } + const text = formatMatrixAudioTranscriptEcho( + params.transcript, + audio.echoFormat ?? MATRIX_DEFAULT_ECHO_TRANSCRIPT_FORMAT, + ); + try { + const { sendDurableMessageBatch } = await loadMatrixPreflightAudioRuntime(); + const send = await sendDurableMessageBatch({ + cfg: params.cfg, + channel: "matrix", + to: params.originatingTo, + accountId: params.accountId, + threadId: params.messageThreadId, + payloads: [{ text }], + bestEffort: true, + durability: "best_effort", + }); + if (send.status === "failed") { + throw send.error; + } + } catch (err) { + logVerbose(`matrix: audio transcript echo failed: ${String(err)}`); + } +} diff --git a/extensions/matrix/src/matrix/monitor/replies.test.ts b/extensions/matrix/src/matrix/monitor/replies.test.ts index 4dcba4be1fc9..09a0e7342de5 100644 --- a/extensions/matrix/src/matrix/monitor/replies.test.ts +++ b/extensions/matrix/src/matrix/monitor/replies.test.ts @@ -149,7 +149,7 @@ describe("deliverMatrixReplies", () => { expect(sendOptions(2).replyToId).toBe("reply-text"); }); - it("suppresses replyToId when threadId is set", async () => { + it("keeps replyToId when threadId is set so Matrix can send fallback metadata", async () => { chunkMatrixTextMock.mockImplementation((text: string) => ({ trimmedText: text.trim(), convertedText: text, @@ -160,19 +160,20 @@ describe("deliverMatrixReplies", () => { await deliverMatrixReplies({ cfg, - replies: [{ text: "hello|thread", replyToId: "reply-thread" }], + replies: [{ text: "hello|thread" }], roomId: "room:3", client: {} as MatrixClient, runtime: runtimeEnv, textLimit: 4000, - replyToMode: "all", + replyToMode: "off", threadId: "thread-77", + replyToId: "reply-thread", }); expect(sendMessageMatrixMock).toHaveBeenCalledTimes(2); - expect(sendOptions(0).replyToId).toBeUndefined(); + expect(sendOptions(0).replyToId).toBe("reply-thread"); expect(sendOptions(0).threadId).toBe("thread-77"); - expect(sendOptions(1).replyToId).toBeUndefined(); + expect(sendOptions(1).replyToId).toBe("reply-thread"); expect(sendOptions(1).threadId).toBe("thread-77"); }); diff --git a/extensions/matrix/src/matrix/monitor/replies.ts b/extensions/matrix/src/matrix/monitor/replies.ts index a40e43408ef4..695c939a4b46 100644 --- a/extensions/matrix/src/matrix/monitor/replies.ts +++ b/extensions/matrix/src/matrix/monitor/replies.ts @@ -39,6 +39,7 @@ export async function deliverMatrixReplies(params: { textLimit: number; replyToMode: "off" | "first" | "all" | "batched"; threadId?: string; + replyToId?: string; accountId?: string; mediaLocalRoots?: readonly string[]; tableMode?: MarkdownTableMode; @@ -72,8 +73,12 @@ export async function deliverMatrixReplies(params: { params.runtime.error?.("matrix reply missing text/media"); continue; } - const replyToIdRaw = reply.replyToId?.trim(); - const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw; + const replyToIdRaw = (reply.replyToId ?? params.replyToId)?.trim(); + const replyToId = params.threadId + ? replyToIdRaw + : params.replyToMode === "off" + ? undefined + : replyToIdRaw; const rawText = reply.text ?? ""; const mediaList = reply.mediaUrls?.length ? reply.mediaUrls @@ -82,7 +87,7 @@ export async function deliverMatrixReplies(params: { : []; const shouldIncludeReply = (id?: string) => - Boolean(id) && (params.replyToMode === "all" || !hasReplied); + Boolean(id) && (params.threadId || params.replyToMode === "all" || !hasReplied); const replyToIdForReply = shouldIncludeReply(replyToId) ? replyToId : undefined; if (mediaList.length === 0) { diff --git a/extensions/matrix/src/matrix/monitor/room-history.test.ts b/extensions/matrix/src/matrix/monitor/room-history.test.ts index c0ac7b501a66..35ff6fb7ab88 100644 --- a/extensions/matrix/src/matrix/monitor/room-history.test.ts +++ b/extensions/matrix/src/matrix/monitor/room-history.test.ts @@ -65,6 +65,223 @@ describe("createRoomHistoryTracker — watermark monotonicity", () => { expect(retried.snapshotIdx).toBe(first.snapshotIdx); }); + it("reserved triggers keep their arrival-order history window", () => { + const tracker = createRoomHistoryTrackerForTests(); + + tracker.recordPending(ROOM, { sender: "user", body: "before", messageId: "$before" }); + const reserved = tracker.reservePending(AGENT, ROOM, { + sender: "user", + body: "audio placeholder", + messageId: "$audio", + }); + tracker.recordPending(ROOM, { sender: "user", body: "after", messageId: "$after" }); + + const prepared = tracker.prepareReservedTrigger(AGENT, ROOM, 100, reserved, { + sender: "user", + body: "audio trigger", + messageId: "$audio", + }); + + expect(prepared.history.map((entryValue) => entryValue.body)).toEqual(["before"]); + tracker.consumeHistory(AGENT, ROOM, prepared, "$audio"); + expect( + tracker.getPendingHistory(AGENT, ROOM, 100).map((entryValue) => entryValue.body), + ).toEqual(["after"]); + }); + + it("reserved pending slots are finalized in arrival order", () => { + const tracker = createRoomHistoryTrackerForTests(); + + const reserved = tracker.reservePending(AGENT, ROOM, { + sender: "user", + body: "audio placeholder", + messageId: "$audio", + }); + tracker.recordPending(ROOM, { sender: "user", body: "after", messageId: "$after" }); + tracker.finalizePending(ROOM, reserved, { + sender: "user", + body: "audio final", + messageId: "$audio", + }); + + expect( + tracker.getPendingHistory(AGENT, ROOM, 100).map((entryValue) => entryValue.body), + ).toEqual(["audio final", "after"]); + }); + + it("discarded reserved slots do not leak into later history", () => { + const tracker = createRoomHistoryTrackerForTests(); + + const reserved = tracker.reservePending(AGENT, ROOM, { + sender: "blocked", + body: "blocked audio", + messageId: "$blocked", + }); + tracker.discardPending(ROOM, reserved); + tracker.recordPending(ROOM, { sender: "user", body: "after", messageId: "$after" }); + + const prepared = tracker.prepareTrigger(AGENT, ROOM, 100, { + sender: "user", + body: "trigger", + messageId: "$trigger", + }); + + expect(prepared.history.map((entryValue) => entryValue.body)).toEqual(["after"]); + }); + + it("reserved triggers use the arrival-time watermark even if a later trigger consumes history", () => { + const tracker = createRoomHistoryTrackerForTests(); + + tracker.recordPending(ROOM, { sender: "user", body: "before", messageId: "$before" }); + const reserved = tracker.reservePending(AGENT, ROOM, { + sender: "user", + body: "audio placeholder", + messageId: "$audio", + }); + const later = tracker.prepareTrigger(AGENT, ROOM, 100, { + sender: "user", + body: "later trigger", + messageId: "$later", + }); + tracker.consumeHistory(AGENT, ROOM, later, "$later"); + + const prepared = tracker.prepareReservedTrigger(AGENT, ROOM, 100, reserved, { + sender: "user", + body: "audio trigger", + messageId: "$audio", + }); + + expect(prepared.history.map((entryValue) => entryValue.body)).toEqual(["before"]); + }); + + it("does not let later triggers consume unfinalized reserved slots", () => { + const tracker = createRoomHistoryTrackerForTests(); + + const reserved = tracker.reservePending(AGENT, ROOM, { + sender: "user", + body: "audio placeholder", + messageId: "$audio", + }); + const later = tracker.prepareTrigger(AGENT, ROOM, 100, { + sender: "user", + body: "later trigger", + messageId: "$later", + }); + tracker.consumeHistory(AGENT, ROOM, later, "$later"); + tracker.finalizePending(ROOM, reserved, { + sender: "user", + body: "audio transcript", + messageId: "$audio", + }); + + const followUp = tracker.prepareTrigger(AGENT, ROOM, 100, { + sender: "user", + body: "follow up", + messageId: "$follow-up", + }); + + expect(followUp.history.map((entryValue) => entryValue.body)).toEqual(["audio transcript"]); + }); + + it("reserved trigger retries discard the extra placeholder slot", () => { + const tracker = createRoomHistoryTrackerForTests(); + + tracker.recordPending(ROOM, { sender: "user", body: "before", messageId: "$before" }); + const firstReserved = tracker.reservePending(AGENT, ROOM, { + sender: "user", + body: "audio placeholder", + messageId: "$audio", + }); + const firstPrepared = tracker.prepareReservedTrigger(AGENT, ROOM, 100, firstReserved, { + sender: "user", + body: "audio trigger", + messageId: "$audio", + }); + + const retryReserved = tracker.reservePending(AGENT, ROOM, { + sender: "user", + body: "audio placeholder retry", + messageId: "$audio", + }); + const retried = tracker.prepareReservedTrigger(AGENT, ROOM, 100, retryReserved, { + sender: "user", + body: "audio trigger", + messageId: "$audio", + }); + tracker.consumeHistory(AGENT, ROOM, retried, "$audio"); + + expect(retried.snapshotIdx).toBe(firstPrepared.snapshotIdx); + expect(tracker.getPendingHistory(AGENT, ROOM, 100)).toHaveLength(0); + }); + + it("keeps main-room and thread histories isolated", () => { + const tracker = createRoomHistoryTrackerForTests(); + + tracker.recordPending(ROOM, entry("main-1")); + tracker.recordPending(ROOM, entry("thread-1"), "$thread"); + tracker.recordPending(ROOM, entry("main-2")); + + const mainPrepared = tracker.prepareTrigger(AGENT, ROOM, 100, entry("main-trigger")); + const threadPrepared = tracker.prepareTrigger( + AGENT, + ROOM, + 100, + entry("thread-trigger"), + "$thread", + ); + + expect(mainPrepared.history.map((entryValue) => entryValue.body)).toEqual(["main-1", "main-2"]); + expect(threadPrepared.history.map((entryValue) => entryValue.body)).toEqual(["thread-1"]); + }); + + it("advances watermarks independently per thread", () => { + const tracker = createRoomHistoryTrackerForTests(); + + tracker.recordPending(ROOM, entry("thread-a-1"), "$thread-a"); + tracker.recordPending(ROOM, entry("thread-b-1"), "$thread-b"); + const snapA = tracker.prepareTrigger(AGENT, ROOM, 100, entry("trigger-a"), "$thread-a"); + tracker.consumeHistory(AGENT, ROOM, snapA, undefined, "$thread-a"); + + expect(tracker.getPendingHistory(AGENT, ROOM, 100, "$thread-a")).toHaveLength(0); + expect( + tracker.getPendingHistory(AGENT, ROOM, 100, "$thread-b").map((entryValue) => entryValue.body), + ).toEqual(["thread-b-1"]); + }); + + it("reserved thread triggers keep the thread arrival-order history window", () => { + const tracker = createRoomHistoryTrackerForTests(); + + tracker.recordPending(ROOM, entry("main-before")); + tracker.recordPending(ROOM, entry("thread-before"), "$thread"); + const reserved = tracker.reservePending( + AGENT, + ROOM, + { + sender: "user", + body: "audio placeholder", + messageId: "$audio", + }, + "$thread", + ); + tracker.recordPending(ROOM, entry("thread-after"), "$thread"); + tracker.recordPending(ROOM, entry("main-after")); + + const prepared = tracker.prepareReservedTrigger( + AGENT, + ROOM, + 100, + reserved, + { + sender: "user", + body: "audio trigger", + messageId: "$audio", + }, + "$thread", + ); + + expect(prepared.history.map((entryValue) => entryValue.body)).toEqual(["thread-before"]); + }); + it("refreshes watermark recency before capped-map eviction", () => { const tracker = createRoomHistoryTrackerForTests(200, 10, 2); const room1 = "!room1:test"; diff --git a/extensions/matrix/src/matrix/monitor/room-history.ts b/extensions/matrix/src/matrix/monitor/room-history.ts index 5bd389495c06..d50953613882 100644 --- a/extensions/matrix/src/matrix/monitor/room-history.ts +++ b/extensions/matrix/src/matrix/monitor/room-history.ts @@ -11,6 +11,9 @@ * Race-condition safety: the watermark only advances to the snapshot index taken at * dispatch time, NOT to the queue's end at reply time. Messages that land in the queue * while the agent is processing stay visible to the next trigger for that agent. + * + * Thread-scoped history uses a separate sub-queue per Matrix thread root. Main-room + * history and thread history must not share watermarks or pending context. */ import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history"; @@ -23,6 +26,8 @@ const DEFAULT_MAX_ROOM_QUEUES = 1000; const MAX_WATERMARK_ENTRIES = 5000; /** Maximum prepared trigger snapshots retained per room for retry reuse. */ const MAX_PREPARED_TRIGGER_ENTRIES = 500; +/** Maximum thread queues retained per room (FIFO eviction beyond this). */ +const MAX_THREAD_QUEUES_PER_ROOM = 200; export type { HistoryEntry }; @@ -31,6 +36,17 @@ type HistorySnapshotToken = { queueGeneration: number; }; +export type ReservedHistorySlot = HistorySnapshotToken & { + slotIdx: number; + watermarkIdx?: number; +}; + +type QueuedHistoryEntry = HistoryEntry & { + discarded?: true; + reserved?: true; + consumedBy?: Set; +}; + type PreparedTriggerResult = { history: HistoryEntry[]; } & HistorySnapshotToken; @@ -40,7 +56,26 @@ type RoomHistoryTracker = { * Record a non-trigger message for future context. * Call this when a room message arrives but does not mention the bot. */ - recordPending: (roomId: string, entry: HistoryEntry) => void; + recordPending: (roomId: string, entry: HistoryEntry, threadRootId?: string) => void; + + /** Reserve an arrival-order slot for slow preflight work that finishes later. */ + reservePending: ( + agentId: string, + roomId: string, + entry: HistoryEntry, + threadRootId?: string, + ) => ReservedHistorySlot; + + /** Replace a reserved slot with its final non-trigger history entry. */ + finalizePending: ( + roomId: string, + slot: ReservedHistorySlot, + entry: HistoryEntry, + threadRootId?: string, + ) => void; + + /** Remove a reserved slot without changing later absolute indexes. */ + discardPending: (roomId: string, slot: ReservedHistorySlot, threadRootId?: string) => void; /** * Capture pending history and append the trigger as one idempotent operation. @@ -51,6 +86,17 @@ type RoomHistoryTracker = { roomId: string, limit: number, entry: HistoryEntry, + threadRootId?: string, + ) => PreparedTriggerResult; + + /** Prepare a trigger using a previously reserved arrival-order slot. */ + prepareReservedTrigger: ( + agentId: string, + roomId: string, + limit: number, + slot: ReservedHistorySlot, + entry: HistoryEntry, + threadRootId?: string, ) => PreparedTriggerResult; /** @@ -63,6 +109,7 @@ type RoomHistoryTracker = { roomId: string, snapshot: HistorySnapshotToken, messageId?: string, + threadRootId?: string, ) => void; }; @@ -70,22 +117,35 @@ type RoomHistoryTrackerTestApi = RoomHistoryTracker & { /** * Test-only helper for inspecting pending room history directly. */ - getPendingHistory: (agentId: string, roomId: string, limit: number) => HistoryEntry[]; + getPendingHistory: ( + agentId: string, + roomId: string, + limit: number, + threadRootId?: string, + ) => HistoryEntry[]; /** * Test-only helper for manually appending a trigger entry and snapshot index. */ - recordTrigger: (roomId: string, entry: HistoryEntry) => HistorySnapshotToken; + recordTrigger: ( + roomId: string, + entry: HistoryEntry, + threadRootId?: string, + ) => HistorySnapshotToken; }; -type RoomQueue = { - entries: HistoryEntry[]; +type HistoryQueue = { + entries: QueuedHistoryEntry[]; /** Absolute index of entries[0] — increases as old entries are trimmed. */ baseIndex: number; generation: number; preparedTriggers: Map; }; +type RoomQueue = HistoryQueue & { + threadQueues: Map; +}; + function createRoomHistoryTrackerInternal( maxQueueSize = DEFAULT_MAX_QUEUE_SIZE, maxRoomQueues = DEFAULT_MAX_ROOM_QUEUES, @@ -93,27 +153,43 @@ function createRoomHistoryTrackerInternal( maxPreparedTriggerEntries = MAX_PREPARED_TRIGGER_ENTRIES, ): RoomHistoryTrackerTestApi { const roomQueues = new Map(); - /** Maps `${agentId}:${roomId}` → absolute consumed-up-to index */ + /** Maps `{agentId, roomId, scope}` → absolute consumed-up-to index */ const agentWatermarks = new Map(); let nextQueueGeneration = 1; function clearRoomWatermarks(roomId: string): void { - const roomSuffix = `:${roomId}`; for (const key of agentWatermarks.keys()) { - if (key.endsWith(roomSuffix)) { + const parsed = JSON.parse(key) as { roomId?: string } | null; + if (parsed?.roomId === roomId) { agentWatermarks.delete(key); } } } + function clearThreadWatermarks(roomId: string, threadRootId: string): void { + for (const key of agentWatermarks.keys()) { + const parsed = JSON.parse(key) as { roomId?: string; scope?: string } | null; + if (parsed?.roomId === roomId && parsed.scope === threadRootId) { + agentWatermarks.delete(key); + } + } + } + + function createHistoryQueue(): HistoryQueue { + return { + entries: [], + baseIndex: 0, + generation: nextQueueGeneration++, + preparedTriggers: new Map(), + }; + } + function getOrCreateQueue(roomId: string): RoomQueue { let queue = roomQueues.get(roomId); if (!queue) { queue = { - entries: [], - baseIndex: 0, - generation: nextQueueGeneration++, - preparedTriggers: new Map(), + ...createHistoryQueue(), + threadQueues: new Map(), }; roomQueues.set(roomId, queue); // FIFO eviction to prevent unbounded growth across many rooms @@ -128,7 +204,40 @@ function createRoomHistoryTrackerInternal( return queue; } - function appendToQueue(queue: RoomQueue, entry: HistoryEntry): HistorySnapshotToken { + function getOrCreateThreadQueue( + roomId: string, + roomQueue: RoomQueue, + threadRootId: string, + ): HistoryQueue { + let queue = roomQueue.threadQueues.get(threadRootId); + if (!queue) { + queue = createHistoryQueue(); + roomQueue.threadQueues.set(threadRootId, queue); + if (roomQueue.threadQueues.size > MAX_THREAD_QUEUES_PER_ROOM) { + const oldest = roomQueue.threadQueues.keys().next().value; + if (oldest !== undefined) { + roomQueue.threadQueues.delete(oldest); + clearThreadWatermarks(roomId, oldest); + } + } + } + return queue; + } + + function getScopedQueue(roomId: string, threadRootId?: string): HistoryQueue { + const roomQueue = getOrCreateQueue(roomId); + return threadRootId ? getOrCreateThreadQueue(roomId, roomQueue, threadRootId) : roomQueue; + } + + function findScopedQueue(roomId: string, threadRootId?: string): HistoryQueue | undefined { + const roomQueue = roomQueues.get(roomId); + if (!roomQueue) { + return undefined; + } + return threadRootId ? roomQueue.threadQueues.get(threadRootId) : roomQueue; + } + + function appendToQueue(queue: HistoryQueue, entry: QueuedHistoryEntry): HistorySnapshotToken { queue.entries.push(entry); if (queue.entries.length > maxQueueSize) { const overflow = queue.entries.length - maxQueueSize; @@ -141,8 +250,12 @@ function createRoomHistoryTrackerInternal( }; } - function wmKey(agentId: string, roomId: string): string { - return `${agentId}:${roomId}`; + function wmKey(agentId: string, roomId: string, threadRootId?: string): string { + return JSON.stringify({ + agentId, + roomId, + scope: threadRootId ?? "main", + }); } function preparedTriggerKey(agentId: string, messageId?: string): string | null { @@ -167,8 +280,25 @@ function createRoomHistoryTrackerInternal( } } + function markConsumedAfterReservedGap( + queue: HistoryQueue, + key: string, + firstReservedRel: number, + snapshotIdx: number, + ): void { + const endRel = Math.min(snapshotIdx - queue.baseIndex, queue.entries.length); + for (let rel = firstReservedRel + 1; rel < endRel; rel += 1) { + const entry = queue.entries[rel]; + if (!entry || entry.reserved || entry.discarded) { + continue; + } + entry.consumedBy ??= new Set(); + entry.consumedBy.add(key); + } + } + function rememberPreparedTrigger( - queue: RoomQueue, + queue: HistoryQueue, retryKey: string, prepared: PreparedTriggerResult, ): PreparedTriggerResult { @@ -187,53 +317,174 @@ function createRoomHistoryTrackerInternal( } function computePendingHistory( - queue: RoomQueue, + queue: HistoryQueue, agentId: string, roomId: string, limit: number, + endAbsExclusive = queue.baseIndex + queue.entries.length, + startAbsOverride?: number, + threadRootId?: string, ): HistoryEntry[] { if (limit <= 0 || queue.entries.length === 0) { return []; } - const wm = agentWatermarks.get(wmKey(agentId, roomId)) ?? 0; + const wm = startAbsOverride ?? agentWatermarks.get(wmKey(agentId, roomId, threadRootId)) ?? 0; // startAbs: the first absolute index the agent hasn't seen yet const startAbs = Math.max(wm, queue.baseIndex); const startRel = startAbs - queue.baseIndex; - const available = queue.entries.slice(startRel); + const endRel = Math.max( + startRel, + Math.min(endAbsExclusive - queue.baseIndex, queue.entries.length), + ); + const available = queue.entries + .slice(startRel, endRel) + .filter( + (entry) => + !entry.discarded && + !entry.reserved && + !entry.consumedBy?.has(wmKey(agentId, roomId, threadRootId)), + ); return available.length > limit ? available.slice(-limit) : available; } + function prepareTriggerInternal( + agentId: string, + roomId: string, + limit: number, + entry: HistoryEntry, + threadRootId?: string, + ): PreparedTriggerResult { + const queue = getScopedQueue(roomId, threadRootId); + const retryKey = preparedTriggerKey(agentId, entry.messageId); + if (retryKey) { + const prepared = queue.preparedTriggers.get(retryKey); + if (prepared) { + return rememberPreparedTrigger(queue, retryKey, prepared); + } + } + const prepared = { + history: computePendingHistory( + queue, + agentId, + roomId, + limit, + undefined, + undefined, + threadRootId, + ), + ...appendToQueue(queue, entry), + }; + if (retryKey) { + return rememberPreparedTrigger(queue, retryKey, prepared); + } + return prepared; + } + return { - recordPending(roomId, entry) { - const queue = getOrCreateQueue(roomId); + recordPending(roomId, entry, threadRootId) { + const queue = getScopedQueue(roomId, threadRootId); appendToQueue(queue, entry); }, - getPendingHistory(agentId, roomId, limit) { - const queue = roomQueues.get(roomId); + reservePending(agentId, roomId, entry, threadRootId) { + const queue = getScopedQueue(roomId, threadRootId); + const snapshot = appendToQueue(queue, { ...entry, reserved: true }); + return { + ...snapshot, + slotIdx: snapshot.snapshotIdx - 1, + watermarkIdx: agentWatermarks.get(wmKey(agentId, roomId, threadRootId)) ?? 0, + }; + }, + + finalizePending(roomId, slot, entry, threadRootId) { + const queue = findScopedQueue(roomId, threadRootId); + if (!queue || queue.generation !== slot.queueGeneration) { + return; + } + const rel = slot.slotIdx - queue.baseIndex; + if (rel < 0 || rel >= queue.entries.length) { + return; + } + queue.entries[rel] = entry; + }, + + discardPending(roomId, slot, threadRootId) { + const queue = findScopedQueue(roomId, threadRootId); + if (!queue || queue.generation !== slot.queueGeneration) { + return; + } + const rel = slot.slotIdx - queue.baseIndex; + if (rel < 0 || rel >= queue.entries.length) { + return; + } + queue.entries[rel] = { + sender: "", + body: "", + messageId: undefined, + discarded: true, + }; + }, + + getPendingHistory(agentId, roomId, limit, threadRootId) { + const queue = findScopedQueue(roomId, threadRootId); if (!queue) { return []; } - return computePendingHistory(queue, agentId, roomId, limit); + return computePendingHistory( + queue, + agentId, + roomId, + limit, + undefined, + undefined, + threadRootId, + ); }, - recordTrigger(roomId, entry) { - const queue = getOrCreateQueue(roomId); + recordTrigger(roomId, entry, threadRootId) { + const queue = getScopedQueue(roomId, threadRootId); return appendToQueue(queue, entry); }, - prepareTrigger(agentId, roomId, limit, entry) { - const queue = getOrCreateQueue(roomId); + prepareTrigger(agentId, roomId, limit, entry, threadRootId) { + return prepareTriggerInternal(agentId, roomId, limit, entry, threadRootId); + }, + + prepareReservedTrigger(agentId, roomId, limit, slot, entry, threadRootId) { + const queue = findScopedQueue(roomId, threadRootId); + if (!queue || queue.generation !== slot.queueGeneration) { + return prepareTriggerInternal(agentId, roomId, limit, entry, threadRootId); + } + const rel = slot.slotIdx - queue.baseIndex; + if (rel < 0 || rel >= queue.entries.length) { + return prepareTriggerInternal(agentId, roomId, limit, entry, threadRootId); + } const retryKey = preparedTriggerKey(agentId, entry.messageId); if (retryKey) { const prepared = queue.preparedTriggers.get(retryKey); if (prepared) { + queue.entries[rel] = { + sender: "", + body: "", + messageId: undefined, + discarded: true, + }; return rememberPreparedTrigger(queue, retryKey, prepared); } } + queue.entries[rel] = entry; const prepared = { - history: computePendingHistory(queue, agentId, roomId, limit), - ...appendToQueue(queue, entry), + history: computePendingHistory( + queue, + agentId, + roomId, + limit, + slot.slotIdx, + slot.watermarkIdx, + threadRootId, + ), + snapshotIdx: slot.slotIdx + 1, + queueGeneration: queue.generation, }; if (retryKey) { return rememberPreparedTrigger(queue, retryKey, prepared); @@ -241,12 +492,12 @@ function createRoomHistoryTrackerInternal( return prepared; }, - consumeHistory(agentId, roomId, snapshot, messageId) { - const key = wmKey(agentId, roomId); - const queue = roomQueues.get(roomId); + consumeHistory(agentId, roomId, snapshot, messageId, threadRootId) { + const key = wmKey(agentId, roomId, threadRootId); + const queue = findScopedQueue(roomId, threadRootId); if (!queue) { - // The room was evicted while this trigger was in flight. Keep eviction authoritative - // so a late completion cannot recreate a stale watermark against a fresh queue. + // The room or thread was evicted while this trigger was in flight. Keep eviction + // authoritative so a late completion cannot recreate a stale watermark. agentWatermarks.delete(key); return; } @@ -255,12 +506,20 @@ function createRoomHistoryTrackerInternal( // snapshot so it cannot advance or erase state for the new queue generation. return; } + const firstReservedRel = queue.entries.findIndex( + (entry, index) => entry.reserved === true && queue.baseIndex + index < snapshot.snapshotIdx, + ); + if (firstReservedRel >= 0) { + markConsumedAfterReservedGap(queue, key, firstReservedRel, snapshot.snapshotIdx); + } + const consumableSnapshotIdx = + firstReservedRel >= 0 ? queue.baseIndex + firstReservedRel : snapshot.snapshotIdx; // Monotone write: never regress an already-advanced watermark. // Guards against out-of-order completion when two triggers for the same // (agentId, roomId) are in-flight concurrently. - rememberWatermark(key, snapshot.snapshotIdx); + rememberWatermark(key, consumableSnapshotIdx); const retryKey = preparedTriggerKey(agentId, messageId); - if (queue && retryKey) { + if (retryKey) { queue.preparedTriggers.delete(retryKey); } }, @@ -281,7 +540,11 @@ export function createRoomHistoryTracker( ); return { recordPending: tracker.recordPending, + reservePending: tracker.reservePending, + finalizePending: tracker.finalizePending, + discardPending: tracker.discardPending, prepareTrigger: tracker.prepareTrigger, + prepareReservedTrigger: tracker.prepareReservedTrigger, consumeHistory: tracker.consumeHistory, }; } diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts index 3e948f955938..2063c56b3579 100644 --- a/extensions/matrix/src/matrix/sdk.ts +++ b/extensions/matrix/src/matrix/sdk.ts @@ -10,6 +10,7 @@ import { type MatrixClient as MatrixJsClient, type MatrixEvent, } from "matrix-js-sdk/lib/matrix.js"; +import type { Direction } from "matrix-js-sdk/lib/models/event-timeline.js"; import { VerificationMethod } from "matrix-js-sdk/lib/types.js"; import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/ssrf-dispatcher"; @@ -1078,7 +1079,9 @@ export class MatrixClient { relationType: string | null, eventType?: string | null, opts: { + dir?: Direction; from?: string; + limit?: number; } = {}, ): Promise { const result = await this.client.relations(roomId, eventId, relationType, eventType, opts); diff --git a/extensions/matrix/src/matrix/sdk/event-helpers.test.ts b/extensions/matrix/src/matrix/sdk/event-helpers.test.ts index 58b6c24800bf..6b259835ba7f 100644 --- a/extensions/matrix/src/matrix/sdk/event-helpers.test.ts +++ b/extensions/matrix/src/matrix/sdk/event-helpers.test.ts @@ -97,6 +97,59 @@ describe("event-helpers", () => { }); }); + it("preserves original thread relation when serializing edited current content", () => { + const event = { + getId: () => "$root", + getSender: () => "@alice:example.org", + getType: () => "m.room.message", + getTs: () => 1000, + getOriginalContent: () => ({ + body: "original", + msgtype: "m.text", + "m.relates_to": { + rel_type: "m.thread", + event_id: "$thread", + }, + }), + getContent: () => ({ + body: "@bot edited", + "m.mentions": { user_ids: ["@bot:example.org"] }, + msgtype: "m.text", + }), + getUnsigned: () => ({}), + } as unknown as MatrixEvent; + + expect(matrixEventToRaw(event).content["m.relates_to"]).toEqual({ + rel_type: "m.thread", + event_id: "$thread", + }); + }); + + it("preserves wire thread relation for decrypted encrypted events", () => { + const event = { + getId: () => "$encrypted", + getSender: () => "@alice:example.org", + getType: () => "m.room.message", + getTs: () => 1000, + getContent: () => ({ + body: "decrypted edit", + msgtype: "m.text", + }), + getUnsigned: () => ({}), + getWireContent: () => ({ + "m.relates_to": { + rel_type: "m.thread", + event_id: "$thread", + }, + }), + } as unknown as MatrixEvent; + + expect(matrixEventToRaw(event).content["m.relates_to"]).toEqual({ + rel_type: "m.thread", + event_id: "$thread", + }); + }); + it("can serialize original content for inbound trigger filtering", () => { expect(matrixEventToRaw(makeEditedMessageEvent(), { contentMode: "original" })).toEqual({ event_id: "$root", diff --git a/extensions/matrix/src/matrix/sdk/event-helpers.ts b/extensions/matrix/src/matrix/sdk/event-helpers.ts index 58e9b93280fa..61047096a2b3 100644 --- a/extensions/matrix/src/matrix/sdk/event-helpers.ts +++ b/extensions/matrix/src/matrix/sdk/event-helpers.ts @@ -19,12 +19,13 @@ export function matrixEventToRaw( opts.contentMode === "original" ? (eventWithOriginalContent.getOriginalContent?.() ?? event.getContent?.() ?? {}) : (event.getContent?.() ?? eventWithOriginalContent.getOriginalContent?.() ?? {}); + const normalizedContent = preserveMatrixRelation(event, content || {}); const raw: MatrixRawEvent = { event_id: event.getId() ?? "", sender: event.getSender() ?? "", type: event.getType() ?? "", origin_server_ts: event.getTs() ?? 0, - content: content || {}, + content: normalizedContent, unsigned, }; const stateKey = resolveMatrixStateKey(event); @@ -34,6 +35,39 @@ export function matrixEventToRaw( return raw; } +function preserveMatrixRelation( + event: MatrixEvent, + content: Record, +): Record { + if (Object.hasOwn(content, "m.relates_to")) { + return content; + } + const relation = resolveMatrixRelation(event); + return relation ? { ...content, "m.relates_to": relation } : content; +} + +function resolveMatrixRelation(event: MatrixEvent): unknown { + const originalContent = ( + event as { getOriginalContent?: () => Record | undefined } + ).getOriginalContent?.(); + const originalRelation = originalContent?.["m.relates_to"]; + if (originalRelation) { + return originalRelation; + } + const wireContent = ( + event as { getWireContent?: () => Record | undefined } + ).getWireContent?.(); + const wireRelation = wireContent?.["m.relates_to"]; + if (wireRelation) { + return wireRelation; + } + const rawContent = (event as { event?: { content?: unknown } }).event?.content; + if (rawContent && typeof rawContent === "object") { + return (rawContent as Record)["m.relates_to"]; + } + return undefined; +} + export function parseMxc(url: string): { server: string; mediaId: string } | null { const match = /^mxc:\/\/([^/]+)\/(.+)$/.exec(url.trim()); if (!match) { diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts index 067f350c5346..b26b6ddac70e 100644 --- a/extensions/matrix/src/matrix/send.test.ts +++ b/extensions/matrix/src/matrix/send.test.ts @@ -607,6 +607,34 @@ describe("sendMessageMatrix threads", () => { "m.relates_to"?: { rel_type?: string; event_id?: string; + is_falling_back?: boolean; + "m.in_reply_to"?: { event_id?: string }; + }; + }; + + expect(content["m.relates_to"]).toEqual({ + rel_type: "m.thread", + event_id: "$thread", + }); + expect(content["m.relates_to"]).not.toHaveProperty("is_falling_back"); + expect(content["m.relates_to"]).not.toHaveProperty("m.in_reply_to"); + }); + + it("includes thread fallback metadata only with an explicit reply target", async () => { + const { client, sendMessage } = makeClient(); + + await sendMessageMatrix("room:!room:example", "hello thread", { + client, + cfg: {} as never, + threadId: "$thread", + replyToId: "$reply", + }); + + const content = sentContent(sendMessage) as { + "m.relates_to"?: { + rel_type?: string; + event_id?: string; + is_falling_back?: boolean; "m.in_reply_to"?: { event_id?: string }; }; }; @@ -615,7 +643,7 @@ describe("sendMessageMatrix threads", () => { rel_type: "m.thread", event_id: "$thread", is_falling_back: true, - "m.in_reply_to": { event_id: "$thread" }, + "m.in_reply_to": { event_id: "$reply" }, }); }); @@ -913,6 +941,52 @@ describe("editMessageMatrix mentions", () => { expect(content[MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]).toBe(true); expect(newContent(content)[MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]).toBe(true); }); + + it("edits threaded originals with a pure replace relation", async () => { + const { client, getEvent, sendMessage } = makeClient(); + getEvent.mockResolvedValue({ + content: { + body: "before", + msgtype: "m.text", + "m.relates_to": { + rel_type: "m.thread", + event_id: "$thread", + }, + }, + }); + + await editMessageMatrix("room:!room:example", "$original", "done", { + client, + cfg: {} as never, + threadId: "$thread", + }); + + const content = sentContent(sendMessage); + expect(content["m.relates_to"]).toEqual({ + rel_type: "m.replace", + event_id: "$original", + }); + expect(newContent(content)).not.toHaveProperty("m.relates_to"); + }); + + it("rejects thread edits when the original event is not already in that thread", async () => { + const { client, getEvent, sendMessage } = makeClient(); + getEvent.mockResolvedValue({ + content: { + body: "before", + msgtype: "m.text", + }, + }); + + await expect( + editMessageMatrix("room:!room:example", "$original", "done", { + client, + cfg: {} as never, + threadId: "$thread", + }), + ).rejects.toThrow("cannot add or change the original event thread relation"); + expect(sendMessage).not.toHaveBeenCalled(); + }); }); describe("sendPollMatrix mentions", () => { diff --git a/extensions/matrix/src/matrix/send.ts b/extensions/matrix/src/matrix/send.ts index f4ecf69a153b..4e7495d5a9bf 100644 --- a/extensions/matrix/src/matrix/send.ts +++ b/extensions/matrix/src/matrix/send.ts @@ -127,6 +127,28 @@ function resolvePreviousEditContent(previousEvent: unknown): Record)["m.relates_to"]; + if (!relation || typeof relation !== "object") { + return undefined; + } + const relationRecord = relation as { event_id?: unknown; rel_type?: unknown }; + if ( + relationRecord.rel_type !== RelationType.Thread || + typeof relationRecord.event_id !== "string" + ) { + return undefined; + } + return normalizeThreadId(relationRecord.event_id) ?? undefined; +} + function hasMatrixMentionsMetadata(content: Record | undefined): boolean { return Boolean(content && Object.hasOwn(content, "m.mentions")); } @@ -585,6 +607,7 @@ export async function editMessageMatrix( markdown: convertedText, includeMentions: opts.includeMentions, }); + const previousEvent = await getPreviousMatrixEvent(client, resolvedRoom, originalEventId); const replaceMentions = opts.includeMentions === false ? undefined @@ -592,9 +615,7 @@ export async function editMessageMatrix( extractMatrixMentions(newContent), await resolvePreviousEditMentions({ client, - content: resolvePreviousEditContent( - await getPreviousMatrixEvent(client, resolvedRoom, originalEventId), - ), + content: resolvePreviousEditContent(previousEvent), }), ); @@ -604,9 +625,11 @@ export async function editMessageMatrix( }; const threadId = normalizeThreadId(opts.threadId); if (threadId) { - // Thread-aware replace: Synapse needs the thread context to keep the - // edited event visible in the thread timeline. - replaceRelation["m.in_reply_to"] = { event_id: threadId }; + // Matrix applies m.new_content while preserving the original relation. + // Edits can update threaded events, but cannot add or move thread membership. + if (resolvePreviousThreadId(previousEvent) !== threadId) { + throw new Error("Matrix edit cannot add or change the original event thread relation."); + } } // Spread newContent into the outer event so clients that don't support diff --git a/extensions/matrix/src/matrix/send/formatting.ts b/extensions/matrix/src/matrix/send/formatting.ts index 45de76552126..806b7d89b727 100644 --- a/extensions/matrix/src/matrix/send/formatting.ts +++ b/extensions/matrix/src/matrix/send/formatting.ts @@ -144,12 +144,16 @@ export function buildReplyRelation(replyToId?: string): MatrixReplyRelation | un export function buildThreadRelation(threadId: string, replyToId?: string): MatrixThreadRelation { const trimmed = threadId.trim(); - return { + const relation: MatrixThreadRelation = { rel_type: RelationType.Thread, event_id: trimmed, - is_falling_back: true, - "m.in_reply_to": { event_id: replyToId?.trim() || trimmed }, }; + const fallbackReplyToId = replyToId?.trim(); + if (fallbackReplyToId) { + relation.is_falling_back = true; + relation["m.in_reply_to"] = { event_id: fallbackReplyToId }; + } + return relation; } export function resolveMatrixMsgType(contentType?: string, _fileName?: string): MatrixMediaMsgType { diff --git a/extensions/matrix/src/tool-actions.ts b/extensions/matrix/src/tool-actions.ts index d97028bc5b36..cd4385dd6d74 100644 --- a/extensions/matrix/src/tool-actions.ts +++ b/extensions/matrix/src/tool-actions.ts @@ -272,10 +272,12 @@ export async function handleMatrixAction( }); const before = readStringParam(params, "before"); const after = readStringParam(params, "after"); + const threadId = readStringParam(params, "threadId"); const result = await readMatrixMessages(roomId, { limit: limit ?? undefined, before: before ?? undefined, after: after ?? undefined, + threadId: threadId ?? undefined, ...clientOpts, }); return jsonResult({ ok: true, ...result }); diff --git a/extensions/qa-matrix/src/runners/contract/scenario-catalog.ts b/extensions/qa-matrix/src/runners/contract/scenario-catalog.ts index 19d5d350a67f..fcbc39925984 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-catalog.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-catalog.ts @@ -32,6 +32,7 @@ type MatrixQaScenarioId = | "matrix-room-image-understanding-attachment" | "matrix-room-generated-image-delivery" | "matrix-media-type-coverage" + | "matrix-voice-preflight-mention" | "matrix-attachment-only-ignored" | "matrix-unsupported-media-safe" | "matrix-dm-reply-shape" @@ -497,6 +498,19 @@ export const MATRIX_QA_SCENARIOS: MatrixQaScenarioDefinition[] = [ title: "Matrix media attachments cover image, audio, video, PDF, and EPUB transport", topology: MATRIX_QA_MEDIA_ROOM_TOPOLOGY, }, + { + id: "matrix-voice-preflight-mention", + configOverrides: { + audio: { + enabled: true, + }, + groupMentionPatterns: ["\\S"], + }, + providerMode: "live-frontier", + timeoutMs: 180_000, + title: "Matrix voice notes can trigger mention gating through transcription", + topology: MATRIX_QA_MEDIA_ROOM_TOPOLOGY, + }, { id: "matrix-attachment-only-ignored", timeoutMs: 8_000, @@ -1225,6 +1239,7 @@ const MATRIX_QA_MEDIA_PROFILE_SCENARIO_IDS = [ "matrix-room-image-understanding-attachment", "matrix-room-generated-image-delivery", "matrix-media-type-coverage", + "matrix-voice-preflight-mention", "matrix-attachment-only-ignored", "matrix-unsupported-media-safe", "matrix-e2ee-media-image", diff --git a/extensions/qa-matrix/src/runners/contract/scenario-media-fixtures.ts b/extensions/qa-matrix/src/runners/contract/scenario-media-fixtures.ts index f4b3f73af370..08e9291aca84 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-media-fixtures.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-media-fixtures.ts @@ -17,6 +17,10 @@ const MATRIX_QA_SPLIT_COLOR_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAHElEQVR4nGP4z8DwnxLMMGrAsDCAQv2jBgwPAwAxtf4Q24P5oAAAAABJRU5ErkJggg=="; const MATRIX_QA_SPLIT_COLOR_JPEG_BASE64 = "/9j/4AAQSkZJRgABAQAASABIAAD/4QBMRXhpZgAATU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAEKADAAQAAAABAAAAEAAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgAEAAQAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAgICAgICAwICAwUDAwMFBgUFBQUGCAYGBgYGCAoICAgICAgKCgoKCgoKCgwMDAwMDA4ODg4ODw8PDw8PDw8PD//bAEMBAgICBAQEBwQEBxALCQsQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEP/dAAQAAf/aAAwDAQACEQMRAD8A+L6K+Q6K/qj/AIpl/wDU/wD/AC2/++D+1P8AioZ/1JP/AC4/+4H/2Q=="; +const MATRIX_QA_VOICE_PREFLIGHT_WAV_BASE64 = + "UklGRtbTAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAARkxMUswPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA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"; +export const MATRIX_QA_VOICE_PREFLIGHT_FILENAME = "matrix-qa-voice-preflight.wav"; +export const MATRIX_QA_VOICE_PREFLIGHT_REPLY_MARKER = "MATRIX QA VOICE PREFLIGHT OK"; export function createMatrixQaSplitColorImagePng() { return Buffer.from(MATRIX_QA_SPLIT_COLOR_PNG_BASE64, "base64"); @@ -68,6 +72,10 @@ function createMatrixQaMp4Fixture() { ]); } +export function createMatrixQaVoicePreflightWav() { + return Buffer.from(MATRIX_QA_VOICE_PREFLIGHT_WAV_BASE64, "base64"); +} + export const MATRIX_QA_MEDIA_TYPE_COVERAGE_CASES: MatrixQaMediaTypeCoverageCase[] = [ { contentType: "image/jpeg", diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime-media.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime-media.ts index 5dfdeb69f24f..48bab417f395 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime-media.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-media.ts @@ -4,10 +4,13 @@ import { MATRIX_QA_MEDIA_ROOM_KEY, resolveMatrixQaScenarioRoomId } from "./scena import { buildMatrixQaImageGenerationPrompt, buildMatrixQaImageUnderstandingPrompt, + createMatrixQaVoicePreflightWav, createMatrixQaSplitColorImagePng, hasMatrixQaExpectedColorReply, MATRIX_QA_IMAGE_ATTACHMENT_FILENAME, MATRIX_QA_MEDIA_TYPE_COVERAGE_CASES, + MATRIX_QA_VOICE_PREFLIGHT_FILENAME, + MATRIX_QA_VOICE_PREFLIGHT_REPLY_MARKER, } from "./scenario-media-fixtures.js"; import { advanceMatrixQaActorCursor, @@ -63,6 +66,17 @@ function buildMatrixQaMediaTypeCoveragePrompt(params: { return `${params.sutUserId} Matrix media type coverage (${params.label}): ignore the attachment content and reply with only this exact marker: ${params.token}`; } +function normalizeMatrixQaVoiceReply(value: string | undefined) { + return (value ?? "") + .toUpperCase() + .replace(/[^A-Z0-9]+/g, " ") + .trim(); +} + +function hasMatrixQaVoicePreflightReply(body: string | undefined) { + return normalizeMatrixQaVoiceReply(body).includes(MATRIX_QA_VOICE_PREFLIGHT_REPLY_MARKER); +} + export async function runImageUnderstandingAttachmentScenario(context: MatrixQaScenarioContext) { const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_MEDIA_ROOM_KEY); const { client, startSince } = await primeMatrixQaDriverMediaClient(context); @@ -217,6 +231,67 @@ export async function runMediaTypeCoverageScenario(context: MatrixQaScenarioCont } satisfies MatrixQaScenarioExecution; } +export async function runVoicePreflightMentionScenario(context: MatrixQaScenarioContext) { + const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_MEDIA_ROOM_KEY); + const { client, startSince } = await primeMatrixQaDriverMediaClient(context); + const driverEventId = await client.sendMediaMessage({ + buffer: createMatrixQaVoicePreflightWav(), + contentType: "audio/wav", + fileName: MATRIX_QA_VOICE_PREFLIGHT_FILENAME, + kind: "audio", + roomId, + }); + const attachmentEvent = await client.waitForRoomEvent({ + observedEvents: context.observedEvents, + predicate: (event) => + event.roomId === roomId && + event.eventId === driverEventId && + event.sender === context.driverUserId && + event.msgtype === "m.audio" && + event.attachment?.kind === "audio" && + event.attachment.filename === MATRIX_QA_VOICE_PREFLIGHT_FILENAME && + event.attachment.caption === undefined, + roomId, + since: startSince, + timeoutMs: context.timeoutMs, + }); + const matched = await client.waitForRoomEvent({ + observedEvents: context.observedEvents, + predicate: (event) => + event.roomId === roomId && + event.sender === context.sutUserId && + event.type === "m.room.message" && + event.relatesTo === undefined && + isMatrixQaMessageLikeKind(event.kind) && + hasMatrixQaVoicePreflightReply(event.body), + roomId, + since: attachmentEvent.since, + timeoutMs: context.timeoutMs, + }); + advanceMatrixQaActorCursor({ + actorId: "driver", + syncState: context.syncState, + nextSince: matched.since, + startSince, + }); + const reply = buildMatrixReplyArtifact(matched.event, MATRIX_QA_VOICE_PREFLIGHT_REPLY_MARKER); + return { + artifacts: { + attachmentFilename: MATRIX_QA_VOICE_PREFLIGHT_FILENAME, + driverEventId, + reply, + roomId, + expectedMarker: MATRIX_QA_VOICE_PREFLIGHT_REPLY_MARKER, + }, + details: [ + `room id: ${roomId}`, + `driver voice event: ${driverEventId}`, + `voice filename: ${MATRIX_QA_VOICE_PREFLIGHT_FILENAME}`, + ...buildMatrixReplyDetails("reply", reply), + ].join("\n"), + } satisfies MatrixQaScenarioExecution; +} + export async function runAttachmentOnlyIgnoredScenario(context: MatrixQaScenarioContext) { const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_MEDIA_ROOM_KEY); const { client, startSince } = await primeMatrixQaDriverMediaClient(context); diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime.ts index 2fe872751666..024646f775f7 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime.ts @@ -77,6 +77,7 @@ import { runImageUnderstandingAttachmentScenario, runMediaTypeCoverageScenario, runUnsupportedMediaSafeScenario, + runVoicePreflightMentionScenario, } from "./scenario-runtime-media.js"; import { runReactionNotAReplyScenario, @@ -248,6 +249,8 @@ export async function runMatrixQaScenario( return await runGeneratedImageDeliveryScenario(context); case "matrix-media-type-coverage": return await runMediaTypeCoverageScenario(context); + case "matrix-voice-preflight-mention": + return await runVoicePreflightMentionScenario(context); case "matrix-attachment-only-ignored": return await runAttachmentOnlyIgnoredScenario(context); case "matrix-unsupported-media-safe": diff --git a/extensions/qa-matrix/src/runners/contract/scenario-types.ts b/extensions/qa-matrix/src/runners/contract/scenario-types.ts index 0c4096163e3b..ae24beb9daa3 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-types.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-types.ts @@ -55,6 +55,7 @@ export type MatrixQaScenarioArtifacts = { editEventId?: string; editedToken?: string; expectedNoReplyWindowMs?: number; + expectedMarker?: string; firstDriverEventId?: string; firstReply?: MatrixQaReplyArtifact; firstToken?: string; diff --git a/extensions/qa-matrix/src/runners/contract/scenarios.test.ts b/extensions/qa-matrix/src/runners/contract/scenarios.test.ts index 314630eab1a6..4a5f2c0a9f52 100644 --- a/extensions/qa-matrix/src/runners/contract/scenarios.test.ts +++ b/extensions/qa-matrix/src/runners/contract/scenarios.test.ts @@ -50,7 +50,11 @@ import { findMissingLiveTransportStandardScenarios, } from "../../shared/live-transport-scenarios.js"; import type { MatrixQaObservedEvent } from "../../substrate/events.js"; -import { MATRIX_QA_MEDIA_TYPE_COVERAGE_CASES } from "./scenario-media-fixtures.js"; +import { + MATRIX_QA_MEDIA_TYPE_COVERAGE_CASES, + MATRIX_QA_VOICE_PREFLIGHT_FILENAME, + MATRIX_QA_VOICE_PREFLIGHT_REPLY_MARKER, +} from "./scenario-media-fixtures.js"; import { testing as scenarioTesting, MATRIX_QA_SCENARIOS, @@ -383,6 +387,7 @@ describe("matrix live qa scenarios", () => { "matrix-room-image-understanding-attachment", "matrix-room-generated-image-delivery", "matrix-media-type-coverage", + "matrix-voice-preflight-mention", "matrix-attachment-only-ignored", "matrix-unsupported-media-safe", "matrix-dm-reply-shape", @@ -4568,6 +4573,129 @@ describe("matrix live qa scenarios", () => { ).toBe(true); }); + it("sends voice preflight audio without a text mention and waits for the transcribed reply", async () => { + const primeRoom = vi.fn().mockResolvedValue("driver-sync-start"); + const sendMediaMessage = vi.fn().mockResolvedValue("$voice-preflight"); + const waitForRoomEvent = vi.fn().mockImplementation(async () => { + const callIndex = waitForRoomEvent.mock.calls.length - 1; + if (callIndex === 0) { + return { + event: { + kind: "message", + roomId: "!media:matrix-qa.test", + eventId: "$voice-preflight", + sender: "@driver:matrix-qa.test", + type: "m.room.message", + msgtype: "m.audio", + attachment: { + kind: "audio", + filename: MATRIX_QA_VOICE_PREFLIGHT_FILENAME, + }, + }, + since: "driver-sync-attachment", + }; + } + return { + event: { + kind: "message", + roomId: "!media:matrix-qa.test", + eventId: "$voice-reply", + sender: "@sut:matrix-qa.test", + type: "m.room.message", + body: `Sure: ${MATRIX_QA_VOICE_PREFLIGHT_REPLY_MARKER}.`, + }, + since: "driver-sync-reply", + }; + }); + + createMatrixQaClient.mockReturnValue({ + primeRoom, + sendMediaMessage, + waitForRoomEvent, + }); + + const scenario = requireMatrixQaScenario("matrix-voice-preflight-mention"); + expect(scenario.configOverrides?.audio?.enabled).toBe(true); + expect(scenario.configOverrides?.groupMentionPatterns).toEqual(["\\S"]); + + const result = await runMatrixQaScenario(scenario, { + baseUrl: "http://127.0.0.1:28008/", + canary: undefined, + driverAccessToken: "driver-token", + driverUserId: "@driver:matrix-qa.test", + observedEvents: [], + observerAccessToken: "observer-token", + observerUserId: "@observer:matrix-qa.test", + roomId: "!main:matrix-qa.test", + restartGateway: undefined, + syncState: {}, + sutAccessToken: "sut-token", + sutUserId: "@sut:matrix-qa.test", + timeoutMs: 8_000, + topology: { + defaultRoomId: "!main:matrix-qa.test", + defaultRoomKey: "main", + rooms: [ + { + key: scenarioTesting.MATRIX_QA_MEDIA_ROOM_KEY, + kind: "group", + memberRoles: ["driver", "observer", "sut"], + memberUserIds: [ + "@driver:matrix-qa.test", + "@observer:matrix-qa.test", + "@sut:matrix-qa.test", + ], + name: "Media", + requireMention: true, + roomId: "!media:matrix-qa.test", + }, + ], + }, + }); + + const mediaMessage = mockObjectArg(sendMediaMessage, "sendMediaMessage") as { + body?: unknown; + buffer?: Buffer; + contentType?: unknown; + fileName?: unknown; + kind?: unknown; + mentionUserIds?: unknown; + roomId?: unknown; + }; + expect(mediaMessage.body).toBeUndefined(); + expect(mediaMessage.buffer?.byteLength).toBeGreaterThan(1_000); + expect(mediaMessage.contentType).toBe("audio/wav"); + expect(mediaMessage.fileName).toBe(MATRIX_QA_VOICE_PREFLIGHT_FILENAME); + expect(mediaMessage.kind).toBe("audio"); + expect(mediaMessage.mentionUserIds).toBeUndefined(); + expect(mediaMessage.roomId).toBe("!media:matrix-qa.test"); + + const replyWait = mockObjectArg(waitForRoomEvent, "waitForRoomEvent", 1) as { + predicate: (event: MatrixQaObservedEvent) => boolean; + }; + expect( + replyWait.predicate({ + kind: "message", + roomId: "!media:matrix-qa.test", + eventId: "$voice-reply", + sender: "@sut:matrix-qa.test", + type: "m.room.message", + body: ` ${MATRIX_QA_VOICE_PREFLIGHT_REPLY_MARKER.toLowerCase()}!\n`, + }), + ).toBe(true); + + const artifacts = result.artifacts as { + attachmentFilename?: unknown; + driverEventId?: unknown; + expectedMarker?: unknown; + reply?: { eventId?: unknown }; + }; + expect(artifacts.attachmentFilename).toBe(MATRIX_QA_VOICE_PREFLIGHT_FILENAME); + expect(artifacts.driverEventId).toBe("$voice-preflight"); + expect(artifacts.expectedMarker).toBe(MATRIX_QA_VOICE_PREFLIGHT_REPLY_MARKER); + expect(artifacts.reply?.eventId).toBe("$voice-reply"); + }); + it("uses DM thread override scenarios against the provisioned DM room", async () => { const primeRoom = vi.fn().mockResolvedValue("driver-sync-start"); const sendTextMessage = vi.fn().mockResolvedValue("$dm-thread-trigger"); diff --git a/extensions/qa-matrix/src/substrate/config.test.ts b/extensions/qa-matrix/src/substrate/config.test.ts index e83c322328e8..03ee7762ceea 100644 --- a/extensions/qa-matrix/src/substrate/config.test.ts +++ b/extensions/qa-matrix/src/substrate/config.test.ts @@ -110,6 +110,7 @@ describe("matrix qa config", () => { allowBots: "mentions", configuredBotRoles: ["observer"], groupAllowFrom: ["@driver:matrix-qa.test", "@observer:matrix-qa.test"], + groupMentionPatterns: ["\\S"], groupsByKey: { secondary: { allowBots: false, @@ -127,6 +128,10 @@ describe("matrix qa config", () => { spawnSessions: true, }, threadReplies: "always", + audio: { + echoTranscript: false, + enabled: true, + }, toolProfile: "coding", }, observerAccessToken: "observer-token", @@ -147,6 +152,11 @@ describe("matrix qa config", () => { minChars: 1, }); expect(next.tools?.profile).toBe("coding"); + expect(next.tools?.media?.audio).toEqual({ + echoTranscript: false, + enabled: true, + }); + expect(next.messages?.groupChat?.mentionPatterns).toEqual(["\\S"]); const observer = next.channels?.matrix?.accounts?.["qa-observer-bot-source"]; expect(observer?.accessToken).toBe("observer-token"); expect(observer?.enabled).toBe(false); @@ -227,6 +237,7 @@ describe("matrix qa config", () => { dm: { sessionScope: "per-room", }, + groupMentionPatterns: ["\\S"], groupPolicy: "open", streaming: true, }, @@ -255,6 +266,7 @@ describe("matrix qa config", () => { execApprovals: undefined, configuredBotRoles: [], groupAllowFrom: ["@driver:matrix-qa.test"], + groupMentionPatterns: ["\\S"], groupPolicy: "open", groupsByKey: { main: { @@ -277,6 +289,7 @@ describe("matrix qa config", () => { }); expect(summarizeMatrixQaConfigSnapshot(snapshot)).toContain("allowBots="); expect(summarizeMatrixQaConfigSnapshot(snapshot)).toContain("configuredBotRoles="); + expect(summarizeMatrixQaConfigSnapshot(snapshot)).toContain("groupMentionPatterns=\\S"); expect(summarizeMatrixQaConfigSnapshot(snapshot)).toContain("autoJoin=allowlist"); expect(summarizeMatrixQaConfigSnapshot(snapshot)).toContain("streaming=partial"); expect(summarizeMatrixQaConfigSnapshot(snapshot)).toContain( diff --git a/extensions/qa-matrix/src/substrate/config.ts b/extensions/qa-matrix/src/substrate/config.ts index ae935e98d922..8fcad6604b38 100644 --- a/extensions/qa-matrix/src/substrate/config.ts +++ b/extensions/qa-matrix/src/substrate/config.ts @@ -40,6 +40,10 @@ type MatrixQaToolConfigOverrides = { deny?: string[]; }; +type MatrixQaAudioConfigOverrides = NonNullable< + NonNullable["media"]>["audio"] +>; + type MatrixQaGroupConfigOverrides = { allowBots?: MatrixQaAllowBotsMode; enabled?: boolean; @@ -91,6 +95,7 @@ export type MatrixQaConfigOverrides = { execApprovals?: MatrixQaExecApprovalsConfigOverrides; groupAllowFrom?: string[]; groupAllowRoles?: MatrixQaActorRole[]; + groupMentionPatterns?: string[]; groupPolicy?: MatrixQaGroupPolicy; configuredBotRoles?: MatrixQaActorRole[]; groupsByKey?: Record; @@ -100,6 +105,7 @@ export type MatrixQaConfigOverrides = { textChunkLimit?: number; threadBindings?: MatrixQaThreadBindingsConfigOverrides; threadReplies?: MatrixQaThreadRepliesMode; + audio?: MatrixQaAudioConfigOverrides; toolProfile?: "coding" | "messaging" | "minimal"; }; @@ -124,6 +130,7 @@ export type MatrixQaConfigSnapshot = { execApprovals?: MatrixQaExecApprovalsConfigOverrides; configuredBotRoles: MatrixQaActorRole[]; groupAllowFrom: string[]; + groupMentionPatterns: string[]; groupPolicy: MatrixQaGroupPolicy; groupsByKey: Record; replyToMode: MatrixQaReplyToMode; @@ -507,6 +514,7 @@ export function buildMatrixQaConfigSnapshot(params: { execApprovals: params.overrides?.execApprovals, configuredBotRoles: [...(params.overrides?.configuredBotRoles ?? [])], groupAllowFrom: resolveMatrixQaGroupAllowFrom(params), + groupMentionPatterns: normalizeMatrixQaAllowlist(params.overrides?.groupMentionPatterns), groupPolicy: params.overrides?.groupPolicy ?? "allowlist", groupsByKey: resolveMatrixQaGroupSnapshots({ overrides: params.overrides, @@ -539,6 +547,7 @@ export function summarizeMatrixQaConfigSnapshot(snapshot: MatrixQaConfigSnapshot `dm.policy=${snapshot.dm.policy}`, `dm.sessionScope=${snapshot.dm.sessionScope}`, `dm.threadReplies=${snapshot.dm.threadReplies}`, + `groupMentionPatterns=${snapshot.groupMentionPatterns.length > 0 ? snapshot.groupMentionPatterns.join("|") : ""}`, `streaming=${snapshot.streaming}`, `streaming.preview.toolProgress=${formatMatrixQaBoolean(snapshot.streamingPreviewToolProgress)}`, `textChunkLimit=${snapshot.textChunkLimit ?? ""}`, @@ -616,15 +625,35 @@ export function buildMatrixQaConfig( } : {}; + const toolsConfig = + params.overrides?.toolProfile || params.overrides?.audio + ? { + ...baseCfg.tools, + ...(params.overrides?.toolProfile + ? { + profile: params.overrides.toolProfile, + } + : {}), + ...(params.overrides?.audio + ? { + media: { + ...baseCfg.tools?.media, + audio: { + ...baseCfg.tools?.media?.audio, + ...params.overrides.audio, + }, + }, + } + : {}), + } + : undefined; + return { ...baseCfg, ...approvalForwardingConfig, - ...(params.overrides?.toolProfile + ...(toolsConfig ? { - tools: { - ...baseCfg.tools, - profile: params.overrides.toolProfile, - }, + tools: toolsConfig, } : {}), ...(params.overrides?.agentDefaults @@ -650,6 +679,9 @@ export function buildMatrixQaConfig( ...baseCfg.messages, groupChat: { ...baseCfg.messages?.groupChat, + ...(snapshot.groupMentionPatterns.length > 0 + ? { mentionPatterns: snapshot.groupMentionPatterns } + : {}), visibleReplies: "automatic", }, }, diff --git a/src/commands/migrate/skill-selection-prompt.ts b/src/commands/migrate/skill-selection-prompt.ts index c8ac3370b9d7..4627d7030442 100644 --- a/src/commands/migrate/skill-selection-prompt.ts +++ b/src/commands/migrate/skill-selection-prompt.ts @@ -256,5 +256,9 @@ export function promptMigrationSkillSelectionValues( return prompt.prompt(); } -/** Compatibility alias for plugin selection prompts that share the same picker. */ +/** + * Compatibility alias for plugin selection prompts that share the same picker. + * + * @deprecated Use promptMigrationSkillSelectionValues. + */ export const promptMigrationSelectionValues = promptMigrationSkillSelectionValues;