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:
cliffhall 2026-08-27 06:26:23 -04:00
commit 684eb9c7bc
2 changed files with 96 additions and 7 deletions

View file

@ -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.

View file

@ -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