Skip to content

Commit d7104bd

Browse files
committed
fix(codex,telegram,tui,cli): keep context projection, raw update, TUI shell, and session history truncation UTF-16 safe
1 parent 0de5d37 commit d7104bd

6 files changed

Lines changed: 54 additions & 13 deletions

File tree

extensions/codex/src/app-server/context-engine-projection.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,3 +421,24 @@ describe("projectContextEngineAssemblyForCodex", () => {
421421
);
422422
});
423423
});
424+
425+
describe("truncateText", () => {
426+
it("returns the original text when it fits within maxChars", async () => {
427+
const { truncateText } = await import("./context-engine-projection.js");
428+
expect(truncateText("hello", 100)).toBe("hello");
429+
});
430+
431+
it("truncates and reports the number of dropped characters", async () => {
432+
const { truncateText } = await import("./context-engine-projection.js");
433+
const result = truncateText("x".repeat(200), 100);
434+
expect(result).toContain("[truncated");
435+
expect(result).toContain("chars]");
436+
});
437+
438+
it("does not split a surrogate pair at the truncation boundary", async () => {
439+
const { truncateText } = await import("./context-engine-projection.js");
440+
const input = `aa🚀${"b".repeat(200)}`;
441+
const result = truncateText(input, 80);
442+
expect(result).not.toContain("�");
443+
});
444+
});

