Skip to content

Commit 13b37f0

Browse files
committed
test(agents,skills): construct transcripts exceeding 60k chars, verify old slices dangle
Both experience-review fixtures now exceed EXPERIENCE_REVIEW_MAX_TRANSCRIPT_CHARS so the truncation branch actually fires. Pre-condition assertions prove the old raw .slice(0,6000) / .slice(tailStart) produce isolated surrogates; the production functions (formatSkillExperienceReviewTranscript, formatCliOutputError) do not. cli-output test also adds pre-condition check showing normalizeCliContextValue with raw .slice(0,200) would split a surrogate pair.
1 parent 83f4a99 commit 13b37f0

2 files changed

Lines changed: 64 additions & 14 deletions

File tree

src/agents/cli-output.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2597,6 +2597,9 @@ function hasDanglingSurrogate(value: string): boolean {
25972597
return /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(value);
25982598
}
25992599

2600+
const EMOJI = "😀";
2601+
const LOBSTER = "🦞";
2602+
26002603
describe("formatCliOutputError", () => {
26012604
it("keeps session identity truncation UTF-16 safe near emoji boundaries", () => {
26022605
const result = parseCliJsonl(
@@ -2613,9 +2616,15 @@ describe("formatCliOutputError", () => {
26132616
"claude-cli",
26142617
);
26152618

2619+
// Pre-condition: old normalizeCliContextValue (raw .slice(0,200))
2620+
// would split the surrogate pair at position 199-200.
2621+
const sessionId = "s".repeat(199) + EMOJI + "tail";
2622+
expect(sessionId.slice(0, 200).charCodeAt(199)).toBe(EMOJI.charCodeAt(0)); // lone high
2623+
expect(hasDanglingSurrogate(sessionId.slice(0, 200))).toBe(true);
2624+
26162625
const error = formatCliOutputError(result!, {
2617-
runId: "r".repeat(199) + "🎉extra",
2618-
sessionId: "openclaw-" + "o".repeat(199) + "🦞end",
2626+
runId: "r".repeat(199) + EMOJI + "extra",
2627+
sessionId: "openclaw-" + "o".repeat(199) + LOBSTER + "end",
26192628
});
26202629

26212630
expect(hasDanglingSurrogate(error)).toBe(false);

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

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -347,26 +347,67 @@ function hasDanglingSurrogate(value: string): boolean {
347347
}
348348

349349
describe("formatSkillExperienceReviewTranscript", () => {
350+
const EXPERIENCE_REVIEW_MAX_TRANSCRIPT_CHARS = 60_000;
351+
// U+1F600 (😀) is a surrogate pair in UTF-16; a raw .slice() can split it.
352+
const EMOJI = "😀";
353+
// U+1F99E (🦞) is a surrogate pair in UTF-16.
354+
const LOBSTER = "🦞";
355+
350356
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);
357+
// renderMessage({role:"user", content: "a"×5_992 + 😀 + "rest"})
358+
// → "[user]\n" + "a"×5_992 + 😀 + "rest" (= 6_005 chars)
359+
// 😀 occupies indices [5_999]=\uD83D (high) and [6_000]=\uDE00 (low).
360+
// Old .slice(0, 6_000) → ends at index 5_999 = lone high surrogate \uD83D.
361+
// A second message pushes full > 60_000, triggering the truncation branch.
362+
const content = "a".repeat(5_992) + EMOJI + "rest";
363+
const msgs = [
364+
{ role: "user", content },
365+
{ role: "user", content: "d".repeat(60_000) },
366+
];
367+
368+
// Pre-condition: old raw slice on the first rendered message would dangle
369+
const renderHeader = "[user]\n";
370+
const rendered0 = `${renderHeader}${content}`;
371+
expect(rendered0.length).toBeGreaterThan(6_000);
372+
const rawSlice = rendered0.slice(0, 6_000);
373+
expect(rawSlice.charCodeAt(5_999)).toBe(0xd83d); // lone high surrogate
374+
expect(hasDanglingSurrogate(rawSlice)).toBe(true);
356375

376+
// Production function must NOT produce a dangling surrogate
377+
const transcript = formatSkillExperienceReviewTranscript(msgs);
357378
expect(hasDanglingSurrogate(transcript)).toBe(false);
358379
});
359380

360381
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" },
382+
// rendered[0]: "[user]\nb"×20_000 → 20_007 plain ASCII chars
383+
// first = truncateUtf16Safe(rendered[0], 6_000) → 6_000 chars
384+
// tailBudget = 60_000 − 6_000 − 80 = 53_920
385+
//
386+
// rendered[1]: "[user]\n" + 🦞 + "z"×53_919 → 53_928 chars
387+
// 🦞 occupies indices 7(\uD83E) and 8(\uDD9E) within rendered[1]
388+
// full = 20_007 + 2 + 53_928 = 73_937
389+
// tailStart = 73_937 − 53_920 = 20_017
390+
//
391+
// full[20_017] = rendered[1][8] = \uDD9E (🦞 low surrogate)
392+
// → old .slice(20_017) starts with a lone low surrogate
393+
const msgs = [
394+
{ role: "user", content: "b".repeat(20_000) },
395+
{ role: "user", content: LOBSTER + "z".repeat(53_919) },
366396
];
367-
const transcript = formatSkillExperienceReviewTranscript(messages);
368397

369-
expect(transcript.length).toBeLessThan(70_000);
398+
// Pre-condition: old raw slice at tailStart would yield a dangling surrogate
399+
const renderHeader = "[user]\n";
400+
const r0 = `${renderHeader}${msgs[0]!.content as string}`;
401+
const r1 = `${renderHeader}${msgs[1]!.content as string}`;
402+
const full = `${r0}\n\n${r1}`;
403+
const firstLen = 6_000; // truncateUtf16Safe on pure ASCII → exact 6_000
404+
const tailBudget = EXPERIENCE_REVIEW_MAX_TRANSCRIPT_CHARS - firstLen - 80;
405+
const tailStart = Math.max(0, full.length - tailBudget);
406+
expect(full.charCodeAt(tailStart)).toBe(0xdd9e); // lone low surrogate
407+
expect(hasDanglingSurrogate(full.slice(tailStart))).toBe(true);
408+
409+
// Production function must NOT produce a dangling surrogate
410+
const transcript = formatSkillExperienceReviewTranscript(msgs);
370411
expect(hasDanglingSurrogate(transcript)).toBe(false);
371412
});
372413
});

0 commit comments

Comments
 (0)