fix(matrix): mark finalized preview edits

This commit is contained in:
Gustavo Madeira Santana
2026-04-05 14:23:49 -04:00
parent 1a441f0388
commit 9c8180e233
6 changed files with 184 additions and 33 deletions

View File

@@ -196,7 +196,7 @@ done:
- `streaming: "partial"` creates one editable preview message for the current assistant block instead of sending multiple partial messages.
- `blockStreaming: true` enables separate Matrix progress messages. With `streaming: "partial"`, Matrix keeps the live draft for the current block and preserves completed blocks as separate messages.
- When `streaming: "partial"` and `blockStreaming` is off, Matrix only edits the live draft and sends the completed reply once that block or turn finishes.
- Draft preview events use quiet Matrix notices, so notifications fire on completed blocks or the final completed reply instead of the first streamed token.
- Draft preview events use quiet Matrix notices. On stock Matrix push rules, notice previews and later edit events are both non-notifying.
- If the preview no longer fits in one Matrix event, OpenClaw stops preview streaming and falls back to normal final delivery.
- Media replies still send attachments normally. If a stale preview can no longer be reused safely, OpenClaw redacts it before sending the final media reply.
- Preview edits cost extra Matrix API calls. Leave streaming off if you want the most conservative rate-limit behavior.
@@ -204,6 +204,60 @@ done:
`blockStreaming` does not enable draft previews by itself.
Use `streaming: "partial"` for preview edits; then add `blockStreaming: true` only if you also want completed assistant blocks to remain visible as separate progress messages.
If you need notifications without custom Matrix push rules, leave `streaming` off. Then:
- `blockStreaming: true` sends each finished block as a normal notifying Matrix message.
- `blockStreaming: false` sends only the final completed reply as a normal notifying Matrix message.
### Self-hosted push rules for finalized previews
If you run your own Matrix infrastructure and want `streaming: "partial"` previews to notify only when a
block or final reply is done, add a per-user push rule for finalized preview edits.
OpenClaw marks finalized text-only preview edits with:
```json
{
"com.openclaw.finalized_preview": true
}
```
Create an override push rule for each recipient account which should receive these notifications:
```bash
curl -X PUT \
"https://matrix.example.org/_matrix/client/v3/pushrules/global/override/openclaw-finalized-preview" \
-H "Authorization: Bearer $USER_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
--data '{
"conditions": [
{ "kind": "event_match", "key": "type", "pattern": "m.room.message" },
{
"kind": "event_property_is",
"key": "content.m\\.relates_to.rel_type",
"value": "m.replace"
},
{
"kind": "event_property_is",
"key": "content.com\\.openclaw\\.finalized_preview",
"value": true
},
{ "kind": "event_match", "key": "sender", "pattern": "@bot:example.org" }
],
"actions": [
"notify",
{ "set_tweak": "sound", "value": "default" },
{ "set_tweak": "highlight", "value": false }
]
}'
```
Notes:
- Create the rule with the receiving user's access token, not the bot's.
- New user-defined `override` rules are inserted ahead of default suppress rules, so no extra ordering parameter is needed.
- This only affects text-only preview edits that OpenClaw can safely finalize in place. Media fallbacks and stale-preview fallbacks still use normal Matrix delivery.
## Encryption and verification
In encrypted (E2EE) rooms, outbound image events use `thumbnail_file` so image previews are encrypted alongside the full attachment. Unencrypted rooms still use plain `thumbnail_url`. No configuration is needed — the plugin detects E2EE state automatically.

View File

