Skip to content

Commit 7cdaad0

Browse files
create sampling response form
1 parent 3032a67 commit 7cdaad0

File tree

5 files changed

+272
-30
lines changed

5 files changed

+272
-30
lines changed

Diff for: client/src/components/DynamicJsonForm.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,17 @@ interface DynamicJsonFormProps {
3636
value: JsonValue;
3737
onChange: (value: JsonValue) => void;
3838
maxDepth?: number;
39+
defaultIsJsonMode?: boolean;
3940
}
4041

4142
const DynamicJsonForm = ({
4243
schema,
4344
value,
4445
onChange,
4546
maxDepth = 3,
47+
defaultIsJsonMode = false,
4648
}: DynamicJsonFormProps) => {
47-
const [isJsonMode, setIsJsonMode] = useState(false);
49+
const [isJsonMode, setIsJsonMode] = useState(defaultIsJsonMode);
4850
const [jsonError, setJsonError] = useState<string>();
4951
// Store the raw JSON string to allow immediate feedback during typing
5052
// while deferring parsing until the user stops typing

Diff for: client/src/components/SamplingRequest.tsx

+133
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { Button } from "@/components/ui/button";
2+
import JsonView from "./JsonView";
3+
import { useState } from "react";
4+
import {
5+
CreateMessageResult,
6+
CreateMessageResultSchema,
7+
} from "@modelcontextprotocol/sdk/types.js";
8+
import { PendingRequest } from "./SamplingTab";
9+
import DynamicJsonForm, { JsonSchemaType, JsonValue } from "./DynamicJsonForm";
10+
11+
import { useToast } from "@/hooks/use-toast";
12+
13+
export type SamplingRequestProps = {
14+
request: PendingRequest;
15+
onApprove: (id: number, result: CreateMessageResult) => void;
16+
onReject: (id: number) => void;
17+
};
18+
19+
const schema: JsonSchemaType = {
20+
type: "object",
21+
description: "Message result",
22+
properties: {
23+
model: {
24+
type: "string",
25+
default: "GPT-4o",
26+
description: "model name",
27+
},
28+
stopReason: {
29+
type: "string",
30+
default: "endTurn",
31+
description: "Stop reason",
32+
},
33+
role: {
34+
type: "string",
35+
default: "endTurn",
36+
description: "Role of the model",
37+
},
38+
content: {
39+
type: "object",
40+
properties: {
41+
type: {
42+
type: "string",
43+
default: "text",
44+
description: "Type of content",
45+
},
46+
text: {
47+
type: "string",
48+
default: "",
49+
description: "Text content",
50+
},
51+
data: {
52+
type: "string",
53+
default: "",
54+
description: "Base64 encoded image data",
55+
},
56+
mimeType: {
57+
type: "string",
58+
default: "",
59+
description: "Mime type of the image",
60+
},
61+
},
62+
},
63+
},
64+
};
65+
66+
const SamplingRequest = ({
67+
onApprove,
68+
request,
69+
onReject,
70+
}: SamplingRequestProps) => {
71+
const { toast } = useToast();
72+
73+
const [messageResult, setMessageResult] = useState<JsonValue>({
74+
model: "GPT-4o",
75+
stopReason: "endTurn",
76+
role: "assistant",
77+
content: {
78+
type: "text",
79+
text: "",
80+
},
81+
});
82+
83+
const handleApprove = (id: number) => {
84+
const validationResult = CreateMessageResultSchema.safeParse(messageResult);
85+
if (!validationResult.success) {
86+
toast({
87+
title: "Error",
88+
description: `There was an error validating the message result: ${validationResult.error.message}`,
89+
variant: "destructive",
90+
});
91+
return;
92+
}
93+
94+
onApprove(id, validationResult.data);
95+
};
96+
97+
return (
98+
<div
99+
data-testid="sampling-request"
100+
className="flex gap-4 p-4 border rounded-lg space-y-4"
101+
>
102+
<div className="flex-1 bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
103+
<JsonView data={JSON.stringify(request.request)} />
104+
</div>
105+
<form className="flex-1 space-y-4">
106+
<div className="space-y-2">
107+
<DynamicJsonForm
108+
defaultIsJsonMode={true}
109+
schema={schema}
110+
value={messageResult}
111+
onChange={(newValue: JsonValue) => {
112+
setMessageResult(newValue);
113+
}}
114+
/>
115+
</div>
116+
<div className="flex space-x-2 mt-1">
117+
<Button type="button" onClick={() => handleApprove(request.id)}>
118+
Approve
119+
</Button>
120+
<Button
121+
type="button"
122+
variant="outline"
123+
onClick={() => onReject(request.id)}
124+
>
125+
Reject
126+
</Button>
127+
</div>
128+
</form>
129+
</div>
130+
);
131+
};
132+
133+
export default SamplingRequest;

Diff for: client/src/components/SamplingTab.tsx

+8-29
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { Alert, AlertDescription } from "@/components/ui/alert";
2-
import { Button } from "@/components/ui/button";
32
import { TabsContent } from "@/components/ui/tabs";
43
import {
54
CreateMessageRequest,
65
CreateMessageResult,
76
} from "@modelcontextprotocol/sdk/types.js";
8-
import JsonView from "./JsonView";
7+
import SamplingRequest from "./SamplingRequest";
98

109
export type PendingRequest = {
1110
id: number;
@@ -19,21 +18,8 @@ export type Props = {
1918
};
2019

2120
const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {
22-
const handleApprove = (id: number) => {
23-
// For now, just return a stub response
24-
onApprove(id, {
25-
model: "stub-model",
26-
stopReason: "endTurn",
27-
role: "assistant",
28-
content: {
29-
type: "text",
30-
text: "This is a stub response.",
31-
},
32-
});
33-
};
34-
3521
return (
36-
<TabsContent value="sampling" className="h-96">
22+
<TabsContent value="sampling" className="mh-96">
3723
<Alert>
3824
<AlertDescription>
3925
When the server requests LLM sampling, requests will appear here for
@@ -43,19 +29,12 @@ const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {
4329
<div className="mt-4 space-y-4">
4430
<h3 className="text-lg font-semibold">Recent Requests</h3>
4531
{pendingRequests.map((request) => (
46-
<div key={request.id} className="p-4 border rounded-lg space-y-4">
47-
<JsonView
48-
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 rounded"
49-
data={JSON.stringify(request.request)}
50-
/>
51-
52-
<div className="flex space-x-2">
53-
<Button onClick={() => handleApprove(request.id)}>Approve</Button>
54-
<Button variant="outline" onClick={() => onReject(request.id)}>
55-
Reject
56-
</Button>
57-
</div>
58-
</div>
32+
<SamplingRequest
33+
key={request.id}
34+
request={request}
35+
onApprove={onApprove}
36+
onReject={onReject}
37+
/>
5938
))}
6039
{pendingRequests.length === 0 && (
6140
<p className="text-gray-500">No pending requests</p>
+73
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { render, screen, fireEvent } from "@testing-library/react";
2+
import SamplingRequest from "../SamplingRequest";
3+
import { PendingRequest } from "../SamplingTab";
4+
5+
const mockRequest: PendingRequest = {
6+
id: 1,
7+
request: {
8+
method: "sampling/createMessage",
9+
params: {
10+
messages: [
11+
{
12+
role: "user",
13+
content: {
14+
type: "text",
15+
text: "What files are in the current directory?",
16+
},
17+
},
18+
],
19+
systemPrompt: "You are a helpful file system assistant.",
20+
includeContext: "thisServer",
21+
maxTokens: 100,
22+
},
23+
},
24+
};
25+
26+
describe("Form to handle sampling response", () => {
27+
const mockOnApprove = jest.fn();
28+
const mockOnReject = jest.fn();
29+
30+
afterEach(() => {
31+
jest.clearAllMocks();
32+
});
33+
34+
it("should call onApprove with correct text content when Approve button is clicked", () => {
35+
render(
36+
<SamplingRequest
37+
request={mockRequest}
38+
onApprove={mockOnApprove}
39+
onReject={mockOnReject}
40+
/>,
41+
);
42+
43+
// Click the Approve button
44+
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
45+
46+
// Assert that onApprove is called with the correct arguments
47+
expect(mockOnApprove).toHaveBeenCalledWith(mockRequest.id, {
48+
model: "GPT-4o",
49+
stopReason: "endTurn",
50+
role: "assistant",
51+
content: {
52+
type: "text",
53+
text: "",
54+
},
55+
});
56+
});
57+
58+
it("should call onReject with correct request id when Reject button is clicked", () => {
59+
render(
60+
<SamplingRequest
61+
request={mockRequest}
62+
onApprove={mockOnApprove}
63+
onReject={mockOnReject}
64+
/>,
65+
);
66+
67+
// Click the Approve button
68+
fireEvent.click(screen.getByRole("button", { name: /Reject/i }));
69+
70+
// Assert that onApprove is called with the correct arguments
71+
expect(mockOnReject).toHaveBeenCalledWith(mockRequest.id);
72+
});
73+
});

Diff for: client/src/components/__tests__/samplingTab.test.tsx

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { Tabs } from "@/components/ui/tabs";
3+
import SamplingTab, { PendingRequest } from "../SamplingTab";
4+
5+
describe("Sampling tab", () => {
6+
const mockOnApprove = jest.fn();
7+
const mockOnReject = jest.fn();
8+
9+
const renderSamplingTab = (pendingRequests: PendingRequest[]) =>
10+
render(
11+
<Tabs defaultValue="sampling">
12+
<SamplingTab
13+
pendingRequests={pendingRequests}
14+
onApprove={mockOnApprove}
15+
onReject={mockOnReject}
16+
/>
17+
</Tabs>,
18+
);
19+
20+
it("should render 'No pending requests' when there are no pending requests", () => {
21+
renderSamplingTab([]);
22+
expect(
23+
screen.getByText(
24+
"When the server requests LLM sampling, requests will appear here for approval.",
25+
),
26+
).toBeTruthy();
27+
expect(screen.findByText("No pending requests")).toBeTruthy();
28+
});
29+
30+
it("should render the correct number of requests", () => {
31+
renderSamplingTab(
32+
Array.from({ length: 5 }, (_, i) => ({
33+
id: i,
34+
request: {
35+
method: "sampling/createMessage",
36+
params: {
37+
messages: [
38+
{
39+
role: "user",
40+
content: {
41+
type: "text",
42+
text: "What files are in the current directory?",
43+
},
44+
},
45+
],
46+
systemPrompt: "You are a helpful file system assistant.",
47+
includeContext: "thisServer",
48+
maxTokens: 100,
49+
},
50+
},
51+
})),
52+
);
53+
expect(screen.getAllByTestId("sampling-request").length).toBe(5);
54+
});
55+
});

0 commit comments

Comments
 (0)