fix(skills): keep history scans UTF-16 safe (#107720)

This commit is contained in:
Leon-SK668
2026-07-16 15:11:53 +08:00
committed by GitHub
parent f9e7a4cab8
commit 2fd3d8608d
2 changed files with 35 additions and 3 deletions

View File

@@ -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}`;
}

View File

@@ -48,6 +48,10 @@ function summary(sessionKey: string, overrides: Partial<SessionEntrySummary["ent
>;
}
function hasDanglingSurrogate(value: string): boolean {
return /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(value);
}
describe("Skill Workshop history scan", () => {
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 },