From 4ebf65bb06a6123f31f6ff0e8e248ac479b904f5 Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:05:28 +0900 Subject: [PATCH] Cut a long tool result or relayed answer between characters, not through an emoji A tool result over MAX_RESULT_CHARS (in `resultText` and the two builtin transports' `asResult`) and a handoff answer over RELAY_ANSWER_LIMIT were cut with `slice`, which counts UTF-16 code units. When the limit landed between the halves of a surrogate pair, the text handed to the model ended on a lone high surrogate: JSON carries it as a bare `\ud83d` and UTF-8 encodes it as U+FFFD, so the model read a broken character that was never in what the tool or the Bot said. `extractDocumentText` already stops one code unit short in exactly this case for attachments. The same rule now lives in one helper beside `oneLine` and is used at all four cuts. Text that fits, and a cut that lands between characters, are unchanged, and so are the notes saying a result was cut. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 9 +++++++ server/src/agents/handoff-runner.ts | 3 ++- server/src/channels/text.ts | 19 ++++++++++++++ server/src/plugins/builtin-routines.ts | 3 ++- server/src/plugins/google-drive-rest.ts | 3 ++- server/src/plugins/mcp.ts | 3 ++- server/tests/agent-handoff-runner.test.ts | 14 ++++++++++ server/tests/builtin-routines.test.ts | 28 ++++++++++++++++++++ server/tests/google-drive-rest.test.ts | 31 +++++++++++++++++++++++ server/tests/mcp-result.test.ts | 23 +++++++++++++++++ 10 files changed, 132 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9131e34e..155eea49b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### A long tool result or relayed answer is cut between characters, not through an emoji + +A tool result over 20,000 characters, and a Bot's answer over 12,000 relayed back through a handoff, +were cut by UTF-16 code unit. When the cut landed inside an emoji or any other character outside the +Basic Multilingual Plane, the text handed to the model ended on half of it: a lone surrogate that +JSON carries as a bare `\ud83d` and UTF-8 turns into a replacement character. The cut now stops one +unit short in that case, the way an attached text file's already did. Anything that fits is +untouched, and the note saying the result was cut reads as before. + ## 0.0.10 ### The LangGraph Bot says a refused tool call was refused, not that it found nothing diff --git a/server/src/agents/handoff-runner.ts b/server/src/agents/handoff-runner.ts index 3b8354be1..8b1775a91 100644 --- a/server/src/agents/handoff-runner.ts +++ b/server/src/agents/handoff-runner.ts @@ -15,6 +15,7 @@ import { type AuditStore, recordAuditEvent, } from "../audit"; +import { cutAtCodeUnits } from "../channels/text"; import { DEFAULT_MAX_ATTEMPTS, type WorkQueue } from "../work/queue"; import { HANDOFF_KIND } from "./handoff"; @@ -508,7 +509,7 @@ const RELAY_ANSWER_LIMIT = 12_000; function clip(answer: string): string { if (answer.length <= RELAY_ANSWER_LIMIT) return answer; - return `${answer.slice(0, RELAY_ANSWER_LIMIT)}\n\n[…the answer was cut here for length]`; + return `${cutAtCodeUnits(answer, RELAY_ANSWER_LIMIT)}\n\n[…the answer was cut here for length]`; } /** diff --git a/server/src/channels/text.ts b/server/src/channels/text.ts index 52d9c9471..2bb8a7edb 100644 --- a/server/src/channels/text.ts +++ b/server/src/channels/text.ts @@ -9,6 +9,25 @@ */ const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: "grapheme" }); +/** + * The first `limit` UTF-16 code units of `text`, one fewer when the cut would split a character. + * + * `slice` counts code units, and every emoji, every astral-plane glyph and every CJK extension + * character is two of them. A limit landing between the two halves leaves a lone high surrogate as + * the last unit, which is not a character: `JSON.stringify` sends it as a bare `\ud83d` and UTF-8 + * encodes it as U+FFFD, so whatever reads the cut text — a model, most often — is handed a broken + * character that was never in the source. `extractDocumentText` guards the same cut on attachments. + * + * The orphan is dropped rather than completed, so the result never exceeds the limit it was asked + * for. It cannot be a lone surrogate that was already in the text and happened to land last: only a + * high surrogate is dropped, and one followed by its pair in the source is exactly the split case. + */ +export function cutAtCodeUnits(text: string, limit: number): string { + const sliced = text.slice(0, limit); + const last = sliced.charCodeAt(sliced.length - 1); + return last >= 0xd800 && last <= 0xdbff ? sliced.slice(0, -1) : sliced; +} + /** * One line a roster can draw: control characters stripped, whitespace collapsed, cut on grapheme * clusters so an emoji is never split. The caller supplies the cap; a preview and a title want diff --git a/server/src/plugins/builtin-routines.ts b/server/src/plugins/builtin-routines.ts index b0b626abd..5fc9638b2 100644 --- a/server/src/plugins/builtin-routines.ts +++ b/server/src/plugins/builtin-routines.ts @@ -1,3 +1,4 @@ +import { cutAtCodeUnits } from "../channels/text"; import { MAX_RUN_ERROR, type Routine, @@ -242,7 +243,7 @@ function asResult(text: string): McpCallResult { return { text, isError: false, truncated: false }; } return { - text: `${text.slice(0, MAX_RESULT_CHARS)}\n\n[truncated: the tool returned ${text.length} characters]`, + text: `${cutAtCodeUnits(text, MAX_RESULT_CHARS)}\n\n[truncated: the tool returned ${text.length} characters]`, isError: false, truncated: true, }; diff --git a/server/src/plugins/google-drive-rest.ts b/server/src/plugins/google-drive-rest.ts index 99b49bb35..605ca5496 100644 --- a/server/src/plugins/google-drive-rest.ts +++ b/server/src/plugins/google-drive-rest.ts @@ -1,3 +1,4 @@ +import { cutAtCodeUnits } from "../channels/text"; import { MAX_RESULT_CHARS, type McpCallResult, type McpTool } from "./mcp"; /** @@ -292,7 +293,7 @@ function asResult(text: string): McpCallResult { return { text: joined, isError: false, truncated: false }; } return { - text: `${joined.slice(0, MAX_RESULT_CHARS)}\n\n[truncated: the tool returned ${joined.length} characters]`, + text: `${cutAtCodeUnits(joined, MAX_RESULT_CHARS)}\n\n[truncated: the tool returned ${joined.length} characters]`, isError: false, truncated: true, }; diff --git a/server/src/plugins/mcp.ts b/server/src/plugins/mcp.ts index 23e5599d7..791a70ac4 100644 --- a/server/src/plugins/mcp.ts +++ b/server/src/plugins/mcp.ts @@ -1,5 +1,6 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { cutAtCodeUnits } from "../channels/text"; /** * The only place in this deployment that speaks MCP to somebody else's server. @@ -74,7 +75,7 @@ export function resultText(content: unknown): { return { text: joined, truncated: false }; } return { - text: `${joined.slice(0, MAX_RESULT_CHARS)}\n\n[truncated: the tool returned ${joined.length} characters]`, + text: `${cutAtCodeUnits(joined, MAX_RESULT_CHARS)}\n\n[truncated: the tool returned ${joined.length} characters]`, truncated: true, }; } diff --git a/server/tests/agent-handoff-runner.test.ts b/server/tests/agent-handoff-runner.test.ts index f13301a45..a2ef67ee2 100644 --- a/server/tests/agent-handoff-runner.test.ts +++ b/server/tests/agent-handoff-runner.test.ts @@ -518,6 +518,20 @@ describe("relaying the answer home", () => { expect(offered[0]?.task).toContain("[…the answer was cut here for length]"); expect((offered[0]?.task ?? "").length).toBeLessThan(14_000); }); + + test("an answer cut inside a character loses the whole character, not half of it", async () => { + // 12,000 code units are kept, and an emoji is two: its high half on the last kept unit would + // reach the relaying run's prompt as a lone surrogate. + const { runner: sweeper, offered } = runner({ + answer: `${"x".repeat(11_999)}😀tail`, + }); + + await sweeper.sweep(); + + expect(offered[0]?.task).toContain( + `${"x".repeat(11_999)}\n\n[…the answer was cut here for length]`, + ); + }); }); /** diff --git a/server/tests/builtin-routines.test.ts b/server/tests/builtin-routines.test.ts index a9b3d8c68..cbcbef6ae 100644 --- a/server/tests/builtin-routines.test.ts +++ b/server/tests/builtin-routines.test.ts @@ -5,6 +5,7 @@ import { type RoutineTools, useRoutineTools, } from "../src/plugins/builtin-routines"; +import { MAX_RESULT_CHARS } from "../src/plugins/mcp"; import { type Routine, RoutineNotFoundError, @@ -104,6 +105,33 @@ afterEach(() => { useRoutineTools(null); }); +describe("a list too long for one result", () => { + test("is cut between characters, never through one", async () => { + const listing = (instruction: string) => + recordingTools({ + async listFor() { + return [{ ...SUMMARY, instruction }]; + }, + }); + + // Where the instruction starts in a listing, measured rather than assumed, so the emoji's high + // half lands on the last code unit the limit keeps whatever words surround it. + listing("MARKER"); + const probe = await callTool(CONNECTION, "list_routines", {}); + const before = probe.text.indexOf("MARKER"); + expect(before).toBeGreaterThan(-1); + + const filler = "a".repeat(MAX_RESULT_CHARS - 1 - before); + listing(`${filler}😀tail`); + const result = await callTool(CONNECTION, "list_routines", {}); + + expect(result.truncated).toBe(true); + expect(result.text.split("\n\n[truncated")[0]).toBe( + `${probe.text.slice(0, before)}${filler}`, + ); + }); +}); + describe("the tool list", () => { test("is the four routine tools, named exactly", async () => { const tools = await listTools(); diff --git a/server/tests/google-drive-rest.test.ts b/server/tests/google-drive-rest.test.ts index 0cf1cbdf1..9163e7516 100644 --- a/server/tests/google-drive-rest.test.ts +++ b/server/tests/google-drive-rest.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { catalogueEntry } from "../src/plugins/catalogue"; import { callTool, listTools } from "../src/plugins/google-drive-rest"; +import { MAX_RESULT_CHARS } from "../src/plugins/mcp"; import { transportFor } from "../src/plugins/transport"; /** @@ -236,4 +237,34 @@ describe("reading a file asks Drive what it is first", () => { expect(result.isError).toBe(true); expect(calls).toHaveLength(0); }); + + test("a file too long for one result is cut between characters, never through one", async () => { + // The name and a blank line lead the result, so the filler is sized to put an emoji's high half + // on the last code unit the limit keeps. + const heading = "notes.txt\n\n"; + const filler = "a".repeat(MAX_RESULT_CHARS - 1 - heading.length); + let served = 0; + globalThis.fetch = (async () => { + served += 1; + return served === 1 + ? new Response( + JSON.stringify({ + id: "txt1", + name: "notes.txt", + mimeType: "text/plain", + }), + { headers: { "content-type": "application/json" } }, + ) + : new Response(`${filler}😀tail`, { + headers: { "content-type": "text/plain" }, + }); + }) as unknown as typeof fetch; + + const result = await callTool(connection, "read_file_content", { + fileId: "txt1", + }); + + expect(result.truncated).toBe(true); + expect(result.text.split("\n\n[truncated")[0]).toBe(`${heading}${filler}`); + }); }); diff --git a/server/tests/mcp-result.test.ts b/server/tests/mcp-result.test.ts index 4f0a83aa6..604674869 100644 --- a/server/tests/mcp-result.test.ts +++ b/server/tests/mcp-result.test.ts @@ -99,4 +99,27 @@ describe("a result too large to hand a model", () => { expect(truncated).toBe(false); expect(text).toBe(exact); }); + + test("a cut that would land inside a character stops one code unit short", () => { + // The limit counts UTF-16 code units, and an emoji is two of them. Cut between the two and the + // result ends on a lone high surrogate: `JSON.stringify` sends it as a bare `\ud83d` and UTF-8 + // turns it into U+FFFD, so the model is handed a broken character for a reason that has nothing + // to do with what the tool said. `extractDocumentText` guards the same cut on attachments. + const emoji = "😀"; + expect(emoji.length).toBe(2); + const input = `${"a".repeat(MAX_RESULT_CHARS - 1)}${emoji}tail`; + const { text, truncated } = resultText([{ type: "text", text: input }]); + expect(truncated).toBe(true); + expect(text).toBe( + `${"a".repeat(MAX_RESULT_CHARS - 1)}\n\n[truncated: the tool returned ${input.length} characters]`, + ); + }); + + test("a cut that lands between characters still keeps the whole limit", () => { + const input = `${"a".repeat(MAX_RESULT_CHARS - 2)}😀tail`; + const { text } = resultText([{ type: "text", text: input }]); + expect(text).toBe( + `${"a".repeat(MAX_RESULT_CHARS - 2)}😀\n\n[truncated: the tool returned ${input.length} characters]`, + ); + }); });