diff --git a/src/skills/workshop/history-scan-transcript-content.ts b/src/skills/workshop/history-scan-transcript-content.ts index 7a09000eb23c..b9079c3312b9 100644 --- a/src/skills/workshop/history-scan-transcript-content.ts +++ b/src/skills/workshop/history-scan-transcript-content.ts @@ -1,3 +1,4 @@ +import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { filterHeartbeatTranscriptTurns } from "../../auto-reply/heartbeat-transcript-turns.js"; import { redactSensitiveText } from "../../logging/redact.js"; import { formatSkillExperienceReviewTranscript } from "./experience-review-prompt.js"; @@ -20,12 +21,12 @@ function capSessionTranscript(transcript: string, maxChars: number): string { } const omission = "\n\n[older session content omitted]\n\n"; if (maxChars <= omission.length) { - return transcript.slice(0, maxChars); + return truncateUtf16Safe(transcript, maxChars); } const contentBudget = Math.max(0, maxChars - omission.length); const headLength = Math.min(2_000, Math.floor(contentBudget / 2)); - const head = transcript.slice(0, headLength); - const tail = transcript.slice(-(contentBudget - headLength)); + const head = truncateUtf16Safe(transcript, headLength); + const tail = sliceUtf16Safe(transcript, -(contentBudget - head.length)); return `${head}${omission}${tail}`; } diff --git a/src/skills/workshop/history-scan.test.ts b/src/skills/workshop/history-scan.test.ts index fc9065ca5e23..51dbed5f64a0 100644 --- a/src/skills/workshop/history-scan.test.ts +++ b/src/skills/workshop/history-scan.test.ts @@ -48,6 +48,10 @@ function summary(sessionKey: string, overrides: Partial; } +function hasDanglingSurrogate(value: string): boolean { + return /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { it("keeps interactive sessions and excludes system-owned work", () => { expect(isSkillHistoryScanSessionEligible(summary("agent:main:main"))).toBe(true); @@ -244,6 +248,33 @@ describe("Skill Workshop history scan", () => { expect(transcript).toContain("…redacted…"); }); + it.each([ + { + name: "a prefix-only budget", + content: "😀tail", + maxChars: 8, + includesOmission: false, + }, + { + name: "the retained head", + content: `😀${"a".repeat(80)}`, + maxChars: 51, + includesOmission: true, + }, + { + name: "the retained tail", + content: `${"a".repeat(60)}😀${"x".repeat(7)}`, + maxChars: 51, + includesOmission: true, + }, + ])("keeps $name UTF-16 safe", ({ content, maxChars, includesOmission }) => { + const transcript = formatSkillHistoryScanTranscript([{ role: "user", content }], maxChars); + + expect(transcript.length).toBeLessThanOrEqual(maxChars); + expect(transcript.includes("[older session content omitted]")).toBe(includesOmission); + expect(hasDanglingSurrogate(transcript)).toBe(false); + }); + it("continues before the oldest cursor and can return to new work", () => { const candidates = [ { instanceId: "new", sessionKey: "main", updatedAtMs: 300, entry: summary("new").entry },