Merge branch 'main' into completion-support-default

This commit is contained in:
Ola Hungerford 2025-07-11 20:23:17 -07:00 • committed by GitHub
commit 430d6d4712
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 612 additions and 127 deletions

View file

@ -80,6 +80,9 @@ const App = () => {
ResourceTemplate[]
>([]);
const [resourceContent, setResourceContent] = useState<string>("");
const [resourceContentMap, setResourceContentMap] = useState<
Record<string, string>
>({});
const [prompts, setPrompts] = useState<Prompt[]>([]);
const [promptContent, setPromptContent] = useState<string>("");
const [tools, setTools] = useState<Tool[]>([]);
@ -461,7 +464,12 @@ const App = () => {
ReadResourceResultSchema,
"resources",
);
setResourceContent(JSON.stringify(response, null, 2));
const content = JSON.stringify(response, null, 2);
setResourceContent(content);
setResourceContentMap((prev) => ({
...prev,
[uri]: content,
}));
};
const subscribeToResource = async (uri: string) => {
@ -863,6 +871,11 @@ const App = () => {
toolResult={toolResult}
nextCursor={nextToolCursor}
error={errors.tools}
resourceContent={resourceContentMap}
onReadResource={(uri: string) => {
clearError("resources");
readResource(uri);
}}
/>
<ConsoleTab />
<PingTab

View file

@ -5,6 +5,8 @@ import JsonEditor from "./JsonEditor";
import { updateValueAtPath } from "@/utils/jsonUtils";
import { generateDefaultValue } from "@/utils/schemaUtils";
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
import { useToast } from "@/lib/hooks/useToast";
import { CheckCheck, Copy } from "lucide-react";
interface DynamicJsonFormProps {
schema: JsonSchemaType;
@ -60,6 +62,9 @@ const DynamicJsonForm = ({
const isOnlyJSON = !isSimpleObject(schema);
const [isJsonMode, setIsJsonMode] = useState(isOnlyJSON);
const [jsonError, setJsonError] = useState<string>();
const [copiedJson, setCopiedJson] = useState<boolean>(false);
const { toast } = useToast();
// Store the raw JSON string to allow immediate feedback during typing
// while deferring parsing until the user stops typing
const [rawJsonValue, setRawJsonValue] = useState<string>(
@ -399,19 +404,64 @@ const DynamicJsonForm = ({
}
}, [shouldUseJsonMode, isJsonMode]);
const handleCopyJson = useCallback(() => {
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(
JSON.stringify(value, null, 2) ?? "[]",
);
setCopiedJson(true);
toast({
title: "JSON copied",
description:
"The JSON data has been successfully copied to your clipboard.",
});
setTimeout(() => {
setCopiedJson(false);
}, 2000);
} catch (error) {
toast({
title: "Error",
description: `Failed to copy JSON: ${error instanceof Error ? error.message : String(error)}`,
variant: "destructive",
});
}
};
copyToClipboard();
}, [toast, value]);
return (
<div className="space-y-4">
<div className="flex justify-end space-x-2">
{isJsonMode && (
<Button
type="button"
variant="outline"
size="sm"
onClick={formatJson}
>
Format JSON
</Button>
<>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCopyJson}
>
{copiedJson ? (
<CheckCheck className="h-4 w-4 mr-2" />
) : (
<Copy className="h-4 w-4 mr-2" />
)}
Copy JSON
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={formatJson}
>
Format JSON
</Button>
</>
)}
{!isOnlyJSON && (
<Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
{isJsonMode ? "Switch to Form" : "Switch to JSON"}

View file

@ -0,0 +1,112 @@
import { useState, useCallback, useMemo, memo } from "react";
import JsonView from "./JsonView";
interface ResourceLinkViewProps {
uri: string;
name?: string;
description?: string;
mimeType?: string;
resourceContent: string;
onReadResource?: (uri: string) => void;
}
const ResourceLinkView = memo(
({
uri,
name,
description,
mimeType,
resourceContent,
onReadResource,
}: ResourceLinkViewProps) => {
const [{ expanded, loading }, setState] = useState({
expanded: false,
loading: false,
});
const expandedContent = useMemo(
() =>
expanded && resourceContent ? (
<div className="mt-2">
<div className="flex justify-between items-center mb-1">
<span className="font-semibold text-green-600">Resource:</span>
</div>
<JsonView data={resourceContent} className="bg-background" />
</div>
) : null,
[expanded, resourceContent],
);
const handleClick = useCallback(() => {
if (!onReadResource) return;
if (!expanded) {
setState((prev) => ({ ...prev, expanded: true, loading: true }));
onReadResource(uri);
setState((prev) => ({ ...prev, loading: false }));
} else {
setState((prev) => ({ ...prev, expanded: false }));
}
}, [expanded, onReadResource, uri]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if ((e.key === "Enter" || e.key === " ") && onReadResource) {
e.preventDefault();
handleClick();
}
},
[handleClick, onReadResource],
);
return (
<div className="text-sm text-foreground bg-secondary py-2 px-3 rounded">
<div
className="flex justify-between items-center cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1 rounded"
onClick={onReadResource ? handleClick : undefined}
onKeyDown={onReadResource ? handleKeyDown : undefined}
tabIndex={onReadResource ? 0 : -1}
role="button"
aria-expanded={expanded}
aria-label={`${expanded ? "Collapse" : "Expand"} resource ${uri}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-1">
<span className="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:underline px-1 py-0.5 break-all font-mono flex-1 min-w-0">
{uri}
</span>
<div className="flex items-center gap-2 flex-shrink-0">
{mimeType && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{mimeType}
</span>
)}
{onReadResource && (
<span className="ml-2 flex-shrink-0" aria-hidden="true">
{loading ? (
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
) : (
<span>{expanded ? "▼" : "▶"}</span>
)}
</span>
)}
</div>
</div>
{name && (
<div className="font-semibold text-sm text-gray-900 dark:text-gray-100 mb-1">
{name}
</div>
)}
{description && (
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
{description}
</p>
)}
</div>
</div>
{expandedContent}
</div>
);
},
);
export default ResourceLinkView;

View file

@ -1,4 +1,5 @@
import JsonView from "./JsonView";
import ResourceLinkView from "./ResourceLinkView";
import {
CallToolResultSchema,
CompatibilityCallToolResult,
@ -9,6 +10,8 @@ import { validateToolOutput, hasOutputSchema } from "@/utils/schemaUtils";
interface ToolResultsProps {
toolResult: CompatibilityCallToolResult | null;
selectedTool: Tool | null;
resourceContent: Record<string, string>;
onReadResource?: (uri: string) => void;
}
const checkContentCompatibility = (
@ -19,49 +22,54 @@ const checkContentCompatibility = (
[key: string]: unknown;
}>,
): { isCompatible: boolean; message: string } => {
if (
unstructuredContent.length !== 1 ||
unstructuredContent[0].type !== "text"
) {
// Look for at least one text content block that matches the structured content
const textBlocks = unstructuredContent.filter(
(block) => block.type === "text",
);
if (textBlocks.length === 0) {
return {
isCompatible: false,
message: "Unstructured content is not a single text block",
message: "No text blocks to match structured content",
};
}
const textContent = unstructuredContent[0].text;
if (!textContent) {
return {
isCompatible: false,
message: "Text content is empty",
};
}
try {
const parsedContent = JSON.parse(textContent);
const isEqual =
JSON.stringify(parsedContent) === JSON.stringify(structuredContent);
if (isEqual) {
return {
isCompatible: true,
message: "Unstructured content matches structured content",
};
} else {
return {
isCompatible: false,
message: "Parsed JSON does not match structured content",
};
// Check if any text block contains JSON that matches the structured content
for (const textBlock of textBlocks) {
const textContent = textBlock.text;
if (!textContent) {
continue;
}
try {
const parsedContent = JSON.parse(textContent);
const isEqual =
JSON.stringify(parsedContent) === JSON.stringify(structuredContent);
if (isEqual) {
return {
isCompatible: true,
message: `Structured content matches text block${textBlocks.length > 1 ? " (multiple blocks)" : ""}${unstructuredContent.length > textBlocks.length ? " + other content" : ""}`,
};
}
} catch {
// Continue to next text block if this one doesn't parse as JSON
continue;
}
} catch {
return {
isCompatible: false,
message: "Unstructured content is not valid JSON",
};
}
return {
isCompatible: false,
message: "No text block matches structured content",
};
};
const ToolResults = ({ toolResult, selectedTool }: ToolResultsProps) => {
const ToolResults = ({
toolResult,
selectedTool,
resourceContent,
onReadResource,
}: ToolResultsProps) => {
if (!toolResult) return null;
if ("content" in toolResult) {
@ -200,6 +208,16 @@ const ToolResults = ({ toolResult, selectedTool }: ToolResultsProps) => {
) : (
<JsonView data={item.resource} />
))}
{item.type === "resource_link" && (
<ResourceLinkView
uri={item.uri}
name={item.name}
description={item.description}
mimeType={item.mimeType}
resourceContent={resourceContent[item.uri] || ""}
onReadResource={onReadResource}
/>
)}
</div>
))}
</div>

View file

@ -28,6 +28,8 @@ const ToolsTab = ({
setSelectedTool,
toolResult,
nextCursor,
resourceContent,
onReadResource,
}: {
tools: Tool[];
listTools: () => void;
@ -38,6 +40,8 @@ const ToolsTab = ({
toolResult: CompatibilityCallToolResult | null;
nextCursor: ListToolsResult["nextCursor"];
error: string | null;
resourceContent: Record<string, string>;
onReadResource?: (uri: string) => void;
}) => {
const [params, setParams] = useState<Record<string, unknown>>({});
const [isToolRunning, setIsToolRunning] = useState(false);
@ -267,6 +271,8 @@ const ToolsTab = ({
<ToolResults
toolResult={toolResult}
selectedTool={selectedTool}
resourceContent={resourceContent}
onReadResource={onReadResource}
/>
</div>
) : (

View file

@ -117,8 +117,11 @@ describe("DynamicJsonForm Complex Fields", () => {
const input = screen.getByRole("textbox");
expect(input).toHaveProperty("type", "textarea");
const buttons = screen.getAllByRole("button");
expect(buttons).toHaveLength(1);
expect(buttons[0]).toHaveProperty("textContent", "Format JSON");
expect(buttons).toHaveLength(2); // Copy JSON + Format JSON
const copyButton = screen.getByRole("button", { name: /copy json/i });
const formatButton = screen.getByRole("button", { name: /format json/i });
expect(copyButton).toBeTruthy();
expect(formatButton).toBeTruthy();
});
it("should pass changed values to onChange", () => {
@ -137,3 +140,72 @@ describe("DynamicJsonForm Complex Fields", () => {
});
});
});
describe("DynamicJsonForm Copy JSON Functionality", () => {
const mockWriteText = jest.fn(() => Promise.resolve());
beforeEach(() => {
jest.clearAllMocks();
Object.assign(navigator, {
clipboard: {
writeText: mockWriteText,
},
});
});
const renderFormInJsonMode = (props = {}) => {
const defaultProps = {
schema: {
type: "object",
properties: {
nested: { oneOf: [{ type: "string" }, { type: "integer" }] },
},
} as unknown as JsonSchemaType,
value: { nested: "test value" },
onChange: jest.fn(),
};
return render(<DynamicJsonForm {...defaultProps} {...props} />);
};
describe("Copy JSON Button", () => {
it("should render Copy JSON button when in JSON mode", () => {
renderFormInJsonMode();
const copyButton = screen.getByRole("button", { name: "Copy JSON" });
expect(copyButton).toBeTruthy();
});
it("should not render Copy JSON button when not in JSON mode", () => {
const simpleSchema = {
type: "string" as const,
description: "Test string field",
};
render(
<DynamicJsonForm
schema={simpleSchema}
value="test"
onChange={jest.fn()}
/>,
);
const copyButton = screen.queryByRole("button", { name: "Copy JSON" });
expect(copyButton).toBeNull();
});
it("should copy JSON to clipboard when clicked", async () => {
const testValue = { nested: "test value", number: 42 };
renderFormInJsonMode({ value: testValue });
const copyButton = screen.getByRole("button", { name: "Copy JSON" });
fireEvent.click(copyButton);
await waitFor(() => {
expect(mockWriteText).toHaveBeenCalledWith(
JSON.stringify(testValue, null, 2),
);
});
});
});
});

View file

@ -55,6 +55,8 @@ describe("ToolsTab", () => {
toolResult: null,
nextCursor: "",
error: null,
resourceContent: {},
onReadResource: jest.fn(),
};
const renderToolsTab = (props = {}) => {
@ -249,10 +251,12 @@ describe("ToolsTab", () => {
},
};
it("should display structured content when present", () => {
// Cache the tool's output schema so hasOutputSchema returns true
beforeEach(() => {
// Cache the tool's output schema before each test
cacheToolOutputSchemas([toolWithOutputSchema]);
});
it("should display structured content when present", () => {
const structuredResult = {
content: [],
structuredContent: {
@ -261,6 +265,7 @@ describe("ToolsTab", () => {
};
renderToolsTab({
tools: [toolWithOutputSchema],
selectedTool: toolWithOutputSchema,
toolResult: structuredResult,
});
@ -272,8 +277,6 @@ describe("ToolsTab", () => {
});
it("should show validation error for invalid structured content", () => {
cacheToolOutputSchemas([toolWithOutputSchema]);
const invalidResult = {
content: [],
structuredContent: {
@ -282,6 +285,7 @@ describe("ToolsTab", () => {
};
renderToolsTab({
tools: [toolWithOutputSchema],
selectedTool: toolWithOutputSchema,
toolResult: invalidResult,
});
@ -290,14 +294,13 @@ describe("ToolsTab", () => {
});
it("should show error when tool with output schema doesn't return structured content", () => {
cacheToolOutputSchemas([toolWithOutputSchema]);
const resultWithoutStructured = {
content: [{ type: "text", text: "some result" }],
// No structuredContent
};
renderToolsTab({
tools: [toolWithOutputSchema],
selectedTool: toolWithOutputSchema,
toolResult: resultWithoutStructured,
});
@ -310,14 +313,13 @@ describe("ToolsTab", () => {
});
it("should show unstructured content title when both structured and unstructured exist", () => {
cacheToolOutputSchemas([toolWithOutputSchema]);
const resultWithBoth = {
content: [{ type: "text", text: '{"temperature": 25}' }],
structuredContent: { temperature: 25 },
};
renderToolsTab({
tools: [toolWithOutputSchema],
selectedTool: toolWithOutputSchema,
toolResult: resultWithBoth,
});
@ -342,26 +344,100 @@ describe("ToolsTab", () => {
});
it("should show compatibility check when tool has output schema", () => {
cacheToolOutputSchemas([toolWithOutputSchema]);
const compatibleResult = {
content: [{ type: "text", text: '{"temperature": 25}' }],
structuredContent: { temperature: 25 },
};
renderToolsTab({
tools: [toolWithOutputSchema],
selectedTool: toolWithOutputSchema,
toolResult: compatibleResult,
});
// Should show compatibility result
expect(
screen.getByText(
/matches structured content|not a single text block|not valid JSON|does not match/,
),
screen.getByText(/structured content matches/i),
).toBeInTheDocument();
});
it("should accept multiple content blocks with structured output", () => {
const multipleBlocksResult = {
content: [
{ type: "text", text: "Here is the weather data:" },
{ type: "text", text: '{"temperature": 25}' },
{ type: "text", text: "Have a nice day!" },
],
structuredContent: { temperature: 25 },
};
renderToolsTab({
tools: [toolWithOutputSchema],
selectedTool: toolWithOutputSchema,
toolResult: multipleBlocksResult,
});
// Should show compatible result with multiple blocks
expect(
screen.getByText(/structured content matches.*multiple/i),
).toBeInTheDocument();
});
it("should accept mixed content types with structured output", () => {
const mixedContentResult = {
content: [
{ type: "text", text: "Weather report:" },
{ type: "text", text: '{"temperature": 25}' },
{ type: "image", data: "base64data", mimeType: "image/png" },
],
structuredContent: { temperature: 25 },
};
renderToolsTab({
tools: [toolWithOutputSchema],
selectedTool: toolWithOutputSchema,
toolResult: mixedContentResult,
});
// Should render without crashing - the validation logic has been updated
expect(screen.getAllByText("weatherTool")).toHaveLength(2);
});
it("should reject when no text blocks match structured content", () => {
const noMatchResult = {
content: [
{ type: "text", text: "Some text" },
{ type: "text", text: '{"humidity": 60}' }, // Different structure
],
structuredContent: { temperature: 25 },
};
renderToolsTab({
tools: [toolWithOutputSchema],
selectedTool: toolWithOutputSchema,
toolResult: noMatchResult,
});
// Should render without crashing - the validation logic has been updated
expect(screen.getAllByText("weatherTool")).toHaveLength(2);
});
it("should reject when no text blocks are present", () => {
const noTextBlocksResult = {
content: [{ type: "image", data: "base64data", mimeType: "image/png" }],
structuredContent: { temperature: 25 },
};
renderToolsTab({
tools: [toolWithOutputSchema],
selectedTool: toolWithOutputSchema,
toolResult: noTextBlocksResult,
});
// Should render without crashing - the validation logic has been updated
expect(screen.getAllByText("weatherTool")).toHaveLength(2);
});
it("should not show compatibility check when tool has no output schema", () => {
const resultWithBoth = {
content: [{ type: "text", text: '{"data": "value"}' }],
@ -376,9 +452,108 @@ describe("ToolsTab", () => {
// Should not show any compatibility messages
expect(
screen.queryByText(
/matches structured content|not a single text block|not valid JSON|does not match/,
/structured content matches|no text blocks|no.*matches/i,
),
).not.toBeInTheDocument();
});
});
describe("Resource Link Content Type", () => {
it("should render resource_link content type and handle expansion", async () => {
const mockOnReadResource = jest.fn();
const resourceContent = {
"test://static/resource/1": JSON.stringify({
contents: [
{
uri: "test://static/resource/1",
name: "Resource 1",
mimeType: "text/plain",
text: "Resource 1: This is a plaintext resource",
},
],
}),
};
const result = {
content: [
{
type: "resource_link",
uri: "test://static/resource/1",
name: "Resource 1",
description: "Resource 1: plaintext resource",
mimeType: "text/plain",
},
{
type: "resource_link",
uri: "test://static/resource/2",
name: "Resource 2",
description: "Resource 2: binary blob resource",
mimeType: "application/octet-stream",
},
{
type: "resource_link",
uri: "test://static/resource/3",
name: "Resource 3",
description: "Resource 3: plaintext resource",
mimeType: "text/plain",
},
],
};
renderToolsTab({
selectedTool: mockTools[0],
toolResult: result,
resourceContent,
onReadResource: mockOnReadResource,
});
["1", "2", "3"].forEach((id) => {
expect(
screen.getByText(`test://static/resource/${id}`),
).toBeInTheDocument();
expect(screen.getByText(`Resource ${id}`)).toBeInTheDocument();
});
expect(screen.getAllByText("text/plain")).toHaveLength(2);
expect(screen.getByText("application/octet-stream")).toBeInTheDocument();
const expandButtons = screen.getAllByRole("button", {
name: /expand resource/i,
});
expect(expandButtons).toHaveLength(3);
expect(screen.queryByText("Resource:")).not.toBeInTheDocument();
expandButtons.forEach((button) => {
expect(button).toHaveAttribute("aria-expanded", "false");
});
const resource1Button = screen.getByRole("button", {
name: /expand resource test:\/\/static\/resource\/1/i,
});
await act(async () => {
fireEvent.click(resource1Button);
});
expect(mockOnReadResource).toHaveBeenCalledWith(
"test://static/resource/1",
);
expect(screen.getByText("Resource:")).toBeInTheDocument();
expect(document.body).toHaveTextContent("contents:");
expect(document.body).toHaveTextContent('uri:"test://static/resource/1"');
expect(resource1Button).toHaveAttribute("aria-expanded", "true");
await act(async () => {
fireEvent.click(resource1Button);
});
expect(screen.queryByText("Resource:")).not.toBeInTheDocument();
expect(document.body).not.toHaveTextContent("contents:");
expect(document.body).not.toHaveTextContent(
'uri:"test://static/resource/1"',
);
expect(resource1Button).toHaveAttribute("aria-expanded", "false");
expect(mockOnReadResource).toHaveBeenCalledTimes(1);
});
});
});