Skip to content

Commit 87fe266

Browse files
steipetely85206559lzw112Alix-007
authored
fix: preserve emoji at remaining bounded-text edges (#101711)
* fix: keep remaining text bounds UTF-16 safe Co-authored-by: ben.li <[email protected]> Co-authored-by: 0668001336 <[email protected]> Co-authored-by: Alix-007 <[email protected]> * docs: credit bounded Unicode fixes * test: update UTF-16 browser import boundary * chore: leave Unicode note to release changelog --------- Co-authored-by: ben.li <[email protected]> Co-authored-by: 0668001336 <[email protected]> Co-authored-by: Alix-007 <[email protected]>
1 parent ed28c57 commit 87fe266

15 files changed

Lines changed: 170 additions & 24 deletions

File tree

extensions/diagnostics-otel/src/service.test.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2175,11 +2175,13 @@ describe("diagnostics-otel service", () => {
21752175

21762176
test("bounds plugin-emitted log attributes and omits source paths", async () => {
21772177
const service = createDiagnosticsOtelService();
2178-
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { logs: true });
2178+
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { logs: true, captureContent: true });
21792179
await service.start(ctx);
21802180

2181+
const boundaryMessage = `${"x".repeat(4095)}🚀tail`;
2182+
const boundaryAttribute = `${"y".repeat(4095)}🚀tail`;
21812183
const attributes = Object.create(null) as Record<string, string>;
2182-
attributes.good = "y".repeat(6000);
2184+
attributes.good = boundaryAttribute;
21832185
attributes["bad key"] = "drop-me";
21842186
attributes[PROTO_KEY] = "pollute";
21852187
attributes["constructor"] = "pollute";
@@ -2189,7 +2191,7 @@ describe("diagnostics-otel service", () => {
21892191
emitDiagnosticEvent({
21902192
type: "log.record",
21912193
level: "INFO",
2192-
message: "x".repeat(6000),
2194+
message: boundaryMessage,
21932195
attributes,
21942196
code: {
21952197
filepath: "/Users/alice/openclaw/src/private.ts",
@@ -2204,11 +2206,10 @@ describe("diagnostics-otel service", () => {
22042206
attributes: Record<string, unknown>;
22052207
body: string;
22062208
};
2207-
expect(emitCall.body.length).toBeLessThanOrEqual(4200);
2208-
expect(String(emitCall.attributes["openclaw.good"])).toMatch(/^y+/);
2209+
expect(emitCall.body).toBe(`${"x".repeat(4095)}...(truncated)`);
2210+
expect(emitCall.attributes["openclaw.good"]).toBe(`${"y".repeat(4095)}...(truncated)`);
22092211
expect(emitCall.attributes["code.lineno"]).toBe(42);
22102212
expect(emitCall.attributes["code.function"]).toBe("handler");
2211-
expect(String(emitCall.attributes["openclaw.good"]).length).toBeLessThanOrEqual(4200);
22122213
expect(Object.hasOwn(emitCall.attributes, `openclaw.${PROTO_KEY}`)).toBe(false);
22132214
expect(Object.hasOwn(emitCall.attributes, "openclaw.constructor")).toBe(false);
22142215
expect(Object.hasOwn(emitCall.attributes, "openclaw.prototype")).toBe(false);

extensions/diagnostics-otel/src/service.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { waitForDiagnosticEventsDrained } from "openclaw/plugin-sdk/diagnostic-r
4040
import { createNodeProxyAgent } from "openclaw/plugin-sdk/fetch-runtime";
4141
import { registerUnhandledRejectionHandler } from "openclaw/plugin-sdk/runtime-env";
4242
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
43+
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
4344
import type {
4445
DiagnosticEventMetadata,
4546
DiagnosticEventPayload,
@@ -682,7 +683,7 @@ function addUpstreamRequestIdSpanEvent(
682683
}
683684

684685
function clampOtelLogText(value: string, maxChars: number): string {
685-
return value.length > maxChars ? `${value.slice(0, maxChars)}...(truncated)` : value;
686+
return value.length > maxChars ? `${truncateUtf16Safe(value, maxChars)}...(truncated)` : value;
686687
}
687688

688689
function normalizeOtelLogString(value: string, maxChars: number): string {

extensions/parallel/src/parallel-search-normalize.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
wrapWebContent,
99
} from "openclaw/plugin-sdk/provider-web-search";
1010
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
11+
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
1112

1213
// Internal-only bounds (the model-facing tool schema declares its own copies).
1314
const PARALLEL_MAX_SEARCH_COUNT = 40;
@@ -58,7 +59,7 @@ export function normalizeParallelObjective(value: string | undefined): string |
5859
}
5960
return trimmed.length <= PARALLEL_MAX_OBJECTIVE_CHARS
6061
? trimmed
61-
: trimmed.slice(0, PARALLEL_MAX_OBJECTIVE_CHARS);
62+
: truncateUtf16Safe(trimmed, PARALLEL_MAX_OBJECTIVE_CHARS);
6263
}
6364

6465
export function normalizeParallelClientModel(value: string | undefined): string | undefined {
@@ -68,7 +69,7 @@ export function normalizeParallelClientModel(value: string | undefined): string
6869
}
6970
return trimmed.length <= PARALLEL_CLIENT_MODEL_MAX_LENGTH
7071
? trimmed
71-
: trimmed.slice(0, PARALLEL_CLIENT_MODEL_MAX_LENGTH);
72+
: truncateUtf16Safe(trimmed, PARALLEL_CLIENT_MODEL_MAX_LENGTH);
7273
}
7374

7475
// Parallel's API caps each entry at 200 chars and accepts up to 5 queries. We
@@ -90,7 +91,7 @@ export function normalizeParallelSearchQueries(value: unknown): string[] {
9091
const capped =
9192
trimmed.length <= PARALLEL_MAX_SEARCH_QUERY_CHARS
9293
? trimmed
93-
: trimmed.slice(0, PARALLEL_MAX_SEARCH_QUERY_CHARS);
94+
: truncateUtf16Safe(trimmed, PARALLEL_MAX_SEARCH_QUERY_CHARS);
9495
if (seen.has(capped)) {
9596
continue;
9697
}

extensions/parallel/src/parallel-web-search-provider.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ describe("parallel web search provider", () => {
254254
expect(testing.normalizeParallelObjective(undefined)).toBeUndefined();
255255
expect(testing.normalizeParallelObjective("")).toBeUndefined();
256256
expect((testing.normalizeParallelObjective("x".repeat(6000)) ?? "").length).toBe(5000);
257+
expect(testing.normalizeParallelObjective(`${"x".repeat(4999)}🚀tail`)).toBe("x".repeat(4999));
257258
});
258259

259260
it("normalizes search_queries: trim, drop blanks, dedupe, cap length, cap count", () => {
@@ -270,6 +271,9 @@ describe("parallel web search provider", () => {
270271
expect(testing.normalizeParallelSearchQueries(undefined)).toEqual([]);
271272
expect(testing.normalizeParallelSearchQueries("openclaw github")).toEqual([]);
272273
expect(testing.normalizeParallelSearchQueries(["x".repeat(250)])).toEqual(["x".repeat(200)]);
274+
expect(testing.normalizeParallelSearchQueries([`${"x".repeat(199)}🚀tail`])).toEqual([
275+
"x".repeat(199),
276+
]);
273277
const six = ["a", "b", "c", "d", "e", "f"];
274278
expect(testing.normalizeParallelSearchQueries(six)).toEqual(["a", "b", "c", "d", "e"]);
275279
});
@@ -289,6 +293,7 @@ describe("parallel web search provider", () => {
289293
expect(testing.normalizeParallelClientModel(" gpt-5.5 ")).toBe("gpt-5.5");
290294
expect(testing.normalizeParallelClientModel(undefined)).toBeUndefined();
291295
expect((testing.normalizeParallelClientModel("a".repeat(200)) ?? "").length).toBe(100);
296+
expect(testing.normalizeParallelClientModel(`${"m".repeat(99)}🚀tail`)).toBe("m".repeat(99));
292297
});
293298

294299
it("normalizes the Parallel /v1/search response shape", () => {

extensions/workboard/src/store.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1938,6 +1938,24 @@ describe("WorkboardStore", () => {
19381938
await expect(store.buildWorkerContext(card.id)).resolves.toContain("Failure screenshot");
19391939
});
19401940

1941+
it("keeps worker-context text bounds UTF-16 safe", async () => {
1942+
const store = new WorkboardStore(createMemoryStore());
1943+
const card = await store.create({
1944+
title: "Bound context",
1945+
metadata: {
1946+
comments: [
1947+
{
1948+
id: "comment-1",
1949+
body: `${"x".repeat(398)}🚀tail`,
1950+
createdAt: 10,
1951+
},
1952+
],
1953+
},
1954+
});
1955+
1956+
await expect(store.buildWorkerContext(card.id)).resolves.toContain(`- ${"x".repeat(398)}…`);
1957+
});
1958+
19411959
it("scopes idempotent creates and stats by board", async () => {
19421960
const store = new WorkboardStore(createMemoryStore());
19431961
const ops = await store.create({

extensions/workboard/src/store.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
MAX_DATE_TIMESTAMP_MS,
66
resolveExpiresAtMsFromDurationMs,
77
} from "openclaw/plugin-sdk/number-runtime";
8+
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
89
import type {
910
PersistedWorkboardAttachment,
1011
PersistedWorkboardBoard,
@@ -2039,7 +2040,7 @@ function capText(value: string | undefined, max: number): string | undefined {
20392040
if (!value) {
20402041
return undefined;
20412042
}
2042-
return value.length <= max ? value : `${value.slice(0, Math.max(0, max - 1))}…`;
2043+
return value.length <= max ? value : `${truncateUtf16Safe(value, Math.max(0, max - 1))}…`;
20432044
}
20442045

20452046
function cardBoardId(card: WorkboardCard): string {

src/agents/agent-hooks/compaction-safeguard.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,21 @@ describe("compaction-safeguard tool failures", () => {
392392
expect(section).toContain("exec (exitCode=2): failed");
393393
});
394394

395+
it("keeps bounded tool-failure text UTF-16 safe", () => {
396+
const failures = collectToolFailures([
397+
{
398+
role: "toolResult",
399+
toolCallId: "call-boundary",
400+
toolName: "exec",
401+
isError: true,
402+
content: [{ type: "text", text: `${"x".repeat(236)}🚀tail` }],
403+
timestamp: Date.now(),
404+
},
405+
]);
406+
407+
expect(failures[0]?.summary).toBe(`${"x".repeat(236)}...`);
408+
});
409+
395410
it("caps the number of failures and adds overflow line", () => {
396411
const messages: AgentMessage[] = Array.from({ length: 9 }, (_, idx) => ({
397412
role: "toolResult",
@@ -454,6 +469,18 @@ describe("compaction-safeguard summary budgets", () => {
454469
expect(capped.endsWith(SUMMARY_TRUNCATED_MARKER)).toBe(true);
455470
});
456471

472+
it("keeps compaction summary prefixes UTF-16 safe", () => {
473+
const prefixBudget = MAX_COMPACTION_SUMMARY_CHARS - SUMMARY_TRUNCATED_MARKER.length;
474+
const oversized = `${"x".repeat(prefixBudget - 1)}🚀${"z".repeat(
475+
SUMMARY_TRUNCATED_MARKER.length + 10,
476+
)}`;
477+
478+
expect(capCompactionSummary(oversized)).toBe(
479+
`${"x".repeat(prefixBudget - 1)}${SUMMARY_TRUNCATED_MARKER}`,
480+
);
481+
expect(capCompactionSummary(`${"x".repeat(9)}🚀tail`, 10)).toBe("x".repeat(9));
482+
});
483+
457484
it("preserves workspace critical rules suffix when capping", () => {
458485
const suffix =
459486
"\n\n<workspace-critical-rules>\n## Session Startup\nRead AGENTS.md\n</workspace-critical-rules>";
@@ -522,6 +549,10 @@ describe("compaction-safeguard summary budgets", () => {
522549
expect(capped).toContain("<read-files>");
523550
expect(capped).toContain("## Session Startup");
524551
});
552+
553+
it("keeps an oversized preserved suffix UTF-16 safe at its leading edge", () => {
554+
expect(capCompactionSummaryPreservingSuffix("body", "A🚀tail", 5)).toBe("tail");
555+
});
525556
});
526557

527558
describe("computeAdaptiveChunkRatio", () => {
@@ -906,6 +937,18 @@ describe("compaction-safeguard recent-turn preservation", () => {
906937
expect(section).toContain("[non-text content: image]");
907938
});
908939

940+
it("keeps bounded preserved-turn text UTF-16 safe", () => {
941+
const section = formatPreservedTurnsSection([
942+
{
943+
role: "user",
944+
content: `${"x".repeat(599)}🚀tail`,
945+
timestamp: 1,
946+
},
947+
]);
948+
949+
expect(section).toContain(`- User: ${"x".repeat(599)}...`);
950+
});
951+
909952
it("does not add non-text placeholders for text-only content blocks", () => {
910953
const section = formatPreservedTurnsSection([
911954
{
@@ -2692,6 +2735,14 @@ describe("readWorkspaceContextForSummary", () => {
26922735
expect(result).not.toContain("Ignore me");
26932736
});
26942737

2738+
it("keeps bounded workspace rules UTF-16 safe", async () => {
2739+
const heading = "## Session Startup\n";
2740+
const safePrefix = `${heading}${"x".repeat(1_999 - heading.length)}`;
2741+
const result = await withWorkspaceSummary(`${safePrefix}🚀tail\n`, ["Session Startup"]);
2742+
2743+
expect(result).toContain(`${safePrefix}\n...[truncated]...`);
2744+
});
2745+
26952746
it("reads workspace context from the configured workspace instead of process cwd", async () => {
26962747
const processRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-compaction-cwd-"));
26972748
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-compaction-workspace-"));

src/agents/agent-hooks/compaction-safeguard.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
/** Extension that safeguards compaction with structured summaries and quality repair. */
2+
23
import fs from "node:fs";
34
import path from "node:path";
5+
import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
46
import { extractSections } from "../../auto-reply/reply/post-compaction-context.js";
7+
import { isAbortError } from "../../infra/abort-signal.js";
58
import { openRootFile } from "../../infra/boundary-file-read.js";
69
import { formatErrorMessage } from "../../infra/errors.js";
7-
import { isAbortError } from "../../infra/abort-signal.js";
810
import { createSubsystemLogger } from "../../logging/subsystem.js";
911
import {
1012
getCompactionProvider,
@@ -404,7 +406,7 @@ function truncateFailureText(text: string, maxChars: number): string {
404406
if (text.length <= maxChars) {
405407
return text;
406408
}
407-
return `${text.slice(0, Math.max(0, maxChars - 3))}...`;
409+
return `${truncateUtf16Safe(text, Math.max(0, maxChars - 3))}...`;
408410
}
409411

410412
function formatToolFailureMeta(details: unknown): string | undefined {
@@ -568,9 +570,9 @@ function capCompactionSummary(summary: string, maxChars = MAX_COMPACTION_SUMMARY
568570
const budget = Math.max(0, maxChars - marker.length);
569571
if (budget <= 0) {
570572
// Marker cannot fit; keep body prefix instead of a partial marker fragment.
571-
return summary.slice(0, maxChars);
573+
return truncateUtf16Safe(summary, maxChars);
572574
}
573-
return `${summary.slice(0, budget)}${marker}`;
575+
return `${truncateUtf16Safe(summary, budget)}${marker}`;
574576
}
575577

576578
function capCompactionSummaryPreservingSuffix(
@@ -586,7 +588,7 @@ function capCompactionSummaryPreservingSuffix(
586588
}
587589
if (suffix.length >= maxChars) {
588590
// Preserve tail (workspace rules, diagnostics) over head (preserved turns).
589-
return suffix.slice(-maxChars);
591+
return sliceUtf16Safe(suffix, -maxChars);
590592
}
591593
const bodyBudget = Math.max(0, maxChars - suffix.length);
592594
const cappedBody = capCompactionSummary(summaryBody, bodyBudget);
@@ -790,7 +792,7 @@ function formatContextMessages(messages: AgentMessage[]): string[] {
790792
}
791793
const trimmed =
792794
rendered.length > MAX_RECENT_TURN_TEXT_CHARS
793-
? `${rendered.slice(0, MAX_RECENT_TURN_TEXT_CHARS)}...`
795+
? `${truncateUtf16Safe(rendered, MAX_RECENT_TURN_TEXT_CHARS)}...`
794796
: rendered;
795797
return `- ${roleLabel}: ${trimmed}`;
796798
})
@@ -884,7 +886,7 @@ async function readWorkspaceContextForSummary(
884886
const combined = sections.join("\n\n");
885887
const safeContent =
886888
combined.length > MAX_SUMMARY_CONTEXT_CHARS
887-
? combined.slice(0, MAX_SUMMARY_CONTEXT_CHARS) + "\n...[truncated]..."
889+
? `${truncateUtf16Safe(combined, MAX_SUMMARY_CONTEXT_CHARS)}\n...[truncated]...`
888890
: combined;
889891

890892
return `\n\n<workspace-critical-rules>\n${safeContent}\n</workspace-critical-rules>`;

src/infra/system-presence.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,13 @@ describe("system-presence", () => {
114114
expect(entry?.text).toBe("Node: relay-host · mode operator");
115115
});
116116

117+
it("keeps fallback text keys UTF-16 safe", () => {
118+
const keyPrefix = `presence-${randomUUID()}`.padEnd(63, "x");
119+
const update = updateSystemPresence({ text: `${keyPrefix}🚀tail` });
120+
121+
expect(update.key).toBe(keyPrefix);
122+
});
123+
117124
it("prunes stale non-self entries after TTL", () => {
118125
vi.useFakeTimers();
119126
vi.setSystemTime(Date.now());

src/infra/system-presence.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
normalizeOptionalLowercaseString,
88
normalizeOptionalString,
99
} from "@openclaw/normalization-core/string-coerce";
10+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
1011
import { resolveRuntimeServiceVersion } from "../version.js";
1112
import { pickBestEffortPrimaryLanIPv4 } from "./network-discovery-display.js";
1213

@@ -201,7 +202,7 @@ export function updateSystemPresence(payload: SystemPresencePayload): SystemPres
201202
normalizePresenceKey(parsed.instanceId) ||
202203
normalizePresenceKey(parsed.host) ||
203204
parsed.ip ||
204-
parsed.text.slice(0, 64) ||
205+
truncateUtf16Safe(parsed.text, 64) ||
205206
normalizeLowercaseStringOrEmpty(os.hostname());
206207
const hadExisting = entries.has(key);
207208
const existing = entries.get(key) ?? ({} as SystemPresence);

0 commit comments

Comments
 (0)