Skip to content

Create sampling response form #246

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Apr 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ jobs:
# - run: npm ci
- run: npm install --no-package-lock

- name: Check linting
working-directory: ./client
run: npm run lint

- name: Run client tests
working-directory: ./client
run: npm test
Expand Down
7 changes: 6 additions & 1 deletion client/src/components/DynamicJsonForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,12 @@ const DynamicJsonForm = ({
<div className="space-y-4">
<div className="flex justify-end space-x-2">
{isJsonMode && (
<Button variant="outline" size="sm" onClick={formatJson}>
<Button
type="button"
variant="outline"
size="sm"
onClick={formatJson}
>
Format JSON
</Button>
)}
Expand Down
167 changes: 167 additions & 0 deletions client/src/components/SamplingRequest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
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 from "./DynamicJsonForm";
import { useToast } from "@/hooks/use-toast";
import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils";

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<JsonValue>({
model: "stub-model",
stopReason: "endTurn",
role: "assistant",
content: {
type: "text",
text: "",
},
});

const contentType = (
(messageResult as { [key: string]: JsonValue })?.content as {
[key: string]: JsonValue;
}
)?.type;

const schema = useMemo(() => {
const s: JsonSchemaType = {
type: "object",
description: "Message result",
properties: {
model: {
type: "string",
default: "stub-model",
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",
},
},
},
},
};

if (contentType === "text" && s.properties) {
s.properties.content.properties = {
...s.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" && s.properties) {
s.properties.content.properties = {
...s.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 s;
}, [contentType]);

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 (
<div
data-testid="sampling-request"
className="flex gap-4 p-4 border rounded-lg space-y-4"
>
<div className="flex-1 bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
<JsonView data={JSON.stringify(request.request)} />
</div>
<form className="flex-1 space-y-4">
<div className="space-y-2">
<DynamicJsonForm
schema={schema}
value={messageResult}
onChange={(newValue: JsonValue) => {
setMessageResult(newValue);
}}
/>
</div>
<div className="flex space-x-2 mt-1">
<Button type="button" onClick={() => handleApprove(request.id)}>
Approve
</Button>
<Button
type="button"
variant="outline"
onClick={() => onReject(request.id)}
>
Reject
</Button>
</div>
</form>
</div>
);
};

export default SamplingRequest;
37 changes: 7 additions & 30 deletions client/src/components/SamplingTab.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -19,19 +18,6 @@ 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 (
<TabsContent value="sampling">
<div className="h-96">
Expand All @@ -44,21 +30,12 @@ const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {
<div className="mt-4 space-y-4">
<h3 className="text-lg font-semibold">Recent Requests</h3>
{pendingRequests.map((request) => (
<div key={request.id} className="p-4 border rounded-lg space-y-4">
<JsonView
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 rounded"
data={JSON.stringify(request.request)}
/>

<div className="flex space-x-2">
<Button onClick={() => handleApprove(request.id)}>
Approve
</Button>
<Button variant="outline" onClick={() => onReject(request.id)}>
Reject
</Button>
</div>
</div>
<SamplingRequest
key={request.id}
request={request}
onApprove={onApprove}
onReject={onReject}
/>
))}
{pendingRequests.length === 0 && (
<p className="text-gray-500">No pending requests</p>
Expand Down
73 changes: 73 additions & 0 deletions client/src/components/__tests__/samplingRequest.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<SamplingRequest
request={mockRequest}
onApprove={mockOnApprove}
onReject={mockOnReject}
/>,
);

// 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: "stub-model",
stopReason: "endTurn",
role: "assistant",
content: {
type: "text",
text: "",
},
});
});

it("should call onReject with correct request id when Reject button is clicked", () => {
render(
<SamplingRequest
request={mockRequest}
onApprove={mockOnApprove}
onReject={mockOnReject}
/>,
);

// 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);
});
});
55 changes: 55 additions & 0 deletions client/src/components/__tests__/samplingTab.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Tabs defaultValue="sampling">
<SamplingTab
pendingRequests={pendingRequests}
onApprove={mockOnApprove}
onReject={mockOnReject}
/>
</Tabs>,
);

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);
});
});