Skip to content

Commit 881ec2f

Browse files
ly-wang19claudeclawsweeper[bot]
authored
fix(mattermost): truncate draft previews on code-point boundaries (#97472)
Summary: - The PR replaces Mattermost draft preview raw UTF-16 slicing with the existing SDK `sliceUtf16Safe` helper and adds a regression test for an emoji that straddles the preview limit. - PR surface: Source +1, Tests +26. Total +27 across 2 files. - Reproducibility: yes. Source inspection shows current main raw-slices at `maxChars - 3`; driving `createMatt ... at UTF-16 indices 8-9 reaches the lone-surrogate path, though I did not run tests in this read-only sweep. Automerge notes: - PR branch already contained follow-up commit before automerge: fix(mattermost): truncate draft previews on code-point boundaries Validation: - ClawSweeper review passed for head 86d0dd2. - Required merge gates passed before the squash merge. Prepared head SHA: 86d0dd2 Review: #97472 (comment) Co-authored-by: ly-wang19 <[email protected]> Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: takhoffman
1 parent e5205a4 commit 881ec2f

2 files changed

Lines changed: 28 additions & 1 deletion

File tree

extensions/mattermost/src/mattermost/draft-stream.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,32 @@ describe("createMattermostDraftStream", () => {
205205
expect(stream.postId()).toBeUndefined();
206206
});
207207

208+
it("truncates on a code-point boundary so a straddling emoji is dropped whole", async () => {
209+
const { client, calls } = createMockClient();
210+
// maxChars=12 => cut point is maxChars-3=9. The emoji 😀 occupies UTF-16
211+
// indices 8-9, so a raw slice(0,9) would keep the lone high surrogate at
212+
// index 8 and drop its low surrogate at index 9, leaking a dangling half.
213+
const stream = createMattermostDraftStream({
214+
client,
215+
channelId: "channel-1",
216+
throttleMs: 0,
217+
maxChars: 12,
218+
});
219+
220+
const input = `${"a".repeat(8)}\u{1F600}${"b".repeat(5)}`;
221+
stream.update(input);
222+
await stream.flush();
223+
224+
expect(calls).toHaveLength(1);
225+
const message = parseRequestJson(calls[0]?.init).message;
226+
expect(typeof message).toBe("string");
227+
const sent = message as string;
228+
// The straddling emoji must be dropped whole, leaving no dangling surrogate half.
229+
expect(/[\uD800-\uDFFF]/u.test(sent)).toBe(false);
230+
expect(sent.length).toBeLessThanOrEqual(12);
231+
expect(sent).toBe("aaaaaaaa...");
232+
});
233+
208234
it("does not resend after an update failure followed by stop", async () => {
209235
const warn = vi.fn();
210236
const calls: RequestRecord[] = [];

extensions/mattermost/src/mattermost/draft-stream.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Mattermost plugin module implements draft stream behavior.
22
import { createFinalizableDraftLifecycle } from "openclaw/plugin-sdk/channel-outbound";
3+
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
34
import {
45
createMattermostPost,
56
deleteMattermostPost,
@@ -29,7 +30,7 @@ function normalizeMattermostDraftText(text: string, maxChars: number): string {
2930
if (trimmed.length <= maxChars) {
3031
return trimmed;
3132
}
32-
return `${trimmed.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`;
33+
return `${sliceUtf16Safe(trimmed, 0, Math.max(0, maxChars - 3)).trimEnd()}...`;
3334
}
3435

3536
export function createMattermostDraftStream(params: {

0 commit comments

Comments
 (0)