Skip to content
Open
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
24 changes: 18 additions & 6 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -438,15 +438,27 @@ const App = () => {
onElicitationRequest: (request, resolve) => {
const currentTab = lastToolCallOriginTabRef.current;

const requestId = nextRequestId.current++;
const requestData =
request.params.mode === "url"
? {
id: requestId,
mode: "url" as const,
message: request.params.message,
url: request.params.url,
elicitationId: request.params.elicitationId,
}
: {
id: requestId,
message: request.params.message,
requestedSchema: request.params.requestedSchema,
};

setPendingElicitationRequests((prev) => [
...prev,
{
id: nextRequestId.current++,
request: {
id: nextRequestId.current,
message: request.params.message,
requestedSchema: request.params.requestedSchema,
},
id: requestId,
request: requestData,
originatingTab: currentTab,
resolve,
decline: (error: Error) => {
Expand Down
109 changes: 100 additions & 9 deletions client/src/components/ElicitationRequest.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import DynamicJsonForm from "./DynamicJsonForm";
import JsonView from "./JsonView";
import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils";
import { generateDefaultValue } from "@/utils/schemaUtils";
import {
PendingElicitationRequest,
ElicitationResponse,
ElicitationFormRequestData,
} from "./ElicitationTab";
import Ajv from "ajv";

Expand All @@ -21,12 +30,94 @@ const ElicitationRequest = ({
}: ElicitationRequestProps) => {
const [formData, setFormData] = useState<JsonValue>({});
const [validationError, setValidationError] = useState<string | null>(null);
const [showUrlConfirm, setShowUrlConfirm] = useState(false);

const requestData = request.request;
const isUrlMode = requestData.mode === "url";

useEffect(() => {
const defaultValue = generateDefaultValue(request.request.requestedSchema);
if (isUrlMode) return;
const defaultValue = generateDefaultValue(
(requestData as ElicitationFormRequestData).requestedSchema,
);
setFormData(defaultValue);
setValidationError(null);
}, [request.request.requestedSchema]);
}, [isUrlMode, requestData]);

if (isUrlMode) {
const handleConfirmOpen = () => {
window.open(requestData.url, "_blank", "noopener,noreferrer");
setShowUrlConfirm(false);
};

return (
<div
data-testid="elicitation-request"
className="flex flex-col gap-4 p-4 border rounded-lg"
>
<div className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
<div className="space-y-2">
<h4 className="font-semibold">URL Request</h4>
<p className="text-sm">{requestData.message}</p>
</div>
</div>
<div className="flex space-x-2">
<Button type="button" onClick={() => setShowUrlConfirm(true)}>
Open URL
</Button>
<Button
type="button"
onClick={() => onResolve(request.id, { action: "accept" })}
>
Accept
</Button>
<Button
type="button"
variant="outline"
onClick={() => onResolve(request.id, { action: "decline" })}
>
Decline
</Button>
<Button
type="button"
variant="outline"
onClick={() => onResolve(request.id, { action: "cancel" })}
>
Cancel
</Button>
</div>

<Dialog open={showUrlConfirm} onOpenChange={setShowUrlConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>Open External URL</DialogTitle>
<DialogDescription>
The server is requesting you visit the following URL:
</DialogDescription>
</DialogHeader>
<p
data-testid="url-confirm-text"
className="text-sm font-mono bg-gray-100 dark:bg-gray-800 p-3 rounded break-all select-all"
>
{requestData.url}
</p>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowUrlConfirm(false)}
>
Cancel
</Button>
<Button onClick={handleConfirmOpen}>Open</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

// After URL-mode early return, requestData is guaranteed to be form mode
const formRequest = requestData as ElicitationFormRequestData;

const validateEmailFormat = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
Expand Down Expand Up @@ -75,12 +166,12 @@ const ElicitationRequest = ({

const handleAccept = () => {
try {
if (!validateFormData(formData, request.request.requestedSchema)) {
if (!validateFormData(formData, formRequest.requestedSchema)) {
return;
}

const ajv = new Ajv();
const validate = ajv.compile(request.request.requestedSchema);
const validate = ajv.compile(formRequest.requestedSchema);
const isValid = validate(formData);

if (!isValid) {
Expand Down Expand Up @@ -109,8 +200,8 @@ const ElicitationRequest = ({
};

const schemaTitle =
request.request.requestedSchema.title || "Information Request";
const schemaDescription = request.request.requestedSchema.description;
formRequest.requestedSchema.title || "Information Request";
const schemaDescription = formRequest.requestedSchema.description;

return (
<div
Expand All @@ -120,14 +211,14 @@ const ElicitationRequest = ({
<div className="flex-1 bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
<div className="space-y-2">
<h4 className="font-semibold">{schemaTitle}</h4>
<p className="text-sm">{request.request.message}</p>
<p className="text-sm">{formRequest.message}</p>
{schemaDescription && (
<p className="text-xs text-muted-foreground">{schemaDescription}</p>
)}
<div className="mt-2">
<h5 className="text-xs font-medium mb-1">Request Schema:</h5>
<JsonView
data={JSON.stringify(request.request.requestedSchema, null, 2)}
data={JSON.stringify(formRequest.requestedSchema, null, 2)}
/>
</div>
</div>
Expand All @@ -137,7 +228,7 @@ const ElicitationRequest = ({
<div className="space-y-2">
<h4 className="font-medium">Response Form</h4>
<DynamicJsonForm
schema={request.request.requestedSchema}
schema={formRequest.requestedSchema}
value={formData}
onChange={(newValue: JsonValue) => {
setFormData(newValue);
Expand Down
16 changes: 15 additions & 1 deletion client/src/components/ElicitationTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,26 @@ import { TabsContent } from "@/components/ui/tabs";
import { JsonSchemaType } from "@/utils/jsonUtils";
import ElicitationRequest from "./ElicitationRequest";

export interface ElicitationRequestData {
interface ElicitationRequestBase {
id: number;
message: string;
}

export interface ElicitationFormRequestData extends ElicitationRequestBase {
mode?: "form";
requestedSchema: JsonSchemaType;
}

export interface ElicitationUrlRequestData extends ElicitationRequestBase {
mode: "url";
url: string;
elicitationId: string;
}

export type ElicitationRequestData =
| ElicitationFormRequestData
| ElicitationUrlRequestData;

export interface ElicitationResponse {
action: "accept" | "decline" | "cancel";
content?: Record<string, unknown>;
Expand Down
135 changes: 135 additions & 0 deletions client/src/components/__tests__/ElicitationRequest.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,4 +199,139 @@ describe("ElicitationRequest", () => {
);
});
});

describe("URL Mode", () => {
const createUrlRequest = (): PendingElicitationRequest => ({
id: 2,
request: {
id: 2,
mode: "url",
message: "Please complete authentication",
url: "https://example.com/auth",
elicitationId: "elicit-123",
},
});

it("should render URL mode request with message but no clickable link", () => {
renderElicitationRequest(createUrlRequest());
expect(screen.getByTestId("elicitation-request")).toBeInTheDocument();
expect(
screen.getByText("Please complete authentication"),
).toBeInTheDocument();
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});

it("should render Open URL, Accept, Decline, and Cancel buttons", () => {
renderElicitationRequest(createUrlRequest());
expect(
screen.getByRole("button", { name: /open url/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /accept/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /decline/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /cancel/i }),
).toBeInTheDocument();
});

it("should not render DynamicJsonForm", () => {
renderElicitationRequest(createUrlRequest());
expect(screen.queryByTestId("dynamic-json-form")).not.toBeInTheDocument();
});

it("should show consent dialog with URL as text when Open URL is clicked", async () => {
renderElicitationRequest(createUrlRequest());

await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /open url/i }));
});

expect(screen.getByTestId("url-confirm-text")).toHaveTextContent(
"https://example.com/auth",
);
expect(screen.getByText("Open External URL")).toBeInTheDocument();
});

it("should open URL when confirmed in consent dialog", async () => {
const windowOpenSpy = jest
.spyOn(window, "open")
.mockImplementation(() => null);
renderElicitationRequest(createUrlRequest());

await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /open url/i }));
});

await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /^open$/i }));
});

expect(windowOpenSpy).toHaveBeenCalledWith(
"https://example.com/auth",
"_blank",
"noopener,noreferrer",
);
expect(mockOnResolve).not.toHaveBeenCalled();
windowOpenSpy.mockRestore();
});

it("should close consent dialog without opening URL when cancelled", async () => {
const windowOpenSpy = jest
.spyOn(window, "open")
.mockImplementation(() => null);
renderElicitationRequest(createUrlRequest());

await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /open url/i }));
});

expect(screen.getByTestId("url-confirm-text")).toBeInTheDocument();

await act(async () => {
// The Cancel button inside the dialog
const dialogButtons = screen.getAllByRole("button", {
name: /cancel/i,
});
fireEvent.click(dialogButtons[dialogButtons.length - 1]);
});

expect(windowOpenSpy).not.toHaveBeenCalled();
expect(mockOnResolve).not.toHaveBeenCalled();
windowOpenSpy.mockRestore();
});

it("should resolve with accept and no content when Accept is clicked", async () => {
renderElicitationRequest(createUrlRequest());

await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /accept/i }));
});

