diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c3541db3..06ba50531 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 Composio result or failure is cut between characters, not through an emoji + +A Composio action's answer over 20,000 characters, and a failure sentence as long, 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 the MCP and built-in transports' cuts already do. Anything that fits is untouched, and the +note saying the answer was cut reads as before. + ## 0.0.12 ### A deployment can broker its Bots into a few hundred apps through Composio diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 4e40a10c6..240a926ff 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -1,3 +1,4 @@ +import { cutAtCodeUnits } from "../channels/text"; import { brokerSentence, flagOf } from "./broker"; import { type ListedTool, MAX_RESULT_CHARS, type McpCallResult } from "./mcp"; @@ -1320,7 +1321,7 @@ function listingSentence(toolkit: string, error: unknown): string { function cap(text: string): { text: string; truncated: boolean } { if (text.length <= MAX_RESULT_CHARS) return { text, truncated: false }; return { - text: `${text.slice(0, MAX_RESULT_CHARS)}\n\n[truncated]`, + text: `${cutAtCodeUnits(text, MAX_RESULT_CHARS)}\n\n[truncated]`, truncated: true, }; } diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 8fcbd6940..9c1086a55 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -2377,6 +2377,59 @@ describe("calling one action", () => { expect(result.text.length).toBe(CAPPED_LENGTH); }); + test("a result or a failure over the cap is cut between characters, not through an emoji", async () => { + /* + * `slice` counts UTF-16 code units and an emoji is two of them. When the cap lands between the + * halves, the last unit a model reads is a lone high surrogate: JSON carries it as a bare + * `\ud83d` and UTF-8 as U+FFFD, a broken character that was never in what Composio sent. A + * result and a failure go through the same cap, so both are driven to the same boundary. + */ + const straddling = (lead: number) => + `${"x".repeat(lead)}😀${"x".repeat(100)}`; + // `JSON.stringify(data, null, 2)` writes `{\n "body": "` before the string, so this puts the + // emoji's high surrogate on the cap's last unit. + const opening = '{\n "body": "'.length; + const answers = [ + { + name: "result", + execute: async () => + answered({ body: straddling(RESULT_CAP - opening - 1) }), + }, + { + name: "failure", + execute: async () => { + throw new Error(straddling(RESULT_CAP - 1)); + }, + }, + ]; + + const seen: string[] = []; + for (const { name, execute } of answers) { + useComposioClient(recording({ execute }).client); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + const kept = result.text.endsWith(TRUNCATION_MARKER) + ? result.text.slice(0, -TRUNCATION_MARKER.length) + : result.text; + const last = kept.charCodeAt(kept.length - 1); + const lone = last >= 0xd800 && last <= 0xdbff; + seen.push( + `${name}: truncated ${result.truncated}, marked ${kept !== result.text}, kept ${kept.length}, ends on a lone surrogate ${lone}`, + ); + } + + // The orphan is dropped rather than completed, so the cap is never exceeded. + expect(seen).toEqual([ + `result: truncated true, marked true, kept ${RESULT_CAP - 1}, ends on a lone surrogate false`, + `failure: truncated true, marked true, kept ${RESULT_CAP - 1}, ends on a lone surrogate false`, + ]); + }); + test("an empty answer says so in words rather than being empty", async () => { useComposioClient(recording({ execute: async () => answered({}) }).client);