Skip to content

Commit 99b58c4

Browse files
committed
fix(agents): keep chunkString and buildResumeMessage truncation UTF-16 safe
1 parent 19f7b72 commit 99b58c4

4 files changed

Lines changed: 86 additions & 5 deletions

File tree

src/agents/bash-tools.shared.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Covers strict env parsing and compact session labels.
44
*/
55
import { afterEach, describe, expect, it, vi } from "vitest";
6-
import { deriveSessionName, readEnvInt } from "./bash-tools.shared.js";
6+
import { chunkString, deriveSessionName, readEnvInt } from "./bash-tools.shared.js";
77

88
describe("readEnvInt", () => {
99
afterEach(() => {
@@ -70,3 +70,42 @@ describe("deriveSessionName", () => {
7070
}
7171
});
7272
});
73+
74+
describe("chunkString", () => {
75+
it("preserves surrogate pairs at chunk boundaries", () => {
76+
// 8191 'a' + emoji + "b" = 8194 code units. With chunk limit 8192,
77+
// raw slice(0, 8192) would cut between the emoji's high surrogate at
78+
// index 8191 and low surrogate at index 8192 → lone surrogate in chunk 1.
79+
// sliceUtf16Safe backs out the boundary → both surrogates stay together.
80+
const input = "a".repeat(8191) + "🚀b";
81+
const chunks = chunkString(input, 8192);
82+
expect(chunks.length).toBe(2);
83+
for (const chunk of chunks) {
84+
const rt = new TextDecoder().decode(new TextEncoder().encode(chunk));
85+
expect(rt).not.toContain("�");
86+
}
87+
// The emoji must not be lost across chunks.
88+
const rejoined = chunks.join("");
89+
expect(rejoined).toBe(input);
90+
});
91+
92+
it("returns single chunk for input smaller than limit", () => {
93+
const chunks = chunkString("hello", 8192);
94+
expect(chunks).toEqual(["hello"]);
95+
});
96+
97+
it("splits cleanly when chunk boundary aligns with surrogate pair", () => {
98+
// 2 'a' + emoji + 2 'b' = 6 code units. chunk limit 2 splits at
99+
// index 2 (inside the emoji). sliceUtf16Safe backs out to index 2
100+
// at chunk end and advances to index 3 at chunk start → both halves
101+
// preserved, data not lost.
102+
const input = "aa🚀bb";
103+
const chunks = chunkString(input, 2);
104+
const rejoined = chunks.join("");
105+
expect(rejoined).toBe(input);
106+
for (const chunk of chunks) {
107+
const rt = new TextDecoder().decode(new TextEncoder().encode(chunk));
108+
expect(rt).not.toContain("�");
109+
}
110+
});
111+
});

src/agents/bash-tools.shared.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,11 +133,20 @@ export function readEnvInt(key: string, legacyKey?: string) {
133133
return parseStrictInteger(raw);
134134
}
135135

136-
/** Splits large output into fixed-size UTF-16 chunks for transport. */
136+
/** Splits large output into fixed-size chunks for transport, preserving UTF-16 surrogate pairs at chunk boundaries. */
137137
export function chunkString(input: string, limit = CHUNK_LIMIT) {
138138
const chunks: string[] = [];
139-
for (let i = 0; i < input.length; i += limit) {
140-
chunks.push(input.slice(i, i + limit));
139+
let i = 0;
140+
while (i < input.length) {
141+
const chunk = sliceUtf16Safe(input, i, i + limit);
142+
if (chunk.length === 0 && limit > 0) {
143+
// sliceUtf16Safe backed out completely — force-advance past the
144+
// surrogate pair so we don't loop forever on a tiny limit.
145+
i += Math.max(2, input.codePointAt(i) !== undefined ? 2 : 1);
146+
continue;
147+
}
148+
chunks.push(chunk);
149+
i += chunk.length;
141150
}
142151
return chunks;
143152
}

src/agents/subagent-orphan-recovery.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { callGateway } from "../gateway/call.js";
2323
import { readSessionMessagesAsync } from "../gateway/session-transcript-readers.js";
2424
import { formatErrorMessage } from "../infra/errors.js";
2525
import { createSubsystemLogger } from "../logging/subsystem.js";
26+
import { truncateUtf16Safe } from "../utils.js";
2627
import { resolveInternalSessionEffectsTranscriptPath } from "./internal-session-effects.js";
2728
import {
2829
evaluateSubagentRecoveryGate,
@@ -72,7 +73,8 @@ function reclassifyLegacyRestartInterruptedRun(runRecord: SubagentRunRecord): vo
7273
*/
7374
function buildResumeMessage(task: string, lastHumanMessage?: string): string {
7475
const maxTaskLen = 2000;
75-
const truncatedTask = task.length > maxTaskLen ? `${task.slice(0, maxTaskLen)}...` : task;
76+
const truncatedTask =
77+
task.length > maxTaskLen ? `${truncateUtf16Safe(task, maxTaskLen)}...` : task;
7678

7779
let message =
7880
`[System] Your previous turn was interrupted by a gateway reload. ` +
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// UTF-16-safe truncation test for `task.slice(0, 2000)` in
2+
// buildResumeMessage (src/agents/subagent-orphan-recovery.ts:75).
3+
// Subagent orphan recovery truncates the task description in the resume
4+
// message at 2000 code units, which can split a surrogate pair.
5+
import { describe, expect, it } from "vitest";
6+
import { truncateUtf16Safe } from "../utils.js";
7+
8+
describe("task resume truncation", () => {
9+
it("drops the incomplete emoji pair instead of producing a lone surrogate (maxChars=2000)", () => {
10+
// task = 1999 't' + emoji = 2001 code units. emoji high surrogate at
11+
// index 1999 (within the 2000-char limit), low surrogate at index 2000
12+
// (outside). slice(0, 2000) → lone high surrogate → U+FFFD.
13+
// truncateUtf16Safe(2000) backs out to 1999 pure 't'.
14+
const task = "t".repeat(1999) + "🚀";
15+
expect(task.slice(0, 2000).charCodeAt(1999)).toBe(0xd83d); // lone high surrogate
16+
expect(truncateUtf16Safe(task, 2000)).toBe("t".repeat(1999)); // pair dropped cleanly
17+
});
18+
19+
it("preserves the complete emoji when it fits fully within the boundary", () => {
20+
// task = 1998 't' + emoji = 2000 code units. Both surrogate halves
21+
// are within the limit — no truncation needed.
22+
const task = "t".repeat(1998) + "🚀";
23+
expect(task.length).toBe(2000);
24+
expect(truncateUtf16Safe(task, 2000)).toBe(task);
25+
});
26+
27+
it("preserves task strings shorter than the limit unchanged", () => {
28+
expect(truncateUtf16Safe("deploy database migration", 2000)).toBe("deploy database migration");
29+
expect(truncateUtf16Safe("", 2000)).toBe("");
30+
});
31+
});

0 commit comments

Comments
 (0)