From afdb9fd26408530a60d95176ca45d2c9356ed8ca Mon Sep 17 00:00:00 2001 From: maweibin Date: Tue, 7 Jul 2026 19:05:15 +0800 Subject: [PATCH] fix(session-cost-usage): keep emoji / surrogate pairs intact during content truncation (#101517) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * test(usage): cover UTF-16-safe log truncation --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Peter Steinberger --- src/infra/session-cost-usage.test.ts | 19 +++++++++++++++++++ src/infra/session-cost-usage.ts | 5 +++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/infra/session-cost-usage.test.ts b/src/infra/session-cost-usage.test.ts index 906646a98da2..ccd22934d540 100644 --- a/src/infra/session-cost-usage.test.ts +++ b/src/infra/session-cost-usage.test.ts @@ -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"); diff --git a/src/infra/session-cost-usage.ts b/src/infra/session-cost-usage.ts index 773fbb11c613..48700eaf18d0 100644 --- a/src/infra/session-cost-usage.ts +++ b/src/infra/session-cost-usage.ts @@ -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