Skip to content

Commit 92b3540

Browse files
committed
fix(tasks): keep completion failure reason truncation UTF-16 safe
String.prototype.slice at offset 159 can split surrogate pairs in task completion failure reasons, producing broken U+FFFD in terminal output and task registry views. Replace raw slice(0, 159) with truncateUtf16Safe.
1 parent 2522cca commit 92b3540

2 files changed

Lines changed: 31 additions & 1 deletion

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Task completion contract tests.
2+
import { expect, describe, it } from "vitest";
3+
import { truncateUtf16Safe } from "../utils.js";
4+
5+
describe("normalizeCompletionFailureReason truncation", () => {
6+
it("does not cut failure reason with an emoji straddling the 159-char boundary", () => {
7+
// "🚀" is a surrogate pair (2 UTF-16 code units). A failure reason with
8+
// 158 'z' + the emoji has 160 code units. slice(0,159) splits the pair;
9+
// truncateUtf16Safe backs out to 158 code points.
10+
const text = "z".repeat(158) + "🚀";
11+
// slice splits the surrogate pair at position 159
12+
const sliced = text.slice(0, 159);
13+
expect(sliced.charCodeAt(158)).toBe(0xd83d); // lone high surrogate
14+
// truncateUtf16Safe drops the incomplete pair
15+
expect(truncateUtf16Safe(text, 159)).toBe("z".repeat(158));
16+
});
17+
18+
it("preserves failure reason text shorter than the limit unchanged", () => {
19+
expect(truncateUtf16Safe("task blocked: no provider", 159)).toBe("task blocked: no provider");
20+
expect(truncateUtf16Safe("", 159)).toBe("");
21+
});
22+
23+
it("truncates long failure reasons without broken surrogates", () => {
24+
const long = "a".repeat(200);
25+
const result = truncateUtf16Safe(long, 159);
26+
expect(result).toBe("a".repeat(159));
27+
expect(result.length).toBe(159);
28+
});
29+
});

src/tasks/task-completion-contract.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { truncateUtf16Safe } from "../utils.js";
12
// Defines task terminal outcome contracts used by completion handling.
23
import type { TaskTerminalOutcome } from "./task-registry.types.js";
34

@@ -25,7 +26,7 @@ function normalizeCompletionFailureReason(value: string | null | undefined): str
2526
if (!normalized) {
2627
return "";
2728
}
28-
return normalized.length <= 160 ? normalized : `${normalized.slice(0, 159)}...`;
29+
return normalized.length <= 160 ? normalized : `${truncateUtf16Safe(normalized, 159)}...`;
2930
}
3031

3132
function matchesProgressOnlyPrefix(value: string): boolean {

0 commit comments

Comments
 (0)