mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-01 02:02:20 +00:00
feat(ui): configure and inspect agent identities in existing forms
This commit is contained in:
parent
963da299a5
commit
03d0cb2ecc
25 changed files with 864 additions and 44 deletions
|
|
@ -0,0 +1,43 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders, testQueryClient } from "../../../../../tests/test-utils";
|
||||
import { apiClient } from "@/components/networking";
|
||||
import { AgentIdentityDetails } from "./AgentIdentityDetails";
|
||||
|
||||
vi.mock("@/components/networking", () => ({ apiClient: { get: vi.fn() } }));
|
||||
|
||||
const identity = {
|
||||
provider: "microsoft_entra",
|
||||
tenant_id: "11111111-1111-4111-8111-111111111111",
|
||||
client_id: "22222222-2222-4222-8222-222222222222",
|
||||
};
|
||||
|
||||
const status = {
|
||||
enabled: true,
|
||||
execution_mode: "autonomous",
|
||||
last_authenticated_at: "2026-09-24T12:00:00Z",
|
||||
};
|
||||
|
||||
describe("agent identity evidence", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testQueryClient.clear();
|
||||
});
|
||||
|
||||
it("shows persisted application identity evidence and links to the current logs route", async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue(status);
|
||||
renderWithProviders(<AgentIdentityDetails agentId="native" identity={identity} accessToken="admin" isAdmin />);
|
||||
expect(await screen.findByText(/Last authenticated identity match:/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Application \(Client\) ID:/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "View request logs" })).toHaveAttribute("href", "/ui/logs/");
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/v1/agents/native/identity", { accessToken: "admin" });
|
||||
});
|
||||
|
||||
it("does not request or show administrator identity evidence to ordinary users", () => {
|
||||
renderWithProviders(
|
||||
<AgentIdentityDetails agentId="native" identity={identity} accessToken="user" isAdmin={false} />,
|
||||
);
|
||||
expect(screen.queryByRole("region", { name: "Agent Identity" })).not.toBeInTheDocument();
|
||||
expect(apiClient.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import React from "react";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/components/networking";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { readAgentIdentity } from "./agent_identity";
|
||||
|
||||
const authenticationMessage = (error: boolean, lastAuthenticated?: string | null): string => {
|
||||
if (error) return "Could not load authentication evidence";
|
||||
if (lastAuthenticated) return `Last authenticated identity match: ${new Date(lastAuthenticated).toLocaleString()}`;
|
||||
return "Configured, awaiting an authenticated request";
|
||||
};
|
||||
|
||||
export const AgentIdentityDetails = ({
|
||||
agentId,
|
||||
identity: value,
|
||||
accessToken,
|
||||
isAdmin,
|
||||
}: {
|
||||
agentId: string;
|
||||
identity: unknown;
|
||||
accessToken: string | null;
|
||||
isAdmin: boolean;
|
||||
}) => {
|
||||
const identity = readAgentIdentity(value);
|
||||
const { data, isError, isFetching, refetch } = useQuery({
|
||||
queryKey: ["agent-identity", agentId, identity],
|
||||
queryFn: () =>
|
||||
apiClient.get<components["schemas"]["ManagedAgentIdentityStatus"]>(
|
||||
`/v1/agents/${encodeURIComponent(agentId)}/identity`,
|
||||
{
|
||||
accessToken: accessToken ?? "",
|
||||
},
|
||||
),
|
||||
enabled: Boolean(isAdmin && accessToken && identity),
|
||||
});
|
||||
|
||||
if (!identity || !isAdmin) return null;
|
||||
const executionLabel = data?.enabled ? "Enabled" : "Disabled";
|
||||
return (
|
||||
<section aria-label="Agent Identity" className="mb-6 space-y-2 rounded-lg border border-border p-4">
|
||||
<h3 className="font-medium">Agent Identity: Microsoft Entra ID</h3>
|
||||
<p className="text-sm">
|
||||
Tenant: <span className="font-mono">{identity.tenant_id}</span>
|
||||
</p>
|
||||
<>
|
||||
<p className="text-sm">
|
||||
Application (Client) ID: <span className="font-mono">{identity.client_id}</span>
|
||||
</p>
|
||||
<p className="text-sm">Enterprise application Object ID: {identity.service_principal_id || "Not configured"}</p>
|
||||
</>
|
||||
<p className="text-sm">
|
||||
Execution: {data ? executionLabel : "Loading"} · Mode: {data?.execution_mode ?? "Loading"}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{data?.identity?.active === false
|
||||
? "Identity unbound; execution is disabled"
|
||||
: authenticationMessage(isError, data?.last_authenticated_at)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recent evidence comes from a validated Entra token matching this binding. It is persisted across restarts and
|
||||
cleared when the binding changes. Tool and model permissions are checked separately.
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isFetching}
|
||||
onClick={() => {
|
||||
void refetch();
|
||||
}}
|
||||
>
|
||||
Refresh authentication evidence
|
||||
</Button>
|
||||
<a className="text-sm underline" href="/ui/logs/">
|
||||
View request logs
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,259 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { useWatch } from "react-hook-form";
|
||||
import { apiClient } from "@/components/networking";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { AgentFormField, type AgentFormValues } from "./AgentFormKit";
|
||||
import { entraTenantFromIssuer, IDENTITY_UUID_PATTERN } from "./agent_identity";
|
||||
|
||||
const PROVIDER_OPTIONS = [
|
||||
{ value: "none", label: "No explicit identity binding" },
|
||||
{ value: "microsoft_entra", label: "Microsoft Entra ID" },
|
||||
];
|
||||
const EXECUTION_MODE_OPTIONS = [
|
||||
{ value: "autonomous", label: "Autonomous" },
|
||||
{ value: "delegated", label: "On behalf of a user" },
|
||||
{ value: "both", label: "Both" },
|
||||
];
|
||||
const EXECUTION_OPTIONS = [
|
||||
{ value: "enabled", label: "Enabled" },
|
||||
{ value: "disabled", label: "Disabled" },
|
||||
];
|
||||
|
||||
export const AgentIdentityFields = ({ accessToken }: { accessToken: string | null }) => {
|
||||
const provider = useWatch<AgentFormValues>({ name: "identity_provider" });
|
||||
const mode = useWatch<AgentFormValues>({ name: "execution_mode" });
|
||||
const showScopes = mode !== "autonomous" && mode !== undefined;
|
||||
const [tenants, setTenants] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken || provider !== "microsoft_entra") return;
|
||||
let active = true;
|
||||
apiClient
|
||||
.get<string[]>("/v1/agents/identity/providers", { accessToken })
|
||||
.then((issuers) => {
|
||||
if (active)
|
||||
setTenants(
|
||||
issuers.flatMap((issuer) => {
|
||||
const tenant = entraTenantFromIssuer(issuer);
|
||||
return tenant ? [tenant] : [];
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setError("Could not load the gateway's trusted identity providers");
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [accessToken, provider]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section aria-label="Agent Identity" className="my-6 space-y-4 rounded-lg border border-border p-4">
|
||||
<div>
|
||||
<h3 className="font-medium">Agent Identity</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Connect an existing identity provider application to this agent. Its name and runtime address can change
|
||||
independently.
|
||||
</p>
|
||||
</div>
|
||||
<AgentFormField name="identity_provider" label="Identity Provider" defaultValue="none">
|
||||
{({ value, onChange, id }) => (
|
||||
<Select
|
||||
items={PROVIDER_OPTIONS}
|
||||
value={typeof value === "string" ? value : "none"}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
<SelectTrigger id={id}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROVIDER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</AgentFormField>
|
||||
{provider === "microsoft_entra" && (
|
||||
<>
|
||||
<AgentFormField
|
||||
name="identity_tenant_id"
|
||||
label="Trusted Entra Tenant"
|
||||
rules={{ required: "Select a trusted tenant" }}
|
||||
>
|
||||
{({ value, onChange, id }) => (
|
||||
<Select value={typeof value === "string" ? value : ""} onValueChange={onChange}>
|
||||
<SelectTrigger id={id}>
|
||||
<SelectValue placeholder="Select the gateway's trusted tenant" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tenants.map((tenant) => (
|
||||
<SelectItem key={tenant} value={tenant}>
|
||||
{tenant}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</AgentFormField>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!error && tenants.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No trusted Entra tenant is available. Configure JWT issuer and audience validation on the gateway first.
|
||||
Dashboard Microsoft SSO is configured separately.
|
||||
</p>
|
||||
)}
|
||||
<AgentFormField
|
||||
name="identity_client_id"
|
||||
label="Application (Client) ID"
|
||||
rules={{
|
||||
required: "Enter the Entra application client ID",
|
||||
pattern: { value: IDENTITY_UUID_PATTERN, message: "Enter a valid application client UUID" },
|
||||
}}
|
||||
description={
|
||||
<>
|
||||
Find this under{" "}
|
||||
<a className="underline" href="https://entra.microsoft.com/" target="_blank" rel="noreferrer">
|
||||
Entra App registrations
|
||||
</a>
|
||||
, select your agent application, then Overview. No client secret is required here.
|
||||
</>
|
||||
}
|
||||
>
|
||||
{({ value, onChange, ref, ...control }) => (
|
||||
<Input
|
||||
{...control}
|
||||
ref={ref}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
/>
|
||||
)}
|
||||
</AgentFormField>
|
||||
<AgentFormField name="execution_mode" label="Execution Mode" defaultValue="autonomous">
|
||||
{({ value, onChange, id }) => (
|
||||
<Select
|
||||
items={EXECUTION_MODE_OPTIONS}
|
||||
value={typeof value === "string" ? value : "autonomous"}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
<SelectTrigger id={id}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EXECUTION_MODE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</AgentFormField>
|
||||
<AgentFormField
|
||||
name="identity_service_principal_id"
|
||||
label="Enterprise Application Object ID"
|
||||
rules={{
|
||||
required: mode !== "delegated" ? "Enter the service principal Object ID" : false,
|
||||
pattern: { value: IDENTITY_UUID_PATTERN, message: "Enter a valid service principal UUID" },
|
||||
}}
|
||||
description={
|
||||
<>
|
||||
Open{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://entra.microsoft.com/#view/Microsoft_AAD_IAM/StartboardApplicationsMenuBlade/~/AppAppsPreview"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Entra Enterprise applications
|
||||
</a>
|
||||
, select this application, and copy its Object ID. The App registrations Object ID is a different
|
||||
value.
|
||||
</>
|
||||
}
|
||||
>
|
||||
{({ value, onChange, ref, ...control }) => (
|
||||
<Input {...control} ref={ref} value={typeof value === "string" ? value : ""} onChange={onChange} />
|
||||
)}
|
||||
</AgentFormField>
|
||||
|
||||
<AgentFormField
|
||||
name="identity_required_roles"
|
||||
label="Required Application Roles"
|
||||
description="Comma-separated role values required on autonomous application tokens"
|
||||
>
|
||||
{({ value, onChange, ref, ...control }) => (
|
||||
<Input
|
||||
{...control}
|
||||
ref={ref}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
placeholder="Agent.Invoke"
|
||||
/>
|
||||
)}
|
||||
</AgentFormField>
|
||||
|
||||
{showScopes && (
|
||||
<>
|
||||
<AgentFormField
|
||||
name="identity_required_scopes"
|
||||
label="Required Delegated Scopes"
|
||||
defaultValue="user_impersonation"
|
||||
rules={{ required: "Enter a delegated scope" }}
|
||||
>
|
||||
{({ value, onChange, ref, ...control }) => (
|
||||
<Input
|
||||
{...control}
|
||||
ref={ref}
|
||||
value={typeof value === "string" ? value : "user_impersonation"}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</AgentFormField>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Users must first sign in through this gateway's Microsoft SSO. Subsequent delegated calls must
|
||||
satisfy both user and agent permissions.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<AgentFormField name="enabled" label="Execution" defaultValue={true}>
|
||||
{({ value, onChange, id }) => (
|
||||
<Select
|
||||
items={EXECUTION_OPTIONS}
|
||||
value={value === false ? "disabled" : "enabled"}
|
||||
onValueChange={(next) => onChange(next === "enabled")}
|
||||
>
|
||||
<SelectTrigger id={id}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EXECUTION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</AgentFormField>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
LiteLLM verifies the agent's Entra token before matching this identity. Saving these fields
|
||||
configures the binding; an authenticated request provides verification. Runtime authentication headers are
|
||||
configured separately.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -145,10 +145,10 @@ const AgentsPanel: React.FC<AgentsPanelProps> = ({ accessToken, userRole, teams
|
|||
</p>
|
||||
<Alert className="mb-3">
|
||||
<Info />
|
||||
<AlertTitle>Why do agents need keys?</AlertTitle>
|
||||
<AlertTitle>How do agents authenticate?</AlertTitle>
|
||||
<AlertDescription>
|
||||
Keys scope access to an agent and allow it to call MCP tools. Assign a key when creating an agent or from
|
||||
the Virtual Keys page.
|
||||
Agents can authenticate with a virtual key or a trusted identity provider using JWT. Configure an identity
|
||||
binding when adding or editing an agent. JWT authentication does not require a virtual key.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{isAdmin && (
|
||||
|
|
|
|||
|
|
@ -62,6 +62,12 @@ describe("AgentsTable", () => {
|
|||
expect(within(keylessRow).getByText("Needs Setup")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows JWT configured for agents without a virtual key", () => {
|
||||
render(<AgentsTable agents={[makeAgent({ keys: [], jwt_auth_configured: true })]} {...baseProps} />);
|
||||
expect(screen.getByText("JWT configured")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Needs Setup")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("deletes an agent through the ⋯ actions menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDeleteClick = vi.fn();
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ export const getAgentsTableColumns = ({
|
|||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const hasKeys = (row.original.keys?.length ?? 0) > 0;
|
||||
if (row.original.jwt_auth_configured) return <StatusBadge tone="success" label="JWT configured" />;
|
||||
return hasKeys ? (
|
||||
<StatusBadge tone="success" label="Active" />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from "react";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import AddAgentForm from "./add_agent_form";
|
||||
|
|
@ -8,6 +8,7 @@ import type { AgentCreateInfo } from "@/components/networking";
|
|||
import { chooseSelectOption, renderWithProviders as render } from "../../../../../tests/test-utils";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
apiClient: { get: vi.fn() },
|
||||
createAgentCall: vi.fn(),
|
||||
getAgentCreateMetadata: vi.fn(),
|
||||
getAgentsList: vi.fn(),
|
||||
|
|
@ -95,6 +96,51 @@ describe("AddAgentForm submit payload", () => {
|
|||
.mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
it("registers a readable agent with an explicit Entra identity and no virtual key", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
const tenant = "11111111-1111-4111-8111-111111111111";
|
||||
const clientId = "22222222-2222-4222-8222-222222222222";
|
||||
vi.mocked(networking.apiClient.get).mockResolvedValue([`https://login.microsoftonline.com/${tenant}/v2.0`]);
|
||||
renderForm();
|
||||
fireEvent.change(await screen.findByLabelText("Agent Name"), { target: { value: "Readable agent" } });
|
||||
fireEvent.change(screen.getByLabelText("URL"), { target: { value: "https://runtime.example/a2a" } });
|
||||
fireEvent.change(screen.getByLabelText("Display Name"), { target: { value: "Readable agent" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("Describe what this agent does..."), {
|
||||
target: { value: "Test agent" },
|
||||
});
|
||||
await user.click(screen.getByLabelText("Identity Provider"));
|
||||
await user.click(await screen.findByRole("option", { name: "Microsoft Entra ID" }));
|
||||
await user.click(screen.getByLabelText("Trusted Entra Tenant"));
|
||||
await user.click(await screen.findByRole("option", { name: tenant }));
|
||||
fireEvent.change(screen.getByLabelText("Application (Client) ID"), { target: { value: clientId } });
|
||||
fireEvent.change(screen.getByLabelText("Enterprise Application Object ID"), {
|
||||
target: { value: "33333333-3333-4333-8333-333333333333" },
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: /^Next/ }));
|
||||
await user.click(screen.getByRole("button", { name: /^Next/ }));
|
||||
await user.click(screen.getByRole("button", { name: /^Next/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Use Entra JWT authentication" }));
|
||||
await user.click(screen.getByRole("button", { name: /Create Agent/ }));
|
||||
await waitFor(() => expect(networking.createAgentCall).toHaveBeenCalledTimes(1));
|
||||
expect(createdPayload().agent_name).toBe("Readable agent");
|
||||
const expectedIdentity = {
|
||||
provider: "microsoft_entra",
|
||||
tenant_id: tenant,
|
||||
client_id: clientId,
|
||||
service_principal_id: "33333333-3333-4333-8333-333333333333",
|
||||
required_roles: [],
|
||||
required_scopes: ["user_impersonation"],
|
||||
};
|
||||
expect(createdPayload().identity).toEqual(expectedIdentity);
|
||||
expect(createdPayload()).not.toHaveProperty("litellm_params.identity");
|
||||
expect(networking.keyCreateForAgentCall).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Microsoft Entra ID is configured. Send an authenticated agent request to verify the connection.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends every a2a field the user filled across all collapsible panels", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
renderForm();
|
||||
|
|
|
|||
|
|
@ -83,7 +83,9 @@ describe("AddAgentForm logos", () => {
|
|||
expect(titleLogo).toBeInstanceOf(HTMLImageElement);
|
||||
expect(titleLogo).toHaveAttribute("src", expect.stringContaining("assets/logos/a2a_agent.png"));
|
||||
|
||||
const selectionLogo = within(await screen.findByRole("combobox")).getByAltText("A2A Agent logo");
|
||||
const selectionLogo = within(await screen.findByRole("combobox", { name: "Agent Type" })).getByAltText(
|
||||
"A2A Agent logo",
|
||||
);
|
||||
expect(selectionLogo).toBeInstanceOf(HTMLImageElement);
|
||||
expect(selectionLogo).toHaveAttribute("src", expect.stringContaining("assets/logos/a2a_agent.png"));
|
||||
});
|
||||
|
|
@ -93,14 +95,14 @@ describe("AddAgentForm logos", () => {
|
|||
|
||||
await screen.findByAltText("A2A Agent logo");
|
||||
|
||||
expect(screen.getByLabelText("Agent Type")).toBe(screen.getByRole("combobox"));
|
||||
expect(screen.getByLabelText("Agent Type")).toBe(screen.getByRole("combobox", { name: "Agent Type" }));
|
||||
});
|
||||
|
||||
it("renders the option logo when the agent type dropdown is opened", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
renderForm();
|
||||
|
||||
const trigger = await screen.findByRole("combobox");
|
||||
const trigger = await screen.findByRole("combobox", { name: "Agent Type" });
|
||||
await within(trigger).findByAltText("A2A Agent logo");
|
||||
await user.click(trigger);
|
||||
|
||||
|
|
@ -123,7 +125,7 @@ describe("AddAgentForm logos", () => {
|
|||
expect(screen.queryByAltText("Agent logo")).not.toBeInTheDocument();
|
||||
expect(within(header).getByText("A")).toBeInTheDocument();
|
||||
|
||||
const trigger = screen.getByRole("combobox");
|
||||
const trigger = screen.getByRole("combobox", { name: "Agent Type" });
|
||||
fireEvent.error(within(trigger).getByAltText("A2A Agent logo"));
|
||||
expect(within(trigger).queryByAltText("A2A Agent logo")).not.toBeInTheDocument();
|
||||
expect(warnSpy).toHaveBeenCalledTimes(2);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { AgentIdentityFields } from "./AgentIdentityFields";
|
||||
import { withAgentIdentity } from "./agent_identity";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { FormProvider, useForm, useWatch } from "react-hook-form";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
|
@ -287,6 +289,7 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
|
||||
const buildAgentData = (values: AgentFormValues): AgentRequestPayload | null => {
|
||||
if (agentType === CUSTOM_AGENT_TYPE) {
|
||||
if (values.identity_provider === "microsoft_entra") return { agent_name: values.agent_name };
|
||||
return {
|
||||
agent_name: values.agent_name,
|
||||
agent_card_params: {
|
||||
|
|
@ -353,12 +356,13 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
return;
|
||||
}
|
||||
const values = form.getValues();
|
||||
const agentData = buildAgentData(values);
|
||||
if (!agentData) {
|
||||
const built = buildAgentData(values);
|
||||
if (!built) {
|
||||
toast.error("Failed to build agent data");
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
const agentData = withAgentIdentity(built, values);
|
||||
|
||||
// Build object_permission from MCP Tools step (allowed_mcp_servers_and_groups, mcp_tool_permissions)
|
||||
const mcpServersAndGroups = values.allowed_mcp_servers_and_groups ?? {};
|
||||
|
|
@ -792,7 +796,7 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
<StatusBadge tone="warning" label="GENERIC" className="h-4 px-1 text-[10px]" />
|
||||
</span>
|
||||
<span className="block text-xs whitespace-normal text-warning">
|
||||
For agents that don't follow a standard protocol, just needs a virtual key
|
||||
For outbound agents using an identity provider or virtual key
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
|
@ -801,6 +805,8 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
</Select>
|
||||
</Field>
|
||||
|
||||
<AgentIdentityFields accessToken={accessToken} />
|
||||
|
||||
<div className="mt-4">
|
||||
{agentType === CUSTOM_AGENT_TYPE ? (
|
||||
<FieldGroup>
|
||||
|
|
@ -910,7 +916,7 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
name="team_id"
|
||||
label={labelWithHint(
|
||||
"Assign to Team",
|
||||
"Optionally assign this agent to a team. The agent and its key will belong to the selected team.",
|
||||
"Optionally select a team for the virtual key. The agent identity and its permissions are managed separately.",
|
||||
)}
|
||||
>
|
||||
{({ value, onChange }) => (
|
||||
|
|
@ -920,6 +926,11 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
|
||||
<Separator className="my-4" />
|
||||
|
||||
{form.getValues("identity_provider") === "microsoft_entra" && (
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
This agent will authenticate with Microsoft Entra ID. You can skip virtual key creation.
|
||||
</p>
|
||||
)}
|
||||
<RadioGroup
|
||||
value={keyAssignOption}
|
||||
onValueChange={(value) => setKeyAssignOption(value as "create_new" | "existing_key" | "skip")}
|
||||
|
|
@ -1004,7 +1015,9 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
className="text-sm text-muted-foreground underline hover:text-foreground"
|
||||
onClick={() => setKeyAssignOption("skip")}
|
||||
>
|
||||
Skip for now — I'll assign a key later
|
||||
{form.getValues("identity_provider") === "microsoft_entra"
|
||||
? "Use Entra JWT authentication"
|
||||
: "Skip for now, I’ll assign a key later"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1033,7 +1046,9 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
)}
|
||||
{!createdKeyValue && !assignedKeyAlias && keyAssignOption === "skip" && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No key assigned. You can create one from the Virtual Keys page.
|
||||
{form.getValues("identity_provider") === "microsoft_entra"
|
||||
? "Microsoft Entra ID is configured. Send an authenticated agent request to verify the connection."
|
||||
: "No key assigned. You can create one from the Virtual Keys page."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { parseIdentityForForm } from "./agent_identity";
|
||||
/**
|
||||
* Shared configuration for agent form fields
|
||||
* Used across create, view, and update operations
|
||||
|
|
@ -57,7 +58,7 @@ export const AGENT_FORM_CONFIG: {
|
|||
name: "description",
|
||||
label: "Description",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
required: false,
|
||||
placeholder: "Describe what this agent does...",
|
||||
rows: 3,
|
||||
},
|
||||
|
|
@ -340,6 +341,7 @@ export const parseAccessGroupIdsForForm = (agent: { access_group_ids?: string[]
|
|||
});
|
||||
|
||||
export const parseMcpPermissionsForForm = (agent: any) => ({
|
||||
...parseIdentityForForm(agent),
|
||||
allowed_mcp_servers_and_groups: {
|
||||
servers: agent.object_permission?.mcp_servers ?? [],
|
||||
accessGroups: agent.object_permission?.mcp_access_groups ?? [],
|
||||
|
|
@ -363,8 +365,9 @@ export const buildMcpObjectPermission = (values: any) => ({
|
|||
* Parse agent data for form fields
|
||||
*/
|
||||
export const parseAgentForForm = (agent: any) => {
|
||||
const card = agent.agent_card_params ?? {};
|
||||
const skills =
|
||||
agent.agent_card_params?.skills?.map((skill: any) => ({
|
||||
card.skills?.map((skill: any) => ({
|
||||
...skill,
|
||||
tags: skill.tags,
|
||||
examples: skill.examples || [],
|
||||
|
|
@ -372,18 +375,18 @@ export const parseAgentForForm = (agent: any) => {
|
|||
|
||||
return {
|
||||
agent_name: agent.agent_name,
|
||||
name: agent.agent_card_params?.name,
|
||||
description: agent.agent_card_params?.description,
|
||||
url: agent.agent_card_params?.url,
|
||||
version: agent.agent_card_params?.version,
|
||||
protocolVersion: agent.agent_card_params?.protocolVersion,
|
||||
streaming: agent.agent_card_params?.capabilities?.streaming,
|
||||
pushNotifications: agent.agent_card_params?.capabilities?.pushNotifications,
|
||||
stateTransitionHistory: agent.agent_card_params?.capabilities?.stateTransitionHistory,
|
||||
name: card.name || agent.agent_name,
|
||||
description: card.description,
|
||||
url: card.url,
|
||||
version: card.version,
|
||||
protocolVersion: card.protocolVersion,
|
||||
streaming: card.capabilities?.streaming,
|
||||
pushNotifications: card.capabilities?.pushNotifications,
|
||||
stateTransitionHistory: card.capabilities?.stateTransitionHistory,
|
||||
skills: skills,
|
||||
iconUrl: agent.agent_card_params?.iconUrl,
|
||||
documentationUrl: agent.agent_card_params?.documentationUrl,
|
||||
supportsAuthenticatedExtendedCard: agent.agent_card_params?.supportsAuthenticatedExtendedCard,
|
||||
iconUrl: card.iconUrl,
|
||||
documentationUrl: card.documentationUrl,
|
||||
supportsAuthenticatedExtendedCard: card.supportsAuthenticatedExtendedCard,
|
||||
model: agent.litellm_params?.model,
|
||||
make_public: agent.litellm_params?.make_public,
|
||||
cost_per_query: agent.litellm_params?.cost_per_query,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildIdentityParams,
|
||||
entraTenantFromIssuer,
|
||||
parseIdentityForForm,
|
||||
readAgentIdentity,
|
||||
withAgentIdentity,
|
||||
} from "./agent_identity";
|
||||
|
||||
const identity = {
|
||||
provider: "microsoft_entra",
|
||||
tenant_id: "11111111-1111-4111-8111-111111111111",
|
||||
client_id: "22222222-2222-4222-8222-222222222222",
|
||||
service_principal_id: "33333333-3333-4333-8333-333333333333",
|
||||
required_roles: ["Agent.Invoke"],
|
||||
required_scopes: ["user_impersonation"],
|
||||
} satisfies import("./agent_identity").EntraAgentIdentity;
|
||||
|
||||
describe("agent identity configuration", () => {
|
||||
it("round trips an existing binding independently of the agent name and runtime", () => {
|
||||
const values = {
|
||||
...parseIdentityForForm({
|
||||
identity: { ...identity, agent_id: "stable", active: true, revision: "rev", issuer: "https://issuer.example" },
|
||||
}),
|
||||
agent_name: "Renamed",
|
||||
url: "https://new-runtime.example",
|
||||
};
|
||||
expect(buildIdentityParams(values)).toEqual({ identity });
|
||||
});
|
||||
it("preserves untouched bindings and explicitly clears a removed binding", () => {
|
||||
expect(buildIdentityParams({ agent_name: "legacy" })).toEqual({});
|
||||
expect(buildIdentityParams({ identity_provider: "none" }, identity)).toEqual({ identity: null });
|
||||
expect(parseIdentityForForm({}).identity_provider).toBe("none");
|
||||
});
|
||||
it.each([
|
||||
null,
|
||||
{},
|
||||
"invalid",
|
||||
{ ...identity, client_id: "bad" },
|
||||
{ ...identity, tenant_id: 3 },
|
||||
{ ...identity, provider: "other" },
|
||||
])("rejects malformed bindings: %j", (value) => {
|
||||
expect(readAgentIdentity(value)).toBeNull();
|
||||
});
|
||||
it("rejects incomplete submissions", () => {
|
||||
expect(() => buildIdentityParams({ identity_provider: "microsoft_entra" })).toThrow("Enter valid Entra");
|
||||
});
|
||||
it("submits identity as top-level settings without changing runtime parameters", () => {
|
||||
const formValues = {
|
||||
identity_provider: "microsoft_entra",
|
||||
identity_tenant_id: identity.tenant_id,
|
||||
identity_client_id: identity.client_id,
|
||||
identity_service_principal_id: identity.service_principal_id,
|
||||
execution_mode: "both",
|
||||
enabled: false,
|
||||
};
|
||||
const payload = withAgentIdentity({ litellm_params: { model: "runtime" } }, formValues);
|
||||
expect(payload.litellm_params).toEqual({ model: "runtime" });
|
||||
expect(payload.identity).toMatchObject({
|
||||
client_id: identity.client_id,
|
||||
service_principal_id: identity.service_principal_id,
|
||||
});
|
||||
expect(payload.execution_mode).toBe("both");
|
||||
expect(payload.enabled).toBe(false);
|
||||
});
|
||||
it("requires a service principal for autonomous execution", () => {
|
||||
const values = {
|
||||
identity_provider: "microsoft_entra",
|
||||
identity_tenant_id: identity.tenant_id,
|
||||
identity_client_id: identity.client_id,
|
||||
execution_mode: "autonomous",
|
||||
};
|
||||
expect(() => buildIdentityParams(values)).toThrow("Enterprise application Object ID");
|
||||
});
|
||||
|
||||
it("only offers tenant-specific Microsoft issuers", () => {
|
||||
expect(entraTenantFromIssuer(`https://login.microsoftonline.com/${identity.tenant_id}/v2.0`)).toBe(
|
||||
identity.tenant_id,
|
||||
);
|
||||
expect(entraTenantFromIssuer("https://attacker.example/tenant/v2.0")).toBeNull();
|
||||
expect(entraTenantFromIssuer("https://login.microsoftonline.com/common/v2.0")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import { z } from "zod";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
import type { AgentFormValues, AgentRequestPayload } from "./AgentFormKit";
|
||||
|
||||
export type EntraAgentIdentity = components["schemas"]["EntraIdentityConfig"];
|
||||
type AgentIdentityState = Pick<components["schemas"]["AgentResponse"], "identity" | "enabled" | "execution_mode">;
|
||||
|
||||
export const IDENTITY_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
const stringGrants = (fallback: string[]) =>
|
||||
z
|
||||
.unknown()
|
||||
.transform((value) =>
|
||||
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : fallback,
|
||||
);
|
||||
|
||||
const identityShape = {
|
||||
provider: z.literal("microsoft_entra"),
|
||||
tenant_id: z.string().regex(IDENTITY_UUID_PATTERN),
|
||||
client_id: z.string().regex(IDENTITY_UUID_PATTERN),
|
||||
service_principal_id: z.string().regex(IDENTITY_UUID_PATTERN).nullable().default(null),
|
||||
required_roles: stringGrants([]),
|
||||
required_scopes: stringGrants(["user_impersonation"]),
|
||||
};
|
||||
const identitySchema = z.object(identityShape);
|
||||
|
||||
export const readAgentIdentity = (value: unknown): EntraAgentIdentity | null => {
|
||||
const parsed = identitySchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : null;
|
||||
};
|
||||
|
||||
const identityFormFields = (identity: EntraAgentIdentity | null): AgentFormValues => ({
|
||||
identity_provider: identity?.provider ?? "none",
|
||||
identity_tenant_id: identity?.tenant_id ?? "",
|
||||
identity_client_id: identity?.client_id ?? "",
|
||||
identity_service_principal_id: identity?.service_principal_id ?? "",
|
||||
identity_required_roles: identity?.required_roles?.join(", ") ?? "",
|
||||
identity_required_scopes: identity?.required_scopes?.join(", ") ?? "user_impersonation",
|
||||
});
|
||||
|
||||
export const parseIdentityForForm = (agent?: Partial<AgentIdentityState> | null): AgentFormValues => {
|
||||
const identity = agent?.identity?.active === false ? null : readAgentIdentity(agent?.identity);
|
||||
return {
|
||||
...identityFormFields(identity),
|
||||
execution_mode: agent?.execution_mode ?? "autonomous",
|
||||
enabled: agent?.enabled ?? true,
|
||||
};
|
||||
};
|
||||
|
||||
const splitGrants = (value: unknown, fallback: string[]): string[] =>
|
||||
typeof value === "string"
|
||||
? value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
: fallback;
|
||||
|
||||
export const buildIdentityParams = (
|
||||
values: AgentFormValues,
|
||||
existingIdentity?: unknown,
|
||||
): { identity?: EntraAgentIdentity | null } => {
|
||||
if (values.identity_provider === undefined) return {};
|
||||
if (values.identity_provider !== "microsoft_entra")
|
||||
return readAgentIdentity(existingIdentity) ? { identity: null } : {};
|
||||
const candidate: EntraAgentIdentity = {
|
||||
provider: "microsoft_entra",
|
||||
tenant_id: typeof values.identity_tenant_id === "string" ? values.identity_tenant_id.trim().toLowerCase() : "",
|
||||
client_id: typeof values.identity_client_id === "string" ? values.identity_client_id.trim().toLowerCase() : "",
|
||||
service_principal_id:
|
||||
typeof values.identity_service_principal_id === "string" && values.identity_service_principal_id.trim()
|
||||
? values.identity_service_principal_id.trim().toLowerCase()
|
||||
: null,
|
||||
required_roles: splitGrants(values.identity_required_roles, []),
|
||||
required_scopes: splitGrants(values.identity_required_scopes, ["user_impersonation"]),
|
||||
};
|
||||
const identity = readAgentIdentity(candidate);
|
||||
if (!identity) throw new Error("Enter valid Entra tenant, application client and service principal IDs");
|
||||
if (values.execution_mode !== "delegated" && !identity.service_principal_id)
|
||||
throw new Error("Autonomous agents require the Enterprise application Object ID");
|
||||
return { identity };
|
||||
};
|
||||
|
||||
export const entraTenantFromIssuer = (issuer: string): string | null => {
|
||||
const match = /^https:\/\/login\.microsoftonline\.com\/([^/]+)\/v2\.0$/.exec(issuer);
|
||||
return match && IDENTITY_UUID_PATTERN.test(match[1]) ? match[1] : null;
|
||||
};
|
||||
|
||||
export const withAgentIdentity = (
|
||||
payload: AgentRequestPayload,
|
||||
values: AgentFormValues,
|
||||
existing?: Partial<AgentIdentityState>,
|
||||
): AgentRequestPayload => {
|
||||
const identityFields = buildIdentityParams(values, existing?.identity);
|
||||
const managed = values.identity_provider === "microsoft_entra" || Boolean(readAgentIdentity(existing?.identity));
|
||||
return {
|
||||
...payload,
|
||||
...identityFields,
|
||||
...(managed && values.execution_mode !== undefined ? { execution_mode: values.execution_mode } : {}),
|
||||
...(managed && values.enabled !== undefined ? { enabled: values.enabled } : {}),
|
||||
};
|
||||
};
|
||||
|
|
@ -8,6 +8,7 @@ import * as networking from "@/components/networking";
|
|||
import type { AgentCreateInfo } from "@/components/networking";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
apiClient: { get: vi.fn() },
|
||||
getAgentInfo: vi.fn(),
|
||||
patchAgentCall: vi.fn(),
|
||||
getAgentCreateMetadata: vi.fn(),
|
||||
|
|
@ -155,6 +156,50 @@ describe("AgentInfoView update payload", () => {
|
|||
.mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
it.each(["complete", "empty"])("preserves the Entra binding while renaming an agent with a %s card", async (card) => {
|
||||
const user = setup();
|
||||
const identity = {
|
||||
provider: "microsoft_entra",
|
||||
tenant_id: "11111111-1111-4111-8111-111111111111",
|
||||
client_id: "22222222-2222-4222-8222-222222222222",
|
||||
service_principal_id: "33333333-3333-4333-8333-333333333333",
|
||||
};
|
||||
const params = { ...A2A_AGENT.litellm_params, require_trace_id_on_calls_by_agent: true };
|
||||
vi.mocked(networking.getAgentInfo).mockResolvedValue({
|
||||
...A2A_AGENT,
|
||||
agent_card_params: card === "empty" ? {} : A2A_AGENT.agent_card_params,
|
||||
litellm_params: params,
|
||||
identity: { ...identity, agent_id: "agent-1", issuer: "https://issuer.example", revision: "rev", active: true },
|
||||
identity_managed: true,
|
||||
execution_mode: "autonomous",
|
||||
enabled: true,
|
||||
access_group_ids: ["ag-entra"],
|
||||
} as never);
|
||||
vi.mocked(networking.apiClient.get).mockImplementation(async (path) =>
|
||||
path.endsWith("/providers")
|
||||
? [`https://login.microsoftonline.com/${identity.tenant_id}/v2.0`]
|
||||
: { last_authenticated_at: null },
|
||||
);
|
||||
renderView();
|
||||
expect(await screen.findByText("Configured, awaiting an authenticated request")).toBeInTheDocument();
|
||||
await openEditor(user);
|
||||
expect(screen.getByLabelText("Application (Client) ID")).toHaveValue(identity.client_id);
|
||||
expect(screen.getByRole("combobox", { name: "Identity Provider" })).toHaveTextContent("Microsoft Entra ID");
|
||||
expect(screen.getByRole("combobox", { name: "Execution Mode" })).toHaveTextContent("Autonomous");
|
||||
expect(screen.getByRole("combobox", { name: "Execution", exact: true })).toHaveTextContent("Enabled");
|
||||
fireEvent.change(screen.getByLabelText("Agent Name"), { target: { value: "Renamed agent" } });
|
||||
await save(user);
|
||||
expect(patchedPayload().agent_name).toBe("Renamed agent");
|
||||
expect(patchedPayload()).not.toHaveProperty("litellm_params");
|
||||
expect(patchedPayload().identity).toMatchObject(identity);
|
||||
expect(patchedPayload().access_group_ids).toEqual(["ag-entra"]);
|
||||
expect(networking.patchAgentCall).toHaveBeenCalledWith(
|
||||
"tok",
|
||||
"agent-1",
|
||||
expect.objectContaining({ agent_name: "Renamed agent" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends only the fields whose panel has been opened, dropping the rest", async () => {
|
||||
const user = setup();
|
||||
renderView();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from "react";
|
|||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import AgentInfoView from "./agent_info";
|
||||
import AgentFormFields from "./agent_form_fields";
|
||||
import * as networking from "@/components/networking";
|
||||
import type { Agent } from "@/components/agents/types";
|
||||
|
||||
|
|
@ -16,12 +17,16 @@ vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({
|
|||
useKeys: () => ({ data: { keys: [] }, isLoading: false, refetch: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("./AgentIdentityDetails", () => ({
|
||||
AgentIdentityDetails: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./agent_card_discovery", () => ({
|
||||
default: () => <div data-testid="agent-card-discovery" />,
|
||||
}));
|
||||
|
||||
vi.mock("./agent_form_fields", () => ({
|
||||
default: () => <div data-testid="agent-form-fields" />,
|
||||
default: vi.fn(() => <div data-testid="agent-form-fields" />),
|
||||
unmountedA2AFieldNames: () => [],
|
||||
}));
|
||||
|
||||
|
|
@ -77,6 +82,9 @@ const agent = {
|
|||
describe("AgentInfoView settings", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.mocked(AgentFormFields)
|
||||
.mockReset()
|
||||
.mockImplementation(() => <div data-testid="agent-form-fields" />);
|
||||
vi.mocked(networking.getAgentInfo).mockReset().mockResolvedValue(agent);
|
||||
vi.mocked(networking.getAgentCreateMetadata).mockReset().mockResolvedValue([]);
|
||||
vi.mocked(networking.patchAgentCall).mockReset().mockResolvedValue({});
|
||||
|
|
@ -104,6 +112,23 @@ describe("AgentInfoView settings", () => {
|
|||
expect(payload.access_group_ids).toEqual([]);
|
||||
});
|
||||
|
||||
it("saves unrelated settings when the existing card has no description", async () => {
|
||||
const actual = await vi.importActual<typeof import("./agent_form_fields")>("./agent_form_fields");
|
||||
vi.mocked(AgentFormFields).mockImplementation(actual.default);
|
||||
const { description: _description, ...card } = agent.agent_card_params;
|
||||
vi.mocked(networking.getAgentInfo).mockResolvedValue({ ...agent, agent_card_params: card });
|
||||
render(<AgentInfoView agentId="agent-1" onClose={vi.fn()} accessToken="sk-test" isAdmin={true} />);
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "Settings" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit Settings" }));
|
||||
expect(await screen.findByLabelText("Description")).toHaveValue("");
|
||||
fireEvent.change(screen.getByLabelText("TPM Limit"), { target: { value: "42" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save Changes/ }));
|
||||
await waitFor(() => expect(networking.patchAgentCall).toHaveBeenCalledOnce());
|
||||
const [, , payload] = vi.mocked(networking.patchAgentCall).mock.calls[0];
|
||||
expect(payload.tpm_limit).toBe(42);
|
||||
expect(payload.agent_card_params.description).toBe("");
|
||||
});
|
||||
|
||||
it("sends the newly attached access group in the update payload", async () => {
|
||||
render(<AgentInfoView agentId="agent-1" onClose={vi.fn()} accessToken="sk-test" isAdmin={true} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import { AgentIdentityFields } from "./AgentIdentityFields";
|
||||
import { AgentIdentityDetails } from "./AgentIdentityDetails";
|
||||
import { withAgentIdentity } from "./agent_identity";
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { cx } from "@/lib/cva.config";
|
||||
import { FormProvider, useForm, useWatch } from "react-hook-form";
|
||||
|
|
@ -237,7 +240,7 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
: built;
|
||||
|
||||
await patchAgentCall(accessToken, agentId, {
|
||||
...updateData,
|
||||
...withAgentIdentity(updateData, values, agent),
|
||||
object_permission: buildMcpObjectPermission(values),
|
||||
access_group_ids: values.access_group_ids ?? [],
|
||||
});
|
||||
|
|
@ -337,6 +340,12 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
<div>
|
||||
{/* Overview Panel */}
|
||||
<TabsContent value="overview" keepMounted>
|
||||
<AgentIdentityDetails
|
||||
agentId={agentId}
|
||||
identity={agent.identity}
|
||||
accessToken={accessToken}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
<DetailList>
|
||||
<DetailItem label="Agent ID">{agent.agent_id}</DetailItem>
|
||||
<DetailItem label="Agent Name">{agent.agent_name}</DetailItem>
|
||||
|
|
@ -505,6 +514,8 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
<AgentFormFields showAgentName={true} panels={panels} />
|
||||
)}
|
||||
|
||||
<AgentIdentityFields accessToken={accessToken} />
|
||||
|
||||
{discoveryRequest && (
|
||||
<div className="mt-4">
|
||||
<AgentCardDiscovery
|
||||
|
|
|
|||
|
|
@ -82,6 +82,35 @@ describe("AgentSelector", () => {
|
|||
expect(screen.getByRole("option", { name: /group-b/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers only individual agents when legacy groups are disabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<AgentSelector {...defaultProps} onChange={onChange} allowAccessGroups={false} />);
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(await screen.findByRole("option", { name: /Agent One/ }));
|
||||
expect(screen.queryByRole("option", { name: /group-a/ })).not.toBeInTheDocument();
|
||||
expect(onChange).toHaveBeenCalledWith({ agents: ["agent-1"], accessGroups: [] });
|
||||
});
|
||||
|
||||
it("preserves a saved legacy group while adding an agent with legacy groups disabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<AgentSelector
|
||||
{...defaultProps}
|
||||
onChange={onChange}
|
||||
allowAccessGroups={false}
|
||||
value={{ agents: [], accessGroups: ["retired-group"] }}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
expect(await screen.findByRole("option", { name: /retired-group/ })).toHaveTextContent(
|
||||
"Existing legacy agent group",
|
||||
);
|
||||
await user.click(await screen.findByRole("option", { name: /Agent One/ }));
|
||||
expect(onChange).toHaveBeenCalledWith({ agents: ["agent-1"], accessGroups: ["retired-group"] });
|
||||
});
|
||||
|
||||
it("respects disabled prop", () => {
|
||||
render(<AgentSelector {...defaultProps} disabled />);
|
||||
expect(screen.getByRole("combobox")).toBeDisabled();
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ interface AgentSelectorProps {
|
|||
accessToken: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
allowAccessGroups?: boolean;
|
||||
}
|
||||
|
||||
const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
|
|
@ -28,6 +29,7 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
|
|||
accessToken,
|
||||
placeholder = "Select agents",
|
||||
disabled = false,
|
||||
allowAccessGroups = true,
|
||||
}) => {
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [accessGroups, setAccessGroups] = useState<string[]>([]);
|
||||
|
|
@ -60,12 +62,15 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
|
|||
fetchData();
|
||||
}, [accessToken]);
|
||||
|
||||
// Combine options, access groups first
|
||||
const selectableGroups = allowAccessGroups
|
||||
? Array.from(new Set([...accessGroups, ...(value?.accessGroups ?? [])]))
|
||||
: value?.accessGroups ?? [];
|
||||
|
||||
const options: MultiSelectOption[] = [
|
||||
...accessGroups.map((group) => ({
|
||||
...selectableGroups.map((group) => ({
|
||||
label: group,
|
||||
value: `group:${group}`,
|
||||
description: "Access Group",
|
||||
description: allowAccessGroups ? "Access Group" : "Existing legacy agent group",
|
||||
})),
|
||||
...agents.map((agent) => ({
|
||||
label: `${agent.agent_name || agent.agent_id}`,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ export type AgentKillSwitchConfig = components["schemas"]["AgentKillSwitchConfig
|
|||
export type AgentKillSwitchResult = components["schemas"]["AgentKillSwitchResult"];
|
||||
|
||||
export interface Agent {
|
||||
identity?: components["schemas"]["AgentIdentityBinding"] | null;
|
||||
identity_managed?: boolean;
|
||||
enabled?: boolean;
|
||||
execution_mode?: components["schemas"]["AgentResponse"]["execution_mode"];
|
||||
jwt_auth_configured?: boolean;
|
||||
agent_id: string;
|
||||
agent_name: string;
|
||||
litellm_params: {
|
||||
|
|
|
|||
|
|
@ -170,3 +170,41 @@ describe("MCPServerSelector all-proxy-mcpservers option", () => {
|
|||
expect(optionByLabel("Server One")).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCPServerSelector unified group flow", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setupMcpMocks();
|
||||
mockUseMCPAccessGroups.mockReturnValue({ data: ["legacy-group"], isLoading: false } as ReturnType<
|
||||
typeof useMCPAccessGroups
|
||||
>);
|
||||
});
|
||||
|
||||
it("offers servers without legacy groups when disabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(<MCPServerSelector accessToken="tok" onChange={onChange} allowAccessGroups={false} />);
|
||||
await openSelector(user);
|
||||
expect(optionByLabel("legacy-group")).toBeUndefined();
|
||||
await user.click(optionByLabel("Server One")!);
|
||||
expect(onChange).toHaveBeenCalledWith({ servers: ["srv-1"], accessGroups: [], toolsets: [] });
|
||||
});
|
||||
|
||||
it("preserves a saved legacy group even if discovery no longer returns it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<MCPServerSelector
|
||||
accessToken="tok"
|
||||
onChange={onChange}
|
||||
allowAccessGroups={false}
|
||||
value={{ servers: [], accessGroups: ["retired-group"] }}
|
||||
/>,
|
||||
);
|
||||
await openSelector(user);
|
||||
expect(optionByLabel("retired-group")).toHaveTextContent("Existing legacy MCP group");
|
||||
expect(optionByLabel("legacy-group")).toBeUndefined();
|
||||
await user.click(optionByLabel("Server One")!);
|
||||
expect(onChange).toHaveBeenCalledWith({ servers: ["srv-1"], accessGroups: ["retired-group"], toolsets: [] });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,11 +18,15 @@ interface MCPServerSelectorProps {
|
|||
disabled?: boolean;
|
||||
teamId?: string | null;
|
||||
allowNoMcpServers?: boolean;
|
||||
allowAccessGroups?: boolean;
|
||||
allowAllProxyMcpServers?: boolean;
|
||||
}
|
||||
|
||||
const TOOLSET_PREFIX = "toolset:";
|
||||
|
||||
const selectableLegacyGroups = (available: string[], selected: string[] = [], allowNew: boolean): string[] =>
|
||||
allowNew ? Array.from(new Set([...available, ...selected])) : selected;
|
||||
|
||||
const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
|
||||
onChange,
|
||||
value,
|
||||
|
|
@ -32,22 +36,24 @@ const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
|
|||
disabled = false,
|
||||
teamId,
|
||||
allowNoMcpServers = false,
|
||||
allowAccessGroups = true,
|
||||
allowAllProxyMcpServers = false,
|
||||
}) => {
|
||||
const { data: mcpServers = [], isLoading: serversLoading } = useMCPServers(teamId);
|
||||
const { data: accessGroups = [], isLoading: groupsLoading } = useMCPAccessGroups();
|
||||
const { data: toolsets = [], isLoading: toolsetsLoading } = useMCPToolsets();
|
||||
|
||||
const loading = serversLoading || groupsLoading || toolsetsLoading;
|
||||
const loading = [serversLoading, groupsLoading, toolsetsLoading].some(Boolean);
|
||||
|
||||
const accessGroupSet = new Set(accessGroups);
|
||||
const selectableGroups = selectableLegacyGroups(accessGroups, value?.accessGroups, allowAccessGroups);
|
||||
const accessGroupSet = new Set(selectableGroups);
|
||||
|
||||
// Combine options: access groups + servers + toolsets
|
||||
const options = [
|
||||
...accessGroups.map((group) => ({
|
||||
...selectableGroups.map((group) => ({
|
||||
label: group,
|
||||
value: group,
|
||||
description: "Access Group",
|
||||
description: allowAccessGroups ? "Access Group" : "Existing legacy MCP group",
|
||||
})),
|
||||
...mcpServers.map((server) => ({
|
||||
label: `${server.server_name || server.server_id} (${server.server_id})`,
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export function AgentPermissions({
|
|||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserGroupIcon className="h-4 w-4 text-purple-600" />
|
||||
<p className="text-sm font-semibold text-foreground">Agents</p>
|
||||
<p className="text-sm font-semibold text-foreground">Allowed agents to call</p>
|
||||
<Badge variant="secondary">{totalCount}</Badge>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -2249,9 +2249,9 @@ describe("TeamInfoView - the exact bytes the update call sends", () => {
|
|||
await openEditorWithAgents(user);
|
||||
|
||||
await user.click(within(screen.getByLabelText("agent-1")).getByRole("button"));
|
||||
await user.click(within(screen.getByLabelText("group:group-a")).getByRole("button"));
|
||||
await user.click(within(screen.getByLabelText("group-a")).getByRole("button"));
|
||||
expect(screen.queryByLabelText("agent-1")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("group:group-a")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("group-a")).not.toBeInTheDocument();
|
||||
|
||||
const payload = await save(user);
|
||||
|
||||
|
|
|
|||
|
|
@ -2039,13 +2039,14 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="mcp_servers_and_groups" label="MCP Servers / Access Groups">
|
||||
<FormField control={form.control} name="mcp_servers_and_groups" label="MCP Servers">
|
||||
{({ value, onChange }) => (
|
||||
<MCPServerSelector
|
||||
allowAccessGroups={false}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select MCP servers or access groups (optional)"
|
||||
placeholder="Select MCP servers or toolsets (optional)"
|
||||
allowAllProxyMcpServers={is_proxy_admin}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -2062,13 +2063,14 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<FormField control={form.control} name="agents_and_groups" label="Agents / Access Groups">
|
||||
<FormField control={form.control} name="agents_and_groups" label="Agents">
|
||||
{({ value, onChange }) => (
|
||||
<AgentSelector
|
||||
allowAccessGroups={false}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select agents or access groups (optional)"
|
||||
placeholder="Select agents (optional)"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
|
|
|||
|
|
@ -375,3 +375,17 @@ describe("TTFT column", () => {
|
|||
expect(screen.getByText("1.00")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Request outcome", () => {
|
||||
it("shows a failed agent outcome even when metadata has no status", () => {
|
||||
renderRows([logEntry({ call_type: "asend_message", status: "failure", session_total_count: 4 })]);
|
||||
expect(screen.getByText("Failure")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Success")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("prefers the recorded outcome over stale metadata", () => {
|
||||
renderRows([logEntry({ status: "success", metadata: { status: "failure" } })]);
|
||||
expect(screen.getByText("Success")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Failure")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ export const getRequestLogsTableColumns = ({
|
|||
enableSorting: false,
|
||||
meta: { skeleton: "badge" },
|
||||
cell: ({ row }) => {
|
||||
const status = readMetaString(row.original.metadata, "status") ?? "Success";
|
||||
const status = row.original.status || readMetaString(row.original.metadata, "status") || "Success";
|
||||
const isSuccess = status.toLowerCase() !== "failure";
|
||||
const batchCounts = isSuccess ? getBatchRequestCounts(row.original.metadata) : undefined;
|
||||
if (batchCounts && batchCounts.failed > 0) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue