From 22a066f7798886a9208394b5614c2a40f7fd1b9a Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:37:04 +0900 Subject: [PATCH] Cut a long value in an approval between characters, not inside an emoji Signed-off-by: kevin9327 <5299031+kevin9327@users.noreply.github.com> --- CHANGELOG.md | 6 ++++++ server/src/approvals/types.ts | 3 ++- server/src/delivery/opentag.ts | 3 ++- server/tests/approvals.test.ts | 20 ++++++++++++++++++++ 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f9d3f16f4..66781798be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,12 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. `COMPUTER_MEMORY_HIGH_PERCENT=0` and `COMPUTER_TAB_IDLE_MS=0` (Helm: `computers.memoryHighPercent` and `computers.tabIdleMs`) keep the old behaviour. +### An approval cuts a long value between characters + +An approval shows what a Bot is about to type, write or send, cut to 2,000 characters, and each field +on the OpenTag card is cut to 300. When the cut landed inside an emoji, the approval showed a "�" +before the "…". The cut now drops the half character instead. + ### A computer closes browsers nobody is using when its memory runs high A computer runs one browser for each Bot that is browsing, and the only limits were a count and half diff --git a/server/src/approvals/types.ts b/server/src/approvals/types.ts index 1a2b69ba4a..c545a31c9d 100644 --- a/server/src/approvals/types.ts +++ b/server/src/approvals/types.ts @@ -6,6 +6,7 @@ import { withApprovalContext, } from "@openbot/toolkit/continuation"; import type { AuditInitiator } from "../audit"; +import { cutAtCodeUnits } from "../channels/text"; /** `handled` answers a hand-off: the person did it themselves, so it is never executed for them. */ export type ApprovalDecision = @@ -374,7 +375,7 @@ export function approvalPreview(args: unknown, depth = 0): unknown { ]), ); if (typeof args === "string") - return (args.length > 2000 ? `${args.slice(0, 1999)}…` : args) + return (args.length > 2000 ? `${cutAtCodeUnits(args, 1999)}…` : args) .replace( /((?:password|token|secret|api[_-]?key)\s*[=:]\s*)[^\s;]+/gi, "$1[private value]", diff --git a/server/src/delivery/opentag.ts b/server/src/delivery/opentag.ts index 7ca63ca031..ded1dee43b 100644 --- a/server/src/delivery/opentag.ts +++ b/server/src/delivery/opentag.ts @@ -19,6 +19,7 @@ */ import { timingSafeEqual } from "node:crypto"; import { z } from "zod"; +import { cutAtCodeUnits } from "../channels/text"; import { DELIVERY_REQUEST_TIMEOUT_MS } from "../timeouts"; import { type BindingTransport, @@ -391,7 +392,7 @@ export function approvalFields(preview: unknown) { } function clip(value: string | undefined, max = 300) { const text = value ?? ""; - return text.length <= max ? text : `${text.slice(0, max - 1)}…`; + return text.length <= max ? text : `${cutAtCodeUnits(text, max - 1)}…`; } /** Minimal AG-UI SSE writer: `data: \n\n` per event, as `@ag-ui/client`'s parser reads it. */ diff --git a/server/tests/approvals.test.ts b/server/tests/approvals.test.ts index 89b9b162ef..bb3cecae55 100644 --- a/server/tests/approvals.test.ts +++ b/server/tests/approvals.test.ts @@ -565,6 +565,26 @@ test("an approval preview shows what will be typed, written or sent, and still h }); }); +test("an approval preview cuts a long value between characters, not inside an emoji", async () => { + const { approvalPreview } = await import("../src/approvals/types"); + const { approvalFields } = await import("../src/delivery/opentag"); + const hasLoneSurrogate = (text: string) => + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { const store = storeFixture(); const service = serviceModule.createApprovalService(store);