Skip to content

Commit 8c96243

Browse files
committed
fix(acp): keep background-task summaries UTF-16 safe at truncation boundaries
The bounded task label and progress summary shown for child ACP background runs used raw String.slice, which can split a surrogate pair at the 160/240-char boundaries and surface U+FFFD in requester-facing task status. Part of the UTF-16 safety sweep.
1 parent 6765eb0 commit 8c96243

2 files changed

Lines changed: 70 additions & 2 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/** Regression coverage for ACP background-task summary truncation boundaries. */
2+
import { describe, expect, it } from "vitest";
3+
import type { OpenClawConfig } from "../../config/types.openclaw.js";
4+
import {
5+
appendBackgroundTaskProgressSummary,
6+
resolveBackgroundTaskContext,
7+
} from "./manager.background-task.js";
8+
import type { AcpSessionManagerDeps } from "./manager.types.js";
9+
10+
// U+1F99E (🦞) is a surrogate pair in UTF-16; a raw .slice() boundary can split it.
11+
const LOBSTER = "🦞";
12+
13+
const HIGH_SURROGATE_WITHOUT_LOW = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])/;
14+
15+
function fakeDeps(): AcpSessionManagerDeps {
16+
const readSessionEntry = (params: { sessionKey: string }) =>
17+
params.sessionKey === "child-session"
18+
? { entry: { spawnedBy: "requester-session" } }
19+
: { entry: {} };
20+
return { readSessionEntry } as unknown as AcpSessionManagerDeps;
21+
}
22+
23+
describe("appendBackgroundTaskProgressSummary", () => {
24+
it("keeps surrogate pairs intact at the progress truncation boundary", () => {
25+
// combined length 244 puts the pair astride the 239-char cut point.
26+
const result = appendBackgroundTaskProgressSummary("x".repeat(238), `${LOBSTER}tail`);
27+
expect(result).toBe(`${"x".repeat(238)}…`);
28+
expect(HIGH_SURROGATE_WITHOUT_LOW.test(result)).toBe(false);
29+
expect(result.length).toBeLessThanOrEqual(240);
30+
});
31+
32+
it("still truncates plain ASCII exactly at the boundary", () => {
33+
const result = appendBackgroundTaskProgressSummary("a".repeat(240), "b");
34+
expect(result).toBe(`${"a".repeat(239)}…`);
35+
});
36+
37+
it("returns short combined summaries unchanged", () => {
38+
expect(appendBackgroundTaskProgressSummary("done: ", "ok")).toBe("done: ok");
39+
expect(appendBackgroundTaskProgressSummary("", ` step ${LOBSTER}`)).toBe(`step ${LOBSTER}`);
40+
});
41+
});
42+
43+
describe("resolveBackgroundTaskContext", () => {
44+
it("keeps surrogate pairs intact in the bounded task label", () => {
45+
// normalized length 164 puts the pair astride the 159-char cut point.
46+
const context = resolveBackgroundTaskContext({
47+
deps: fakeDeps(),
48+
cfg: {} as unknown as OpenClawConfig,
49+
sessionKey: "child-session",
50+
requestId: "run-1",
51+
text: `${"y".repeat(158)}${LOBSTER}tail`,
52+
});
53+
expect(context?.task).toBe(`${"y".repeat(158)}…`);
54+
expect(HIGH_SURROGATE_WITHOUT_LOW.test(context?.task ?? "")).toBe(false);
55+
});
56+
57+
it("passes short task text through unchanged", () => {
58+
const context = resolveBackgroundTaskContext({
59+
deps: fakeDeps(),
60+
cfg: {} as unknown as OpenClawConfig,
61+
sessionKey: "child-session",
62+
requestId: "run-2",
63+
text: `summarize ${LOBSTER} feedback`,
64+
});
65+
expect(context?.task).toBe(`summarize ${LOBSTER} feedback`);
66+
});
67+
});

src/acp/control-plane/manager.background-task.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/** Mirrors child ACP turns into detached-task status for requester-facing progress. */
2+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
23
import type { OpenClawConfig } from "../../config/types.openclaw.js";
34
import { logVerbose } from "../../globals.js";
45
import {
@@ -32,7 +33,7 @@ function summarizeBackgroundTaskText(text: string): string {
3233
if (normalized.length <= ACP_BACKGROUND_TASK_TEXT_MAX_LENGTH) {
3334
return normalized;
3435
}
35-
return `${normalized.slice(0, ACP_BACKGROUND_TASK_TEXT_MAX_LENGTH - 1)}…`;
36+
return `${truncateUtf16Safe(normalized, ACP_BACKGROUND_TASK_TEXT_MAX_LENGTH - 1)}…`;
3637
}
3738

3839
/** Appends bounded progress text while preserving a single-line task summary. */
@@ -49,7 +50,7 @@ export function appendBackgroundTaskProgressSummary(current: string, chunk: stri
4950
if (combined.length <= ACP_BACKGROUND_TASK_PROGRESS_MAX_LENGTH) {
5051
return combined;
5152
}
52-
return `${combined.slice(0, ACP_BACKGROUND_TASK_PROGRESS_MAX_LENGTH - 1)}…`;
53+
return `${truncateUtf16Safe(combined, ACP_BACKGROUND_TASK_PROGRESS_MAX_LENGTH - 1)}…`;
5354
}
5455

5556
/** Maps ACP runtime failures to detached-task terminal states. */

0 commit comments

Comments
 (0)