@@ -8,6 +8,7 @@ import {
} from "openclaw/plugin-sdk/conversation-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { installMatrixMonitorTestRuntime } from "../../test-runtime.js";
import { MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY } from "../send/types.js";
import { createMatrixRoomMessageHandler } from "./handler.js";
import {
createMatrixHandlerTestHarness,
@@ -2080,6 +2081,14 @@ describe("matrix monitor handler draft streaming", () => {
await deliver({ text: "Single block" }, { kind: "final" });
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
expect(editMessageMatrixMock).toHaveBeenCalledWith(
"!room:example.org",
"$draft1",
"Single block",
expect.objectContaining({
extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true },
}),
);
expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
expect(redactEventMock).not.toHaveBeenCalled();
await finish();
@@ -2097,6 +2106,14 @@ describe("matrix monitor handler draft streaming", () => {
deliverMatrixRepliesMock.mockClear();
await deliver({ text: "Block one" }, { kind: "block" });
expect(editMessageMatrixMock).toHaveBeenCalledWith(
"!room:example.org",
"$draft1",
"Block one",
expect.objectContaining({
extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true },
}),
);
expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
expect(redactEventMock).not.toHaveBeenCalled();
@@ -2112,6 +2129,14 @@ describe("matrix monitor handler draft streaming", () => {
await deliver({ text: "Block two" }, { kind: "final" });
expect(editMessageMatrixMock).toHaveBeenCalledWith(
"!room:example.org",
"$draft2",
"Block two",
expect.objectContaining({
extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true },
}),
);
expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
expect(redactEventMock).not.toHaveBeenCalled();
await finish();
@@ -2400,7 +2425,14 @@ describe("matrix monitor handler draft streaming", () => {
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
});
expect(sendSingleTextMessageMatrixMock.mock.calls[0]?.[1]).toBe("Beta");
expect(editMessageMatrixMock).not.toHaveBeenCalled();
expect(editMessageMatrixMock).toHaveBeenCalledWith(
"!room:example.org",
"$draft1",
"Alpha",
expect.objectContaining({
extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true },
}),
);
sendSingleTextMessageMatrixMock.mockClear();
editMessageMatrixMock.mockClear();
@@ -2414,7 +2446,14 @@ describe("matrix monitor handler draft streaming", () => {
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
});
expect(sendSingleTextMessageMatrixMock.mock.calls[0]?.[1]).toBe("Gamma");
expect(editMessageMatrixMock).not.toHaveBeenCalled();
expect(editMessageMatrixMock).toHaveBeenCalledWith(
"!room:example.org",
"$draft2",
"Beta",
expect.objectContaining({
extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true },
}),
);
await finish();
});

View File