extensions/codex/src/app-server/context-engine-projection.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
12
/**
23
* Projects OpenClaw context-engine assemblies into Codex prompt text while
34
* preserving safety boundaries and redacting tool payloads.
@@ -484,10 +485,13 @@ function resolveTextPartMaxChars(maxRenderedContextChars: number): number {
484485
);
485486
}
486487

487-
function truncateText(text: string, maxChars: number): string {
488-
return text.length > maxChars
489-
? `${text.slice(0, maxChars)}\n[truncated ${text.length - maxChars} chars]`
490-
: text;
488+
export function truncateText(text: string, maxChars: number): string {
489+
if (text.length <= maxChars) {
490+
return text;
491+
}
492+
const sliced = truncateUtf16Safe(text, maxChars);
493+
const truncatedChars = text.length - sliced.length;
494+
return `${sliced}\n[truncated ${truncatedChars} chars]`;
491495
}
492496

493497
function truncateOlderContext(text: string, maxChars: number): string {

extensions/telegram/src/bot-core.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
12
// Telegram plugin module implements bot core behavior.
23
import {
34
resolveChannelGroupPolicy,
@@ -248,7 +249,9 @@ export function createTelegramBotCore(
248249
try {
249250
const raw = stringifyTelegramRawUpdateForLog(ctx.update);
250251
const preview =
251-
raw.length > MAX_RAW_UPDATE_CHARS ? `${raw.slice(0, MAX_RAW_UPDATE_CHARS)}...` : raw;
252+
raw.length > MAX_RAW_UPDATE_CHARS
253+
? `${truncateUtf16Safe(raw, MAX_RAW_UPDATE_CHARS)}...`
254+
: raw;
252255
rawUpdateLogger.debug(`telegram update: ${preview}`);
253256
} catch (err) {
254257
rawUpdateLogger.debug(`telegram update log failed: ${String(err)}`);

src/agents/cli-runner/session-history.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,4 +1019,14 @@ describe("buildCliSessionHistoryPrompt", () => {
10191019
expect(prompt).toContain("POST_SUMMARY_TAIL_USER");
10201020
expect(prompt).toContain("POST_SUMMARY_TAIL_ASSISTANT");
10211021
});
1022+
1023+
it("does not produce broken surrogates when reseed history contains emoji", async () => {
1024+
const messages = [
1025+
makeHistory("user", `Hello from a previous session. ${"a".repeat(200)}🚀`),
1026+
makeHistory("assistant", "Got it — here is the answer.".repeat(20)),
1027+
];
1028+
const prompt = buildCliSessionHistoryPrompt({ messages, maxHistoryChars: 600 });
1029+
expect(prompt).toBeDefined();
1030+
expect(prompt).not.toContain("�");
1031+
});
10221032
});

src/agents/cli-runner/session-history.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55
import fsp from "node:fs/promises";
66
import path from "node:path";
7+
import { truncateUtf16Safe, sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
78
import {
89
resolveSessionFilePath,
910
resolveSessionFilePathOptions,
@@ -214,8 +215,8 @@ export function buildCliSessionHistoryPrompt(params: {
214215
0,
215216
maxHistoryChars - truncationMarker.length - separatorBudget - tailBudget,
216217
);
217-
const summaryTruncated = renderedSummary.slice(0, summaryBudget).trimEnd();
218-
const tailTruncated = tailBudget > 0 ? tailRaw.slice(-tailBudget).trimStart() : "";
218+
const summaryTruncated = truncateUtf16Safe(renderedSummary, summaryBudget).trimEnd();
219+
const tailTruncated = tailBudget > 0 ? sliceUtf16Safe(tailRaw, -tailBudget).trimStart() : "";
219220
return [truncationMarker, summaryTruncated, tailTruncated].filter(Boolean).join("\n");
220221
};
221222

@@ -242,7 +243,7 @@ export function buildCliSessionHistoryPrompt(params: {
242243
// reserved tail budget instead of being dropped wholesale.
243244
renderedHistory = renderTruncatedSummaryWithTail(summaryRendered);
244245
} else if (tailRaw.length > remainingBudget) {
245-
renderedHistory = `${summaryBlock}${truncationMarker}\n${tailRaw.slice(-remainingBudget).trimStart()}`;
246+
renderedHistory = `${summaryBlock}${truncationMarker}\n${sliceUtf16Safe(tailRaw, -remainingBudget).trimStart()}`;
246247
} else {
247248
renderedHistory = `${summaryBlock}${tailRaw}`;
248249
}
@@ -253,7 +254,7 @@ export function buildCliSessionHistoryPrompt(params: {
253254
// (older turns dropped, recent tail retained).
254255
renderedHistory =
255256
tailRaw.length > maxHistoryChars
256-
? `${truncationMarker}\n${tailRaw.slice(-maxHistoryChars).trimStart()}`
257+
? `${truncationMarker}\n${sliceUtf16Safe(tailRaw, -maxHistoryChars).trimStart()}`
257258
: tailRaw;
258259
}
259260

src/tui/tui-local-shell.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Launches and manages the local shell process used by TUI local mode.
22
import { spawn } from "node:child_process";
33
import type { Component, OverlayHandle, SelectItem } from "@earendil-works/pi-tui";
4+
import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
45
import { tryProcessCwd } from "../infra/safe-cwd.js";
56
import { createSearchableSelectList } from "./components/selectors.js";
67

@@ -114,7 +115,7 @@ export function createLocalShellRunner(deps: LocalShellDeps) {
114115

115116
const appendWithCap = (text: string, chunk: string) => {
116117
const combined = text + chunk;
117-
return combined.length > maxChars ? combined.slice(-maxChars) : combined;
118+
return combined.length > maxChars ? sliceUtf16Safe(combined, -maxChars) : combined;
118119
};
119120

120121
await new Promise<void>((resolve) => {
@@ -143,9 +144,10 @@ export function createLocalShellRunner(deps: LocalShellDeps) {
143144
// Keep the tail (consistent with the streaming appendWithCap above) so a
144145
// large stdout cannot evict stderr: the failure reason (FATAL etc.) at the
145146
// end is what the operator needs most when output overflows the cap.
146-
const combined = (stdout + (stderr ? (stdout ? "\n" : "") + stderr : ""))
147-
.slice(-maxChars)
148-
.trimEnd();
147+
const combined = sliceUtf16Safe(
148+
stdout + (stderr ? (stdout ? "\n" : "") + stderr : ""),
149+
-maxChars,
150+
).trimEnd();
149151

150152
if (combined) {
151153
for (const lineLocal of combined.split("\n")) {

0 commit comments

Comments
 (0)