diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index 1925448fd5fa..ea3d803419d7 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -53,6 +53,15 @@ const resolveMatrixMentionsForBodyMock = vi.hoisted(() => }; }), ); +const getGlobalHookRunnerMock = vi.hoisted(() => vi.fn()); + +vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getGlobalHookRunner: getGlobalHookRunnerMock, + }; +}); vi.mock("../send.js", () => ({ editMessageMatrix: editMessageMatrixMock, @@ -116,6 +125,7 @@ async function writeMatrixSessionMeta( beforeEach(() => { sessionBindingTesting.resetSessionBindingAdaptersForTests(); installMatrixMonitorTestRuntime(); + getGlobalHookRunnerMock.mockReset().mockReturnValue(null); prepareMatrixSingleTextMock.mockReset().mockImplementation((text: string) => { const trimmedText = text.trim(); return { @@ -2988,6 +2998,53 @@ describe("matrix monitor handler draft streaming", () => { await finish(); }); + it("preserves provider previews for observer-only hooks", async () => { + getGlobalHookRunnerMock.mockReturnValue({ + hasHooks: vi.fn((hookName: string) => hookName === "message_sent"), + }); + const { dispatch } = createStreamingHarness({ streaming: "partial" }); + const { deliver, opts, finish } = await dispatch(); + + opts.onPartialReply?.({ text: "Visible preview" }); + await waitForMatrixState(() => { + expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1); + }); + await deliver({ text: "Visible preview" }, { kind: "final" }); + + expectEditLiveFlag("$draft1", "Visible preview", false); + expect(deliverMatrixRepliesMock).not.toHaveBeenCalled(); + await finish(); + }); + + it.each([ + { label: "reply_payload_sending", hooks: ["reply_payload_sending"] }, + { label: "message_sending", hooks: ["message_sending"] }, + { + label: "both modifying hooks", + hooks: ["reply_payload_sending", "message_sending"], + }, + ])("suppresses provider previews when $label is registered", async ({ hooks }) => { + const registered = new Set(hooks); + getGlobalHookRunnerMock.mockReturnValue({ + hasHooks: vi.fn((hookName: string) => registered.has(hookName)), + }); + const { dispatch } = createStreamingHarness({ + previewToolProgressEnabled: true, + streaming: "progress", + }); + const { deliver, opts, finish } = await dispatch(); + + expect(opts.onPartialReply).toBeUndefined(); + expect(opts.onToolStart).toBeUndefined(); + expect(opts.suppressDefaultToolProgressMessages).toBeUndefined(); + await deliver({ text: "Durable final" }, { kind: "final" }); + + expect(sendSingleTextMessageMatrixMock).not.toHaveBeenCalled(); + expect(editMessageMatrixMock).not.toHaveBeenCalled(); + expect(deliverMatrixRepliesMock).toHaveBeenCalledTimes(1); + await finish(); + }); + it("streams tool progress into the Matrix draft preview when enabled", async () => { const { dispatch, redactEventMock } = createStreamingHarness({ previewToolProgressEnabled: true, diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 4349b7ef3801..91975b5d0b76 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -5,6 +5,7 @@ import { } from "openclaw/plugin-sdk/channel-inbound"; import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/context-visibility-runtime"; import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; +import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime"; import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing"; import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime"; import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; @@ -388,9 +389,16 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam }); }, }); + // Matrix drafts are provider-visible before outbound modifiers run. Keep them off when a + // hook can rewrite or cancel so the original payload cannot escape the delivery gate. + const hookRunner = getGlobalHookRunner(); + const allowProviderPreview = !( + (hookRunner?.hasHooks("reply_payload_sending") ?? false) || + (hookRunner?.hasHooks("message_sending") ?? false) + ); const draftController = await createMatrixDraftController({ - streaming, - previewToolProgressEnabled, + streaming: allowProviderPreview ? streaming : "off", + previewToolProgressEnabled: allowProviderPreview && previewToolProgressEnabled, replyToMode, messageId, threadTarget,