Skip to content

Commit 53892cc

Browse files
committed
fix(auto-reply,infra): keep startup context and heartbeat event text UTF-16 safe
1 parent b20b02a commit 53892cc

4 files changed

Lines changed: 44 additions & 4 deletions

File tree

src/auto-reply/reply/startup-context.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import os from "node:os";
55
import path from "node:path";
66
import { afterEach, describe, expect, it, vi } from "vitest";
77
import type { OpenClawConfig } from "../../config/config.js";
8-
import { buildSessionStartupContextPrelude, shouldApplyStartupContext } from "./startup-context.js";
8+
import {
9+
buildSessionStartupContextPrelude,
10+
shouldApplyStartupContext,
11+
trimStartupMemoryContent,
12+
} from "./startup-context.js";
913

1014
const tmpDirs: string[] = [];
1115

@@ -493,3 +497,22 @@ describe("shouldApplyStartupContext", () => {
493497
expect(shouldApplyStartupContext({ cfg: applyOnCfg, action: "reset" })).toBe(false);
494498
});
495499
});
500+
501+
describe("trimStartupMemoryContent", () => {
502+
it("returns the original text when it fits within maxChars", () => {
503+
expect(trimStartupMemoryContent("hello", 100)).toBe("hello");
504+
});
505+
506+
it("trims whitespace and truncates long content", () => {
507+
const input = ` ${"x".repeat(200)} `;
508+
const result = trimStartupMemoryContent(input, 100);
509+
expect(result.length).toBeLessThan(input.length);
510+
expect(result).toContain("[truncated]");
511+
});
512+
513+
it("does not split a surrogate pair at the boundary", () => {
514+
const input = `aa🚀${"b".repeat(200)}`;
515+
const result = trimStartupMemoryContent(input, 80);
516+
expect(result).not.toContain("�");
517+
});
518+
});

src/auto-reply/reply/startup-context.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fs from "node:fs";
33
import path from "node:path";
44
import { resolveIntegerOption } from "@openclaw/normalization-core/number-coercion";
55
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
6+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
67
import { formatDateStamp, resolveUserTimezone } from "../../agents/date-time.js";
78
import type { OpenClawConfig } from "../../config/config.js";
89
import { openRootFile } from "../../infra/boundary-file-read.js";
@@ -89,12 +90,12 @@ function buildStartupMemoryDateStamps(params: {
8990
: [...localWindow, utcTodayStamp];
9091
}
9192

92-
function trimStartupMemoryContent(content: string, maxChars: number): string {
93+
export function trimStartupMemoryContent(content: string, maxChars: number): string {
9394
const trimmed = content.trim();
9495
if (trimmed.length <= maxChars) {
9596
return trimmed;
9697
}
97-
return `${trimmed.slice(0, maxChars)}\n...[truncated]...`;
98+
return `${truncateUtf16Safe(trimmed, maxChars)}\n...[truncated]...`;
9899
}
99100

100101
function escapeQuotedStartupMemory(content: string): string {

src/infra/heartbeat-events-filter.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,18 @@ describe("isExecCompletionEvent", () => {
211211
expect(isExecCompletionEvent("Check: exec completed (last run was yesterday)")).toBe(false);
212212
});
213213
});
214+
215+
describe("buildExecEventPrompt truncation", () => {
216+
it("does not split surrogate pairs in long event text", () => {
217+
const emojiLine = `result: aa🚀${"b".repeat(8_000)}`;
218+
const result = buildExecEventPrompt([emojiLine]);
219+
expect(result).not.toContain("�");
220+
expect(result.length).toBeLessThanOrEqual(8_500); // body + notice
221+
});
222+
223+
it("passes through short event text unchanged", () => {
224+
const result = buildExecEventPrompt(["hello"]);
225+
expect(result).toContain("hello");
226+
expect(result).not.toContain("[truncated]");
227+
});
228+
});

src/infra/heartbeat-events-filter.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Filters heartbeat event text before it is added to prompts.
22
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
3+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
34
import { HEARTBEAT_RESPONSE_TOOL_INSTRUCTIONS } from "../auto-reply/heartbeat.js";
45
import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js";
56

@@ -124,7 +125,7 @@ export function buildExecEventPrompt(
124125
const { text: rawEventText, hasMissingOutputFailure } = formatExecEventPromptText(pendingEvents);
125126
const eventText =
126127
rawEventText.length > MAX_EXEC_EVENT_PROMPT_CHARS
127-
? `${rawEventText.slice(0, MAX_EXEC_EVENT_PROMPT_CHARS)}\n\n[truncated]`
128+
? `${truncateUtf16Safe(rawEventText, MAX_EXEC_EVENT_PROMPT_CHARS)}\n\n[truncated]`
128129
: rawEventText;
129130
if (!eventText) {
130131
if (useHeartbeatResponseTool) {

0 commit comments

Comments
 (0)