Skip to content

Commit 52e2b00

Browse files
committed
fix(codex): keep CLI session preview text on code-point boundaries
truncateText shortened the cached lastMessage preview with value.slice(0, max - 3), which can cut a surrogate pair in half and emit a lone surrogate into the codex CLI session list JSON. Use the shared truncateUtf16Safe helper so truncation falls back to a whole code-point boundary. Add regressions for both the history.jsonl and sessions/**/*.jsonl preview paths.
1 parent c6f5725 commit 52e2b00

2 files changed

Lines changed: 66 additions & 1 deletion

File tree

extensions/codex/src/node-cli-sessions.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,4 +207,65 @@ describe("codex cli node sessions", () => {
207207
}),
208208
).rejects.toThrow("Codex CLI node command returned malformed payloadJSON.");
209209
});
210+
211+
it("keeps Codex history session previews on UTF-16 code point boundaries", async () => {
212+
const sessionId = "019e2007-1f7e-7eb1-a42b-8c01f4b9b5ce";
213+
const text = `${"a".repeat(136)}🤖tail`;
214+
await fs.writeFile(
215+
path.join(tempDir, "history.jsonl"),
216+
JSON.stringify({ session_id: sessionId, ts: 1778678322, text }),
217+
);
218+
219+
const command = createCodexCliSessionNodeHostCommands().find(
220+
(entry) => entry.command === CODEX_CLI_SESSIONS_LIST_COMMAND,
221+
);
222+
const raw = await command?.handle(JSON.stringify({ filter: "", limit: 5 }));
223+
const parsed = JSON.parse(raw ?? "{}") as {
224+
sessions?: Array<{ lastMessage?: string }>;
225+
};
226+
227+
expect(parsed.sessions?.[0]?.lastMessage).toBe(`${"a".repeat(136)}...`);
228+
expect(parsed.sessions?.[0]?.lastMessage).not.toContain("\ud83e");
229+
expect(parsed.sessions?.[0]?.lastMessage).not.toContain("\udd16");
230+
});
231+
232+
it("keeps Codex session-file previews on UTF-16 code point boundaries", async () => {
233+
const sessionId = "019e23d1-f33d-78e3-959e-0f56f30a5248";
234+
const sessionDir = path.join(tempDir, "sessions", "2026", "05", "14");
235+
const sessionFile = path.join(sessionDir, `rollout-2026-05-14T00-10-22-${sessionId}.jsonl`);
236+
const text = `${"b".repeat(136)}🤖tail`;
237+
238+
await fs.mkdir(sessionDir, { recursive: true });
239+
await fs.writeFile(
240+
sessionFile,
241+
[
242+
JSON.stringify({
243+
timestamp: "2026-05-14T00:10:23.618Z",
244+
type: "session_meta",
245+
payload: { id: sessionId, cwd: "/tmp/codex-work" },
246+
}),
247+
JSON.stringify({
248+
timestamp: "2026-05-14T00:10:23.619Z",
249+
type: "response_item",
250+
payload: {
251+
type: "message",
252+
role: "user",
253+
content: [{ type: "input_text", text }],
254+
},
255+
}),
256+
].join("\n"),
257+
);
258+
259+
const command = createCodexCliSessionNodeHostCommands().find(
260+
(entry) => entry.command === CODEX_CLI_SESSIONS_LIST_COMMAND,
261+
);
262+
const raw = await command?.handle(JSON.stringify({ filter: "", limit: 5 }));
263+
const parsed = JSON.parse(raw ?? "{}") as {
264+
sessions?: Array<{ lastMessage?: string }>;
265+
};
266+
267+
expect(parsed.sessions?.[0]?.lastMessage).toBe(`${"b".repeat(136)}...`);
268+
expect(parsed.sessions?.[0]?.lastMessage).not.toContain("\ud83e");
269+
expect(parsed.sessions?.[0]?.lastMessage).not.toContain("\udd16");
270+
});
210271
});

extensions/codex/src/node-cli-sessions.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
1313
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
1414
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
15+
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
1516
import {
1617
materializeWindowsSpawnProgram,
1718
resolveWindowsSpawnProgram,
@@ -691,7 +692,10 @@ function normalizeTimeoutMs(value: unknown): number {
691692
}
692693

693694
function truncateText(value: string, max: number): string {
694-
return value.length > max ? `${value.slice(0, max - 3)}...` : value;
695+
if (value.length <= max) {
696+
return value;
697+
}
698+
return `${truncateUtf16Safe(value, Math.max(0, max - 3))}...`;
695699
}
696700

697701
function compareOptionalStringsDesc(a?: string, b?: string): number {

0 commit comments

Comments
 (0)