Skip to content

Commit 3301ab0

Browse files
fix(voice-call): drop dangling surrogate in partial-transcript tail slice
1 parent 56c2d63 commit 3301ab0

2 files changed

Lines changed: 55 additions & 1 deletion

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Voice-call tests cover the UTF-16-safe partial-transcript tail slice in
2+
// webhook/realtime-handler.ts (limitPartialUserTranscript). Verifies that
3+
// `sliceUtf16Safe(text, -MAX_PARTIAL_USER_TRANSCRIPT_CHARS)` drops a surrogate
4+
// pair that straddles the tail boundary instead of leaving a lone high-
5+
// surrogate half in the partial transcript fed to the agent.
6+
import { describe, expect, it } from "vitest";
7+
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
8+
9+
describe("voice-call partial-transcript tail UTF-16", () => {
10+
// Mirrors MAX_PARTIAL_USER_TRANSCRIPT_CHARS in realtime-handler.ts. The
11+
// exact cap is not exposed publicly, but the UTF-16-safe slicing contract
12+
// is the same regardless of the numeric value, so we mirror a representative
13+
// small cap here.
14+
const MAX_PARTIAL_USER_TRANSCRIPT_CHARS = 200;
15+
const emoji = "🎉";
16+
17+
it("sliceUtf16Safe drops a surrogate pair straddling the tail-start boundary", () => {
18+
// Build a string of length 301 where the emoji sits at positions
19+
// (100, 101). With sliceUtf16Safe(input, -200), `from = 301 - 200 = 101`,
20+
// which is the low surrogate of the emoji, and position 100 is the high
21+
// surrogate. The helper increments `from` past the high surrogate so the
22+
// tail does not start with a lone surrogate half.
23+
const input = "a".repeat(100) + emoji + "a".repeat(199);
24+
const tail = sliceUtf16Safe(input, -MAX_PARTIAL_USER_TRANSCRIPT_CHARS);
25+
// Tail was supposed to be 200 code units, but the dangling high surrogate
26+
// at position 100 is dropped, so the tail is 199 code units.
27+
expect(tail.length).toBe(199);
28+
expect(tail.charCodeAt(0)).toBeLessThan(0xd800);
29+
});
30+
31+
it("sliceUtf16Safe is a pass-through for plain ASCII partial transcript", () => {
32+
const input = "hello world, this is a normal transcript";
33+
expect(sliceUtf16Safe(input, -MAX_PARTIAL_USER_TRANSCRIPT_CHARS)).toBe(input);
34+
});
35+
36+
it("sliceUtf16Safe preserves an emoji that sits entirely inside the tail window", () => {
37+
// Input is exactly 200 chars. The emoji at position 100 is fully inside
38+
// the tail window (no surrogate straddles the boundary).
39+
const input = "a".repeat(100) + emoji + "a".repeat(98);
40+
const tail = sliceUtf16Safe(input, -MAX_PARTIAL_USER_TRANSCRIPT_CHARS);
41+
expect(tail.length).toBe(200);
42+
expect(tail.includes(emoji)).toBe(true);
43+
});
44+
45+
it("limitPartialUserTranscript-shaped behavior: short body passes through", () => {
46+
// Mirror the production guard at the top of limitPartialUserTranscript:
47+
// when text.length <= MAX_PARTIAL_USER_TRANSCRIPT_CHARS, the helper is
48+
// not called (the production guard short-circuits first), so sliceUtf16Safe
49+
// here is verifying a no-op for short input.
50+
const input = "short";
51+
expect(sliceUtf16Safe(input, -MAX_PARTIAL_USER_TRANSCRIPT_CHARS)).toBe(input);
52+
});
53+
});

extensions/voice-call/src/webhook/realtime-handler.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
type TalkSessionController,
2828
} from "openclaw/plugin-sdk/realtime-voice";
2929
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
30+
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
3031
import WebSocket, { WebSocketServer } from "ws";
3132
import type { VoiceCallRealtimeConfig } from "../config.js";
3233
import type { CallManager } from "../manager.js";
@@ -154,7 +155,7 @@ function limitPartialUserTranscript(text: string): string {
154155
if (text.length <= MAX_PARTIAL_USER_TRANSCRIPT_CHARS) {
155156
return text;
156157
}
157-
const tail = text.slice(-MAX_PARTIAL_USER_TRANSCRIPT_CHARS);
158+
const tail = sliceUtf16Safe(text, -MAX_PARTIAL_USER_TRANSCRIPT_CHARS);
158159
return tail.replace(/^\S+\s+/, "").trimStart() || tail.trimStart();
159160
}
160161

0 commit comments

Comments
 (0)