Skip to content

Commit d4da0d3

Browse files
committed
fix(msteams): truncate reflection prompt on UTF-16 boundary
buildReflectionPrompt truncated the thumbed-down response with String.slice(0, 500) on a UTF-16 code-unit index, so an astral character straddling the 500-char cap was cut into a lone surrogate in the reflection prompt built for the LLM. Use truncateUtf16Safe so truncation never splits a surrogate pair, keeping the existing 500-char budget and '...' suffix. Adds tests asserting the prompt stays UTF-16 well formed when truncating and that a boundary emoji is preserved when it fits.
1 parent c6f5725 commit d4da0d3

2 files changed

Lines changed: 27 additions & 1 deletion

File tree

extensions/msteams/src/feedback-reflection-prompt.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Msteams plugin module implements feedback reflection prompt behavior.
22
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
3+
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
34

45
/** Max chars of the thumbed-down response to include in the reflection prompt. */
56
const MAX_RESPONSE_CHARS = 500;
@@ -19,7 +20,7 @@ export function buildReflectionPrompt(params: {
1920
if (params.thumbedDownResponse) {
2021
const truncated =
2122
params.thumbedDownResponse.length > MAX_RESPONSE_CHARS
22-
? `${params.thumbedDownResponse.slice(0, MAX_RESPONSE_CHARS)}...`
23+
? `${truncateUtf16Safe(params.thumbedDownResponse, MAX_RESPONSE_CHARS)}...`
2324
: params.thumbedDownResponse;
2425
parts.push(`\nYour response was:\n> ${truncated}`);
2526
}

extensions/msteams/src/feedback-reflection.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ import { msteamsRuntimeStub } from "./test-support/runtime.js";
1919

2020
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
2121

22+
// Matches an unpaired UTF-16 surrogate (lone high or lone low), without relying
23+
// on the ES2024 String.prototype.isWellFormed() runtime API.
24+
const UNPAIRED_SURROGATE_RE =
25+
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
26+
2227
describe("buildFeedbackEvent", () => {
2328
it("builds a well-formed custom event", () => {
2429
const event = buildFeedbackEvent({
@@ -73,6 +78,26 @@ describe("buildReflectionPrompt", () => {
7378
expect(prompt.length).toBeLessThan(longResponse.length + 500);
7479
});
7580

81+
it("does not split UTF-16 surrogate pairs when truncating a thumbed-down response", () => {
82+
const thumbedDownResponse = `${"a".repeat(499)}🦞${"b".repeat(20)}`;
83+
84+
const prompt = buildReflectionPrompt({ thumbedDownResponse });
85+
86+
expect(prompt).not.toMatch(UNPAIRED_SURROGATE_RE);
87+
expect(prompt).toContain(`${"a".repeat(499)}...`);
88+
expect(prompt).not.toContain("\ud83e");
89+
expect(prompt).not.toContain("\udd9e");
90+
});
91+
92+
it("keeps a boundary emoji when it fully fits before the truncation cap", () => {
93+
const thumbedDownResponse = `${"a".repeat(498)}🦞${"b".repeat(20)}`;
94+
95+
const prompt = buildReflectionPrompt({ thumbedDownResponse });
96+
97+
expect(prompt).not.toMatch(UNPAIRED_SURROGATE_RE);
98+
expect(prompt).toContain(`${"a".repeat(498)}🦞...`);
99+
});
100+
76101
it("includes user comment when provided", () => {
77102
const prompt = buildReflectionPrompt({
78103
thumbedDownResponse: "Some response",

0 commit comments

Comments
 (0)