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 <noreply@anthropic.com>

* test(usage): cover UTF-16-safe log truncation

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
maweibin
2026-07-07 19:05:15 +08:00
committed by GitHub
parent e7d617d4d9
commit afdb9fd264
2 changed files with 22 additions and 2 deletions
+19
View File
@@ -2993,6 +2993,25 @@ example
expect(logs?.[0]?.content).toBe("hello there");
});
it("does not split surrogate pairs when truncating session log content", async () => {
const root = await makeSessionCostRoot("logs-utf16");
const sessionFile = path.join(root, "session.jsonl");
const content = "x".repeat(1999) + "🚀tail";
await fs.writeFile(
sessionFile,
JSON.stringify({
type: "message",
timestamp: "2026-02-21T17:47:00.000Z",
message: { role: "assistant", content },
}),
"utf-8",
);
const logs = await loadSessionLogs({ sessionFile });
expect(logs?.[0]?.content).toBe(`${"x".repeat(1999)}`);
});
it("normalizes malformed log timestamps with the transcript timestamp rules", async () => {
const root = await makeSessionCostRoot("logs-malformed-timestamp");
const sessionsDir = path.join(root, "agents", "main", "sessions");
+3 -2
View File
@@ -4,6 +4,7 @@ import path from "node:path";
import readline from "node:readline";
import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import type { NormalizedUsage, UsageLike } from "../agents/usage.js";
import { normalizeUsage } from "../agents/usage.js";
import { stripInboundMetadata } from "../auto-reply/reply/strip-inbound-meta.js";
@@ -2749,10 +2750,10 @@ export async function loadSessionLogs(params: {
continue;
}
// Truncate very long content
// Truncate very long content.
const maxLen = 2000;
if (content.length > maxLen) {
content = content.slice(0, maxLen) + "…";
content = truncateUtf16Safe(content, maxLen) + "…";
}
// Get timestamp