Skip to content

Commit 966f95b

Browse files
authored
Merge branch 'main' into fix-streamable-endpoint
2 parents 5d0c3c4 + 2e4a522 commit 966f95b

File tree

7 files changed

+313
-31
lines changed

7 files changed

+313
-31
lines changed

.github/workflows/main.yml

+4
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ jobs:
2626
# - run: npm ci
2727
- run: npm install --no-package-lock
2828

29+
- name: Check linting
30+
working-directory: ./client
31+
run: npm run lint
32+
2933
- name: Run client tests
3034
working-directory: ./client
3135
run: npm test

client/src/App.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,7 @@ const App = () => {
647647
setSelectedPrompt={(prompt) => {
648648
clearError("prompts");
649649
setSelectedPrompt(prompt);
650+
setPromptContent("");
650651
}}
651652
handleCompletion={handleCompletion}
652653
completionsSupported={completionsSupported}

client/src/components/DynamicJsonForm.tsx

+6-1
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,12 @@ const DynamicJsonForm = ({
250250
<div className="space-y-4">
251251
<div className="flex justify-end space-x-2">
252252
{isJsonMode && (
253-
<Button variant="outline" size="sm" onClick={formatJson}>
253+
<Button
254+
type="button"
255+
variant="outline"
256+
size="sm"
257+
onClick={formatJson}
258+
>
254259
Format JSON
255260
</Button>
256261
)}
+167
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { Button } from "@/components/ui/button";
2+
import JsonView from "./JsonView";
3+
import { useMemo, useState } from "react";
4+
import {
5+
CreateMessageResult,
6+
CreateMessageResultSchema,
7+
} from "@modelcontextprotocol/sdk/types.js";
8+
import { PendingRequest } from "./SamplingTab";
9+
import DynamicJsonForm from "./DynamicJsonForm";
10+
import { useToast } from "@/hooks/use-toast";
11+
import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils";
12+
13+
export type SamplingRequestProps = {
14+
request: PendingRequest;
15+
onApprove: (id: number, result: CreateMessageResult) => void;
16+
onReject: (id: number) => void;
17+
};
18+
19+
const SamplingRequest = ({
20+
onApprove,
21+
request,
22+
onReject,
23+
}: SamplingRequestProps) => {
24+
const { toast } = useToast();
25+
26+
const [messageResult, setMessageResult] = useState<JsonValue>({
27+
model: "stub-model",
28+
stopReason: "endTurn",
29+
role: "assistant",
30+
content: {
31+
type: "text",
32+
text: "",
33+
},
34+
});
35+
36+
const contentType = (
37+
(messageResult as { [key: string]: JsonValue })?.content as {
38+
[key: string]: JsonValue;
39+
}
40+
)?.type;
41+
42+
const schema = useMemo(() => {
43+
const s: JsonSchemaType = {
44+
type: "object",
45+
description: "Message result",
46+
properties: {
47+
model: {
48+
type: "string",
49+
default: "stub-model",
50+
description: "model name",
51+
},
52+
stopReason: {
53+
type: "string",
54+
default: "endTurn",
55+
description: "Stop reason",
56+
},
57+
role: {
58+
type: "string",
59+
default: "endTurn",
60+
description: "Role of the model",
61+
},
62+
content: {
63+
type: "object",
64+
properties: {
65+
type: {
66+
type: "string",
67+
default: "text",
68+
description: "Type of content",
69+
},
70+
},
71+
},
72+
},
73+
};
74+
75+
if (contentType === "text" && s.properties) {
76+
s.properties.content.properties = {
77+
...s.properties.content.properties,
78+
text: {
79+
type: "string",
80+
default: "",
81+
description: "text content",
82+
},
83+
};
84+
setMessageResult((prev) => ({
85+
...(prev as { [key: string]: JsonValue }),
86+
content: {
87+
type: contentType,
88+
text: "",
89+
},
90+
}));
91+
} else if (contentType === "image" && s.properties) {
92+
s.properties.content.properties = {
93+
...s.properties.content.properties,
94+
data: {
95+
type: "string",
96+
default: "",
97+
description: "Base64 encoded image data",
98+
},
99+
mimeType: {
100+
type: "string",
101+
default: "",
102+
description: "Mime type of the image",
103+
},
104+
};
105+
setMessageResult((prev) => ({
106+
...(prev as { [key: string]: JsonValue }),
107+
content: {
108+
type: contentType,
109+
data: "",
110+
mimeType: "",
111+
},
112+
}));
113+
}
114+
115+
return s;
116+
}, [contentType]);
117+
118+
const handleApprove = (id: number) => {
119+
const validationResult = CreateMessageResultSchema.safeParse(messageResult);
120+
if (!validationResult.success) {
121+
toast({
122+
title: "Error",
123+
description: `There was an error validating the message result: ${validationResult.error.message}`,
124+
variant: "destructive",
125+
});
126+
return;
127+
}
128+
129+
onApprove(id, validationResult.data);
130+
};
131+
132+
return (
133+
<div
134+
data-testid="sampling-request"
135+
className="flex gap-4 p-4 border rounded-lg space-y-4"
136+
>
137+
<div className="flex-1 bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
138+
<JsonView data={JSON.stringify(request.request)} />
139+
</div>
140+
<form className="flex-1 space-y-4">
141+
<div className="space-y-2">
142+
<DynamicJsonForm
143+
schema={schema}
144+
value={messageResult}
145+
onChange={(newValue: JsonValue) => {
146+
setMessageResult(newValue);
147+
}}
148+
/>
149+
</div>
150+
<div className="flex space-x-2 mt-1">
151+
<Button type="button" onClick={() => handleApprove(request.id)}>
152+
Approve
153+
</Button>
154+
<Button
155+
type="button"
156+
variant="outline"
157+
onClick={() => onReject(request.id)}
158+
>
159+
Reject
160+
</Button>
161+
</div>
162+
</form>
163+
</div>
164+
);
165+
};
166+
167+
export default SamplingRequest;

client/src/components/SamplingTab.tsx

+7-30
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,19 +18,6 @@ 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 (
3622
<TabsContent value="sampling">
3723
<div className="h-96">
@@ -44,21 +30,12 @@ const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {
4430
<div className="mt-4 space-y-4">
4531
<h3 className="text-lg font-semibold">Recent Requests</h3>
4632
{pendingRequests.map((request) => (
47-
<div key={request.id} className="p-4 border rounded-lg space-y-4">
48-
<JsonView
49-
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 rounded"
50-
data={JSON.stringify(request.request)}
51-
/>
52-
53-
<div className="flex space-x-2">
54-
<Button onClick={() => handleApprove(request.id)}>
55-
Approve
56-
</Button>
57-
<Button variant="outline" onClick={() => onReject(request.id)}>
58-
Reject
59-
</Button>
60-
</div>
61-
</div>
33+
<SamplingRequest
34+
key={request.id}
35+
request={request}
36+
onApprove={onApprove}
37+
onReject={onReject}
38+
/>
6239
))}
6340
{pendingRequests.length === 0 && (
6441
<p className="text-gray-500">No pending requests</p>
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: "stub-model",
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+
});
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)