From adad262ba9cf4d8d9a112f98e84462d4c7157a36 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 29 Jul 2026 22:37:22 +0200 Subject: [PATCH] fix(xdev): include truncation marker in summary byte cap (cherry picked from commit aa2067bf7952191beab85b71002aafe812f544bc) --- packages/coding-agent/src/tools/xdev.ts | 14 +++++++++----- .../test/write-xdev-dispatch.test.ts | 17 +++++++++++------ 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/coding-agent/src/tools/xdev.ts b/packages/coding-agent/src/tools/xdev.ts index a65919dcf..073a412a2 100644 --- a/packages/coding-agent/src/tools/xdev.ts +++ b/packages/coding-agent/src/tools/xdev.ts @@ -179,8 +179,10 @@ function toolSummary(inst: Tool): string { return firstLine?.trim() ?? inst.label ?? inst.name; } -/** C0/C1 control characters; a summary must never smuggle escapes or line breaks into the prompt. */ -const SUMMARY_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]+/g; +/** C0/C1 controls and Unicode line/paragraph separators; summaries must remain one line. */ +const SUMMARY_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g; +const SUMMARY_ELLIPSIS = "…"; +const SUMMARY_ELLIPSIS_BYTES = Buffer.byteLength(SUMMARY_ELLIPSIS, "utf-8"); /** * Bound a catalog summary for prompt rendering. External summaries are @@ -191,9 +193,11 @@ const SUMMARY_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]+/g; */ function sanitizeCatalogSummary(summary: string, maxBytes?: number): string { const cleaned = summary.replace(SUMMARY_CONTROL_CHARS, " ").trim(); - if (maxBytes === undefined || maxBytes <= 0) return cleaned; - if (Buffer.byteLength(cleaned, "utf-8") <= maxBytes) return cleaned; - return `${truncateHeadBytes(cleaned, maxBytes).text.trimEnd()}…`; + if (maxBytes === undefined || Buffer.byteLength(cleaned, "utf-8") <= maxBytes) return cleaned; + if (maxBytes <= 0) return ""; + if (maxBytes < SUMMARY_ELLIPSIS_BYTES) return truncateHeadBytes(cleaned, maxBytes).text; + const body = truncateHeadBytes(cleaned, maxBytes - SUMMARY_ELLIPSIS_BYTES).text.trimEnd(); + return `${body}${SUMMARY_ELLIPSIS}`; } function promptCatalogSummary(inst: Tool, maxBytes?: number): string { diff --git a/packages/coding-agent/test/write-xdev-dispatch.test.ts b/packages/coding-agent/test/write-xdev-dispatch.test.ts index b7ba17853..fc1198597 100644 --- a/packages/coding-agent/test/write-xdev-dispatch.test.ts +++ b/packages/coding-agent/test/write-xdev-dispatch.test.ts @@ -302,7 +302,7 @@ describe("read and write route xd:// device URLs", () => { name: "mcp__weather__forecast", label: "Forecast", description: "Weather forecast for a place.", - summary: `Napoved\u0007vremena ${multiByteTail}`, + summary: `Napoved\u0007\u2028vremena ${multiByteTail}`, parameters: type({ query: "string" }), async execute() { return { content: [{ type: "text", text: "" }] }; @@ -324,16 +324,21 @@ describe("read and write route xd:// device URLs", () => { const dynamic = entries.get("mcp__weather__forecast"); if (!dynamic) throw new Error("expected the dynamic device entry"); expect(dynamic.dynamic).toBe(true); - // Control characters collapse to a space instead of reaching the prompt. + // Control characters and Unicode line separators collapse to a space + // instead of reaching the prompt. expect(dynamic.summary.startsWith("Napoved vremena ")).toBe(true); expect(dynamic.summary.endsWith("…")).toBe(true); const body = dynamic.summary.slice(0, -1); const bodyBytes = Buffer.byteLength(body, "utf-8"); - expect(bodyBytes).toBeLessThanOrEqual(XDEV_EXTERNAL_DESCRIPTION_CAP); - // The cut backs off at most one code point: the character straddling the - // budget is dropped whole rather than split. - expect(bodyBytes).toBeGreaterThan(XDEV_EXTERNAL_DESCRIPTION_CAP - 3); + const summaryBytes = Buffer.byteLength(dynamic.summary, "utf-8"); + expect(summaryBytes).toBeLessThanOrEqual(XDEV_EXTERNAL_DESCRIPTION_CAP); + // The ellipsis is inside the byte budget, and the cut backs off at most + // one code point rather than splitting the character at the boundary. + expect(bodyBytes).toBeLessThanOrEqual( + XDEV_EXTERNAL_DESCRIPTION_CAP - Buffer.byteLength("…", "utf-8"), + ); + expect(bodyBytes).toBeGreaterThan(XDEV_EXTERNAL_DESCRIPTION_CAP - 6); expect(body.endsWith("あ")).toBe(true); // A split code point would decode to U+FFFD and fail the round trip. expect(Buffer.from(body, "utf-8").toString("utf-8")).toBe(body);