fix(web): reject an underflowing literal, and route embedded JSON resources
Copilot's thirteenth pass, two comments, both real. - `1e-400` has a nonzero mantissa and parses to `0`. The digit comparison skips it for being exponent-form and it is not negative zero, so nothing saw a document that says one number and sends none of it. Caught now by pairing `Number(literal) === 0` with a nonzero digit in the mantissa, which leaves a literal written as zero (`0e-400`) and a subnormal that still parses to something (`1e-320`) alone. - The `resource` content-block branch rendered every embedded resource in a plain code block, ignoring `resource.mimeType`, so a prompt message carrying JSON did not inherit this migration even though the text-block branch did. An embedded *text* resource declaring JSON now goes through the same dispatch. Narrowed to JSON, as the Network tab's bodies are: routing every declared type through would also put an embedded `text/html` resource into the sandboxed frame and a `text/csv` one into a table. The Resources screen already renders resource contents that way, so doing it here too is defensible — but it is a change to what a tool result *is*, and belongs to whoever decides that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4 Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
8aa6f01505
commit
684eb9c7bc
4 changed files with 96 additions and 7 deletions
|
|
@ -263,6 +263,39 @@ describe("ContentViewer", () => {
|
|||
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The `resource` block branch used to render every embedded resource in a
|
||||
// plain code block, so a prompt message carrying JSON did not inherit this
|
||||
// migration even though the text-block branch did.
|
||||
it("renders an embedded JSON resource in the JSON editor", () => {
|
||||
const block: ContentBlock = {
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: "file:///a.json",
|
||||
mimeType: "application/json",
|
||||
text: '{"a":1}',
|
||||
},
|
||||
};
|
||||
renderWithMantine(<ContentViewer block={block} />);
|
||||
expect(getAceText()).toBe('{\n "a": 1\n}');
|
||||
});
|
||||
|
||||
// Narrowed to JSON on purpose: routing every declared type through would put
|
||||
// an embedded `text/html` resource into the sandboxed frame, which is a
|
||||
// change to what a tool result is.
|
||||
it("leaves a non-JSON embedded resource on the plain renderer", () => {
|
||||
const block: ContentBlock = {
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: "file:///a.html",
|
||||
mimeType: "text/html",
|
||||
text: "<p>hi</p>",
|
||||
},
|
||||
};
|
||||
renderWithMantine(<ContentViewer block={block} />);
|
||||
expect(screen.getByText("<p>hi</p>")).toBeInTheDocument();
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing when neither block nor contents is provided", () => {
|
||||
const { container } = renderWithMantine(<ContentViewer />);
|
||||
// MantineProvider injects a <style> node; assert no actual content renders.
|
||||
|
|
|
|||
|
|
@ -482,18 +482,40 @@ function BlockContent({
|
|||
return <ImageContent data={block.data} mimeType={block.mimeType} />;
|
||||
case "audio":
|
||||
return <AudioContent data={block.data} mimeType={block.mimeType} />;
|
||||
case "resource":
|
||||
case "resource": {
|
||||
// An embedded *text* resource that declares JSON goes through the same
|
||||
// dispatch a text block does, so this branch inherits the JSON renderer
|
||||
// rather than staying on a plain code block (#2151).
|
||||
//
|
||||
// Narrowed to JSON on purpose, the same way the Network tab's bodies are:
|
||||
// routing every declared type through would also put an embedded
|
||||
// `text/html` resource into the sandboxed frame and a `text/csv` one into
|
||||
// a table. The Resources screen already renders resource contents that
|
||||
// way, so doing it here too is defensible — but it is a change to what a
|
||||
// tool result *is*, and belongs to whoever decides that rather than to
|
||||
// this swap.
|
||||
const embedded = "text" in block.resource ? block.resource : null;
|
||||
if (embedded && getMimeKind(embedded.mimeType ?? "") === "json") {
|
||||
return (
|
||||
<TextualContent
|
||||
text={embedded.text}
|
||||
mimeType={embedded.mimeType}
|
||||
copyable={copyable}
|
||||
wrap={wrap}
|
||||
jsonLabel={jsonLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<ContentWrapper>
|
||||
<CodeBlock>
|
||||
{"text" in block.resource
|
||||
? block.resource.text
|
||||
: `[blob: ${block.resource.uri}]`}
|
||||
{embedded ? embedded.text : `[blob: ${block.resource.uri}]`}
|
||||
</CodeBlock>
|
||||
</ContentWrapper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
case "resource_link":
|
||||
// Static metadata only. The interactive, read-on-demand presentation
|
||||
// lives in the `groups/ResourceLink` group, rendered by content-block
|
||||
|
|
|
|||
|
|
@ -78,9 +78,26 @@ describe("findUnsendableNumberLiteral", () => {
|
|||
},
|
||||
);
|
||||
|
||||
// A nonzero literal that parses to zero anyway: the digit comparison skips it
|
||||
// for being exponent-form, and it is not negative zero, so nothing else here
|
||||
// would see a document that says one number and sends none of it.
|
||||
it.each(["1e-400", "-1e-400", "0.00001e-400", "9e-999"])(
|
||||
"rejects a literal that underflows to zero: %s",
|
||||
(literal) => {
|
||||
expect(findUnsendableNumberLiteral(`{"n":${literal}}`)).toBe(literal);
|
||||
},
|
||||
);
|
||||
|
||||
// Subnormal but representable — a real value, not an underflow to nothing.
|
||||
it("accepts a subnormal that still parses to something", () => {
|
||||
expect(findUnsendableNumberLiteral('{"n":1e-320}')).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts a positive zero", () => {
|
||||
expect(findUnsendableNumberLiteral('{"n":0}')).toBeNull();
|
||||
expect(findUnsendableNumberLiteral('{"n":0.0}')).toBeNull();
|
||||
// Written as zero, so it is zero — not an underflow.
|
||||
expect(findUnsendableNumberLiteral('{"n":0e-400}')).toBeNull();
|
||||
});
|
||||
|
||||
it("says nothing about text that is not JSON", () => {
|
||||
|
|
|
|||
|
|
@ -41,9 +41,10 @@ export function isJsonObject(value: unknown): value is StrictJsonObject {
|
|||
* Only integer literals written out in full are checked for lost digits. An
|
||||
* exponent or a fraction is a double by nature and is sent as the double it
|
||||
* parsed to, so nothing is lost between typing and sending — and `{"n":1e308}`
|
||||
* stays acceptable, which `parseJsonObjectDraft`'s own tests pin. Negative zero
|
||||
* is the exception, and is checked in every spelling: there the loss happens on
|
||||
* the way *out*, not on the way in.
|
||||
* stays acceptable, which `parseJsonObjectDraft`'s own tests pin. The two
|
||||
* exceptions are checked in every spelling: negative zero, where the loss
|
||||
* happens on the way *out*, and underflow, where a nonzero literal parses to
|
||||
* zero on the way in.
|
||||
*/
|
||||
export function findUnsendableNumberLiteral(text: string): string | null {
|
||||
for (const literal of jsonNumberLiterals(text)) {
|
||||
|
|
@ -51,6 +52,13 @@ export function findUnsendableNumberLiteral(text: string): string | null {
|
|||
// exactly, so the digit comparison below has nothing to say about it, but
|
||||
// `JSON.stringify` writes it as `0`: the sign is lost on the way out.
|
||||
if (Object.is(Number(literal), -0)) return literal;
|
||||
// Underflow: a literal with a nonzero mantissa that parses to zero anyway,
|
||||
// `1e-400` being the shape of it. The digit comparison skips it for being
|
||||
// exponent-form, and it is not negative zero, so nothing else here sees a
|
||||
// document that says one number and sends none of it.
|
||||
if (Number(literal) === 0 && /[1-9]/.test(mantissaOf(literal))) {
|
||||
return literal;
|
||||
}
|
||||
if (!/^-?\d+$/.test(literal)) continue;
|
||||
// BigInt on both sides, so the comparison is exact: `Number(literal)` is an
|
||||
// integer-valued double, and converting it back shows what survived.
|
||||
|
|
@ -59,6 +67,15 @@ export function findUnsendableNumberLiteral(text: string): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The part of a numeric literal before its exponent — `1` of `1e-400`, `-0.0`
|
||||
* of `-0.0e5`. Whether it holds a nonzero digit is what separates a literal
|
||||
* that underflowed to zero from one that was written as zero.
|
||||
*/
|
||||
function mantissaOf(literal: string): string {
|
||||
return literal.split(/[eE]/)[0];
|
||||
}
|
||||
|
||||
/** Matches one JSON number token, anchored where the scan currently stands. */
|
||||
const JSON_NUMBER = /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue