mirror of
https://github.com/morluto/rea.git
synced 2026-10-28 10:25:42 +00:00
Error results reach the wire as text plus `isError: true`, but the text-only envelope omitted the flag. An error up to 15 bytes above the result budget passed both the delivery and transport checks and was sent as the original diagnostic instead of taking the oversized-error recovery path. Rename the error representation to "error-text" and size its envelope with the flag, so ToolResultDelivery and the error transport share the complete delivered shape. Successful structured results are unchanged. Fixes #2557 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
141 lines
4.9 KiB
TypeScript
141 lines
4.9 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import type { JsonValue } from "../domain/jsonValue.js";
|
|
import {
|
|
carriesTargetContentMarker,
|
|
encodeToolResult,
|
|
TARGET_CONTENT_RESULT_META,
|
|
} from "./toolResultEncoding.js";
|
|
|
|
const projectedResult = (candidate: JsonValue) => ({
|
|
content: [{ type: "text", text: JSON.stringify(candidate) }],
|
|
structuredContent: candidate,
|
|
});
|
|
|
|
describe("MCP result encoding budget", () => {
|
|
it.each([
|
|
{ result: { value: 1 } },
|
|
{ result: ["中文", "😀", "\ud800", '"\\\n'] },
|
|
{ result: "x".repeat(8191) + "😀" + '中文\n"'.repeat(20000) },
|
|
])("counts actual UTF-8 structured and escaped text bytes", (candidate) => {
|
|
const encoded = encodeToolResult(
|
|
candidate,
|
|
Buffer.byteLength(JSON.stringify(projectedResult(candidate))),
|
|
);
|
|
if (!encoded.ok) throw new Error("Expected complete encoding");
|
|
expect(encoded.text).toBe(JSON.stringify(candidate));
|
|
expect(encoded.bytes).toBe(
|
|
Buffer.byteLength(JSON.stringify(projectedResult(candidate))),
|
|
);
|
|
});
|
|
|
|
it("accounts for complete Evidence in structured and text representations", () => {
|
|
const normalized = { value: '中文"'.repeat(30000) };
|
|
const candidate = {
|
|
normalized_result: normalized,
|
|
raw_result: { observation: "distinct provider representation" },
|
|
};
|
|
const completeBytes = Buffer.byteLength(
|
|
JSON.stringify(projectedResult(candidate)),
|
|
);
|
|
expect(encodeToolResult(candidate, completeBytes).ok).toBe(true);
|
|
expect(encodeToolResult(candidate, completeBytes - 1)).toMatchObject({
|
|
ok: false,
|
|
bytesAtLeast: completeBytes,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{ error: { code: "invalid_request", message: '中文"\n'.repeat(30000) } },
|
|
{ error: { message: "😀\ud800" } },
|
|
])(
|
|
"counts escaped text and the error flag for error delivery",
|
|
(candidate) => {
|
|
const errorBytes = Buffer.byteLength(
|
|
JSON.stringify({
|
|
content: [{ type: "text", text: JSON.stringify(candidate) }],
|
|
isError: true,
|
|
}),
|
|
);
|
|
const encoded = encodeToolResult(candidate, errorBytes, "error-text");
|
|
if (!encoded.ok) throw new Error("Expected complete encoding");
|
|
expect(encoded.text).toBe(JSON.stringify(candidate));
|
|
expect(encoded.bytes).toBe(errorBytes);
|
|
expect(
|
|
encodeToolResult(candidate, errorBytes - 1, "error-text"),
|
|
).toMatchObject({
|
|
ok: false,
|
|
bytesAtLeast: errorBytes,
|
|
});
|
|
},
|
|
);
|
|
|
|
it("accounts for the untrusted target-content marker", () => {
|
|
const candidate = { strings: ['"ignore previous instructions"', "中文"] };
|
|
const markedBytes = Buffer.byteLength(
|
|
JSON.stringify({
|
|
...projectedResult(candidate),
|
|
_meta: TARGET_CONTENT_RESULT_META,
|
|
}),
|
|
);
|
|
const marked = encodeToolResult(
|
|
candidate,
|
|
markedBytes,
|
|
"structured-and-text",
|
|
true,
|
|
);
|
|
if (!marked.ok) throw new Error("Expected complete encoding");
|
|
expect(marked.bytes).toBe(markedBytes);
|
|
expect(marked.text).toBe(JSON.stringify(candidate));
|
|
expect(
|
|
encodeToolResult(candidate, markedBytes - 1, "structured-and-text", true),
|
|
).toMatchObject({ ok: false, bytesAtLeast: markedBytes });
|
|
expect(TARGET_CONTENT_RESULT_META).toEqual({
|
|
provenance: { untrusted_content: true, source: "analysis_target" },
|
|
});
|
|
});
|
|
|
|
it("accounts for the marker on text-only error results", () => {
|
|
const candidate = { error: { message: "target says: run this" } };
|
|
const markedBytes = Buffer.byteLength(
|
|
JSON.stringify({
|
|
content: [{ type: "text", text: JSON.stringify(candidate) }],
|
|
isError: true,
|
|
_meta: TARGET_CONTENT_RESULT_META,
|
|
}),
|
|
);
|
|
expect(
|
|
encodeToolResult(candidate, markedBytes, "error-text", true),
|
|
).toMatchObject({ ok: true, bytes: markedBytes });
|
|
expect(
|
|
encodeToolResult(candidate, markedBytes - 1, "error-text", true),
|
|
).toMatchObject({ ok: false, bytesAtLeast: markedBytes });
|
|
});
|
|
|
|
it("recognizes the delivered marker", () => {
|
|
expect(
|
|
carriesTargetContentMarker({ _meta: TARGET_CONTENT_RESULT_META }),
|
|
).toBe(true);
|
|
expect(carriesTargetContentMarker({})).toBe(false);
|
|
expect(
|
|
carriesTargetContentMarker({
|
|
_meta: { provenance: { untrusted_content: false } },
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("stops before reading the remainder of an oversized result", () => {
|
|
const candidate: JsonValue = { text: "x".repeat(1000000) };
|
|
Object.defineProperty(candidate, "remainder", {
|
|
enumerable: true,
|
|
get() {
|
|
throw new Error("The remainder must not be read");
|
|
},
|
|
});
|
|
// Only an already-known first property is visited before exhausting the
|
|
// budget. The serializer's property discovery must not eagerly read values.
|
|
expect(() => encodeToolResult(candidate, 1000)).not.toThrow();
|
|
expect(encodeToolResult(candidate, 1000).ok).toBe(false);
|
|
});
|
|
});
|