Skip to content

Commit 917f5f2

Browse files
committed
fix(skills): keep history scans UTF-16 safe
1 parent 61df1db commit 917f5f2

2 files changed

Lines changed: 35 additions & 3 deletions

File tree

src/skills/workshop/history-scan-transcript-content.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
12
import { filterHeartbeatTranscriptTurns } from "../../auto-reply/heartbeat-transcript-turns.js";
23
import { redactSensitiveText } from "../../logging/redact.js";
34
import { formatSkillExperienceReviewTranscript } from "./experience-review-prompt.js";
@@ -20,12 +21,12 @@ function capSessionTranscript(transcript: string, maxChars: number): string {
2021
}
2122
const omission = "\n\n[older session content omitted]\n\n";
2223
if (maxChars <= omission.length) {
23-
return transcript.slice(0, maxChars);
24+
return truncateUtf16Safe(transcript, maxChars);
2425
}
2526
const contentBudget = Math.max(0, maxChars - omission.length);
2627
const headLength = Math.min(2_000, Math.floor(contentBudget / 2));
27-
const head = transcript.slice(0, headLength);
28-
const tail = transcript.slice(-(contentBudget - headLength));
28+
const head = truncateUtf16Safe(transcript, headLength);
29+
const tail = sliceUtf16Safe(transcript, -(contentBudget - head.length));
2930
return `${head}${omission}${tail}`;
3031
}
3132

src/skills/workshop/history-scan.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ function summary(sessionKey: string, overrides: Partial<SessionEntrySummary["ent
4848
>;
4949
}
5050

51+
function hasDanglingSurrogate(value: string): boolean {
52+
return /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(value);
53+
}
54+
5155
describe("Skill Workshop history scan", () => {
5256
it("keeps interactive sessions and excludes system-owned work", () => {
5357
expect(isSkillHistoryScanSessionEligible(summary("agent:main:main"))).toBe(true);
@@ -244,6 +248,33 @@ describe("Skill Workshop history scan", () => {
244248
expect(transcript).toContain("…redacted…");
245249
});
246250

251+
it.each([
252+
{
253+
name: "a prefix-only budget",
254+
content: "😀tail",
255+
maxChars: 8,
256+
includesOmission: false,
257+
},
258+
{
259+
name: "the retained head",
260+
content: `😀${"a".repeat(80)}`,
261+
maxChars: 51,
262+
includesOmission: true,
263+
},
264+
{
265+
name: "the retained tail",
266+
content: `${"a".repeat(60)}😀${"x".repeat(7)}`,
267+
maxChars: 51,
268+
includesOmission: true,
269+
},
270+
])("keeps $name UTF-16 safe", ({ content, maxChars, includesOmission }) => {
271+
const transcript = formatSkillHistoryScanTranscript([{ role: "user", content }], maxChars);
272+
273+
expect(transcript.length).toBeLessThanOrEqual(maxChars);
274+
expect(transcript.includes("[older session content omitted]")).toBe(includesOmission);
275+
expect(hasDanglingSurrogate(transcript)).toBe(false);
276+
});
277+
247278
it("continues before the oldest cursor and can return to new work", () => {
248279
const candidates = [
249280
{ instanceId: "new", sessionKey: "main", updatedAtMs: 300, entry: summary("new").entry },

0 commit comments

Comments
 (0)