Merge pull request #24472 from BerriAI/litellm_extract_pipeline_test_panel

[Refactor] UI - Policies: Extract PipelineTestPanel from pipeline_flow_builder
This commit is contained in:
yuneng-jiang 2026-03-23 22:55:42 -07:00 • committed by GitHub
commit 0ce1ea37c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1253 additions and 612 deletions

View file

@ -2,199 +2,30 @@ import React, { useState } from "react";
import { Select, Typography, Spin } from "antd";
import MessageManager from "@/components/molecules/message_manager";
import { Button, TextInput } from "@tremor/react";
import { ArrowLeftIcon, PlusIcon } from "@heroicons/react/outline";
import { ArrowLeftIcon } from "@heroicons/react/outline";
import { DotsVerticalIcon } from "@heroicons/react/solid";
import { GuardrailPipeline, PipelineStep, PipelineTestResult, PolicyCreateRequest, PolicyUpdateRequest, Policy } from "./types";
import { GuardrailPipeline, PipelineStep, PolicyCreateRequest, PolicyUpdateRequest, Policy } from "./types";
import { Guardrail } from "../guardrails/types";
import { testPipelineCall, listPolicyVersions, createPolicyVersion, updatePolicyVersionStatus } from "../networking";
import { listPolicyVersions, createPolicyVersion, updatePolicyVersionStatus } from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
import {
getComplianceDatasetPrompts,
getFrameworks,
} from "../../data/compliancePrompts";
import type { CompliancePrompt } from "../../data/compliancePrompts";
const TEST_SOURCE_QUICK = "quick_chat";
const TEST_SOURCE_ALL = "__all__";
function getPromptsForTestSource(source: string): CompliancePrompt[] {
if (source === TEST_SOURCE_QUICK) return [];
if (source === TEST_SOURCE_ALL) return getComplianceDatasetPrompts();
const fw = getFrameworks().find((f) => f.name === source);
return fw ? fw.categories.flatMap((c) => c.prompts) : [];
}
ACTION_OPTIONS,
ACTION_LABELS,
createDefaultStep,
insertStep,
removeStep,
updateStepAtIndex,
derivePipelineFromPolicy,
GuardrailIcon,
PlayIcon,
PassIcon,
FailIcon,
Connector,
} from "./pipeline_utils";
import { PipelineTestPanel } from "./pipeline_test_drawer";
const { Text } = Typography;
const ACTION_OPTIONS = [
{ label: "Next Step", value: "next" },
{ label: "Allow", value: "allow" },
{ label: "Block", value: "block" },
{ label: "Custom Response", value: "modify_response" },
];
const ACTION_LABELS: Record<string, string> = {
allow: "Allow",
block: "Block",
next: "Next Step",
modify_response: "Custom Response",
};
function createDefaultStep(): PipelineStep {
return {
guardrail: "",
on_pass: "next",
on_fail: "block",
pass_data: false,
modify_response_message: null,
};
}
function insertStep(steps: PipelineStep[], atIndex: number): PipelineStep[] {
const newSteps = [...steps];
newSteps.splice(atIndex, 0, createDefaultStep());
return newSteps;
}
function removeStep(steps: PipelineStep[], index: number): PipelineStep[] {
if (steps.length <= 1) return steps;
const newSteps = [...steps];
newSteps.splice(index, 1);
return newSteps;
}
function updateStepAtIndex(
steps: PipelineStep[],
index: number,
updated: Partial<PipelineStep>
): PipelineStep[] {
return steps.map((s, i) => (i === index ? { ...s, ...updated } : s));
}
/**
* Derives a pipeline from a policy. When the policy has a pipeline, use it.
* When it only has guardrails_add (legacy/simple form), convert those guardrails
* into pipeline steps in order.
*/
function derivePipelineFromPolicy(policy: Policy | null | undefined): GuardrailPipeline {
if (!policy) {
return { mode: "pre_call", steps: [createDefaultStep()] };
}
if (policy.pipeline?.steps?.length) {
return policy.pipeline;
}
const guardrails = policy.guardrails_add || [];
if (guardrails.length > 0) {
return {
mode: policy.pipeline?.mode ?? "pre_call",
steps: guardrails.map((g) => ({
guardrail: g,
on_pass: "next" as const,
on_fail: "block" as const,
pass_data: false,
modify_response_message: null,
})),
};
}
return { mode: "pre_call", steps: [createDefaultStep()] };
}
// ─────────────────────────────────────────────────────────────────────────────
// Icons (matching the reference image)
// ─────────────────────────────────────────────────────────────────────────────
const GuardrailIcon: React.FC = () => (
<div
style={{
width: 28,
height: 28,
borderRadius: "50%",
backgroundColor: "#eef2ff",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6366f1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4" />
</svg>
</div>
);
const PlayIcon: React.FC = () => (
<div
style={{
width: 28,
height: 28,
borderRadius: "50%",
backgroundColor: "#f3f4f6",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="#6b7280" stroke="none">
<polygon points="6,3 20,12 6,21" />
</svg>
</div>
);
const PassIcon: React.FC = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<circle cx="12" cy="12" r="10" />
<path d="M9 12l2 2 4-4" />
</svg>
);
const FailIcon: React.FC = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#f87171" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<circle cx="12" cy="12" r="10" />
</svg>
);
// ─────────────────────────────────────────────────────────────────────────────
// Connector
// ─────────────────────────────────────────────────────────────────────────────
interface ConnectorProps {
onInsert: () => void;
}
const Connector: React.FC<ConnectorProps> = ({ onInsert }) => (
<div className="flex flex-col items-center" style={{ height: 56 }}>
<div style={{ width: 1, flex: 1, backgroundColor: "#d1d5db" }} />
<button
onClick={onInsert}
className="flex items-center justify-center"
style={{
width: 24,
height: 24,
borderRadius: "50%",
border: "1px solid #d1d5db",
backgroundColor: "#fff",
cursor: "pointer",
zIndex: 1,
transition: "all 0.15s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "#6366f1";
e.currentTarget.style.backgroundColor = "#eef2ff";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "#d1d5db";
e.currentTarget.style.backgroundColor = "#fff";
}}
title="Insert step"
>
<PlusIcon style={{ width: 12, height: 12, color: "#9ca3af" }} />
</button>
<div style={{ width: 1, flex: 1, backgroundColor: "#d1d5db" }} />
</div>
);
// ─────────────────────────────────────────────────────────────────────────────
// Step Card (editable)
// ─────────────────────────────────────────────────────────────────────────────
@ -581,432 +412,6 @@ export const PipelineInfoDisplay: React.FC<PipelineInfoDisplayProps> = ({ pipeli
</div>
);
// ─────────────────────────────────────────────────────────────────────────────
// Pipeline Test Panel (right drawer)
// ─────────────────────────────────────────────────────────────────────────────
interface PipelineTestPanelProps {
pipeline: GuardrailPipeline;
accessToken: string | null;
onClose: () => void;
}
const OUTCOME_STYLES: Record<string, { bg: string; color: string; label: string }> = {
pass: { bg: "#f0fdf4", color: "#16a34a", label: "PASS" },
fail: { bg: "#fef2f2", color: "#dc2626", label: "FAIL" },
error: { bg: "#fffbeb", color: "#d97706", label: "ERROR" },
};
const TERMINAL_STYLES: Record<string, { bg: string; color: string }> = {
allow: { bg: "#f0fdf4", color: "#16a34a" },
block: { bg: "#fef2f2", color: "#dc2626" },
modify_response: { bg: "#eff6ff", color: "#2563eb" },
};
interface ComplianceRunEntry {
prompt: CompliancePrompt;
result: PipelineTestResult | null;
error?: string;
matched: boolean;
}
function complianceMatchExpected(expected: "pass" | "fail", terminalAction: string): boolean {
if (expected === "pass") {
return terminalAction === "allow" || terminalAction === "modify_response";
}
return terminalAction === "block";
}
const testSourceOptions = [
{ value: TEST_SOURCE_QUICK, label: "Quick chat (custom message)" },
...getFrameworks().map((f) => ({ value: f.name, label: f.name })),
{ value: TEST_SOURCE_ALL, label: "All compliance datasets" },
];
const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({
pipeline,
accessToken,
onClose,
}) => {
const [testSource, setTestSource] = useState<string>(TEST_SOURCE_QUICK);
const [testMessage, setTestMessage] = useState("Hello, can you help me?");
const [isRunning, setIsRunning] = useState(false);
const [result, setResult] = useState<PipelineTestResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [complianceResults, setComplianceResults] = useState<ComplianceRunEntry[]>([]);
const isQuickChat = testSource === TEST_SOURCE_QUICK;
const promptsForSource = getPromptsForTestSource(testSource);
const isDataset = promptsForSource.length > 0;
const handleRunTest = async () => {
if (!accessToken) return;
const emptySteps = pipeline.steps.filter((s) => !s.guardrail);
if (emptySteps.length > 0) {
setError("All steps must have a guardrail selected");
return;
}
setError(null);
setIsRunning(true);
setResult(null);
setComplianceResults([]);
if (isQuickChat) {
try {
const data = await testPipelineCall(
accessToken,
pipeline,
[{ role: "user", content: testMessage }]
);
setResult(data);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setIsRunning(false);
}
return;
}
const entries: ComplianceRunEntry[] = [];
for (const prompt of promptsForSource) {
try {
const data = await testPipelineCall(accessToken, pipeline, [
{ role: "user", content: prompt.prompt },
]);
const matched = complianceMatchExpected(prompt.expectedResult, data.terminal_action);
entries.push({ prompt, result: data, matched });
} catch (e) {
const errMsg = e instanceof Error ? e.message : String(e);
entries.push({
prompt,
result: null,
error: errMsg,
matched: false,
});
}
}
setComplianceResults(entries);
setIsRunning(false);
};
return (
<div
style={{
width: 400,
borderLeft: "1px solid #e5e7eb",
backgroundColor: "#fff",
display: "flex",
flexDirection: "column",
flexShrink: 0,
overflow: "hidden",
}}
>
{/* Panel header */}
<div
style={{
padding: "12px 16px",
borderBottom: "1px solid #e5e7eb",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span style={{ fontSize: 14, fontWeight: 600, color: "#111827" }}>Test Pipeline</span>
<button
onClick={onClose}
style={{
background: "none",
border: "none",
cursor: "pointer",
fontSize: 18,
color: "#9ca3af",
padding: "0 4px",
}}
>
x
</button>
</div>
{/* Input section */}
<div style={{ padding: 16, borderBottom: "1px solid #e5e7eb" }}>
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Test with
</label>
<Select
value={testSource}
onChange={setTestSource}
options={testSourceOptions}
style={{ width: "100%", marginBottom: 12 }}
size="middle"
/>
{isQuickChat && (
<>
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Message
</label>
<textarea
value={testMessage}
onChange={(e) => setTestMessage(e.target.value)}
placeholder="Enter a test message..."
rows={3}
style={{
width: "100%",
border: "1px solid #d1d5db",
borderRadius: 6,
padding: "8px 10px",
fontSize: 13,
resize: "vertical",
fontFamily: "inherit",
}}
/>
</>
)}
{isDataset && (
<div
style={{
fontSize: 12,
color: "#6b7280",
padding: "8px 10px",
backgroundColor: "#f9fafb",
borderRadius: 6,
marginBottom: 8,
}}
>
{testSource === TEST_SOURCE_ALL
? "Run pipeline against all compliance prompts (EU AI Act, GDPR, Topic Blocking, Airline, etc.)."
: `Run pipeline against ${promptsForSource.length} prompts from "${testSource}".`}
</div>
)}
<Button
onClick={handleRunTest}
loading={isRunning}
style={{ marginTop: 8, width: "100%" }}
>
Run Test
</Button>
</div>
{/* Results section */}
<div style={{ flex: 1, overflowY: "auto", padding: 16 }}>
{error && (
<div
style={{
padding: "10px 12px",
backgroundColor: "#fef2f2",
border: "1px solid #fecaca",
borderRadius: 6,
fontSize: 13,
color: "#dc2626",
marginBottom: 12,
}}
>
{error}
</div>
)}
{result && (
<div>
{/* Step results */}
{result.step_results.map((step, i) => {
const style = OUTCOME_STYLES[step.outcome] || OUTCOME_STYLES.error;
return (
<div
key={i}
style={{
border: "1px solid #e5e7eb",
borderRadius: 8,
padding: "10px 12px",
marginBottom: 8,
}}
>
<div className="flex items-center justify-between" style={{ marginBottom: 4 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: "#111827" }}>
Step {i + 1}: {step.guardrail_name}
</span>
<span
style={{
fontSize: 11,
fontWeight: 700,
backgroundColor: style.bg,
color: style.color,
padding: "2px 8px",
borderRadius: 4,
}}
>
{style.label}
</span>
</div>
<div style={{ fontSize: 12, color: "#6b7280" }}>
Action: {ACTION_LABELS[step.action_taken] || step.action_taken}
{step.duration_seconds != null && (
<span style={{ marginLeft: 8 }}>
({(step.duration_seconds * 1000).toFixed(0)}ms)
</span>
)}
</div>
{step.error_detail && (
<div style={{ fontSize: 12, color: "#dc2626", marginTop: 4 }}>
{step.error_detail}
</div>
)}
</div>
);
})}
{/* Terminal result */}
<div
style={{
borderTop: "1px solid #e5e7eb",
paddingTop: 12,
marginTop: 4,
}}
>
<div className="flex items-center justify-between">
<span style={{ fontSize: 13, fontWeight: 600, color: "#111827" }}>Result</span>
{(() => {
const ts = TERMINAL_STYLES[result.terminal_action] || TERMINAL_STYLES.block;
return (
<span
style={{
fontSize: 12,
fontWeight: 700,
backgroundColor: ts.bg,
color: ts.color,
padding: "3px 10px",
borderRadius: 4,
textTransform: "uppercase",
}}
>
{result.terminal_action === "modify_response" ? "Custom Response" : result.terminal_action}
</span>
);
})()}
</div>
{result.error_message && (
<div style={{ fontSize: 12, color: "#dc2626", marginTop: 6 }}>
{result.error_message}
</div>
)}
{result.modify_response_message && (
<div style={{ fontSize: 12, color: "#2563eb", marginTop: 6 }}>
Response: {result.modify_response_message}
</div>
)}
</div>
</div>
)}
{complianceResults.length > 0 && (
<div style={{ marginTop: 16 }}>
<div
style={{
fontSize: 13,
fontWeight: 600,
color: "#111827",
marginBottom: 8,
}}
>
Compliance dataset
</div>
<div
style={{
fontSize: 12,
color: "#6b7280",
marginBottom: 10,
}}
>
{complianceResults.filter((e) => e.matched).length} / {complianceResults.length} matched
expected
</div>
<div
style={{
maxHeight: 320,
overflowY: "auto",
border: "1px solid #e5e7eb",
borderRadius: 8,
}}
>
{complianceResults.map((entry, i) => {
const actual =
entry.result?.terminal_action ?? (entry.error ? "error" : "—");
const matchStyle = entry.matched
? { bg: "#f0fdf4", color: "#16a34a" }
: { bg: "#fef2f2", color: "#dc2626" };
return (
<div
key={entry.prompt.id ?? i}
style={{
padding: "8px 10px",
borderBottom:
i < complianceResults.length - 1
? "1px solid #e5e7eb"
: "none",
fontSize: 12,
}}
>
<div
style={{
color: "#374151",
marginBottom: 4,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={entry.prompt.prompt}
>
{entry.prompt.prompt}
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
flexWrap: "wrap",
}}
>
<span style={{ color: "#6b7280" }}>
expected: {entry.prompt.expectedResult}
</span>
<span style={{ color: "#9ca3af" }}>→</span>
<span style={{ color: "#6b7280" }}>
actual: {actual}
</span>
<span
style={{
backgroundColor: matchStyle.bg,
color: matchStyle.color,
padding: "1px 6px",
borderRadius: 4,
fontWeight: 600,
}}
>
{entry.matched ? "✓" : "✗"}
</span>
</div>
{entry.error && (
<div style={{ color: "#dc2626", marginTop: 4 }}>
{entry.error}
</div>
)}
</div>
);
})}
</div>
</div>
)}
{!result && !error && complianceResults.length === 0 && (
<div style={{ textAlign: "center", color: "#9ca3af", fontSize: 13, marginTop: 24 }}>
Choose a test source above (quick chat or a compliance dataset) and click &quot;Run Test&quot;
</div>
)}
</div>
</div>
);
};
// ─────────────────────────────────────────────────────────────────────────────
// Policy Versions Sidebar (left sidebar when editing a policy)
// ─────────────────────────────────────────────────────────────────────────────

View file

@ -0,0 +1,342 @@
import { screen, waitFor, fireEvent, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "../../../tests/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PipelineTestPanel } from "./pipeline_test_drawer";
import type { GuardrailPipeline, PipelineTestResult } from "./types";
import * as networking from "../networking";
vi.mock("../networking");
vi.mock("../../data/compliancePrompts", () => ({
getFrameworks: () => [
{
name: "TestFW",
icon: "",
description: "",
categories: [
{
name: "cat",
icon: "",
description: "",
prompts: [
{
id: "cp-1",
framework: "TestFW",
category: "cat",
categoryIcon: "",
categoryDescription: "",
prompt: "Is this safe?",
expectedResult: "pass",
},
{
id: "cp-2",
framework: "TestFW",
category: "cat",
categoryIcon: "",
categoryDescription: "",
prompt: "How to hack a server?",
expectedResult: "fail",
},
],
},
],
},
],
getComplianceDatasetPrompts: () => [],
}));
const validPipeline: GuardrailPipeline = {
mode: "pre_call",
steps: [
{
guardrail: "content-filter",
on_pass: "allow",
on_fail: "block",
pass_data: false,
modify_response_message: null,
},
],
};
const emptyGuardrailPipeline: GuardrailPipeline = {
mode: "pre_call",
steps: [
{
guardrail: "",
on_pass: "next",
on_fail: "block",
pass_data: false,
modify_response_message: null,
},
],
};
const mockTestResult: PipelineTestResult = {
terminal_action: "allow",
step_results: [
{
guardrail_name: "content-filter",
outcome: "pass",
action_taken: "allow",
modified_data: null,
error_detail: null,
duration_seconds: 0.123,
},
],
modified_data: null,
error_message: null,
modify_response_message: null,
};
const defaultProps = {
pipeline: validPipeline,
accessToken: "test-token",
onClose: vi.fn(),
};
async function selectAntOption(user: ReturnType<typeof userEvent.setup>, title: string) {
const selector = document.querySelector(".ant-select-selector");
if (!selector) throw new Error("Ant Select selector not found");
await user.click(selector);
await waitFor(() => {
expect(document.querySelector(`[title="${title}"].ant-select-item-option`)).toBeInTheDocument();
});
const option = document.querySelector(`[title="${title}"].ant-select-item-option`);
if (!option) throw new Error(`Ant Select option "${title}" not found`);
await user.click(option);
}
describe("PipelineTestPanel", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should render", () => {
renderWithProviders(<PipelineTestPanel {...defaultProps} />);
expect(screen.getByText("Test Pipeline")).toBeInTheDocument();
});
it("should call onClose when the close button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<PipelineTestPanel {...defaultProps} />);
await user.click(screen.getByRole("button", { name: /x/i }));
expect(defaultProps.onClose).toHaveBeenCalled();
});
it("should show the default test message textarea", () => {
renderWithProviders(<PipelineTestPanel {...defaultProps} />);
expect(screen.getByPlaceholderText("Enter a test message...")).toBeInTheDocument();
});
it("should show placeholder text before any test is run", () => {
renderWithProviders(<PipelineTestPanel {...defaultProps} />);
expect(screen.getByText(/choose a test source above/i)).toBeInTheDocument();
});
it("should show an error when a step has no guardrail selected", async () => {
const user = userEvent.setup();
renderWithProviders(
<PipelineTestPanel
{...defaultProps}
pipeline={emptyGuardrailPipeline}
/>
);
await user.click(screen.getByRole("button", { name: /run test/i }));
expect(screen.getByText("All steps must have a guardrail selected")).toBeInTheDocument();
});
it("should display step results after a successful test", async () => {
vi.mocked(networking.testPipelineCall).mockResolvedValue(mockTestResult);
const user = userEvent.setup();
renderWithProviders(<PipelineTestPanel {...defaultProps} />);
await user.click(screen.getByRole("button", { name: /run test/i }));
await waitFor(() => {
expect(screen.getByText(/Step 1: content-filter/)).toBeInTheDocument();
});
expect(screen.getByText("PASS")).toBeInTheDocument();
expect(screen.getByText(/123ms/)).toBeInTheDocument();
});
it("should display the terminal action after a successful test", async () => {
vi.mocked(networking.testPipelineCall).mockResolvedValue(mockTestResult);
const user = userEvent.setup();
renderWithProviders(<PipelineTestPanel {...defaultProps} />);
await user.click(screen.getByRole("button", { name: /run test/i }));
await waitFor(() => {
expect(screen.getByText("allow")).toBeInTheDocument();
});
});
it("should display 'Custom Response' for modify_response terminal action", async () => {
const modifyResult: PipelineTestResult = {
...mockTestResult,
terminal_action: "modify_response",
modify_response_message: "Sorry, I can't help with that.",
};
vi.mocked(networking.testPipelineCall).mockResolvedValue(modifyResult);
const user = userEvent.setup();
renderWithProviders(<PipelineTestPanel {...defaultProps} />);
await user.click(screen.getByRole("button", { name: /run test/i }));
await waitFor(() => {
expect(screen.getByText("Custom Response")).toBeInTheDocument();
});
expect(screen.getByText(/Sorry, I can't help with that/)).toBeInTheDocument();
});
it("should display an error when the API call fails", async () => {
vi.mocked(networking.testPipelineCall).mockRejectedValue(new Error("Network error"));
const user = userEvent.setup();
renderWithProviders(<PipelineTestPanel {...defaultProps} />);
await user.click(screen.getByRole("button", { name: /run test/i }));
await waitFor(() => {
expect(screen.getByText("Network error")).toBeInTheDocument();
});
});
it("should not call the API when accessToken is null", async () => {
const user = userEvent.setup();
renderWithProviders(
<PipelineTestPanel {...defaultProps} accessToken={null} />
);
await user.click(screen.getByRole("button", { name: /run test/i }));
expect(networking.testPipelineCall).not.toHaveBeenCalled();
});
it("should show step error detail when present", async () => {
const resultWithError: PipelineTestResult = {
terminal_action: "block",
step_results: [
{
guardrail_name: "content-filter",
outcome: "error",
action_taken: "block",
modified_data: null,
error_detail: "Guardrail timed out",
duration_seconds: null,
},
],
modified_data: null,
error_message: null,
modify_response_message: null,
};
vi.mocked(networking.testPipelineCall).mockResolvedValue(resultWithError);
const user = userEvent.setup();
renderWithProviders(<PipelineTestPanel {...defaultProps} />);
await user.click(screen.getByRole("button", { name: /run test/i }));
await waitFor(() => {
expect(screen.getByText("Guardrail timed out")).toBeInTheDocument();
});
});
});
describe("PipelineTestPanel compliance-dataset mode", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should show matched/total count after running a compliance dataset", async () => {
const allowResult: PipelineTestResult = {
terminal_action: "allow",
step_results: [],
modified_data: null,
error_message: null,
modify_response_message: null,
};
const blockResult: PipelineTestResult = {
terminal_action: "block",
step_results: [],
modified_data: null,
error_message: null,
modify_response_message: null,
};
vi.mocked(networking.testPipelineCall)
.mockResolvedValueOnce(allowResult)
.mockResolvedValueOnce(blockResult);
const user = userEvent.setup();
renderWithProviders(<PipelineTestPanel {...defaultProps} />);
await selectAntOption(user, "TestFW");
await user.click(screen.getByRole("button", { name: /run test/i }));
await waitFor(() => {
expect(screen.getByText(/2 \/ 2 matched/)).toBeInTheDocument();
});
});
it("should show a mismatch indicator when actual result differs from expected", async () => {
const blockResult: PipelineTestResult = {
terminal_action: "block",
step_results: [],
modified_data: null,
error_message: null,
modify_response_message: null,
};
// Both prompts return "block": pass-expected prompt mismatches, fail-expected matches
vi.mocked(networking.testPipelineCall)
.mockResolvedValueOnce(blockResult)
.mockResolvedValueOnce(blockResult);
const user = userEvent.setup();
renderWithProviders(<PipelineTestPanel {...defaultProps} />);
await selectAntOption(user, "TestFW");
await user.click(screen.getByRole("button", { name: /run test/i }));
await waitFor(() => {
expect(screen.getByText(/1 \/ 2 matched/)).toBeInTheDocument();
});
});
it("should display per-prompt error when an API call fails in dataset mode", async () => {
vi.mocked(networking.testPipelineCall)
.mockRejectedValueOnce(new Error("Timeout on prompt 1"))
.mockResolvedValueOnce({
terminal_action: "block",
step_results: [],
modified_data: null,
error_message: null,
modify_response_message: null,
});
const user = userEvent.setup();
renderWithProviders(<PipelineTestPanel {...defaultProps} />);
await selectAntOption(user, "TestFW");
await user.click(screen.getByRole("button", { name: /run test/i }));
await waitFor(() => {
expect(screen.getByText("Timeout on prompt 1")).toBeInTheDocument();
});
expect(screen.getByText(/1 \/ 2 matched/)).toBeInTheDocument();
});
it("should call testPipelineCall once per prompt in the dataset", async () => {
const allowResult: PipelineTestResult = {
terminal_action: "allow",
step_results: [],
modified_data: null,
error_message: null,
modify_response_message: null,
};
vi.mocked(networking.testPipelineCall).mockResolvedValue(allowResult);
const user = userEvent.setup();
renderWithProviders(<PipelineTestPanel {...defaultProps} />);
await selectAntOption(user, "TestFW");
await user.click(screen.getByRole("button", { name: /run test/i }));
await waitFor(() => {
expect(networking.testPipelineCall).toHaveBeenCalledTimes(2);
});
});
});

View file

@ -0,0 +1,440 @@
import React, { useMemo, useState } from "react";
import { Select } from "antd";
import { Button } from "@tremor/react";
import { GuardrailPipeline, PipelineStep, PipelineTestResult } from "./types";
import { testPipelineCall } from "../networking";
import type { CompliancePrompt } from "../../data/compliancePrompts";
import {
TEST_SOURCE_QUICK,
TEST_SOURCE_ALL,
ACTION_LABELS,
getPromptsForTestSource,
getTestSourceOptions,
complianceMatchExpected,
} from "./pipeline_utils";
// ─────────────────────────────────────────────────────────────────────────────
// Style maps
// ─────────────────────────────────────────────────────────────────────────────
const OUTCOME_STYLES: Record<string, { bg: string; color: string; label: string }> = {
pass: { bg: "#f0fdf4", color: "#16a34a", label: "PASS" },
fail: { bg: "#fef2f2", color: "#dc2626", label: "FAIL" },
error: { bg: "#fffbeb", color: "#d97706", label: "ERROR" },
};
const TERMINAL_STYLES: Record<string, { bg: string; color: string }> = {
allow: { bg: "#f0fdf4", color: "#16a34a" },
block: { bg: "#fef2f2", color: "#dc2626" },
modify_response: { bg: "#eff6ff", color: "#2563eb" },
};
// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────
interface ComplianceRunEntry {
prompt: CompliancePrompt;
result: PipelineTestResult | null;
error?: string;
matched: boolean;
}
// ─────────────────────────────────────────────────────────────────────────────
// PipelineTestPanel
// ─────────────────────────────────────────────────────────────────────────────
export interface PipelineTestPanelProps {
pipeline: GuardrailPipeline;
accessToken: string | null;
onClose: () => void;
}
export const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({
pipeline,
accessToken,
onClose,
}) => {
const [testSource, setTestSource] = useState<string>(TEST_SOURCE_QUICK);
const [testMessage, setTestMessage] = useState("Hello, can you help me?");
const [isRunning, setIsRunning] = useState(false);
const [result, setResult] = useState<PipelineTestResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [complianceResults, setComplianceResults] = useState<ComplianceRunEntry[]>([]);
const testSourceOptions = useMemo(() => getTestSourceOptions(), []);
const isQuickChat = testSource === TEST_SOURCE_QUICK;
const promptsForSource = useMemo(() => getPromptsForTestSource(testSource), [testSource]);
const isDataset = promptsForSource.length > 0;
const handleRunTest = async () => {
if (!accessToken) return;
const emptySteps = pipeline.steps.filter((s) => !s.guardrail);
if (emptySteps.length > 0) {
setError("All steps must have a guardrail selected");
return;
}
setError(null);
setIsRunning(true);
setResult(null);
setComplianceResults([]);
if (isQuickChat) {
try {
const data = await testPipelineCall(
accessToken,
pipeline,
[{ role: "user", content: testMessage }]
);
setResult(data);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setIsRunning(false);
}
return;
}
const entries: ComplianceRunEntry[] = [];
try {
for (const prompt of promptsForSource) {
try {
const data = await testPipelineCall(accessToken, pipeline, [
{ role: "user", content: prompt.prompt },
]);
const matched = complianceMatchExpected(prompt.expectedResult, data.terminal_action);
entries.push({ prompt, result: data, matched });
} catch (e) {
const errMsg = e instanceof Error ? e.message : String(e);
entries.push({
prompt,
result: null,
error: errMsg,
matched: false,
});
}
}
setComplianceResults(entries);
} finally {
setIsRunning(false);
}
};
return (
<div
style={{
width: 400,
borderLeft: "1px solid #e5e7eb",
backgroundColor: "#fff",
display: "flex",
flexDirection: "column",
flexShrink: 0,
overflow: "hidden",
}}
>
{/* Panel header */}
<div
style={{
padding: "12px 16px",
borderBottom: "1px solid #e5e7eb",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span style={{ fontSize: 14, fontWeight: 600, color: "#111827" }}>Test Pipeline</span>
<button
onClick={onClose}
style={{
background: "none",
border: "none",
cursor: "pointer",
fontSize: 18,
color: "#9ca3af",
padding: "0 4px",
}}
>
x
</button>
</div>
{/* Input section */}
<div style={{ padding: 16, borderBottom: "1px solid #e5e7eb" }}>
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Test with
</label>
<Select
value={testSource}
onChange={setTestSource}
options={testSourceOptions}
style={{ width: "100%", marginBottom: 12 }}
size="middle"
/>
{isQuickChat && (
<>
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Message
</label>
<textarea
value={testMessage}
onChange={(e) => setTestMessage(e.target.value)}
placeholder="Enter a test message..."
rows={3}
style={{
width: "100%",
border: "1px solid #d1d5db",
borderRadius: 6,
padding: "8px 10px",
fontSize: 13,
resize: "vertical",
fontFamily: "inherit",
}}
/>
</>
)}
{isDataset && (
<div
style={{
fontSize: 12,
color: "#6b7280",
padding: "8px 10px",
backgroundColor: "#f9fafb",
borderRadius: 6,
marginBottom: 8,
}}
>
{testSource === TEST_SOURCE_ALL
? "Run pipeline against all compliance prompts (EU AI Act, GDPR, Topic Blocking, Airline, etc.)."
: `Run pipeline against ${promptsForSource.length} prompts from "${testSource}".`}
</div>
)}
<Button
onClick={handleRunTest}
loading={isRunning}
style={{ marginTop: 8, width: "100%" }}
>
Run Test
</Button>
</div>
{/* Results section */}
<div style={{ flex: 1, overflowY: "auto", padding: 16 }}>
{error && (
<div
style={{
padding: "10px 12px",
backgroundColor: "#fef2f2",
border: "1px solid #fecaca",
borderRadius: 6,
fontSize: 13,
color: "#dc2626",
marginBottom: 12,
}}
>
{error}
</div>
)}
{result && (
<div>
{/* Step results */}
{result.step_results.map((step, i) => {
const style = OUTCOME_STYLES[step.outcome] || OUTCOME_STYLES.error;
return (
<div
key={i}
style={{
border: "1px solid #e5e7eb",
borderRadius: 8,
padding: "10px 12px",
marginBottom: 8,
}}
>
<div className="flex items-center justify-between" style={{ marginBottom: 4 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: "#111827" }}>
Step {i + 1}: {step.guardrail_name}
</span>
<span
style={{
fontSize: 11,
fontWeight: 700,
backgroundColor: style.bg,
color: style.color,
padding: "2px 8px",
borderRadius: 4,
}}
>
{style.label}
</span>
</div>
<div style={{ fontSize: 12, color: "#6b7280" }}>
Action: {ACTION_LABELS[step.action_taken] || step.action_taken}
{step.duration_seconds != null && (
<span style={{ marginLeft: 8 }}>
({(step.duration_seconds * 1000).toFixed(0)}ms)
</span>
)}
</div>
{step.error_detail && (
<div style={{ fontSize: 12, color: "#dc2626", marginTop: 4 }}>
{step.error_detail}
</div>
)}
</div>
);
})}
{/* Terminal result */}
<div
style={{
borderTop: "1px solid #e5e7eb",
paddingTop: 12,
marginTop: 4,
}}
>
<div className="flex items-center justify-between">
<span style={{ fontSize: 13, fontWeight: 600, color: "#111827" }}>Result</span>
{(() => {
const ts = TERMINAL_STYLES[result.terminal_action] || TERMINAL_STYLES.block;
return (
<span
style={{
fontSize: 12,
fontWeight: 700,
backgroundColor: ts.bg,
color: ts.color,
padding: "3px 10px",
borderRadius: 4,
textTransform: "uppercase",
}}
>
{result.terminal_action === "modify_response" ? "Custom Response" : result.terminal_action}
</span>
);
})()}
</div>
{result.error_message && (
<div style={{ fontSize: 12, color: "#dc2626", marginTop: 6 }}>
{result.error_message}
</div>
)}
{result.modify_response_message && (
<div style={{ fontSize: 12, color: "#2563eb", marginTop: 6 }}>
Response: {result.modify_response_message}
</div>
)}
</div>
</div>
)}
{complianceResults.length > 0 && (
<div style={{ marginTop: 16 }}>
<div
style={{
fontSize: 13,
fontWeight: 600,
color: "#111827",
marginBottom: 8,
}}
>
Compliance dataset
</div>
<div
style={{
fontSize: 12,
color: "#6b7280",
marginBottom: 10,
}}
>
{complianceResults.filter((e) => e.matched).length} / {complianceResults.length} matched
expected
</div>
<div
style={{
maxHeight: 320,
overflowY: "auto",
border: "1px solid #e5e7eb",
borderRadius: 8,
}}
>
{complianceResults.map((entry, i) => {
const actual =
entry.result?.terminal_action ?? (entry.error ? "error" : "—");
const matchStyle = entry.matched
? { bg: "#f0fdf4", color: "#16a34a" }
: { bg: "#fef2f2", color: "#dc2626" };
return (
<div
key={entry.prompt.id ?? i}
style={{
padding: "8px 10px",
borderBottom:
i < complianceResults.length - 1
? "1px solid #e5e7eb"
: "none",
fontSize: 12,
}}
>
<div
style={{
color: "#374151",
marginBottom: 4,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={entry.prompt.prompt}
>
{entry.prompt.prompt}
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
flexWrap: "wrap",
}}
>
<span style={{ color: "#6b7280" }}>
expected: {entry.prompt.expectedResult}
</span>
<span style={{ color: "#9ca3af" }}>→</span>
<span style={{ color: "#6b7280" }}>
actual: {actual}
</span>
<span
style={{
backgroundColor: matchStyle.bg,
color: matchStyle.color,
padding: "1px 6px",
borderRadius: 4,
fontWeight: 600,
}}
>
{entry.matched ? "✓" : "✗"}
</span>
</div>
{entry.error && (
<div style={{ color: "#dc2626", marginTop: 4 }}>
{entry.error}
</div>
)}
</div>
);
})}
</div>
</div>
)}
{!result && !error && complianceResults.length === 0 && (
<div style={{ textAlign: "center", color: "#9ca3af", fontSize: 13, marginTop: 24 }}>
Choose a test source above (quick chat or a compliance dataset) and click &quot;Run Test&quot;
</div>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,244 @@
import { describe, expect, it, vi } from "vitest";
import {
createDefaultStep,
insertStep,
removeStep,
updateStepAtIndex,
derivePipelineFromPolicy,
complianceMatchExpected,
getPromptsForTestSource,
getTestSourceOptions,
TEST_SOURCE_QUICK,
TEST_SOURCE_ALL,
} from "./pipeline_utils";
import type { PipelineStep, Policy } from "./types";
import * as complianceData from "../../data/compliancePrompts";
const makeStep = (guardrail: string, overrides?: Partial<PipelineStep>): PipelineStep => ({
guardrail,
on_pass: "next",
on_fail: "block",
pass_data: false,
modify_response_message: null,
...overrides,
});
describe("createDefaultStep", () => {
it("should return a step with empty guardrail and default actions", () => {
const step = createDefaultStep();
expect(step).toEqual({
guardrail: "",
on_pass: "next",
on_fail: "block",
pass_data: false,
modify_response_message: null,
});
});
});
describe("insertStep", () => {
it("should insert a default step at the given index", () => {
const steps = [makeStep("a"), makeStep("b")];
const result = insertStep(steps, 1);
expect(result).toHaveLength(3);
expect(result[1].guardrail).toBe("");
expect(result[0].guardrail).toBe("a");
expect(result[2].guardrail).toBe("b");
});
it("should insert at the beginning when index is 0", () => {
const steps = [makeStep("a")];
const result = insertStep(steps, 0);
expect(result[0].guardrail).toBe("");
expect(result[1].guardrail).toBe("a");
});
it("should not mutate the original array", () => {
const steps = [makeStep("a")];
const result = insertStep(steps, 0);
expect(steps).toHaveLength(1);
expect(result).toHaveLength(2);
});
});
describe("removeStep", () => {
it("should remove the step at the given index", () => {
const steps = [makeStep("a"), makeStep("b"), makeStep("c")];
const result = removeStep(steps, 1);
expect(result).toHaveLength(2);
expect(result.map((s) => s.guardrail)).toEqual(["a", "c"]);
});
it("should not remove if only one step remains", () => {
const steps = [makeStep("a")];
const result = removeStep(steps, 0);
expect(result).toHaveLength(1);
expect(result[0].guardrail).toBe("a");
});
it("should not mutate the original array", () => {
const steps = [makeStep("a"), makeStep("b")];
removeStep(steps, 0);
expect(steps).toHaveLength(2);
});
});
describe("updateStepAtIndex", () => {
it("should update only the step at the target index", () => {
const steps = [makeStep("a"), makeStep("b")];
const result = updateStepAtIndex(steps, 1, { guardrail: "updated" });
expect(result[0].guardrail).toBe("a");
expect(result[1].guardrail).toBe("updated");
});
it("should merge partial updates into the existing step", () => {
const steps = [makeStep("a", { on_pass: "allow" })];
const result = updateStepAtIndex(steps, 0, { on_fail: "allow" });
expect(result[0].on_pass).toBe("allow");
expect(result[0].on_fail).toBe("allow");
expect(result[0].guardrail).toBe("a");
});
});
describe("derivePipelineFromPolicy", () => {
it("should return a default pipeline when policy is null", () => {
const pipeline = derivePipelineFromPolicy(null);
expect(pipeline.mode).toBe("pre_call");
expect(pipeline.steps).toHaveLength(1);
expect(pipeline.steps[0].guardrail).toBe("");
});
it("should return a default pipeline when policy is undefined", () => {
const pipeline = derivePipelineFromPolicy(undefined);
expect(pipeline.mode).toBe("pre_call");
expect(pipeline.steps).toHaveLength(1);
});
it("should use the existing pipeline when present", () => {
const policy: Policy = {
policy_id: "p1",
policy_name: "test",
inherit: null,
description: null,
guardrails_add: [],
guardrails_remove: [],
condition: null,
pipeline: {
mode: "post_call",
steps: [makeStep("existing-guardrail")],
},
};
const pipeline = derivePipelineFromPolicy(policy);
expect(pipeline.mode).toBe("post_call");
expect(pipeline.steps[0].guardrail).toBe("existing-guardrail");
});
it("should convert guardrails_add to pipeline steps when no pipeline exists", () => {
const policy: Policy = {
policy_id: "p1",
policy_name: "test",
inherit: null,
description: null,
guardrails_add: ["gA", "gB"],
guardrails_remove: [],
condition: null,
};
const pipeline = derivePipelineFromPolicy(policy);
expect(pipeline.steps).toHaveLength(2);
expect(pipeline.steps[0].guardrail).toBe("gA");
expect(pipeline.steps[1].guardrail).toBe("gB");
expect(pipeline.steps[0].on_pass).toBe("next");
expect(pipeline.steps[0].on_fail).toBe("block");
});
it("should return a default pipeline when policy has no pipeline and no guardrails", () => {
const policy: Policy = {
policy_id: "p1",
policy_name: "test",
inherit: null,
description: null,
guardrails_add: [],
guardrails_remove: [],
condition: null,
};
const pipeline = derivePipelineFromPolicy(policy);
expect(pipeline.steps).toHaveLength(1);
expect(pipeline.steps[0].guardrail).toBe("");
});
});
describe("complianceMatchExpected", () => {
it("should match 'pass' expectation with 'allow' action", () => {
expect(complianceMatchExpected("pass", "allow")).toBe(true);
});
it("should match 'pass' expectation with 'modify_response' action", () => {
expect(complianceMatchExpected("pass", "modify_response")).toBe(true);
});
it("should not match 'pass' expectation with 'block' action", () => {
expect(complianceMatchExpected("pass", "block")).toBe(false);
});
it("should match 'fail' expectation with 'block' action", () => {
expect(complianceMatchExpected("fail", "block")).toBe(true);
});
it("should not match 'fail' expectation with 'allow' action", () => {
expect(complianceMatchExpected("fail", "allow")).toBe(false);
});
});
describe("getPromptsForTestSource", () => {
it("should return an empty array for quick_chat source", () => {
expect(getPromptsForTestSource(TEST_SOURCE_QUICK)).toEqual([]);
});
it("should return all compliance prompts for __all__ source", () => {
const mockPrompts = [
{ id: "p1", framework: "fw", category: "c", categoryIcon: "", categoryDescription: "", prompt: "test", expectedResult: "pass" as const },
];
vi.spyOn(complianceData, "getComplianceDatasetPrompts").mockReturnValue(mockPrompts);
const result = getPromptsForTestSource(TEST_SOURCE_ALL);
expect(result).toEqual(mockPrompts);
vi.restoreAllMocks();
});
it("should return prompts for a matching framework name", () => {
const prompt = { id: "p1", framework: "GDPR", category: "c", categoryIcon: "", categoryDescription: "", prompt: "test", expectedResult: "fail" as const };
vi.spyOn(complianceData, "getFrameworks").mockReturnValue([
{ name: "GDPR", icon: "", description: "", categories: [{ name: "c", icon: "", description: "", prompts: [prompt] }] },
]);
const result = getPromptsForTestSource("GDPR");
expect(result).toEqual([prompt]);
vi.restoreAllMocks();
});
it("should return an empty array for an unrecognized source", () => {
vi.spyOn(complianceData, "getFrameworks").mockReturnValue([]);
expect(getPromptsForTestSource("nonexistent")).toEqual([]);
vi.restoreAllMocks();
});
});
describe("getTestSourceOptions", () => {
it("should always include quick chat and all datasets options", () => {
vi.spyOn(complianceData, "getFrameworks").mockReturnValue([]);
const options = getTestSourceOptions();
expect(options[0]).toEqual({ value: TEST_SOURCE_QUICK, label: "Quick chat (custom message)" });
expect(options[options.length - 1]).toEqual({ value: TEST_SOURCE_ALL, label: "All compliance datasets" });
vi.restoreAllMocks();
});
it("should include framework names from getFrameworks", () => {
vi.spyOn(complianceData, "getFrameworks").mockReturnValue([
{ name: "GDPR", icon: "", description: "", categories: [] },
{ name: "EU AI Act", icon: "", description: "", categories: [] },
]);
const options = getTestSourceOptions();
expect(options).toHaveLength(4);
expect(options[1]).toEqual({ value: "GDPR", label: "GDPR" });
expect(options[2]).toEqual({ value: "EU AI Act", label: "EU AI Act" });
vi.restoreAllMocks();
});
});

View file

@ -0,0 +1,210 @@
import React from "react";
import { PlusIcon } from "@heroicons/react/outline";
import { PipelineStep, GuardrailPipeline } from "./types";
import type { CompliancePrompt } from "../../data/compliancePrompts";
import { getComplianceDatasetPrompts, getFrameworks } from "../../data/compliancePrompts";
import { Policy } from "./types";
// ─────────────────────────────────────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────────────────────────────────────
export const TEST_SOURCE_QUICK = "quick_chat";
export const TEST_SOURCE_ALL = "__all__";
export const ACTION_OPTIONS = [
{ label: "Next Step", value: "next" },
{ label: "Allow", value: "allow" },
{ label: "Block", value: "block" },
{ label: "Custom Response", value: "modify_response" },
];
export const ACTION_LABELS: Record<string, string> = {
allow: "Allow",
block: "Block",
next: "Next Step",
modify_response: "Custom Response",
};
// ─────────────────────────────────────────────────────────────────────────────
// Utility functions
// ─────────────────────────────────────────────────────────────────────────────
export function getPromptsForTestSource(source: string): CompliancePrompt[] {
if (source === TEST_SOURCE_QUICK) return [];
if (source === TEST_SOURCE_ALL) return getComplianceDatasetPrompts();
const fw = getFrameworks().find((f) => f.name === source);
return fw ? fw.categories.flatMap((c) => c.prompts) : [];
}
export function getTestSourceOptions(): { value: string; label: string }[] {
return [
{ value: TEST_SOURCE_QUICK, label: "Quick chat (custom message)" },
...getFrameworks().map((f) => ({ value: f.name, label: f.name })),
{ value: TEST_SOURCE_ALL, label: "All compliance datasets" },
];
}
export function createDefaultStep(): PipelineStep {
return {
guardrail: "",
on_pass: "next",
on_fail: "block",
pass_data: false,
modify_response_message: null,
};
}
export function insertStep(steps: PipelineStep[], atIndex: number): PipelineStep[] {
const newSteps = [...steps];
newSteps.splice(atIndex, 0, createDefaultStep());
return newSteps;
}
export function removeStep(steps: PipelineStep[], index: number): PipelineStep[] {
if (steps.length <= 1) return steps;
const newSteps = [...steps];
newSteps.splice(index, 1);
return newSteps;
}
export function updateStepAtIndex(
steps: PipelineStep[],
index: number,
updated: Partial<PipelineStep>
): PipelineStep[] {
return steps.map((s, i) => (i === index ? { ...s, ...updated } : s));
}
/**
* Derives a pipeline from a policy. When the policy has a pipeline, use it.
* When it only has guardrails_add (legacy/simple form), convert those guardrails
* into pipeline steps in order.
*/
export function derivePipelineFromPolicy(policy: Policy | null | undefined): GuardrailPipeline {
if (!policy) {
return { mode: "pre_call", steps: [createDefaultStep()] };
}
if (policy.pipeline?.steps?.length) {
return policy.pipeline;
}
const guardrails = policy.guardrails_add || [];
if (guardrails.length > 0) {
return {
mode: policy.pipeline?.mode ?? "pre_call",
steps: guardrails.map((g) => ({
guardrail: g,
on_pass: "next" as const,
on_fail: "block" as const,
pass_data: false,
modify_response_message: null,
})),
};
}
return { mode: "pre_call", steps: [createDefaultStep()] };
}
export function complianceMatchExpected(expected: "pass" | "fail", terminalAction: string): boolean {
if (expected === "pass") {
return terminalAction === "allow" || terminalAction === "modify_response";
}
return terminalAction === "block";
}
// ─────────────────────────────────────────────────────────────────────────────
// Icons
// ─────────────────────────────────────────────────────────────────────────────
export const GuardrailIcon: React.FC = () => (
<div
style={{
width: 28,
height: 28,
borderRadius: "50%",
backgroundColor: "#eef2ff",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6366f1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4" />
</svg>
</div>
);
export const PlayIcon: React.FC = () => (
<div
style={{
width: 28,
height: 28,
borderRadius: "50%",
backgroundColor: "#f3f4f6",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="#6b7280" stroke="none">
<polygon points="6,3 20,12 6,21" />
</svg>
</div>
);
export const PassIcon: React.FC = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<circle cx="12" cy="12" r="10" />
<path d="M9 12l2 2 4-4" />
</svg>
);
export const FailIcon: React.FC = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#f87171" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<circle cx="12" cy="12" r="10" />
<path d="M15 9l-6 6" />
<path d="M9 9l6 6" />
</svg>
);
// ─────────────────────────────────────────────────────────────────────────────
// Connector
// ─────────────────────────────────────────────────────────────────────────────
interface ConnectorProps {
onInsert: () => void;
}
export const Connector: React.FC<ConnectorProps> = ({ onInsert }) => (
<div className="flex flex-col items-center" style={{ height: 56 }}>
<div style={{ width: 1, flex: 1, backgroundColor: "#d1d5db" }} />
<button
onClick={onInsert}
className="flex items-center justify-center"
style={{
width: 24,
height: 24,
borderRadius: "50%",
border: "1px solid #d1d5db",
backgroundColor: "#fff",
cursor: "pointer",
zIndex: 1,
transition: "all 0.15s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "#6366f1";
e.currentTarget.style.backgroundColor = "#eef2ff";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "#d1d5db";
e.currentTarget.style.backgroundColor = "#fff";
}}
title="Insert step"
>
<PlusIcon style={{ width: 12, height: 12, color: "#9ca3af" }} />
</button>
<div style={{ width: 1, flex: 1, backgroundColor: "#d1d5db" }} />
</div>
);