expect(mockOnResolve).toHaveBeenCalledWith(2, { action: "accept" });
expect(mockOnResolve.mock.calls[0][1]).not.toHaveProperty("content");
});

it("should resolve with decline when Decline is clicked", async () => {
renderElicitationRequest(createUrlRequest());

await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /decline/i }));
});

expect(mockOnResolve).toHaveBeenCalledWith(2, { action: "decline" });
});

it("should resolve with cancel when Cancel is clicked", async () => {
renderElicitationRequest(createUrlRequest());

await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
});

expect(mockOnResolve).toHaveBeenCalledWith(2, { action: "cancel" });
});
});
});
18 changes: 18 additions & 0 deletions client/src/components/__tests__/ElicitationTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,22 @@ describe("Elicitation tab", () => {
);
expect(screen.getAllByTestId("elicitation-request").length).toBe(3);
});

it("should render a URL-mode elicitation request", () => {
renderElicitationTab([
{
id: 1,
request: {
id: 1,
mode: "url",
message: "Please authenticate",
url: "https://example.com/auth",
elicitationId: "elicit-456",
},
},
]);
expect(screen.getAllByTestId("elicitation-request").length).toBe(1);
expect(screen.getByText("Please authenticate")).toBeTruthy();
expect(screen.getByRole("button", { name: /open url/i })).toBeTruthy();
});
});
Loading
Loading