diff --git a/src/plugins/result-truncation-plugin.test.ts b/src/plugins/result-truncation-plugin.test.ts new file mode 100644 index 000000000..008c9fdbb --- /dev/null +++ b/src/plugins/result-truncation-plugin.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; +import { createSizeCapTransform } from "@intx/inference"; +import type { StrategyContext, ToolResult } from "@intx/types/runtime"; +import { MAX_RESULT_CHARS, truncateToolResultContent } from "./result-truncation-plugin.js"; + +describe("truncateToolResultContent", () => { + test("within-cap content passes through unchanged", () => { + const content = "x".repeat(100); + expect(truncateToolResultContent(content)).toBe(content); + }); + + test("oversized content gets a marker that never promises retrievable remainder", () => { + const content = "x".repeat(MAX_RESULT_CHARS + 500); + const truncated = truncateToolResultContent(content); + + expect(truncated).toContain("[output truncated"); + expect(truncated).toContain("NOT retrievable"); + // The pre-cap discard must never be described as recoverable elsewhere. + expect(truncated).not.toContain("see the rest"); + expect(truncated).not.toContain("Full output available"); + }); + + test("truncation marker survives the size-cap blob spill", async () => { + // Reproduce the production pipeline for an output over MAX_RESULT_CHARS: + // truncation runs first (at the tool), size-cap spills the already-cut + // text to a blob and tells the model the blob holds the full output. The + // blob's tail must therefore carry the honest "discarded, NOT retrievable" + // marker so the model does not loop re-running the command. + const original = "x".repeat(MAX_RESULT_CHARS + 500); + const truncated = truncateToolResultContent(original); + + const blobs = new Map(); + const transform = createSizeCapTransform({ + maxChars: 10_000, + contextStore: { + writeBlob: async (key: string, bytes: Uint8Array) => { + blobs.set(key, new TextDecoder().decode(bytes)); + }, + }, + }); + + const result: ToolResult = { + callId: "call-1", + content: truncated, + isError: false, + }; + const { output } = await transform.apply( + { call: { id: "call-1", name: "run_shell", arguments: {} }, result }, + {} as StrategyContext, + ); + + const spilled = blobs.get("call-1"); + expect(spilled).toBe(truncated); + // The blob's tail tells the truth about the pre-spill discard. + expect(spilled).toContain("NOT retrievable"); + expect(spilled?.endsWith("Use offset/limit or a narrower query.]")).toBe(true); + // The inline marker's blob promise is now genuine: the blob really does + // hold everything that still exists. + expect(output.content).toContain("tool-output:///call-1"); + }); +}); diff --git a/src/plugins/result-truncation-plugin.ts b/src/plugins/result-truncation-plugin.ts index 18d53c246..c77796813 100644 --- a/src/plugins/result-truncation-plugin.ts +++ b/src/plugins/result-truncation-plugin.ts @@ -20,10 +20,17 @@ export function truncateToolResultContent( if (content.length <= maxChars) return content; const remaining = content.length - maxChars; + // Truncation happens here, at the source — before the reactor's size-cap + // transform spills to a tool-output:/// blob. The blob therefore holds only + // this already-truncated text, so the marker must say the remainder is gone: + // a "see the blob for the rest" promise would send the model chasing content + // that does not exist and re-running the command in a loop. return ( content.slice(0, maxChars) + - `\n[output truncated — ${remaining.toLocaleString()} characters omitted. ` + - `Use offset/limit params or a more targeted query to see the rest.]` + `\n[output truncated at ${maxChars.toLocaleString()} chars — ` + + `${remaining.toLocaleString()} chars discarded, NOT retrievable ` + + `(no tool-output URI has them; re-running gives the same cut). ` + + `Use offset/limit or a narrower query.]` ); }