Merge branch 'main' into completion-support-default
This commit is contained in:
commit
430d6d4712
7 changed files with 612 additions and 127 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
112
client/src/components/ResourceLinkView.tsx
Normal file
112
client/src/components/ResourceLinkView.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue