diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index e3385644d90..d401efbd48e 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -116,8 +116,19 @@ export interface Interface extends State.Transformable { export class Service extends Context.Service()("@opencode/SessionCompaction") {} -const truncate = (value: string) => - value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]` +export const truncateToolOutput = (value: string) => { + if (value.length <= TOOL_OUTPUT_MAX_CHARS) return value + let end = 0 + for (let count = 0; count < TOOL_OUTPUT_MAX_CHARS && end < value.length; count++) { + const code = value.charCodeAt(end) + end += + code >= 0xd800 && code <= 0xdbff && value.charCodeAt(end + 1) >= 0xdc00 && value.charCodeAt(end + 1) <= 0xdfff + ? 2 + : 1 + } + if (end === value.length) return value + return `${value.slice(0, end)}\n[truncated]` +} export const serializeToolContent = (content: SessionMessage.ToolStateCompleted["content"]) => content @@ -146,7 +157,7 @@ const serialize = (message: SessionMessage.Info) => { if (part.state.status === "completed") return [ `[Assistant tool call]: ${part.name}(${input})`, - `[Tool result]: ${truncate(serializeToolContent(part.state.content))}`, + `[Tool result]: ${truncateToolOutput(serializeToolContent(part.state.content))}`, ] if (part.state.status === "error") return [`[Assistant tool call]: ${part.name}(${input})`, `[Tool error]: ${part.state.error.message}`] @@ -157,7 +168,8 @@ const serialize = (message: SessionMessage.Info) => { if (message.type === "system") return `[System update]: ${message.text}` if (message.type === "synthetic") return `[Synthetic context]: ${message.text}` if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}` - if (message.type === "shell") return `[Shell]: ${message.command}\n${truncate(message.output?.output ?? "")}` + if (message.type === "shell") + return `[Shell]: ${message.command}\n${truncateToolOutput(message.output?.output ?? "")}` return "" } diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index d49b798aad5..81beb9bef17 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -110,6 +110,13 @@ test("compaction describes tool media without embedding base64", () => { expect(serialized).not.toContain(base64) }) +test("compaction truncation does not split surrogate pairs", () => { + const prefix = "a".repeat(1_999) + + expect(SessionCompaction.truncateToolOutput(`${prefix}😀suffix`)).toBe(`${prefix}😀\n[truncated]`) + expect(SessionCompaction.truncateToolOutput("😀".repeat(2_000))).toBe("😀".repeat(2_000)) +}) + test("compaction prompt requires the checkpoint headings in order", () => { const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] }) expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([