From 6b2ac585550e898c49b3389d4dd15768105cf18d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E7=89=87=E4=B8=80=E7=89=87?= <3037678834@qq.com> Date: Sun, 25 Oct 2026 15:49:10 +0800 Subject: [PATCH] fix(scheduled-tasks): count code points when shortening a run summary (#7452) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit briefReason() measured and sliced UTF-16 code units, so a cut inside an emoji's surrogate pair left a lone high surrogate that the outcome notice renders as U+FFFD. Measuring code points also stops shortening a summary that fits the documented budget. Add a regression test for both the fits-exactly and truncating cases. Co-authored-by: 一片一片 <192845873+gdut4140@users.noreply.github.com> --- frontend/src/core/scheduled-tasks/format.ts | 12 ++++++++++-- .../tests/unit/core/scheduled-tasks/format.test.ts | 13 +++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/frontend/src/core/scheduled-tasks/format.ts b/frontend/src/core/scheduled-tasks/format.ts index b1f95e9b22..4ff2e5f22c 100644 --- a/frontend/src/core/scheduled-tasks/format.ts +++ b/frontend/src/core/scheduled-tasks/format.ts @@ -270,14 +270,22 @@ export function displayTimeZone( * A run summary short enough to quote mid-sentence: its first sentence, no * trailing sentence punctuation (the template adds its own), at most `max` * characters with an ellipsis. The full text stays in the linked run. + * + * The budget counts code points, so an emoji at the cut is never split into a + * lone surrogate (`conversation-outline.ts` and `tool-detail-preview.ts` + * follow the same rule for their own limits). */ export function briefReason(summary: string, max = 120): string { const text = summary.replace(/\s+/g, " ").trim(); const sentenceEnd = /[。!?]|[.!?](?=\s|$)/.exec(text); const first = sentenceEnd ? text.slice(0, sentenceEnd.index + 1) : text; const bare = first.replace(/[\s。..!!??;;,,、::]+$/u, ""); - if (bare.length <= max) { + const characters = Array.from(bare); + if (characters.length <= max) { return bare; } - return `${bare.slice(0, max - 1).trimEnd()}…`; + return `${characters + .slice(0, Math.max(0, max - 1)) + .join("") + .trimEnd()}…`; } diff --git a/frontend/tests/unit/core/scheduled-tasks/format.test.ts b/frontend/tests/unit/core/scheduled-tasks/format.test.ts index 806f182df7..4c305391a9 100644 --- a/frontend/tests/unit/core/scheduled-tasks/format.test.ts +++ b/frontend/tests/unit/core/scheduled-tasks/format.test.ts @@ -199,4 +199,17 @@ describe("briefReason", () => { expect(reason).toHaveLength(120); expect(reason.endsWith("…")).toBe(true); }); + + it("counts code points so a cut never splits an emoji", () => { + // 120 code points but 121 UTF-16 units: the whole summary fits. + const fits = `${"a".repeat(118)}😀b`; + expect(briefReason(fits)).toBe(fits); + + // Cut lands before the extra text, after the whole emoji: no lone surrogate. + const overflow = `${"a".repeat(118)}😀bc`; + expect(briefReason(overflow)).toBe(`${"a".repeat(118)}😀…`); + expect(briefReason(overflow)).not.toMatch( + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])/, + ); + }); });