Skip to content

Commit e17feaa

Browse files
wings1029claude
andcommitted
fix(session-cost-usage): keep emoji / surrogate pairs intact during first user message truncation
The discoverAllSessions helper in session-cost-usage.ts truncates the first user message with .slice(0, 100), which counts UTF-16 code units. When an emoji straddles the cut boundary, .slice() produces a lone surrogate that renders as � in session titles and usage API output. Replace .slice(0, 100) with the already-imported truncateUtf16Safe() to avoid splitting surrogate pairs at the boundary. Co-Authored-By: Claude <[email protected]>
1 parent 60f0749 commit e17feaa

2 files changed

Lines changed: 43 additions & 2 deletions

File tree

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2721,6 +2721,47 @@ describe("session cost usage", () => {
27212721
});
27222722
});
27232723

2724+
it("does not split surrogate pairs when truncating the first user message during discovery", async () => {
2725+
const root = await makeSessionCostRoot("discover-utf16-first-msg");
2726+
const sessionsDir = path.join(root, "agents", "main", "sessions");
2727+
await fs.mkdir(sessionsDir, { recursive: true });
2728+
2729+
// "a" (1 code unit) + 50 🦞 (100 code units) = 101 code units > 100
2730+
// .slice(0, 100) would cut between the surrogates of the last emoji
2731+
const content = "a" + "🦞".repeat(50);
2732+
const sessionFile = path.join(sessionsDir, "sess-emoji.jsonl");
2733+
await fs.writeFile(
2734+
sessionFile,
2735+
JSON.stringify({
2736+
type: "message",
2737+
timestamp: "2026-02-21T17:47:00.000Z",
2738+
message: { role: "user", content },
2739+
}),
2740+
"utf-8",
2741+
);
2742+
2743+
await withStateDir(root, async () => {
2744+
const sessions = await discoverAllSessions();
2745+
expect(sessions).toHaveLength(1);
2746+
const msg = sessions[0]?.firstUserMessage;
2747+
expect(msg).toBeDefined();
2748+
2749+
// Must not include a dangling high surrogate
2750+
for (let i = 0; i < msg!.length; i++) {
2751+
const cp = msg!.charCodeAt(i);
2752+
if (cp >= 0xd800 && cp <= 0xdbff) {
2753+
// High surrogate must be followed by a low surrogate
2754+
expect(msg!.charCodeAt(i + 1)).toBeGreaterThanOrEqual(0xdc00);
2755+
expect(msg!.charCodeAt(i + 1)).toBeLessThanOrEqual(0xdfff);
2756+
}
2757+
}
2758+
2759+
// After safe truncation, the last code unit should not be a lone surrogate
2760+
const lastCode = msg!.charCodeAt(msg!.length - 1);
2761+
expect(lastCode < 0xd800 || lastCode > 0xdbff).toBe(true);
2762+
});
2763+
});
2764+
27242765
it("falls back to archived reset transcripts for per-session detail queries", async () => {
27252766
const root = await makeSessionCostRoot("session-archive-fallback");
27262767
const sessionsDir = path.join(root, "agents", "main", "sessions");

src/infra/session-cost-usage.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2160,7 +2160,7 @@ export async function discoverAllSessions(params?: {
21602160
if (message?.role === "user") {
21612161
const content = message.content;
21622162
if (typeof content === "string") {
2163-
firstUserMessage = content.slice(0, 100);
2163+
firstUserMessage = truncateUtf16Safe(content, 100);
21642164
} else if (Array.isArray(content)) {
21652165
for (const block of content) {
21662166
if (
@@ -2170,7 +2170,7 @@ export async function discoverAllSessions(params?: {
21702170
) {
21712171
const text = (block as Record<string, unknown>).text;
21722172
if (typeof text === "string") {
2173-
firstUserMessage = text.slice(0, 100);
2173+
firstUserMessage = truncateUtf16Safe(text, 100);
21742174
}
21752175
break;
21762176
}

0 commit comments

Comments
 (0)