diff --git a/extensions/discord/src/approval-handler.runtime.test.ts b/extensions/discord/src/approval-handler.runtime.test.ts index 0aac2261d111..5d4b2b883e20 100644 --- a/extensions/discord/src/approval-handler.runtime.test.ts +++ b/extensions/discord/src/approval-handler.runtime.test.ts @@ -2,7 +2,54 @@ import { describe, expect, it } from "vitest"; import { discordApprovalNativeRuntime } from "./approval-handler.runtime.js"; +async function buildExecApprovalPayloadText(commandText: string): Promise { + const pending = await discordApprovalNativeRuntime.presentation.buildPendingPayload({ + cfg: {} as never, + accountId: "main", + context: { + token: "discord-token", + config: {} as never, + }, + request: { + id: "approval-1", + request: { + command: commandText, + }, + createdAtMs: 0, + expiresAtMs: 1_000, + }, + approvalKind: "exec", + nowMs: 0, + view: { + approvalKind: "exec", + phase: "pending", + approvalId: "approval-1", + title: "Exec Approval Required", + commandText, + commandPreview: null, + expiresAtMs: 1_000, + metadata: [], + actions: [ + { + label: "Allow", + decision: "allow-once", + style: "success", + command: "/approve approval-1 allow-once", + }, + ], + }, + }); + return JSON.stringify(pending); +} + describe("discordApprovalNativeRuntime", () => { + it("does not split emoji graphemes when truncating exec command previews", async () => { + const prefix = "a".repeat(999); + + await expect(buildExecApprovalPayloadText(`${prefix}😀x`)).resolves.toContain(`${prefix}...`); + await expect(buildExecApprovalPayloadText(`${prefix}🇺🇸x`)).resolves.toContain(`${prefix}...`); + }); + it("routes origin approval updates to the Discord thread channel when threadId is present", async () => { const prepared = await discordApprovalNativeRuntime.transport.prepareTarget({ cfg: {} as never, diff --git a/extensions/discord/src/approval-handler.runtime.ts b/extensions/discord/src/approval-handler.runtime.ts index e7af3195d816..a0a7f5d0cabd 100644 --- a/extensions/discord/src/approval-handler.runtime.ts +++ b/extensions/discord/src/approval-handler.runtime.ts @@ -159,10 +159,38 @@ function buildExecApprovalPayload(container: DiscordUiContainer): MessagePayload return { components }; } +const commandPreviewSegmenter = + typeof Intl !== "undefined" && "Segmenter" in Intl + ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) + : null; + +function* iterateCommandPreviewSegments(commandText: string): Iterable { + if (!commandPreviewSegmenter) { + yield* Array.from(commandText); + return; + } + try { + for (const segment of commandPreviewSegmenter.segment(commandText)) { + yield segment.segment; + } + } catch { + yield* Array.from(commandText); + } +} + +function truncateCommandPreview(commandText: string, maxChars: number): string { + let commandRaw = ""; + for (const segment of iterateCommandPreviewSegments(commandText)) { + if (commandRaw.length + segment.length > maxChars) { + return `${commandRaw}...`; + } + commandRaw += segment; + } + return commandText; +} + function formatCommandPreview(commandText: string, maxChars: number): string { - const commandRaw = - commandText.length > maxChars ? `${commandText.slice(0, maxChars)}...` : commandText; - return commandRaw.replace(/`/g, "\u200b`"); + return truncateCommandPreview(commandText, maxChars).replace(/`/g, "\u200b`"); } function formatOptionalCommandPreview(