diff --git a/frontend/src/components/ScenarioPresets/LaunchPresetDialog.styles.ts b/frontend/src/components/ScenarioPresets/LaunchPresetDialog.styles.ts
new file mode 100644
index 0000000000..7f11684d49
--- /dev/null
+++ b/frontend/src/components/ScenarioPresets/LaunchPresetDialog.styles.ts
@@ -0,0 +1,20 @@
+import { makeStyles, tokens } from '@fluentui/react-components'
+
+export const useLaunchPresetDialogStyles = makeStyles({
+ body: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalM,
+ },
+ summary: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalXXS,
+ },
+ scenarioLine: {
+ color: tokens.colorNeutralForeground3,
+ },
+ numberInput: {
+ maxWidth: '10rem',
+ },
+})
diff --git a/frontend/src/components/ScenarioPresets/LaunchPresetDialog.test.tsx b/frontend/src/components/ScenarioPresets/LaunchPresetDialog.test.tsx
new file mode 100644
index 0000000000..9b2f63e1c0
--- /dev/null
+++ b/frontend/src/components/ScenarioPresets/LaunchPresetDialog.test.tsx
@@ -0,0 +1,352 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { FluentProvider, webLightTheme } from '@fluentui/react-components'
+import { MemoryRouter } from 'react-router'
+
+import { scenarioPresetsApi, scenariosApi } from '@/services/api'
+import { makeTarget } from '@/test-utils/targetFixtures'
+import type { ScenarioPreset, TargetInstance } from '@/types'
+
+import LaunchPresetDialog from './LaunchPresetDialog'
+
+jest.mock('@/services/api', () => ({
+ scenarioPresetsApi: {
+ resolve: jest.fn(),
+ },
+ scenariosApi: {
+ startRun: jest.fn(),
+ },
+}))
+
+const mockResolve = scenarioPresetsApi.resolve as jest.Mock
+const mockStartRun = scenariosApi.startRun as jest.Mock
+
+const mockNavigate = jest.fn()
+
+jest.mock('react-router', () => ({
+ ...jest.requireActual('react-router'),
+ useNavigate: () => mockNavigate,
+}))
+
+const PRESET: ScenarioPreset = {
+ name: 'nightly_probe',
+ scenario_name: 'foundry.red_team_agent',
+ techniques: ['crescendo'],
+}
+
+const OBJECTIVE_TARGET = makeTarget({ target_registry_name: 'gpt4o' })
+const ADVERSARIAL_TARGET: TargetInstance = makeTarget({
+ target_registry_name: 'adversary',
+ capabilities: {
+ supports_multi_turn: true,
+ supports_json_schema: false,
+ supports_json_output: false,
+ supports_system_prompt: true,
+ supported_input_modalities: ['text'],
+ supported_output_modalities: ['text'],
+ },
+})
+
+interface RenderOptions {
+ preset?: ScenarioPreset
+ version?: string
+ defaultObjectiveTarget?: TargetInstance | null
+ defaultAdversarialTarget?: TargetInstance | null
+ labels?: Record
+}
+
+const onDismiss = jest.fn()
+const onPresetChanged = jest.fn()
+
+function renderDialog({
+ preset = PRESET,
+ version = 'v1',
+ defaultObjectiveTarget = OBJECTIVE_TARGET,
+ defaultAdversarialTarget = null,
+ labels = {},
+}: RenderOptions = {}) {
+ return render(
+
+
+
+
+ ,
+ )
+}
+
+beforeEach(() => {
+ jest.clearAllMocks()
+ mockResolve.mockResolvedValue({ scenario_name: 'foundry.red_team_agent', target_name: 'gpt4o' })
+ mockStartRun.mockResolvedValue({ scenario_result_id: 'run-1' })
+})
+
+describe('LaunchPresetDialog', () => {
+ it('reports scenario defaults for the fields the preset leaves unset', () => {
+ renderDialog()
+
+ expect(screen.getByTestId('launch-preset-summary')).toHaveTextContent(
+ '1 technique · scenario default datasets',
+ )
+ })
+
+ it('summarizes the configuration the preset pins', () => {
+ renderDialog({
+ preset: {
+ name: 'nightly_probe',
+ scenario_name: 'foundry.red_team_agent',
+ techniques: ['crescendo', 'flip'],
+ datasets: {
+ sources: [{ name: 'harmbench' }, { name: 'advbench' }],
+ max_total: 25,
+ },
+ include_baseline: true,
+ },
+ })
+
+ expect(screen.getByTestId('launch-preset-summary')).toHaveTextContent(
+ '2 techniques · harmbench, advbench · max 25 cases · baseline included',
+ )
+ })
+
+ it('treats a limit of default as saying nothing about the limit', () => {
+ renderDialog({
+ preset: {
+ ...PRESET,
+ datasets: { sources: [{ name: 'harmbench' }], max_total: 'default', max_per_dataset: 'default' },
+ },
+ })
+
+ const summary = screen.getByTestId('launch-preset-summary')
+ expect(summary).toHaveTextContent('harmbench')
+ expect(summary).not.toHaveTextContent('max')
+ })
+
+ it('reports an unbounded limit the flat schema could not express', () => {
+ renderDialog({
+ preset: { ...PRESET, datasets: { sources: [{ name: 'harmbench' }], max_total: 'all' } },
+ })
+
+ expect(screen.getByTestId('launch-preset-summary')).toHaveTextContent('every case')
+ })
+
+ it('distinguishes an excluded baseline from an included one', () => {
+ renderDialog({ preset: { ...PRESET, include_baseline: false } })
+
+ expect(screen.getByTestId('launch-preset-summary')).toHaveTextContent('baseline excluded')
+ })
+
+ it('resolves the preset server-side and navigates to the started run', async () => {
+ const user = userEvent.setup()
+ renderDialog({ labels: { op: 'nightly' } })
+
+ await user.click(screen.getByTestId('confirm-launch-preset'))
+
+ await waitFor(() => expect(mockResolve).toHaveBeenCalledWith('nightly_probe', {
+ expected_version: 'v1',
+ target_name: 'gpt4o',
+ max_concurrency: 10,
+ max_retries: 0,
+ labels: { op: 'nightly' },
+ }))
+ expect(mockStartRun).toHaveBeenCalledWith({
+ scenario_name: 'foundry.red_team_agent',
+ target_name: 'gpt4o',
+ })
+ expect(mockNavigate).toHaveBeenCalledWith('/scanner-history/run-1', {
+ state: { scenarioName: 'foundry.red_team_agent' },
+ })
+ })
+
+ it('omits the adversarial target and labels when neither is set', async () => {
+ const user = userEvent.setup()
+ renderDialog()
+
+ await user.click(screen.getByTestId('confirm-launch-preset'))
+
+ await waitFor(() => expect(mockResolve).toHaveBeenCalledWith('nightly_probe', {
+ expected_version: 'v1',
+ target_name: 'gpt4o',
+ max_concurrency: 10,
+ max_retries: 0,
+ }))
+ })
+
+ it('hands a preset edited mid-launch back to the library instead of running it', async () => {
+ const user = userEvent.setup()
+ mockResolve.mockRejectedValue({
+ isAxiosError: true,
+ response: { status: 409, data: { detail: "Scenario preset 'nightly_probe' changed since it was read" } },
+ })
+
+ renderDialog()
+
+ await user.click(screen.getByTestId('confirm-launch-preset'))
+
+ await waitFor(() => expect(onPresetChanged).toHaveBeenCalled())
+ expect(mockStartRun).not.toHaveBeenCalled()
+ expect(mockNavigate).not.toHaveBeenCalled()
+ })
+
+ it('reports a run-start conflict without claiming the preset changed', async () => {
+ const user = userEvent.setup()
+ mockStartRun.mockRejectedValueOnce({
+ isAxiosError: true,
+ response: { status: 409, data: { detail: 'A scenario run is already using this identifier.' } },
+ })
+
+ renderDialog()
+
+ await user.click(screen.getByTestId('confirm-launch-preset'))
+
+ expect(await screen.findByText('A scenario run is already using this identifier.')).toBeInTheDocument()
+ expect(onPresetChanged).not.toHaveBeenCalled()
+ expect(mockNavigate).not.toHaveBeenCalled()
+ expect(screen.getByTestId('confirm-launch-preset')).toBeEnabled()
+
+ await user.click(screen.getByTestId('confirm-launch-preset'))
+ await waitFor(() => expect(mockNavigate).toHaveBeenCalled())
+ expect(onPresetChanged).not.toHaveBeenCalled()
+ })
+
+ it('sends the default adversarial target when one is configured', async () => {
+ const user = userEvent.setup()
+ renderDialog({ defaultAdversarialTarget: ADVERSARIAL_TARGET })
+
+ await user.click(screen.getByTestId('confirm-launch-preset'))
+
+ await waitFor(() => expect(mockResolve).toHaveBeenCalledWith(
+ 'nightly_probe',
+ expect.objectContaining({ adversarial_target_name: 'adversary' }),
+ ))
+ })
+
+ it('blocks launching until a target is chosen', () => {
+ renderDialog({ defaultObjectiveTarget: null })
+
+ expect(screen.getByTestId('confirm-launch-preset')).toBeDisabled()
+ })
+
+ it('ignores a default target that this deployment does not have', () => {
+ renderDialog({ defaultObjectiveTarget: makeTarget({ target_registry_name: 'retired' }) })
+
+ expect(screen.getByTestId('confirm-launch-preset')).toBeDisabled()
+ })
+
+ it('reports a resolve failure and does not start a run', async () => {
+ const user = userEvent.setup()
+ mockResolve.mockRejectedValue(new Error('Unknown scenario "foundry.retired".'))
+
+ renderDialog()
+
+ await user.click(screen.getByTestId('confirm-launch-preset'))
+
+ expect(await screen.findByText('Unknown scenario "foundry.retired".')).toBeInTheDocument()
+ expect(mockStartRun).not.toHaveBeenCalled()
+ expect(mockNavigate).not.toHaveBeenCalled()
+ })
+
+ it('re-enables launch after a failure so the operator can retry', async () => {
+ const user = userEvent.setup()
+ mockStartRun.mockRejectedValueOnce(new Error('Target is unavailable.'))
+
+ renderDialog()
+
+ await user.click(screen.getByTestId('confirm-launch-preset'))
+
+ expect(await screen.findByText('Target is unavailable.')).toBeInTheDocument()
+ expect(screen.getByTestId('confirm-launch-preset')).toBeEnabled()
+
+ await user.click(screen.getByTestId('confirm-launch-preset'))
+ await waitFor(() => expect(mockNavigate).toHaveBeenCalled())
+ })
+
+ it('dismisses without launching', async () => {
+ const user = userEvent.setup()
+ renderDialog()
+
+ await user.click(screen.getByRole('button', { name: 'Cancel' }))
+
+ expect(onDismiss).toHaveBeenCalled()
+ expect(mockResolve).not.toHaveBeenCalled()
+ })
+
+ it('dismisses when the dialog itself is closed', async () => {
+ const user = userEvent.setup()
+ renderDialog()
+
+ await user.keyboard('{Escape}')
+
+ expect(onDismiss).toHaveBeenCalled()
+ })
+
+ it('sends the target the operator picked instead of the default', async () => {
+ const user = userEvent.setup()
+ renderDialog()
+
+ await user.selectOptions(screen.getByRole('combobox', { name: 'Target' }), 'adversary')
+ await user.click(screen.getByTestId('confirm-launch-preset'))
+
+ await waitFor(() => expect(mockResolve).toHaveBeenCalledWith(
+ 'nightly_probe',
+ expect.objectContaining({ target_name: 'adversary' }),
+ ))
+ })
+
+ it('sends the adversarial target the operator picked', async () => {
+ const user = userEvent.setup()
+ renderDialog()
+
+ await user.selectOptions(screen.getByRole('combobox', { name: 'Adversarial Target' }), 'adversary')
+ await user.click(screen.getByTestId('confirm-launch-preset'))
+
+ await waitFor(() => expect(mockResolve).toHaveBeenCalledWith(
+ 'nightly_probe',
+ expect.objectContaining({ adversarial_target_name: 'adversary' }),
+ ))
+ })
+
+ it('sends the concurrency and retry limits the operator set', async () => {
+ const user = userEvent.setup()
+ renderDialog()
+
+ const concurrency = screen.getByTestId('preset-max-concurrency-input')
+ await user.clear(concurrency)
+ await user.type(concurrency, '4')
+ const retries = screen.getByTestId('preset-max-retries-input')
+ await user.clear(retries)
+ await user.type(retries, '2')
+
+ await user.click(screen.getByTestId('confirm-launch-preset'))
+
+ await waitFor(() => expect(mockResolve).toHaveBeenCalledWith(
+ 'nightly_probe',
+ expect.objectContaining({ max_concurrency: 4, max_retries: 2 }),
+ ))
+ })
+
+ it('does not resolve twice while a launch is in flight', async () => {
+ const user = userEvent.setup()
+ let release: () => void = () => {}
+ mockResolve.mockReturnValue(new Promise((resolve) => {
+ release = () => resolve({ scenario_name: 'foundry.red_team_agent', target_name: 'gpt4o' })
+ }))
+
+ renderDialog()
+
+ await user.click(screen.getByTestId('confirm-launch-preset'))
+ expect(screen.getByTestId('confirm-launch-preset')).toBeDisabled()
+
+ release()
+ await waitFor(() => expect(mockNavigate).toHaveBeenCalled())
+ expect(mockResolve).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/frontend/src/components/ScenarioPresets/LaunchPresetDialog.tsx b/frontend/src/components/ScenarioPresets/LaunchPresetDialog.tsx
new file mode 100644
index 0000000000..4a2e23949d
--- /dev/null
+++ b/frontend/src/components/ScenarioPresets/LaunchPresetDialog.tsx
@@ -0,0 +1,247 @@
+import { type FormEvent, useState } from 'react'
+
+import {
+ Button,
+ Dialog,
+ DialogActions,
+ DialogBody,
+ DialogContent,
+ DialogSurface,
+ DialogTitle,
+ Field,
+ MessageBar,
+ MessageBarBody,
+ Text,
+} from '@fluentui/react-components'
+import { useNavigate } from 'react-router'
+
+import TargetSelect from '@/components/Config/TargetSelect'
+import SingleStepSpinButton from '@/components/Parameters/SingleStepSpinButton'
+import {
+ DEFAULT_MAX_CONCURRENCY,
+ DEFAULT_MAX_RETRIES,
+ MAX_MAX_CONCURRENCY,
+ MAX_MAX_RETRIES,
+ MIN_MAX_CONCURRENCY,
+ MIN_MAX_RETRIES,
+ resolveSpinButtonValue,
+} from '@/components/Scenarios/scenarioRunLimits'
+import { scenarioPresetsApi, scenariosApi } from '@/services/api'
+import { toApiError } from '@/services/errors'
+import type { RunScenarioRequest, ScenarioPreset, TargetInstance } from '@/types'
+import { scenarioRunRoutePath } from '@/utils/routeParams'
+
+import { useLaunchPresetDialogStyles } from './LaunchPresetDialog.styles'
+
+interface LaunchPresetDialogProps {
+ preset: ScenarioPreset
+ version: string | null
+ targets: TargetInstance[]
+ defaultObjectiveTarget: TargetInstance | null
+ defaultAdversarialTarget: TargetInstance | null
+ labels: Record
+ onDismiss: () => void
+ onPresetChanged: () => void
+}
+
+function initialTargetName(
+ candidate: TargetInstance | null,
+ available: TargetInstance[],
+): string {
+ if (!candidate) {
+ return ''
+ }
+ const match = available.some(
+ (target) => target.target_registry_name === candidate.target_registry_name,
+ )
+ return match ? candidate.target_registry_name : ''
+}
+
+/**
+ * Restates what the preset pins. The dialog covers the library card, so without
+ * this the operator commits to a run knowing only the preset and scenario name.
+ * Omitted fields read as scenario defaults because that is what the server does
+ * with them, and a limit of 'default' is an explicit way of saying the same thing.
+ */
+function presetSummary(preset: ScenarioPreset): string {
+ const techniques = preset.techniques ?? []
+ const sources = preset.datasets?.sources ?? []
+ const parts = [
+ techniques.length === 0
+ ? 'Scenario default techniques'
+ : `${techniques.length} technique${techniques.length === 1 ? '' : 's'}`,
+ sources.length === 0 ? 'scenario default datasets' : sources.map((source) => source.name).join(', '),
+ ]
+ const total = preset.datasets?.max_total
+ if (total != null && total !== 'default') {
+ parts.push(total === 'all' ? 'every case' : `max ${total} cases`)
+ }
+ const perDataset = preset.datasets?.max_per_dataset
+ if (perDataset != null && perDataset !== 'default') {
+ parts.push(perDataset === 'all' ? 'every case per dataset' : `max ${perDataset} per dataset`)
+ }
+ if (preset.include_baseline != null) {
+ parts.push(preset.include_baseline ? 'baseline included' : 'baseline excluded')
+ }
+ return parts.join(' · ')
+}
+
+/**
+ * Collects the launch-owned fields a preset deliberately omits, then asks the
+ * server to merge them with the stored preset. Resolution stays server-side so
+ * the browser never reimplements the preset-to-run mapping.
+ *
+ * The summary above describes the preset as it was read, so the launch sends that
+ * version back. An edit landing in between is reported rather than launched, which
+ * is the one case where a run would otherwise differ from what was confirmed.
+ */
+export default function LaunchPresetDialog({
+ preset,
+ version,
+ targets,
+ defaultObjectiveTarget,
+ defaultAdversarialTarget,
+ labels,
+ onDismiss,
+ onPresetChanged,
+}: LaunchPresetDialogProps) {
+ const styles = useLaunchPresetDialogStyles()
+ const navigate = useNavigate()
+ const adversarialTargets = targets.filter(
+ (target) => target.capabilities?.supports_multi_turn === true,
+ )
+ const [targetName, setTargetName] = useState(
+ () => initialTargetName(defaultObjectiveTarget, targets),
+ )
+ const [adversarialTargetName, setAdversarialTargetName] = useState(
+ () => initialTargetName(defaultAdversarialTarget, adversarialTargets),
+ )
+ const [maxConcurrency, setMaxConcurrency] = useState(DEFAULT_MAX_CONCURRENCY)
+ const [maxRetries, setMaxRetries] = useState(DEFAULT_MAX_RETRIES)
+ const [submitting, setSubmitting] = useState(false)
+ const [error, setError] = useState(null)
+
+ const handleSubmit = async (event: FormEvent): Promise => {
+ event.preventDefault()
+ if (targetName === '' || submitting) {
+ return
+ }
+ setSubmitting(true)
+ setError(null)
+ let request: RunScenarioRequest
+ try {
+ request = await scenarioPresetsApi.resolve(preset.name, {
+ expected_version: version,
+ target_name: targetName,
+ ...(adversarialTargetName === '' ? {} : { adversarial_target_name: adversarialTargetName }),
+ max_concurrency: maxConcurrency,
+ max_retries: maxRetries,
+ ...(Object.keys(labels).length > 0 ? { labels } : {}),
+ })
+ } catch (err) {
+ const apiError = toApiError(err)
+ if (apiError.status === 409) {
+ // The summary on screen no longer describes the preset, so re-reading is the
+ // only way forward; keeping the dialog open would invite a confirm-and-retry loop.
+ onPresetChanged()
+ return
+ }
+ setError(apiError.detail)
+ setSubmitting(false)
+ return
+ }
+ try {
+ const summary = await scenariosApi.startRun(request)
+ navigate(scenarioRunRoutePath(summary.scenario_result_id), {
+ state: { scenarioName: preset.scenario_name },
+ })
+ } catch (err) {
+ setError(toApiError(err).detail)
+ setSubmitting(false)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/ScenarioPresets/ScenarioPresetEditor.styles.ts b/frontend/src/components/ScenarioPresets/ScenarioPresetEditor.styles.ts
new file mode 100644
index 0000000000..d48adda1f6
--- /dev/null
+++ b/frontend/src/components/ScenarioPresets/ScenarioPresetEditor.styles.ts
@@ -0,0 +1,70 @@
+import { makeStyles, tokens } from '@fluentui/react-components'
+
+import { mobileTouchTarget, NARROW_VIEWPORT_QUERY } from '@/styles/touchTargets'
+import { WORKSPACE_CANVAS_BACKGROUND } from '@/styles/workspaceBackground'
+
+export const useScenarioPresetEditorStyles = makeStyles({
+ root: {
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100%',
+ width: '100%',
+ minWidth: 0,
+ padding: tokens.spacingVerticalXXL,
+ overflowX: 'hidden',
+ overflowY: 'auto',
+ backgroundColor: WORKSPACE_CANVAS_BACKGROUND,
+ [NARROW_VIEWPORT_QUERY]: {
+ padding: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalM}`,
+ },
+ },
+ headerText: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalXS,
+ marginBottom: tokens.spacingVerticalL,
+ },
+ subtitle: {
+ color: tokens.colorNeutralForeground3,
+ },
+ form: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalL,
+ maxWidth: '60rem',
+ },
+ section: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalM,
+ padding: tokens.spacingVerticalL,
+ borderRadius: tokens.borderRadiusMedium,
+ border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`,
+ backgroundColor: tokens.colorNeutralBackground1,
+ },
+ control: {
+ maxWidth: '32rem',
+ },
+ dynamicParameters: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalM,
+ },
+ actions: {
+ display: 'flex',
+ flexWrap: 'wrap',
+ gap: tokens.spacingHorizontalS,
+ alignItems: 'center',
+ },
+ touchTarget: {
+ ...mobileTouchTarget,
+ },
+ centeredState: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ gap: tokens.spacingVerticalM,
+ padding: tokens.spacingVerticalXXL,
+ textAlign: 'center',
+ },
+})
diff --git a/frontend/src/components/ScenarioPresets/ScenarioPresetEditor.test.tsx b/frontend/src/components/ScenarioPresets/ScenarioPresetEditor.test.tsx
new file mode 100644
index 0000000000..80397b7a6e
--- /dev/null
+++ b/frontend/src/components/ScenarioPresets/ScenarioPresetEditor.test.tsx
@@ -0,0 +1,735 @@
+import { act, render, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { FluentProvider, webLightTheme } from '@fluentui/react-components'
+import { MemoryRouter, Route, Routes } from 'react-router'
+
+import { scenarioPresetsApi, scenariosApi } from '@/services/api'
+import { makeScenario } from '@/test-utils/scenarioFixtures'
+import type { ScenarioDatasetSettings, ScenarioPreset, ScenarioRunSizeEstimateResponse } from '@/types'
+
+import ScenarioPresetEditor from './ScenarioPresetEditor'
+
+jest.mock('@/services/api', () => ({
+ datasetsApi: {
+ listDatasets: jest.fn().mockResolvedValue({
+ items: ['harmbench', 'xstest'].map((name) => ({
+ name, selection_key: `dataset:named:${name}`, loaded: true, provider_available: true,
+ logical_examples: 8, seed_pieces: null, objectives: null, modalities: ['text'],
+ harm_categories: ['violence'], has_unlabeled_harm_categories: false,
+ })),
+ }),
+ },
+ scenarioPresetsApi: {
+ get: jest.fn(),
+ create: jest.fn(),
+ update: jest.fn(),
+ },
+ scenariosApi: {
+ listCatalog: jest.fn(),
+ getScenario: jest.fn(),
+ estimateRun: jest.fn(),
+ },
+}))
+
+const mockGet = scenarioPresetsApi.get as jest.Mock
+const mockCreate = scenarioPresetsApi.create as jest.Mock
+const mockUpdate = scenarioPresetsApi.update as jest.Mock
+const mockListCatalog = scenariosApi.listCatalog as jest.Mock
+const mockGetScenario = scenariosApi.getScenario as jest.Mock
+const mockEstimateRun = scenariosApi.estimateRun as jest.Mock
+
+const mockNavigate = jest.fn()
+
+jest.mock('react-router', () => ({
+ ...jest.requireActual('react-router'),
+ useNavigate: () => mockNavigate,
+}))
+
+const SCENARIO = makeScenario()
+
+const STORED_PRESET: ScenarioPreset = {
+ name: 'nightly_probe',
+ scenario_name: 'foundry.red_team_agent',
+ description: 'Nightly smoke test.',
+ techniques: ['crescendo'],
+ include_baseline: false,
+}
+
+function apiError(status: number, detail: string): unknown {
+ return {
+ isAxiosError: true,
+ response: { status, data: { detail } },
+ }
+}
+
+/** Drives the shared dataset table's limit popover the same way its own suite does. */
+async function editLimit(user: ReturnType, label: string, value: string): Promise {
+ await user.click(await screen.findByRole('button', { name: `Edit ${label}` }))
+ const all = screen.getByRole('checkbox', { name: `All available for ${label}` })
+ if (all instanceof HTMLInputElement && all.checked) await user.click(all)
+ const input = screen.getByRole('textbox', { name: label })
+ await user.clear(input)
+ await user.type(input, value)
+}
+
+function renderCreate() {
+ return render(
+
+
+
+ } />
+
+
+ ,
+ )
+}
+
+function renderEdit(name = 'nightly_probe') {
+ return render(
+
+
+
+ }
+ />
+
+
+ ,
+ )
+}
+
+beforeEach(() => {
+ jest.clearAllMocks()
+ mockListCatalog.mockResolvedValue({
+ items: [SCENARIO],
+ pagination: { has_more: false, next_cursor: null },
+ })
+ mockGetScenario.mockResolvedValue(SCENARIO)
+ mockEstimateRun.mockResolvedValue(SCENARIO.default_run_size)
+ mockGet.mockResolvedValue({ preset: STORED_PRESET, version: 'v1', issues: [] })
+ mockCreate.mockResolvedValue({ preset: STORED_PRESET, version: 'v1', issues: [] })
+ mockUpdate.mockResolvedValue({ preset: STORED_PRESET, version: 'v2', issues: [] })
+})
+
+describe('ScenarioPresetEditor create mode', () => {
+ it('cannot save before a scenario is chosen', async () => {
+ renderCreate()
+
+ expect(await screen.findByTestId('scenario-preset-editor')).toBeInTheDocument()
+ expect(screen.getByTestId('save-preset-btn')).toBeDisabled()
+ })
+
+ it('creates a preset that pins nothing the operator left at the scenario default', async () => {
+ const user = userEvent.setup()
+ renderCreate()
+
+ await user.type(await screen.findByTestId('preset-name-input'), 'nightly_probe')
+ await user.click(screen.getByTestId('preset-scenario-select'))
+ await user.click(await screen.findByRole('option', { name: 'foundry.red_team_agent' }))
+
+ await waitFor(() => expect(screen.getByTestId('save-preset-btn')).toBeEnabled())
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ await waitFor(() => expect(mockCreate).toHaveBeenCalledWith({
+ name: 'nightly_probe',
+ scenario_name: 'foundry.red_team_agent',
+ }))
+ expect(mockUpdate).not.toHaveBeenCalled()
+ expect(mockNavigate).toHaveBeenCalledWith('/registry/scenario-presets')
+ })
+
+ it('pins a technique selection the operator moved off the scenario default', async () => {
+ const user = userEvent.setup()
+ renderCreate()
+
+ await user.type(await screen.findByTestId('preset-name-input'), 'nightly_probe')
+ await user.click(screen.getByTestId('preset-scenario-select'))
+ await user.click(await screen.findByRole('option', { name: 'foundry.red_team_agent' }))
+
+ await user.click(await screen.findByTestId('technique-crescendo'))
+ await waitFor(() => expect(screen.getByTestId('save-preset-btn')).toBeEnabled())
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ await waitFor(() => expect(mockCreate).toHaveBeenCalledWith({
+ name: 'nightly_probe',
+ scenario_name: 'foundry.red_team_agent',
+ techniques: ['default_technique', 'crescendo'],
+ }))
+ })
+
+ it('rejects a name the server pattern would reject, without calling the API', async () => {
+ const user = userEvent.setup()
+ renderCreate()
+
+ await user.type(await screen.findByTestId('preset-name-input'), 'Nightly-Probe')
+ await user.click(screen.getByTestId('preset-scenario-select'))
+ await user.click(await screen.findByRole('option', { name: 'foundry.red_team_agent' }))
+
+ await waitFor(() => expect(screen.getByTestId('save-preset-btn')).toBeEnabled())
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ expect(await screen.findByText(/Use lowercase letters/)).toBeInTheDocument()
+ expect(mockCreate).not.toHaveBeenCalled()
+ })
+
+ it('reports a duplicate name returned by the server', async () => {
+ const user = userEvent.setup()
+ mockCreate.mockRejectedValue(apiError(409, 'A preset named "nightly_probe" already exists.'))
+ renderCreate()
+
+ await user.type(await screen.findByTestId('preset-name-input'), 'nightly_probe')
+ await user.click(screen.getByTestId('preset-scenario-select'))
+ await user.click(await screen.findByRole('option', { name: 'foundry.red_team_agent' }))
+
+ await waitFor(() => expect(screen.getByTestId('save-preset-btn')).toBeEnabled())
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ expect(
+ await screen.findByText('A preset named "nightly_probe" already exists.'),
+ ).toBeInTheDocument()
+ expect(mockNavigate).not.toHaveBeenCalled()
+ })
+
+ it('reports a missing admin permission', async () => {
+ const user = userEvent.setup()
+ mockCreate.mockRejectedValue(apiError(403, 'Admin access required.'))
+ renderCreate()
+
+ await user.type(await screen.findByTestId('preset-name-input'), 'nightly_probe')
+ await user.click(screen.getByTestId('preset-scenario-select'))
+ await user.click(await screen.findByRole('option', { name: 'foundry.red_team_agent' }))
+
+ await waitFor(() => expect(screen.getByTestId('save-preset-btn')).toBeEnabled())
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ expect(await screen.findByText('Admin access required.')).toBeInTheDocument()
+ })
+
+ it('reports an unselectable technique set rather than saving an empty one', async () => {
+ const user = userEvent.setup()
+ mockGetScenario.mockResolvedValue(makeScenario({
+ default_techniques: [],
+ default_technique: 'all',
+ aggregate_techniques: ['all'],
+ }))
+ renderCreate()
+
+ await user.type(await screen.findByTestId('preset-name-input'), 'nightly_probe')
+ await user.click(screen.getByTestId('preset-scenario-select'))
+ await user.click(await screen.findByRole('option', { name: 'foundry.red_team_agent' }))
+
+ await waitFor(() => expect(screen.getByTestId('save-preset-btn')).toBeEnabled())
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ expect(await screen.findByText('Select at least one technique.')).toBeInTheDocument()
+ expect(mockCreate).not.toHaveBeenCalled()
+ })
+
+ it('reports a failure to load the scenario the operator picked', async () => {
+ const user = userEvent.setup()
+ mockGetScenario.mockRejectedValue(apiError(503, 'Scenario registry is unavailable.'))
+ renderCreate()
+
+ await user.click(await screen.findByTestId('preset-scenario-select'))
+ await user.click(await screen.findByRole('option', { name: 'foundry.red_team_agent' }))
+
+ expect(await screen.findByText('Scenario registry is unavailable.')).toBeInTheDocument()
+ expect(screen.getByTestId('save-preset-btn')).toBeDisabled()
+ })
+})
+
+describe('ScenarioPresetEditor edit mode', () => {
+ it('loads the stored preset and pins its name and scenario', async () => {
+ renderEdit()
+
+ expect(await screen.findByTestId('scenario-preset-editor')).toBeInTheDocument()
+ expect(screen.getByTestId('preset-name-input')).toBeDisabled()
+ expect(screen.getByTestId('preset-name-input')).toHaveValue('nightly_probe')
+ expect(screen.getByTestId('preset-description-input')).toHaveValue('Nightly smoke test.')
+ })
+
+ it('updates with the version it read so a concurrent edit is not overwritten', async () => {
+ const user = userEvent.setup()
+ renderEdit()
+
+ await user.click(await screen.findByTestId('save-preset-btn'))
+
+ await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith(
+ 'nightly_probe',
+ expect.objectContaining({ name: 'nightly_probe', techniques: ['crescendo'] }),
+ 'v1',
+ ))
+ expect(mockCreate).not.toHaveBeenCalled()
+ expect(mockNavigate).toHaveBeenCalledWith('/registry/scenario-presets')
+ })
+
+ it('reports a version conflict instead of navigating away', async () => {
+ const user = userEvent.setup()
+ mockUpdate.mockRejectedValue(apiError(409, 'The preset changed since it was loaded.'))
+ renderEdit()
+
+ await user.click(await screen.findByTestId('save-preset-btn'))
+
+ expect(await screen.findByText('The preset changed since it was loaded.')).toBeInTheDocument()
+ expect(mockNavigate).not.toHaveBeenCalled()
+ })
+
+ it('keeps pinned techniques this deployment does not offer instead of rewriting them', async () => {
+ const user = userEvent.setup()
+ mockGet.mockResolvedValue({
+ preset: { ...STORED_PRESET, techniques: ['crescendo', 'retired_attack'] },
+ version: 'v1',
+ issues: [{ field: 'techniques', message: "Technique 'retired_attack' is not available." }],
+ })
+
+ renderEdit()
+
+ expect(await screen.findByTestId('dropped-techniques-warning')).toHaveTextContent(
+ 'retired_attack',
+ )
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith(
+ 'nightly_probe',
+ expect.objectContaining({ techniques: ['crescendo', 'retired_attack'] }),
+ 'v1',
+ ))
+ })
+
+ it('keeps a converter-qualified technique through an unrelated edit', async () => {
+ const user = userEvent.setup()
+ mockGet.mockResolvedValue({
+ preset: { ...STORED_PRESET, techniques: ['crescendo:converter.translation_spanish'] },
+ version: 'v1',
+ issues: [],
+ })
+
+ renderEdit()
+
+ await user.type(await screen.findByTestId('preset-description-input'), ' Updated.')
+ expect(screen.queryByTestId('dropped-techniques-warning')).not.toBeInTheDocument()
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith(
+ 'nightly_probe',
+ expect.objectContaining({ techniques: ['crescendo:converter.translation_spanish'] }),
+ 'v1',
+ ))
+ })
+
+ it('shows modifier validation from the server even when the base technique is available', async () => {
+ mockGet.mockResolvedValue({
+ preset: { ...STORED_PRESET, techniques: ['crescendo:converter.missing'] },
+ version: 'v1',
+ issues: [{ field: 'techniques', message: "Converter 'missing' is not registered." }],
+ })
+
+ renderEdit()
+
+ expect(await screen.findByTestId('dropped-techniques-warning')).toHaveTextContent(
+ "Converter 'missing' is not registered.",
+ )
+ expect(screen.getByTestId('techniques-without-checkbox')).toHaveTextContent('crescendo:converter.missing')
+ })
+
+ it('does not invent a technique issue for a token the server accepts', async () => {
+ mockGet.mockResolvedValue({
+ preset: { ...STORED_PRESET, techniques: ['server_extension'] },
+ version: 'v1',
+ issues: [{ field: 'scenario_params', message: 'A parameter is unavailable.' }],
+ })
+
+ renderEdit()
+
+ expect(await screen.findByTestId('techniques-without-checkbox')).toHaveTextContent('server_extension')
+ expect(screen.queryByTestId('dropped-techniques-warning')).not.toBeInTheDocument()
+ })
+
+ it('shows a pinned aggregate technique as a tag the operator can remove', async () => {
+ const user = userEvent.setup()
+ mockGet.mockResolvedValue({
+ preset: { ...STORED_PRESET, techniques: ['all'] },
+ version: 'v1',
+ issues: [],
+ })
+
+ renderEdit()
+
+ expect(await screen.findByTestId('techniques-without-checkbox')).toHaveTextContent('all')
+ expect(screen.getByTestId('technique-crescendo')).not.toBeChecked()
+ expect(screen.queryByTestId('dropped-techniques-warning')).not.toBeInTheDocument()
+
+ await user.click(screen.getByTestId('technique-crescendo'))
+ await user.click(screen.getByRole('button', { name: 'Remove all' }))
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith(
+ 'nightly_probe',
+ expect.objectContaining({ techniques: ['crescendo'] }),
+ 'v1',
+ ))
+ })
+
+ it('reports a missing preset as not found', async () => {
+ mockGet.mockRejectedValue(apiError(404, 'No such preset.'))
+
+ renderEdit()
+
+ expect(await screen.findByTestId('editor-error-state')).toHaveTextContent(
+ 'No preset named "nightly_probe"',
+ )
+ })
+
+ it('reports an unavailable scenario as unavailable, not as a missing preset', async () => {
+ mockGetScenario.mockRejectedValue(apiError(404, 'Unknown scenario "foundry.red_team_agent".'))
+
+ renderEdit()
+
+ expect(await screen.findByTestId('scenario-unavailable-warning')).toBeInTheDocument()
+ expect(screen.queryByTestId('editor-error-state')).not.toBeInTheDocument()
+ expect(screen.getByTestId('save-preset-btn')).toBeDisabled()
+ })
+
+ it('decodes a preset name that was escaped into the route', async () => {
+ mockGet.mockResolvedValue({
+ preset: { ...STORED_PRESET, name: 'a_b' },
+ version: 'v1',
+ issues: [],
+ })
+
+ renderEdit('a_b')
+
+ await waitFor(() => expect(mockGet).toHaveBeenCalledWith('a_b'))
+ })
+
+ it('saves the edited description', async () => {
+ const user = userEvent.setup()
+ renderEdit()
+
+ const description = await screen.findByTestId('preset-description-input')
+ await user.clear(description)
+ await user.type(description, 'Weekly smoke test.')
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith(
+ 'nightly_probe',
+ expect.objectContaining({ description: 'Weekly smoke test.' }),
+ 'v1',
+ ))
+ })
+
+ it('saves the techniques and baseline choice the operator changed', async () => {
+ const user = userEvent.setup()
+ renderEdit()
+
+ await user.click(await screen.findByTestId('technique-default_technique'))
+ await user.click(screen.getByTestId('baseline-checkbox'))
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith(
+ 'nightly_probe',
+ expect.objectContaining({
+ techniques: ['crescendo', 'default_technique'],
+ include_baseline: true,
+ }),
+ 'v1',
+ ))
+ })
+
+ it('saves the dataset selection the operator made', async () => {
+ const user = userEvent.setup()
+ renderEdit()
+
+ await user.click(await screen.findByRole('combobox', { name: 'Add dataset' }))
+ await user.click(screen.getByRole('option', { name: 'xstest (unknown)', exact: true }))
+ await editLimit(user, 'Maximum objectives', '25')
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ await waitFor(() => expect(mockUpdate).toHaveBeenCalled())
+ const saved = mockUpdate.mock.calls.at(-1)?.[1] as ScenarioPreset
+ expect(saved.datasets?.sources?.map((source) => source.name)).toEqual(['harmbench', 'xstest'])
+ expect(saved.datasets?.max_total).toBe(25)
+ })
+
+ it('refuses to save while the dataset control reports an invalid limit', async () => {
+ const user = userEvent.setup()
+ renderEdit()
+
+ await editLimit(user, 'Maximum objectives', '0')
+ await user.keyboard('{Escape}')
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ await waitFor(() => expect(screen.getByTestId('save-preset-btn')).toBeEnabled())
+ expect(mockUpdate).not.toHaveBeenCalled()
+ })
+
+ it('offers no objective cap on a scenario that sizes itself by prompt generation', async () => {
+ const generativeScenario = makeScenario({
+ default_run_size: {
+ dataset_size: { kind: 'indeterminate', detail: 'Generated at run time.' },
+ dataset_limit: { state: 'not_applicable' },
+ estimated_attack_count: null,
+ components: [],
+ datasets: [],
+ note: null,
+ },
+ })
+ mockGetScenario.mockResolvedValue(generativeScenario)
+ renderEdit()
+
+ expect(await screen.findByRole('combobox', { name: 'Add dataset' })).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Edit Maximum objectives' })).not.toBeInTheDocument()
+ })
+
+ it('leaves the editor without saving when cancelled', async () => {
+ const user = userEvent.setup()
+ renderEdit()
+
+ await user.click(await screen.findByRole('button', { name: 'Cancel' }))
+
+ expect(mockUpdate).not.toHaveBeenCalled()
+ expect(mockNavigate).toHaveBeenCalledWith('/registry/scenario-presets')
+ })
+
+ it('returns to the library from the not-found state', async () => {
+ const user = userEvent.setup()
+ mockGet.mockRejectedValue(apiError(404, 'No such preset.'))
+
+ renderEdit()
+
+ await user.click(await screen.findByRole('button', { name: 'Back to presets' }))
+
+ expect(mockNavigate).toHaveBeenCalledWith('/registry/scenario-presets')
+ })
+
+ it('reports a catalog failure that is not a missing preset as an error', async () => {
+ mockListCatalog.mockRejectedValue(apiError(503, 'Preset storage is not configured.'))
+
+ renderEdit()
+
+ expect(await screen.findByTestId('editor-error-state')).toHaveTextContent(
+ 'Preset storage is not configured.',
+ )
+ })
+})
+
+describe('ScenarioPresetEditor dataset projection', () => {
+ const defaults: ScenarioDatasetSettings = {
+ sources: [{ name: 'harmbench', max_size: 5, fetch: 'if_missing', configured_provider: false }],
+ max_per_dataset: 5,
+ max_total: 'all',
+ fetch: 'if_missing',
+ filters: {},
+ }
+
+ function projection(limit: number | 'all'): ScenarioRunSizeEstimateResponse {
+ return {
+ ...SCENARIO.default_run_size,
+ dataset_defaults: defaults,
+ effective_datasets: {
+ ...defaults,
+ max_per_dataset: limit,
+ sources: [{ ...defaults.sources[0], max_size: limit }],
+ },
+ }
+ }
+
+ beforeEach(() => {
+ jest.useFakeTimers()
+ mockGetScenario.mockResolvedValue(makeScenario({ dataset_defaults: defaults }))
+ })
+
+ afterEach(() => {
+ jest.useRealTimers()
+ })
+
+ it.each([
+ { limit: 2, text: 'Up to 2 of 8' },
+ { limit: 'all', text: '8 examples' },
+ ] as const)('shows the effective size for max_per_dataset=$limit', async ({ limit, text }) => {
+ const datasets = { max_per_dataset: limit }
+ mockGet.mockResolvedValue({
+ preset: { ...STORED_PRESET, datasets },
+ version: 'v1',
+ issues: [],
+ })
+ mockEstimateRun.mockResolvedValue(projection(limit))
+
+ renderEdit()
+
+ const row = await screen.findByRole('row', { name: /harmbench/ })
+ expect(await within(row).findByText(text)).toBeInTheDocument()
+ expect(mockEstimateRun).toHaveBeenCalledWith(
+ STORED_PRESET.scenario_name,
+ expect.objectContaining({ techniques: ['crescendo'], datasets, include_baseline: false }),
+ expect.any(AbortSignal),
+ )
+ })
+
+ it('uses projected limits for a source that is not in the scenario defaults', async () => {
+ const datasets = { sources: [{ name: 'xstest' }], max_per_dataset: 2 }
+ mockGet.mockResolvedValue({
+ preset: { ...STORED_PRESET, datasets },
+ version: 'v1',
+ issues: [],
+ })
+ mockEstimateRun.mockResolvedValue({
+ ...projection(2),
+ effective_datasets: {
+ ...defaults,
+ max_per_dataset: 2,
+ sources: [{ name: 'xstest', max_size: 2, fetch: 'if_missing', configured_provider: false }],
+ },
+ })
+
+ renderEdit()
+
+ const row = await screen.findByRole('row', { name: /xstest/ })
+ expect(await within(row).findByText('Up to 2 of 8')).toBeInTheDocument()
+ })
+
+ it('preserves a source-specific cap while projecting the inherited per-dataset cap', async () => {
+ const sourceDefaults: ScenarioDatasetSettings = {
+ ...defaults,
+ sources: [
+ { ...defaults.sources[0], max_size: 3 },
+ { ...defaults.sources[0], name: 'xstest' },
+ ],
+ }
+ mockGetScenario.mockResolvedValue(makeScenario({ dataset_defaults: sourceDefaults }))
+ mockGet.mockResolvedValue({
+ preset: { ...STORED_PRESET, datasets: { max_per_dataset: 2 } },
+ version: 'v1',
+ issues: [],
+ })
+ mockEstimateRun.mockResolvedValue({
+ ...projection(2),
+ dataset_defaults: sourceDefaults,
+ effective_datasets: {
+ ...sourceDefaults,
+ max_per_dataset: 2,
+ sources: [sourceDefaults.sources[0], { ...sourceDefaults.sources[1], max_size: 2 }],
+ },
+ })
+
+ renderEdit()
+
+ const inherited = await screen.findByRole('row', { name: /xstest/ })
+ expect(await within(inherited).findByText('Up to 2 of 8')).toBeInTheDocument()
+ const pinned = screen.getByRole('row', { name: /harmbench/ })
+ expect(within(pinned).getByText('Up to 3 of 8')).toBeInTheDocument()
+ })
+
+ it('reports projection errors without preventing a preset from being saved', async () => {
+ const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime })
+ mockEstimateRun.mockRejectedValue(new Error('Projection unavailable.'))
+
+ renderEdit()
+
+ expect(await screen.findByText(
+ 'Dataset preview could not be updated: Projection unavailable.',
+ )).toBeInTheDocument()
+ await user.click(screen.getByTestId('save-preset-btn'))
+ await waitFor(() => expect(mockUpdate).toHaveBeenCalled())
+ })
+
+ it('ignores a pending projection after the operator resets the datasets', async () => {
+ const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime })
+ let complete: (value: ScenarioRunSizeEstimateResponse) => void = () => {}
+ mockGet.mockResolvedValue({
+ preset: { ...STORED_PRESET, datasets: { max_per_dataset: 2 } },
+ version: 'v1',
+ issues: [],
+ })
+ mockEstimateRun
+ .mockReturnValueOnce(new Promise((resolve) => { complete = resolve }))
+ .mockResolvedValue(projection(5))
+
+ renderEdit()
+
+ await waitFor(() => expect(mockEstimateRun).toHaveBeenCalledTimes(1))
+ const signal: AbortSignal = mockEstimateRun.mock.calls[0][2]
+ await user.click(screen.getByRole('button', { name: 'Reset datasets' }))
+ await waitFor(() => expect(mockEstimateRun).toHaveBeenCalledTimes(2))
+ expect(signal.aborted).toBe(true)
+
+ await act(async () => { complete(projection(2)) })
+
+ const row = screen.getByRole('row', { name: /harmbench/ })
+ expect(within(row).getByText('Up to 5 of 8')).toBeInTheDocument()
+ expect(within(row).queryByText('Up to 2 of 8')).not.toBeInTheDocument()
+ })
+
+ it('stops the preview spinner when an invalid edit cancels the pending request', async () => {
+ const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime })
+ mockEstimateRun.mockReturnValueOnce(new Promise(() => {}))
+
+ renderEdit()
+
+ await waitFor(() => expect(mockEstimateRun).toHaveBeenCalledTimes(1))
+ expect(screen.getByText('Updating dataset preview...')).toBeInTheDocument()
+ const signal: AbortSignal = mockEstimateRun.mock.calls[0][2]
+
+ await user.click(screen.getByTestId('technique-crescendo'))
+
+ expect(signal.aborted).toBe(true)
+ expect(screen.queryByText('Updating dataset preview...')).not.toBeInTheDocument()
+ await user.click(screen.getByTestId('save-preset-btn'))
+ expect(await screen.findByText('Select at least one technique.')).toBeInTheDocument()
+ expect(mockUpdate).not.toHaveBeenCalled()
+ })
+})
+
+describe('ScenarioPresetEditor dynamic parameters', () => {
+ const SCENARIO_WITH_PARAM = makeScenario({
+ supported_parameters: [
+ {
+ name: 'max_turns',
+ type_name: 'int',
+ required: false,
+ default: '5',
+ description: 'Turn budget.',
+ },
+ ],
+ })
+
+ it('stores a scenario-specific parameter the operator changed', async () => {
+ const user = userEvent.setup()
+ mockGetScenario.mockResolvedValue(SCENARIO_WITH_PARAM)
+ renderEdit()
+
+ const field = await screen.findByTestId('preset-param-max_turns')
+ await user.clear(field)
+ await user.type(field, '9')
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith(
+ 'nightly_probe',
+ expect.objectContaining({ scenario_params: { max_turns: 9 } }),
+ 'v1',
+ ))
+ })
+
+ it('preserves and surfaces stored parameters this editor renders no field for', async () => {
+ const user = userEvent.setup()
+ mockGetScenario.mockResolvedValue(SCENARIO_WITH_PARAM)
+ mockGet.mockResolvedValue({
+ preset: { ...STORED_PRESET, scenario_params: { max_turns: 5, max_concurrency: 4 } },
+ version: 'v1',
+ issues: [],
+ })
+
+ renderEdit()
+
+ expect(await screen.findByTestId('carried-params-notice')).toHaveTextContent('max_concurrency')
+
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith(
+ 'nightly_probe',
+ expect.objectContaining({ scenario_params: { max_turns: 5, max_concurrency: 4 } }),
+ 'v1',
+ ))
+ })
+})
diff --git a/frontend/src/components/ScenarioPresets/ScenarioPresetEditor.tsx b/frontend/src/components/ScenarioPresets/ScenarioPresetEditor.tsx
new file mode 100644
index 0000000000..2cabb296b3
--- /dev/null
+++ b/frontend/src/components/ScenarioPresets/ScenarioPresetEditor.tsx
@@ -0,0 +1,503 @@
+import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react'
+
+import {
+ Button,
+ Field,
+ Input,
+ MessageBar,
+ MessageBarBody,
+ Option,
+ Spinner,
+ Combobox,
+ Text,
+ Textarea,
+} from '@fluentui/react-components'
+import { useNavigate, useParams } from 'react-router'
+
+import ParameterField from '@/components/Parameters/ParameterField'
+import type { ParameterFormValue } from '@/components/Parameters/parameterForm'
+import ScenarioDatasetTable from '@/components/Scenarios/ScenarioDatasetTable'
+import ScenarioTechniqueSelector from '@/components/Scenarios/ScenarioTechniqueSelector'
+import { scenarioDatasetDefaults, scenarioDatasetOptions } from '@/components/Scenarios/datasetPresentation'
+import {
+ buildScenarioConfig,
+ dynamicScenarioParameters,
+ initialScenarioConfigState,
+ uniqueTechniqueOptions,
+ type ScenarioConfigFormState,
+} from '@/components/Scenarios/scenarioConfigForm'
+import { useScenarioDatasets } from '@/hooks/useScenarioDatasets'
+import { scenarioPresetsApi, scenariosApi } from '@/services/api'
+import { toApiError } from '@/services/errors'
+import type {
+ RegisteredScenario,
+ ScenarioPresetResponse,
+ ScenarioRunSizeEstimateRequest,
+ ScenarioRunSizeEstimateResponse,
+} from '@/types'
+import { fetchAllPages } from '@/utils/fetchAllPages'
+import { routerPathParamValue } from '@/utils/routeParams'
+
+import { useScenarioPresetEditorStyles } from './ScenarioPresetEditor.styles'
+import { PRESETS_ROUTE } from './presetRoutes'
+import {
+ configToPreset,
+ presetToConfigState,
+ uneditableScenarioParams,
+ validatePresetName,
+} from './scenarioPresetForm'
+
+/** Items requested per catalog page while paging the scenario picker's options. */
+const CATALOG_PAGE_SIZE = 200
+const DATASET_PROJECTION_DEBOUNCE_MS = 250
+
+type LoadStatus = 'loading' | 'ready' | 'not-found' | 'error'
+
+interface DatasetProjectionResult {
+ request: ScenarioRunSizeEstimateRequest
+ scenarioName: string
+ error: string | null
+}
+
+export default function ScenarioPresetEditor({ mode }: { mode: 'create' | 'edit' }) {
+ const { presetName } = useParams<{ presetName: string }>()
+ // Remount on navigation between two presets so every field resets to the
+ // newly loaded document rather than retaining the previous one's edits.
+ return
+}
+
+interface ScenarioPresetEditorContentProps {
+ mode: 'create' | 'edit'
+ presetName: string | undefined
+}
+
+function ScenarioPresetEditorContent({ mode, presetName }: ScenarioPresetEditorContentProps) {
+ const styles = useScenarioPresetEditorStyles()
+ const navigate = useNavigate()
+ const decodedName = presetName === undefined ? '' : routerPathParamValue(presetName)
+
+ const [status, setStatus] = useState('loading')
+ const [loadError, setLoadError] = useState(null)
+ const [catalog, setCatalog] = useState([])
+ const [loaded, setLoaded] = useState(null)
+
+ const [name, setName] = useState(mode === 'edit' ? decodedName : '')
+ const [description, setDescription] = useState('')
+ const [scenario, setScenario] = useState(null)
+ const [scenarioLoading, setScenarioLoading] = useState(false)
+ const [config, setConfig] = useState(null)
+ const [scenarioUnavailable, setScenarioUnavailable] = useState(null)
+ const [datasetError, setDatasetError] = useState(null)
+ const [datasetProjection, setDatasetProjection] = useState(null)
+ const [datasetProjectionResult, setDatasetProjectionResult] = useState(null)
+
+ const [saving, setSaving] = useState(false)
+ const [validationError, setValidationError] = useState(null)
+ const [saveError, setSaveError] = useState(null)
+
+ useEffect(() => {
+ let cancelled = false
+
+ const load = async (): Promise => {
+ try {
+ const scenarios = await fetchAllPages(
+ (cursor) => scenariosApi.listCatalog(CATALOG_PAGE_SIZE, cursor, false),
+ undefined,
+ (entry) => entry.scenario_name,
+ )
+ if (cancelled) return
+ setCatalog(scenarios)
+
+ if (mode === 'create') {
+ setStatus('ready')
+ return
+ }
+
+ const response = await scenarioPresetsApi.get(decodedName)
+ if (cancelled) return
+ setLoaded(response)
+ setName(response.preset.name)
+ setDescription(response.preset.description ?? '')
+ setStatus('ready')
+
+ // The list endpoint omits run-size estimates, so the preset's scenario is
+ // re-fetched in full to get the configured dataset caps the form shows.
+ // A preset may legitimately name a scenario this deployment lacks; that
+ // is an unavailable scenario, not a missing preset, so it must not be
+ // reported as a 404 on the preset itself.
+ try {
+ const full = await scenariosApi.getScenario(response.preset.scenario_name)
+ if (cancelled) return
+ setScenario(full)
+ setConfig(presetToConfigState(full, response.preset))
+ } catch (scenarioErr) {
+ if (cancelled) return
+ setScenarioUnavailable(toApiError(scenarioErr).detail)
+ }
+ } catch (err) {
+ if (cancelled) return
+ const apiError = toApiError(err)
+ setLoadError(apiError.detail)
+ setStatus(apiError.status === 404 ? 'not-found' : 'error')
+ }
+ }
+
+ void load()
+ return () => {
+ cancelled = true
+ }
+ }, [decodedName, mode])
+
+ const handleScenarioChange = useCallback(async (nextScenarioName: string): Promise => {
+ setScenarioLoading(true)
+ setSaveError(null)
+ setValidationError(null)
+ setDatasetProjection(null)
+ setDatasetProjectionResult(null)
+ try {
+ const full = await scenariosApi.getScenario(nextScenarioName)
+ setScenario(full)
+ setConfig(initialScenarioConfigState(full))
+ } catch (err) {
+ setSaveError(toApiError(err).detail)
+ } finally {
+ setScenarioLoading(false)
+ }
+ }, [])
+
+ const datasetMetadata = useScenarioDatasets()
+ const datasetBaseline = useMemo(
+ () => (scenario
+ ? {
+ defaults: datasetProjection?.dataset_defaults ?? scenarioDatasetDefaults(scenario),
+ options: datasetProjection?.dataset_options ?? scenarioDatasetOptions(scenario),
+ }
+ : null),
+ [datasetProjection, scenario],
+ )
+
+ const dynamicParameters = useMemo(
+ () => (scenario ? dynamicScenarioParameters(scenario) : []),
+ [scenario],
+ )
+ const techniqueOptions = useMemo(
+ () => (scenario ? uniqueTechniqueOptions(scenario).techniques : []),
+ [scenario],
+ )
+ const carriedScenarioParams = useMemo(
+ () => (scenario ? Object.keys(uneditableScenarioParams(scenario, loaded?.preset ?? null)) : []),
+ [loaded, scenario],
+ )
+ const techniqueIssues = loaded?.issues.filter((issue) => issue.field === 'techniques') ?? []
+ const builtConfig = useMemo(
+ () => (config ? buildScenarioConfig({
+ techniques: config.techniques,
+ dynamicParameters,
+ scenarioParamValues: config.scenarioParamValues,
+ datasets: config.datasets,
+ includeBaseline: config.includeBaseline,
+ }) : null),
+ [config, dynamicParameters],
+ )
+ const scenarioName = scenario?.scenario_name
+ const projectionRequest = !scenarioLoading && builtConfig?.ok ? builtConfig.config : null
+ const projectionIsCurrent = datasetProjectionResult?.request === projectionRequest
+ && datasetProjectionResult?.scenarioName === scenarioName
+ const datasetProjectionLoading = Boolean(scenarioName && projectionRequest && !projectionIsCurrent)
+ const datasetProjectionError = projectionIsCurrent ? datasetProjectionResult?.error : null
+
+ useEffect(() => {
+ if (!scenarioName || !projectionRequest) return
+
+ const controller = new AbortController()
+ const timer = window.setTimeout(() => {
+ void scenariosApi.estimateRun(scenarioName, projectionRequest, controller.signal)
+ .then((response) => {
+ if (controller.signal.aborted) return
+ setDatasetProjection(response)
+ setDatasetProjectionResult({ request: projectionRequest, scenarioName, error: null })
+ })
+ .catch((err: unknown) => {
+ if (controller.signal.aborted) return
+ setDatasetProjectionResult({ request: projectionRequest, scenarioName, error: toApiError(err).detail })
+ })
+ }, DATASET_PROJECTION_DEBOUNCE_MS)
+
+ return () => {
+ window.clearTimeout(timer)
+ controller.abort()
+ }
+ }, [projectionRequest, scenarioName])
+
+ const updateConfig = (patch: Partial): void => {
+ setConfig((current) => (current ? { ...current, ...patch } : current))
+ setValidationError(null)
+ }
+
+ const updateScenarioParam = useCallback((parameterName: string, value: ParameterFormValue) => {
+ setConfig((current) => (current
+ ? { ...current, scenarioParamValues: { ...current.scenarioParamValues, [parameterName]: value } }
+ : current))
+ setValidationError(null)
+ }, [])
+
+ const handleSubmit = async (event: FormEvent): Promise => {
+ event.preventDefault()
+ if (saving) {
+ return
+ }
+ setSaveError(null)
+
+ const trimmedName = name.trim()
+ const nameError = validatePresetName(trimmedName)
+ if (nameError) {
+ setValidationError(nameError)
+ return
+ }
+ if (!scenario || !config) {
+ setValidationError('Select a scenario.')
+ return
+ }
+ if (!builtConfig?.ok) {
+ setValidationError(builtConfig?.error ?? 'Select a scenario.')
+ return
+ }
+ if (datasetError) {
+ setValidationError(datasetError)
+ return
+ }
+ setValidationError(null)
+
+ const preset = configToPreset(
+ { name: trimmedName, scenarioName: scenario.scenario_name, description },
+ builtConfig.config,
+ { scenario, previous: loaded?.preset ?? null },
+ )
+
+ setSaving(true)
+ try {
+ if (loaded === null) {
+ await scenarioPresetsApi.create(preset)
+ } else if (loaded.version === null) {
+ setSaveError(`Preset '${loaded.preset.name}' is defined in code and cannot be edited here.`)
+ setSaving(false)
+ return
+ } else {
+ await scenarioPresetsApi.update(loaded.preset.name, preset, loaded.version)
+ }
+ navigate(PRESETS_ROUTE)
+ } catch (err) {
+ setSaveError(toApiError(err).detail)
+ setSaving(false)
+ }
+ }
+
+ if (status === 'loading') {
+ return (
+
+ )
+ }
+
+ if (status !== 'ready') {
+ return (
+
+
+
+
+ {status === 'not-found' ? `No preset named "${decodedName}".` : loadError}
+
+
+
+
+
+ )
+ }
+
+ const editing = loaded !== null
+
+ return (
+
+
+
+ {editing ? `Edit ${decodedName}` : 'New preset'}
+
+
+ A preset stores what to test. The target, concurrency and retries are chosen at launch.
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/ScenarioPresets/ScenarioPresetLibrary.styles.ts b/frontend/src/components/ScenarioPresets/ScenarioPresetLibrary.styles.ts
new file mode 100644
index 0000000000..a1adee152e
--- /dev/null
+++ b/frontend/src/components/ScenarioPresets/ScenarioPresetLibrary.styles.ts
@@ -0,0 +1,104 @@
+import { makeStyles, tokens } from '@fluentui/react-components'
+
+import { mobileTouchTarget, NARROW_VIEWPORT_QUERY } from '@/styles/touchTargets'
+import { WORKSPACE_CANVAS_BACKGROUND } from '@/styles/workspaceBackground'
+
+export const useScenarioPresetLibraryStyles = makeStyles({
+ root: {
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100%',
+ width: '100%',
+ minWidth: 0,
+ padding: tokens.spacingVerticalXXL,
+ overflowX: 'hidden',
+ overflowY: 'auto',
+ backgroundColor: WORKSPACE_CANVAS_BACKGROUND,
+ [NARROW_VIEWPORT_QUERY]: {
+ padding: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalM}`,
+ },
+ },
+ header: {
+ display: 'flex',
+ alignItems: 'flex-start',
+ justifyContent: 'space-between',
+ flexWrap: 'wrap',
+ gap: tokens.spacingVerticalL,
+ marginBottom: tokens.spacingVerticalL,
+ [NARROW_VIEWPORT_QUERY]: {
+ flexDirection: 'column',
+ alignItems: 'stretch',
+ },
+ },
+ headerText: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalXS,
+ },
+ subtitle: {
+ color: tokens.colorNeutralForeground3,
+ },
+ headerActions: {
+ display: 'flex',
+ flexWrap: 'wrap',
+ gap: tokens.spacingHorizontalS,
+ alignItems: 'center',
+ },
+ touchTarget: {
+ ...mobileTouchTarget,
+ },
+ sourceLine: {
+ color: tokens.colorNeutralForeground3,
+ marginBottom: tokens.spacingVerticalM,
+ wordBreak: 'break-all',
+ },
+ centeredState: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ gap: tokens.spacingVerticalM,
+ padding: tokens.spacingVerticalXXL,
+ textAlign: 'center',
+ },
+ tableContainer: {
+ width: '100%',
+ overflowX: 'auto',
+ },
+ table: {
+ minWidth: '900px',
+ backgroundColor: tokens.colorNeutralBackground1,
+ },
+ nameCell: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalXXS,
+ minWidth: 0,
+ },
+ cardTitleRow: {
+ display: 'flex',
+ alignItems: 'center',
+ flexWrap: 'wrap',
+ gap: tokens.spacingHorizontalS,
+ },
+ cardMeta: {
+ color: tokens.colorNeutralForeground3,
+ },
+ cardActions: {
+ display: 'flex',
+ flexWrap: 'wrap',
+ gap: tokens.spacingHorizontalXS,
+ alignItems: 'center',
+ },
+ issueList: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalXXS,
+ margin: 0,
+ paddingLeft: tokens.spacingHorizontalL,
+ },
+ dialogBody: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalM,
+ },
+})
diff --git a/frontend/src/components/ScenarioPresets/ScenarioPresetLibrary.test.tsx b/frontend/src/components/ScenarioPresets/ScenarioPresetLibrary.test.tsx
new file mode 100644
index 0000000000..eb03cb7938
--- /dev/null
+++ b/frontend/src/components/ScenarioPresets/ScenarioPresetLibrary.test.tsx
@@ -0,0 +1,399 @@
+import { act, render, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { FluentProvider, webLightTheme } from '@fluentui/react-components'
+import { MemoryRouter } from 'react-router'
+
+import { scenarioPresetsApi, scenariosApi } from '@/services/api'
+import type {
+ PresetIssue,
+ ScenarioPreset,
+ ScenarioPresetResponse,
+ ScenarioRunSizeEstimateResponse,
+} from '@/types'
+
+import ScenarioPresetLibrary from './ScenarioPresetLibrary'
+
+jest.mock('@/services/api', () => ({
+ scenarioPresetsApi: {
+ list: jest.fn(),
+ remove: jest.fn(),
+ resolve: jest.fn(),
+ },
+ scenariosApi: {
+ startRun: jest.fn(),
+ estimateRun: jest.fn(),
+ },
+}))
+
+const mockList = scenarioPresetsApi.list as jest.Mock
+const mockRemove = scenarioPresetsApi.remove as jest.Mock
+const mockEstimateRun = scenariosApi.estimateRun as jest.Mock
+
+const mockNavigate = jest.fn()
+
+jest.mock('react-router', () => ({
+ ...jest.requireActual('react-router'),
+ useNavigate: () => mockNavigate,
+}))
+
+const NIGHTLY_PRESET: ScenarioPreset = {
+ name: 'nightly_probe',
+ scenario_name: 'foundry.red_team_agent',
+ techniques: ['crescendo'],
+}
+
+function makeItem(
+ preset: ScenarioPreset = NIGHTLY_PRESET,
+ issues: PresetIssue[] = [],
+ runSize: ScenarioRunSizeEstimateResponse | null = null,
+): ScenarioPresetResponse {
+ return { preset, origin: 'stored', version: 'v1', issues, run_size: runSize }
+}
+
+function makeRegisteredItem(preset: ScenarioPreset = NIGHTLY_PRESET): ScenarioPresetResponse {
+ return { preset, origin: 'registered', version: null, issues: [], run_size: null }
+}
+
+function makeRunSize(attacks: number): ScenarioRunSizeEstimateResponse {
+ return {
+ status: 'exact',
+ dataset_size: { kind: 'bounded', value: attacks },
+ dataset_limit: { state: 'value', value: attacks },
+ estimated_attack_count: attacks,
+ components: [{ label: 'Crescendo', count: attacks, is_baseline: false, note: null }],
+ datasets: [],
+ note: null,
+ }
+}
+
+function renderLibrary() {
+ return render(
+
+
+
+
+ ,
+ )
+}
+
+beforeEach(() => {
+ jest.clearAllMocks()
+ mockList.mockResolvedValue({ source: 'presets.yaml', items: [] })
+ mockEstimateRun.mockResolvedValue(makeRunSize(12))
+})
+
+describe('ScenarioPresetLibrary', () => {
+ it('shows an empty state and where presets are stored', async () => {
+ renderLibrary()
+
+ expect(await screen.findByTestId('empty-state')).toBeInTheDocument()
+ expect(screen.getByText('Stored in presets.yaml')).toBeInTheDocument()
+ })
+
+ it('lists each saved preset with its scenario and technique count', async () => {
+ mockList.mockResolvedValue({
+ source: 'presets.yaml',
+ items: [
+ makeItem(),
+ makeItem({ name: 'weekly_probe', scenario_name: 'foundry.encoding', techniques: ['base64'] }),
+ ],
+ })
+
+ renderLibrary()
+
+ const row = await screen.findByTestId('preset-row-nightly_probe')
+ expect(within(row).getByText('foundry.red_team_agent')).toBeInTheDocument()
+ expect(within(row).getByText('1 technique')).toBeInTheDocument()
+ expect(screen.getByTestId('preset-row-weekly_probe')).toBeInTheDocument()
+ })
+
+ it('reports scenario default techniques when the preset pins none', async () => {
+ mockList.mockResolvedValue({
+ source: 'presets.yaml',
+ items: [makeItem({ name: 'nightly_probe', scenario_name: 'foundry.red_team_agent' })],
+ })
+
+ renderLibrary()
+
+ expect(await screen.findByText('Scenario default techniques')).toBeInTheDocument()
+ })
+
+ it('shows the run size the preset itself produces', async () => {
+ mockList.mockResolvedValue({
+ source: 'presets.yaml',
+ items: [makeItem()],
+ })
+ mockEstimateRun.mockResolvedValue(makeRunSize(24))
+
+ renderLibrary()
+
+ const cell = await screen.findByTestId('preset-run-size-nightly_probe')
+ await waitFor(() => expect(within(cell).getByText('24 attacks')).toBeInTheDocument())
+ })
+
+ it('paints the table before estimates finish without re-reading preset storage', async () => {
+ let complete: (value: ScenarioRunSizeEstimateResponse) => void = () => {}
+ mockList.mockResolvedValue({
+ source: 'presets.yaml',
+ items: [makeItem()],
+ })
+ mockEstimateRun.mockReturnValueOnce(new Promise((resolve) => {
+ complete = resolve
+ }))
+
+ renderLibrary()
+
+ await screen.findByTestId('preset-row-nightly_probe')
+ expect(screen.getByText('Calculating...')).toBeInTheDocument()
+ expect(mockList).toHaveBeenCalledTimes(1)
+ expect(mockList.mock.calls[0][1]).toBe(false)
+
+ await act(async () => { complete(makeRunSize(24)) })
+
+ const cell = screen.getByTestId('preset-run-size-nightly_probe')
+ expect(await within(cell).findByText('24 attacks')).toBeInTheDocument()
+ expect(mockList).toHaveBeenCalledTimes(1)
+ })
+
+ it('leaves the run size blank for a preset this deployment cannot size', async () => {
+ mockList.mockResolvedValue({ source: 'presets.yaml', items: [makeItem()] })
+ mockEstimateRun.mockRejectedValueOnce(new Error('Size unavailable.'))
+
+ renderLibrary()
+
+ const cell = await screen.findByTestId('preset-run-size-nightly_probe')
+ await waitFor(() => expect(within(cell).getByText('—')).toBeInTheDocument())
+ })
+
+ it('keeps the table usable when run sizes cannot be calculated', async () => {
+ mockList.mockResolvedValueOnce({ source: 'presets.yaml', items: [makeItem()] })
+ mockEstimateRun.mockRejectedValueOnce(new Error('sizing unavailable'))
+
+ renderLibrary()
+
+ expect(await screen.findByTestId('preset-row-nightly_probe')).toBeInTheDocument()
+ expect(
+ await screen.findByText('Run sizes could not be calculated: nightly_probe: sizing unavailable'),
+ ).toBeInTheDocument()
+ })
+
+ it('estimates the displayed configuration without re-resolving its stored version', async () => {
+ const preset: ScenarioPreset = {
+ ...NIGHTLY_PRESET,
+ datasets: { sources: [{ name: 'harmbench' }], max_per_dataset: 3, max_total: 12 },
+ include_baseline: false,
+ scenario_params: { max_turns: 4 },
+ }
+ mockList.mockResolvedValue({ source: 'presets.yaml', items: [makeItem(preset)] })
+
+ renderLibrary()
+
+ await waitFor(() => expect(mockEstimateRun).toHaveBeenCalledWith(
+ preset.scenario_name,
+ {
+ techniques: preset.techniques,
+ datasets: preset.datasets,
+ include_baseline: false,
+ scenario_params: { max_turns: 4 },
+ },
+ expect.any(AbortSignal),
+ ))
+ expect(mockList).toHaveBeenCalledTimes(1)
+ })
+
+ it('keeps successful estimates when another preset cannot be sized', async () => {
+ mockList.mockResolvedValue({
+ source: 'presets.yaml',
+ items: [
+ makeItem(),
+ makeItem({ name: 'broken_probe', scenario_name: 'foundry.encoding' }),
+ ],
+ })
+ mockEstimateRun
+ .mockResolvedValueOnce(makeRunSize(7))
+ .mockRejectedValueOnce(new Error('Invalid configuration.'))
+
+ renderLibrary()
+
+ const good = await screen.findByTestId('preset-run-size-nightly_probe')
+ expect(await within(good).findByText('7 attacks')).toBeInTheDocument()
+ expect(within(screen.getByTestId('preset-run-size-broken_probe')).getByText('—')).toBeInTheDocument()
+ expect(screen.getByText(
+ 'Run sizes could not be calculated: broken_probe: Invalid configuration.',
+ )).toBeInTheDocument()
+ expect(mockList).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not replace a refreshed definition with an older pending estimate', async () => {
+ const user = userEvent.setup()
+ let complete: (value: ScenarioRunSizeEstimateResponse) => void = () => {}
+ const updated = { ...NIGHTLY_PRESET, description: 'Updated configuration.' }
+ mockList
+ .mockResolvedValueOnce({ source: 'presets.yaml', items: [makeItem()] })
+ .mockResolvedValueOnce({
+ source: 'presets.yaml',
+ items: [{ ...makeItem(updated), version: 'v2' }],
+ })
+ mockEstimateRun
+ .mockReturnValueOnce(new Promise((resolve) => { complete = resolve }))
+ .mockResolvedValue(makeRunSize(24))
+
+ renderLibrary()
+
+ await waitFor(() => expect(mockEstimateRun).toHaveBeenCalledTimes(1))
+ const signal: AbortSignal = mockEstimateRun.mock.calls[0][2]
+ await user.click(screen.getByRole('button', { name: 'Refresh' }))
+ const cell = await screen.findByTestId('preset-run-size-nightly_probe')
+ expect(await within(cell).findByText('24 attacks')).toBeInTheDocument()
+ expect(signal.aborted).toBe(true)
+
+ await act(async () => { complete(makeRunSize(12)) })
+
+ expect(screen.getByText('Updated configuration.')).toBeInTheDocument()
+ expect(within(cell).getByText('24 attacks')).toBeInTheDocument()
+ expect(within(cell).queryByText('12 attacks')).not.toBeInTheDocument()
+ expect(mockList).toHaveBeenCalledTimes(2)
+ })
+
+ it('attributes a preset to its author and leaves the column blank without one', async () => {
+ mockList.mockResolvedValue({
+ source: 'presets.yaml',
+ items: [
+ makeItem({ ...NIGHTLY_PRESET, author: 'Ada Lovelace' }),
+ makeItem({ name: 'weekly_probe', scenario_name: 'foundry.encoding' }),
+ ],
+ })
+
+ renderLibrary()
+
+ const authored = await screen.findByTestId('preset-author-nightly_probe')
+ expect(within(authored).getByText('Ada Lovelace')).toBeInTheDocument()
+ expect(within(screen.getByTestId('preset-author-weekly_probe')).getByText('—')).toBeInTheDocument()
+ })
+
+ it('disables launch for an unresolvable preset and names the missing reference', async () => {
+ mockList.mockResolvedValue({
+ source: 'presets.yaml',
+ items: [makeItem(NIGHTLY_PRESET, [
+ { field: 'scenario_name', message: 'Unknown scenario "foundry.retired".' },
+ ])],
+ })
+
+ renderLibrary()
+
+ expect(await screen.findByTestId('launch-preset-nightly_probe')).toBeDisabled()
+ expect(screen.getByText('Not runnable here')).toBeInTheDocument()
+ expect(screen.getByText('Unknown scenario "foundry.retired".')).toBeInTheDocument()
+ expect(mockEstimateRun).not.toHaveBeenCalled()
+ })
+
+ it('keeps launch enabled when the server reports no issues', async () => {
+ mockList.mockResolvedValue({ source: 'presets.yaml', items: [makeItem()] })
+
+ renderLibrary()
+
+ expect(await screen.findByTestId('launch-preset-nightly_probe')).toBeEnabled()
+ expect(screen.queryByTestId('preset-issues-nightly_probe')).not.toBeInTheDocument()
+ })
+
+ it('offers no edit or delete for a preset defined in code, but still allows launch', async () => {
+ mockList.mockResolvedValue({ source: 'presets.yaml', items: [makeRegisteredItem()] })
+
+ renderLibrary()
+
+ expect(await screen.findByTestId('edit-preset-nightly_probe')).toBeDisabled()
+ expect(screen.getByTestId('delete-preset-nightly_probe')).toBeDisabled()
+ expect(screen.getByTestId('launch-preset-nightly_probe')).toBeEnabled()
+ })
+
+ it('surfaces a load failure with a retry that refetches', async () => {
+ const user = userEvent.setup()
+ mockList.mockRejectedValueOnce(new Error('storage unavailable'))
+
+ renderLibrary()
+
+ expect(await screen.findByTestId('error-state')).toBeInTheDocument()
+ expect(screen.getByText('storage unavailable')).toBeInTheDocument()
+
+ mockList.mockResolvedValue({ source: 'presets.yaml', items: [makeItem()] })
+ await user.click(screen.getByTestId('retry-btn'))
+
+ expect(await screen.findByTestId('preset-row-nightly_probe')).toBeInTheDocument()
+ })
+
+ it('requires confirmation before deleting and refreshes afterwards', async () => {
+ const user = userEvent.setup()
+ mockList.mockResolvedValue({ source: 'presets.yaml', items: [makeItem()] })
+ mockRemove.mockResolvedValue(undefined)
+
+ renderLibrary()
+
+ await user.click(await screen.findByTestId('delete-preset-nightly_probe'))
+ expect(mockRemove).not.toHaveBeenCalled()
+
+ mockList.mockResolvedValue({ source: 'presets.yaml', items: [] })
+ const dialog = await screen.findByRole('dialog')
+ await user.click(within(dialog).getByRole('button', { name: 'Delete' }))
+
+ await waitFor(() => expect(mockRemove).toHaveBeenCalledWith('nightly_probe'))
+ expect(await screen.findByTestId('empty-state')).toBeInTheDocument()
+ })
+
+ it('cancels a delete without calling the server', async () => {
+ const user = userEvent.setup()
+ mockList.mockResolvedValue({ source: 'presets.yaml', items: [makeItem()] })
+
+ renderLibrary()
+
+ await user.click(await screen.findByTestId('delete-preset-nightly_probe'))
+ const dialog = await screen.findByRole('dialog')
+ await user.click(within(dialog).getByRole('button', { name: 'Cancel' }))
+
+ expect(mockRemove).not.toHaveBeenCalled()
+ expect(screen.getByTestId('preset-row-nightly_probe')).toBeInTheDocument()
+ })
+
+ it('reports a delete failure without removing the card', async () => {
+ const user = userEvent.setup()
+ mockList.mockResolvedValue({ source: 'presets.yaml', items: [makeItem()] })
+ mockRemove.mockRejectedValue(new Error('Admin access required.'))
+
+ renderLibrary()
+
+ await user.click(await screen.findByTestId('delete-preset-nightly_probe'))
+ const dialog = await screen.findByRole('dialog')
+ await user.click(within(dialog).getByRole('button', { name: 'Delete' }))
+
+ expect(await screen.findByText('Admin access required.')).toBeInTheDocument()
+ expect(screen.getByTestId('preset-row-nightly_probe')).toBeInTheDocument()
+ })
+
+ it('navigates to the editor for create and edit', async () => {
+ const user = userEvent.setup()
+ mockList.mockResolvedValue({ source: 'presets.yaml', items: [makeItem()] })
+
+ renderLibrary()
+
+ await user.click(await screen.findByTestId('edit-preset-nightly_probe'))
+ expect(mockNavigate).toHaveBeenCalledWith('/registry/scenario-presets/nightly_probe/edit')
+
+ await user.click(screen.getByTestId('new-preset-btn'))
+ expect(mockNavigate).toHaveBeenCalledWith('/registry/scenario-presets/new')
+ })
+
+ it('opens the launch dialog only for a runnable preset', async () => {
+ const user = userEvent.setup()
+ mockList.mockResolvedValue({ source: 'presets.yaml', items: [makeItem()] })
+
+ renderLibrary()
+
+ await user.click(await screen.findByTestId('launch-preset-nightly_probe'))
+
+ expect(await screen.findByTestId('confirm-launch-preset')).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/components/ScenarioPresets/ScenarioPresetLibrary.tsx b/frontend/src/components/ScenarioPresets/ScenarioPresetLibrary.tsx
new file mode 100644
index 0000000000..41c83cd23d
--- /dev/null
+++ b/frontend/src/components/ScenarioPresets/ScenarioPresetLibrary.tsx
@@ -0,0 +1,435 @@
+import { useCallback, useEffect, useState } from 'react'
+
+import {
+ Badge,
+ Button,
+ Dialog,
+ DialogActions,
+ DialogBody,
+ DialogContent,
+ DialogSurface,
+ DialogTitle,
+ MessageBar,
+ MessageBarBody,
+ Spinner,
+ Table,
+ TableBody,
+ TableCell,
+ TableHeader,
+ TableHeaderCell,
+ TableRow,
+ Text,
+} from '@fluentui/react-components'
+import {
+ AddRegular,
+ ArrowSyncRegular,
+ DeleteRegular,
+ EditRegular,
+ PlayRegular,
+} from '@fluentui/react-icons'
+import { Link, useNavigate } from 'react-router'
+
+import { ScenarioRunEstimateSummary } from '@/components/Scenarios/ScenarioRunEstimate'
+import { mapScenarioRunEstimate } from '@/components/Scenarios/scenarioRunEstimateAdapter'
+import { scenarioPresetsApi, scenariosApi } from '@/services/api'
+import { toApiError } from '@/services/errors'
+import type { ScenarioPresetResponse, TargetInstance } from '@/types'
+
+import LaunchPresetDialog from './LaunchPresetDialog'
+import { useScenarioPresetLibraryStyles } from './ScenarioPresetLibrary.styles'
+import { NEW_PRESET_ROUTE, presetEditorRoutePath } from './presetRoutes'
+
+const DEFINED_IN_CODE_HINT = 'Defined in code, so it cannot be edited or deleted here.'
+
+interface ScenarioPresetLibraryProps {
+ targets: TargetInstance[]
+ defaultObjectiveTarget: TargetInstance | null
+ defaultAdversarialTarget: TargetInstance | null
+ labels: Record
+}
+
+function techniqueSummary(item: ScenarioPresetResponse): string {
+ const { techniques } = item.preset
+ if (!techniques || techniques.length === 0) {
+ return 'Scenario default techniques'
+ }
+ return `${techniques.length} technique${techniques.length === 1 ? '' : 's'}`
+}
+
+/**
+ * Lists the saved scenario presets. Presets that reference something this
+ * deployment does not have are still listed — the server reports the problems
+ * as advisory issues — but cannot be launched here.
+ */
+export default function ScenarioPresetLibrary({
+ targets,
+ defaultObjectiveTarget,
+ defaultAdversarialTarget,
+ labels,
+}: ScenarioPresetLibraryProps) {
+ const styles = useScenarioPresetLibraryStyles()
+ const navigate = useNavigate()
+ const [items, setItems] = useState([])
+ const [source, setSource] = useState('')
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [actionError, setActionError] = useState(null)
+ const [estimatesLoading, setEstimatesLoading] = useState(true)
+ const [estimateError, setEstimateError] = useState(null)
+ const [launching, setLaunching] = useState(null)
+ const [pendingDelete, setPendingDelete] = useState(null)
+ const [deleting, setDeleting] = useState(false)
+ const [generation, setGeneration] = useState(0)
+
+ useEffect(() => {
+ const controller = new AbortController()
+ let cancelled = false
+
+ // Estimate the displayed definitions, without re-reading storage or changing
+ // the versions that the operator will confirm at launch.
+ const loadPresets = async (): Promise => {
+ try {
+ const response = await scenarioPresetsApi.list(controller.signal, false)
+ if (cancelled) return
+ setItems(response.items)
+ setSource(response.source)
+ setError(null)
+ setLoading(false)
+
+ if (response.items.length === 0) {
+ setEstimatesLoading(false)
+ return
+ }
+
+ try {
+ const estimates = await Promise.allSettled(response.items.map((item) => (
+ item.issues.length > 0 ? Promise.resolve(null) : scenariosApi.estimateRun(
+ item.preset.scenario_name,
+ {
+ techniques: item.preset.techniques,
+ datasets: item.preset.datasets ?? undefined,
+ include_baseline: item.preset.include_baseline,
+ scenario_params: item.preset.scenario_params,
+ },
+ controller.signal,
+ )
+ )))
+ if (cancelled) return
+ const failures: string[] = []
+ setItems(response.items.map((item, index) => {
+ const estimate = estimates[index]
+ if (estimate.status === 'rejected') {
+ failures.push(`${item.preset.name}: ${toApiError(estimate.reason).detail}`)
+ return { ...item, run_size: null }
+ }
+ return { ...item, run_size: estimate.value }
+ }))
+ setEstimateError(failures.length > 0 ? failures.join('; ') : null)
+ } catch (err: unknown) {
+ if (cancelled || controller.signal.aborted) return
+ setEstimateError(toApiError(err).detail)
+ } finally {
+ if (!cancelled) {
+ setEstimatesLoading(false)
+ }
+ }
+ } catch (err: unknown) {
+ if (cancelled || controller.signal.aborted) return
+ setError(toApiError(err).detail)
+ setLoading(false)
+ setEstimatesLoading(false)
+ }
+ }
+
+ void loadPresets()
+
+ return () => {
+ cancelled = true
+ controller.abort()
+ }
+ }, [generation])
+
+ const refresh = useCallback(() => {
+ setLoading(true)
+ setEstimatesLoading(true)
+ setError(null)
+ setEstimateError(null)
+ setGeneration((current) => current + 1)
+ }, [])
+
+ const handleDelete = async (): Promise => {
+ if (!pendingDelete) {
+ return
+ }
+ setDeleting(true)
+ setActionError(null)
+ try {
+ await scenarioPresetsApi.remove(pendingDelete.preset.name)
+ setPendingDelete(null)
+ refresh()
+ } catch (err) {
+ setActionError(toApiError(err).detail)
+ } finally {
+ setDeleting(false)
+ }
+ }
+
+ return (
+
+
+
+
+ Scenario presets
+
+
+ Reusable scenario configurations. A preset pins what to test; you choose the target at
+ launch.
+
+
+
+ }
+ onClick={() => navigate(NEW_PRESET_ROUTE)}
+ data-testid="new-preset-btn"
+ >
+ New preset
+
+ }
+ onClick={refresh}
+ disabled={loading}
+ >
+ Refresh
+
+
+
+
+ {source !== '' && (
+
+ Stored in {source}
+
+ )}
+
+ {actionError && (
+
+ {actionError}
+
+ )}
+
+ {estimateError && (
+
+ Run sizes could not be calculated: {estimateError}
+
+ )}
+
+ {loading ? (
+
+
+
+ ) : error ? (
+
+
+ {error}
+
+ }
+ onClick={refresh}
+ data-testid="retry-btn"
+ >
+ Retry
+
+
+ ) : items.length === 0 ? (
+
+ No presets saved yet
+
+ Create one to reuse a scenario configuration across targets and runs.
+
+
+ ) : (
+
+
+
+
+ Name
+ Scenario
+ Run size
+ Author
+ Actions
+
+
+
+ {items.map((item) => {
+ const runnable = item.issues.length === 0
+ const editable = item.origin === 'stored'
+ const estimateState = item.run_size
+ ? mapScenarioRunEstimate(item.run_size, 'request')
+ : null
+ return (
+
+
+
+
+ {item.preset.name}
+
+ {!runnable && (
+
+ Not runnable here
+
+ )}
+
+ {item.preset.description && (
+
+ {item.preset.description}
+
+ )}
+ {!runnable && (
+
+ {item.issues.map((issue) => (
+ -
+ {issue.message}
+
+ ))}
+
+ )}
+
+
+ {item.preset.scenario_name}
+
+ {techniqueSummary(item)}
+
+
+
+ {estimatesLoading ? (
+
+ ) : estimateState ? (
+
+ ) : (
+ —
+ )}
+
+
+ {item.preset.author
+ ? {item.preset.author}
+ : —}
+
+
+
+ }
+ disabled={!runnable}
+ onClick={() => setLaunching(item)}
+ data-testid={`launch-preset-${item.preset.name}`}
+ >
+ Launch
+
+ }
+ disabled={!editable}
+ title={editable ? undefined : DEFINED_IN_CODE_HINT}
+ onClick={() => navigate(presetEditorRoutePath(item.preset.name))}
+ data-testid={`edit-preset-${item.preset.name}`}
+ >
+ Edit
+
+ }
+ disabled={!editable}
+ title={editable ? undefined : DEFINED_IN_CODE_HINT}
+ onClick={() => {
+ setActionError(null)
+ setPendingDelete(item)
+ }}
+ data-testid={`delete-preset-${item.preset.name}`}
+ >
+ Delete
+
+
+
+
+ )
+ })}
+
+
+
+ )}
+
+ {launching && (
+ setLaunching(null)}
+ onPresetChanged={() => {
+ const changedName = launching.preset.name
+ setLaunching(null)
+ refresh()
+ setActionError(
+ `Preset '${changedName}' changed while you were launching it. Review it and launch again.`,
+ )
+ }}
+ />
+ )}
+
+ {pendingDelete && (
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/ScenarioPresets/presetRoutes.test.ts b/frontend/src/components/ScenarioPresets/presetRoutes.test.ts
new file mode 100644
index 0000000000..c6f40146e9
--- /dev/null
+++ b/frontend/src/components/ScenarioPresets/presetRoutes.test.ts
@@ -0,0 +1,26 @@
+import { LEGACY_PRESETS_ROUTE, NEW_PRESET_ROUTE, PRESETS_ROUTE, presetEditorRoutePath } from './presetRoutes'
+
+describe('presetEditorRoutePath', () => {
+ it('builds an edit route under the preset library', () => {
+ expect(presetEditorRoutePath('nightly_probe')).toBe('/registry/scenario-presets/nightly_probe/edit')
+ })
+
+ it('escapes a name so it stays inside one path segment', () => {
+ expect(presetEditorRoutePath('a/b')).toBe('/registry/scenario-presets/a%2Fb/edit')
+ })
+
+ it('does not collide with the create route for a preset named "new"', () => {
+ expect(presetEditorRoutePath('new')).not.toBe(NEW_PRESET_ROUTE)
+ expect(presetEditorRoutePath('new').startsWith(`${NEW_PRESET_ROUTE}/`)).toBe(true)
+ })
+
+ it('nests both routes under the registry so the registry nav item stays selected', () => {
+ expect(PRESETS_ROUTE.startsWith('/registry/')).toBe(true)
+ expect(NEW_PRESET_ROUTE.startsWith('/registry/')).toBe(true)
+ })
+
+ it('keeps the old scanner location distinct so it can redirect', () => {
+ expect(LEGACY_PRESETS_ROUTE).toBe('/scanner/presets')
+ expect(LEGACY_PRESETS_ROUTE).not.toBe(PRESETS_ROUTE)
+ })
+})
diff --git a/frontend/src/components/ScenarioPresets/presetRoutes.ts b/frontend/src/components/ScenarioPresets/presetRoutes.ts
new file mode 100644
index 0000000000..e9df5dfaa2
--- /dev/null
+++ b/frontend/src/components/ScenarioPresets/presetRoutes.ts
@@ -0,0 +1,19 @@
+/**
+ * Presets live under the registry because a preset is a stored, reusable
+ * configuration alongside targets and converters, not a property of one scan.
+ *
+ * Editing uses a trailing `/edit` segment rather than bare
+ * `/registry/scenario-presets/:presetName` so the static create route cannot
+ * shadow a preset whose name happens to be `new` — preset names are
+ * user-authored and `new` matches the server's name pattern.
+ */
+
+export const PRESETS_ROUTE = '/registry/scenario-presets'
+export const NEW_PRESET_ROUTE = `${PRESETS_ROUTE}/new`
+
+/** Where presets used to live. Kept as a redirect so bookmarked links still resolve. */
+export const LEGACY_PRESETS_ROUTE = '/scanner/presets'
+
+export function presetEditorRoutePath(name: string): string {
+ return `${PRESETS_ROUTE}/${encodeURIComponent(name)}/edit`
+}
diff --git a/frontend/src/components/ScenarioPresets/scenarioPresetForm.test.ts b/frontend/src/components/ScenarioPresets/scenarioPresetForm.test.ts
new file mode 100644
index 0000000000..1df714ce80
--- /dev/null
+++ b/frontend/src/components/ScenarioPresets/scenarioPresetForm.test.ts
@@ -0,0 +1,433 @@
+import {
+ buildScenarioConfig,
+ initialScenarioConfigState,
+} from '@/components/Scenarios/scenarioConfigForm'
+import { makeScenario } from '@/test-utils/scenarioFixtures'
+import type { Parameter, ScenarioPreset } from '@/types'
+
+import {
+ configToPreset,
+ presetToConfigState,
+ uneditableScenarioParams,
+ validatePresetName,
+} from './scenarioPresetForm'
+
+function makePreset(overrides: Partial = {}): ScenarioPreset {
+ return {
+ name: 'nightly_probe',
+ scenario_name: 'foundry.red_team_agent',
+ ...overrides,
+ }
+}
+
+/** A scenario whose dataset cap resolves to a concrete number, i.e. one this deployment could pin. */
+function makeCappedScenario() {
+ return makeScenario({
+ default_run_size: {
+ dataset_size: { kind: 'bounded', value: 40 },
+ dataset_limit: { state: 'value', value: 40 },
+ estimated_attack_count: 40,
+ components: [],
+ datasets: [{
+ name: 'harmbench',
+ kind: 'dataset',
+ logical_seed_group_count: 400,
+ selected_seed_group_count: 40,
+ configured_caps: [{ label: 'cap', count: 40, configured_on: 'dataset', dataset_name: 'harmbench' }],
+ selection_note: null,
+ }],
+ note: null,
+ },
+ })
+}
+
+const IDENTITY = { name: 'nightly_probe', scenarioName: 'foundry.red_team_agent', description: '' }
+const ITERATION_PARAMETER: Parameter = {
+ name: 'max_turns',
+ type_name: 'int',
+ required: false,
+ default: '5',
+ description: 'Turn budget.',
+}
+
+const CONCURRENCY_PARAMETER: Parameter = {
+ name: 'max_concurrency',
+ type_name: 'int',
+ required: false,
+ default: '1',
+ description: 'Owned by the launch form, never rendered as a preset field.',
+}
+
+describe('validatePresetName', () => {
+ it('rejects an empty name', () => {
+ expect(validatePresetName('')).toBe('Name is required.')
+ })
+
+ it.each([
+ ['nightly_probe'],
+ ['a'],
+ ['a1_b2'],
+ [`a${'b'.repeat(63)}`],
+ ])('accepts %s', (name) => {
+ expect(validatePresetName(name)).toBeNull()
+ })
+
+ it.each([
+ ['Nightly'],
+ ['1nightly'],
+ ['nightly-probe'],
+ ['nightly probe'],
+ ['nightly.probe'],
+ [`a${'b'.repeat(64)}`],
+ ])('rejects %s', (name) => {
+ expect(validatePresetName(name)).not.toBeNull()
+ })
+})
+
+describe('uneditableScenarioParams', () => {
+ it('returns nothing when the preset stores no parameters', () => {
+ expect(uneditableScenarioParams(makeScenario(), makePreset())).toEqual({})
+ expect(uneditableScenarioParams(makeScenario(), null)).toEqual({})
+ })
+
+ it('names the stored keys that have no field, whether undeclared or owned by the launch form', () => {
+ const scenario = makeScenario({
+ supported_parameters: [ITERATION_PARAMETER, CONCURRENCY_PARAMETER],
+ })
+ const preset = makePreset({
+ scenario_params: { max_turns: 9, retired_knob: 'x', max_concurrency: 4 },
+ })
+
+ expect(uneditableScenarioParams(scenario, preset)).toEqual({ retired_knob: 'x', max_concurrency: 4 })
+ })
+})
+
+describe('presetToConfigState', () => {
+ it('falls back to the scenario defaults for every omitted field', () => {
+ const scenario = makeScenario()
+ const state = presetToConfigState(scenario, makePreset())
+
+ expect(state.techniques).toEqual(['default_technique'])
+ expect(state.includeBaseline).toBe(true)
+ expect(state.datasets).toEqual({})
+ })
+
+ it('expands the stored fields a preset does carry', () => {
+ const scenario = makeScenario()
+ const state = presetToConfigState(scenario, makePreset({
+ techniques: ['crescendo'],
+ include_baseline: false,
+ datasets: {
+ sources: [{ name: 'harmbench' }, { name: 'xstest' }],
+ max_total: 25,
+ filters: { harm_categories: ['violence'], data_types: ['text'] },
+ },
+ }))
+
+ expect(state.techniques).toEqual(['crescendo'])
+ expect(state.includeBaseline).toBe(false)
+ expect(state.datasets).toEqual({
+ sources: [{ name: 'harmbench' }, { name: 'xstest' }],
+ max_total: 25,
+ filters: { harm_categories: ['violence'], data_types: ['text'] },
+ })
+ })
+
+ it('keeps pinned techniques the scenario no longer offers rather than rewriting the selection', () => {
+ const scenario = makeScenario({ all_techniques: ['crescendo', 'default_technique'] })
+ const state = presetToConfigState(
+ scenario,
+ makePreset({ techniques: ['crescendo', 'retired_attack'] }),
+ )
+
+ expect(state.techniques).toEqual(['crescendo', 'retired_attack'])
+ })
+
+ it('keeps a converter-qualified technique the selector renders no checkbox for', () => {
+ const scenario = makeScenario({ all_techniques: ['crescendo', 'default_technique'] })
+ const state = presetToConfigState(
+ scenario,
+ makePreset({ techniques: ['crescendo:converter.translation_spanish'] }),
+ )
+
+ expect(state.techniques).toEqual(['crescendo:converter.translation_spanish'])
+ })
+
+ it('falls back to the scenario defaults only when the preset pins nothing', () => {
+ const scenario = makeScenario({ all_techniques: ['crescendo', 'default_technique'] })
+ const state = presetToConfigState(scenario, makePreset({ techniques: [] }))
+
+ expect(state.techniques).toEqual(['default_technique'])
+ })
+
+ it('keeps a pinned aggregate technique that the selector renders no checkbox for', () => {
+ const scenario = makeScenario({ aggregate_techniques: ['all', 'default'] })
+ const state = presetToConfigState(scenario, makePreset({ techniques: ['all'] }))
+
+ expect(state.techniques).toEqual(['all'])
+ })
+
+ it('leaves the dataset request empty when the preset omits it, even where the scenario has a cap', () => {
+ const state = presetToConfigState(makeCappedScenario(), makePreset())
+
+ expect(state.datasets).toEqual({})
+ })
+
+ it('keeps baseline off when the scenario forbids it, whatever the preset stored', () => {
+ const scenario = makeScenario({ baseline_policy: 'forbidden' })
+ const state = presetToConfigState(scenario, makePreset({ include_baseline: true }))
+
+ expect(state.includeBaseline).toBe(false)
+ })
+
+ it('seeds dynamic parameter values from the stored scenario params', () => {
+ const scenario = makeScenario({ supported_parameters: [ITERATION_PARAMETER] })
+ const state = presetToConfigState(
+ scenario,
+ makePreset({ scenario_params: { max_turns: 9 } }),
+ )
+
+ expect(state.scenarioParamValues.max_turns).toBe('9')
+ })
+})
+
+describe('configToPreset', () => {
+ function buildConfig(overrides: Partial[0]> = {}) {
+ const result = buildScenarioConfig({
+ techniques: ['crescendo'],
+ dynamicParameters: [],
+ scenarioParamValues: {},
+ datasets: {},
+ includeBaseline: false,
+ ...overrides,
+ })
+ if (!result.ok) {
+ throw new Error(result.error)
+ }
+ return result.config
+ }
+
+ it('omits a blank description rather than storing an empty string', () => {
+ const scenario = makeScenario()
+ const preset = configToPreset(
+ { ...IDENTITY, description: ' ' },
+ buildConfig(),
+ { scenario, previous: null },
+ )
+
+ expect(preset).not.toHaveProperty('description')
+ })
+
+ it('trims a description it does keep', () => {
+ const scenario = makeScenario()
+ const preset = configToPreset(
+ { ...IDENTITY, description: ' nightly ' },
+ buildConfig(),
+ { scenario, previous: null },
+ )
+
+ expect(preset.description).toBe('nightly')
+ })
+
+ it('keeps the author the server stamped at create', () => {
+ const scenario = makeScenario()
+ const preset = configToPreset(
+ IDENTITY,
+ buildConfig(),
+ {
+ scenario,
+ previous: { name: 'nightly', scenario_name: scenario.scenario_name, author: 'Ada Lovelace' },
+ },
+ )
+
+ expect(preset.author).toBe('Ada Lovelace')
+ })
+
+ it('leaves the author unset on create so the server can stamp it', () => {
+ const scenario = makeScenario()
+ const preset = configToPreset(IDENTITY, buildConfig(), { scenario, previous: null })
+
+ expect(preset).not.toHaveProperty('author')
+ })
+
+ it('carries only the fields the operator moved off the scenario default', () => {
+ const scenario = makeScenario()
+ const preset = configToPreset(IDENTITY, buildConfig(), { scenario, previous: null })
+
+ expect(Object.keys(preset).sort()).toEqual(
+ ['include_baseline', 'name', 'scenario_name', 'techniques'],
+ )
+ })
+
+ it('pins nothing when a new preset leaves every scenario-owned field alone', () => {
+ const scenario = makeScenario()
+ const state = initialScenarioConfigState(scenario)
+ const preset = configToPreset(
+ IDENTITY,
+ buildConfig({
+ techniques: state.techniques,
+ includeBaseline: state.includeBaseline,
+ datasets: state.datasets,
+ }),
+ { scenario, previous: null },
+ )
+
+ expect(preset).toEqual({ name: IDENTITY.name, scenario_name: IDENTITY.scenarioName })
+ })
+
+ it('leaves unpinned fields unset when an unrelated edit round-trips a sparse preset', () => {
+ const scenario = makeCappedScenario()
+ const original = makePreset({ description: 'Nightly smoke test.' })
+ const state = presetToConfigState(scenario, original)
+
+ const roundTripped = configToPreset(
+ { ...IDENTITY, description: 'Edited.' },
+ buildConfig({
+ techniques: state.techniques,
+ includeBaseline: state.includeBaseline,
+ datasets: state.datasets,
+ }),
+ { scenario, previous: original },
+ )
+
+ expect(roundTripped).toEqual({ ...original, description: 'Edited.' })
+ })
+
+ it('keeps a field the preset already pinned even where it equals the scenario default', () => {
+ const scenario = makeScenario()
+ const original = makePreset({ techniques: ['default_technique'], include_baseline: true })
+ const state = presetToConfigState(scenario, original)
+
+ const roundTripped = configToPreset(
+ IDENTITY,
+ buildConfig({ techniques: state.techniques, includeBaseline: state.includeBaseline }),
+ { scenario, previous: original },
+ )
+
+ expect(roundTripped.techniques).toEqual(['default_technique'])
+ expect(roundTripped.include_baseline).toBe(true)
+ })
+
+ it('pins nothing when the default techniques come back in a different order', () => {
+ const scenario = makeScenario({
+ all_techniques: ['default_technique', 'crescendo'],
+ default_techniques: ['default_technique', 'crescendo'],
+ })
+ const preset = configToPreset(
+ IDENTITY,
+ buildConfig({ techniques: ['crescendo', 'default_technique'], includeBaseline: true }),
+ { scenario, previous: null },
+ )
+
+ expect(preset).toEqual({ name: IDENTITY.name, scenario_name: IDENTITY.scenarioName })
+ })
+
+ it('still pins techniques when the selection swaps a member rather than reordering', () => {
+ const scenario = makeScenario({
+ all_techniques: ['default_technique', 'crescendo', 'flip'],
+ default_techniques: ['default_technique', 'crescendo'],
+ })
+ const preset = configToPreset(
+ IDENTITY,
+ buildConfig({ techniques: ['crescendo', 'flip'], includeBaseline: true }),
+ { scenario, previous: null },
+ )
+
+ expect(preset.techniques).toEqual(['crescendo', 'flip'])
+ })
+
+ it('preserves a stored baseline pin the forbidden policy hides from the form', () => {
+ const scenario = makeScenario({ baseline_policy: 'forbidden' })
+ const original = makePreset({ include_baseline: true })
+ const state = presetToConfigState(scenario, original)
+
+ const roundTripped = configToPreset(
+ IDENTITY,
+ buildConfig({ includeBaseline: state.includeBaseline }),
+ { scenario, previous: original },
+ )
+
+ expect(roundTripped.include_baseline).toBe(true)
+ })
+
+ it('pins no baseline for a new preset the scenario forbids one on', () => {
+ const scenario = makeScenario({ baseline_policy: 'forbidden' })
+ const preset = configToPreset(IDENTITY, buildConfig(), { scenario, previous: null })
+
+ expect(preset).not.toHaveProperty('include_baseline')
+ })
+
+ it('omits a dynamic parameter left at its declared default', () => {
+ const scenario = makeScenario({ supported_parameters: [ITERATION_PARAMETER] })
+ const preset = configToPreset(
+ IDENTITY,
+ buildConfig({
+ dynamicParameters: [ITERATION_PARAMETER],
+ scenarioParamValues: { max_turns: '5' },
+ }),
+ { scenario, previous: null },
+ )
+
+ expect(preset).not.toHaveProperty('scenario_params')
+ })
+
+ it('pins a dynamic parameter the operator moved off its declared default', () => {
+ const scenario = makeScenario({ supported_parameters: [ITERATION_PARAMETER] })
+ const preset = configToPreset(
+ IDENTITY,
+ buildConfig({
+ dynamicParameters: [ITERATION_PARAMETER],
+ scenarioParamValues: { max_turns: '9' },
+ }),
+ { scenario, previous: null },
+ )
+
+ expect(preset.scenario_params).toEqual({ max_turns: 9 })
+ })
+
+ it('carries stored parameters this editor renders no control for', () => {
+ const scenario = makeScenario({ supported_parameters: [ITERATION_PARAMETER] })
+ const original = makePreset({
+ scenario_params: { max_turns: 5, retired_knob: 'x', max_concurrency: 4 },
+ })
+ const state = presetToConfigState(scenario, original)
+
+ const roundTripped = configToPreset(
+ IDENTITY,
+ buildConfig({
+ techniques: state.techniques,
+ dynamicParameters: [ITERATION_PARAMETER],
+ scenarioParamValues: state.scenarioParamValues,
+ }),
+ { scenario, previous: original },
+ )
+
+ expect(roundTripped.scenario_params).toEqual({ max_turns: 5, retired_knob: 'x', max_concurrency: 4 })
+ })
+
+ it('round-trips a fully populated preset back through the form state', () => {
+ const scenario = makeScenario()
+ const original = makePreset({
+ techniques: ['crescendo'],
+ include_baseline: false,
+ datasets: {
+ sources: [{ name: 'harmbench' }],
+ max_total: 25,
+ filters: { harm_categories: ['violence'] },
+ },
+ })
+ const state = presetToConfigState(scenario, original)
+
+ const roundTripped = configToPreset(
+ IDENTITY,
+ buildConfig({
+ techniques: state.techniques,
+ includeBaseline: state.includeBaseline,
+ datasets: state.datasets,
+ }),
+ { scenario, previous: original },
+ )
+
+ expect(roundTripped).toEqual(original)
+ })
+})
diff --git a/frontend/src/components/ScenarioPresets/scenarioPresetForm.ts b/frontend/src/components/ScenarioPresets/scenarioPresetForm.ts
new file mode 100644
index 0000000000..ac058cb886
--- /dev/null
+++ b/frontend/src/components/ScenarioPresets/scenarioPresetForm.ts
@@ -0,0 +1,197 @@
+import { buildParametersFromForm, getInitialFormValues } from '@/components/Parameters/parameterForm'
+import {
+ dynamicScenarioParameters,
+ initialScenarioConfigState,
+ uniqueTechniqueOptions,
+ type ScenarioConfigFields,
+ type ScenarioConfigFormState,
+} from '@/components/Scenarios/scenarioConfigForm'
+import type { RegisteredScenario, ScenarioPreset } from '@/types'
+
+/**
+ * Preset-specific form logic: the translation between a stored `ScenarioPreset`
+ * and the scenario-config form state shared with the launch form. The launch
+ * form never sees a preset, so none of this belongs in `scenarioConfigForm`.
+ *
+ * Every scenario-owned field on a preset is tri-state: an explicit value pins it,
+ * and an omitted field means "whatever the scenario defaults to at run time". The
+ * editor has to prefill omitted fields to render a control for them, so it writes a
+ * field back only when the operator moved it off that default or the document already
+ * pinned it. Round-tripping a preset through the editor otherwise freezes this
+ * deployment's current defaults into the document on the first unrelated edit.
+ */
+
+/** Mirrors the server-side `ScenarioPreset.name` pattern so the editor fails fast instead of on a 422. */
+const PRESET_NAME_PATTERN = /^[a-z][a-z0-9_]{0,63}$/
+
+export function validatePresetName(name: string): string | null {
+ if (name.length === 0) {
+ return 'Name is required.'
+ }
+ if (!PRESET_NAME_PATTERN.test(name)) {
+ return 'Use lowercase letters, digits and underscores, starting with a letter (max 64 characters).'
+ }
+ return null
+}
+
+/**
+ * Parameters the preset stores that this editor renders no control for — a key the
+ * scenario no longer declares, or one of the common parameters the launch form owns.
+ * They are carried through untouched rather than dropped on an unrelated edit.
+ */
+export function uneditableScenarioParams(
+ scenario: RegisteredScenario,
+ preset: ScenarioPreset | null,
+): Record {
+ const stored = preset?.scenario_params
+ if (!stored) {
+ return {}
+ }
+ const editable = new Set(dynamicScenarioParameters(scenario).map((parameter) => parameter.name))
+ return Object.fromEntries(Object.entries(stored).filter(([name]) => !editable.has(name)))
+}
+
+/**
+ * Expands a stored preset into editable form state. Fields the preset omits
+ * mean "use the scenario default", so they fall back to the same initial state
+ * the launch form starts from.
+ *
+ * Every pinned technique is kept, including tokens the selector renders no checkbox for —
+ * an aggregate, a converter-qualified token, or a technique this deployment dropped. The
+ * selector shows those as dismissible tags; filtering them here would replace the whole
+ * selection with this scenario's defaults the moment an operator edited the description.
+ */
+export function presetToConfigState(
+ scenario: RegisteredScenario,
+ preset: ScenarioPreset,
+): ScenarioConfigFormState {
+ const defaults = initialScenarioConfigState(scenario)
+ const pinned = preset.techniques ?? []
+ return {
+ techniques: pinned.length > 0 ? pinned : defaults.techniques,
+ includeBaseline: scenario.baseline_policy === 'forbidden'
+ ? false
+ : preset.include_baseline ?? defaults.includeBaseline,
+ datasets: preset.datasets ?? defaults.datasets,
+ scenarioParamValues: getInitialFormValues(
+ dynamicScenarioParameters(scenario),
+ preset.scenario_params ?? null,
+ ),
+ }
+}
+
+interface PresetIdentity {
+ name: string
+ scenarioName: string
+ description: string
+}
+
+interface PresetWriteContext {
+ scenario: RegisteredScenario
+ /** The document being edited, or `null` when creating. A field it already pins stays pinned. */
+ previous: ScenarioPreset | null
+}
+
+interface ScenarioOwnedDefaults {
+ techniques: string[]
+ includeBaseline: boolean
+ scenarioParams: Record
+}
+
+/** What the scenario would resolve each field to on its own, i.e. what pinning nothing means. */
+function scenarioOwnedDefaults(scenario: RegisteredScenario): ScenarioOwnedDefaults {
+ const parameters = dynamicScenarioParameters(scenario)
+ const built = parameters.length > 0
+ ? buildParametersFromForm(parameters, getInitialFormValues(parameters))
+ : null
+ return {
+ techniques: uniqueTechniqueOptions(scenario).defaultTechniques,
+ includeBaseline: scenario.baseline_policy !== 'forbidden' && scenario.include_baseline_by_default,
+ // A scenario whose untouched form cannot resolve (a required parameter with no
+ // default) has no implicit value to compare against, so every key counts as edited.
+ scenarioParams: built?.ok ? built.parameters ?? {} : {},
+ }
+}
+
+/** Both sides originate from `buildParametersFromForm`, so a structural compare is sound. */
+function matchesDefault(value: unknown, fallback: unknown): boolean {
+ return JSON.stringify(value) === JSON.stringify(fallback)
+}
+
+/**
+ * Compares technique selections as sets, because the two sides are ordered differently:
+ * the selector appends in click order while the scenario lists its defaults in registry
+ * order, and the backend resolves either through a set. A structural compare would treat
+ * a reorder as an edit and pin the whole default list, costing the preset its tracking.
+ */
+function matchesTechniques(value: string[], fallback: string[]): boolean {
+ if (value.length !== fallback.length) {
+ return false
+ }
+ const sortedFallback = [...fallback].sort()
+ return [...value].sort().every((name, index) => name === sortedFallback[index])
+}
+
+function presetScenarioParams(
+ config: ScenarioConfigFields,
+ { scenario, previous }: PresetWriteContext,
+ defaults: Record,
+): Record | null {
+ const stored = previous?.scenario_params ?? {}
+ const edited = Object.entries(config.scenario_params ?? {}).filter(
+ ([name, value]) => (
+ Object.prototype.hasOwnProperty.call(stored, name) || !matchesDefault(value, defaults[name])
+ ),
+ )
+ const merged = {
+ ...Object.fromEntries(edited),
+ ...uneditableScenarioParams(scenario, previous),
+ }
+ return Object.keys(merged).length > 0 ? merged : null
+}
+
+/**
+ * Combines the preset's identity with the resolved scenario config, keeping fields the
+ * operator left at the scenario's default unset so the preset keeps tracking them.
+ */
+export function configToPreset(
+ { name, scenarioName, description }: PresetIdentity,
+ config: ScenarioConfigFields,
+ context: PresetWriteContext,
+): ScenarioPreset {
+ const { scenario, previous } = context
+ const defaults = scenarioOwnedDefaults(scenario)
+ const preset: ScenarioPreset = { name, scenario_name: scenarioName }
+
+ const trimmedDescription = description.trim()
+ if (trimmedDescription.length > 0) {
+ preset.description = trimmedDescription
+ }
+ // The server stamps `author` once, at create. Editing rebuilds the document from
+ // form state, so without carrying it forward every edit would erase it.
+ if (previous?.author != null) {
+ preset.author = previous.author
+ }
+ if (previous?.techniques != null || !matchesTechniques(config.techniques, defaults.techniques)) {
+ preset.techniques = config.techniques
+ }
+ // A forbidden baseline renders the checkbox disabled and forced off, so the form value
+ // carries no operator intent — only whatever the document already said.
+ const includeBaseline = scenario.baseline_policy === 'forbidden'
+ ? previous?.include_baseline
+ : config.include_baseline
+ if (
+ includeBaseline != null
+ && (previous?.include_baseline != null || includeBaseline !== defaults.includeBaseline)
+ ) {
+ preset.include_baseline = includeBaseline
+ }
+ if (config.datasets) {
+ preset.datasets = config.datasets
+ }
+ const scenarioParams = presetScenarioParams(config, context, defaults.scenarioParams)
+ if (scenarioParams) {
+ preset.scenario_params = scenarioParams
+ }
+ return preset
+}
diff --git a/frontend/src/components/Scenarios/ScenarioDetail.tsx b/frontend/src/components/Scenarios/ScenarioDetail.tsx
index b252c80bcf..88ab752a3a 100644
--- a/frontend/src/components/Scenarios/ScenarioDetail.tsx
+++ b/frontend/src/components/Scenarios/ScenarioDetail.tsx
@@ -68,6 +68,7 @@ import { sameTarget, targetModelName } from '@/utils/targetIdentity'
import { useScenarioDetailStyles } from './ScenarioDetail.styles'
import { ScenarioRunEstimateDetails } from './ScenarioRunEstimate'
+import { scenarioDatasetDefaults, scenarioDatasetOptions } from './datasetPresentation'
import { normalizeScenarioMarkdown } from './scenarioMarkdown'
import { mapScenarioRunEstimate } from './scenarioRunEstimateAdapter'
import { techniqueSetName } from './scenarioTechniqueSets'
@@ -630,17 +631,11 @@ function ScenarioLaunchForm({
const [datasetError, setDatasetError] = useState(null)
const datasetMetadata = useScenarioDatasets()
const [datasetProjection, setDatasetProjection] = useState(null)
- const datasetDefaults: ScenarioDatasetSettings = datasetProjection?.dataset_defaults ?? scenario.dataset_defaults ?? {
- sources: scenario.default_datasets.map((name) => ({ name, max_size: 'all', fetch: 'if_missing', configured_provider: false })),
- max_per_dataset: 'all', max_total: scenario.default_run_size.dataset_limit.value ?? 'all',
- fetch: 'if_missing', filters: {},
- }
+ const datasetDefaults: ScenarioDatasetSettings = datasetProjection?.dataset_defaults
+ ?? scenarioDatasetDefaults(scenario)
const effectiveDatasets = datasetProjection?.effective_datasets ?? datasetDefaults
- const datasetOptions: ScenarioDatasetOptions = datasetProjection?.dataset_options ?? scenario.dataset_options ?? {
- mode: 'any', allowed_names: [], per_dataset_limit: true,
- total_limit: scenario.default_run_size.dataset_limit.state !== 'not_applicable',
- supports_overrides: true, filters: true,
- }
+ const datasetOptions: ScenarioDatasetOptions = datasetProjection?.dataset_options
+ ?? scenarioDatasetOptions(scenario)
const selectedDatasetNames = (datasetEdits.sources ?? datasetDefaults.sources).map((source) => source.name)
const datasetSelectionKey = JSON.stringify(selectedDatasetNames)
const reloadDatasetMetadata = datasetMetadata.reload
diff --git a/frontend/src/components/Scenarios/ScenarioTechniqueSelector.styles.ts b/frontend/src/components/Scenarios/ScenarioTechniqueSelector.styles.ts
new file mode 100644
index 0000000000..6e952325c6
--- /dev/null
+++ b/frontend/src/components/Scenarios/ScenarioTechniqueSelector.styles.ts
@@ -0,0 +1,56 @@
+import { makeStyles, tokens } from '@fluentui/react-components'
+
+import {
+ mobileTouchTargetHeight,
+ NARROW_VIEWPORT_QUERY,
+} from '@/styles/touchTargets'
+
+export const useScenarioTechniqueSelectorStyles = makeStyles({
+ section: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalM,
+ padding: tokens.spacingVerticalL,
+ border: `1px solid ${tokens.colorNeutralStroke2}`,
+ borderRadius: tokens.borderRadiusLarge,
+ backgroundColor: tokens.colorNeutralBackground1,
+ },
+ hint: {
+ color: tokens.colorNeutralForeground3,
+ },
+ errorText: {
+ color: tokens.colorPaletteRedForeground1,
+ },
+ techniqueList: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalS,
+ },
+ pinnedTokens: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalXS,
+ },
+ techniqueOption: {
+ display: 'grid',
+ gridTemplateColumns: 'minmax(12rem, 35%) minmax(0, 1fr)',
+ gap: tokens.spacingHorizontalM,
+ alignItems: 'start',
+ padding: tokens.spacingVerticalS,
+ border: `1px solid ${tokens.colorNeutralStroke2}`,
+ borderRadius: tokens.borderRadiusMedium,
+ [NARROW_VIEWPORT_QUERY]: {
+ gridTemplateColumns: 'minmax(0, 1fr)',
+ gap: tokens.spacingVerticalXS,
+ },
+ },
+ selectionControl: {
+ ...mobileTouchTargetHeight,
+ },
+ techniqueDetails: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalXS,
+ minWidth: 0,
+ },
+})
diff --git a/frontend/src/components/Scenarios/ScenarioTechniqueSelector.tsx b/frontend/src/components/Scenarios/ScenarioTechniqueSelector.tsx
new file mode 100644
index 0000000000..4cea0d7238
--- /dev/null
+++ b/frontend/src/components/Scenarios/ScenarioTechniqueSelector.tsx
@@ -0,0 +1,142 @@
+import { useId, useMemo } from 'react'
+
+import { Checkbox, Tag, TagGroup, Text } from '@fluentui/react-components'
+
+import type { ScenarioTechniqueSummary } from '@/types'
+
+import { useScenarioTechniqueSelectorStyles } from './ScenarioTechniqueSelector.styles'
+import { buildSelectableTechniques, type SelectableTechnique } from './scenarioConfigForm'
+
+interface ScenarioTechniqueSelectorProps {
+ techniqueOptions: ScenarioTechniqueSummary[]
+ selectedTechniques: string[]
+ includeBaseline: boolean
+ isBaselineForbidden: boolean
+ disabled: boolean
+ onTechniquesChange: (techniques: string[]) => void
+ onIncludeBaselineChange: (includeBaseline: boolean) => void
+}
+
+/**
+ * Technique picker shared by the scenario launch form and the preset editor.
+ *
+ * Baseline is rendered as a pseudo-technique so an operator sees one list, but it
+ * travels as its own flag because the backend models it as `include_baseline`.
+ *
+ * A selection may hold tokens no checkbox represents: an aggregate such as `all`, or a
+ * technique carrying a converter modifier (`role_play:converter.translation_spanish`).
+ * Those render as dismissible tags, because leaving them out would show every checkbox
+ * cleared while the run still executes them.
+ */
+export default function ScenarioTechniqueSelector({
+ techniqueOptions,
+ selectedTechniques,
+ includeBaseline,
+ isBaselineForbidden,
+ disabled,
+ onTechniquesChange,
+ onIncludeBaselineChange,
+}: ScenarioTechniqueSelectorProps) {
+ const styles = useScenarioTechniqueSelectorStyles()
+ const titleId = useId()
+
+ const selectableTechniques = useMemo(
+ () => buildSelectableTechniques(techniqueOptions, isBaselineForbidden),
+ [isBaselineForbidden, techniqueOptions],
+ )
+
+ const isTechniqueSelected = (technique: SelectableTechnique): boolean => (
+ technique.isBaseline ? includeBaseline : selectedTechniques.includes(technique.name)
+ )
+
+ const tokensWithoutCheckbox = useMemo(() => {
+ const checkboxNames = new Set(
+ selectableTechniques.filter((technique) => !technique.isBaseline).map((technique) => technique.name),
+ )
+ return selectedTechniques.filter((token) => !checkboxNames.has(token))
+ }, [selectableTechniques, selectedTechniques])
+
+ const handleTechniqueChange = (technique: SelectableTechnique, checked: boolean): void => {
+ if (technique.isBaseline) {
+ onIncludeBaselineChange(checked)
+ return
+ }
+ if (checked) {
+ if (!selectedTechniques.includes(technique.name)) {
+ onTechniquesChange([...selectedTechniques, technique.name])
+ }
+ return
+ }
+ onTechniquesChange(selectedTechniques.filter((name) => name !== technique.name))
+ }
+
+ return (
+
+
+ Techniques
+
+
+ Select individual techniques.
+
+ {selectedTechniques.length === 0 && (
+
+ Select at least one attack technique.
+
+ )}
+ {tokensWithoutCheckbox.length > 0 && (
+
+
+ Also running, with no checkbox of their own:
+
+ onTechniquesChange(
+ selectedTechniques.filter((name) => name !== data.value),
+ )}
+ >
+ {tokensWithoutCheckbox.map((token) => (
+
+ {token}
+
+ ))}
+
+
+ )}
+
+ {selectableTechniques.map((technique) => {
+ const selected = isTechniqueSelected(technique)
+ return (
+
+
handleTechniqueChange(technique, data.checked === true)}
+ data-testid={technique.isBaseline ? 'baseline-checkbox' : `technique-${technique.name}`}
+ />
+
+ {technique.description && (
+ {technique.description}
+ )}
+ {technique.disabled && (
+
+ This scenario does not support a baseline comparison.
+
+ )}
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/frontend/src/components/Scenarios/datasetPresentation.ts b/frontend/src/components/Scenarios/datasetPresentation.ts
index e3bcb842da..c0a2770f28 100644
--- a/frontend/src/components/Scenarios/datasetPresentation.ts
+++ b/frontend/src/components/Scenarios/datasetPresentation.ts
@@ -1,8 +1,45 @@
import type {
- DatasetFetchPolicy, DatasetInfo, ScenarioDatasetOptions, ScenarioDatasetRequest,
- ScenarioDatasetSettings, ScenarioDatasetSourceInput, ScenarioDatasetSourceSettings,
+ DatasetFetchPolicy,
+ DatasetInfo,
+ RegisteredScenario,
+ ScenarioDatasetOptions,
+ ScenarioDatasetRequest,
+ ScenarioDatasetSettings,
+ ScenarioDatasetSourceInput,
+ ScenarioDatasetSourceSettings,
} from '@/types'
+/**
+ * The scenario's declared dataset defaults, or a stand-in built from its legacy fields.
+ *
+ * A run's estimate projects these per selection; a form with no estimate in hand (the
+ * preset editor) still needs the same baseline, so both read it from here rather than
+ * each carrying its own copy of the fallback.
+ */
+export function scenarioDatasetDefaults(scenario: RegisteredScenario): ScenarioDatasetSettings {
+ return scenario.dataset_defaults ?? {
+ sources: scenario.default_datasets.map(
+ (name) => ({ name, max_size: 'all', fetch: 'if_missing', configured_provider: false }),
+ ),
+ max_per_dataset: 'all',
+ max_total: scenario.default_run_size.dataset_limit.value ?? 'all',
+ fetch: 'if_missing',
+ filters: {},
+ }
+}
+
+/** The scenario's declared dataset capabilities, or a permissive stand-in when it declares none. */
+export function scenarioDatasetOptions(scenario: RegisteredScenario): ScenarioDatasetOptions {
+ return scenario.dataset_options ?? {
+ mode: 'any',
+ allowed_names: [],
+ per_dataset_limit: true,
+ total_limit: scenario.default_run_size.dataset_limit.state !== 'not_applicable',
+ supports_overrides: true,
+ filters: true,
+ }
+}
+
function explicitDatasetPolicy(value: ScenarioDatasetRequest['fetch']): DatasetFetchPolicy | undefined {
return value === 'never' || value === 'if_missing' || value === 'always' ? value : undefined
}
diff --git a/frontend/src/components/Scenarios/scenarioConfigForm.ts b/frontend/src/components/Scenarios/scenarioConfigForm.ts
new file mode 100644
index 0000000000..4c94d5d710
--- /dev/null
+++ b/frontend/src/components/Scenarios/scenarioConfigForm.ts
@@ -0,0 +1,174 @@
+import {
+ buildParametersFromForm,
+ getInitialFormValues,
+ type ParameterFormValue,
+} from '@/components/Parameters/parameterForm'
+import type {
+ Parameter,
+ RegisteredScenario,
+ ScenarioDatasetRequest,
+ ScenarioTechniqueSummary,
+} from '@/types'
+
+/**
+ * Common/opaque parameters every scenario declares via
+ * `Scenario._common_scenario_parameters` — the launch form already exposes a
+ * purpose-built control for each of these (target, techniques, datasets,
+ * labels, concurrency, retries, baseline), and `technique_converters` has no
+ * UI at all. They're hidden from the dynamic scenario-specific parameter list.
+ */
+const COMMON_SCENARIO_PARAMETER_NAMES = new Set([
+ 'objective_target',
+ 'scenario_techniques',
+ 'technique_converters',
+ 'dataset_config',
+ 'memory_labels',
+ 'max_concurrency',
+ 'max_retries',
+ 'include_baseline',
+])
+
+export const BASELINE_TECHNIQUE: ScenarioTechniqueSummary = {
+ name: 'baseline',
+ description: 'Sends each objective directly to the target for comparison.',
+ tags: ['baseline', 'single_turn'],
+}
+
+/** A technique checkbox, including the baseline pseudo-technique the scenario may forbid. */
+export interface SelectableTechnique extends ScenarioTechniqueSummary {
+ isBaseline: boolean
+ disabled: boolean
+}
+
+/** The scenario-owned configuration the preset editor collects. */
+export interface ScenarioConfigFormState {
+ techniques: string[]
+ includeBaseline: boolean
+ datasets: ScenarioDatasetRequest
+ scenarioParamValues: Record
+}
+
+/** The resolved scenario-owned fields shared by an estimate request, a run request, and a preset. */
+export interface ScenarioConfigFields {
+ techniques: string[]
+ include_baseline: boolean
+ datasets?: ScenarioDatasetRequest
+ scenario_params?: Record
+}
+
+export type BuildScenarioConfigResult =
+ | { ok: true; config: ScenarioConfigFields }
+ | { ok: false; error: string }
+
+interface TechniqueOptions {
+ techniques: ScenarioTechniqueSummary[]
+ defaultTechniques: string[]
+}
+
+/**
+ * Whether a dataset request carries nothing the scenario would not already do.
+ *
+ * The table always hands back a populated object, so without this an untouched editor
+ * would write `{max_per_dataset: 'default', max_total: 'default'}` into every stored
+ * document. A preset that says nothing about datasets should stay silent about them.
+ */
+export function datasetRequestIsEmpty(request: ScenarioDatasetRequest): boolean {
+ const isDefaultLimit = (value: ScenarioDatasetRequest['max_total']) => value === undefined || value === 'default'
+ return (
+ (request.sources ?? []).length === 0 &&
+ isDefaultLimit(request.max_per_dataset) &&
+ isDefaultLimit(request.max_total) &&
+ (request.fetch === undefined || request.fetch === null || request.fetch === 'default') &&
+ Object.keys(request.filters ?? {}).length === 0
+ )
+}
+
+export function uniqueTechniqueOptions(scenario: RegisteredScenario): TechniqueOptions {
+ const aggregateNames = new Set(scenario.aggregate_techniques)
+ const summariesByName = new Map(
+ scenario.technique_summaries.map((summary) => [summary.name, summary]),
+ )
+ const techniques: ScenarioTechniqueSummary[] = []
+ const seen = new Set()
+ for (const name of scenario.all_techniques) {
+ if (!aggregateNames.has(name) && !seen.has(name)) {
+ techniques.push(summariesByName.get(name) ?? { name, description: null, tags: [] })
+ seen.add(name)
+ }
+ }
+ const concreteNames = new Set(techniques.map((technique) => technique.name))
+ const defaultTechniques = scenario.default_techniques.filter((name) => concreteNames.has(name))
+ if (defaultTechniques.length === 0 && concreteNames.has(scenario.default_technique)) {
+ defaultTechniques.push(scenario.default_technique)
+ }
+ return { techniques, defaultTechniques }
+}
+
+/** Scenario-specific parameters, excluding the common ones that already have a purpose-built control. */
+export function dynamicScenarioParameters(scenario: RegisteredScenario): Parameter[] {
+ return scenario.supported_parameters.filter(
+ (parameter) => !COMMON_SCENARIO_PARAMETER_NAMES.has(parameter.name),
+ )
+}
+
+export function buildSelectableTechniques(
+ techniqueOptions: ScenarioTechniqueSummary[],
+ isBaselineForbidden: boolean,
+): SelectableTechnique[] {
+ return [
+ { ...BASELINE_TECHNIQUE, isBaseline: true, disabled: isBaselineForbidden },
+ ...techniqueOptions.map((technique) => ({ ...technique, isBaseline: false, disabled: false })),
+ ]
+}
+
+export function initialScenarioConfigState(scenario: RegisteredScenario): ScenarioConfigFormState {
+ return {
+ techniques: uniqueTechniqueOptions(scenario).defaultTechniques,
+ includeBaseline: scenario.baseline_policy !== 'forbidden' && scenario.include_baseline_by_default,
+ datasets: {},
+ scenarioParamValues: getInitialFormValues(dynamicScenarioParameters(scenario)),
+ }
+}
+
+interface BuildScenarioConfigInput {
+ techniques: string[]
+ dynamicParameters: Parameter[]
+ scenarioParamValues: Record
+ datasets: ScenarioDatasetRequest
+ includeBaseline: boolean
+}
+
+/**
+ * Resolves the scenario-owned form state into the fields an estimate, a run, or a preset carries.
+ * Fields the operator left blank are omitted rather than sent as an empty value, so the scenario's
+ * own default still applies downstream.
+ */
+export function buildScenarioConfig({
+ techniques,
+ dynamicParameters,
+ scenarioParamValues,
+ datasets,
+ includeBaseline,
+}: BuildScenarioConfigInput): BuildScenarioConfigResult {
+ if (techniques.length === 0) {
+ return { ok: false, error: 'Select at least one technique.' }
+ }
+
+ let scenarioParams: Record | null = null
+ if (dynamicParameters.length > 0) {
+ const result = buildParametersFromForm(dynamicParameters, scenarioParamValues)
+ if (!result.ok) {
+ return result
+ }
+ scenarioParams = result.parameters
+ }
+
+ const config: ScenarioConfigFields = { techniques, include_baseline: includeBaseline }
+ if (!datasetRequestIsEmpty(datasets)) {
+ config.datasets = datasets
+ }
+ if (scenarioParams) {
+ config.scenario_params = scenarioParams
+ }
+ return { ok: true, config }
+}
diff --git a/frontend/src/components/Scenarios/scenarioRunLimits.ts b/frontend/src/components/Scenarios/scenarioRunLimits.ts
new file mode 100644
index 0000000000..15b5332576
--- /dev/null
+++ b/frontend/src/components/Scenarios/scenarioRunLimits.ts
@@ -0,0 +1,24 @@
+/**
+ * Launch-owned execution bounds, mirroring the server-side `RunScenarioRequest`
+ * field constraints. Shared by every surface that starts a scenario run so the
+ * client never offers a value the API will reject.
+ */
+
+export const MIN_MAX_CONCURRENCY = 1
+export const MAX_MAX_CONCURRENCY = 100
+export const MIN_MAX_RETRIES = 0
+export const MAX_MAX_RETRIES = 20
+export const DEFAULT_MAX_CONCURRENCY = 10
+export const DEFAULT_MAX_RETRIES = 0
+
+/** Resolves a Fluent `SpinButton` change event to a numeric value, preferring the parsed `value` over the raw `displayValue`. */
+export function resolveSpinButtonValue(
+ data: { value?: number | null; displayValue?: string },
+ previous: number,
+): number {
+ if (typeof data.value === 'number') {
+ return data.value
+ }
+ const parsed = data.displayValue !== undefined ? Number(data.displayValue) : NaN
+ return Number.isFinite(parsed) ? parsed : previous
+}
diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts
index e5cefa4499..d50ff2c07a 100644
--- a/frontend/src/services/api.test.ts
+++ b/frontend/src/services/api.test.ts
@@ -32,6 +32,7 @@ import {
labelsApi,
scenariosApi,
operationsApi,
+ scenarioPresetsApi,
datasetsApi,
analyticsApi,
} from "./api";
@@ -107,6 +108,31 @@ describe("api service", () => {
});
});
+ describe("scenarioPresetsApi", () => {
+ it("lists definitions without opting in to run-size estimates", async () => {
+ const response = { source: "presets", items: [] };
+ (apiClient.get as jest.Mock).mockResolvedValueOnce({ data: response });
+
+ await expect(scenarioPresetsApi.list()).resolves.toEqual(response);
+ expect(apiClient.get).toHaveBeenCalledWith("/scenario-presets", {
+ params: { include_estimates: false },
+ signal: undefined,
+ });
+ });
+
+ it("explicitly opts in to estimates and forwards cancellation", async () => {
+ const controller = new AbortController();
+ (apiClient.get as jest.Mock).mockResolvedValueOnce({ data: { source: "presets", items: [] } });
+
+ await scenarioPresetsApi.list(controller.signal, true);
+
+ expect(apiClient.get).toHaveBeenCalledWith("/scenario-presets", {
+ params: { include_estimates: true },
+ signal: controller.signal,
+ });
+ });
+ });
+
describe("apiClient", () => {
it("should be defined", () => {
expect(apiClient).toBeDefined();
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 2bd21495cd..94fbd8c364 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -46,7 +46,12 @@ import type {
ChangeMainConversationResponse,
ListRegisteredScenariosResponse,
RegisteredScenario,
+ ResolveScenarioPresetRequest,
RunScenarioRequest,
+ ScenarioPreset,
+ ScenarioPresetListResponse,
+ ScenarioPresetResponse,
+ UpdateScenarioPresetRequest,
ScenarioRunSizeEstimateResponse,
ScenarioRunSizeEstimateRequest,
ScenarioDatasetLoadResponse,
@@ -715,6 +720,63 @@ export const scenariosApi = {
},
}
+export const scenarioPresetsApi = {
+ /** Read definitions without run-size calculation unless explicitly requested. */
+ list: async (
+ signal?: AbortSignal,
+ includeEstimates = false,
+ ): Promise => {
+ const params = { include_estimates: includeEstimates }
+ const response = await apiClient.get('/scenario-presets', { params, signal })
+ return response.data
+ },
+
+ get: async (name: string): Promise => {
+ const response = await apiClient.get(`/scenario-presets/${encodeURIComponent(name)}`)
+ return response.data
+ },
+
+ /** Creates a preset that must not already exist; the server returns 409 when the name is taken. */
+ create: async (preset: ScenarioPreset): Promise => {
+ const response = await apiClient.post('/scenario-presets', preset)
+ return response.data
+ },
+
+ /**
+ * Replaces a preset the caller has read. `expectedVersion` is the version returned
+ * by the read, so a concurrent edit fails with 409 instead of being overwritten.
+ */
+ update: async (
+ name: string,
+ preset: ScenarioPreset,
+ expectedVersion: string,
+ ): Promise => {
+ const body: UpdateScenarioPresetRequest = { preset, expected_version: expectedVersion }
+ const response = await apiClient.put(`/scenario-presets/${encodeURIComponent(name)}`, body)
+ return response.data
+ },
+
+ remove: async (name: string): Promise => {
+ await apiClient.delete(`/scenario-presets/${encodeURIComponent(name)}`)
+ },
+
+ /**
+ * Combines a stored preset with the launch-owned fields it omits into a runnable request.
+ * Sending `expected_version` fails with 409 when the preset changed after it was read,
+ * so a launch cannot silently run a configuration the operator never saw.
+ */
+ resolve: async (
+ name: string,
+ request: ResolveScenarioPresetRequest,
+ ): Promise => {
+ const response = await apiClient.post(
+ `/scenario-presets/${encodeURIComponent(name)}/resolve`,
+ request,
+ )
+ return response.data
+ },
+}
+
const MAX_SEED_SEARCH_LENGTH = 1_000
function seedListQuery(params: SeedExampleListParams): Record {
@@ -784,4 +846,5 @@ export const datasetsApi = {
return response.data
},
}
+
import type { RuntimeReadiness, RuntimeStatus } from '@/types'
diff --git a/frontend/src/test-utils/scenarioFixtures.ts b/frontend/src/test-utils/scenarioFixtures.ts
new file mode 100644
index 0000000000..c136b29a66
--- /dev/null
+++ b/frontend/src/test-utils/scenarioFixtures.ts
@@ -0,0 +1,55 @@
+import type { RegisteredScenario } from '../types'
+
+/**
+ * Builds a `RegisteredScenario` for tests. Technique fields are derived from
+ * one another so a test can override just `all_techniques` (or just
+ * `default_techniques`) and still get a self-consistent scenario.
+ */
+export function makeScenario(overrides: Partial = {}): RegisteredScenario {
+ const description = overrides.description ?? 'Red teams a target.'
+ const defaultTechnique = overrides.default_technique ?? 'default'
+ const aggregateTechniques = overrides.aggregate_techniques ?? ['all', 'default']
+ const defaultTechniques = overrides.default_techniques
+ ?? (aggregateTechniques.includes(defaultTechnique) ? ['default_technique'] : [defaultTechnique])
+ const allTechniques = overrides.all_techniques ?? ['default_technique', 'crescendo']
+ const techniqueSummaries = overrides.technique_summaries ?? allTechniques.map((name) => ({
+ name,
+ description: `${name} description.`,
+ tags: name === 'default_technique' ? ['default', 'single_turn'] : ['multi_turn'],
+ }))
+ return {
+ scenario_name: 'foundry.red_team_agent',
+ scenario_type: 'RedTeamAgentScenario',
+ scenario_version: 1,
+ aggregate_technique_expansions: overrides.aggregate_technique_expansions
+ ?? Object.fromEntries(
+ aggregateTechniques.map((name) => [name, name === defaultTechnique ? defaultTechniques : []]),
+ ),
+ all_techniques: allTechniques,
+ technique_summaries: techniqueSummaries,
+ default_datasets: ['harmbench'],
+ baseline_policy: 'enabled',
+ include_baseline_by_default: true,
+ uses_default_adversarial_target: true,
+ supported_parameters: [],
+ supported_parameter_names: overrides.supported_parameter_names ?? [
+ 'objective_target', 'scenario_techniques', 'technique_converters', 'dataset_config',
+ 'memory_labels', 'max_concurrency', 'max_retries', 'include_baseline',
+ ...(overrides.supported_parameters ?? []).map((parameter) => parameter.name),
+ ],
+ default_run_size: {
+ dataset_size: { kind: 'indeterminate', detail: 'Default sizing is unavailable.' },
+ dataset_limit: { state: 'scenario_default' },
+ estimated_attack_count: null,
+ components: [],
+ datasets: [],
+ note: 'Default sizing is unavailable.',
+ },
+ ...overrides,
+ description,
+ description_markdown: overrides.description_markdown ?? description,
+ default_technique: defaultTechnique,
+ default_techniques: defaultTechniques,
+ aggregate_techniques: aggregateTechniques,
+ }
+}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 2946cb8bf5..c936aa65f2 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -1291,6 +1291,74 @@ export interface ListRegisteredScenariosResponse {
pagination: PaginationInfo
}
+/**
+ * A saved, portable scenario configuration.
+ *
+ * Every optional field is tri-state: absent means "use the scenario's own default",
+ * so a preset only pins what the author deliberately chose. It carries no target,
+ * concurrency, retries or labels — those are launch-time decisions supplied by
+ * `ResolveScenarioPresetRequest`.
+ */
+export interface ScenarioPreset {
+ name: string
+ scenario_name: string
+ description?: string | null
+ /** Who created the preset. Descriptive only; never used to authorize a read or a write. */
+ author?: string | null
+ techniques?: string[] | null
+ /** Dataset sources, limits, fetch policy, and filters. Omit to use the scenario defaults. */
+ datasets?: ScenarioDatasetRequest | null
+ include_baseline?: boolean | null
+ scenario_params?: Record | null
+}
+
+/** One advisory problem found checking a preset against this deployment's registry. */
+export interface PresetIssue {
+ field: string
+ message: string
+}
+
+export interface ScenarioPresetResponse {
+ preset: ScenarioPreset
+ /**
+ * Whether a stored document or registering code contributed this preset. A
+ * registered preset cannot be updated or deleted through the API.
+ */
+ origin: 'stored' | 'registered'
+ /** Opaque document version; required to update the preset. Null when registered in code. */
+ version: string | null
+ /** Empty when the preset is runnable here. Never blocks a save. */
+ issues: PresetIssue[]
+ /**
+ * How much this preset runs with its own techniques, datasets, and limits.
+ * List responses fill this in only when estimates are explicitly requested
+ * (off by default); it stays null for a preset this deployment cannot resolve.
+ */
+ run_size?: ScenarioRunSizeEstimateResponse | null
+}
+
+export interface ScenarioPresetListResponse {
+ source: string
+ items: ScenarioPresetResponse[]
+}
+
+export interface UpdateScenarioPresetRequest {
+ preset: ScenarioPreset
+ expected_version: string
+}
+
+/** The launch-owned fields a preset deliberately omits. */
+export interface ResolveScenarioPresetRequest {
+ expected_version?: string | null
+ target_name: string
+ adversarial_target_name?: string | null
+ initializers?: string[] | null
+ initializer_args?: Record> | null
+ max_concurrency?: number | null
+ max_retries?: number | null
+ labels?: Record | null
+}
+
export interface RunScenarioRequest {
scenario_name: string
target_name?: string | null
diff --git a/pyrit/backend/main.py b/pyrit/backend/main.py
index 8f9f596c65..8cf4af5153 100644
--- a/pyrit/backend/main.py
+++ b/pyrit/backend/main.py
@@ -40,6 +40,7 @@
media,
message_sends,
operations,
+ scenario_presets,
scenarios,
scorers,
scores,
@@ -139,6 +140,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
app.include_router(datasets.router, prefix="/api", tags=["datasets"])
app.include_router(operations.router, prefix="/api", tags=["operations"])
app.include_router(scenarios.router, prefix="/api", tags=["scenarios"])
+app.include_router(scenario_presets.router, prefix="/api", tags=["scenario-presets"])
app.include_router(initializers.router, prefix="/api", tags=["initializers"])
app.include_router(labels.router, prefix="/api", tags=["labels"])
app.include_router(health.router, prefix="/api", tags=["health"])
diff --git a/pyrit/backend/middleware/auth.py b/pyrit/backend/middleware/auth.py
index 2b5c402f0e..3799358485 100644
--- a/pyrit/backend/middleware/auth.py
+++ b/pyrit/backend/middleware/auth.py
@@ -88,6 +88,25 @@ def require_admin(request: Request) -> None:
)
+def current_user_name(request: Request) -> str | None:
+ """
+ Return a display name for the signed-in user, or None when nobody is identified.
+
+ Authentication is optional in PyRIT, so most deployments have no identity to
+ report and callers must treat the result as unknown rather than anonymous.
+
+ Args:
+ request (Request): The request whose authenticated user to read.
+
+ Returns:
+ str | None: The user's display name, their email when no name is set, or None.
+ """
+ user = getattr(request.state, "user", None)
+ if not isinstance(user, AuthenticatedUser):
+ return None
+ return user.name or user.email or None
+
+
class EntraAuthMiddleware(BaseHTTPMiddleware):
"""Authenticate API requests through Microsoft Graph."""
diff --git a/pyrit/backend/models/__init__.py b/pyrit/backend/models/__init__.py
index 9167c0c529..ff5e57f1d2 100644
--- a/pyrit/backend/models/__init__.py
+++ b/pyrit/backend/models/__init__.py
@@ -63,6 +63,13 @@
ListRegisteredInitializersResponse,
RegisterInitializerRequest,
)
+ from pyrit.backend.models.scenario_presets import (
+ PresetIssue,
+ ResolveScenarioPresetRequest,
+ ScenarioPresetListResponse,
+ ScenarioPresetResponse,
+ UpdateScenarioPresetRequest,
+ )
from pyrit.backend.models.scenarios import ListRegisteredScenariosResponse, ScenarioRunListResponse
from pyrit.backend.models.scorers import (
CreateScorerRequest,
@@ -114,6 +121,11 @@
"PreviewStep": "pyrit.backend.models.converters",
"DatasetInfo": "pyrit.backend.models.datasets",
"DatasetListResponse": "pyrit.backend.models.datasets",
+ "PresetIssue": "pyrit.backend.models.scenario_presets",
+ "ResolveScenarioPresetRequest": "pyrit.backend.models.scenario_presets",
+ "ScenarioPresetListResponse": "pyrit.backend.models.scenario_presets",
+ "ScenarioPresetResponse": "pyrit.backend.models.scenario_presets",
+ "UpdateScenarioPresetRequest": "pyrit.backend.models.scenario_presets",
"ListRegisteredScenariosResponse": "pyrit.backend.models.scenarios",
"CreateScorerRequest": "pyrit.backend.models.scorers",
"ScorerListResponse": "pyrit.backend.models.scorers",
diff --git a/pyrit/backend/models/scenario_presets.py b/pyrit/backend/models/scenario_presets.py
new file mode 100644
index 0000000000..2b5187e2c8
--- /dev/null
+++ b/pyrit/backend/models/scenario_presets.py
@@ -0,0 +1,116 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""
+REST envelopes for the scenario preset endpoints.
+
+The canonical preset types (``ScenarioPreset``, ``StoredPreset``, ``PresetIssue``)
+live in ``pyrit.models.catalog.scenario_preset`` and are imported from there
+directly. ``PresetIssue`` is re-exported here so that a client of the wire models
+does not have to reach across into the catalog for one field's element type.
+The models here only add the wire-level concerns: the storage source, the run
+size attached to a listing, and the launch-owned fields that a preset
+deliberately does not carry.
+"""
+
+from typing import Any, Literal
+
+from pydantic import BaseModel, Field
+
+from pyrit.models.catalog.scenario import ScenarioRunSizeEstimate
+from pyrit.models.catalog.scenario_preset import PresetIssue, ScenarioPreset
+
+__all__ = [
+ "PresetIssue",
+ "ResolveScenarioPresetRequest",
+ "ScenarioPresetListResponse",
+ "ScenarioPresetResponse",
+ "UpdateScenarioPresetRequest",
+]
+
+
+class ScenarioPresetResponse(BaseModel):
+ """A preset, the version of the document it was read from, and its advisory issues."""
+
+ preset: ScenarioPreset = Field(..., description="The preset")
+ origin: Literal["stored", "registered"] = Field(
+ "stored",
+ description=(
+ "Whether a stored document or registering code contributed this preset. A registered preset "
+ "is held in memory by the code that registered it and cannot be updated or deleted here."
+ ),
+ )
+ version: str | None = Field(
+ ...,
+ description=(
+ "Opaque version of the stored document, required to update it. None for a registered preset, "
+ "which has no document to guard and nothing to round-trip into a write."
+ ),
+ )
+ issues: list[PresetIssue] = Field(
+ default_factory=list,
+ description="Advisory problems resolving this preset against the live registry; empty when it is runnable",
+ )
+ run_size: ScenarioRunSizeEstimate | None = Field(
+ None,
+ description=(
+ "Run size for this preset's own techniques, datasets, and limits rather than the scenario default. "
+ "Only the list endpoint fills this in, and only when estimates are requested; it stays None for a "
+ "preset that cannot be resolved in this deployment."
+ ),
+ )
+
+
+class ScenarioPresetListResponse(BaseModel):
+ """The configured preset storage source and every preset readable from it."""
+
+ source: str = Field(..., description="Credential-free configured preset source")
+ items: list[ScenarioPresetResponse] = Field(..., description="Stored presets, sorted by name")
+
+
+class UpdateScenarioPresetRequest(BaseModel):
+ """
+ Request body for updating an existing preset.
+
+ ``expected_version`` is required rather than optional because updating is a
+ distinct operation from creating: a client that has not read the document it
+ is replacing has nothing to be optimistic about. Creation goes through POST,
+ where no version exists to supply.
+ An omitted or null ``preset.author`` preserves the stored author.
+ """
+
+ preset: ScenarioPreset = Field(..., description="The replacement preset")
+ expected_version: str = Field(..., description="Version returned when the preset being edited was read")
+
+
+class ResolveScenarioPresetRequest(BaseModel):
+ """
+ The launch-owned fields a preset deliberately omits.
+
+ A preset answers *what to test*; these answer *how and where*. Resolution is
+ a union of the two, which is why nothing here overlaps a preset field.
+
+ ``expected_version`` is the exception: it is a precondition rather than a
+ launch field. A client that previewed a resolution sends the version it
+ previewed so an edit landing in between is reported instead of silently
+ launching a configuration the operator never saw.
+ """
+
+ expected_version: str | None = Field(
+ None, description="Version the caller resolved against; omit to resolve whatever is stored now"
+ )
+ target_name: str = Field(..., description="Name of a registered target from the TargetRegistry")
+ adversarial_target_name: str | None = Field(
+ None, description="Name of a registered adversarial target, when the scenario uses one"
+ )
+ initializers: list[str] | None = Field(None, description="Initializer names to run before the scenario")
+ initializer_args: dict[str, dict[str, Any]] | None = Field(
+ None, description="Per-initializer parameter overrides keyed by initializer name"
+ )
+ max_concurrency: int | None = Field(
+ None, ge=1, le=100, description="Maximum concurrent operations; omit to use the run default"
+ )
+ max_retries: int | None = Field(
+ None, ge=0, le=20, description="Maximum retry attempts on failure; omit to use the run default"
+ )
+ labels: dict[str, str] | None = Field(None, description="Labels to attach to memory entries")
diff --git a/pyrit/backend/routes/__init__.py b/pyrit/backend/routes/__init__.py
index a14ea13acf..0005923d1f 100644
--- a/pyrit/backend/routes/__init__.py
+++ b/pyrit/backend/routes/__init__.py
@@ -19,6 +19,7 @@
initializers,
labels,
media,
+ scenario_presets,
scenarios,
scorers,
targets,
@@ -33,6 +34,7 @@
"initializers": ("pyrit.backend.routes.initializers", None),
"labels": ("pyrit.backend.routes.labels", None),
"media": ("pyrit.backend.routes.media", None),
+ "scenario_presets": ("pyrit.backend.routes.scenario_presets", None),
"scenarios": ("pyrit.backend.routes.scenarios", None),
"scorers": ("pyrit.backend.routes.scorers", None),
"targets": ("pyrit.backend.routes.targets", None),
diff --git a/pyrit/backend/routes/scenario_presets.py b/pyrit/backend/routes/scenario_presets.py
new file mode 100644
index 0000000000..9c6fb7c4d0
--- /dev/null
+++ b/pyrit/backend/routes/scenario_presets.py
@@ -0,0 +1,321 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""
+Scenario preset API routes.
+
+Presets are data, not code, so unlike custom initializers they are not gated behind
+an ``allow_`` switch. Reads are open and mutations require an admin, matching how
+the rest of the API treats shared, server-side configuration.
+
+Route structure:
+ GET /api/scenario-presets — list every stored preset
+ GET /api/scenario-presets/{name} — get one preset and its version
+ POST /api/scenario-presets — create a preset that must not exist
+ PUT /api/scenario-presets/{name} — replace a preset the caller has read
+ DELETE /api/scenario-presets/{name} — delete a preset
+ POST /api/scenario-presets/{name}/resolve — combine a preset with launch fields
+"""
+
+from typing import Annotated
+
+from azure.core.exceptions import AzureError
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+
+from pyrit.backend.middleware.auth import current_user_name, require_admin
+from pyrit.backend.models.common import ProblemDetail
+from pyrit.backend.models.scenario_presets import (
+ ResolveScenarioPresetRequest,
+ ScenarioPresetListResponse,
+ ScenarioPresetResponse,
+ UpdateScenarioPresetRequest,
+)
+from pyrit.backend.services.scenario_preset_service import get_scenario_preset_service
+from pyrit.models.catalog import RunScenarioRequest, ScenarioPreset
+from pyrit.registry import ScenarioPresetConflictError, ScenarioPresetNameRegisteredError
+
+router = APIRouter(prefix="/scenario-presets", tags=["scenario-presets"])
+
+
+def _storage_unavailable() -> HTTPException:
+ """
+ Create a sanitized response for unavailable preset storage.
+
+ Returns:
+ HTTPException: A service-unavailable response without SDK details.
+ """
+ return HTTPException(
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ detail="Scenario preset storage is temporarily unavailable",
+ )
+
+
+def _not_found(name: str) -> HTTPException:
+ """
+ Create a not-found response for a missing preset.
+
+ Args:
+ name: The requested preset name.
+
+ Returns:
+ HTTPException: A not-found response naming the preset.
+ """
+ return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Scenario preset '{name}' not found")
+
+
+async def _load_preset_or_404_async(name: str) -> ScenarioPresetResponse:
+ """
+ Read one preset, translating storage failures into HTTP responses.
+
+ Args:
+ name: The preset name.
+
+ Returns:
+ ScenarioPresetResponse: The stored preset, its version, and its advisory issues.
+
+ Raises:
+ HTTPException: 404 if no preset is stored, 400 for an illegal name, 503 if storage fails.
+ """
+ try:
+ preset = await get_scenario_preset_service().get_preset_async(name=name)
+ except AzureError as exc:
+ raise _storage_unavailable() from exc
+ except ValueError as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None
+
+ if preset is None:
+ raise _not_found(name)
+ return preset
+
+
+@router.get("", response_model=ScenarioPresetListResponse)
+async def list_scenario_presets( # pyrit-async-suffix-exempt
+ include_estimates: bool = Query(False, description="Opt in to each preset's own run-size estimate"),
+) -> ScenarioPresetListResponse:
+ """
+ List every readable preset from the configured storage source.
+
+ Presets that cannot be parsed are skipped rather than failing the listing.
+
+ Args:
+ include_estimates: Whether to size each preset. This is off by default because sizing
+ constructs every scenario. The scenario estimate endpoint can size the returned
+ definitions without listing storage again.
+
+ Returns:
+ ScenarioPresetListResponse: The configured source and the stored presets.
+ """
+ try:
+ return await get_scenario_preset_service().list_presets_async(include_estimates=include_estimates)
+ except AzureError as exc:
+ raise _storage_unavailable() from exc
+
+
+@router.get(
+ "/{name}",
+ response_model=ScenarioPresetResponse,
+ responses={404: {"model": ProblemDetail, "description": "Preset not found"}},
+)
+async def get_scenario_preset(name: str) -> ScenarioPresetResponse: # pyrit-async-suffix-exempt
+ """
+ Get one preset and the version required to update it.
+
+ Args:
+ name: The preset name.
+
+ Returns:
+ ScenarioPresetResponse: The stored preset, its version, and its advisory issues.
+ """
+ return await _load_preset_or_404_async(name)
+
+
+@router.post(
+ "",
+ response_model=ScenarioPresetResponse,
+ status_code=status.HTTP_201_CREATED,
+ dependencies=[Depends(require_admin)],
+ responses={
+ 409: {
+ "model": ProblemDetail,
+ "description": "A preset is already stored under this name, or the name is registered in code",
+ },
+ },
+)
+async def create_scenario_preset( # pyrit-async-suffix-exempt
+ preset: ScenarioPreset,
+ author: Annotated[str | None, Depends(current_user_name)],
+) -> ScenarioPresetResponse:
+ """
+ Create a preset that must not already exist.
+
+ References that do not resolve in this deployment are reported on the response
+ rather than rejected, so a preset authored elsewhere can still be stored here.
+
+ An author supplied in the body is kept, so importing a preset written elsewhere
+ preserves who wrote it; otherwise the signed-in user is recorded.
+
+ Args:
+ preset: The preset to create.
+ author: The signed-in user, or None when the deployment has no authentication.
+
+ Returns:
+ ScenarioPresetResponse: The persisted preset, its version, and its advisory issues.
+ """
+ if preset.author is None and author is not None:
+ preset = preset.model_copy(update={"author": author})
+
+ try:
+ return await get_scenario_preset_service().save_preset_async(preset=preset, expected_version=None)
+ except ScenarioPresetNameRegisteredError as exc:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=f"Scenario preset '{preset.name}' is registered in code and cannot be stored",
+ ) from exc
+ except ScenarioPresetConflictError as exc:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=f"Scenario preset '{preset.name}' already exists",
+ ) from exc
+ except AzureError as exc:
+ raise _storage_unavailable() from exc
+
+
+@router.put(
+ "/{name}",
+ response_model=ScenarioPresetResponse,
+ dependencies=[Depends(require_admin)],
+ responses={
+ 400: {"model": ProblemDetail, "description": "Body name does not match the path"},
+ 409: {
+ "model": ProblemDetail,
+ "description": "The preset changed after it was read, or the name is registered in code",
+ },
+ },
+)
+async def update_scenario_preset( # pyrit-async-suffix-exempt
+ name: str,
+ body: UpdateScenarioPresetRequest,
+) -> ScenarioPresetResponse:
+ """
+ Replace a preset the caller has read.
+
+ The update fails if the stored document no longer matches ``expected_version``,
+ so a concurrent edit is reported instead of being silently overwritten.
+ An omitted or null author preserves the stored author.
+
+ Args:
+ name: The preset name from the path, which is authoritative.
+ body: The replacement preset and the version it is replacing.
+
+ Returns:
+ ScenarioPresetResponse: The persisted preset, its new version, and its advisory issues.
+
+ Raises:
+ HTTPException: 400 if the body names a different preset, 409 if the stored version
+ moved or the name is registered in code.
+ """
+ if body.preset.name != name:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Preset name '{body.preset.name}' does not match path '{name}'",
+ )
+
+ try:
+ service = get_scenario_preset_service()
+ preset = body.preset
+ if preset.author is None:
+ stored = await service.get_preset_async(name=name)
+ if stored is not None:
+ preset = preset.model_copy(update={"author": stored.preset.author})
+ return await service.save_preset_async(preset=preset, expected_version=body.expected_version)
+ except ScenarioPresetNameRegisteredError as exc:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=f"Scenario preset '{name}' is registered in code and cannot be stored",
+ ) from exc
+ except ScenarioPresetConflictError as exc:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=f"Scenario preset '{name}' changed since it was read; re-read it and retry",
+ ) from exc
+ except AzureError as exc:
+ raise _storage_unavailable() from exc
+
+
+@router.delete(
+ "/{name}",
+ status_code=status.HTTP_204_NO_CONTENT,
+ dependencies=[Depends(require_admin)],
+ responses={404: {"model": ProblemDetail, "description": "Preset not found"}},
+)
+async def delete_scenario_preset(name: str) -> None: # pyrit-async-suffix-exempt
+ """
+ Delete one stored preset.
+
+ Args:
+ name: The preset name.
+
+ Raises:
+ HTTPException: 404 if no preset is stored, 400 for an illegal name, 503 if storage fails.
+ """
+ try:
+ deleted = await get_scenario_preset_service().delete_preset_async(name=name)
+ except AzureError as exc:
+ raise _storage_unavailable() from exc
+ except ValueError as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None
+
+ if not deleted:
+ raise _not_found(name)
+
+
+@router.post(
+ "/{name}/resolve",
+ response_model=RunScenarioRequest,
+ responses={
+ 404: {"model": ProblemDetail, "description": "Preset not found"},
+ 409: {"model": ProblemDetail, "description": "The preset changed after it was read"},
+ },
+)
+async def resolve_scenario_preset( # pyrit-async-suffix-exempt
+ name: str,
+ body: ResolveScenarioPresetRequest,
+) -> RunScenarioRequest:
+ """
+ Combine a stored preset with the launch-owned fields it omits.
+
+ The result is an ordinary run request for the existing ``POST /scenarios/runs``
+ endpoint. Resolving here rather than in each client keeps one implementation of
+ the merge, so the distinction between "unset" and "set to the default" cannot
+ drift between callers.
+
+ When the caller supplies ``expected_version`` the stored version must still match,
+ so an edit landing between the preview and the launch is reported rather than
+ quietly running a configuration the operator never confirmed.
+
+ Args:
+ name: The preset name.
+ body: The target and execution fields for this launch.
+
+ Returns:
+ RunScenarioRequest: The request to post to the scenario run endpoint.
+
+ Raises:
+ HTTPException: 404 if no preset is stored, 409 if the stored version moved,
+ 400 for an illegal name, 503 if storage fails.
+ """
+ try:
+ resolved = await get_scenario_preset_service().resolve_run_request_async(name=name, launch=body)
+ except ScenarioPresetConflictError:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=f"Scenario preset '{name}' changed since it was read; re-read it and retry",
+ ) from None
+ except AzureError as exc:
+ raise _storage_unavailable() from exc
+ except ValueError as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None
+
+ if resolved is None:
+ raise _not_found(name)
+ return resolved
diff --git a/pyrit/backend/services/__init__.py b/pyrit/backend/services/__init__.py
index 918bdfcdb0..ed5975bfcf 100644
--- a/pyrit/backend/services/__init__.py
+++ b/pyrit/backend/services/__init__.py
@@ -17,6 +17,7 @@
from pyrit.backend.services.converter_service import ConverterService, get_converter_service
from pyrit.backend.services.dataset_service import DatasetService, get_dataset_service
from pyrit.backend.services.initializer_service import InitializerService, get_initializer_service
+ from pyrit.backend.services.scenario_preset_service import ScenarioPresetService, get_scenario_preset_service
from pyrit.backend.services.scenario_run_service import ScenarioRunService, get_scenario_run_service
from pyrit.backend.services.scenario_service import ScenarioService, get_scenario_service
from pyrit.backend.services.target_service import TargetService, get_target_service
@@ -32,6 +33,8 @@
"get_initializer_service": "pyrit.backend.services.initializer_service",
"ScenarioService": "pyrit.backend.services.scenario_service",
"get_scenario_service": "pyrit.backend.services.scenario_service",
+ "ScenarioPresetService": "pyrit.backend.services.scenario_preset_service",
+ "get_scenario_preset_service": "pyrit.backend.services.scenario_preset_service",
"ScenarioRunService": "pyrit.backend.services.scenario_run_service",
"get_scenario_run_service": "pyrit.backend.services.scenario_run_service",
"TargetService": "pyrit.backend.services.target_service",
diff --git a/pyrit/backend/services/scenario_preset_service.py b/pyrit/backend/services/scenario_preset_service.py
new file mode 100644
index 0000000000..32e8c8afe4
--- /dev/null
+++ b/pyrit/backend/services/scenario_preset_service.py
@@ -0,0 +1,264 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""
+Scenario preset service for CRUD and launch resolution.
+"""
+
+import asyncio
+import logging
+from functools import lru_cache
+from typing import Any, Literal
+
+from pyrit.backend.models.scenario_presets import (
+ ResolveScenarioPresetRequest,
+ ScenarioPresetListResponse,
+ ScenarioPresetResponse,
+)
+from pyrit.backend.services.scenario_service import get_scenario_service
+from pyrit.models.catalog import (
+ RunScenarioRequest,
+ ScenarioPreset,
+ ScenarioRunSizeEstimate,
+ ScenarioRunSizeEstimateRequest,
+)
+from pyrit.registry import ScenarioPresetRegistry
+
+logger = logging.getLogger(__name__)
+
+
+class ScenarioPresetService:
+ """
+ Wire adapter over ``ScenarioPresetRegistry``.
+
+ The registry owns preset storage, validation, and version-checked resolution, so this
+ service adds only what the HTTP layer needs on top of it: it runs the registry's
+ synchronous file and blob I/O on a worker thread so a directory listing cannot block
+ the event loop, and it attaches run-size estimates, which are the one part of a preset
+ response that has to be computed by the backend rather than read from storage.
+ """
+
+ async def list_presets_async(self, *, include_estimates: bool = False) -> ScenarioPresetListResponse:
+ """
+ Read every stored preset, newest advisory issues included.
+
+ Args:
+ include_estimates (bool): Whether to size each preset's own configuration. Sizing
+ constructs every scenario and is opt-in. Callers can estimate the returned
+ configurations through the scenario estimate endpoint without re-reading storage.
+
+ Returns:
+ ScenarioPresetListResponse: The configured source and every readable preset.
+ """
+ registry = ScenarioPresetRegistry.get_registry_singleton()
+ items = await asyncio.to_thread(self._read_responses, registry)
+ if include_estimates:
+ estimates = await asyncio.gather(*(self._estimate_run_size_async(preset=item.preset) for item in items))
+ items = [
+ item.model_copy(update={"run_size": estimate}) for item, estimate in zip(items, estimates, strict=True)
+ ]
+ return ScenarioPresetListResponse(source=registry.source, items=items)
+
+ async def get_preset_async(self, *, name: str) -> ScenarioPresetResponse | None:
+ """
+ Read one stored preset.
+
+ Args:
+ name (str): The preset name.
+
+ Returns:
+ ScenarioPresetResponse | None: The preset, or None if nothing is stored under *name*.
+ """
+ entry = await asyncio.to_thread(ScenarioPresetRegistry.get_registry_singleton().get_preset, name=name)
+ if entry is None:
+ return None
+ return await self._to_response_async(preset=entry.preset, version=entry.version, origin=entry.origin)
+
+ async def save_preset_async(
+ self,
+ *,
+ preset: ScenarioPreset,
+ expected_version: str | None,
+ ) -> ScenarioPresetResponse:
+ """
+ Persist one preset.
+
+ Unresolvable references are reported on the response rather than rejected, so a
+ preset authored against one deployment can be saved on another.
+
+ Args:
+ preset (ScenarioPreset): The preset to persist.
+ expected_version (str | None): None to create a preset that must not already exist,
+ or the version returned when the edited preset was read.
+
+ Returns:
+ ScenarioPresetResponse: The persisted preset, its new version, and its advisory issues.
+
+ Raises:
+ ScenarioPresetConflictError: If the stored version does not match *expected_version*.
+ """
+ stored = await asyncio.to_thread(
+ ScenarioPresetRegistry.get_registry_singleton().save_preset,
+ preset=preset,
+ expected_version=expected_version,
+ )
+ return await self._to_response_async(preset=stored.preset, version=stored.version, origin="stored")
+
+ async def delete_preset_async(self, *, name: str) -> bool:
+ """
+ Delete one stored preset.
+
+ Args:
+ name (str): The preset name.
+
+ Returns:
+ bool: True if a preset was stored under *name*, False if nothing was.
+ """
+ return await asyncio.to_thread(ScenarioPresetRegistry.get_registry_singleton().delete_preset, name=name)
+
+ async def resolve_run_request_async(
+ self, *, name: str, launch: ResolveScenarioPresetRequest
+ ) -> RunScenarioRequest | None:
+ """
+ Read one preset for launch and combine it with the launch-owned fields.
+
+ The version check belongs to the read, so it is performed by the registry against
+ a fresh read rather than by the caller against a response it already holds. A
+ caller that re-implemented it would be comparing a version to itself.
+
+ Args:
+ name (str): The preset name.
+ launch (ResolveScenarioPresetRequest): The target and execution fields for this launch.
+
+ Returns:
+ RunScenarioRequest | None: The resolved run request, or None if nothing usable
+ is stored under *name*.
+
+ Raises:
+ ScenarioPresetConflictError: If the stored version no longer matches the version
+ the caller supplied.
+ """
+ stored = await asyncio.to_thread(
+ ScenarioPresetRegistry.get_registry_singleton().resolve_preset,
+ name=name,
+ expected_version=launch.expected_version,
+ )
+ return None if stored is None else self.resolve_run_request(preset=stored.preset, launch=launch)
+
+ @staticmethod
+ def resolve_run_request(*, preset: ScenarioPreset, launch: ResolveScenarioPresetRequest) -> RunScenarioRequest:
+ """
+ Combine a preset with the launch-owned fields it omits.
+
+ ``max_concurrency`` and ``max_retries`` are the only run fields that are not
+ tri-state, so an unset value is dropped rather than passed as None. Passing it
+ through would pin the run default at resolution time instead of letting the
+ request model supply it.
+
+ Args:
+ preset (ScenarioPreset): The preset supplying the scenario-owned fields.
+ launch (ResolveScenarioPresetRequest): The target and execution fields for this launch.
+
+ Returns:
+ RunScenarioRequest: The request to post to the existing scenario run endpoint.
+ """
+ run_defaults: dict[str, Any] = {
+ "max_concurrency": launch.max_concurrency,
+ "max_retries": launch.max_retries,
+ }
+
+ return RunScenarioRequest(
+ scenario_name=preset.scenario_name,
+ techniques=preset.techniques,
+ datasets=preset.datasets,
+ include_baseline=preset.include_baseline,
+ scenario_params=preset.scenario_params,
+ target_name=launch.target_name,
+ adversarial_target_name=launch.adversarial_target_name,
+ initializers=launch.initializers,
+ initializer_args=launch.initializer_args,
+ labels=launch.labels,
+ **{name: value for name, value in run_defaults.items() if value is not None},
+ )
+
+ @staticmethod
+ def _read_responses(registry: ScenarioPresetRegistry) -> list[ScenarioPresetResponse]:
+ """
+ Read and check every stored preset in one worker-thread hop.
+
+ Listing and checking are both synchronous, so they share a hop rather than taking
+ one per preset.
+
+ Args:
+ registry (ScenarioPresetRegistry): The registry to read through.
+
+ Returns:
+ list[ScenarioPresetResponse]: Every readable preset, sorted by name.
+ """
+ return [
+ ScenarioPresetResponse(
+ preset=entry.preset,
+ origin=entry.origin,
+ version=entry.version,
+ issues=registry.check_preset(preset=entry.preset),
+ )
+ for entry in registry.list_presets()
+ ]
+
+ @staticmethod
+ async def _to_response_async(
+ *, preset: ScenarioPreset, version: str | None, origin: Literal["stored", "registered"]
+ ) -> ScenarioPresetResponse:
+ """
+ Attach advisory issues to a preset.
+
+ Args:
+ preset (ScenarioPreset): The preset to describe.
+ version (str | None): The version of the document it was read from, or None
+ for a preset registered in code.
+ origin (Literal["stored", "registered"]): Which layer contributed the preset.
+
+ Returns:
+ ScenarioPresetResponse: The wire representation of the preset.
+ """
+ issues = await asyncio.to_thread(ScenarioPresetRegistry.get_registry_singleton().check_preset, preset=preset)
+ return ScenarioPresetResponse(preset=preset, origin=origin, version=version, issues=issues)
+
+ async def _estimate_run_size_async(self, *, preset: ScenarioPreset) -> ScenarioRunSizeEstimate | None:
+ """
+ Size one preset against its own techniques, datasets, and limits.
+
+ A preset is allowed to name things this deployment has not registered, so a
+ preset that cannot be sized reports no size rather than failing the listing
+ that every other preset shares.
+
+ Args:
+ preset (ScenarioPreset): The preset to size.
+
+ Returns:
+ ScenarioRunSizeEstimate | None: The estimate, or None when it cannot be computed here.
+ """
+ request = ScenarioRunSizeEstimateRequest(
+ techniques=preset.techniques,
+ datasets=preset.datasets,
+ include_baseline=preset.include_baseline,
+ scenario_params=preset.scenario_params,
+ )
+ try:
+ return await get_scenario_service().estimate_scenario_run_size_async(
+ scenario_name=preset.scenario_name, request=request
+ )
+ except Exception:
+ logger.debug("Could not size scenario preset '%s'", preset.name, exc_info=True)
+ return None
+
+
+@lru_cache(maxsize=1)
+def get_scenario_preset_service() -> ScenarioPresetService:
+ """
+ Get the global scenario preset service instance.
+
+ Returns:
+ ScenarioPresetService: The singleton scenario preset service instance.
+ """
+ return ScenarioPresetService()
diff --git a/pyrit/models/catalog/scenario_preset.py b/pyrit/models/catalog/scenario_preset.py
index 7c3aa93d6f..065a494686 100644
--- a/pyrit/models/catalog/scenario_preset.py
+++ b/pyrit/models/catalog/scenario_preset.py
@@ -21,15 +21,13 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pyrit.models.catalog.scenario import (
- _RequestFilters,
_RequestName,
- _RequestNames,
_RequestParams,
_RequestTechniques,
- _validate_dataset_filter_mapping,
)
from pyrit.models.identifiers.class_name_utils import validate_registry_name
from pyrit.models.identifiers.component_identifier import ComponentIdentifier, Identifiable
+from pyrit.models.scenario_dataset import ScenarioDatasetRequest
PRESET_SCHEMA_VERSION = 1
@@ -59,7 +57,11 @@ class ScenarioPreset(BaseModel, Identifiable):
model_config = ConfigDict(extra="forbid")
name: str = Field(..., description="Unique preset name, used as the storage key and as the reference from scans")
- schema_version: int = Field(PRESET_SCHEMA_VERSION, ge=1, description="Shape of this document")
+ # Literal rather than PRESET_SCHEMA_VERSION: a document written before this field existed
+ # is a version 1 document, and must keep reading back as one after the constant is bumped.
+ # Defaulting to the constant would relabel those documents as the newest version while they
+ # still hold the oldest content, so a migration would skip exactly the documents needing it.
+ schema_version: int = Field(1, ge=1, description="Shape of this document")
scenario_name: _RequestName = Field(..., min_length=1, description="Registered scenario this preset configures")
description: str | None = Field(None, description="Human-readable summary of what this preset tests")
author: str | None = Field(
@@ -67,11 +69,9 @@ class ScenarioPreset(BaseModel, Identifiable):
description="Who created this preset; descriptive only, never used to authorize a read or a write",
)
techniques: _RequestTechniques | None = Field(None, description="Technique names; None uses the scenario default")
- dataset_names: _RequestNames | None = Field(None, description="Dataset names; None uses the scenario default")
- max_dataset_size: int | None = Field(None, ge=1, description="Maximum selected logical seed groups")
- dataset_filters: _RequestFilters | None = Field(
+ datasets: ScenarioDatasetRequest | None = Field(
None,
- description="Dataset seed filters keyed by field. Accepted keys: harm_categories, data_types.",
+ description="Dataset sources, limits, fetch policy, and filters. Omit to use the scenario defaults.",
)
include_baseline: bool | None = Field(None, description="Override the scenario baseline default")
scenario_params: _RequestParams | None = Field(
@@ -93,17 +93,6 @@ def _validate_name(cls, value: str) -> str:
validate_registry_name(value)
return value
- @field_validator("dataset_filters")
- @classmethod
- def _validate_dataset_filters(cls, value: dict[str, list[str]] | None) -> dict[str, list[str]] | None:
- """
- Validate dataset filters against the shared allow-list.
-
- Returns:
- dict[str, list[str]] | None: Validated filters.
- """
- return _validate_dataset_filter_mapping(value)
-
@field_validator("schema_version")
@classmethod
def _validate_schema_version(cls, value: int) -> int:
diff --git a/tests/unit/backend/test_scenario_preset_routes.py b/tests/unit/backend/test_scenario_preset_routes.py
new file mode 100644
index 0000000000..b18c320273
--- /dev/null
+++ b/tests/unit/backend/test_scenario_preset_routes.py
@@ -0,0 +1,531 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""Tests for the scenario preset routes."""
+
+from collections.abc import Iterator
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from azure.core.exceptions import AzureError
+from fastapi.testclient import TestClient
+
+from pyrit.backend.main import app
+from pyrit.backend.middleware.auth import current_user_name, require_admin
+from pyrit.backend.models.scenario_presets import (
+ PresetIssue,
+ ScenarioPresetListResponse,
+ ScenarioPresetResponse,
+)
+from pyrit.backend.services.scenario_preset_service import ScenarioPresetService
+from pyrit.models.catalog import ScenarioPreset, ScenarioRunSizeEstimate
+from pyrit.models.catalog.scenario import ScenarioRunSizeComponent
+from pyrit.registry import ScenarioPresetConflictError, ScenarioPresetNameRegisteredError
+
+PRESET_NAME = "quick_scan"
+SCENARIO_NAME = "foundry.red_team_agent"
+
+
+@pytest.fixture
+def client(compatibility_headers: dict[str, str]) -> Iterator[TestClient]:
+ """Create a test client with admin authorization satisfied."""
+ app.dependency_overrides[require_admin] = lambda: None
+ try:
+ yield TestClient(app, headers=compatibility_headers)
+ finally:
+ app.dependency_overrides.pop(require_admin, None)
+
+
+@pytest.fixture
+def anonymous_client(compatibility_headers: dict[str, str]) -> Iterator[TestClient]:
+ """Create a test client without admin authorization."""
+ yield TestClient(app, headers=compatibility_headers)
+
+
+@pytest.fixture
+def service() -> Iterator[MagicMock]:
+ """Patch the preset service the routes resolve."""
+ mock_service = MagicMock(spec=ScenarioPresetService)
+ mock_service.get_preset_async.return_value = _response()
+ with patch(
+ "pyrit.backend.routes.scenario_presets.get_scenario_preset_service",
+ return_value=mock_service,
+ ):
+ yield mock_service
+
+
+@pytest.fixture
+def signed_in_user() -> Iterator[None]:
+ """Report a signed-in user to routes that record one."""
+ app.dependency_overrides[current_user_name] = lambda: "Ada Lovelace"
+ try:
+ yield
+ finally:
+ app.dependency_overrides.pop(current_user_name, None)
+
+
+def _estimate(count: int) -> ScenarioRunSizeEstimate:
+ """Build an exact run-size estimate of *count* attacks."""
+ return ScenarioRunSizeEstimate(
+ total_attack_count=count,
+ components=[ScenarioRunSizeComponent(label="Preset sweep", count=count)],
+ )
+
+
+def _preset(name: str = PRESET_NAME) -> ScenarioPreset:
+ """Build a minimal preset."""
+ return ScenarioPreset(name=name, scenario_name=SCENARIO_NAME)
+
+
+def _response(
+ *,
+ name: str = PRESET_NAME,
+ version: str | None = "v1",
+ origin: str = "stored",
+ issues: list[PresetIssue] | None = None,
+ run_size: ScenarioRunSizeEstimate | None = None,
+):
+ """Build a preset response envelope."""
+ return ScenarioPresetResponse(
+ preset=_preset(name),
+ origin=origin, # type: ignore[arg-type]
+ version=version,
+ issues=issues or [],
+ run_size=run_size,
+ )
+
+
+class TestListPresets:
+ """GET /api/scenario-presets."""
+
+ def test_list_returns_the_source_and_items(self, client: TestClient, service: MagicMock) -> None:
+ service.list_presets_async = AsyncMock(
+ return_value=ScenarioPresetListResponse(source="/tmp/presets", items=[_response()])
+ )
+
+ response = client.get("/api/scenario-presets")
+
+ assert response.status_code == 200
+ body = response.json()
+ assert body["source"] == "/tmp/presets"
+ assert body["items"][0]["preset"]["name"] == PRESET_NAME
+ assert body["items"][0]["version"] == "v1"
+ assert service.list_presets_async.call_args.kwargs["include_estimates"] is False
+
+ def test_list_serializes_a_registered_preset_with_a_null_version(
+ self, client: TestClient, service: MagicMock
+ ) -> None:
+ service.list_presets_async = AsyncMock(
+ return_value=ScenarioPresetListResponse(source="/tmp", items=[_response(origin="registered", version=None)])
+ )
+
+ response = client.get("/api/scenario-presets")
+
+ assert response.status_code == 200
+ assert response.json()["items"][0]["version"] is None
+ assert response.json()["items"][0]["origin"] == "registered"
+
+ def test_list_reports_advisory_issues(self, client: TestClient, service: MagicMock) -> None:
+ issue = PresetIssue(field="scenario_name", message="Scenario is not registered in this deployment.")
+ service.list_presets_async = AsyncMock(
+ return_value=ScenarioPresetListResponse(source="/tmp", items=[_response(issues=[issue])])
+ )
+
+ response = client.get("/api/scenario-presets")
+
+ assert response.json()["items"][0]["issues"] == [
+ {"field": "scenario_name", "message": "Scenario is not registered in this deployment."}
+ ]
+
+ def test_list_is_readable_without_admin(self, anonymous_client: TestClient, service: MagicMock) -> None:
+ service.list_presets_async = AsyncMock(return_value=ScenarioPresetListResponse(source="/tmp", items=[]))
+
+ assert anonymous_client.get("/api/scenario-presets").status_code == 200
+
+ def test_storage_failure_is_reported_without_sdk_detail(self, client: TestClient, service: MagicMock) -> None:
+ service.list_presets_async = AsyncMock(side_effect=AzureError("container 'x' key=secret"))
+
+ response = client.get("/api/scenario-presets")
+
+ assert response.status_code == 503
+ assert "secret" not in response.text
+
+ def test_each_preset_carries_its_own_run_size(self, client: TestClient, service: MagicMock) -> None:
+ estimate = _estimate(42)
+ service.list_presets_async = AsyncMock(
+ return_value=ScenarioPresetListResponse(source="/tmp", items=[_response(run_size=estimate)])
+ )
+
+ response = client.get("/api/scenario-presets", params={"include_estimates": "true"})
+
+ assert response.json()["items"][0]["run_size"]["total_attack_count"] == 42
+ assert service.list_presets_async.call_args.kwargs["include_estimates"] is True
+
+ def test_sizing_can_be_skipped_so_the_table_paints_first(self, client: TestClient, service: MagicMock) -> None:
+ service.list_presets_async = AsyncMock(
+ return_value=ScenarioPresetListResponse(source="/tmp", items=[_response()])
+ )
+
+ response = client.get("/api/scenario-presets", params={"include_estimates": "false"})
+
+ assert response.status_code == 200
+ assert response.json()["items"][0]["run_size"] is None
+ assert service.list_presets_async.call_args.kwargs["include_estimates"] is False
+
+
+class TestGetPreset:
+ """GET /api/scenario-presets/{name}."""
+
+ def test_get_returns_the_preset_and_version(self, client: TestClient, service: MagicMock) -> None:
+ service.get_preset_async = AsyncMock(return_value=_response())
+
+ response = client.get(f"/api/scenario-presets/{PRESET_NAME}")
+
+ assert response.status_code == 200
+ assert response.json()["version"] == "v1"
+
+ def test_get_reports_a_missing_preset(self, client: TestClient, service: MagicMock) -> None:
+ service.get_preset_async = AsyncMock(return_value=None)
+
+ response = client.get("/api/scenario-presets/missing")
+
+ assert response.status_code == 404
+
+ def test_an_illegal_name_is_a_client_error(self, client: TestClient, service: MagicMock) -> None:
+ service.get_preset_async = AsyncMock(side_effect=ValueError("Invalid registry name 'Bad Name'"))
+
+ response = client.get("/api/scenario-presets/BadName")
+
+ assert response.status_code == 400
+
+
+class TestCreatePreset:
+ """POST /api/scenario-presets."""
+
+ def test_create_returns_201_with_the_new_version(self, client: TestClient, service: MagicMock) -> None:
+ service.save_preset_async = AsyncMock(return_value=_response(version="v2"))
+
+ response = client.post("/api/scenario-presets", json=_preset().model_dump())
+
+ assert response.status_code == 201
+ assert response.json()["version"] == "v2"
+ assert service.save_preset_async.call_args.kwargs["expected_version"] is None
+
+ def test_create_over_an_existing_name_is_a_conflict(self, client: TestClient, service: MagicMock) -> None:
+ service.save_preset_async = AsyncMock(
+ side_effect=ScenarioPresetConflictError(name=PRESET_NAME, expected_version=None, actual_version="v1")
+ )
+
+ response = client.post("/api/scenario-presets", json=_preset().model_dump())
+
+ assert response.status_code == 409
+
+ def test_create_over_a_registered_name_is_a_conflict(self, client: TestClient, service: MagicMock) -> None:
+ service.save_preset_async = AsyncMock(side_effect=ScenarioPresetNameRegisteredError(name=PRESET_NAME))
+
+ response = client.post("/api/scenario-presets", json=_preset().model_dump())
+
+ assert response.status_code == 409
+
+ def test_create_requires_admin(self, anonymous_client: TestClient, service: MagicMock) -> None:
+ service.save_preset_async = AsyncMock(return_value=_response())
+
+ response = anonymous_client.post("/api/scenario-presets", json=_preset().model_dump())
+
+ assert response.status_code == 403
+ service.save_preset_async.assert_not_called()
+
+ def test_an_unknown_field_is_rejected(self, client: TestClient, service: MagicMock) -> None:
+ payload = _preset().model_dump()
+ payload["tecniques"] = ["crescendo"]
+
+ response = client.post("/api/scenario-presets", json=payload)
+
+ assert response.status_code == 422
+
+ @pytest.mark.usefixtures("signed_in_user")
+ def test_the_signed_in_user_is_recorded_as_the_author(self, client: TestClient, service: MagicMock) -> None:
+ service.save_preset_async = AsyncMock(return_value=_response())
+
+ client.post("/api/scenario-presets", json=_preset().model_dump())
+
+ assert service.save_preset_async.call_args.kwargs["preset"].author == "Ada Lovelace"
+
+ @pytest.mark.usefixtures("signed_in_user")
+ def test_an_author_in_the_body_survives_import(self, client: TestClient, service: MagicMock) -> None:
+ service.save_preset_async = AsyncMock(return_value=_response())
+ payload = _preset().model_dump()
+ payload["author"] = "Grace Hopper"
+
+ client.post("/api/scenario-presets", json=payload)
+
+ assert service.save_preset_async.call_args.kwargs["preset"].author == "Grace Hopper"
+
+ def test_an_unauthenticated_deployment_leaves_the_author_unset(
+ self, client: TestClient, service: MagicMock
+ ) -> None:
+ service.save_preset_async = AsyncMock(return_value=_response())
+
+ client.post("/api/scenario-presets", json=_preset().model_dump())
+
+ assert service.save_preset_async.call_args.kwargs["preset"].author is None
+
+
+class TestUpdatePreset:
+ """PUT /api/scenario-presets/{name}."""
+
+ def test_update_passes_the_expected_version_through(self, client: TestClient, service: MagicMock) -> None:
+ service.save_preset_async = AsyncMock(return_value=_response(version="v2"))
+
+ response = client.put(
+ f"/api/scenario-presets/{PRESET_NAME}",
+ json={"preset": _preset().model_dump(), "expected_version": "v1"},
+ )
+
+ assert response.status_code == 200
+ assert service.save_preset_async.call_args.kwargs["expected_version"] == "v1"
+
+ @pytest.mark.parametrize("explicit_null", [False, True])
+ @pytest.mark.usefixtures("signed_in_user")
+ def test_an_update_without_an_author_preserves_the_original_author(
+ self, *, client: TestClient, service: MagicMock, explicit_null: bool
+ ) -> None:
+ stored = _preset().model_copy(update={"author": "Grace Hopper"})
+ service.get_preset_async.return_value = _response().model_copy(update={"preset": stored})
+ service.save_preset_async.return_value = _response(version="v2").model_copy(update={"preset": stored})
+ replacement = _preset().model_dump(exclude_none=True)
+ if explicit_null:
+ replacement["author"] = None
+
+ response = client.put(
+ f"/api/scenario-presets/{PRESET_NAME}",
+ json={"preset": replacement, "expected_version": "v1"},
+ )
+
+ assert response.status_code == 200
+ assert response.json()["preset"]["author"] == "Grace Hopper"
+ service.get_preset_async.assert_awaited_once_with(name=PRESET_NAME)
+ assert service.save_preset_async.call_args.kwargs["preset"].author == "Grace Hopper"
+ assert service.save_preset_async.call_args.kwargs["expected_version"] == "v1"
+
+ def test_an_explicit_author_is_preserved_without_an_extra_read(
+ self, *, client: TestClient, service: MagicMock
+ ) -> None:
+ service.save_preset_async.return_value = _response(version="v2")
+ replacement = _preset().model_dump()
+ replacement["author"] = "Explicit author"
+
+ response = client.put(
+ f"/api/scenario-presets/{PRESET_NAME}",
+ json={"preset": replacement, "expected_version": "v1"},
+ )
+
+ assert response.status_code == 200
+ assert service.save_preset_async.call_args.kwargs["preset"].author == "Explicit author"
+ service.get_preset_async.assert_not_called()
+
+ def test_preserving_an_author_does_not_adopt_a_newer_storage_version(
+ self, *, client: TestClient, service: MagicMock
+ ) -> None:
+ stored = _preset().model_copy(update={"author": "Grace Hopper"})
+ service.get_preset_async.return_value = _response(version="v2").model_copy(update={"preset": stored})
+ service.save_preset_async.side_effect = ScenarioPresetConflictError(
+ name=PRESET_NAME, expected_version="v1", actual_version="v2"
+ )
+
+ response = client.put(
+ f"/api/scenario-presets/{PRESET_NAME}",
+ json={"preset": _preset().model_dump(), "expected_version": "v1"},
+ )
+
+ assert response.status_code == 409
+ assert service.save_preset_async.call_args.kwargs["expected_version"] == "v1"
+
+ def test_a_missing_preset_still_conflicts_instead_of_being_recreated(
+ self, *, client: TestClient, service: MagicMock
+ ) -> None:
+ service.get_preset_async.return_value = None
+ service.save_preset_async.side_effect = ScenarioPresetConflictError(
+ name=PRESET_NAME, expected_version="v1", actual_version=None
+ )
+
+ response = client.put(
+ f"/api/scenario-presets/{PRESET_NAME}",
+ json={"preset": _preset().model_dump(), "expected_version": "v1"},
+ )
+
+ assert response.status_code == 409
+ assert service.save_preset_async.call_args.kwargs["expected_version"] == "v1"
+
+ def test_a_failed_author_read_is_reported_without_storage_details(
+ self, *, client: TestClient, service: MagicMock
+ ) -> None:
+ service.get_preset_async.side_effect = AzureError("key=secret")
+
+ response = client.put(
+ f"/api/scenario-presets/{PRESET_NAME}",
+ json={"preset": _preset().model_dump(), "expected_version": "v1"},
+ )
+
+ assert response.status_code == 503
+ assert "secret" not in response.text
+ service.save_preset_async.assert_not_called()
+
+ def test_a_body_naming_a_different_preset_is_rejected(self, client: TestClient, service: MagicMock) -> None:
+ service.save_preset_async = AsyncMock(return_value=_response())
+
+ response = client.put(
+ "/api/scenario-presets/other_name",
+ json={"preset": _preset().model_dump(), "expected_version": "v1"},
+ )
+
+ assert response.status_code == 400
+ service.save_preset_async.assert_not_called()
+
+ def test_a_stale_version_is_a_conflict(self, client: TestClient, service: MagicMock) -> None:
+ service.save_preset_async = AsyncMock(
+ side_effect=ScenarioPresetConflictError(name=PRESET_NAME, expected_version="v1", actual_version="v2")
+ )
+
+ response = client.put(
+ f"/api/scenario-presets/{PRESET_NAME}",
+ json={"preset": _preset().model_dump(), "expected_version": "v1"},
+ )
+
+ assert response.status_code == 409
+
+ def test_update_of_a_registered_name_is_a_conflict(self, client: TestClient, service: MagicMock) -> None:
+ service.save_preset_async = AsyncMock(side_effect=ScenarioPresetNameRegisteredError(name=PRESET_NAME))
+
+ response = client.put(
+ f"/api/scenario-presets/{PRESET_NAME}",
+ json={"preset": _preset().model_dump(), "expected_version": "v1"},
+ )
+
+ assert response.status_code == 409
+
+ def test_an_omitted_version_is_rejected(self, client: TestClient, service: MagicMock) -> None:
+ response = client.put(
+ f"/api/scenario-presets/{PRESET_NAME}",
+ json={"preset": _preset().model_dump()},
+ )
+
+ assert response.status_code == 422
+
+ def test_update_requires_admin(self, anonymous_client: TestClient, service: MagicMock) -> None:
+ service.save_preset_async = AsyncMock(return_value=_response())
+
+ response = anonymous_client.put(
+ f"/api/scenario-presets/{PRESET_NAME}",
+ json={"preset": _preset().model_dump(), "expected_version": "v1"},
+ )
+
+ assert response.status_code == 403
+ service.save_preset_async.assert_not_called()
+
+
+class TestDeletePreset:
+ """DELETE /api/scenario-presets/{name}."""
+
+ def test_delete_returns_204(self, client: TestClient, service: MagicMock) -> None:
+ service.delete_preset_async = AsyncMock(return_value=True)
+
+ response = client.delete(f"/api/scenario-presets/{PRESET_NAME}")
+
+ assert response.status_code == 204
+
+ def test_delete_reports_a_missing_preset(self, client: TestClient, service: MagicMock) -> None:
+ service.delete_preset_async = AsyncMock(return_value=False)
+
+ response = client.delete(f"/api/scenario-presets/{PRESET_NAME}")
+
+ assert response.status_code == 404
+
+ def test_delete_requires_admin(self, anonymous_client: TestClient, service: MagicMock) -> None:
+ service.delete_preset_async = AsyncMock(return_value=True)
+
+ response = anonymous_client.delete(f"/api/scenario-presets/{PRESET_NAME}")
+
+ assert response.status_code == 403
+ service.delete_preset_async.assert_not_called()
+
+
+class TestResolvePreset:
+ """POST /api/scenario-presets/{name}/resolve."""
+
+ @staticmethod
+ def _resolving(preset: ScenarioPreset | None = None) -> AsyncMock:
+ """
+ Stand in for the service's read-and-merge.
+
+ The route delegates the read, the version check, and the merge wholesale, so the
+ stub performs the real merge and these tests cover only what the route adds:
+ mapping absence and conflict onto status codes.
+ """
+ resolved = preset if preset is not None else _preset()
+ return AsyncMock(
+ side_effect=lambda *, name, launch: ScenarioPresetService.resolve_run_request(
+ preset=resolved, launch=launch
+ )
+ )
+
+ def test_resolve_returns_a_run_request(self, client: TestClient, service: MagicMock) -> None:
+ service.resolve_run_request_async = self._resolving()
+
+ response = client.post(
+ f"/api/scenario-presets/{PRESET_NAME}/resolve",
+ json={"target_name": "gpt4"},
+ )
+
+ assert response.status_code == 200
+ body = response.json()
+ assert body["scenario_name"] == SCENARIO_NAME
+ assert body["target_name"] == "gpt4"
+ assert body["max_concurrency"] == 10
+ assert body["techniques"] is None
+
+ def test_resolve_reports_a_missing_preset(self, client: TestClient, service: MagicMock) -> None:
+ service.resolve_run_request_async = AsyncMock(return_value=None)
+
+ response = client.post("/api/scenario-presets/missing/resolve", json={"target_name": "gpt4"})
+
+ assert response.status_code == 404
+
+ def test_resolve_requires_a_target(self, client: TestClient, service: MagicMock) -> None:
+ service.resolve_run_request_async = self._resolving()
+
+ response = client.post(f"/api/scenario-presets/{PRESET_NAME}/resolve", json={})
+
+ assert response.status_code == 422
+
+ def test_resolve_passes_the_version_the_caller_read_through(self, client: TestClient, service: MagicMock) -> None:
+ service.resolve_run_request_async = self._resolving()
+
+ response = client.post(
+ f"/api/scenario-presets/{PRESET_NAME}/resolve",
+ json={"target_name": "gpt4", "expected_version": "v1"},
+ )
+
+ assert response.status_code == 200
+ assert service.resolve_run_request_async.call_args.kwargs["launch"].expected_version == "v1"
+
+ def test_a_preset_edited_after_it_was_read_is_a_conflict(self, client: TestClient, service: MagicMock) -> None:
+ service.resolve_run_request_async = AsyncMock(
+ side_effect=ScenarioPresetConflictError(name=PRESET_NAME, expected_version="v1", actual_version="v2")
+ )
+
+ response = client.post(
+ f"/api/scenario-presets/{PRESET_NAME}/resolve",
+ json={"target_name": "gpt4", "expected_version": "v1"},
+ )
+
+ assert response.status_code == 409
+ assert PRESET_NAME in response.json()["detail"]
+
+ def test_an_omitted_version_resolves_whatever_is_stored(self, client: TestClient, service: MagicMock) -> None:
+ service.resolve_run_request_async = self._resolving()
+
+ response = client.post(f"/api/scenario-presets/{PRESET_NAME}/resolve", json={"target_name": "gpt4"})
+
+ assert response.status_code == 200
+ assert service.resolve_run_request_async.call_args.kwargs["launch"].expected_version is None
diff --git a/tests/unit/backend/test_scenario_preset_service.py b/tests/unit/backend/test_scenario_preset_service.py
new file mode 100644
index 0000000000..5ca9c134df
--- /dev/null
+++ b/tests/unit/backend/test_scenario_preset_service.py
@@ -0,0 +1,489 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""Tests for the scenario preset service."""
+
+from collections.abc import Iterator
+from pathlib import Path
+from typing import cast
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from pyrit.backend.models.scenario_presets import ResolveScenarioPresetRequest
+from pyrit.backend.services import scenario_preset_service as preset_service_module
+from pyrit.backend.services.scenario_preset_service import (
+ ScenarioPresetService,
+ get_scenario_preset_service,
+)
+from pyrit.models import Parameter
+from pyrit.models.catalog import PresetIssue, RegisteredScenario, ScenarioPreset
+from pyrit.models.catalog.scenario import ScenarioRunSizeComponent, ScenarioRunSizeEstimate
+from pyrit.models.scenario_dataset import DatasetSourceInput, ScenarioDatasetRequest
+from pyrit.registry import (
+ ScenarioPresetConflictError,
+ ScenarioPresetNameRegisteredError,
+ ScenarioPresetRegistry,
+ ScenarioPresetStorage,
+)
+
+SCENARIO_NAME = "foundry.red_team_agent"
+
+
+def _registered_scenario(
+ *,
+ all_techniques: list[str] | None = None,
+ aggregate_techniques: list[str] | None = None,
+ supported_parameters: list[str] | None = None,
+ baseline_policy: str = "enabled",
+) -> RegisteredScenario:
+ """Build a registered scenario with only the fields preset validation reads."""
+ techniques = all_techniques if all_techniques is not None else ["crescendo", "flip"]
+ parameters = [
+ Parameter(name=name, description="", param_type=str) for name in (supported_parameters or ["max_turns"])
+ ]
+ return RegisteredScenario(
+ scenario_name=SCENARIO_NAME,
+ scenario_type="RedTeamAgentScenario",
+ description="Test scenario",
+ default_technique=techniques[0],
+ all_techniques=techniques,
+ aggregate_techniques=aggregate_techniques or [],
+ default_datasets=["harmbench"],
+ baseline_policy=baseline_policy,
+ supported_parameters=parameters,
+ supported_parameter_names=[parameter.name for parameter in parameters],
+ default_run_size=ScenarioRunSizeEstimate.unavailable(),
+ )
+
+
+def _estimate(count: int) -> ScenarioRunSizeEstimate:
+ """Build an exact run-size estimate of *count* attacks."""
+ return ScenarioRunSizeEstimate(
+ total_attack_count=count,
+ components=[ScenarioRunSizeComponent(label="Preset sweep", count=count)],
+ )
+
+
+@pytest.fixture
+def preset_registry(tmp_path: Path) -> Iterator[ScenarioPresetRegistry]:
+ """
+ Install a preset registry backed by an isolated directory as the process singleton.
+
+ Validation belongs to the registry and is tested against the real token grammar there,
+ so it is stubbed out here. These tests cover what the service does with the result.
+ """
+ instance = ScenarioPresetRegistry(storage=ScenarioPresetStorage(source=str(tmp_path)))
+ with (
+ patch.object(ScenarioPresetRegistry, "get_registry_singleton", return_value=instance),
+ patch.object(instance, "check_preset", return_value=[]),
+ ):
+ yield instance
+
+
+@pytest.fixture
+def service(preset_registry: ScenarioPresetRegistry) -> ScenarioPresetService:
+ """Create a service reading through the isolated preset registry."""
+ return ScenarioPresetService()
+
+
+@pytest.fixture
+def registered_scenario() -> Iterator[RegisteredScenario]:
+ """Patch the scenario lookup so presets resolve against a known scenario."""
+ scenario = _registered_scenario()
+ with patch("pyrit.backend.services.scenario_preset_service.get_scenario_service") as mock_factory:
+ mock_factory.return_value.get_scenario_async = AsyncMock(return_value=scenario)
+ mock_factory.return_value.estimate_scenario_run_size_async = AsyncMock(return_value=_estimate(12))
+ yield scenario
+
+
+@pytest.fixture
+def scenario_service(registered_scenario: RegisteredScenario) -> MagicMock:
+ """Return the scenario service the preset service calls while it is patched."""
+ return cast("MagicMock", preset_service_module.get_scenario_service())
+
+
+def _preset(name: str = "quick_scan", **overrides: object) -> ScenarioPreset:
+ """Build a preset that resolves cleanly against the fixture scenario."""
+ fields: dict[str, object] = {"name": name, "scenario_name": SCENARIO_NAME}
+ fields.update(overrides)
+ return ScenarioPreset(**fields) # type: ignore[arg-type]
+
+
+class TestPresetCrud:
+ """CRUD behavior over the configured storage source."""
+
+ async def test_list_presets_is_empty_when_nothing_is_stored(
+ self, service: ScenarioPresetService, tmp_path: Path, registered_scenario: RegisteredScenario
+ ) -> None:
+ response = await service.list_presets_async()
+
+ assert response.items == []
+ assert response.source == str(tmp_path)
+
+ async def test_list_presets_is_sorted_by_name(
+ self, service: ScenarioPresetService, registered_scenario: RegisteredScenario
+ ) -> None:
+ for name in ("zebra", "alpha", "middle"):
+ await service.save_preset_async(preset=_preset(name), expected_version=None)
+
+ response = await service.list_presets_async()
+
+ assert [item.preset.name for item in response.items] == ["alpha", "middle", "zebra"]
+
+ async def test_get_preset_returns_none_when_not_stored(
+ self, service: ScenarioPresetService, registered_scenario: RegisteredScenario
+ ) -> None:
+ assert await service.get_preset_async(name="missing") is None
+
+ async def test_saved_preset_round_trips_every_field(
+ self, service: ScenarioPresetService, registered_scenario: RegisteredScenario
+ ) -> None:
+ preset = _preset(
+ author="Grace Hopper",
+ description="Nightly smoke",
+ techniques=["crescendo"],
+ datasets=ScenarioDatasetRequest(
+ sources=[DatasetSourceInput(name="harmbench", max_size=10)],
+ max_total=25,
+ filters={"harm_categories": ["violence"]},
+ ),
+ include_baseline=True,
+ scenario_params={"max_turns": 3},
+ )
+
+ await service.save_preset_async(preset=preset, expected_version=None)
+ read_back = await service.get_preset_async(name=preset.name)
+
+ assert read_back is not None
+ assert read_back.preset == preset
+
+ async def test_update_with_the_version_from_a_read_succeeds(
+ self, service: ScenarioPresetService, registered_scenario: RegisteredScenario
+ ) -> None:
+ created = await service.save_preset_async(preset=_preset(), expected_version=None)
+
+ updated = await service.save_preset_async(
+ preset=_preset(description="changed"), expected_version=created.version
+ )
+
+ assert updated.preset.description == "changed"
+ assert updated.version != created.version
+
+ async def test_update_with_a_stale_version_conflicts(
+ self, service: ScenarioPresetService, registered_scenario: RegisteredScenario
+ ) -> None:
+ created = await service.save_preset_async(preset=_preset(), expected_version=None)
+ await service.save_preset_async(preset=_preset(description="first"), expected_version=created.version)
+
+ with pytest.raises(ScenarioPresetConflictError):
+ await service.save_preset_async(preset=_preset(description="second"), expected_version=created.version)
+
+ async def test_create_over_an_existing_preset_conflicts(
+ self, service: ScenarioPresetService, registered_scenario: RegisteredScenario
+ ) -> None:
+ await service.save_preset_async(preset=_preset(), expected_version=None)
+
+ with pytest.raises(ScenarioPresetConflictError):
+ await service.save_preset_async(preset=_preset(), expected_version=None)
+
+ async def test_delete_removes_the_stored_preset(
+ self, service: ScenarioPresetService, registered_scenario: RegisteredScenario
+ ) -> None:
+ await service.save_preset_async(preset=_preset(), expected_version=None)
+
+ await service.delete_preset_async(name="quick_scan")
+
+ assert await service.get_preset_async(name="quick_scan") is None
+
+ async def test_delete_reports_a_missing_preset_rather_than_succeeding_silently(
+ self, service: ScenarioPresetService
+ ) -> None:
+ assert await service.delete_preset_async(name="missing") is False
+
+
+class TestRegisteredPresets:
+ """A preset contributed by code has no stored document behind it."""
+
+ async def test_listing_a_registered_preset_reports_no_version(
+ self,
+ service: ScenarioPresetService,
+ preset_registry: ScenarioPresetRegistry,
+ registered_scenario: RegisteredScenario,
+ ) -> None:
+ preset_registry.register_preset(preset=_preset("builtin_smoke"))
+
+ response = await service.list_presets_async(include_estimates=False)
+
+ assert [(item.preset.name, item.origin, item.version) for item in response.items] == [
+ ("builtin_smoke", "registered", None)
+ ]
+
+ async def test_getting_a_registered_preset_reports_no_version(
+ self,
+ service: ScenarioPresetService,
+ preset_registry: ScenarioPresetRegistry,
+ registered_scenario: RegisteredScenario,
+ ) -> None:
+ preset_registry.register_preset(preset=_preset("builtin_smoke"))
+
+ response = await service.get_preset_async(name="builtin_smoke")
+
+ assert response is not None
+ assert response.origin == "registered"
+ assert response.version is None
+
+ async def test_a_stored_preset_is_still_reported_as_stored_and_versioned(
+ self,
+ service: ScenarioPresetService,
+ preset_registry: ScenarioPresetRegistry,
+ registered_scenario: RegisteredScenario,
+ ) -> None:
+ preset_registry.register_preset(preset=_preset("builtin_smoke"))
+ await service.save_preset_async(preset=_preset("nightly"), expected_version=None)
+
+ response = await service.list_presets_async(include_estimates=False)
+ by_name = {item.preset.name: item for item in response.items}
+
+ assert by_name["nightly"].origin == "stored"
+ assert by_name["nightly"].version is not None
+ assert by_name["builtin_smoke"].version is None
+
+ async def test_saving_over_a_registered_name_is_rejected(
+ self,
+ service: ScenarioPresetService,
+ preset_registry: ScenarioPresetRegistry,
+ registered_scenario: RegisteredScenario,
+ ) -> None:
+ preset_registry.register_preset(preset=_preset("builtin_smoke"))
+
+ with pytest.raises(ScenarioPresetNameRegisteredError):
+ await service.save_preset_async(preset=_preset("builtin_smoke"), expected_version=None)
+
+
+class TestAdvisoryIssues:
+ """The service attaches the registry's advisory issues to the wire response."""
+
+ async def test_registry_issues_reach_a_saved_preset(
+ self, service: ScenarioPresetService, preset_registry: ScenarioPresetRegistry
+ ) -> None:
+ issue = PresetIssue(field="scenario_name", message="Scenario 'nope' is not registered in this deployment.")
+
+ with patch.object(preset_registry, "check_preset", return_value=[issue]):
+ saved = await service.save_preset_async(preset=_preset(), expected_version=None)
+
+ assert saved.issues == [issue]
+
+ async def test_registry_issues_reach_a_listed_preset(
+ self, service: ScenarioPresetService, preset_registry: ScenarioPresetRegistry
+ ) -> None:
+ await service.save_preset_async(preset=_preset(), expected_version=None)
+ issue = PresetIssue(field="techniques", message="Technique 'nope' is not available.")
+
+ with patch.object(preset_registry, "check_preset", return_value=[issue]):
+ response = await service.list_presets_async(include_estimates=False)
+
+ assert response.items[0].issues == [issue]
+
+ async def test_an_unresolvable_preset_is_still_saved(
+ self, service: ScenarioPresetService, preset_registry: ScenarioPresetRegistry
+ ) -> None:
+ issue = PresetIssue(field="scenario_name", message="Scenario 'nope' is not registered in this deployment.")
+
+ with patch.object(preset_registry, "check_preset", return_value=[issue]):
+ await service.save_preset_async(preset=_preset(), expected_version=None)
+
+ assert await service.get_preset_async(name="quick_scan") is not None
+
+
+class TestRunRequestResolution:
+ """A preset plus launch fields becomes an ordinary run request."""
+
+ def test_unset_run_fields_fall_back_to_the_request_defaults(self) -> None:
+ resolved = ScenarioPresetService.resolve_run_request(
+ preset=_preset(), launch=ResolveScenarioPresetRequest(target_name="gpt4")
+ )
+
+ assert resolved.max_concurrency == 10
+ assert resolved.max_retries == 0
+
+ def test_explicit_run_fields_are_applied(self) -> None:
+ resolved = ScenarioPresetService.resolve_run_request(
+ preset=_preset(),
+ launch=ResolveScenarioPresetRequest(target_name="gpt4", max_concurrency=4, max_retries=2),
+ )
+
+ assert resolved.max_concurrency == 4
+ assert resolved.max_retries == 2
+
+ def test_unset_preset_fields_stay_unset_so_the_scenario_default_still_applies(self) -> None:
+ resolved = ScenarioPresetService.resolve_run_request(
+ preset=_preset(), launch=ResolveScenarioPresetRequest(target_name="gpt4")
+ )
+
+ assert resolved.techniques is None
+ assert resolved.datasets is None
+ assert resolved.dataset_request is None
+ assert resolved.include_baseline is None
+ assert resolved.scenario_params is None
+
+ def test_a_baseline_explicitly_disabled_by_the_preset_is_preserved(self) -> None:
+ resolved = ScenarioPresetService.resolve_run_request(
+ preset=_preset(include_baseline=False), launch=ResolveScenarioPresetRequest(target_name="gpt4")
+ )
+
+ assert resolved.include_baseline is False
+
+ def test_every_preset_field_reaches_the_run_request(self) -> None:
+ preset = _preset(
+ techniques=["crescendo"],
+ datasets=ScenarioDatasetRequest(
+ sources=[DatasetSourceInput(name="harmbench", max_size=10)],
+ max_total=25,
+ filters={"harm_categories": ["violence"]},
+ ),
+ include_baseline=True,
+ scenario_params={"max_turns": 3},
+ )
+
+ resolved = ScenarioPresetService.resolve_run_request(
+ preset=preset, launch=ResolveScenarioPresetRequest(target_name="gpt4")
+ )
+
+ assert resolved.scenario_name == preset.scenario_name
+ assert resolved.techniques == preset.techniques
+ assert resolved.datasets == preset.datasets
+ assert resolved.include_baseline == preset.include_baseline
+ assert resolved.scenario_params == preset.scenario_params
+
+ def test_launch_fields_reach_the_run_request(self) -> None:
+ launch = ResolveScenarioPresetRequest(
+ target_name="gpt4",
+ adversarial_target_name="adversary",
+ initializers=["scorer"],
+ initializer_args={"scorer": {"threshold": 0.5}},
+ labels={"operator": "red"},
+ )
+
+ resolved = ScenarioPresetService.resolve_run_request(preset=_preset(), launch=launch)
+
+ assert resolved.target_name == "gpt4"
+ assert resolved.adversarial_target_name == "adversary"
+ assert resolved.initializers == ["scorer"]
+ assert resolved.initializer_args == {"scorer": {"threshold": 0.5}}
+ assert resolved.labels == {"operator": "red"}
+
+
+class TestRunSizeEstimates:
+ """Sizing a preset against its own configuration rather than the scenario default."""
+
+ async def test_every_listed_preset_carries_its_own_size(
+ self, service: ScenarioPresetService, scenario_service: MagicMock
+ ) -> None:
+ await service.save_preset_async(preset=_preset(), expected_version=None)
+
+ response = await service.list_presets_async(include_estimates=True)
+
+ assert response.items[0].run_size is not None
+ assert response.items[0].run_size.total_attack_count == 12
+
+ async def test_the_preset_configuration_is_what_gets_sized(
+ self, service: ScenarioPresetService, scenario_service: MagicMock
+ ) -> None:
+ preset = _preset(
+ techniques=["crescendo"],
+ datasets=ScenarioDatasetRequest(
+ sources=[DatasetSourceInput(name="harmbench", max_size=10)],
+ max_total=25,
+ filters={"harm_categories": ["violence"]},
+ ),
+ include_baseline=True,
+ scenario_params={"max_turns": 3},
+ )
+ await service.save_preset_async(preset=preset, expected_version=None)
+
+ await service.list_presets_async(include_estimates=True)
+
+ estimate_call = scenario_service.estimate_scenario_run_size_async.call_args
+ assert estimate_call.kwargs["scenario_name"] == SCENARIO_NAME
+ request = estimate_call.kwargs["request"]
+ assert request.techniques == ["crescendo"]
+ assert request.datasets is not None
+ assert request.datasets.sources is not None
+ assert request.datasets.sources[0].name == "harmbench"
+ assert request.datasets.max_total == 25
+ assert request.datasets.filters == {"harm_categories": ["violence"]}
+ assert request.include_baseline is True
+ assert request.scenario_params == {"max_turns": 3}
+
+ async def test_a_preset_that_sets_nothing_is_sized_without_overrides(
+ self, service: ScenarioPresetService, scenario_service: MagicMock
+ ) -> None:
+ await service.save_preset_async(preset=_preset(), expected_version=None)
+
+ await service.list_presets_async(include_estimates=True)
+
+ request = scenario_service.estimate_scenario_run_size_async.call_args.kwargs["request"]
+ assert request.techniques is None
+ assert request.datasets is None
+ assert request.target_name is None
+
+ async def test_estimates_are_off_by_default(
+ self, service: ScenarioPresetService, scenario_service: MagicMock
+ ) -> None:
+ await service.save_preset_async(preset=_preset(), expected_version=None)
+
+ response = await service.list_presets_async()
+
+ assert response.items[0].run_size is None
+ scenario_service.estimate_scenario_run_size_async.assert_not_called()
+
+ async def test_one_preset_that_cannot_be_sized_does_not_hide_the_others(
+ self, service: ScenarioPresetService, scenario_service: MagicMock
+ ) -> None:
+ for name in ("alpha", "broken"):
+ await service.save_preset_async(preset=_preset(name), expected_version=None)
+ scenario_service.estimate_scenario_run_size_async = AsyncMock(
+ side_effect=[_estimate(7), ValueError("unknown technique 'nope'")]
+ )
+
+ response = await service.list_presets_async(include_estimates=True)
+
+ assert [item.preset.name for item in response.items] == ["alpha", "broken"]
+ assert response.items[0].run_size is not None
+ assert response.items[0].run_size.total_attack_count == 7
+ assert response.items[1].run_size is None
+
+ async def test_a_sizer_that_returns_nothing_leaves_the_size_unset(
+ self, service: ScenarioPresetService, scenario_service: MagicMock
+ ) -> None:
+ await service.save_preset_async(preset=_preset(), expected_version=None)
+ scenario_service.estimate_scenario_run_size_async = AsyncMock(return_value=None)
+
+ response = await service.list_presets_async(include_estimates=True)
+
+ assert response.items[0].run_size is None
+
+ async def test_a_single_read_stays_cheap(self, service: ScenarioPresetService, scenario_service: MagicMock) -> None:
+ await service.save_preset_async(preset=_preset(), expected_version=None)
+ scenario_service.estimate_scenario_run_size_async.reset_mock()
+
+ read_back = await service.get_preset_async(name="quick_scan")
+
+ assert read_back is not None
+ assert read_back.run_size is None
+ scenario_service.estimate_scenario_run_size_async.assert_not_called()
+
+
+class TestServiceConfiguration:
+ """Storage source selection."""
+
+ def test_the_service_is_a_singleton(self) -> None:
+ assert get_scenario_preset_service() is get_scenario_preset_service()
+
+ async def test_the_listed_source_is_the_registry_source(
+ self, service: ScenarioPresetService, tmp_path: Path
+ ) -> None:
+ response = await service.list_presets_async(include_estimates=False)
+
+ assert response.source == str(tmp_path)
diff --git a/tests/unit/models/test_scenario_preset.py b/tests/unit/models/test_scenario_preset.py
index 94870cb15f..6491b51018 100644
--- a/tests/unit/models/test_scenario_preset.py
+++ b/tests/unit/models/test_scenario_preset.py
@@ -7,6 +7,7 @@
from pyrit.models.catalog.scenario_preset import PRESET_SCHEMA_VERSION, ScenarioPreset
from pyrit.models.request_limits import MAX_IDENTIFIER_LENGTH, MAX_ITEMS
+from pyrit.models.scenario_dataset import DatasetSourceInput, ScenarioDatasetRequest
def test_init_defaults_every_optional_field_to_none() -> None:
@@ -14,9 +15,7 @@ def test_init_defaults_every_optional_field_to_none() -> None:
preset = ScenarioPreset(name="nightly", scenario_name="foundry.red_team_agent")
assert preset.techniques is None
- assert preset.dataset_names is None
- assert preset.max_dataset_size is None
- assert preset.dataset_filters is None
+ assert preset.datasets is None
assert preset.include_baseline is None
assert preset.scenario_params is None
assert preset.description is None
@@ -32,6 +31,17 @@ def test_schema_version_defaults_so_a_document_without_it_still_reads() -> None:
assert ScenarioPreset(name="nightly", scenario_name="foundry.red_team_agent").schema_version == 1
+def test_schema_version_default_does_not_track_the_current_schema_version() -> None:
+ """
+ Test that the default stays pinned to 1 as PRESET_SCHEMA_VERSION grows.
+
+ A key-less document is a version 1 document. If the default tracked the constant, bumping
+ the constant would relabel every such document as the newest shape while it still held the
+ oldest content, and a migration keyed on the version would skip exactly those documents.
+ """
+ assert ScenarioPreset.model_fields["schema_version"].default == 1
+
+
def test_init_rejects_a_document_shape_this_build_cannot_read() -> None:
"""Test that a future document fails loudly instead of being reinterpreted as this shape."""
with pytest.raises(ValueError, match="newer than this build reads"):
@@ -65,7 +75,7 @@ def test_init_rejects_unknown_dataset_filter() -> None:
ScenarioPreset(
name="nightly",
scenario_name="foundry.red_team_agent",
- dataset_filters={"not_a_field": ["x"]},
+ datasets=ScenarioDatasetRequest(filters={"not_a_field": ["x"]}),
)
@@ -74,16 +84,48 @@ def test_init_accepts_known_dataset_filters() -> None:
preset = ScenarioPreset(
name="nightly",
scenario_name="foundry.red_team_agent",
- dataset_filters={"harm_categories": ["violence"], "data_types": ["text"]},
+ datasets=ScenarioDatasetRequest(filters={"harm_categories": ["violence"], "data_types": ["text"]}),
)
- assert preset.dataset_filters == {"harm_categories": ["violence"], "data_types": ["text"]}
+ assert preset.datasets is not None
+ assert preset.datasets.filters == {"harm_categories": ["violence"], "data_types": ["text"]}
-def test_init_rejects_zero_max_dataset_size() -> None:
+def test_init_rejects_zero_dataset_limit() -> None:
"""Test that a dataset cap must select at least one item."""
with pytest.raises(ValueError):
- ScenarioPreset(name="nightly", scenario_name="foundry.red_team_agent", max_dataset_size=0)
+ ScenarioPreset(
+ name="nightly",
+ scenario_name="foundry.red_team_agent",
+ datasets=ScenarioDatasetRequest(max_total=0),
+ )
+
+
+def test_init_accepts_the_unbounded_dataset_limit() -> None:
+ """Test the limit the flat schema could not express, which motivated adopting this model."""
+ preset = ScenarioPreset(
+ name="nightly",
+ scenario_name="foundry.red_team_agent",
+ datasets=ScenarioDatasetRequest(max_total="all", max_per_dataset="all"),
+ )
+
+ assert preset.datasets is not None
+ assert preset.datasets.max_total == "all"
+ assert preset.datasets.max_per_dataset == "all"
+
+
+def test_init_carries_per_source_dataset_settings() -> None:
+ """Test that a preset can pin a limit to one named source, not only to the whole run."""
+ preset = ScenarioPreset(
+ name="nightly",
+ scenario_name="foundry.red_team_agent",
+ datasets=ScenarioDatasetRequest(sources=[DatasetSourceInput(name="harmbench", max_size=5)]),
+ )
+
+ assert preset.datasets is not None
+ assert preset.datasets.sources is not None
+ assert preset.datasets.sources[0].name == "harmbench"
+ assert preset.datasets.sources[0].max_size == 5
def test_init_rejects_empty_scenario_name() -> None:
@@ -115,7 +157,7 @@ def test_init_rejects_a_preset_too_large_to_launch() -> None:
ScenarioPreset(
name="nightly",
scenario_name="foundry.red_team_agent",
- dataset_names=["x" * (MAX_IDENTIFIER_LENGTH + 1)],
+ datasets=ScenarioDatasetRequest(sources=[DatasetSourceInput(name="x" * (MAX_IDENTIFIER_LENGTH + 1))]),
)
with pytest.raises(ValueError):
@@ -171,12 +213,12 @@ def test_identity_params_are_json_safe_for_nested_configuration() -> None:
preset = ScenarioPreset(
name="nightly",
scenario_name="foundry.red_team_agent",
- dataset_filters={"harm_categories": ["violence"]},
+ datasets=ScenarioDatasetRequest(filters={"harm_categories": ["violence"]}),
scenario_params={"max_turns": 3},
)
params = preset.get_identifier().params
- assert params["dataset_filters"] == {"harm_categories": ["violence"]}
+ assert params["datasets"]["filters"] == {"harm_categories": ["violence"]}
assert params["scenario_params"] == {"max_turns": 3}
assert "name" not in params
diff --git a/tests/unit/registry/test_scenario_preset_registry.py b/tests/unit/registry/test_scenario_preset_registry.py
index 79b578b0e9..6688e670d6 100644
--- a/tests/unit/registry/test_scenario_preset_registry.py
+++ b/tests/unit/registry/test_scenario_preset_registry.py
@@ -13,6 +13,7 @@
from pyrit.models import Parameter
from pyrit.models.catalog.scenario_preset import ScenarioPreset
+from pyrit.models.scenario_dataset import DatasetSourceInput, ScenarioDatasetRequest
from pyrit.registry.components.converter_registry import ConverterRegistry
from pyrit.registry.components.scenario_preset_registry import (
ScenarioPresetNameRegisteredError,
@@ -107,9 +108,11 @@ def test_saved_preset_round_trips_every_field(self, registry: ScenarioPresetRegi
description="Nightly smoke",
author="tester",
techniques=["crescendo"],
- dataset_names=["harmbench"],
- max_dataset_size=25,
- dataset_filters={"harm_categories": ["violence"]},
+ datasets=ScenarioDatasetRequest(
+ sources=[DatasetSourceInput(name="harmbench")],
+ max_total=25,
+ filters={"harm_categories": ["violence"]},
+ ),
include_baseline=True,
scenario_params={"max_turns": 3},
)
diff --git a/tests/unit/registry/test_scenario_preset_storage.py b/tests/unit/registry/test_scenario_preset_storage.py
index 3184cc5e0b..f3192bc835 100644
--- a/tests/unit/registry/test_scenario_preset_storage.py
+++ b/tests/unit/registry/test_scenario_preset_storage.py
@@ -13,6 +13,7 @@
import pytest
from pyrit.models.catalog.scenario_preset import PRESET_SCHEMA_VERSION, ScenarioPreset
+from pyrit.models.scenario_dataset import DatasetSourceInput, ScenarioDatasetRequest
from pyrit.registry.file_document_storage import _release_exclusive_lock, _try_acquire_exclusive_lock
from pyrit.registry.scenario_preset_storage import ScenarioPresetConflictError, ScenarioPresetStorage
@@ -34,9 +35,11 @@ def test_local_storage_round_trips_a_preset(tmp_path: Path) -> None:
storage = ScenarioPresetStorage(source=str(tmp_path))
preset = _make_preset(
techniques=["crescendo"],
- dataset_names=["harmbench"],
- max_dataset_size=25,
- dataset_filters={"harm_categories": ["violence"]},
+ datasets=ScenarioDatasetRequest(
+ sources=[DatasetSourceInput(name="harmbench", max_size=10)],
+ max_total=25,
+ filters={"harm_categories": ["violence"]},
+ ),
include_baseline=True,
scenario_params={"max_turns": 3},
description="Nightly smoke suite",
@@ -47,9 +50,12 @@ def test_local_storage_round_trips_a_preset(tmp_path: Path) -> None:
assert loaded is not None
assert loaded.preset.techniques == ["crescendo"]
- assert loaded.preset.dataset_names == ["harmbench"]
- assert loaded.preset.max_dataset_size == 25
- assert loaded.preset.dataset_filters == {"harm_categories": ["violence"]}
+ assert loaded.preset.datasets is not None
+ assert loaded.preset.datasets.sources is not None
+ assert loaded.preset.datasets.sources[0].name == "harmbench"
+ assert loaded.preset.datasets.sources[0].max_size == 10
+ assert loaded.preset.datasets.max_total == 25
+ assert loaded.preset.datasets.filters == {"harm_categories": ["violence"]}
assert loaded.preset.include_baseline is True
assert loaded.preset.scenario_params == {"max_turns": 3}
assert loaded.preset.description == "Nightly smoke suite"
@@ -64,12 +70,12 @@ def test_unset_fields_round_trip_as_none_not_false(tmp_path: Path) -> None:
assert loaded is not None
assert loaded.preset.include_baseline is None
- assert loaded.preset.max_dataset_size is None
+ assert loaded.preset.datasets is None
assert loaded.preset.techniques is None
stored = json.loads((tmp_path / "nightly.json").read_text(encoding="utf-8"))
assert "include_baseline" not in stored
- assert "max_dataset_size" not in stored
+ assert "datasets" not in stored
def test_explicit_false_round_trips_as_false(tmp_path: Path) -> None: