Skip to content
61 changes: 61 additions & 0 deletions src/plugins/result-truncation-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
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");
});
});
11 changes: 9 additions & 2 deletions src/plugins/result-truncation-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.]`
);
}

Expand Down
Loading