Skip to content

Commit a3dd351

Browse files
committed
fix(agents,skills): keep session-identity and experience-review transcript truncation UTF-16 safe
normalizeCliContextValue (cli-output.ts) and formatSkillExperienceReviewTranscript (experience-review-prompt.ts) both used raw .slice(0, N) / .slice(-N) to truncate user-facing strings. When a truncation boundary falls inside a surrogate pair (emoji), the resulting string contains a lone surrogate — encodeURIComponent throws URIError, JSON serialisers may reject it, and downstream display surfaces can render garbage. Replace .slice(0, 200) with truncateUtf16Safe in normalizeCliContextValue. Replace .slice(0, 6_000) with truncateUtf16Safe and full.slice(-tailBudget) with sliceUtf16Safe(full, tailStart) in formatSkillExperienceReviewTranscript. Follows the pattern established by #98644, #102470, #101728 and the broader UTF-16 safety sweep already applied across the codebase.
1 parent 7f9d519 commit a3dd351

4 files changed

Lines changed: 65 additions & 3 deletions

File tree

src/agents/cli-output.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2592,4 +2592,33 @@ describe("createCliJsonlStreamingParser", () => {
25922592
expect(commentaryTexts).toEqual(["Reading the file now.", "Now searching."]);
25932593
});
25942594
});
2595+
2596+
function hasDanglingSurrogate(value: string): boolean {
2597+
return /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(value);
2598+
}
2599+
2600+
describe("formatCliOutputError", () => {
2601+
it("keeps session identity truncation UTF-16 safe near emoji boundaries", () => {
2602+
const result = parseCliJsonl(
2603+
JSON.stringify({
2604+
type: "result",
2605+
session_id: "s".repeat(199) + "😀tail",
2606+
terminal_reason: "max_turns",
2607+
}),
2608+
{
2609+
command: "claude",
2610+
output: "jsonl",
2611+
sessionIdFields: ["session_id"],
2612+
},
2613+
"claude-cli",
2614+
);
2615+
2616+
const error = formatCliOutputError(result!, {
2617+
runId: "r".repeat(199) + "🎉extra",
2618+
sessionId: "openclaw-" + "o".repeat(199) + "🦞end",
2619+
});
2620+
2621+
expect(hasDanglingSurrogate(error)).toBe(false);
2622+
});
2623+
});
25952624
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */

src/agents/cli-output.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
77
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
8+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
89
import type { CliBackendConfig } from "../config/types.js";
910
import { extractBalancedJsonFragments } from "../shared/balanced-json.js";
1011
import { isRecord } from "../utils.js";
@@ -62,7 +63,7 @@ export type CliOutput = {
6263

6364
function normalizeCliContextValue(value: string | undefined): string | undefined {
6465
const normalized = value?.trim().replace(/\s+/g, " ");
65-
return normalized ? normalized.slice(0, 200) : undefined;
66+
return normalized ? truncateUtf16Safe(normalized, 200) : undefined;
6667
}
6768

6869
export function formatCliOutputError(

src/skills/workshop/experience-review-prompt.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
2+
13
const EXPERIENCE_REVIEW_MAX_TRANSCRIPT_CHARS = 60_000;
24

35
type ExperienceReviewPromptCandidate = {
@@ -61,9 +63,10 @@ export function formatSkillExperienceReviewTranscript(messages: readonly unknown
6163
if (full.length <= EXPERIENCE_REVIEW_MAX_TRANSCRIPT_CHARS) {
6264
return full;
6365
}
64-
const first = rendered[0]?.slice(0, 6_000) ?? "";
66+
const first = rendered[0] ? truncateUtf16Safe(rendered[0], 6_000) : "";
6567
const tailBudget = EXPERIENCE_REVIEW_MAX_TRANSCRIPT_CHARS - first.length - 80;
66-
return `${first}\n\n[older trajectory omitted]\n\n${full.slice(-tailBudget)}`;
68+
const tailStart = Math.max(0, full.length - tailBudget);
69+
return `${first}\n\n[older trajectory omitted]\n\n${sliceUtf16Safe(full, tailStart)}`;
6770
}
6871

6972
export function buildSkillExperienceReviewPrompt(

src/skills/workshop/experience-review.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,3 +341,32 @@ describe("skill experience review scheduler", () => {
341341
expect(prompt).toContain("[tool call: exec]");
342342
});
343343
});
344+
345+
function hasDanglingSurrogate(value: string): boolean {
346+
return /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(value);
347+
}
348+
349+
describe("formatSkillExperienceReviewTranscript", () => {
350+
it("keeps first-message truncation UTF-16 safe at the 6 000-char boundary", () => {
351+
// [user]\n is 7 chars; 5 992 padding chars reach position 5 999 where 😀 starts.
352+
// Without UTF-16 safety, .slice(0, 6_000) would split the surrogate pair.
353+
const content = "a".repeat(5_992) + "😀rest";
354+
const messages = [{ role: "user", content }];
355+
const transcript = formatSkillExperienceReviewTranscript(messages);
356+
357+
expect(hasDanglingSurrogate(transcript)).toBe(false);
358+
});
359+
360+
it("keeps tail-end truncation UTF-16 safe for transcripts exceeding the limit", () => {
361+
// Two long messages to trigger tail truncation (total > 60 000 chars).
362+
const content = "b".repeat(35_000);
363+
const messages = [
364+
{ role: "user", content: "a".repeat(5_992) + "🎉more" },
365+
{ role: "user", content: content + "🦞after" },
366+
];
367+
const transcript = formatSkillExperienceReviewTranscript(messages);
368+
369+
expect(transcript.length).toBeLessThan(70_000);
370+
expect(hasDanglingSurrogate(transcript)).toBe(false);
371+
});
372+
});

0 commit comments

Comments
 (0)