From c11dbc797ff1a96f7fbe764f8a2d6309f5db5a7c Mon Sep 17 00:00:00 2001 From: "chembo.huang" Date: Wed, 18 Mar 2026 12:49:37 +0800 Subject: [PATCH] feat(control-ui): harden Mermaid rendering --- .../contracts/inbound.contract.test.ts | 64 ++++++------------- src/channels/plugins/contracts/registry.ts | 24 +++---- ui/src/ui/markdown.ts | 2 +- ui/src/ui/mermaid.ts | 43 ++++++++++--- 4 files changed, 68 insertions(+), 65 deletions(-) diff --git a/src/channels/plugins/contracts/inbound.contract.test.ts b/src/channels/plugins/contracts/inbound.contract.test.ts index 9fa108bcb72..9b03c93ec77 100644 --- a/src/channels/plugins/contracts/inbound.contract.test.ts +++ b/src/channels/plugins/contracts/inbound.contract.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { buildDiscordInboundAccessContext } from "../../../../extensions/discord/src/monitor/inbound-context.js"; import type { ResolvedSlackAccount } from "../../../../extensions/slack/src/accounts.js"; import type { SlackMessageEvent } from "../../../../extensions/slack/src/types.js"; import type { MsgContext } from "../../../auto-reply/templating.js"; @@ -73,14 +72,6 @@ vi.mock("../../../../extensions/whatsapp/src/auto-reply/deliver-reply.js", () => deliverWebReply: vi.fn(async () => {}), })); -const { finalizeInboundContext } = await import("../../../auto-reply/reply/inbound-context.js"); -const { prepareSlackMessage } = - await import("../../../../extensions/slack/src/monitor/message-handler/prepare.js"); -const { createInboundSlackTestContext } = - await import("../../../../extensions/slack/src/monitor/message-handler/prepare.test-helpers.js"); -const { buildTelegramMessageContextForTest } = - await import("../../../../extensions/telegram/src/bot-message-context.test-harness.js"); - function createSlackAccount(config: ResolvedSlackAccount["config"] = {}): ResolvedSlackAccount { return { accountId: "default", @@ -112,45 +103,25 @@ describe("channel inbound contract", () => { dispatchInboundMessageMock.mockClear(); }); - it("keeps Discord inbound context finalized", () => { - const { groupSystemPrompt, ownerAllowFrom, untrustedContext } = - buildDiscordInboundAccessContext({ - channelConfig: null, - guildInfo: null, - sender: { id: "U1", name: "Alice", tag: "alice" }, - isGuild: false, - }); - - const ctx = finalizeInboundContext({ - Body: "hi", - BodyForAgent: "hi", - RawBody: "hi", - CommandBody: "hi", - From: "discord:U1", - To: "user:U1", - SessionKey: "agent:main:discord:direct:u1", - AccountId: "default", - ChatType: "direct", - ConversationLabel: "Alice", - SenderName: "Alice", - SenderId: "U1", - SenderUsername: "alice", - GroupSystemPrompt: groupSystemPrompt, - OwnerAllowFrom: ownerAllowFrom, - UntrustedContext: untrustedContext, - Provider: "discord", - Surface: "discord", - WasMentioned: false, - MessageSid: "m1", - CommandAuthorized: true, - OriginatingChannel: "discord", - OriginatingTo: "user:U1", + it("keeps Discord inbound context finalized", async () => { + const { processDiscordMessage } = + await import("../../../../extensions/discord/src/monitor/message-handler.process.js"); + const { createBaseDiscordMessageContext, createDiscordDirectMessageContextOverrides } = + await import("../../../../extensions/discord/src/monitor/message-handler.test-harness.js"); + const messageCtx = await createBaseDiscordMessageContext({ + cfg: { messages: {} }, + ackReactionScope: "direct", + ...createDiscordDirectMessageContextOverrides(), }); - expectChannelInboundContextContract(ctx); + await processDiscordMessage(messageCtx); + + expect(inboundCtxCapture.ctx).toBeTruthy(); + expectChannelInboundContextContract(inboundCtxCapture.ctx!); }); it("keeps Signal inbound context finalized", async () => { + const { finalizeInboundContext } = await import("../../../auto-reply/reply/inbound-context.js"); const ctx = finalizeInboundContext({ Body: "Alice: hi", BodyForAgent: "hi", @@ -178,6 +149,10 @@ describe("channel inbound contract", () => { }); it("keeps Slack inbound context finalized", async () => { + const { prepareSlackMessage } = + await import("../../../../extensions/slack/src/monitor/message-handler/prepare.js"); + const { createInboundSlackTestContext } = + await import("../../../../extensions/slack/src/monitor/message-handler/prepare.test-helpers.js"); const ctx = createInboundSlackTestContext({ cfg: { channels: { slack: { enabled: true } }, @@ -198,6 +173,8 @@ describe("channel inbound contract", () => { }); it("keeps Telegram inbound context finalized", async () => { + const { buildTelegramMessageContextForTest } = + await import("../../../../extensions/telegram/src/bot-message-context.test-harness.js"); const context = await buildTelegramMessageContextForTest({ cfg: { agents: { @@ -232,6 +209,7 @@ describe("channel inbound contract", () => { }); it("keeps WhatsApp inbound context finalized", async () => { + const { finalizeInboundContext } = await import("../../../auto-reply/reply/inbound-context.js"); const ctx = finalizeInboundContext({ Body: "Alice: hi", BodyForAgent: "hi", diff --git a/src/channels/plugins/contracts/registry.ts b/src/channels/plugins/contracts/registry.ts index cf12d4f4355..1dddc70a62a 100644 --- a/src/channels/plugins/contracts/registry.ts +++ b/src/channels/plugins/contracts/registry.ts @@ -196,7 +196,8 @@ bundledChannelRuntimeSetters.setTelegramRuntime({ channel: { telegram: { messageActions: { - describeMessageTool: telegramDescribeMessageToolMock, + listActions: telegramListActionsMock, + getCapabilities: telegramGetCapabilitiesMock, }, }, }, @@ -206,7 +207,8 @@ bundledChannelRuntimeSetters.setDiscordRuntime({ channel: { discord: { messageActions: { - describeMessageTool: discordDescribeMessageToolMock, + listActions: discordListActionsMock, + getCapabilities: discordGetCapabilitiesMock, }, }, }, @@ -405,11 +407,10 @@ export const actionContractRegistry: ActionsContractEntry[] = [ expectedActions: ["send", "poll", "react"], expectedCapabilities: ["interactive", "buttons"], beforeTest: () => { - telegramDescribeMessageToolMock.mockReset(); - telegramDescribeMessageToolMock.mockReturnValue({ - actions: ["send", "poll", "react"], - capabilities: ["interactive", "buttons"], - }); + telegramListActionsMock.mockReset(); + telegramGetCapabilitiesMock.mockReset(); + telegramListActionsMock.mockReturnValue(["send", "poll", "react"]); + telegramGetCapabilitiesMock.mockReturnValue(["interactive", "buttons"]); }, }, ], @@ -424,11 +425,10 @@ export const actionContractRegistry: ActionsContractEntry[] = [ expectedActions: ["send", "react", "poll"], expectedCapabilities: ["interactive", "components"], beforeTest: () => { - discordDescribeMessageToolMock.mockReset(); - discordDescribeMessageToolMock.mockReturnValue({ - actions: ["send", "react", "poll"], - capabilities: ["interactive", "components"], - }); + discordListActionsMock.mockReset(); + discordGetCapabilitiesMock.mockReset(); + discordListActionsMock.mockReturnValue(["send", "react", "poll"]); + discordGetCapabilitiesMock.mockReturnValue(["interactive", "components"]); }, }, ], diff --git a/ui/src/ui/markdown.ts b/ui/src/ui/markdown.ts index 89788059de4..12e813c7204 100644 --- a/ui/src/ui/markdown.ts +++ b/ui/src/ui/markdown.ts @@ -186,7 +186,7 @@ htmlEscapeRenderer.code = ({ escaped?: boolean; }) => { const normalizedLang = lang?.trim().toLowerCase() ?? ""; - const langClass = lang ? ` class="language-${escapeHtml(lang)}"` : ""; + const langClass = normalizedLang ? ` class="language-${escapeHtml(normalizedLang)}"` : ""; const safeText = escaped ? text : escapeHtml(text); const codeBlock = `
${safeText}
`; const langLabel = lang ? `${escapeHtml(lang)}` : ""; diff --git a/ui/src/ui/mermaid.ts b/ui/src/ui/mermaid.ts index 1271ce88667..7178a57ed2a 100644 --- a/ui/src/ui/mermaid.ts +++ b/ui/src/ui/mermaid.ts @@ -1,3 +1,5 @@ +import DOMPurify from "dompurify"; + type MermaidApi = { initialize: (config: Record) => void; render: (id: string, definition: string) => Promise<{ svg: string }>; @@ -9,14 +11,21 @@ let renderCounter = 0; async function loadMermaidApi(): Promise { if (!mermaidApiPromise) { - mermaidApiPromise = import("mermaid").then((mod) => { - const api = (mod.default ?? mod) as MermaidApi; - api.initialize({ - startOnLoad: false, - securityLevel: "strict", + mermaidApiPromise = import("mermaid") + .then((mod) => { + const api = (mod.default ?? mod) as MermaidApi; + api.initialize({ + startOnLoad: false, + securityLevel: "strict", + }); + return api; + }) + .catch((err) => { + // If the dynamic import fails (e.g. chunk mismatch after deploy), + // allow subsequent render attempts to retry without a full reload. + mermaidApiPromise = null; + throw err; }); - return api; - }); } return mermaidApiPromise; } @@ -42,7 +51,21 @@ async function renderMermaidBlocks(root: ParentNode): Promise { return; } - const api = await loadMermaidApi(); + let api: MermaidApi; + try { + api = await loadMermaidApi(); + } catch (err) { + console.warn("[markdown] mermaid module load failed", err); + for (const block of blocks) { + const renderTarget = block.querySelector(".mermaid-block__render"); + if (renderTarget) { + renderTarget.textContent = + "Mermaid render failed to load. Reload the page or expand source to inspect diagram text."; + } + block.dataset.mermaidStatus = "error"; + } + return; + } for (const block of blocks) { const definition = @@ -57,7 +80,9 @@ async function renderMermaidBlocks(root: ParentNode): Promise { try { const id = `openclaw-mermaid-${++renderCounter}`; const { svg } = await api.render(id, definition); - renderTarget.innerHTML = svg; + renderTarget.innerHTML = DOMPurify.sanitize(svg, { + USE_PROFILES: { svg: true, svgFilters: true }, + }); block.dataset.mermaidStatus = "ready"; } catch (err) { console.warn("[markdown] mermaid render failed", err);