diff --git a/CHANGELOG.md b/CHANGELOG.md index 71d91b10b11..eb7b4d9bfb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - Gateway/runtime: reuse the current plugin metadata snapshot for provider discovery so repeated model-provider discovery avoids rebuilding plugin manifest metadata. Thanks @shakkernerd. - Gateway/startup: pass the plugin metadata snapshot from config validation into plugin bootstrap so startup reuses one manifest product instead of rebuilding plugin metadata. Thanks @shakkernerd. - ACP/runtime: add an opt-in bundled Coven backend extension that routes ACP coding sessions through a local Coven daemon when `acp.backend="coven"`, while preserving the existing ACPX backend as the default fallback path. Thanks @BunsDev. +- Matrix: attach versioned structured approval metadata to pending approval messages so capable Matrix clients can render richer approval UI while body text and reaction fallback keep working. (#72432) Thanks @kakahu2015. ### Fixes diff --git a/extensions/matrix/src/approval-handler.runtime.test.ts b/extensions/matrix/src/approval-handler.runtime.test.ts index 11366d2f0a1..bf2e38799a2 100644 --- a/extensions/matrix/src/approval-handler.runtime.test.ts +++ b/extensions/matrix/src/approval-handler.runtime.test.ts @@ -1,7 +1,250 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { matrixApprovalNativeRuntime } from "./approval-handler.runtime.js"; describe("matrixApprovalNativeRuntime", () => { + it("sends versioned Matrix approval content with pending exec approvals", async () => { + const sendSingleTextMessage = vi.fn().mockResolvedValue({ + messageId: "$approval", + primaryMessageId: "$approval", + messageIds: ["$approval"], + roomId: "!room:example.org", + }); + const reactMessage = vi.fn().mockResolvedValue(undefined); + const pendingPayload = await matrixApprovalNativeRuntime.presentation.buildPendingPayload({ + cfg: {} as never, + accountId: "default", + context: { client: {} as never }, + request: { + id: "req-1", + request: { + command: "echo hi", + cwd: "/repo", + host: "gateway", + agentId: "agent-1", + }, + createdAtMs: 0, + expiresAtMs: 1_000, + }, + approvalKind: "exec", + nowMs: 100, + view: { + approvalKind: "exec", + approvalId: "req-1", + phase: "pending", + title: "Exec Approval Required", + description: "A command needs your approval.", + metadata: [], + ask: "on-request", + agentId: "agent-1", + commandText: "echo hi", + commandPreview: "echo hi", + cwd: "/repo", + host: "gateway", + actions: [ + { + decision: "allow-once", + label: "Allow Once", + style: "success", + command: "/approve req-1 allow-once", + }, + { + decision: "deny", + label: "Deny", + style: "danger", + command: "/approve req-1 deny", + }, + ], + expiresAtMs: 1_000, + } as never, + }); + + await matrixApprovalNativeRuntime.transport.deliverPending({ + cfg: {} as never, + accountId: "default", + context: { + client: {} as never, + deps: { + sendSingleTextMessage, + reactMessage, + }, + }, + request: {} as never, + approvalKind: "exec", + preparedTarget: { + to: "room:!room:example.org", + roomId: "!room:example.org", + }, + pendingPayload, + }); + + expect(sendSingleTextMessage).toHaveBeenCalledWith( + "room:!room:example.org", + expect.stringContaining("echo hi"), + expect.objectContaining({ + extraContent: { + "com.openclaw.approval": expect.objectContaining({ + version: 1, + type: "approval.request", + state: "pending", + id: "req-1", + kind: "exec", + commandText: "echo hi", + cwd: "/repo", + agentId: "agent-1", + allowedDecisions: ["allow-once", "deny"], + }), + }, + }), + ); + }); + + it("includes plugin approval fields in Matrix approval content", async () => { + const pendingPayload = await matrixApprovalNativeRuntime.presentation.buildPendingPayload({ + cfg: {} as never, + accountId: "default", + context: { client: {} as never }, + request: { + id: "plugin:req-1", + request: { + title: "Plugin Approval Required", + description: "Approve the tool call.", + severity: "critical", + toolName: "deploy", + pluginId: "ops", + agentId: "agent-1", + }, + createdAtMs: 0, + expiresAtMs: 1_000, + }, + approvalKind: "plugin", + nowMs: 100, + view: { + approvalKind: "plugin", + approvalId: "plugin:req-1", + phase: "pending", + title: "Plugin Approval Required", + description: "Approve the tool call.", + metadata: [], + agentId: "agent-1", + pluginId: "ops", + toolName: "deploy", + severity: "critical", + actions: [ + { + decision: "allow-once", + label: "Allow Once", + style: "success", + command: "/approve plugin:req-1 allow-once", + }, + ], + expiresAtMs: 1_000, + } as never, + }); + + expect(pendingPayload).toMatchObject({ + extraContent: { + "com.openclaw.approval": { + version: 1, + type: "approval.request", + state: "pending", + id: "plugin:req-1", + kind: "plugin", + pluginId: "ops", + toolName: "deploy", + agentId: "agent-1", + severity: "critical", + }, + }, + }); + }); + + it("falls back to chunked Matrix delivery when approval content exceeds one event", async () => { + const sendSingleTextMessage = vi + .fn() + .mockRejectedValue(new Error("Matrix single-message text exceeds limit (5000 > 4000)")); + const sendMessage = vi.fn().mockResolvedValue({ + messageId: "$last", + primaryMessageId: "$primary", + messageIds: ["$primary", "$last"], + roomId: "!room:example.org", + }); + const reactMessage = vi.fn().mockResolvedValue(undefined); + const pendingPayload = await matrixApprovalNativeRuntime.presentation.buildPendingPayload({ + cfg: {} as never, + accountId: "default", + context: { client: {} as never }, + request: { + id: "req-1", + request: { + command: "echo hi", + }, + createdAtMs: 0, + expiresAtMs: 1_000, + }, + approvalKind: "exec", + nowMs: 100, + view: { + approvalKind: "exec", + approvalId: "req-1", + phase: "pending", + title: "Exec Approval Required", + description: "A command needs your approval.", + metadata: [], + commandText: "echo hi", + actions: [ + { + decision: "allow-once", + label: "Allow Once", + style: "success", + command: "/approve req-1 allow-once", + }, + ], + expiresAtMs: 1_000, + } as never, + }); + + const entry = await matrixApprovalNativeRuntime.transport.deliverPending({ + cfg: {} as never, + accountId: "default", + context: { + client: {} as never, + deps: { + sendSingleTextMessage, + sendMessage, + reactMessage, + }, + }, + request: {} as never, + approvalKind: "exec", + preparedTarget: { + to: "room:!room:example.org", + roomId: "!room:example.org", + }, + pendingPayload, + }); + + expect(sendMessage).toHaveBeenCalledWith( + "room:!room:example.org", + pendingPayload.text, + expect.objectContaining({ + accountId: "default", + }), + ); + expect(reactMessage).toHaveBeenCalledWith( + "!room:example.org", + "$primary", + expect.any(String), + expect.objectContaining({ + accountId: "default", + }), + ); + expect(entry).toMatchObject({ + roomId: "!room:example.org", + messageIds: ["$primary", "$last"], + reactionEventId: "$primary", + }); + }); + it("uses a longer code fence when resolved commands contain triple backticks", async () => { const result = await matrixApprovalNativeRuntime.presentation.buildResolvedResult({ cfg: {} as never, diff --git a/extensions/matrix/src/approval-handler.runtime.ts b/extensions/matrix/src/approval-handler.runtime.ts index f9e2173a730..85d83ff3701 100644 --- a/extensions/matrix/src/approval-handler.runtime.ts +++ b/extensions/matrix/src/approval-handler.runtime.ts @@ -34,6 +34,7 @@ import { import { resolveMatrixTargetIdentity } from "./matrix/target-ids.js"; import type { CoreConfig } from "./types.js"; +// OpenClaw Matrix custom event content for capable clients; body and reactions remain fallback. const MATRIX_APPROVAL_METADATA_KEY = "com.openclaw.approval" as const; type PendingMessage = { @@ -114,7 +115,9 @@ function normalizeThreadId(value?: string | number | null): string | undefined { } function isSingleMatrixMessageLimitError(error: unknown): boolean { - return error instanceof Error && error.message.includes("Matrix single-message text exceeds limit"); + return ( + error instanceof Error && error.message.includes("Matrix single-message text exceeds limit") + ); } async function prepareTarget( @@ -166,7 +169,9 @@ function buildMatrixApprovalMetadata(params: { }): Record { const base = removeUndefinedValues({ version: 1, + type: "approval.request", id: params.view.approvalId, + state: "pending", kind: params.view.approvalKind, phase: params.view.phase, title: params.view.title,