Skip to content

Commit 9fb2403

Browse files
steipeteAlix-007
andauthored
fix(discord): keep thread title prompts UTF-16 safe (#101551)
Co-authored-by: Alix-007 <[email protected]>
1 parent 0d9329d commit 9fb2403

2 files changed

Lines changed: 44 additions & 7 deletions

File tree

extensions/discord/src/monitor/thread-title.generate.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,24 @@ function firstCompletionArgs(): Parameters<
2222
return firstCall[0];
2323
}
2424

25+
function hasLoneSurrogate(value: string): boolean {
26+
for (let index = 0; index < value.length; index += 1) {
27+
const code = value.charCodeAt(index);
28+
if (code >= 0xd800 && code <= 0xdbff) {
29+
const next = value.charCodeAt(index + 1);
30+
if (!(next >= 0xdc00 && next <= 0xdfff)) {
31+
return true;
32+
}
33+
index += 1;
34+
continue;
35+
}
36+
if (code >= 0xdc00 && code <= 0xdfff) {
37+
return true;
38+
}
39+
}
40+
return false;
41+
}
42+
2543
beforeAll(async () => {
2644
({ generateThreadTitle } = await import("./thread-title.js"));
2745
});
@@ -200,6 +218,24 @@ describe("generateThreadTitle", () => {
200218
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 60_000);
201219
});
202220

221+
it("keeps truncated prompt fields on UTF-16 boundaries", async () => {
222+
await generateThreadTitle({
223+
cfg: EMPTY_DISCORD_TEST_CONFIG,
224+
agentId: "main",
225+
messageText: `${"m".repeat(599)}😀tail`,
226+
channelName: `${"n".repeat(119)}😀tail`,
227+
channelDescription: `${"d".repeat(319)}😀tail`,
228+
});
229+
230+
const message = firstCompletionArgs().context.messages.at(0);
231+
const content = typeof message?.content === "string" ? message.content : "";
232+
233+
expect(hasLoneSurrogate(content)).toBe(false);
234+
expect(content).toContain(`${"m".repeat(599)}...`);
235+
expect(content).toContain(`${"n".repeat(119)}...`);
236+
expect(content).toContain(`${"d".repeat(319)}...`);
237+
});
238+
203239
it("clamps completion budget to the selected model output cap", async () => {
204240
prepareSimpleCompletionModelForAgentMock.mockResolvedValueOnce({
205241
selection: {

extensions/discord/src/monitor/thread-title.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
extractAssistantText,
77
prepareSimpleCompletionModelForAgent,
88
} from "openclaw/plugin-sdk/simple-completion-runtime";
9+
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
910
import { withAbortTimeout } from "./timeouts.js";
1011

1112
const DEFAULT_THREAD_TITLE_TIMEOUT_MS = 60_000;
@@ -51,9 +52,8 @@ export async function generateThreadTitle(params: {
5152
}
5253

5354
try {
54-
const promptText = truncateThreadTitleSourceText(sourceText);
55-
const userMessage = buildThreadTitleUserMessage({
56-
sourceText: promptText,
55+
const userMessage = buildThreadTitleCompletionUserMessage({
56+
sourceText,
5757
channelName: params.channelName,
5858
channelDescription: params.channelDescription,
5959
});
@@ -104,11 +104,12 @@ async function completeThreadTitle(params: {
104104
});
105105
}
106106

107-
function buildThreadTitleUserMessage(params: {
107+
function buildThreadTitleCompletionUserMessage(params: {
108108
sourceText: string;
109109
channelName?: string;
110110
channelDescription?: string;
111111
}): string {
112+
const sourceText = truncateThreadTitleSourceText(params.sourceText);
112113
const channelName = normalizeTitleContextField(
113114
params.channelName,
114115
MAX_THREAD_TITLE_CHANNEL_NAME_CHARS,
@@ -124,15 +125,15 @@ function buildThreadTitleUserMessage(params: {
124125
if (channelDescription) {
125126
messageLines.push(`Channel description: ${channelDescription}`);
126127
}
127-
messageLines.push(`Message:\n${params.sourceText}`);
128+
messageLines.push(`Message:\n${sourceText}`);
128129
return messageLines.join("\n\n");
129130
}
130131

131132
function truncateThreadTitleSourceText(sourceText: string): string {
132133
if (sourceText.length <= MAX_THREAD_TITLE_SOURCE_CHARS) {
133134
return sourceText;
134135
}
135-
return `${sourceText.slice(0, MAX_THREAD_TITLE_SOURCE_CHARS)}...`;
136+
return `${truncateUtf16Safe(sourceText, MAX_THREAD_TITLE_SOURCE_CHARS)}...`;
136137
}
137138

138139
function resolveThreadTitleTimeoutMs(timeoutMs: number | undefined): number {
@@ -201,5 +202,5 @@ function normalizeTitleContextField(raw: string | undefined, maxChars: number):
201202
if (singleLine.length <= maxChars) {
202203
return singleLine;
203204
}
204-
return `${singleLine.slice(0, maxChars)}...`;
205+
return `${truncateUtf16Safe(singleLine, maxChars)}...`;
205206
}

0 commit comments

Comments
 (0)