@@ -32,6 +32,7 @@ import {
sendTypingMatrix,
} from "../send.js";
import { resolveMatrixStoredSessionMeta } from "../session-store-metadata.js";
import { MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY } from "../send/types.js";
import { resolveMatrixMonitorAccessState } from "./access-state.js";
import { resolveMatrixAckReactionConfig } from "./ack-config.js";
import { resolveMatrixAllowListMatch } from "./allowlist.js";
@@ -84,6 +85,10 @@ async function redactMatrixDraftEvent(
await client.redactEvent(roomId, draftEventId).catch(() => {});
}
function buildMatrixFinalizedPreviewContent(): Record<string, unknown> {
return { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true };
}
export type MatrixMonitorHandlerParams = {
client: MatrixClient;
core: PluginRuntime;
@@ -1421,30 +1426,29 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
!payloadReplyMismatch &&
!mustDeliverFinalNormally
) {
if (payload.text !== draftStream.lastSentText()) {
try {
await editMessageMatrix(roomId, draftEventId, payload.text, {
client,
cfg,
threadId: threadTarget,
accountId: _route.accountId,
});
} catch {
await redactMatrixDraftEvent(client, roomId, draftEventId);
await deliverMatrixReplies({
cfg,
replies: [payload],
roomId,
client,
runtime,
textLimit,
replyToMode,
threadId: threadTarget,
accountId: _route.accountId,
mediaLocalRoots,
tableMode,
});
}
try {
await editMessageMatrix(roomId, draftEventId, payload.text, {
client,
cfg,
threadId: threadTarget,
accountId: _route.accountId,
extraContent: buildMatrixFinalizedPreviewContent(),
});
} catch {
await redactMatrixDraftEvent(client, roomId, draftEventId);
await deliverMatrixReplies({
cfg,
replies: [payload],
roomId,
client,
runtime,
textLimit,
replyToMode,
threadId: threadTarget,
accountId: _route.accountId,
mediaLocalRoots,
tableMode,
});
}
draftConsumed = true;
} else if (draftEventId && hasMedia && !payloadReplyMismatch) {

View File

@@ -9,6 +9,7 @@ import {
sendSingleTextMessageMatrix,
sendTypingMatrix,
} from "./send.js";
import { MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY } from "./send/types.js";
const loadOutboundMediaFromUrlMock = vi.hoisted(() => vi.fn());
const loadWebMediaMock = vi.fn().mockResolvedValue({
@@ -621,6 +622,20 @@ describe("sendSingleTextMessageMatrix", () => {
(sendMessage.mock.calls[0]?.[1] as { formatted_body?: string }).formatted_body,
).not.toContain("matrix.to");
});
it("merges extra content fields into single-event sends", async () => {
const { client, sendMessage } = makeClient();
await sendSingleTextMessageMatrix("room:!room:example", "done", {
client,
extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true },
});
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
body: "done",
[MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true,
});
});
});
describe("editMessageMatrix mentions", () => {
@@ -731,6 +746,22 @@ describe("editMessageMatrix mentions", () => {
)["m.new_content"]?.formatted_body,
).not.toContain("matrix.to");
});
it("merges extra content fields into edit payloads and m.new_content", async () => {
const { client, sendMessage } = makeClient();
await editMessageMatrix("room:!room:example", "$original", "done", {
client,
extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true },
});
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
[MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true,
"m.new_content": {
[MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true,
},
});
});
});
describe("sendPollMatrix mentions", () => {

View File

@@ -33,6 +33,7 @@ import {
EventType,
MsgType,
RelationType,
type MatrixExtraContentFields,
type MatrixOutboundContent,
type MatrixSendOpts,
type MatrixSendResult,
@@ -103,6 +104,16 @@ function hasMatrixMentionsMetadata(content: Record<string, unknown> | undefined)
return Boolean(content && Object.hasOwn(content, "m.mentions"));
}
function withMatrixExtraContentFields<T extends Record<string, unknown>>(
content: T,
extraContent?: MatrixExtraContentFields,
): T {
if (!extraContent) {
return content;
}
return { ...content, ...extraContent };
}
async function resolvePreviousEditMentions(params: {
client: MatrixClient;
content: Record<string, unknown> | undefined;
@@ -401,6 +412,7 @@ export async function sendSingleTextMessageMatrix(
accountId?: string;
msgtype?: MatrixTextMsgType;
includeMentions?: boolean;
extraContent?: MatrixExtraContentFields;
} = {},
): Promise<MatrixSendResult> {
const { trimmedText, convertedText, singleEventLimit, fitsInSingleEvent } =
@@ -428,9 +440,12 @@ export async function sendSingleTextMessageMatrix(
const relation = normalizedThreadId
? buildThreadRelation(normalizedThreadId, opts.replyToId)
: buildReplyRelation(opts.replyToId);
const content = buildTextContent(convertedText, relation, {
msgtype: opts.msgtype,
});
const content = withMatrixExtraContentFields(
buildTextContent(convertedText, relation, {
msgtype: opts.msgtype,
}),
opts.extraContent,
);
await enrichMatrixFormattedContent({
client,
content,
@@ -476,6 +491,7 @@ export async function editMessageMatrix(
timeoutMs?: number;
msgtype?: MatrixTextMsgType;
includeMentions?: boolean;
extraContent?: MatrixExtraContentFields;
} = {},
): Promise<string> {
return await withResolvedMatrixSendClient(
@@ -494,9 +510,12 @@ export async function editMessageMatrix(
accountId: opts.accountId,
});
const convertedText = getCore().channel.text.convertMarkdownTables(newText, tableMode);
const newContent = buildTextContent(convertedText, undefined, {
msgtype: opts.msgtype,
});
const newContent = withMatrixExtraContentFields(
buildTextContent(convertedText, undefined, {
msgtype: opts.msgtype,
}),
opts.extraContent,
);
await enrichMatrixFormattedContent({
client,
content: newContent,

View File

@@ -38,6 +38,8 @@ export const EventType = {
RoomMessage: "m.room.message",
} as const;
export const MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY = "com.openclaw.finalized_preview" as const;
export type MatrixDirectAccountData = Record<string, string[]>;
export type MatrixReplyRelation = {
@@ -118,3 +120,5 @@ export type MatrixFormattedContent = MessageEventContent & {
format?: string;
formatted_body?: string;
};
export type MatrixExtraContentFields = Record<string, unknown>;