From 71bb89ddf220b7937f1ed8e70ebdf0d62ac06a3d Mon Sep 17 00:00:00 2001 From: Nathan Arseneau Date: Tue, 1 Apr 2025 18:02:29 -0400 Subject: [PATCH 1/2] create sampling response form --- client/src/components/DynamicJsonForm.tsx | 18 +- client/src/components/SamplingRequest.tsx | 162 ++++++++++++++++++ client/src/components/SamplingTab.tsx | 37 +--- client/src/components/Sidebar.tsx | 4 +- .../__tests__/samplingRequest.test.tsx | 73 ++++++++ .../components/__tests__/samplingTab.test.tsx | 55 ++++++ 6 files changed, 316 insertions(+), 33 deletions(-) create mode 100644 client/src/components/SamplingRequest.tsx create mode 100644 client/src/components/__tests__/samplingRequest.test.tsx create mode 100644 client/src/components/__tests__/samplingTab.test.tsx diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index f5b0d6301..c4caf0da4 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -36,6 +36,7 @@ interface DynamicJsonFormProps { value: JsonValue; onChange: (value: JsonValue) => void; maxDepth?: number; + defaultIsJsonMode?: boolean; } const DynamicJsonForm = ({ @@ -43,8 +44,9 @@ const DynamicJsonForm = ({ value, onChange, maxDepth = 3, + defaultIsJsonMode = false, }: DynamicJsonFormProps) => { - const [isJsonMode, setIsJsonMode] = useState(false); + const [isJsonMode, setIsJsonMode] = useState(defaultIsJsonMode); const [jsonError, setJsonError] = useState(); // Store the raw JSON string to allow immediate feedback during typing // while deferring parsing until the user stops typing @@ -370,11 +372,21 @@ const DynamicJsonForm = ({
{isJsonMode && ( - )} -
diff --git a/client/src/components/SamplingRequest.tsx b/client/src/components/SamplingRequest.tsx new file mode 100644 index 000000000..84c617232 --- /dev/null +++ b/client/src/components/SamplingRequest.tsx @@ -0,0 +1,162 @@ +import { Button } from "@/components/ui/button"; +import JsonView from "./JsonView"; +import { useMemo, useState } from "react"; +import { + CreateMessageResult, + CreateMessageResultSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import { PendingRequest } from "./SamplingTab"; +import DynamicJsonForm, { JsonSchemaType, JsonValue } from "./DynamicJsonForm"; +import { useToast } from "@/hooks/use-toast"; + +export type SamplingRequestProps = { + request: PendingRequest; + onApprove: (id: number, result: CreateMessageResult) => void; + onReject: (id: number) => void; +}; + +const SamplingRequest = ({ + onApprove, + request, + onReject, +}: SamplingRequestProps) => { + const { toast } = useToast(); + + const [messageResult, setMessageResult] = useState({ + model: "GPT-4o", + stopReason: "endTurn", + role: "assistant", + content: { + type: "text", + text: "", + }, + }); + + const s = useMemo(() => { + const schema: JsonSchemaType = { + type: "object", + description: "Message result", + properties: { + model: { + type: "string", + default: "GPT-4o", + description: "model name", + }, + stopReason: { + type: "string", + default: "endTurn", + description: "Stop reason", + }, + role: { + type: "string", + default: "endTurn", + description: "Role of the model", + }, + content: { + type: "object", + properties: { + type: { + type: "string", + default: "text", + description: "Type of content", + }, + }, + }, + }, + }; + + const contentType = (messageResult as any)?.content?.type; + if (contentType === "text" && schema.properties) { + schema.properties.content.properties = { + ...schema.properties.content.properties, + text: { + type: "string", + default: "", + description: "text content", + }, + }; + setMessageResult((prev) => ({ + ...(prev as { [key: string]: JsonValue }), + content: { + type: contentType, + text: "", + }, + })); + } else if (contentType === "image" && schema.properties) { + schema.properties.content.properties = { + ...schema.properties.content.properties, + data: { + type: "string", + default: "", + description: "Base64 encoded image data", + }, + mimeType: { + type: "string", + default: "", + description: "Mime type of the image", + }, + }; + setMessageResult((prev) => ({ + ...(prev as { [key: string]: JsonValue }), + content: { + type: contentType, + data: "", + mimeType: "", + }, + })); + } + + return schema; + }, [(messageResult as any)?.content?.type]); + + const handleApprove = (id: number) => { + const validationResult = CreateMessageResultSchema.safeParse(messageResult); + if (!validationResult.success) { + toast({ + title: "Error", + description: `There was an error validating the message result: ${validationResult.error.message}`, + variant: "destructive", + }); + return; + } + + onApprove(id, validationResult.data); + }; + + return ( +
+
+ +
+
+
+ { + setMessageResult(newValue); + }} + /> +
+
+ + +
+
+
+ ); +}; + +export default SamplingRequest; diff --git a/client/src/components/SamplingTab.tsx b/client/src/components/SamplingTab.tsx index a72ea7d71..19ed4a339 100644 --- a/client/src/components/SamplingTab.tsx +++ b/client/src/components/SamplingTab.tsx @@ -1,11 +1,10 @@ import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; import { TabsContent } from "@/components/ui/tabs"; import { CreateMessageRequest, CreateMessageResult, } from "@modelcontextprotocol/sdk/types.js"; -import JsonView from "./JsonView"; +import SamplingRequest from "./SamplingRequest"; export type PendingRequest = { id: number; @@ -19,21 +18,8 @@ export type Props = { }; const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => { - const handleApprove = (id: number) => { - // For now, just return a stub response - onApprove(id, { - model: "stub-model", - stopReason: "endTurn", - role: "assistant", - content: { - type: "text", - text: "This is a stub response.", - }, - }); - }; - return ( - + When the server requests LLM sampling, requests will appear here for @@ -43,19 +29,12 @@ const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {

Recent Requests

{pendingRequests.map((request) => ( -
- - -
- - -
-
+ ))} {pendingRequests.length === 0 && (

No pending requests

diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index 19f8020b2..8b40bb760 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -460,7 +460,9 @@ const Sidebar = ({ {Object.values(LoggingLevelSchema.enum).map((level) => ( - {level} + + {level} + ))} diff --git a/client/src/components/__tests__/samplingRequest.test.tsx b/client/src/components/__tests__/samplingRequest.test.tsx new file mode 100644 index 000000000..402c24417 --- /dev/null +++ b/client/src/components/__tests__/samplingRequest.test.tsx @@ -0,0 +1,73 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import SamplingRequest from "../SamplingRequest"; +import { PendingRequest } from "../SamplingTab"; + +const mockRequest: PendingRequest = { + id: 1, + request: { + method: "sampling/createMessage", + params: { + messages: [ + { + role: "user", + content: { + type: "text", + text: "What files are in the current directory?", + }, + }, + ], + systemPrompt: "You are a helpful file system assistant.", + includeContext: "thisServer", + maxTokens: 100, + }, + }, +}; + +describe("Form to handle sampling response", () => { + const mockOnApprove = jest.fn(); + const mockOnReject = jest.fn(); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should call onApprove with correct text content when Approve button is clicked", () => { + render( + , + ); + + // Click the Approve button + fireEvent.click(screen.getByRole("button", { name: /approve/i })); + + // Assert that onApprove is called with the correct arguments + expect(mockOnApprove).toHaveBeenCalledWith(mockRequest.id, { + model: "GPT-4o", + stopReason: "endTurn", + role: "assistant", + content: { + type: "text", + text: "", + }, + }); + }); + + it("should call onReject with correct request id when Reject button is clicked", () => { + render( + , + ); + + // Click the Approve button + fireEvent.click(screen.getByRole("button", { name: /Reject/i })); + + // Assert that onApprove is called with the correct arguments + expect(mockOnReject).toHaveBeenCalledWith(mockRequest.id); + }); +}); diff --git a/client/src/components/__tests__/samplingTab.test.tsx b/client/src/components/__tests__/samplingTab.test.tsx new file mode 100644 index 000000000..3e7212161 --- /dev/null +++ b/client/src/components/__tests__/samplingTab.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from "@testing-library/react"; +import { Tabs } from "@/components/ui/tabs"; +import SamplingTab, { PendingRequest } from "../SamplingTab"; + +describe("Sampling tab", () => { + const mockOnApprove = jest.fn(); + const mockOnReject = jest.fn(); + + const renderSamplingTab = (pendingRequests: PendingRequest[]) => + render( + + + , + ); + + it("should render 'No pending requests' when there are no pending requests", () => { + renderSamplingTab([]); + expect( + screen.getByText( + "When the server requests LLM sampling, requests will appear here for approval.", + ), + ).toBeTruthy(); + expect(screen.findByText("No pending requests")).toBeTruthy(); + }); + + it("should render the correct number of requests", () => { + renderSamplingTab( + Array.from({ length: 5 }, (_, i) => ({ + id: i, + request: { + method: "sampling/createMessage", + params: { + messages: [ + { + role: "user", + content: { + type: "text", + text: "What files are in the current directory?", + }, + }, + ], + systemPrompt: "You are a helpful file system assistant.", + includeContext: "thisServer", + maxTokens: 100, + }, + }, + })), + ); + expect(screen.getAllByTestId("sampling-request").length).toBe(5); + }); +}); From 6b57f7ce117900e6252ff54ddf0c510fd37aa2a6 Mon Sep 17 00:00:00 2001 From: Nathan Arseneau Date: Tue, 15 Apr 2025 21:58:03 -0400 Subject: [PATCH 2/2] feat: test ci linter