fix(scheduled-tasks): count code points when shortening a run summary (#7452)

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>
This commit is contained in:
一片一片 2026-10-25 15:49:10 +08:00 committed by GitHub
parent dcb3a70fbf
commit 6b2ac58555
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 23 additions and 2 deletions

View File

@ -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()}…`;
}

View File

@ -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])/,
);
});
});