Skip to content

Commit afdb9fd

Browse files
maweibinclaudesteipete
authored
fix(session-cost-usage): keep emoji / surrogate pairs intact during content truncation (#101517)
* fix(session-cost-usage): keep emoji / surrogate pairs intact during content truncation .slice(0, maxLen) counts UTF-16 code units, so an emoji straddling the cut point produces a lone surrogate that renders as � in usage cost CLI output. Use [...str] to count full code points instead. Co-Authored-By: Claude Opus 4.8 <[email protected]> * test(usage): cover UTF-16-safe log truncation --------- Co-authored-by: Claude Opus 4.8 <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent e7d617d commit afdb9fd

2 files changed

Lines changed: 22 additions & 2 deletions

File tree

src/infra/session-cost-usage.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2993,6 +2993,25 @@ example
29932993
expect(logs?.[0]?.content).toBe("hello there");
29942994
});
29952995

2996+
it("does not split surrogate pairs when truncating session log content", async () => {
2997+
const root = await makeSessionCostRoot("logs-utf16");
2998+
const sessionFile = path.join(root, "session.jsonl");
2999+
const content = "x".repeat(1999) + "🚀tail";
3000+
await fs.writeFile(
3001+
sessionFile,
3002+
JSON.stringify({
3003+
type: "message",
3004+
timestamp: "2026-02-21T17:47:00.000Z",
3005+
message: { role: "assistant", content },
3006+
}),
3007+
"utf-8",
3008+
);
3009+
3010+
const logs = await loadSessionLogs({ sessionFile });
3011+
3012+
expect(logs?.[0]?.content).toBe(`${"x".repeat(1999)}…`);
3013+
});
3014+
29963015
it("normalizes malformed log timestamps with the transcript timestamp rules", async () => {
29973016
const root = await makeSessionCostRoot("logs-malformed-timestamp");
29983017
const sessionsDir = path.join(root, "agents", "main", "sessions");

src/infra/session-cost-usage.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import path from "node:path";
44
import readline from "node:readline";
55
import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion";
66
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
7+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
78
import type { NormalizedUsage, UsageLike } from "../agents/usage.js";
89
import { normalizeUsage } from "../agents/usage.js";
910
import { stripInboundMetadata } from "../auto-reply/reply/strip-inbound-meta.js";
@@ -2749,10 +2750,10 @@ export async function loadSessionLogs(params: {
27492750
continue;
27502751
}
27512752

2752-
// Truncate very long content
2753+
// Truncate very long content.
27532754
const maxLen = 2000;
27542755
if (content.length > maxLen) {
2755-
content = content.slice(0, maxLen) + "…";
2756+
content = truncateUtf16Safe(content, maxLen) + "…";
27562757
}
27572758

27582759
// Get timestamp

0 commit comments

Comments
 (0)