From dcf012f3df4a0e612046ebb3d21d7cc005f480f1 Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Wed, 28 Oct 2026 23:06:41 -0700 Subject: [PATCH 01/65] FEAT: Add expandable sidebar navigation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- doc/gui/0_gui.md | 8 +- frontend/e2e/accessibility.spec.ts | 8 +- frontend/e2e/api.spec.ts | 2 +- frontend/e2e/chat-recovery.spec.ts | 2 +- frontend/e2e/chat.spec.ts | 16 +- frontend/e2e/compatibility.spec.ts | 10 +- frontend/e2e/config.spec.ts | 6 +- frontend/e2e/converters.spec.ts | 4 +- frontend/e2e/errors.spec.ts | 6 +- frontend/e2e/flows.spec.ts | 2 +- frontend/e2e/routing.spec.ts | 2 +- frontend/e2e/scenario-history.spec.ts | 4 +- frontend/e2e/sidebar.spec.ts | 37 +++ frontend/e2e/touch-targets.spec.ts | 2 +- frontend/src/App.labels.test.tsx | 4 +- .../components/Layout/MainLayout.styles.ts | 15 +- .../src/components/Layout/MainLayout.test.tsx | 55 +++-- frontend/src/components/Layout/MainLayout.tsx | 56 ++++- .../components/Sidebar/Navigation.styles.ts | 37 ++- .../components/Sidebar/Navigation.test.tsx | 227 +++--------------- .../src/components/Sidebar/Navigation.tsx | 175 ++++++-------- frontend/src/components/Tour/tourSteps.ts | 2 +- frontend/src/hooks/useTour.test.ts | 2 +- .../src/hooks/useUserPreferences.test.tsx | 9 +- frontend/src/types/index.ts | 1 + frontend/src/utils/userPreferences.ts | 2 + 26 files changed, 316 insertions(+), 378 deletions(-) create mode 100644 frontend/e2e/sidebar.spec.ts diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index b653464ac1..71e1bdbf11 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -32,8 +32,8 @@ for new work; it does not change saved results. ## Chat -Chat (`/chat`) is the manual conversation workspace. Select a target and write -the messages yourself. You can attach media, transform content with converters, +Select **Attacks** in the sidebar to open Chat (`/chat`), the manual conversation +workspace. Select a target and write the messages yourself. You can attach media, transform content with converters, and branch or edit conversations to try another approach. Add an objective and select a chat scorer if you want automated assessment of responses. @@ -164,5 +164,9 @@ for requirements and limits. ## Preferences +Use the icon at the top of the sidebar to show or hide navigation labels. This +choice is saved for your account in this browser. On narrow screens, the same +icon opens a navigation panel without reducing the workspace width. + Use the sidebar's **Theme** menu to change the appearance. User preferences, such as default targets, are saved in this browser for the current account. diff --git a/frontend/e2e/accessibility.spec.ts b/frontend/e2e/accessibility.spec.ts index 5b4a667ff3..2ce947e513 100644 --- a/frontend/e2e/accessibility.spec.ts +++ b/frontend/e2e/accessibility.spec.ts @@ -129,7 +129,7 @@ test.describe("Accessibility", () => { const objectiveDefault = page.getByRole("combobox", { name: "Default objective target", exact: true }); await expect(objectiveDefault).toBeVisible({ timeout: 5000 }); await objectiveDefault.selectOption({ index: 1 }); - await page.getByTitle("Technique.execute").click(); + await page.getByTitle("Attacks").click(); // Input should be accessible const input = page.getByRole("textbox", { name: "Type prompt here", exact: true }); @@ -146,7 +146,7 @@ test.describe("Accessibility", () => { test("should have accessible sidebar navigation", async ({ page }) => { // Chat button - const chatBtn = page.getByTitle("Technique.execute"); + const chatBtn = page.getByTitle("Attacks"); await expect(chatBtn).toBeVisible(); // Registry button @@ -272,7 +272,7 @@ test.describe("Accessibility", () => { const objectiveDefault = page.getByRole("combobox", { name: "Default objective target", exact: true }); await expect(objectiveDefault).toBeVisible({ timeout: 5000 }); await objectiveDefault.selectOption({ index: 1 }); - await page.getByTitle("Technique.execute").click(); + await page.getByTitle("Attacks").click(); const input = page.getByRole("textbox", { name: "Type prompt here", exact: true }); await expect(input).toBeEnabled({ timeout: 5000 }); @@ -337,7 +337,7 @@ test.describe("Accessibility", () => { const views = [ { button: "History", heading: "History" }, { button: "Registry", heading: "Target Registry" }, - { button: "Technique.execute", heading: "Chat" }, + { button: "Attacks", heading: "Chat" }, ]; for (const view of views) { diff --git a/frontend/e2e/api.spec.ts b/frontend/e2e/api.spec.ts index c15a7dccfe..b8b5ce3dc1 100644 --- a/frontend/e2e/api.spec.ts +++ b/frontend/e2e/api.spec.ts @@ -141,6 +141,6 @@ test.describe("Error Handling", () => { await page.goto("/"); // UI should be responsive even while APIs are delayed - await expect(page.getByTitle("Technique.execute")).toBeVisible({ timeout: 10000 }); + await expect(page.getByTitle("Attacks")).toBeVisible({ timeout: 10000 }); }); }); diff --git a/frontend/e2e/chat-recovery.spec.ts b/frontend/e2e/chat-recovery.spec.ts index 30fc286c76..70d3046d3b 100644 --- a/frontend/e2e/chat-recovery.spec.ts +++ b/frontend/e2e/chat-recovery.spec.ts @@ -240,7 +240,7 @@ test.describe("Chat processing recovery @seeded", () => { await page.getByTitle("Registry", { exact: true }).click(); await page.getByRole("combobox", { name: "Default objective target", exact: true }) .selectOption(localTarget.registryName); - await page.getByTitle("Technique.execute", { exact: true }).click(); + await page.getByTitle("Attacks", { exact: true }).click(); }); test("keeps the drawer closed when a delayed send refresh discovers another conversation", async ({ diff --git a/frontend/e2e/chat.spec.ts b/frontend/e2e/chat.spec.ts index 2229d97e71..b9fbecca4e 100644 --- a/frontend/e2e/chat.spec.ts +++ b/frontend/e2e/chat.spec.ts @@ -152,7 +152,7 @@ async function activateMockTarget(page: Page) { await objectiveDefault.selectOption({ index: 1 }); // Return to Chat view - await page.getByTitle("Technique.execute").click(); + await page.getByTitle("Attacks").click(); await expect(page.getByTestId("new-attack-btn")).toBeVisible({ timeout: 5000 }); } @@ -170,18 +170,18 @@ test.describe("Application Smoke Tests", () => { }); test("should display chat ribbon", async ({ page }) => { - await expect(page.getByTitle("Technique.execute")).toBeVisible({ timeout: 10000 }); - await page.getByTitle("Technique.execute").click(); + await expect(page.getByTitle("Attacks")).toBeVisible({ timeout: 10000 }); + await page.getByTitle("Attacks").click(); await expect(page.getByTestId("new-attack-btn")).toBeVisible({ timeout: 10000 }); }); test("should have New Attack button", async ({ page }) => { - await page.getByTitle("Technique.execute").click(); + await page.getByTitle("Attacks").click(); await expect(page.getByRole("button", { name: /new attack/i })).toBeVisible(); }); test("should offer the target picker without a bottom warning", async ({ page }) => { - await page.getByTitle("Technique.execute").click(); + await page.getByTitle("Attacks").click(); await expect(page.getByRole("combobox", { name: "Chat target" })).toBeVisible(); await expect(page.getByTestId("no-target-banner")).toHaveCount(0); }); @@ -190,7 +190,7 @@ test.describe("Application Smoke Tests", () => { test.describe("Theme Toggle", () => { test("should toggle dark/light theme", async ({ page }) => { await page.goto("/"); - await expect(page.getByTitle("Technique.execute")).toBeVisible({ timeout: 10000 }); + await expect(page.getByTitle("Attacks")).toBeVisible({ timeout: 10000 }); // The app defaults to system mode, so the toggle button title should say "Theme: System" const themeBtn = page.getByTitle("Theme: System"); @@ -358,7 +358,7 @@ test.describe("Multiple Messages", () => { test.describe("Chat without target", () => { test("should allow drafting but block sending when no target is selected", async ({ page }) => { await page.goto("/"); - await page.getByTitle("Technique.execute").click(); + await page.getByTitle("Attacks").click(); const input = page.getByRole("textbox", { name: "Type prompt here", exact: true }); await expect(input).toBeEnabled(); @@ -901,7 +901,7 @@ test.describe("Target type scenarios", () => { .selectOption("dall-e-image-gen"); // Navigate to chat - await page.getByTitle("Technique.execute").click(); + await page.getByTitle("Attacks").click(); // Scope queries to the badge so we don't also match the (hidden) // copy of the target text that Fluent's Tooltip renders into the DOM. const badge = page.getByTestId("target-badge"); diff --git a/frontend/e2e/compatibility.spec.ts b/frontend/e2e/compatibility.spec.ts index b49f8c9095..506264154a 100644 --- a/frontend/e2e/compatibility.spec.ts +++ b/frontend/e2e/compatibility.spec.ts @@ -43,10 +43,10 @@ test("waits for a matching packaged stamp before sending business requests", asy }); await page.goto("/"); await expect(page.getByRole("status")).toContainText("Checking frontend and backend compatibility"); - await expect(page.getByTitle("Technique.execute", { exact: true })).not.toBeVisible(); + await expect(page.getByTitle("Attacks", { exact: true })).not.toBeVisible(); expect(requests).toHaveLength(0); releaseVersion(); - await expect(page.getByTitle("Technique.execute", { exact: true })).toBeVisible(); + await expect(page.getByTitle("Attacks", { exact: true })).toBeVisible(); await expect.poll(() => requests.length).toBeGreaterThan(0); }); @@ -62,7 +62,7 @@ for (const [name, payload] of [ }); await page.goto("/"); await expect(page.getByRole("heading", { name: "PyRIT compatibility blocked" })).toBeVisible(); - await expect(page.getByTitle("Technique.execute", { exact: true })).not.toBeVisible(); + await expect(page.getByTitle("Attacks", { exact: true })).not.toBeVisible(); expect(requests).toHaveLength(0); }); } @@ -81,7 +81,7 @@ for (const [status, type] of [ await route.fulfill({ status, json: { type, expected: otherCompatibilityId(), actual: getCompatibilityId() } }); }); await page.goto("/"); - await expect(page.getByTitle("Technique.execute", { exact: true })).toBeVisible(); + await expect(page.getByTitle("Attacks", { exact: true })).toBeVisible(); await page.evaluate(async () => { const modulePath = "/src/services/api.ts"; const { apiClient } = await import(modulePath); @@ -92,7 +92,7 @@ for (const [status, type] of [ await expect(page.getByRole("heading", { name: "PyRIT compatibility blocked" })).toBeVisible(); await expect(page.getByRole("alertdialog")).toContainText(getCompatibilityId()); await expect(page.getByRole("alertdialog")).toContainText(otherCompatibilityId()); - await expect(page.getByTitle("Technique.execute", { exact: true })).not.toBeVisible(); + await expect(page.getByTitle("Attacks", { exact: true })).not.toBeVisible(); expect(mutations).toBe(1); }); } diff --git a/frontend/e2e/config.spec.ts b/frontend/e2e/config.spec.ts index 0096e2c49d..df81e9ebdf 100644 --- a/frontend/e2e/config.spec.ts +++ b/frontend/e2e/config.spec.ts @@ -679,7 +679,7 @@ test.describe("Target Config ↔ Chat Navigation", () => { await page.getByRole("combobox", { name: "Default objective target", exact: true }).selectOption({ index: 1 }); // Navigate back to chat - await page.getByTitle("Technique.execute").click(); + await page.getByTitle("Attacks").click(); await expect(page.getByTestId("new-attack-btn")).toBeVisible(); // Chat should show the selected target type. Scope to the badge to @@ -696,7 +696,7 @@ test.describe("Target Config ↔ Chat Navigation", () => { }); await page.goto("/"); - await page.getByTitle("Technique.execute").click(); + await page.getByTitle("Attacks").click(); const draft = page.getByRole("textbox", { name: "Type prompt here", exact: true }); await expect(draft).toBeEnabled(); await draft.fill("Draft without a target"); @@ -708,7 +708,7 @@ test.describe("Target Config ↔ Chat Navigation", () => { await page.getByRole("combobox", { name: "Default objective target", exact: true }).selectOption({ index: 1 }); // Return to chat — send should be enabled when there's text - await page.getByTitle("Technique.execute").click(); + await page.getByTitle("Attacks").click(); const input = page.getByRole("textbox", { name: "Type prompt here", exact: true }); await input.fill("Hello"); await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled(); diff --git a/frontend/e2e/converters.spec.ts b/frontend/e2e/converters.spec.ts index 53978d0ff3..65b42e3f51 100644 --- a/frontend/e2e/converters.spec.ts +++ b/frontend/e2e/converters.spec.ts @@ -469,7 +469,7 @@ async function activateMockTarget(page: Page) { await expect(objectiveDefault).toBeVisible({ timeout: 5000 }); await objectiveDefault.selectOption({ index: 1 }); - await page.getByTitle("Technique.execute", { exact: true }).click(); + await page.getByTitle("Attacks", { exact: true }).click(); await expect(page.getByTestId("new-attack-btn")).toBeVisible({ timeout: 5000 }); } @@ -573,7 +573,7 @@ test.describe("Shared per-piece converter pipelines @seeded", () => { await page.getByTitle("Registry", { exact: true }).click(); await page.getByRole("combobox", { name: "Default objective target", exact: true }) .selectOption(targetRegistryName); - await page.getByTitle("Technique.execute", { exact: true }).click(); + await page.getByTitle("Attacks", { exact: true }).click(); await expect(page.getByTestId("chat-input")).toBeEnabled(); }); diff --git a/frontend/e2e/errors.spec.ts b/frontend/e2e/errors.spec.ts index 1add0a1208..98ced7d638 100644 --- a/frontend/e2e/errors.spec.ts +++ b/frontend/e2e/errors.spec.ts @@ -241,7 +241,7 @@ async function activateMockTarget(page: Page) { const objectiveDefault = page.getByRole("combobox", { name: "Default objective target", exact: true }); await expect(objectiveDefault).toBeVisible({ timeout: 5000 }); await objectiveDefault.selectOption({ index: 1 }); - await page.getByTitle("Technique.execute").click(); + await page.getByTitle("Attacks").click(); await expect(page.getByTestId("new-attack-btn")).toBeVisible({ timeout: 5000 }); } @@ -506,7 +506,7 @@ test.describe("Error: connection banner on health failure", () => { }) => { // Let the page load normally first await page.goto("/"); - await expect(page.getByTitle("Technique.execute")).toBeVisible({ + await expect(page.getByTitle("Attacks")).toBeVisible({ timeout: 10000, }); @@ -554,7 +554,7 @@ test.describe("Error: connection banner recovery", () => { }); await page.goto("/"); - await expect(page.getByTitle("Technique.execute")).toBeVisible({ + await expect(page.getByTitle("Attacks")).toBeVisible({ timeout: 10000, }); diff --git a/frontend/e2e/flows.spec.ts b/frontend/e2e/flows.spec.ts index 00fcda714a..65d2915b5b 100644 --- a/frontend/e2e/flows.spec.ts +++ b/frontend/e2e/flows.spec.ts @@ -210,7 +210,7 @@ async function activateTarget( await expect(row).toBeVisible({ timeout: 10_000 }); await page.getByRole("combobox", { name: "Default objective target", exact: true }) .selectOption(targetRegistryName); - await page.getByTitle("Technique.execute").click(); + await page.getByTitle("Attacks").click(); await expect(page.getByTestId("new-attack-btn")).toBeVisible({ timeout: 5_000 }); } diff --git a/frontend/e2e/routing.spec.ts b/frontend/e2e/routing.spec.ts index 6573c8e97d..064ac15bc5 100644 --- a/frontend/e2e/routing.spec.ts +++ b/frontend/e2e/routing.spec.ts @@ -300,7 +300,7 @@ test.describe("URL-driven routing", () => { await expect(page).toHaveURL(/\/history\/attacks$/); await expect(page.getByTestId("attacks-table")).toBeVisible(); - await page.getByTitle("Technique.execute").click(); + await page.getByTitle("Attacks").click(); await expect(page).toHaveURL(/\/chat$/); await expect(page.getByRole("switch", { name: "Markdown" })).toBeChecked(); diff --git a/frontend/e2e/scenario-history.spec.ts b/frontend/e2e/scenario-history.spec.ts index 4aa7fe78a6..7d18cc2736 100644 --- a/frontend/e2e/scenario-history.spec.ts +++ b/frontend/e2e/scenario-history.spec.ts @@ -551,11 +551,11 @@ test.describe("Scenario catalog, history, and live run routing", () => { expect(await primaryButtons.evaluateAll((buttons) => buttons.map((button) => button.getAttribute("aria-label")))).toEqual([ "Home", - "Technique.execute", + "Attacks", + "Scanner", "History", "Analytics", "Operations", - "Scanner", "Datasets", "Registry", "Configuration", diff --git a/frontend/e2e/sidebar.spec.ts b/frontend/e2e/sidebar.spec.ts new file mode 100644 index 0000000000..9ce80be93d --- /dev/null +++ b/frontend/e2e/sidebar.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from "@playwright/test"; + +test("@seeded saves sidebar expansion and keeps narrow navigation separate", async ({ page }) => { + await page.goto("/chat"); + const draft = page.getByRole("textbox", { name: "Type prompt here", exact: true }); + await draft.fill("Keep this draft when the sidebar changes."); + await page.getByRole("button", { name: "Collapse sidebar", exact: true }).click(); + await expect(page.getByRole("button", { name: "Attacks", exact: true })).toHaveText(""); + await expect(draft).toHaveValue("Keep this draft when the sidebar changes."); + + await page.reload(); + await page.getByRole("button", { name: "Expand sidebar", exact: true }).click(); + await expect(page.getByRole("button", { name: "Attacks", exact: true })).toHaveText("Attacks"); + await page.reload(); + await expect(page.getByRole("button", { name: "Collapse sidebar", exact: true })).toBeVisible(); + + await page.setViewportSize({ width: 390, height: 844 }); + const opener = page.getByRole("button", { name: "Expand sidebar", exact: true }); + await opener.click(); + const navigation = page.getByRole("dialog", { name: "Navigation", exact: true }); + const close = navigation.getByRole("button", { name: "Collapse sidebar", exact: true }); + await expect(close).toBeFocused(); + await close.press("Escape"); + await expect(navigation).not.toBeVisible(); + await expect(opener).toBeFocused(); + + await opener.click(); + await navigation.getByRole("button", { name: "Scanner", exact: true }).click(); + await expect(navigation).not.toBeVisible(); + await expect(page).toHaveURL(/\/scanner$/); + await expect(page.getByRole("button", { name: "Scanner", exact: true })).toHaveAttribute("aria-current", "page"); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + + await page.setViewportSize({ width: 1280, height: 900 }); + await expect(page.getByRole("button", { name: "Collapse sidebar", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Scanner", exact: true })).toHaveText("Scanner"); +}); diff --git a/frontend/e2e/touch-targets.spec.ts b/frontend/e2e/touch-targets.spec.ts index a8c9829904..1683952df1 100644 --- a/frontend/e2e/touch-targets.spec.ts +++ b/frontend/e2e/touch-targets.spec.ts @@ -498,7 +498,7 @@ test.describe("Mobile touch targets", () => { await page.getByRole("button", { name: "Registry", exact: true }).click(); await expect(page.getByText("gpt-4o-mobile", { exact: true })).toBeVisible(); await page.getByRole("combobox", { name: "Default objective target", exact: true }).selectOption({ index: 1 }); - await page.getByRole("button", { name: "Technique.execute", exact: true }).click(); + await page.getByRole("button", { name: "Attacks", exact: true }).click(); await page.getByRole("button", { name: "Add objective" }).click(); const objectiveInput = page.getByRole("textbox", { diff --git a/frontend/src/App.labels.test.tsx b/frontend/src/App.labels.test.tsx index a3b186eb91..530f6bbaf5 100644 --- a/frontend/src/App.labels.test.tsx +++ b/frontend/src/App.labels.test.tsx @@ -245,7 +245,7 @@ describe('Shared new run labels', () => { await chooseOperation(user, 'remembered_op') const bar = currentLabels() - for (const destination of ['Home', 'Technique.execute', 'Scanner']) { + for (const destination of ['Home', 'Attacks', 'Scanner']) { await user.click(screen.getByRole('button', { name: destination, exact: true })) expect(screen.getAllByTestId('labels-bar')).toHaveLength(1) expect(currentLabels()).toBe(bar) @@ -327,7 +327,7 @@ describe('Shared new run labels', () => { expect(screen.getAllByTestId('labels-bar')).toHaveLength(1) await user.click(screen.getByRole('button', { name: 'Scanner', exact: true })) expect(screen.queryByRole('group', { name: 'Chat controls' })).not.toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'Technique.execute', exact: true })) + await user.click(screen.getByRole('button', { name: 'Attacks', exact: true })) expect(screen.getAllByRole('group', { name: 'Chat controls' })).toHaveLength(1) expect(within(screen.getByRole('group', { name: 'Conversation actions' })).getByRole('switch')).toBeChecked() }) diff --git a/frontend/src/components/Layout/MainLayout.styles.ts b/frontend/src/components/Layout/MainLayout.styles.ts index 40393860ff..8708e4f3a2 100644 --- a/frontend/src/components/Layout/MainLayout.styles.ts +++ b/frontend/src/components/Layout/MainLayout.styles.ts @@ -5,7 +5,7 @@ export const useMainLayoutStyles = makeStyles({ root: { display: 'flex', flexDirection: 'column', - height: '100vh', + height: '100dvh', width: '100vw', overflow: 'hidden', }, @@ -100,16 +100,27 @@ export const useMainLayoutStyles = makeStyles({ display: 'flex', flex: 1, minWidth: 0, + minHeight: 0, overflow: 'hidden', }, sidebar: { - width: '60px', + width: '64px', flexShrink: 0, backgroundColor: tokens.colorNeutralBackground3, borderRight: `1px solid ${tokens.colorNeutralStroke1}`, display: 'flex', flexDirection: 'column', }, + sidebarExpanded: { + width: '224px', + }, + navigationDrawer: { + width: 'min(280px, calc(100vw - 48px))', + backgroundColor: tokens.colorNeutralBackground3, + }, + drawerBody: { + padding: 0, + }, main: { position: 'relative', isolation: 'isolate', diff --git a/frontend/src/components/Layout/MainLayout.test.tsx b/frontend/src/components/Layout/MainLayout.test.tsx index 39f7b60f4d..1fa3da9fc7 100644 --- a/frontend/src/components/Layout/MainLayout.test.tsx +++ b/frontend/src/components/Layout/MainLayout.test.tsx @@ -8,6 +8,7 @@ import userEvent from "@testing-library/user-event"; import { FluentProvider, webLightTheme } from "@fluentui/react-components"; import { ThemeProvider, useTheme } from "@/hooks/useTheme"; import { UserPreferencesProvider } from "@/hooks/useUserPreferences"; +import { readUserPreferences } from "@/utils/userPreferences"; import MainLayout from "./MainLayout"; // Mock the api module @@ -23,34 +24,16 @@ jest.mock("../../services/api", () => ({ }, })); -// Mock Navigation to simplify testing -jest.mock("../Sidebar/Navigation", () => { - const MockNavigation = ({ - currentView, - onNavigate, - }: { - currentView: string; - onNavigate: (view: string) => void; - }) => { - return ( -
- -
- ); - }; - MockNavigation.displayName = "MockNavigation"; - return { - __esModule: true, - default: MockNavigation, - }; -}); - import { versionApi } from "../../services/api"; const mockedVersionApi = versionApi as jest.Mocked; const renderWithProvider = (ui: React.ReactElement) => { - return render({ui}); + return render( + + {ui} + , + ); }; describe("MainLayout", () => { @@ -68,6 +51,32 @@ describe("MainLayout", () => { onLabelsChange: jest.fn(), }; + it("saves expansion without remounting the workspace and restores it on reload", async () => { + mockedVersionApi.getVersion.mockResolvedValue({ version: "1.0.0" }); + const user = userEvent.setup(); + const content = ( + + + + ); + const app = renderWithProvider(content); + const draft = screen.getByRole("textbox", { name: "Draft" }); + const labels = screen.getByRole("region", { name: "Default Labels" }); + await user.type(draft, "Keep this draft"); + await user.click(screen.getByRole("button", { name: "Collapse sidebar" })); + expect(screen.getByRole("button", { name: "Expand sidebar" })).toHaveAttribute("aria-expanded", "false"); + expect(readUserPreferences("local").sidebarExpanded).toBe(false); + expect(screen.getByRole("textbox", { name: "Draft" })).toBe(draft); + expect(draft).toHaveValue("Keep this draft"); + expect(screen.getByRole("region", { name: "Default Labels" })).toBe(labels); + + app.unmount(); + renderWithProvider(content); + await user.click(screen.getByRole("button", { name: "Expand sidebar" })); + expect(readUserPreferences("local").sidebarExpanded).toBe(true); + expect(screen.getByRole("button", { name: "Collapse sidebar" })).toHaveAttribute("aria-expanded", "true"); + }); + it("renders the header with title and subtitle", async () => { mockedVersionApi.getVersion.mockResolvedValue({ version: "1.0.0" }); diff --git a/frontend/src/components/Layout/MainLayout.tsx b/frontend/src/components/Layout/MainLayout.tsx index d3a26a5dc2..110d5b48d3 100644 --- a/frontend/src/components/Layout/MainLayout.tsx +++ b/frontend/src/components/Layout/MainLayout.tsx @@ -1,6 +1,8 @@ import { useEffect, useState } from 'react' import { Button, + DrawerBody, + OverlayDrawer, Text, Tooltip, mergeClasses, @@ -8,11 +10,13 @@ import { import { QuestionCircleRegular } from '@fluentui/react-icons' import LabelsBar, { type OperationControlProps } from '@/components/Labels/LabelsBar' +import Navigation, { type ViewName } from '@/components/Sidebar/Navigation' +import { UserAccountButton } from '@/components/UserAccountButton' import { useTheme } from '@/hooks/useTheme' +import { useUserPreferences } from '@/hooks/useUserPreferences' +import { versionApi } from '@/services/api' +import type { UserPreferences } from '@/types' -import { versionApi } from '../../services/api' -import Navigation, { type ViewName } from '../Sidebar/Navigation' -import { UserAccountButton } from '../UserAccountButton' import { useMainLayoutStyles } from './MainLayout.styles' interface MainLayoutProps extends OperationControlProps { @@ -28,6 +32,8 @@ interface MainLayoutProps extends OperationControlProps { onStartTour?: () => void } +const NARROW_SCREEN_QUERY = '(max-width: 600px)' + export default function MainLayout({ children, currentView, @@ -45,9 +51,23 @@ export default function MainLayout({ }: MainLayoutProps) { const styles = useMainLayoutStyles() const { background } = useTheme() + const { preferences, updatePreferences } = useUserPreferences() const [version, setVersion] = useState('Loading...') const [commit, setCommit] = useState(null) const [databaseInfo, setDatabaseInfo] = useState(null) + const sidebarExpanded = preferences.sidebarExpanded ?? true + const [narrow, setNarrow] = useState(() => window.matchMedia(NARROW_SCREEN_QUERY).matches) + const [drawerOpen, setDrawerOpen] = useState(false) + + useEffect(() => { + const query = window.matchMedia(NARROW_SCREEN_QUERY) + const handleChange = (event: MediaQueryListEvent): void => { + setNarrow(event.matches) + setDrawerOpen(false) + } + query.addEventListener('change', handleChange) + return () => { query.removeEventListener('change', handleChange) } + }, []) useEffect(() => { versionApi.getVersion() @@ -107,14 +127,42 @@ export default function MainLayout({
-
)} - All execution history +
+ Attack history + Scanner history +
) } diff --git a/frontend/src/components/Registry/ScenarioPresetRegistry.test.tsx b/frontend/src/components/Registry/ScenarioPresetRegistry.test.tsx index 8e87520710..d844988ac2 100644 --- a/frontend/src/components/Registry/ScenarioPresetRegistry.test.tsx +++ b/frontend/src/components/Registry/ScenarioPresetRegistry.test.tsx @@ -115,7 +115,7 @@ describe('ScenarioPresetRegistry', () => { renderRegistry() expect(screen.getByRole('heading', { level: 1, name: 'Scenario presets' })).toBeInTheDocument() - expect(screen.getByRole('link', { name: 'Scanner' })).toHaveAttribute('href', '/scanner') + expect(screen.getByRole('link', { name: 'Scanner' })).toHaveAttribute('href', '/scanner/launch') expect(await screen.findByTestId('suite-empty-state')).toBeInTheDocument() expect(await screen.findByTestId('empty-state')).toBeInTheDocument() }) diff --git a/frontend/src/components/Registry/ScenarioPresetRegistry.tsx b/frontend/src/components/Registry/ScenarioPresetRegistry.tsx index e99b64362f..6ac206b914 100644 --- a/frontend/src/components/Registry/ScenarioPresetRegistry.tsx +++ b/frontend/src/components/Registry/ScenarioPresetRegistry.tsx @@ -28,7 +28,7 @@ export default function ScenarioPresetRegistry() { Define reusable scenario configurations here, then launch them from - the Scanner. + the Scanner. diff --git a/frontend/src/components/ScenarioPresets/presetRoutes.test.ts b/frontend/src/components/ScenarioPresets/presetRoutes.test.ts index 088ecf95d1..e07b1bc7ae 100644 --- a/frontend/src/components/ScenarioPresets/presetRoutes.test.ts +++ b/frontend/src/components/ScenarioPresets/presetRoutes.test.ts @@ -35,7 +35,7 @@ describe('presetEditorRoutePath', () => { describe('presetScannerRoutePath', () => { it('opens the scenario launch form with the preset named in the query string', () => { expect(presetScannerRoutePath('garak.encoding', 'nightly_probe')).toBe( - '/scanner/garak.encoding?preset=nightly_probe', + '/scanner/launch/garak.encoding?preset=nightly_probe', ) }) @@ -43,7 +43,7 @@ describe('presetScannerRoutePath', () => { const path = presetScannerRoutePath('a/b', 'c&d') const url = new URL(path, 'http://localhost') - expect(url.pathname).toBe('/scanner/a%2Fb') + expect(url.pathname).toBe('/scanner/launch/a%2Fb') expect(url.searchParams.get(PRESET_QUERY_PARAM)).toBe('c&d') }) diff --git a/frontend/src/components/ScenarioPresets/presetRoutes.ts b/frontend/src/components/ScenarioPresets/presetRoutes.ts index f5a04a610d..37b9e679fd 100644 --- a/frontend/src/components/ScenarioPresets/presetRoutes.ts +++ b/frontend/src/components/ScenarioPresets/presetRoutes.ts @@ -24,8 +24,8 @@ export const PRESET_QUERY_PARAM = 'preset' /** * Opens the Scanner launch form for the preset's scenario, pre-filled from the preset. * The preset rides in the query string rather than the path so no preset name can - * collide with another `/scanner/...` route. + * collide with another `/scanner/launch/...` route. */ export function presetScannerRoutePath(scenarioName: string, presetName: string): string { - return `/scanner/${encodeURIComponent(scenarioName)}?${PRESET_QUERY_PARAM}=${encodeURIComponent(presetName)}` + return `/scanner/launch/${encodeURIComponent(scenarioName)}?${PRESET_QUERY_PARAM}=${encodeURIComponent(presetName)}` } diff --git a/frontend/src/components/ScenarioSuites/LaunchSuiteDialog.test.tsx b/frontend/src/components/ScenarioSuites/LaunchSuiteDialog.test.tsx index e63bcb54eb..2f753a24a2 100644 --- a/frontend/src/components/ScenarioSuites/LaunchSuiteDialog.test.tsx +++ b/frontend/src/components/ScenarioSuites/LaunchSuiteDialog.test.tsx @@ -163,7 +163,7 @@ describe('LaunchSuiteDialog', () => { labels: { team: 'purple' }, })) expect(mockNavigate).toHaveBeenCalledWith( - '/scanner/suites/executions/execution-1', + '/scanner/history/suites/executions/execution-1', ) }) diff --git a/frontend/src/components/ScenarioSuites/ScenarioSuiteExecutionPage.test.tsx b/frontend/src/components/ScenarioSuites/ScenarioSuiteExecutionPage.test.tsx index 02551d66f1..2b702e0632 100644 --- a/frontend/src/components/ScenarioSuites/ScenarioSuiteExecutionPage.test.tsx +++ b/frontend/src/components/ScenarioSuites/ScenarioSuiteExecutionPage.test.tsx @@ -135,10 +135,10 @@ function setHookResult( function renderPage() { return render( - + } /> @@ -167,10 +167,10 @@ describe('ScenarioSuiteExecutionPage', () => { expect(within(rows[0]).getByText('first_preset')).toBeInTheDocument() expect(within(rows[0]).getByText('2 of 10 attacks')).toBeInTheDocument() expect(within(rows[0]).getByRole('link', { name: 'View run' })) - .toHaveAttribute('href', '/scanner-history/run-1') + .toHaveAttribute('href', '/scanner/history/run-1') expect(within(rows[1]).getByText('second_preset')).toBeInTheDocument() expect(within(rows[1]).getAllByText('Queued')).toHaveLength(2) - expect(screen.getByRole('link', { name: 'Back to scanners' })).toHaveAttribute('href', '/scanner') + expect(screen.getByRole('link', { name: 'Back to scanners' })).toHaveAttribute('href', '/scanner/launch') }) it('shows safe entry failures without rendering backend exception details', () => { @@ -262,6 +262,6 @@ describe('ScenarioSuiteExecutionPage', () => { expect(screen.getByRole('heading', { name: 'Suite execution not found' })).toBeInTheDocument() expect(screen.getByText(/successfully created scenario runs remain/i)).toBeInTheDocument() - expect(screen.getByRole('link', { name: 'Back to scanners' })).toHaveAttribute('href', '/scanner') + expect(screen.getByRole('link', { name: 'Back to scanners' })).toHaveAttribute('href', '/scanner/launch') }) }) diff --git a/frontend/src/components/ScenarioSuites/ScenarioSuiteExecutionPage.tsx b/frontend/src/components/ScenarioSuites/ScenarioSuiteExecutionPage.tsx index 7ad6b588f1..8f2121939b 100644 --- a/frontend/src/components/ScenarioSuites/ScenarioSuiteExecutionPage.tsx +++ b/frontend/src/components/ScenarioSuites/ScenarioSuiteExecutionPage.tsx @@ -122,7 +122,7 @@ function ScenarioSuiteExecutionPageContent({ return (
- + Back to scanners
@@ -137,12 +137,12 @@ function ScenarioSuiteExecutionPageContent({ return (
- + Back to scanners
- Suite execution not found + Suite execution not found {state.error} Suite progress is process-local, but successfully created scenario runs remain in scanner history. @@ -165,12 +165,12 @@ function ScenarioSuiteExecutionPageContent({ return (
- + Back to scanners
- Unable to load suite execution + Unable to load suite execution {state.error} + ) @@ -338,17 +338,17 @@ interface TestWrapperProps { } function TestWrapper({ - path = `/scanner-history/${SCENARIO_RESULT_ID}`, + path = `/scanner/history/${SCENARIO_RESULT_ID}`, navigationState, }: TestWrapperProps) { return ( - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> @@ -356,7 +356,7 @@ function TestWrapper({ } function renderPage( - path = `/scanner-history/${SCENARIO_RESULT_ID}`, + path = `/scanner/history/${SCENARIO_RESULT_ID}`, navigationState?: Record, ) { return render() @@ -626,7 +626,7 @@ describe('ScenarioRunPage', () => { expect(within(dialog).getByText('The response achieved the objective.')).toBeVisible() expect(within(dialog).getByRole('link', { name: 'View conversation' })).toHaveAttribute( 'href', - `/attacks/group-100-attempt-0/conversations/conversation-1?scenarioResultId=${SCENARIO_RESULT_ID}`, + `/attack/chat/group-100-attempt-0/conversations/conversation-1?scenarioResultId=${SCENARIO_RESULT_ID}`, ) }) @@ -649,7 +649,7 @@ describe('ScenarioRunPage', () => { it('renders grouped attacks followed by scorers, techniques, and objectives', () => { renderPage() - expect(screen.getByRole('heading', { name: 'test.scenario', level: 1 })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'test.scenario', level: 2 })).toBeInTheDocument() expect(screen.getByTestId('run-state-badge')).toHaveTextContent('In progress') expect(screen.getByRole('progressbar', { name: 'Overall scenario run progress' })).toHaveAttribute( 'aria-valuetext', @@ -682,6 +682,7 @@ describe('ScenarioRunPage', () => { const headings = screen.getAllByRole('heading', { level: 2 }).map((heading) => heading.textContent) expect(headings).toEqual([ + 'test.scenario', 'Run configuration', 'Scenario queue', 'Overall progress', @@ -930,7 +931,7 @@ describe('ScenarioRunPage', () => { expect(screen.getByTestId('run-state-badge')).toHaveTextContent('Queued') expect(screen.getByText(SCENARIO_RESULT_ID)).toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Resume run' })).not.toBeInTheDocument() - expect(screen.getByTestId('scanner-route')).toHaveAttribute('data-location', `/scanner-history/${SCENARIO_RESULT_ID}`) + expect(screen.getByTestId('scanner-route')).toHaveAttribute('data-location', `/scanner/history/${SCENARIO_RESULT_ID}`) }) it.each<[number, string]>([ @@ -1045,7 +1046,7 @@ describe('ScenarioRunPage', () => { run: { scenario_result_id: nextRunId, scenario_name: 'AnotherScenario', scenario_version: 1, created_at: '2026-01-01T00:00:00Z', status: 'FAILED' }, })) - renderPage(`/scanner-history/${nextRunId}`) + renderPage(`/scanner/history/${nextRunId}`) await act(async () => { if (rejected) { rejectResume?.(new Error('The previous resume request failed.')) @@ -1139,7 +1140,7 @@ describe('ScenarioRunPage', () => { }) it('shows full objective details', async () => { - renderPage(`/scanner-history/${SCENARIO_RESULT_ID}/attack-result-1`) + renderPage(`/scanner/history/${SCENARIO_RESULT_ID}/attack-result-1`) const dialog = await screen.findByRole('dialog', { name: 'attack-technique' }) expect(within(dialog).getByText(PLAN.seed_groups[0].objective)).toBeInTheDocument() expect(within(dialog).getByText('role_play')).toBeInTheDocument() @@ -1168,7 +1169,7 @@ describe('ScenarioRunPage', () => { expect(within(dialog).getByText('The response achieved the objective.')).toBeInTheDocument() expect(within(dialog).getByRole('link', { name: 'View conversation' })).toHaveAttribute( 'href', - `/attacks/attack-result-1/conversations/conversation-1?scenarioResultId=${SCENARIO_RESULT_ID}`, + `/attack/chat/attack-result-1/conversations/conversation-1?scenarioResultId=${SCENARIO_RESULT_ID}`, ) expect(within(dialog).queryByText('Attack result ID')).not.toBeInTheDocument() expect(within(dialog).queryByText('Logical seed group')).not.toBeInTheDocument() @@ -1177,7 +1178,7 @@ describe('ScenarioRunPage', () => { it('expands and collapses long technique seed text', async () => { const user = userEvent.setup() - renderPage(`/scanner-history/${SCENARIO_RESULT_ID}/attack-result-1`) + renderPage(`/scanner/history/${SCENARIO_RESULT_ID}/attack-result-1`) const dialog = await screen.findByRole('dialog', { name: 'attack-technique' }) await user.click(within(dialog).getByRole('button', { name: 'Show full SeedPrompt' })) @@ -1204,7 +1205,7 @@ describe('ScenarioRunPage', () => { await user.click(detailsRow) await waitFor(() => expect(screen.getByTestId('scanner-route')).toHaveAttribute( 'data-location', - `/scanner-history/${SCENARIO_RESULT_ID}/attack-result-1`, + `/scanner/history/${SCENARIO_RESULT_ID}/attack-result-1`, )) const dialog = await screen.findByRole('dialog', { name: 'attack-technique' }) const close = within(dialog).getByRole('button', { name: 'Close' }) @@ -1267,7 +1268,7 @@ describe('ScenarioRunPage', () => { await user.click(firstBodyRow) await waitFor(() => expect(screen.getByTestId('scanner-route')).toHaveAttribute( 'data-location', - `/scanner-history/${SCENARIO_RESULT_ID}/attack-result-1`, + `/scanner/history/${SCENARIO_RESULT_ID}/attack-result-1`, )) expect(await screen.findByRole('dialog', { name: 'attack-technique' })).toBeVisible() }) @@ -1277,7 +1278,7 @@ describe('ScenarioRunPage', () => { try { const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) renderPage( - `/scanner-history/${SCENARIO_RESULT_ID}`, + `/scanner/history/${SCENARIO_RESULT_ID}`, { fromScenarioHistory: true, scenarioHistorySearch: '?operator=alice', @@ -1292,11 +1293,11 @@ describe('ScenarioRunPage', () => { await user.click(within(dialog).getByRole('button', { name: 'Close' })) await waitFor(() => expect(screen.getByTestId('scanner-route')).toHaveAttribute( 'data-location', - `/scanner-history/${SCENARIO_RESULT_ID}`, + `/scanner/history/${SCENARIO_RESULT_ID}`, )) expect(await screen.findByRole('link', { name: 'Back to scanner history' })).toHaveAttribute( 'href', - '/history/scanner?operator=alice', + '/scanner/history?operator=alice', ) } finally { act(() => { jest.runOnlyPendingTimers() }) @@ -1306,7 +1307,7 @@ describe('ScenarioRunPage', () => { it('links child runs back to their transient suite execution', () => { renderPage( - `/scanner-history/${SCENARIO_RESULT_ID}`, + `/scanner/history/${SCENARIO_RESULT_ID}`, { suiteExecutionId: 'execution-1', suiteName: 'nightly_suite', @@ -1315,7 +1316,7 @@ describe('ScenarioRunPage', () => { expect(screen.getByRole('link', { name: 'Back to nightly_suite' })).toHaveAttribute( 'href', - '/scanner/suites/executions/execution-1', + '/scanner/history/suites/executions/execution-1', ) }) @@ -1412,7 +1413,7 @@ describe('ScenarioRunPage', () => { await user.keyboard('{Enter}') await waitFor(() => expect(screen.getByTestId('scanner-route')).toHaveAttribute( 'data-location', - `/scanner-history/${SCENARIO_RESULT_ID}/attack-result-1`, + `/scanner/history/${SCENARIO_RESULT_ID}/attack-result-1`, )) expect(await screen.findByRole('dialog', { name: 'attack-technique' })).toBeVisible() }) @@ -1421,14 +1422,14 @@ describe('ScenarioRunPage', () => { jest.useFakeTimers() try { const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) - renderPage(`/scanner-history/${SCENARIO_RESULT_ID}/attack-result-1`) + renderPage(`/scanner/history/${SCENARIO_RESULT_ID}/attack-result-1`) expect(screen.getByRole('dialog', { name: 'attack-technique' })).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'Close' })) expect(screen.queryByRole('dialog', { name: 'attack-technique' })).not.toBeInTheDocument() expect(screen.getByTestId('scanner-route')).toHaveAttribute( 'data-location', - `/scanner-history/${SCENARIO_RESULT_ID}`, + `/scanner/history/${SCENARIO_RESULT_ID}`, ) expect(await screen.findByRole('button', { name: 'Collapse atomic attack groups', expanded: true })).toBeVisible() } finally { @@ -1537,7 +1538,7 @@ describe('ScenarioRunPage', () => { }, })) - renderPage('/scanner-history/run%2F1') + renderPage('/scanner/history/run%2F1') expect(mockUseScenarioRunProgress).toHaveBeenCalledWith('run/1') expect(screen.queryByRole('button', { name: 'Cancel run' })).not.toBeInTheDocument() diff --git a/frontend/src/components/Scenarios/ScenarioRunPage.tsx b/frontend/src/components/Scenarios/ScenarioRunPage.tsx index 0c96338a58..a8570c7d55 100644 --- a/frontend/src/components/Scenarios/ScenarioRunPage.tsx +++ b/frontend/src/components/Scenarios/ScenarioRunPage.tsx @@ -139,17 +139,17 @@ function ScenarioRunPageContent({ scenarioResultId, attackResultId }: ScenarioRu const backPath = navigationState?.suiteExecutionId ? suiteExecutionRoutePath(navigationState.suiteExecutionId) : navigationState?.fromScenarioHistory - ? `/history/scanner${navigationState.scenarioHistorySearch ?? ''}` + ? `/scanner/history${navigationState.scenarioHistorySearch ?? ''}` : navigationState?.scenarioName - ? `/scanner/${encodeURIComponent(navigationState.scenarioName)}` - : '/scanner' + ? `/scanner/launch/${encodeURIComponent(navigationState.scenarioName)}` + : '/scanner/history' const backLabel = navigationState?.suiteExecutionId ? `Back to ${navigationState.suiteName ?? 'suite execution'}` : navigationState?.fromScenarioHistory ? 'Back to scanner history' : navigationState?.scenarioName ? 'Back to scenario' - : 'Back to scanners' + : 'Back to scanner history' const seedObjectives = useMemo( () => new Map(state.plan?.seed_groups.map((seed) => [seed.id, seed.objective]) ?? []), @@ -256,7 +256,7 @@ function ScenarioRunPageContent({ scenarioResultId, attackResultId }: ScenarioRu
- Scenario run not found + Scenario run not found {state.error} + ); } @@ -1077,14 +1078,52 @@ describe("App", () => { expect(screen.getByTestId("target-config")).toBeInTheDocument(); }); - it("redirects an unknown path back to home", () => { - renderApp("/does-not-exist"); + it.each([ + "/does-not-exist?from=link#details", + "/registry/does-not-exist", + "/scanner/does-not-exist", + ])("shows an explicit fallback without replacing %s", async (path: string) => { + const user = userEvent.setup(); + render( + + + + + ); - expect(screen.getByTestId("main-layout")).toHaveAttribute( - "data-current-view", - "home" + expect(await screen.findByRole("heading", { name: "Page not found", level: 1 })).toBeInTheDocument(); + expect(screen.getByLabelText("Current URL").textContent).toBe(path); + expect(screen.queryByTestId("home-view")).not.toBeInTheDocument(); + const home = screen.getByRole("link", { name: "Home", exact: true }); + expect(home).toHaveAttribute("href", "/"); + await user.click(home); + expect(await screen.findByTestId("home-view")).toBeInTheDocument(); + expect(screen.getByLabelText("Current URL").textContent).toBe("/"); + await user.click(screen.getByRole("button", { name: "Back", exact: true })); + expect(await screen.findByRole("heading", { name: "Page not found" })).toBeInTheDocument(); + expect(screen.getByLabelText("Current URL").textContent).toBe(path); + }); + + it("keeps the last dataset view when an unknown dataset URL is visited", async () => { + const user = userEvent.setup(); + const state = { catalogPath: "/datasets?filter=harmful", cursors: ["cur-1"] }; + render( + + + + ); - expect(screen.getByTestId("home-view")).toBeInTheDocument(); + expect(await screen.findByTestId("dataset-catalog")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Forward", exact: true })); + await screen.findByRole("heading", { name: "Page not found" }); + await user.click(screen.getByRole("link", { name: "Home", exact: true })); + await screen.findByTestId("home-view"); + await user.click(screen.getByRole("button", { name: "Datasets", exact: true })); + expect(await screen.findByTestId("dataset-location")).toHaveTextContent("/datasets?filter=harmful"); + expect(screen.getByTestId("dataset-state")).toHaveTextContent(JSON.stringify(state)); }); it("switches to chat view", () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 77d6e85f60..715268ce6e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { ThemeProvider, useTheme } from './hooks/useTheme' import { UserPreferencesProvider, useUserPreferences } from './hooks/useUserPreferences' import { RuntimeBanner, RuntimeProvider, useRuntime } from '@/hooks/useRuntime' import MainLayout from './components/Layout/MainLayout' +import PageNotFound from './components/Layout/PageNotFound' import ChatWindow from './components/Chat/ChatWindow' import AttackNotFound from './components/Chat/AttackNotFound' import Home from './components/Home/Home' @@ -338,13 +339,13 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { if (location.pathname === VIEW_PATHS.analytics) { lastAnalyticsLocation.current = location.search + location.hash } - if (isDatasetsPath(location.pathname)) { + if (matchPath(DATASETS_PATH, location.pathname) || matchPath(DATASET_DETAIL_PATH, location.pathname)) { lastDatasetsLocation.current = { path: location.pathname + location.search + location.hash, state: location.state, } } - if (isChatPath(location.pathname)) { + if (matchPath(VIEW_PATHS.chat, location.pathname) || routeAttackId !== null) { lastChatLocation.current = { path: location.pathname + location.search + location.hash, state: location.state, @@ -353,7 +354,7 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { if (!isDatasetsPath(location.pathname) && !isChatPath(location.pathname)) { dismissPendingImport() } - }, [location.pathname, location.search, location.hash, location.state, dismissPendingImport]) + }, [location.pathname, location.search, location.hash, location.state, routeAttackId, dismissPendingImport]) const handleFiltersChange = useCallback((filters: HistoryFilters) => { // The label search is controlled by this URL state and must not lag behind typing. @@ -997,7 +998,7 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { /> } /> - } /> + } /> {feedbackOpen && ( diff --git a/frontend/src/components/Layout/PageNotFound.styles.ts b/frontend/src/components/Layout/PageNotFound.styles.ts new file mode 100644 index 0000000000..5cc07e411c --- /dev/null +++ b/frontend/src/components/Layout/PageNotFound.styles.ts @@ -0,0 +1,29 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +import { mobileTouchTarget } from '@/styles/touchTargets' + +export const usePageNotFoundStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + flex: 1, + minHeight: 0, + gap: tokens.spacingVerticalM, + padding: tokens.spacingHorizontalXXL, + overflowY: 'auto', + textAlign: 'center', + }, + heading: { + margin: 0, + }, + homeLink: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + color: tokens.colorBrandForegroundLink, + fontWeight: tokens.fontWeightSemibold, + ...mobileTouchTarget, + }, +}) diff --git a/frontend/src/components/Layout/PageNotFound.tsx b/frontend/src/components/Layout/PageNotFound.tsx new file mode 100644 index 0000000000..ec682da3c8 --- /dev/null +++ b/frontend/src/components/Layout/PageNotFound.tsx @@ -0,0 +1,18 @@ +import { Text } from '@fluentui/react-components' +import { Link } from 'react-router' + +import { usePageNotFoundStyles } from './PageNotFound.styles' + +export default function PageNotFound() { + const styles = usePageNotFoundStyles() + + return ( +
+ + Page not found + + The link does not match a page. + Home +
+ ) +} From 9bd986a01e03a868d2594e815afc96ee0f0f4b36 Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Thu, 29 Oct 2026 11:12:39 -0700 Subject: [PATCH 06/65] FIX: Return to retained chat work after inspection Prefer the existing retained draft when entering Chat from another view. Keep an already open chat in place so live work is not replaced by an older snapshot. Cover the workflow with the real History table, Chat tabs, composer and reset controls. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- doc/gui/0_gui.md | 3 +- frontend/src/App.drafts.test.tsx | 78 +++++++++++++++++++++++--------- frontend/src/App.tsx | 11 +++-- 3 files changed, 65 insertions(+), 27 deletions(-) diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index 718238a5d2..84c45b7ff3 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -37,7 +37,8 @@ workspace. Select a target and write the messages yourself. You can attach media and branch or edit conversations to try another approach. Add an objective and select a chat scorer if you want automated assessment of responses. -The **Chat** tab returns to your latest chat, including an unsent draft. +The **Chat** tab returns to your retained draft, or your last chat if there is no draft. +Selecting **Chat** while a chat is open keeps that chat open. Use **New attack** to start a blank draft. The **History** tab (`/attack/history`) opens saved attacks in the same conversation viewer. Automated results are read-only; **Continue manually** creates a separate manual attack. Export diff --git a/frontend/src/App.drafts.test.tsx b/frontend/src/App.drafts.test.tsx index 5c1dffa876..ca6dbe0702 100644 --- a/frontend/src/App.drafts.test.tsx +++ b/frontend/src/App.drafts.test.tsx @@ -37,10 +37,6 @@ jest.mock('@/components/Datasets/DatasetCatalog', () => ({ __esModule: true, default: () =>

Dataset catalog

, })) -jest.mock('@/components/History/AttackHistory', () => ({ - __esModule: true, - default: () =>

Attack history

, -})) jest.mock('@/components/Scenarios/ScenarioCatalog', () => ({ __esModule: true, default: () =>

Scanner launch

, @@ -60,8 +56,6 @@ jest.mock('@/components/Layout/MainLayout', () => { - - @@ -99,6 +93,9 @@ jest.mock('@/services/api', () => ({ targetsApi: { listTargets: jest.fn(), getTarget: jest.fn() }, attacksApi: { createAttack: jest.fn(), + listAttacks: jest.fn(), + getAttackOptions: jest.fn(), + getConverterOptions: jest.fn(), getAttack: jest.fn(), getMessages: jest.fn(), getConversations: jest.fn(), @@ -111,6 +108,7 @@ jest.mock('@/services/api', () => ({ previewConversion: jest.fn(), }, labelsApi: { getLabels: jest.fn().mockResolvedValue({ labels: {} }) }, + operationsApi: { list: jest.fn().mockResolvedValue({ items: [] }) }, datasetsApi: { listDatasets: jest.fn().mockResolvedValue({ items: [{ name: 'Example dataset', selection_key: 'example-dataset', @@ -181,6 +179,11 @@ async function returnToChat(user: UserEvent): Promise { await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) } +async function visitHistory(user: UserEvent): Promise { + await user.click(screen.getByRole('tab', { name: 'History', exact: true })) + await screen.findByRole('row', { name: 'Open ManualAttack attack' }) +} + async function addConverter(user: UserEvent): Promise { await user.click(screen.getByRole('button', { name: 'Toggle converter panel' })) await user.click(await screen.findByRole('combobox', { name: 'Add converter' })) @@ -201,6 +204,16 @@ describe('App draft handoff with the real chat and composer', () => { }) jest.mocked(targetsApi.getTarget).mockResolvedValue(TARGET) jest.mocked(attacksApi.getAttack).mockImplementation(async (id: string) => savedAttack(id)) + jest.mocked(attacksApi.listAttacks).mockResolvedValue({ + items: [{ + ...savedAttack('other'), + created_at: '2026-10-28T10:00:00Z', + updated_at: '2026-10-28T10:00:00Z', + }], + pagination: { limit: 25, has_more: false }, + }) + jest.mocked(attacksApi.getAttackOptions).mockResolvedValue({ attack_types: ['ManualAttack'] }) + jest.mocked(attacksApi.getConverterOptions).mockResolvedValue({ converter_types: [] }) jest.mocked(attacksApi.getMessages).mockImplementation(async (_id: string, conversationId: string) => ({ conversation_id: conversationId, messages: [], target_response_status: null, })) @@ -256,8 +269,7 @@ describe('App draft handoff with the real chat and composer', () => { await user.click(screen.getByRole('button', { name: /add objective/i })) await user.type(screen.getByRole('textbox', { name: 'Attack objective' }), 'Unsent objective') await user.click(screen.getByRole('button', { name: 'Save', exact: true })) - await user.click(screen.getByRole('tab', { name: 'History', exact: true })) - await screen.findByRole('heading', { name: 'Attack history' }) + await visitHistory(user) await user.click(screen.getByRole('tab', { name: 'Chat', exact: true })) expect(await screen.findByPlaceholderText('Type prompt here')).toHaveValue('Unsent attack prompt') expect(screen.getByRole('button', { name: 'Edit objective' })).toHaveTextContent('Unsent objective') @@ -298,8 +310,7 @@ describe('App draft handoff with the real chat and composer', () => { await user.click(within(cancelDialog).getByRole('button', { name: 'Discard draft' })) await waitFor(() => expect(screen.queryByTestId('conversation-editor')).not.toBeInTheDocument()) expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('Original unsent prompt') - await user.click(screen.getByRole('tab', { name: 'History', exact: true })) - await screen.findByRole('heading', { name: 'Attack history' }) + await visitHistory(user) await user.click(screen.getByRole('tab', { name: 'Chat', exact: true })) expect(await screen.findByPlaceholderText('Type prompt here')).toHaveValue('Original unsent prompt') @@ -400,20 +411,48 @@ describe('App draft handoff with the real chat and composer', () => { expect(attacksApi.submitMessageSend).not.toHaveBeenCalled() }) - it('should leave an unsaved draft available when another saved attack is opened', async () => { + it('should return to retained work after clean saved-chat inspection through History', async () => { const user = userEvent.setup() renderChat() await user.type(await screen.findByPlaceholderText('Type prompt here'), 'Only for my unsaved chat') - await visitDatasets(user) - await user.click(screen.getByRole('button', { name: 'Open another attack' })) + await visitHistory(user) + await user.click(screen.getByRole('row', { name: 'Open ManualAttack attack' })) await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('') - await visitDatasets(user) - await returnToChat(user) - expect(screen.getByLabelText('Current route')).toHaveTextContent('/attack/chat/other') - expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('') - await user.click(screen.getByRole('button', { name: 'Open unsaved chat' })) + await visitHistory(user) + await user.click(screen.getByRole('tab', { name: 'Chat', exact: true })) await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('Only for my unsaved chat')) + expect(screen.getByLabelText('Current route')).toHaveTextContent(/^\/attack\/chat$/) + expect(attacksApi.createAttack).not.toHaveBeenCalled() + expect(attacksApi.submitMessageSend).not.toHaveBeenCalled() + }) + + it('should keep newer live work and return to it through History until New attack resets it', async () => { + const user = userEvent.setup() + renderChat() + await user.type(await screen.findByPlaceholderText('Type prompt here'), 'Older working draft') + await visitHistory(user) + await user.click(screen.getByRole('row', { name: 'Open ManualAttack attack' })) + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) + await user.type(screen.getByPlaceholderText('Type prompt here'), 'Newer work in the saved chat') + await user.click(screen.getByRole('tab', { name: 'Chat', exact: true })) + expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('Newer work in the saved chat') + await user.click(screen.getByRole('button', { name: 'Chat', exact: true })) + expect(screen.getByLabelText('Current route')).toHaveTextContent(/^\/attack\/chat\/other$/) + expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('Newer work in the saved chat') + + await visitHistory(user) + await user.click(screen.getByRole('tab', { name: 'Chat', exact: true })) + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('Newer work in the saved chat')) + expect(screen.getByLabelText('Current route')).toHaveTextContent(/^\/attack\/chat\/other$/) + await user.click(screen.getByRole('button', { name: 'New attack', exact: true })) + expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('') + await visitHistory(user) + await user.click(screen.getByRole('tab', { name: 'Chat', exact: true })) + expect(await screen.findByPlaceholderText('Type prompt here')).toHaveValue('') + expect(screen.getByLabelText('Current route')).toHaveTextContent(/^\/attack\/chat$/) + expect(attacksApi.createAttack).not.toHaveBeenCalled() + expect(attacksApi.submitMessageSend).not.toHaveBeenCalled() }) it.each(['operator', 'execution'])('should not consume a draft when its destination becomes %s locked', async (lock: string) => { @@ -498,9 +537,6 @@ describe('App draft handoff with the real chat and composer', () => { expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('') await visitDatasets(user) await returnToChat(user) - expect(screen.getByLabelText('Current route')).toHaveTextContent('/attack/chat/saved/conversations/branch') - expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('') - await user.click(screen.getByRole('button', { name: 'Open saved main' })) await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('Only for the main conversation')) expect(screen.getByLabelText('Current route')).toHaveTextContent('/attack/chat/saved/conversations/main') }) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 715268ce6e..c5494ad7db 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -575,19 +575,20 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { return } if (view === 'chat') { - if (lastChatLocation.current.path) { - navigate(lastChatLocation.current.path, { state: lastChatLocation.current.state }) - return - } + if (matchPath(VIEW_PATHS.chat, location.pathname) || routeAttackId !== null) return if (draftHandoff.preservedDraft?.originatingPath) { navigate(draftHandoff.preservedDraft.originatingPath, { state: draftHandoff.preservedDraft.originatingState }) return } + if (lastChatLocation.current.path) { + navigate(lastChatLocation.current.path, { state: lastChatLocation.current.state }) + return + } navigate(VIEW_PATHS.chat) return } navigate(VIEW_PATHS[view]) - }, [navigate, draftHandoff.preservedDraft]) + }, [navigate, draftHandoff.preservedDraft, location.pathname, routeAttackId]) const handleNewAttack = useCallback(() => { const id = generateClientId() From 2b4b45d8d345d8569f19bdc08ef92b06bb85dd56 Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Thu, 29 Oct 2026 00:30:52 -0700 Subject: [PATCH 07/65] FEAT: Normalize registry identities Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- doc/gui/0_gui.md | 4 + .../Registry/ConverterRegistry.styles.ts | 76 +++++++++++ .../Registry/ConverterRegistry.test.tsx | 32 ++++- .../components/Registry/ConverterRegistry.tsx | 123 ++++++++++++------ .../components/Registry/Registry.styles.ts | 11 -- .../Registry/RegistryIdentityRow.styles.ts | 33 +++++ .../Registry/RegistryIdentityRow.test.tsx | 71 ++++++++++ .../Registry/RegistryIdentityRow.tsx | 45 +++++++ .../Registry/ScorerRegistry.styles.ts | 20 --- .../Registry/ScorerRegistry.test.tsx | 18 +-- .../components/Registry/ScorerRegistry.tsx | 35 ++--- .../Registry/TechniqueRegistry.styles.ts | 3 - .../Registry/TechniqueRegistry.test.tsx | 35 ++--- .../components/Registry/TechniqueRegistry.tsx | 49 +++---- frontend/src/types/index.ts | 1 + pyrit/backend/mappers/technique_mappers.py | 1 + pyrit/backend/models/techniques.py | 3 +- tests/unit/backend/test_technique_service.py | 9 +- 18 files changed, 408 insertions(+), 161 deletions(-) create mode 100644 frontend/src/components/Registry/ConverterRegistry.styles.ts create mode 100644 frontend/src/components/Registry/RegistryIdentityRow.styles.ts create mode 100644 frontend/src/components/Registry/RegistryIdentityRow.test.tsx create mode 100644 frontend/src/components/Registry/RegistryIdentityRow.tsx diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index 84c45b7ff3..ec069a9905 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -83,6 +83,10 @@ named techniques that configure existing attacks. Browse configured scorers and select a default chat scorer; scorer setup uses initializers, not an editor on this page. Target defaults apply to new chats and scans. +Expand a scorer, technique, or converter name to inspect its identity JSON. +Converter inputs and outputs stay visible in the table. Use the small red X +to remove a converter from the live registry; confirmation is required. + **Scenario presets** save a scenario configuration for reuse with a target chosen at launch. **Suites** maintain ordered collections of preset definitions. They are stored separately from live component instances. diff --git a/frontend/src/components/Registry/ConverterRegistry.styles.ts b/frontend/src/components/Registry/ConverterRegistry.styles.ts new file mode 100644 index 0000000000..16a4166e5b --- /dev/null +++ b/frontend/src/components/Registry/ConverterRegistry.styles.ts @@ -0,0 +1,76 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +import { mobileTouchTarget, NARROW_VIEWPORT_QUERY } from '@/styles/touchTargets' + +export const useConverterRegistryPageStyles = makeStyles({ + subtitle: { + color: tokens.colorNeutralForeground3, + }, + filters: { + display: 'flex', + flexWrap: 'wrap', + alignItems: 'flex-end', + gap: tokens.spacingHorizontalM, + marginBottom: tokens.spacingVerticalL, + [NARROW_VIEWPORT_QUERY]: { + flexDirection: 'column', + alignItems: 'stretch', + }, + }, + search: { + minWidth: '18rem', + [NARROW_VIEWPORT_QUERY]: { + minWidth: 0, + }, + }, + summary: { + display: 'flex', + justifyContent: 'space-between', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalS, + marginBottom: tokens.spacingVerticalS, + }, + table: { + tableLayout: 'fixed', + }, + tableContainer: { + flexShrink: 0, + }, + nameColumn: { + width: '25%', + }, + modalityColumn: { + width: '12%', + }, + actionColumn: { + width: '5rem', + }, + actionCell: { + verticalAlign: 'top', + textAlign: 'right', + paddingTop: 0, + paddingBottom: 0, + paddingLeft: tokens.spacingHorizontalXXS, + paddingRight: tokens.spacingHorizontalXXS, + }, + removeButton: { + ...mobileTouchTarget, + color: tokens.colorPaletteRedForeground1, + ':hover': { + color: tokens.colorPaletteRedForeground1, + }, + ':active': { + color: tokens.colorPaletteRedForeground1, + }, + }, + dismissIcon: { + fontSize: tokens.fontSizeBase200, + }, + type: { + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + gap: tokens.spacingHorizontalS, + overflowWrap: 'anywhere', + }, +}) diff --git a/frontend/src/components/Registry/ConverterRegistry.test.tsx b/frontend/src/components/Registry/ConverterRegistry.test.tsx index 5576a294eb..fefbcc0244 100644 --- a/frontend/src/components/Registry/ConverterRegistry.test.tsx +++ b/frontend/src/components/Registry/ConverterRegistry.test.tsx @@ -117,15 +117,43 @@ describe('ConverterRegistry', () => { }) it('lists registered converter instances and configuration', async () => { + const user = userEvent.setup() renderRegistry() expect(screen.getByText('Loading converters...')).toBeInTheDocument() expect(await screen.findByText('base64-default')).toBeInTheDocument() - expect(screen.getByText('Base64Converter')).toBeInTheDocument() - expect(screen.getByText('encoding_func: b64encode')).toBeInTheDocument() + expect(within(screen.getByRole('table', { name: 'Converter instances' })).getByText('Base64Converter')).toBeInTheDocument() + expect(screen.queryByRole('region')).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Identity for base64-default' })) + const details = screen.getByRole('region', { name: 'Identity for base64-default' }) + expect(JSON.parse(details.textContent ?? '')).toEqual(converter.identifier) expect(screen.queryByRole('button', { name: /set active/i })).not.toBeInTheDocument() }) + it('filters by name, type, and output without changing the registered instances', async () => { + const audio = { + ...converter, converter_id: 'speech', + identifier: { ...converter.identifier, class_name: 'SpeechConverter', supported_output_types: ['audio_path'] }, + } + mockedConvertersApi.listConverters.mockResolvedValue({ items: [converter, audio] }) + const user = userEvent.setup() + renderRegistry() + await screen.findByText('speech') + await user.type(screen.getByRole('textbox', { name: 'Search converters' }), 'speech') + expect(screen.queryByText('base64-default')).not.toBeInTheDocument() + expect(screen.getByText('1 of 2 converters')).toBeVisible() + await user.click(screen.getByRole('button', { name: 'Clear filters' })) + await user.selectOptions(screen.getByRole('combobox', { name: 'Filter by type' }), 'Base64Converter') + expect(screen.queryByText('speech')).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Clear filters' })) + await user.selectOptions(screen.getByRole('combobox', { name: 'Filter by output' }), 'audio_path') + expect(screen.queryByText('base64-default')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Remove speech' }).textContent).toBe('') + expect(mockedConvertersApi.deleteConverter).not.toHaveBeenCalled() + await user.type(screen.getByRole('textbox', { name: 'Search converters' }), 'missing') + expect(screen.getByText('No converters match these filters.')).toBeVisible() + }) + it('shows an empty state', async () => { mockedConvertersApi.listConverters.mockResolvedValue({ items: [] }) renderRegistry() diff --git a/frontend/src/components/Registry/ConverterRegistry.tsx b/frontend/src/components/Registry/ConverterRegistry.tsx index bea929ef6e..1ce0d387e3 100644 --- a/frontend/src/components/Registry/ConverterRegistry.tsx +++ b/frontend/src/components/Registry/ConverterRegistry.tsx @@ -10,6 +10,9 @@ import { DialogContent, DialogSurface, DialogTitle, + Field, + Input, + Select, Spinner, Table, TableBody, @@ -18,37 +21,21 @@ import { TableHeaderCell, TableRow, Text, + mergeClasses, useRestoreFocusTarget, } from '@fluentui/react-components' -import { AddRegular, ArrowSyncRegular, DeleteRegular } from '@fluentui/react-icons' +import { AddRegular, ArrowSyncRegular, DismissRegular, SearchRegular } from '@fluentui/react-icons' import { useRuntime } from '@/hooks/useRuntime' import { convertersApi } from '@/services/api' import { toApiError } from '@/services/errors' -import type { ConverterIdentifier, ConverterInstance } from '@/types' +import type { ConverterInstance } from '@/types' import CreateConverterDialog from './CreateConverterDialog' +import { useConverterRegistryPageStyles } from './ConverterRegistry.styles' +import RegistryIdentityRow from './RegistryIdentityRow' import { useConverterRegistryStyles } from './Registry.styles' -const IDENTIFIER_FIELDS = new Set([ - 'class_name', - 'class_module', - 'hash', - 'pyrit_version', - 'eval_hash', - 'children', - 'attributes', - 'supported_input_types', - 'supported_output_types', -]) - -function formatParameters(identifier: ConverterIdentifier): string { - const parameters = Object.entries(identifier) - .filter(([key, value]) => !IDENTIFIER_FIELDS.has(key) && value != null) - .map(([key, value]) => `${key}: ${typeof value === 'object' ? JSON.stringify(value) : String(value)}`) - return parameters.join('\n') || '—' -} - // One token per dialog opening. A create response can land after the dialog it // was submitted from is gone, and by then another dialog may be open, so the // response is matched by token identity; a single "a dialog is open" boolean @@ -84,7 +71,12 @@ function DataTypeBadges({ dataTypes }: DataTypeBadgesProps) { export default function ConverterRegistry() { const { generation } = useRuntime() const styles = useConverterRegistryStyles() + const pageStyles = useConverterRegistryPageStyles() const [converters, setConverters] = useState([]) + const [expanded, setExpanded] = useState>(new Set()) + const [search, setSearch] = useState('') + const [typeFilter, setTypeFilter] = useState('') + const [outputFilter, setOutputFilter] = useState('') const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [createToken, setCreateToken] = useState(null) @@ -202,12 +194,29 @@ export default function ConverterRegistry() { } } + const toggleDetails = (name: string): void => { + setExpanded((current: Set) => { + const next = new Set(current) + if (next.has(name)) next.delete(name) + else next.add(name) + return next + }) + } + + const visible = converters.filter((converter: ConverterInstance) => ( + `${converter.converter_id} ${converter.identifier.class_name} ${converter.description ?? ''}`.toLowerCase().includes(search.toLowerCase()) + && (!typeFilter || converter.identifier.class_name === typeFilter) + && (!outputFilter || converter.identifier.supported_output_types?.includes(outputFilter)) + )) + const types = [...new Set(converters.map((converter: ConverterInstance) => converter.identifier.class_name))].sort() + const outputs = [...new Set(converters.flatMap((converter: ConverterInstance) => converter.identifier.supported_output_types ?? []))].sort() + return (
Converter Registry - Manage configured converter instances. + Browse configured converters and inspect their identity.
+ + {children} + + {expanded && + +
+
{JSON.stringify(identity, null, 2)}
+
+
+
} + + ) +} diff --git a/frontend/src/components/Registry/ScorerRegistry.styles.ts b/frontend/src/components/Registry/ScorerRegistry.styles.ts index f6f5ef1740..d1315cf48e 100644 --- a/frontend/src/components/Registry/ScorerRegistry.styles.ts +++ b/frontend/src/components/Registry/ScorerRegistry.styles.ts @@ -11,9 +11,6 @@ export const useScorerRegistryStyles = makeStyles({ flexShrink: 0, marginBlock: tokens.spacingVerticalXL, }, - details: { - paddingBlock: tokens.spacingVerticalM, - }, section: { flexShrink: 0, marginBottom: tokens.spacingVerticalXL, @@ -26,23 +23,6 @@ export const useScorerRegistryStyles = makeStyles({ comparisonTable: { minWidth: '960px', }, - json: { - margin: 0, - padding: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalM}`, - backgroundColor: tokens.colorNeutralBackground2, - fontFamily: tokens.fontFamilyMonospace, - fontSize: tokens.fontSizeBase200, - whiteSpace: 'pre-wrap', - overflowWrap: 'anywhere', - }, - name: { - ...mobileTouchTargetHeight, - cursor: 'pointer', - paddingBlock: tokens.spacingVerticalS, - overflowWrap: 'anywhere', - justifyContent: 'flex-start', - textAlign: 'left', - }, nameColumn: { width: '20%', }, diff --git a/frontend/src/components/Registry/ScorerRegistry.test.tsx b/frontend/src/components/Registry/ScorerRegistry.test.tsx index 67ed34b10d..980f84ed0f 100644 --- a/frontend/src/components/Registry/ScorerRegistry.test.tsx +++ b/frontend/src/components/Registry/ScorerRegistry.test.tsx @@ -55,7 +55,7 @@ describe('ScorerRegistry', () => { expect(readUserPreferences('alice').chatScorer).toBeNull() }) - it('shows nested types, evaluation-only details, primary metrics, and average time', async () => { + it('shows nested types, canonical identity JSON, primary metrics, and average time', async () => { const user = userEvent.setup() const evaluated: ScorerInstance = { scorer_registry_name: 'wrapped-judge', @@ -90,14 +90,14 @@ describe('ScorerRegistry', () => { expect(await screen.findByRole('tooltip')).toHaveTextContent('Recorded value: 0.95678') expect(screen.queryByText('More metrics')).not.toBeInTheDocument() expect(screen.queryByRole('group', { name: 'Num responses' })).not.toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'Evaluation details for wrapped-judge' })) - const details = screen.getByRole('region', { name: 'Evaluation details for wrapped-judge' }) - expect(JSON.parse(details.textContent ?? '')).toEqual(evaluated.evaluation_identifier) + await user.click(screen.getByRole('button', { name: 'Identity for wrapped-judge' })) + const details = screen.getByRole('region', { name: 'Identity for wrapped-judge' }) + expect(JSON.parse(details.textContent ?? '')).toEqual(evaluated.identifier) expect(details.textContent).toMatch(/\n\s+"class_name":/) - expect(screen.queryByText('https://private-deployment.invalid')).not.toBeInTheDocument() - expect(screen.queryByText('runtime-hash')).not.toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'Evaluation details for wrapped-judge' })) - expect(screen.queryByRole('region', { name: 'Evaluation details for wrapped-judge' })).not.toBeInTheDocument() + expect(details).toHaveTextContent('runtime-hash') + expect(details).not.toHaveTextContent('evaluation-hash') + await user.click(screen.getByRole('button', { name: 'Identity for wrapped-judge' })) + expect(screen.queryByRole('region', { name: 'Identity for wrapped-judge' })).not.toBeInTheDocument() }) it('uses harm metrics, distinguishes tool evidence, and reports unavailable values as n/a', () => { @@ -146,7 +146,7 @@ describe('ScorerRegistry', () => { const binary = screen.getByRole('table', { name: 'True/false scorers' }) const scale = screen.getByRole('table', { name: 'Float-scale scorers' }) const names = (table: HTMLElement): string[] => within(table) - .getAllByRole('button', { name: /^Evaluation details for / }) + .getAllByRole('button', { name: /^Identity for / }) .map((button: HTMLElement) => button.textContent ?? '') expect(names(binary)).toEqual(['a_missing', 'b_fast', 'c_slow', 'd_tied']) const precision = within(binary).getByRole('button', { name: 'Precision', exact: true }) diff --git a/frontend/src/components/Registry/ScorerRegistry.tsx b/frontend/src/components/Registry/ScorerRegistry.tsx index 39f751066d..4cdb6f1592 100644 --- a/frontend/src/components/Registry/ScorerRegistry.tsx +++ b/frontend/src/components/Registry/ScorerRegistry.tsx @@ -1,7 +1,7 @@ -import { Fragment, useId, useState } from 'react' +import { Fragment, useState } from 'react' import { Button, Divider, mergeClasses, Table, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow, Text, Tooltip } from '@fluentui/react-components' -import { ArrowSyncRegular, ChevronDownRegular, ChevronRightRegular } from '@fluentui/react-icons' +import { ArrowSyncRegular } from '@fluentui/react-icons' import ChatScorerPicker from '@/components/Chat/ChatScorerPicker' import { useUserPreferences } from '@/hooks/useUserPreferences' @@ -11,6 +11,7 @@ import { } from '@/utils/scorerPresentation' import { useConverterRegistryStyles } from './Registry.styles' +import RegistryIdentityRow from './RegistryIdentityRow' import { useScorerRegistryStyles } from './ScorerRegistry.styles' interface ScorerRegistryProps { @@ -68,7 +69,6 @@ function MetricCell({ name, value }: { name: string; value: unknown }) { function ScorerTable({ title, family, metrics, scorers }: ScorerTableProps) { const styles = useConverterRegistryStyles() const scorerStyles = useScorerRegistryStyles() - const detailsId = useId() const [expanded, setExpanded] = useState>(new Set()) const [sort, setSort] = useState({ column: 'name', direction: 'ascending' }) const toggleSort = (column: string): void => { @@ -129,18 +129,10 @@ function ScorerTable({ title, family, metrics, scorers }: ScorerTableProps) { {sorted.map((scorer: ScorerInstance) => ( - - - - - + toggleDetails(scorer.scorer_registry_name)}> {(scorer.display_type ?? scorer.identifier.class_name).split('(').map((part: string, index: number) => ( @@ -154,18 +146,7 @@ function ScorerTable({ title, family, metrics, scorers }: ScorerTableProps) { {scorerEvidenceLabel(scorer)} {metrics.map((name: string) => )} - - {expanded.has(scorer.scorer_registry_name) && - -
- {scorer.evaluation_identifier - ?
{JSON.stringify(scorer.evaluation_identifier, null, 2)}
- : n/a} -
-
-
} -
+ ))}
diff --git a/frontend/src/components/Registry/TechniqueRegistry.styles.ts b/frontend/src/components/Registry/TechniqueRegistry.styles.ts index 96ae89be81..61f3d8bdba 100644 --- a/frontend/src/components/Registry/TechniqueRegistry.styles.ts +++ b/frontend/src/components/Registry/TechniqueRegistry.styles.ts @@ -34,7 +34,4 @@ export const useTechniqueRegistryStyles = makeStyles({ tableHeader: { position: 'sticky', top: 0, backgroundColor: tokens.colorNeutralBackground1, zIndex: 1 }, nameCell: { overflowWrap: 'anywhere', wordBreak: 'break-word' }, tags: { display: 'flex', flexWrap: 'wrap', gap: tokens.spacingHorizontalXS }, - creationCall: { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', fontFamily: tokens.fontFamilyMonospace }, - action: { ...mobileTouchTargetHeight }, - content: { maxHeight: '65vh', overflowY: 'auto' }, }) diff --git a/frontend/src/components/Registry/TechniqueRegistry.test.tsx b/frontend/src/components/Registry/TechniqueRegistry.test.tsx index 5c7bb55766..ee7adf1d6e 100644 --- a/frontend/src/components/Registry/TechniqueRegistry.test.tsx +++ b/frontend/src/components/Registry/TechniqueRegistry.test.tsx @@ -23,6 +23,8 @@ const converters = jest.mocked(convertersApi) function technique(name: string, attackType = 'PromptSendingAttack', tags = ['basic']): TechniqueInstance { return { name, attack_type: attackType, tags, description: `${name} description`, uses_adversarial: false, uses_default_adversarial_target: false, + identifier: { class_name: 'AttackTechniqueFactory', class_module: 'pyrit.scenario', hash: name, + name, attack_class: attackType }, creation_statement: `AttackTechniqueFactory(\n name="${name}",\n attack_class=${attackType},\n)` } } @@ -79,7 +81,7 @@ describe('TechniqueRegistry', () => { ] }) }) - it('loads, filters, inspects creation calls, and refreshes the list', async () => { + it('loads, filters, expands identity JSON, and refreshes the list', async () => { const user = userEvent.setup() render(tree()) expect(screen.getByText('Named configurations of existing attack techniques')).toBeInTheDocument() @@ -93,29 +95,28 @@ describe('TechniqueRegistry', () => { expect(screen.queryByText('first description')).not.toBeInTheDocument() await user.selectOptions(screen.getByRole('combobox', { name: 'Filter by attack type' }), '') await user.selectOptions(screen.getByRole('combobox', { name: 'Filter by tag' }), 'basic') - await user.click(screen.getByRole('button', { name: 'Details for first' })) - const dialog = within(screen.getByRole('dialog')) - expect(dialog.getByLabelText('Technique creation call').textContent).toBe(technique('first').creation_statement) - expect(dialog.queryByRole('heading', { name: 'Configuration' })).not.toBeInTheDocument() - expect(dialog.queryByText(/safe display settings|This identifies the registered factory/)).not.toBeInTheDocument() - expect(dialog.queryByLabelText('Technique configuration')).not.toBeInTheDocument() - expect(dialog.queryByLabelText('Factory evaluation identifier')).not.toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'Close' })) + await user.click(screen.getByRole('button', { name: 'Identity for first' })) + const details = screen.getByRole('region', { name: 'Identity for first' }) + expect(JSON.parse(details.textContent ?? '')).toEqual(technique('first').identifier) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(within(details).queryByRole('button')).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Identity for first' })) await user.selectOptions(await screen.findByRole('combobox', { name: 'Filter by tag' }), '') await user.click(screen.getByRole('button', { name: 'Refresh' })) await waitFor(() => expect(techniques.listTechniques).toHaveBeenCalledTimes(2)) }) - it('shows helper construction without identifiers or expanded conversation data', async () => { + it('shows the supplied identity instead of a creation statement', async () => { const item = technique('simulated') item.creation_statement = 'AttackTechniqueFactory.with_simulated_conversation(\n name="simulated",\n num_turns=2,\n)' techniques.listTechniques.mockResolvedValue(techniquePage([item])) const user = userEvent.setup() render(tree()) - await user.click(await screen.findByRole('button', { name: 'Details for simulated' })) - const dialog = within(screen.getByRole('dialog')) - expect(dialog.getByLabelText('Technique creation call').textContent).toBe(item.creation_statement) - expect(dialog.queryByText(/eval_hash|value_sha256|seed_technique|Configuration/)).not.toBeInTheDocument() + await user.click(await screen.findByRole('button', { name: 'Identity for simulated' })) + expect(JSON.parse(screen.getByRole('region', { name: 'Identity for simulated' }).textContent ?? '')) + .toEqual(item.identifier) + expect(screen.queryByText(item.creation_statement)).not.toBeInTheDocument() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) it('shows empty and no-match states', async () => { @@ -152,9 +153,9 @@ describe('TechniqueRegistry', () => { await user.selectOptions(screen.getByRole('combobox', { name: 'Filter by tag' }), 'later_tag') expect(screen.queryByText('first description')).not.toBeInTheDocument() await user.type(screen.getByRole('textbox', { name: 'Search techniques' }), 'later') - await user.click(screen.getByRole('button', { name: 'Details for later' })) - expect(within(screen.getByRole('dialog')).getByLabelText('Technique creation call').textContent) - .toBe(technique('later', 'RedTeamingAttack', ['later_tag']).creation_statement) + await user.click(screen.getByRole('button', { name: 'Identity for later' })) + expect(JSON.parse(screen.getByRole('region', { name: 'Identity for later' }).textContent ?? '')) + .toEqual(technique('later', 'RedTeamingAttack', ['later_tag']).identifier) }) it('reports a later page failure instead of showing an incomplete catalog', async () => { diff --git a/frontend/src/components/Registry/TechniqueRegistry.tsx b/frontend/src/components/Registry/TechniqueRegistry.tsx index aa8e82ff89..0c36e61a7b 100644 --- a/frontend/src/components/Registry/TechniqueRegistry.tsx +++ b/frontend/src/components/Registry/TechniqueRegistry.tsx @@ -1,9 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { - Badge, Button, Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, - DialogTitle, DialogTrigger, Field, Input, MessageBar, MessageBarBody, Select, Spinner, - Table, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow, Text, + Badge, Button, Field, Input, MessageBar, MessageBarBody, Select, Spinner, + Table, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow, Text, useRestoreFocusTarget, } from '@fluentui/react-components' import { AddRegular, ArrowSyncRegular } from '@fluentui/react-icons' @@ -14,6 +13,7 @@ import type { TechniqueInstance } from '@/types' import { fetchAllPages } from '@/utils/fetchAllPages' import CreateTechniqueDialog from './CreateTechniqueDialog' +import RegistryIdentityRow from './RegistryIdentityRow' import { useTechniqueRegistryStyles } from './TechniqueRegistry.styles' interface TechniqueRegistryPageProps { @@ -23,6 +23,7 @@ interface TechniqueRegistryPageProps { function TechniqueRegistryPage({ ready, runtimeKey }: TechniqueRegistryPageProps) { const styles = useTechniqueRegistryStyles() + const restoreFocusTarget = useRestoreFocusTarget() const [items, setItems] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -30,7 +31,7 @@ function TechniqueRegistryPage({ ready, runtimeKey }: TechniqueRegistryPageProps const [typeFilter, setTypeFilter] = useState('') const [tagFilter, setTagFilter] = useState('') const [createOpen, setCreateOpen] = useState(false) - const [detail, setDetail] = useState(null) + const [expanded, setExpanded] = useState>(new Set()) const [focusRestore, setFocusRestore] = useState(0) const mounted = useRef(true) const listEpoch = useRef(0) @@ -66,7 +67,7 @@ function TechniqueRegistryPage({ ready, runtimeKey }: TechniqueRegistryPageProps setTypeFilter('') setTagFilter('') setCreateOpen(false) - setDetail(null) + setExpanded(new Set()) setFocusRestore((current) => current + 1) } void Promise.resolve().then(() => { if (mounted.current) void load() }) @@ -87,6 +88,15 @@ function TechniqueRegistryPage({ ready, runtimeKey }: TechniqueRegistryPageProps setFocusRestore((current) => current + 1) } + const toggleDetails = (name: string): void => { + setExpanded((current: Set) => { + const next = new Set(current) + if (next.has(name)) next.delete(name) + else next.add(name) + return next + }) + } + const visible = items.filter((item) => ( `${item.name} ${item.description ?? ''} ${item.attack_type} ${item.tags.join(' ')}`.toLowerCase().includes(search.toLowerCase()) && (!typeFilter || item.attack_type === typeFilter) @@ -105,7 +115,7 @@ function TechniqueRegistryPage({ ready, runtimeKey }: TechniqueRegistryPageProps
-
@@ -129,32 +139,15 @@ function TechniqueRegistryPage({ ready, runtimeKey }: TechniqueRegistryPageProps NameDescription - Attack typeTagsDetails + Attack typeTags {visible.map((item) => ( - - {item.name}{item.description ?? 'No description'} + toggleDetails(item.name)}> + {item.description ?? 'No description'} {item.attack_type}
{item.tags.map((tag) => {tag})}
- - { - if (!data.open) setDetail(null) - }}> - - - - - - {item.name} - - {detail &&
{detail.creation_statement}
} -
- -
-
-
-
-
+ ))}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 8bf10d8ad4..77380d4b4f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -542,6 +542,7 @@ export interface TechniqueInstance { tags: string[] uses_adversarial: boolean uses_default_adversarial_target: boolean + identifier: ComponentIdentifier /** Supplied factory creation inputs, with live component credentials omitted. */ creation_statement: string } diff --git a/pyrit/backend/mappers/technique_mappers.py b/pyrit/backend/mappers/technique_mappers.py index 2b1e922da6..6fa6f4b9ce 100644 --- a/pyrit/backend/mappers/technique_mappers.py +++ b/pyrit/backend/mappers/technique_mappers.py @@ -98,6 +98,7 @@ def technique_to_instance(*, name: str, factory: AttackTechniqueFactory) -> Tech tags=factory.technique_tags, uses_adversarial=factory.uses_adversarial, uses_default_adversarial_target=factory.uses_default_adversarial_target, + identifier=factory.get_identifier(), creation_statement=_format_creation_calls(factory), ) diff --git a/pyrit/backend/models/techniques.py b/pyrit/backend/models/techniques.py index 2e9d4d4e04..c37c88aeeb 100644 --- a/pyrit/backend/models/techniques.py +++ b/pyrit/backend/models/techniques.py @@ -8,7 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator from pyrit.backend.models.common import IdentifierStr, PaginationInfo, TextStr -from pyrit.models import Parameter +from pyrit.models import ComponentIdentifier, Parameter from pyrit.models.request_limits import MAX_ITEMS from pyrit.models.technique_edit import TechniqueOverride, TechniqueSettings @@ -24,6 +24,7 @@ class TechniqueInstance(BaseModel): tags: list[str] = Field(default_factory=list) uses_adversarial: bool uses_default_adversarial_target: bool + identifier: ComponentIdentifier creation_statement: str diff --git a/tests/unit/backend/test_technique_service.py b/tests/unit/backend/test_technique_service.py index 8c2aa18a78..f77281557b 100644 --- a/tests/unit/backend/test_technique_service.py +++ b/tests/unit/backend/test_technique_service.py @@ -99,6 +99,7 @@ async def test_catalog_real_factories_and_alias_metadata_async(registry: AttackT "tags", "uses_adversarial", "uses_default_adversarial_target", + "identifier", "creation_statement", } assert await service.get_async("missing") is None @@ -247,11 +248,14 @@ def test_factory_creation_statement_uses_supplied_inputs_without_creating_attack identifier = factory.get_identifier() with ( patch.object(factory, "create", side_effect=AssertionError("Listing must not construct an attack")), - patch.object(factory, "get_identifier", side_effect=AssertionError("Listing does not need identity")), + patch.object(factory, "get_identifier", wraps=factory.get_identifier) as get_identifier, ): item = technique_to_instance(name=name, factory=factory) assert factory.get_identifier() == identifier + get_identifier.assert_called_once() + assert item.identifier == identifier + assert item.model_dump(mode="json")["identifier"] == identifier.model_dump(mode="json") assert item.creation_statement.startswith(factory.get_creation_calls()[0][0] + "(") call = ast.parse(item.creation_statement, mode="eval").body assert isinstance(call, ast.Call) @@ -318,6 +322,9 @@ def test_rest_create_detail_types_and_errors( ) assert created.status_code == 201, created.text assert "'max_attempts_on_failure': 0" in created.json()["creation_statement"] + factory = registry.instances.get("rest_example") + assert factory is not None + assert created.json()["identifier"] == factory.get_identifier().model_dump(mode="json") assert client.get("/api/techniques/rest_example").json() == created.json() assert client.get("/api/techniques/missing").status_code == 404 assert ( From 9fc4c51b4c5a34c832092a7c9d1882376e6264d0 Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Thu, 29 Oct 2026 09:32:33 -0700 Subject: [PATCH 08/65] FIX: Align registry browser checks and identity docs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- frontend/e2e/registry.spec.ts | 52 ++++++++++++++++------------------- pyrit/backend/README.md | 7 +++-- 2 files changed, 29 insertions(+), 30 deletions(-) diff --git a/frontend/e2e/registry.spec.ts b/frontend/e2e/registry.spec.ts index bb475db9ac..42798e00b1 100644 --- a/frontend/e2e/registry.spec.ts +++ b/frontend/e2e/registry.spec.ts @@ -49,22 +49,18 @@ test("creates and selects a runtime technique with the real backend @seeded", as await expect(page.getByText("Named configurations of existing attack techniques", { exact: true })).toBeVisible(); await expect(page.getByText(/Runtime only|No attack runs when you add a technique/)).toHaveCount(0); await expect(page.getByRole("table", { name: "Registered techniques" })).toBeVisible(); - const builtinCalls = { - crescendo_simulated: "AttackTechniqueFactory.with_simulated_conversation(\n" + - " name='crescendo_simulated',\n" + - " description='Escalates gradually over a simulated conversation toward the objective.',\n" + - " technique_tags=['single_turn'],\n)", - tap: "AttackTechniqueFactory(\n" + - " name='tap',\n" + - " attack_class=TreeOfAttacksWithPruningAttack,\n" + - " description='Explores a tree of adversarial prompts, pruning weak branches to refine the attack.',\n" + - " technique_tags=['multi_turn'],\n)", - }; - for (const [builtinName, statement] of Object.entries(builtinCalls)) { - await page.getByRole("button", { name: `Details for ${builtinName}` }).click(); - await expect(page.getByLabel("Technique creation call", { exact: true })).toHaveText(statement); - await expect(page.getByRole("heading", { name: "Configuration" })).toHaveCount(0); - await page.getByRole("button", { name: "Close", exact: true }).click(); + for (const builtinName of ["crescendo_simulated", "tap"]) { + const response = await request.get(`/api/techniques/${builtinName}`, { headers }); + expect(response.ok(), await response.text()).toBe(true); + const builtin = await response.json(); + const toggle = page.getByRole("button", { name: `Identity for ${builtinName}`, exact: true }); + await toggle.click(); + const identity = page.getByRole("region", { name: `Identity for ${builtinName}`, exact: true }); + await expect(identity).toBeVisible(); + expect(JSON.parse(await identity.innerText())).toEqual(builtin.identifier); + await expect(page.getByRole("dialog")).toHaveCount(0); + await toggle.press("Enter"); + await expect(identity).toHaveCount(0); } await page.getByRole("button", { name: "New technique" }).click(); await page.getByRole("textbox", { name: "Registry name" }).fill(name); @@ -92,17 +88,17 @@ test("creates and selects a runtime technique with the real backend @seeded", as expect(body).not.toHaveProperty("seed_technique"); await expect(page.getByRole("dialog")).not.toBeVisible(); await page.getByRole("textbox", { name: "Search techniques" }).fill(name); - await expect(page.getByRole("cell", { name, exact: true })).toBeVisible(); - await page.getByRole("button", { name: `Details for ${name}` }).click(); - const creationCall = page.getByLabel("Technique creation call", { exact: true }); - await expect(creationCall).toHaveText(createdTechnique.creation_statement); - await expect(creationCall).toContainText("AttackTechniqueFactory("); - await expect(creationCall).toContainText("'max_attempts_on_failure': 0"); - expect(createdTechnique).not.toHaveProperty("evaluation_identifier"); - expect(createdTechnique).not.toHaveProperty("configuration"); - await expect(page.getByRole("heading", { name: "Configuration" })).toHaveCount(0); - await page.getByRole("button", { name: "Close", exact: true }).click(); - await expect(page.getByRole("button", { name: `Details for ${name}` })).toBeFocused(); + const identityToggle = page.getByRole("button", { name: `Identity for ${name}`, exact: true }); + await expect(identityToggle).toBeVisible(); + await identityToggle.click(); + const identity = page.getByRole("region", { name: `Identity for ${name}`, exact: true }); + await expect(identity).toBeVisible(); + expect(JSON.parse(await identity.innerText())).toEqual(createdTechnique.identifier); + expect(createdTechnique.identifier.kwargs).toMatchObject({ max_attempts_on_failure: 0 }); + await expect(page.getByRole("dialog")).toHaveCount(0); + await identityToggle.press("Space"); + await expect(identity).toHaveCount(0); + await expect(identityToggle).toBeFocused(); expect(detailRequests).toEqual([]); let finishCreate: (() => void) | undefined; @@ -132,7 +128,7 @@ test("creates and selects a runtime technique with the real backend @seeded", as const staleResponse = page.waitForResponse((response) => response.url().endsWith("/api/techniques") && response.request().method() === "POST"); finishCreate?.(); await staleResponse; - await expect(page.getByRole("cell", { name: "old_runtime_response", exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Identity for old_runtime_response", exact: true })).toHaveCount(0); const current = await request.get("/api/scenarios/catalog/airt.rapid_response", { headers }); expect(current.ok(), await current.text()).toBe(true); diff --git a/pyrit/backend/README.md b/pyrit/backend/README.md index 85bb9c9765..f4a0f5a50c 100644 --- a/pyrit/backend/README.md +++ b/pyrit/backend/README.md @@ -161,10 +161,13 @@ enums when registered factories change; it does not create attacks. Selections from an earlier enum remain valid in the same scenario and registry generation if their factories are unchanged. Earlier aggregate selections retain their original members; they do not gain newly registered techniques. -Responses include `creation_statement`, a Python-style display of the factory's +Responses include `identifier`, the canonical factory identity shown inline in +the Registry. It can contain raw prompt and seed values, including nested +technique seeds. This JSON is not a redacted or hash-only view. +Responses also include `creation_statement`, a Python-style display of the factory's supplied arguments. Helper constructors retain their original call form. Live components without captured inputs use `...`; this display is not executable -or lossless source. Responses do not include identifier or expanded seed JSON. +or lossless source. The GUI displays identity JSON, not the creation statement. ### Manual Messages From 65fe249c1e6f8c854d038d93d7e0ef8384ab2261 Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Thu, 29 Oct 2026 01:13:49 -0700 Subject: [PATCH 09/65] FEAT: Reuse last-used chat and scan selections Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- doc/gui/0_gui.md | 13 ++++- frontend/src/App.drafts.test.tsx | 4 +- frontend/src/App.labels.test.tsx | 6 +- frontend/src/App.targets.test.tsx | 28 ++++++---- frontend/src/App.test.tsx | 23 ++++---- frontend/src/App.tsx | 45 +++++++-------- .../src/components/Chat/ChatScorerPicker.tsx | 12 ++-- .../src/components/Chat/ChatWindow.test.tsx | 12 ++++ frontend/src/components/Chat/ChatWindow.tsx | 22 ++++++-- .../components/Config/TargetConfig.test.tsx | 30 +++++----- .../src/components/Config/TargetConfig.tsx | 8 +-- .../components/Config/TargetTable.test.tsx | 55 ++++++++----------- .../src/components/Config/TargetTable.tsx | 22 +------- .../Registry/CreateConverterDialog.test.tsx | 2 +- .../Registry/ScorerRegistry.test.tsx | 3 + .../components/Registry/ScorerRegistry.tsx | 3 +- .../ScenarioSuites/LaunchSuiteDialog.test.tsx | 9 ++- .../ScenarioSuites/LaunchSuiteDialog.tsx | 10 ++++ .../Scenarios/ScenarioDetail.test.tsx | 20 +++++-- .../components/Scenarios/ScenarioDetail.tsx | 12 +++- .../Scenarios/ScenarioFlow.test.tsx | 9 +-- .../src/hooks/useTargetPreferences.test.tsx | 44 ++++++++++----- frontend/src/hooks/useTargetPreferences.ts | 13 ++++- .../src/hooks/useUserPreferences.test.tsx | 34 ++++++++++-- frontend/src/hooks/useUserPreferences.tsx | 15 ++++- frontend/src/types/index.ts | 4 +- frontend/src/utils/targetIdentity.ts | 2 +- frontend/src/utils/userPreferences.ts | 21 +++++-- 28 files changed, 294 insertions(+), 187 deletions(-) diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index ec069a9905..d8251e4933 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -23,12 +23,12 @@ For other setup options, see [Docker](../getting_started/install_docker.md) or t ## Home -Home shows your default objective target and recent attacks grouped by operation. +Home shows your last-used objective target and recent attacks grouped by operation. Open an attack to return to its conversation, or go to Registry to configure a target. The shared labels bar sets the operator, operation, and custom labels for new work; it does not change saved results. -![Home page with default target and recent work](images/home.png) +![Home page with target and recent work](images/home.png) ## Chat @@ -37,6 +37,11 @@ workspace. Select a target and write the messages yourself. You can attach media and branch or edit conversations to try another approach. Add an objective and select a chat scorer if you want automated assessment of responses. +New chats and scans start with the last objective target you used. New chats also +reuse the last chat scorer, including **Off**, unless a Registry default is set. +These choices are saved in the current browser profile after use. Opening saved +activity or editing an unsent draft does not change them. + The **Chat** tab returns to your retained draft, or your last chat if there is no draft. Selecting **Chat** while a chat is open keeps that chat open. Use **New attack** to start a blank draft. The **History** tab @@ -81,7 +86,9 @@ Registry prepares reusable components; it does not execute attacks. Create targets to connect to model endpoints, converters to transform content, and named techniques that configure existing attacks. Browse configured scorers and select a default chat scorer; scorer setup uses initializers, not an editor -on this page. Target defaults apply to new chats and scans. +on this page. An adversarial-target default applies to new scans; without one, +scans reuse the last adversarial target. Objective targets do not have a Registry +default. Existing chats and runs keep their settings. Expand a scorer, technique, or converter name to inspect its identity JSON. Converter inputs and outputs stay visible in the table. Use the small red X diff --git a/frontend/src/App.drafts.test.tsx b/frontend/src/App.drafts.test.tsx index ca6dbe0702..a836a6216c 100644 --- a/frontend/src/App.drafts.test.tsx +++ b/frontend/src/App.drafts.test.tsx @@ -593,7 +593,7 @@ describe('App draft handoff with the real chat and composer', () => { it('should preserve an explicit empty target instead of reapplying the default', async () => { writeUserPreferences('local', { ...DEFAULT_USER_PREFERENCES, - targets: { objective: targetReference(TARGET), adversarial: null }, + lastUsedTargets: { objective: targetReference(TARGET), adversarial: null }, }) const user = userEvent.setup() renderChat() @@ -723,7 +723,7 @@ describe('App draft handoff with the real chat and composer', () => { it('should keep cleared text empty while retaining the selected target', async () => { writeUserPreferences('local', { ...DEFAULT_USER_PREFERENCES, - targets: { objective: targetReference(TARGET), adversarial: null }, + lastUsedTargets: { objective: targetReference(TARGET), adversarial: null }, }) const user = userEvent.setup() renderChat() diff --git a/frontend/src/App.labels.test.tsx b/frontend/src/App.labels.test.tsx index 8060d85b97..44d9f37d1c 100644 --- a/frontend/src/App.labels.test.tsx +++ b/frontend/src/App.labels.test.tsx @@ -317,7 +317,7 @@ describe('Shared new run labels', () => { .not.toBeInTheDocument() expect(within(actions).getByRole('button', { name: 'Export conversation' })).toBeDisabled() expect(within(actions).getByRole('button', { name: 'Toggle conversations panel' })).toBeDisabled() - expect(within(actions).getByRole('button', { name: 'New Attack' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'New attack', exact: true })).toBeEnabled() const markdown = within(actions).getByRole('switch') await user.click(markdown) expect(readUserPreferences('local').chatMarkdown).toBe(true) @@ -404,8 +404,8 @@ describe('Shared new run labels', () => { expect(panelToggle).toHaveAttribute('aria-expanded', 'true') await user.click(panelToggle) expect(panelToggle).toHaveAttribute('aria-expanded', 'false') - await user.click(within(toolbar).getByRole('button', { name: 'New Attack' })) - expect(await screen.findByRole('button', { name: 'New Attack' })).toBeDisabled() + await user.click(screen.getByRole('button', { name: 'New attack', exact: true })) + expect(await screen.findByRole('button', { name: 'New attack', exact: true })).toBeEnabled() expect(screen.queryByTestId('operator-locked-banner')).not.toBeInTheDocument() expect(within(currentLabels()).getByRole('combobox', { name: 'Operation' })).toHaveValue('future_op') }) diff --git a/frontend/src/App.targets.test.tsx b/frontend/src/App.targets.test.tsx index 8d067a54cf..4fcb1bfbdb 100644 --- a/frontend/src/App.targets.test.tsx +++ b/frontend/src/App.targets.test.tsx @@ -28,11 +28,12 @@ jest.mock('@/components/Layout/MainLayout', () => { const { Link } = jest.requireActual('react-router') return { __esModule: true, - default: ({ children }: { children: ReactNode }) => ( + default: ({ children, featureNavigation }: { children: ReactNode; featureNavigation?: ReactNode }) => ( <> Open new chat Open saved chat Open registry + {featureNavigation} {children} ), @@ -106,7 +107,7 @@ function TestWrapper({ children }: { children: ReactNode }) { function saveDefault(account: string, target: typeof targetA): void { writeUserPreferences(`tenant:${account}`, { ...DEFAULT_USER_PREFERENCES, - targets: { + lastUsedTargets: { objective: { registryName: target.target_registry_name, identifierHash: target.identifier.hash }, adversarial: null, }, @@ -135,13 +136,14 @@ describe('App target selection with the chat composer', () => { }) }) - it('appends to saved target B in the same conversation without replacing default A', async () => { + it('keeps saved target B and remembers it only after sending, not when viewing history', async () => { const user = userEvent.setup() saveDefault('alice', targetA) render(, { wrapper: TestWrapper }) await waitFor(() => expect(screen.getByRole('combobox', { name: 'Chat target' })).toHaveValue('target-a')) await user.click(screen.getByRole('link', { name: 'Open saved chat' })) expect(await screen.findByText('Saved reply from target B')).toBeInTheDocument() + expect(readUserPreferences('tenant:alice').lastUsedTargets?.objective?.registryName).toBe('target-a') const prompt = screen.getByPlaceholderText('Type prompt here') await waitFor(() => expect(prompt).toBeEnabled()) await user.type(prompt, 'Continue this chat') @@ -156,9 +158,9 @@ describe('App target selection with the chat composer', () => { )) expect(attacksApi.createAttack).not.toHaveBeenCalled() expect(screen.queryByRole('combobox', { name: 'Chat target' })).not.toBeInTheDocument() - expect(readUserPreferences('tenant:alice').targets.objective?.registryName).toBe('target-a') + await waitFor(() => expect(readUserPreferences('tenant:alice').lastUsedTargets?.objective?.registryName).toBe('target-b')) await user.click(screen.getByRole('link', { name: 'Open new chat' })) - await waitFor(() => expect(screen.getByRole('combobox', { name: 'Chat target' })).toHaveValue('target-a')) + await waitFor(() => expect(screen.getByRole('combobox', { name: 'Chat target' })).toHaveValue('target-b')) }) it('selects a new-chat target from the badge without changing the default', async () => { @@ -170,7 +172,7 @@ describe('App target selection with the chat composer', () => { expect(selector).toHaveValue('target-b') expect(screen.getByTestId('target-badge')).toHaveAccessibleName('Active target: target-b') expect(screen.queryByRole('button', { name: 'Refresh targets' })).not.toBeInTheDocument() - expect(readUserPreferences('tenant:alice').targets.objective).toBeNull() + expect(readUserPreferences('tenant:alice').lastUsedTargets).toBeUndefined() }) it('clears the draft and loads the next account defaults when the signed-in account changes', async () => { @@ -186,7 +188,7 @@ describe('App target selection with the chat composer', () => { rerender() await waitFor(() => expect(screen.getByRole('combobox', { name: 'Chat target' })).toHaveValue('target-b')) expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('') - expect(readUserPreferences('tenant:alice').targets.objective?.registryName).toBe('target-a') + expect(readUserPreferences('tenant:alice').lastUsedTargets?.objective?.registryName).toBe('target-a') }) it('keeps the draft and sends to its selected target after navigating to Chat again', async () => { @@ -211,7 +213,7 @@ describe('App target selection with the chat composer', () => { )) }) - it('applies a cross-tab default change only to the next draft', async () => { + it('keeps the unsent draft and applies cross-tab last use only to a new attack', async () => { const user = userEvent.setup() saveDefault('alice', targetA) render(, { wrapper: TestWrapper }) @@ -227,6 +229,9 @@ describe('App target selection with the chat composer', () => { expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('Prompt for A') await user.click(screen.getByRole('link', { name: 'Open registry' })) await user.click(screen.getByRole('link', { name: 'Open new chat' })) + await waitFor(() => expect(screen.getByRole('combobox', { name: 'Chat target' })).toHaveValue('target-a')) + expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('Prompt for A') + await user.click(screen.getByRole('button', { name: /^New attack$/i })) await waitFor(() => expect(screen.getByRole('combobox', { name: 'Chat target' })).toHaveValue('target-b')) expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('') }) @@ -238,19 +243,20 @@ describe('App target selection with the chat composer', () => { await user.click(screen.getByRole('link', { name: 'Open saved chat' })) await screen.findByText('Saved reply from target B') await user.type(screen.getByPlaceholderText('Type prompt here'), 'Do not carry this into A') - await user.click(screen.getByRole('button', { name: 'New Attack' })) + await user.click(screen.getByRole('button', { name: /^New attack$/i })) await waitFor(() => expect(screen.getByRole('combobox', { name: 'Chat target' })).toHaveValue('target-a')) expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('') }) it('recovers the app registry after the registry page completes a successful full load', async () => { const user = userEvent.setup() + saveDefault('alice', targetA) jest.mocked(targetsApi.listTargets).mockRejectedValueOnce(new Error('Initial registry failed')) render(, { wrapper: TestWrapper }) await screen.findByText('Targets unavailable', { selector: 'span' }) await user.click(screen.getByRole('link', { name: 'Open registry' })) - const objective = await screen.findByRole('combobox', { name: 'Default objective target' }) - await user.selectOptions(objective, 'target-a') + await screen.findByRole('combobox', { name: 'Default adversarial target' }) + expect(screen.queryByRole('combobox', { name: 'Default objective target' })).not.toBeInTheDocument() await user.click(screen.getByRole('link', { name: 'Open new chat' })) await waitFor(() => expect(screen.getByRole('combobox', { name: 'Chat target' })).toHaveValue('target-a')) expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled() diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 43b0f21b51..ce9726c8ad 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -26,6 +26,7 @@ jest.mock("./hooks/useTargetRegistry", () => ({ loading: false, error: null, refresh: jest.fn(), + synchronizeTargets: setTargets, rememberTarget: (target: import("./types").TargetInstance) => { setTargets((current) => [...current, target]); }, @@ -333,26 +334,28 @@ jest.mock("./components/Chat/ChatWindow", () => { jest.mock("./components/Config/TargetConfig", () => { const { makeTarget } = jest.requireActual("@/test-utils/targetFixtures") as typeof import("@/test-utils/targetFixtures"); + const { useUserPreferences } = jest.requireActual("@/hooks/useUserPreferences") as typeof import("@/hooks/useUserPreferences"); const MockTargetConfig = ({ - defaultObjectiveTarget, - onSetDefaultObjectiveTarget, + onTargetsLoaded, }: { - defaultObjectiveTarget: unknown; - onSetDefaultObjectiveTarget: (t: unknown) => void; + onTargetsLoaded?: (targets: import("@/types").TargetInstance[]) => void; }) => { + const { preferences, rememberUse } = useUserPreferences(); return (
- {(defaultObjectiveTarget as { target_registry_name?: string })?.target_registry_name ?? "none"} + {preferences.lastUsedTargets?.objective?.registryName ?? "none"} - - - ) : null - )) + {!registry.loading && !registry.error && targetDefaults.preferences.adversarial + && !targetDefaults.adversarialTarget && ( + + + The saved default adversarial target is unavailable or has changed. Select a new default in the registry. + + + )} setDefaultTarget('objective', target)} onSetDefaultAdversarialTarget={(target: TargetInstance | null) => setDefaultTarget('adversarial', target)} onTargetsLoaded={registry.synchronizeTargets} /> @@ -930,7 +923,7 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { void + onUseLastUsed?: () => void label?: string disabled?: boolean loading?: boolean @@ -22,24 +23,27 @@ function scorerOptionLabel(scorer: ScorerInstance): string { } export default function ChatScorerPicker({ - value, scorers, onChange, label = 'Chat scorer', disabled, loading, error, inherited = false, usage = 'chat', + value, scorers, onChange, onUseLastUsed, label = 'Chat scorer', disabled, loading, error, inherited = false, usage = 'chat', }: ChatScorerPickerProps) { const styles = useChatScorerPickerStyles() - const selectedScorer = findRegisteredScorer(value, scorers, inherited) + const selectedScorer = findRegisteredScorer(value ?? null, scorers, inherited) const available = !value || selectedScorer !== undefined const eligibleScorers = scorers.filter((scorer: ScorerInstance) => scorer.identifier.scorer_type === 'true_false' && (usage === 'scenario' || !scorer.chat_scoring_disabled_reason)) - const selected = selectedScorer && eligibleScorers.includes(selectedScorer) + const selected = value === undefined && onUseLastUsed ? 'last-used' + : selectedScorer && eligibleScorers.includes(selectedScorer) ? `registered:${selectedScorer.scorer_registry_name}` : value ? 'unavailable' : '' return ( { - setBaselineId(data.value) - setComparison(null) - }} - > - - {baselines.map((group) => ( - - ))} - - - - - - -
- {validSelection && comparison && comparison.baselineId === baselineId && comparison.attackId === attackId && ( - - )} - - )} - - ) -} - -/** A new selection or refresh mounts a new request, clearing previous results immediately. */ -function Comparison({ scenarioResultId, baselineId, attackId }: ComparisonProps) { - const styles = usePairedOutcomesStyles() - const [state, setState] = useState< - { status: 'loading' } - | { status: 'error'; error: string } - | { status: 'ready'; report: PairedOutcomeStatistics } - >({ status: 'loading' }) - - useEffect(() => { - const controller = new AbortController() - void scenariosApi.getPairedOutcomes(scenarioResultId, { - baseline_group_id: baselineId, - attack_group_id: attackId, - }, controller.signal).then((report) => { - if (!controller.signal.aborted) setState({ status: 'ready', report }) - }).catch((error: unknown) => { - if (!controller.signal.aborted) setState({ status: 'error', error: toApiError(error).detail }) - }) - return () => controller.abort() - }, [scenarioResultId, baselineId, attackId]) - - if (state.status === 'loading') return - if (state.status === 'error') { - return {state.error} - } - - const report = state.report - const difference = report.paired_difference === null - ? 'Unavailable' - : `${report.paired_difference > 0 ? '+' : ''}${(report.paired_difference * 100).toFixed(1)} pp` - const transitions = [ - ['Failure → Success', report.failure_to_success], - ['Success → Failure', report.success_to_failure], - ['Failure → Failure', report.failure_to_failure], - ['Success → Success', report.success_to_success], - ] as const - const exclusions = [ - ['Missing baseline', report.missing_baseline], - ['Missing attack', report.missing_attack], - ['Both missing', report.missing_both], - ['Baseline errors', report.baseline_errors], - ['Attack errors', report.attack_errors], - ['Baseline undetermined', report.baseline_undetermined], - ['Attack undetermined', report.attack_undetermined], - ['Unmatched baseline seeds', report.unmatched_baseline_seeds], - ['Unmatched attack seeds', report.unmatched_attack_seeds], - ['Unattributed attempts', report.unattributed_attempts], - ] as const - - return ( -
-
- - - - - -
- Rates use the same decided pairs. Difference = attack − baseline, in percentage points (pp). - - - - Baseline → Attack - Pairs - - - - {transitions.map(([label, count]) => ( - - {label} - {count.toLocaleString()} - - ))} - -
- Exclusion details -
- {exclusions.map(([label, count]) => )} -
- - Planned pairs share a logical seed ID. Unmatched seeds are separate. - Reasons can overlap; do not sum them to reconstruct exclusions. - - - Only the latest top-level outcome counts; child attempts and older retries do not add pairs. - Missing scoring metadata limits compatibility checks. These observed differences do not establish causation. - -
- ) -} - -function rate(value: number | null): string { - return value === null ? 'Unavailable' : `${(value * 100).toFixed(1)}%` -} - -function Metric({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ) -} diff --git a/frontend/src/components/Scenarios/ScenarioDetail.tsx b/frontend/src/components/Scenarios/ScenarioDetail.tsx index dc0e0efec1..d9d52165c5 100644 --- a/frontend/src/components/Scenarios/ScenarioDetail.tsx +++ b/frontend/src/components/Scenarios/ScenarioDetail.tsx @@ -1600,7 +1600,7 @@ function ScenarioLaunchForm({ {expanded &&
{technique.identifier ?
{JSON.stringify(technique.identifier, null, 2)}
- : Identity was not recorded for this technique.} + : {technique.isBaseline ? 'Baseline identity is available after launch.' : 'Identity was not recorded for this technique.'}}
}
) diff --git a/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx b/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx index 8c9a238aa5..825a1174c0 100644 --- a/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx +++ b/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx @@ -47,7 +47,10 @@ const COUNTS = { completed: 2, planned: 2, succeeded: 1, success_percentage: 50, const SUMMARY: ScenarioProgressSummary = { overall: COUNTS, objective_scorer: SCORER, techniques: [{ ...COUNTS, id: 'direct', display_group: 'Direct', atomic_attack_names: ['PromptSendingAttack'], atomic_group_ids: ['group'], tags: [] }], - seed_groups: PLAN.seed_groups.map((seed) => ({ ...COUNTS, id: seed.id, objective: seed.objective, completed: 1, planned: 1 })), + seed_groups: PLAN.seed_groups.map((seed, index) => ({ + ...COUNTS, id: seed.id, objective: seed.objective, completed: 1, planned: 1, + succeeded: index === 0 ? 1 : 0, success_percentage: index === 0 ? 100 : 0, + })), atomic_groups: [{ ...COUNTS, id: 'group', atomic_attack_name: 'PromptSendingAttack', display_group: 'Direct', status: 'RUNNING', technique_details: TECHNIQUE }], } const RUN = { @@ -181,6 +184,64 @@ describe('ScenarioRunPage', () => { expect(dialog.getAllByRole('row', { name: 'View details for PromptSendingAttack' })).toHaveLength(1) await user.click(dialog.getByRole('row', { name: 'View details for PromptSendingAttack' })) expect(await screen.findByRole('button', { name: 'Close attack details' })).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Close attack details' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'First objective' })).toHaveFocus()) + }) + + it('shows per-group and per-objective success rates without restoring paired outcomes', () => { + render() + expect(screen.getByText('Attack success 50%', { exact: true })).toBeInTheDocument() + const objectives = within(screen.getByRole('table', { name: 'Datasets and objectives' })) + expect(objectives.getByRole('row', { name: /First objective/ })).toHaveTextContent('100%') + expect(objectives.getByRole('row', { name: /Second objective/ })).toHaveTextContent('0%') + expect(screen.queryByRole('heading', { name: 'Paired outcomes' })).not.toBeInTheDocument() + }) + + it('keeps elapsed-clock updates local instead of rebuilding result groups every second', async () => { + jest.useFakeTimers() + jest.setSystemTime(new Date('2026-01-01T00:00:02Z')) + try { + render() + expect(screen.getByText('1s elapsed')).toBeInTheDocument() + const renders = jest.mocked(useScenarioRunProgress).mock.calls.length + await act(async () => { jest.advanceTimersByTime(3_000) }) + expect(screen.getByText('4s elapsed')).toBeInTheDocument() + expect(jest.mocked(useScenarioRunProgress).mock.calls.length).toBe(renders) + } finally { + jest.useRealTimers() + } + }) + + it('announces a terminal transition through the existing live region', () => { + const view = render() + const announcement = within(screen.getByTestId('scenario-run-page')).getByRole('status') + expect(announcement).toBeEmptyDOMElement() + state = makeState({ run: { ...RUN, status: 'COMPLETED', completed_at: '2026-01-01T00:00:10Z' } }) + view.rerender() + expect(announcement).toHaveTextContent('test.scenario: completed.') + }) + + it('shows only conditional operational facts with compact optional overload details', async () => { + const user = userEvent.setup() + state = makeState({ + plan: null, planComplete: false, + summary: { ...SUMMARY, unattributed_attempts: 3 }, + overloadSummaries: [{ + component_role: 'objective_target', count: 2, rate_limit_count: 2, server_error_count: 0, + status_codes: [429], latest_timestamp: '2026-01-01T00:00:10Z', + }], + }) + const view = render() + const notice = within(screen.getByLabelText('Scan notices')) + expect(notice.getByText(/Service overload detected/)).toBeInTheDocument() + expect(notice.getByText(/3 executions could not be matched/)).toBeInTheDocument() + expect(notice.getByText(/saved execution plan is incomplete/)).toBeInTheDocument() + expect(notice.queryByText(/HTTP 429/)).not.toBeInTheDocument() + await user.click(notice.getByRole('button', { name: 'Overload details' })) + expect(notice.getByText('objective target: 2 (HTTP 429)')).toBeInTheDocument() + state = makeState() + view.rerender() + expect(screen.queryByLabelText('Scan notices')).not.toBeInTheDocument() }) it('shows the stored scenario identity without a field grid', async () => { diff --git a/frontend/src/components/Scenarios/ScenarioRunPage.tsx b/frontend/src/components/Scenarios/ScenarioRunPage.tsx index 94e2b5b2f5..3c976aa111 100644 --- a/frontend/src/components/Scenarios/ScenarioRunPage.tsx +++ b/frontend/src/components/Scenarios/ScenarioRunPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import { Accordion, AccordionHeader, AccordionItem, AccordionPanel, Badge, Button, @@ -10,7 +10,7 @@ import { ArrowLeftRegular, ArrowClockwiseRegular, ChevronDownRegular, ChevronRig import { Link, useLocation, useNavigate, useParams } from 'react-router' import AttackAttemptDetails from '@/components/AttackResults/AttackAttemptDetails' -import { formatDuration, objectivePreview } from '@/components/AttackResults/attackAttemptFormatting' +import { formatProgressSuccess, objectivePreview } from '@/components/AttackResults/attackAttemptFormatting' import RegistryIdentityRow from '@/components/Registry/RegistryIdentityRow' import { suiteExecutionRoutePath } from '@/components/ScenarioSuites/suiteRoutes' import { useScenarioRunProgress } from '@/hooks/useScenarioRunProgress' @@ -19,14 +19,13 @@ import { scenariosApi } from '@/services/api' import { toApiError } from '@/services/errors' import type { ScenarioComponentIdentity, ScenarioProgressResult, ScenarioRunPlanSeedGroup, ScenarioRunState } from '@/types' import { attackConversationRoutePath, routerPathParamValue, scenarioConversationRoutePath, scenarioRerunRoutePath, scenarioRunAttackRoutePath, scenarioRunRoutePath } from '@/utils/routeParams' -import { getElapsedMilliseconds, getEtaMilliseconds, isTerminalRunState } from '@/utils/scenarioRunProgress' -import { formatAnalyticsPercent } from '@/utils/attackAnalytics' +import { isTerminalRunState } from '@/utils/scenarioRunProgress' import AttackExecutionTable from './AttackExecutionTable' import { ObjectiveDetailsDialog } from './ScenarioRunDialogs' import { useScenarioRunPageStyles } from './ScenarioRunPage.styles' +import ScenarioRunTiming from './ScenarioRunTiming' -const CLOCK_INTERVAL_MS = 1_000 const MAX_VISIBLE_ATTEMPTS = 100 const STATE_COLORS: Record = { CREATED: 'informative', QUEUED: 'informative', IN_PROGRESS: 'brand', COMPLETED: 'success', FAILED: 'danger', CANCELLED: 'warning', @@ -59,7 +58,6 @@ function ScenarioRunContent({ scenarioResultId, attackResultId }: ScenarioRunCon : `/scanner/history${navigationState?.scenarioHistorySearch ?? ''}` const backLabel = navigationState?.suiteExecutionId ? `Back to ${navigationState.suiteName ?? 'suite execution'}` : 'Back to scans' const { state, retry, applyRunSummary } = useScenarioRunProgress(scenarioResultId) - const [now, setNow] = useState(Date.now) const [expanded, setExpanded] = useState | null>(null) const [identityExpanded, setIdentityExpanded] = useState(false) const [objective, setObjective] = useState(null) @@ -69,17 +67,34 @@ function ScenarioRunContent({ scenarioResultId, attackResultId }: ScenarioRunCon const [rerunPending, setRerunPending] = useState(false) const [rerunError, setRerunError] = useState(null) const detailsTrigger = useRef(null) + const objectiveTrigger = useRef(null) + const overviewTarget = useRef(null) const active = Boolean(state.run && !isTerminalRunState(state.run.status)) const resume = useScenarioRunResume({ onResumed: applyRunSummary, onRefresh: (succeeded: boolean): void => { if (!succeeded) retry() }, }) - useEffect(() => { - if (!active) return - const timer = setInterval(() => { setNow(Date.now()) }, CLOCK_INTERVAL_MS) - return () => { clearInterval(timer) } - }, [active]) const seedObjectives = useMemo(() => new Map(state.plan?.seed_groups.map((seed) => [seed.id, seed.objective]) ?? []), [state.plan]) + const planSeeds = useMemo(() => new Map(state.plan?.seed_groups.map((seed) => [seed.id, seed]) ?? []), [state.plan]) + const attemptsBySeed = useMemo(() => { + const groups = new Map() + for (const attempt of state.results) { + const attempts = groups.get(attempt.seed_group_id) ?? [] + attempts.push(attempt) + groups.set(attempt.seed_group_id, attempts) + } + return groups + }, [state.results]) + const datasetsBySeed = useMemo(() => { + const identities = new Map() + for (const attempt of [...state.results, ...state.activeConversations]) { + if (!attempt.seed_group_id) continue + const seeds = identities.get(attempt.seed_group_id) ?? [] + seeds.push(...(attempt.seed_identifiers ?? [])) + identities.set(attempt.seed_group_id, seeds) + } + return new Map([...identities].map(([id, seeds]) => [id, seedDatasetNames(seeds)])) + }, [state.results, state.activeConversations]) const attemptsByGroup = useMemo(() => { const groups = new Map() for (const attempt of [...state.results].reverse()) { @@ -98,12 +113,16 @@ function ScenarioRunContent({ scenarioResultId, attackResultId }: ScenarioRunCon function closeDetails(): void { navigate(runPath, { replace: true, state: location.state }) - requestAnimationFrame(() => { detailsTrigger.current?.focus() }) + requestAnimationFrame(() => { + const target = detailsTrigger.current + if (target?.isConnected) target.focus() + else overviewTarget.current?.focus() + }) } function openDetails(attempt: ScenarioProgressResult, trigger: HTMLElement): void { + detailsTrigger.current = objective ? objectiveTrigger.current : trigger setObjective(null) - detailsTrigger.current = trigger navigate(scenarioRunAttackRoutePath(scenarioResultId, attempt.attack_result_id), { state: location.state }) } @@ -148,19 +167,24 @@ function ScenarioRunContent({ scenarioResultId, attackResultId }: ScenarioRunCon const outcomes = overall.outcomes const groups = summary.display_groups ?? summary.techniques const defaultExpanded = groups.find((group) => (group.atomic_group_ids ?? []).some((id: string) => attemptsByGroup.has(id)))?.id ?? groups[0]?.id - const elapsed = run.started_at ? formatDuration(getElapsedMilliseconds(run, now)) - : run.status === 'CREATED' || run.status === 'QUEUED' ? 'Not started' : 'Not recorded' - const eta = getEtaMilliseconds(run, overall, now) const selectedAttempt = selected ?? selectedActive const firstError = state.results.find((attempt) => attempt.outcome === 'error') const controlError = rerunError ?? resume.error ?? (resume.executionError !== run.error ? resume.executionError : null) + const notices: string[] = [] + if (state.overloadSummaries.length > 0) notices.push('Service overload detected.') + const unmatched = summary.unattributed_attempts ?? 0 + if (unmatched > 0) notices.push(`${unmatched} execution${unmatched === 1 ? '' : 's'} could not be matched to the saved plan.`) + if (!state.planComplete) notices.push('The saved execution plan is incomplete; some totals cannot be verified.') return (
{backLabel} -
+ + {!active ? `${run.scenario_registry_name ?? run.scenario_name}: ${run.status.toLowerCase()}.` : ''} + +
{run.scenario_registry_name ?? run.scenario_name} @@ -186,10 +210,9 @@ function ScenarioRunContent({ scenarioResultId, attackResultId }: ScenarioRunCon {run.operation_id ? {run.operation_name ?? 'Operation'} : Unassigned}
Target{run.target?.model_name ?? run.target?.target_type ?? 'Not recorded'}
-
Timing{elapsed}{active && run.started_at ? ' elapsed' : ''}
+
Timing
Attack success - {outcomes ? outcomes.success_rate_decided === null ? 'Undetermined' : formatAnalyticsPercent(outcomes.success_rate_decided) - : overall.success_percentage === null ? 'Undetermined' : `${overall.success_percentage}%`} + {formatProgressSuccess(overall)} {outcomes ? `${outcomes.successes} / ${outcomes.total_decided} decided` : `${overall.succeeded} / ${overall.completed} complete`}
Errors / retries{overall.errors} / {overall.retries}
@@ -208,12 +231,27 @@ function ScenarioRunContent({ scenarioResultId, attackResultId }: ScenarioRunCon } {state.stale && Live updates paused. {state.error} } + {notices.length > 0 && 0 || unmatched > 0 ? 'warning' : 'info'}> + + {notices.join(' ')} + {state.overloadSummaries.length > 0 && + Overload details + {state.overloadSummaries.map((overload) => ( + + {overload.component_role.replace(/_/g, ' ')}: {overload.count} + {overload.status_codes.length > 0 ? ` (HTTP ${overload.status_codes.join(', ')})` : ''} + + ))} + } + + } {active &&
{run.status === 'QUEUED' ? `Queued${run.queue_position ? ` · Position ${run.queue_position}` : ''}` : `${overall.completed}${overall.planned === null ? '' : ` of ${overall.planned}`} attacks complete`}
- {eta !== null && About {formatDuration(eta)} left} +
} {state.activeConversations.length > 0 &&
Executing @@ -239,6 +277,7 @@ function ScenarioRunContent({ scenarioResultId, attackResultId }: ScenarioRunCon aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${group.display_group}`} aria-expanded={isExpanded} onClick={() => { setExpanded((current: Set | null) => { const next = new Set(current ?? (defaultExpanded ? [defaultExpanded] : [])); if (next.has(group.id)) next.delete(group.id); else next.add(group.id); return next }) }} /> {group.display_group} + Attack success {formatProgressSuccess(group)} {group.completed}{group.planned === null ? '' : ` / ${group.planned}`} complete
{isExpanded && Datasets & objectives
- DatasetObjectiveCompleted + DatasetObjectiveAttack successCompleted{summary.seed_groups.map((seed) => { - const planSeed = state.plan?.seed_groups.find((candidate) => candidate.id === seed.id) - const identities = [ - ...state.results.filter((attempt) => attempt.seed_group_id === seed.id).flatMap((attempt) => attempt.seed_identifiers ?? []), - ...state.activeConversations.filter((attempt) => attempt.seed_group_id === seed.id).flatMap((attempt) => attempt.seed_identifiers), - ] + const planSeed = planSeeds.get(seed.id) return - {seedDatasetNames(identities) || 'Not recorded'} - + {formatProgressSuccess(seed)} {seed.completed}{seed.planned === null ? '' : ` / ${seed.planned}`} })} @@ -294,7 +333,7 @@ function ScenarioRunContent({ scenarioResultId, attackResultId }: ScenarioRunCon { setObjective(null) }} - attempts={state.results.filter((attempt) => attempt.seed_group_id === objective?.id)} + attempts={objective ? attemptsBySeed.get(objective.id) ?? [] : []} onOpenAttempt={openDetails} scenarioResultId={scenarioResultId} /> ) diff --git a/frontend/src/components/Scenarios/ScenarioRunTiming.tsx b/frontend/src/components/Scenarios/ScenarioRunTiming.tsx new file mode 100644 index 0000000000..74bc677508 --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioRunTiming.tsx @@ -0,0 +1,31 @@ +import { useEffect, useState } from 'react' + +import { Text } from '@fluentui/react-components' + +import { formatDuration } from '@/components/AttackResults/attackAttemptFormatting' +import type { ScenarioProgressCounts, ScenarioProgressHeader } from '@/types' +import { getElapsedMilliseconds, getEtaMilliseconds, isTerminalRunState } from '@/utils/scenarioRunProgress' + +const CLOCK_INTERVAL_MS = 1_000 + +interface ScenarioRunTimingProps { + readonly run: ScenarioProgressHeader + readonly overall?: ScenarioProgressCounts +} + +export default function ScenarioRunTiming({ run, overall }: ScenarioRunTimingProps) { + const [now, setNow] = useState(Date.now) + const active = !isTerminalRunState(run.status) + useEffect(() => { + if (!active || !run.started_at) return + const timer = setInterval(() => { setNow(Date.now()) }, CLOCK_INTERVAL_MS) + return () => { clearInterval(timer) } + }, [active, run.started_at]) + if (overall) { + const eta = getEtaMilliseconds(run, overall, now) + return eta === null ? null : About {formatDuration(eta)} left + } + const elapsed = run.started_at ? formatDuration(getElapsedMilliseconds(run, now)) + : run.status === 'CREATED' || run.status === 'QUEUED' ? 'Not started' : 'Not recorded' + return {elapsed}{active && run.started_at ? ' elapsed' : ''} +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 7c9bafb4d8..877112ba01 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -68,7 +68,6 @@ import type { ScenarioRunSummary, ScenarioRunListResponse, ScenarioRunProgress, - PairedOutcomeStatistics, ScenarioQueueSnapshot, ScenarioRunState, ConfigurationFileContent, @@ -738,18 +737,6 @@ export const scenariosApi = { `/scenarios/runs/${encodeURIComponent(scenarioResultId)}/conversations/${encodeURIComponent(conversationId)}`, )).data, - getPairedOutcomes: async ( - scenarioResultId: string, - params: { baseline_group_id: string; attack_group_id: string }, - signal?: AbortSignal, - ): Promise => { - const response = await apiClient.get( - `/scenarios/runs/${encodeURIComponent(scenarioResultId)}/paired-outcomes`, - { params, signal }, - ) - return response.data - }, - getQueue: async (signal?: AbortSignal): Promise => { const response = await apiClient.get('/scenarios/runs/queue', { signal }) return response.data diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 98cae2a613..696cbc5222 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -2057,31 +2057,6 @@ export interface ScenarioRunPlanSeedPrompt { parameters: string[] } -export interface PairedOutcomeStatistics { - baseline_group_id: string - attack_group_id: string - planned_pairs: number - decided_pairs: number - excluded_pairs: number - failure_to_success: number - success_to_failure: number - failure_to_failure: number - success_to_success: number - baseline_success_rate: number | null - attack_success_rate: number | null - paired_difference: number | null - missing_baseline: number - missing_attack: number - missing_both: number - baseline_errors: number - attack_errors: number - baseline_undetermined: number - attack_undetermined: number - unmatched_baseline_seeds: number - unmatched_attack_seeds: number - unattributed_attempts: number -} - export interface ScenarioRunPlanAtomicGroup { kind?: 'baseline' | 'attack' | 'adaptive' | 'unknown' | null id: string diff --git a/pyrit/backend/services/scenario_run_service.py b/pyrit/backend/services/scenario_run_service.py index e17872fbb7..4fe79d5e8a 100644 --- a/pyrit/backend/services/scenario_run_service.py +++ b/pyrit/backend/services/scenario_run_service.py @@ -101,7 +101,7 @@ from pyrit.registry import InitializerRegistry, ScenarioRegistry from pyrit.registry.resolution import reject_non_external_params, resolve_declared_params from pyrit.scenario import IncompatibleTechniquePolicy, Scenario -from pyrit.scenario.core import override_default_adversarial_target +from pyrit.scenario.core import get_default_adversarial_target, override_default_adversarial_target logger = logging.getLogger(__name__) @@ -121,10 +121,12 @@ _SHUTDOWN_INTERRUPTION_REASON = "The backend process shut down before this scenario run completed." _USER_CANCELLATION_REASON = "Run was cancelled by user" _LAUNCH_REQUEST_METADATA_KEY = "scenario_launch_request_v1" +_LAUNCH_REFERENCES_METADATA_KEY = "scenario_launch_references_v1" _RESERVED_INITIAL_METADATA_KEYS = frozenset( { _SCHEDULER_METADATA_KEY, _LAUNCH_REQUEST_METADATA_KEY, + _LAUNCH_REFERENCES_METADATA_KEY, SCENARIO_RUN_PLAN_METADATA_KEY, SCENARIO_RUN_STARTED_AT_METADATA_KEY, } @@ -357,18 +359,56 @@ def _validate_rerun_references(self, *, stored: ScenarioResult, request: RunScen metadata = registry.get_class_metadata(scenario_class) if metadata.scenario_version != stored.scenario_version: raise ScenarioRunConflictError("The scenario version changed. Start a new configuration explicitly.") - if request.objective_scorer is None: - original = stored.objective_scorer_identifier - current = metadata.default_objective_scorer_identifier - if original is None or current is None or original.hash != current.hash: - raise ScenarioRunConflictError( - "The saved default scorer changed. Start a new configuration explicitly." - ) + original = stored.objective_scorer_identifier + current = metadata.default_objective_scorer_identifier + if request.objective_scorer is not None: + resolved = self._configuration_resolver.resolve_constructor_kwargs( + scenario_class=scenario_class, objective_scorer=request.objective_scorer + ) + current = resolved["objective_scorer"].get_identifier() + if original is None or current is None or original.hash != current.hash: + raise ScenarioRunConflictError("The saved objective scorer changed. Start a new configuration explicitly.") if request.target_name: current_target = self._configuration_resolver.resolve_target(target_name=request.target_name) original_target = stored.scenario_identifier.objective_target if original_target is None or current_target.get_identifier().hash != original_target.hash: raise ScenarioRunConflictError("The saved target changed. Start a new configuration explicitly.") + references = stored.metadata.get(_LAUNCH_REFERENCES_METADATA_KEY) + has_adversarial_reference = isinstance(references, dict) and "adversarial_target_hash" in references + if ( + request.adversarial_target_name is not None + or metadata.uses_default_adversarial_target is True + or has_adversarial_reference + ): + original_hash = references.get("adversarial_target_hash") if isinstance(references, dict) else None + if not isinstance(original_hash, str): + raise ScenarioRunConflictError( + "The saved adversarial target identity is unavailable. Start a new configuration explicitly." + ) + target = self._configuration_resolver.resolve_adversarial_target( + target_name=request.adversarial_target_name + ) + target = target if target is not None else get_default_adversarial_target() + if target.get_identifier().hash != original_hash: + raise ScenarioRunConflictError( + "The saved adversarial target changed. Start a new configuration explicitly." + ) + + def _capture_launch_references( + self, *, request: RunScenarioRequest, scenario_class: type[Scenario] + ) -> dict[str, str]: + """ + Pin the resolved adversarial default before a new run is initialized. + + Returns: + dict[str, str]: The selected adversarial target hash, if this run uses one. + """ + metadata = ScenarioRegistry.get_registry_singleton().get_class_metadata(scenario_class) + if request.adversarial_target_name is None and metadata.uses_default_adversarial_target is not True: + return {} + target = self._configuration_resolver.resolve_adversarial_target(target_name=request.adversarial_target_name) + target = target if target is not None else get_default_adversarial_target() + return {"adversarial_target_hash": target.get_identifier().hash} async def get_conversation_async( self, *, scenario_result_id: str, conversation_id: str @@ -1515,6 +1555,15 @@ async def _initialize_scenario_async( launch_request["include_baseline"] = ( baseline if baseline is not None else scenario_class.BASELINE_ATTACK_POLICY.value == "enabled" ) + references = ( + { + _LAUNCH_REFERENCES_METADATA_KEY: await run_legacy_sync_async( + self._capture_launch_references, request=request, scenario_class=scenario_class + ) + } + if request.scenario_result_id is None + else {} + ) return await scenario_registry.create_and_initialize_async( request.scenario_name, scenario_params=request.scenario_params or {}, @@ -1526,6 +1575,7 @@ async def _initialize_scenario_async( **(initial_metadata or {}), _SCHEDULER_METADATA_KEY: _SCHEDULER_METADATA_VALUE, _LAUNCH_REQUEST_METADATA_KEY: launch_request, + **references, }, **init_kwargs, ) @@ -2502,9 +2552,10 @@ def _active_conversations( for execution in snapshot.executions: identifier = execution.atomic_attack_identifier attribution = execution.attribution - if attribution is None or attribution.parent_id != scenario_result_id: + conversation_id = execution.current_conversation_id + if attribution is None or attribution.parent_id != scenario_result_id or conversation_id is None: continue - conversations.extend( + conversations.append( ScenarioActiveConversation( attack_result_id=execution.attack_result_id, conversation_id=conversation_id, @@ -2524,7 +2575,6 @@ def _active_conversations( if identifier is not None else [], ) - for conversation_id in execution.conversation_ids ) return conversations diff --git a/pyrit/backend/services/scenario_service.py b/pyrit/backend/services/scenario_service.py index ff67578fb9..a4d8865a8a 100644 --- a/pyrit/backend/services/scenario_service.py +++ b/pyrit/backend/services/scenario_service.py @@ -55,19 +55,11 @@ def _metadata_to_registered_scenario( RegisteredScenario: Public catalog projection. """ estimate = default_run_size or ScenarioRunSizeEstimate.unavailable() - from pyrit.executor.attack import PromptSendingAttack - from pyrit.models import ComponentIdentifier - return RegisteredScenario( scenario_name=metadata.registry_name, scenario_type=metadata.class_name, scenario_version=metadata.scenario_version, supports_objective_scorer=metadata.supports_objective_scorer, - baseline_identifier=ComponentIdentifier( - class_name=PromptSendingAttack.__name__, class_module=PromptSendingAttack.__module__ - ) - if metadata.baseline_policy != "forbidden" - else None, description=metadata.class_description, description_markdown=metadata.description_markdown, default_technique=metadata.default_technique, diff --git a/pyrit/executor/attack/compound/sequential_attack.py b/pyrit/executor/attack/compound/sequential_attack.py index 12dade46be..8ea3423ace 100644 --- a/pyrit/executor/attack/compound/sequential_attack.py +++ b/pyrit/executor/attack/compound/sequential_attack.py @@ -47,6 +47,7 @@ if TYPE_CHECKING: from collections.abc import Mapping, Sequence + from pyrit.executor.attack.core.attack_executor import ActiveAttackExecution from pyrit.executor.attack.core.attack_result_attribution import AttackResultAttribution from pyrit.models import AtomicAttackIdentifier, ComponentIdentifier from pyrit.prompt_target import PromptTarget @@ -259,6 +260,11 @@ def __init__( self._executor = AttackExecutor(max_concurrency=1) self._memory = CentralMemory.get_memory_instance() + @property + def active_executions(self) -> tuple[ActiveAttackExecution, ...]: + """Expose active child attempts through the executor that owns them.""" + return self._executor.active_executions + def _build_identifier(self) -> ComponentIdentifier: """ Include explicitly packaged child configurations and the completion policy. diff --git a/pyrit/executor/attack/core/attack_executor.py b/pyrit/executor/attack/core/attack_executor.py index 9bbe734aa0..717eb77d6c 100644 --- a/pyrit/executor/attack/core/attack_executor.py +++ b/pyrit/executor/attack/core/attack_executor.py @@ -43,6 +43,7 @@ class ActiveAttackExecution: attack_result_id: str objective: str conversation_ids: tuple[str, ...] + current_conversation_id: str | None started_at: datetime attribution: AttackResultAttribution | None atomic_attack_identifier: AtomicAttackIdentifier | None @@ -168,7 +169,7 @@ def __init__(self, *, max_concurrency: int = 1) -> None: if max_concurrency <= 0: raise ValueError(f"max_concurrency must be a positive integer, got {max_concurrency}") self._max_concurrency = max_concurrency - self._active_contexts: dict[int, tuple[AttackContext[Any], datetime]] = {} + self._active_contexts: dict[int, tuple[AttackContext[Any], datetime, AttackStrategy[Any, Any]]] = {} # The semaphore is created lazily, NOT here. asyncio synchronization primitives # (Semaphore, Lock, Event, ...) bind to whichever event loop is running on their # first await and raise ``RuntimeError: is bound to a different event @@ -183,20 +184,24 @@ def __init__(self, *, max_concurrency: int = 1) -> None: @property def active_executions(self) -> tuple[ActiveAttackExecution, ...]: """Snapshot in-flight attempts without changing execution or persistence.""" - return tuple( - ActiveAttackExecution( - attack_result_id=result_id, - objective=context.objective, - conversation_ids=tuple(sorted(context.objective_conversation_ids)), - started_at=started_at, - attribution=context._attribution, - atomic_attack_identifier=context._atomic_attack_identifier.model_copy(deep=True) - if context._atomic_attack_identifier is not None - else None, - ) - for context, started_at in self._active_contexts.values() - if (result_id := context.attack_result_id) is not None - ) + executions: dict[str, ActiveAttackExecution] = {} + for context, started_at, strategy in self._active_contexts.values(): + result_id = context.attack_result_id + if result_id is not None: + executions[result_id] = ActiveAttackExecution( + attack_result_id=result_id, + objective=context.objective, + conversation_ids=context.objective_conversation_ids, + current_conversation_id=context.current_objective_conversation_id, + started_at=started_at, + attribution=context._attribution, + atomic_attack_identifier=context._atomic_attack_identifier.model_copy(deep=True) + if context._atomic_attack_identifier is not None + else None, + ) + for child in strategy.active_executions: + executions[child.attack_result_id] = child + return tuple(executions.values()) def _get_semaphore(self) -> asyncio.Semaphore: """ @@ -494,7 +499,7 @@ async def run_one_async(index: int, params: AttackParameters) -> AttackStrategyR if task_attribution is not None: context._attribution = task_attribution context_key = id(context) - self._active_contexts[context_key] = (context, datetime.now(UTC)) + self._active_contexts[context_key] = (context, datetime.now(UTC), attack) try: return await attack.execute_with_context_async(context=context) finally: diff --git a/pyrit/executor/attack/core/attack_strategy.py b/pyrit/executor/attack/core/attack_strategy.py index b9363259fa..7d4ae48490 100644 --- a/pyrit/executor/attack/core/attack_strategy.py +++ b/pyrit/executor/attack/core/attack_strategy.py @@ -41,6 +41,7 @@ AttackResultSelection, ComponentIdentifier, ConversationReference, + ConversationType, ConverterIdentifier, Identifiable, Message, @@ -69,6 +70,7 @@ AttackAdversarialConfig, AttackScoringConfig, ) + from pyrit.executor.attack.core.attack_executor import ActiveAttackExecution from pyrit.executor.attack.core.attack_result_attribution import AttackResultAttribution from pyrit.message_normalizer import MessageListNormalizer from pyrit.prompt_target import PromptTarget @@ -223,7 +225,8 @@ class AttackContext(StrategyContext, ABC, Generic[AttackParamsT]): ) _persisted_attack_result_id: str | None = field(default=None, init=False, repr=False, compare=False) _persist_attack_result: bool = field(default=True, init=False, repr=False, compare=False) - _objective_conversation_ids: set[str] = field(default_factory=set, init=False, repr=False, compare=False) + _objective_conversation_ids: dict[str, None] = field(default_factory=dict, init=False, repr=False, compare=False) + _current_objective_conversation_id: str | None = field(default=None, init=False, repr=False, compare=False) _objective_target_conversation_lifecycle: _ObjectiveTargetConversationLifecycle | None = field( default=None, init=False, @@ -358,12 +361,24 @@ def _record_objective_target_invocation(self, *, conversation_id: str) -> None: if lifecycle is None: return lifecycle.record_invocation(conversation_id=conversation_id) - self._objective_conversation_ids.add(conversation_id) + self._objective_conversation_ids.setdefault(conversation_id, None) + self._current_objective_conversation_id = conversation_id @property - def objective_conversation_ids(self) -> frozenset[str]: - """Conversations this execution has sent to its objective target.""" - return frozenset(self._objective_conversation_ids) + def objective_conversation_ids(self) -> tuple[str, ...]: + """Objective conversations in first-invocation order.""" + return tuple(self._objective_conversation_ids) + + @property + def current_objective_conversation_id(self) -> str | None: + """The most recently invoked objective conversation, unless it has been pruned.""" + current = self._current_objective_conversation_id + pruned = { + reference.conversation_id + for reference in self.related_conversations + if reference.conversation_type == ConversationType.PRUNED + } + return current if current not in pruned else None class _DefaultAttackStrategyEventHandler(StrategyEventHandler[AttackStrategyContextT, AttackStrategyResultT]): @@ -986,6 +1001,11 @@ def get_objective_target(self) -> PromptTarget: """ return self._objective_target + @property + def active_executions(self) -> tuple[ActiveAttackExecution, ...]: + """Child execution observations, supplied by compound strategies.""" + return () + def get_attack_scoring_config(self) -> AttackScoringConfig | None: """ Get the attack scoring configuration used by this strategy. @@ -1040,6 +1060,7 @@ async def execute_with_context_async(self, *, context: AttackStrategyContextT) - context._error_result_metadata.clear() context._persisted_attack_result_id = None context._objective_conversation_ids.clear() + context._current_objective_conversation_id = None await self._resolve_operation_async(context=context) self._validate_scoring_expectation(context=context) context._error_result_identifier_builder = lambda: AtomicAttackIdentifier.build( diff --git a/tests/unit/backend/test_scenario_resume.py b/tests/unit/backend/test_scenario_resume.py index 63bbecac9f..ca0f9a3d75 100644 --- a/tests/unit/backend/test_scenario_resume.py +++ b/tests/unit/backend/test_scenario_resume.py @@ -9,13 +9,14 @@ import uuid from collections.abc import AsyncIterator, Awaitable, Mapping, Sequence from typing import Any, ClassVar -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch import pytest from pyrit.backend.models.attacks import ConversationMessagesResponse from pyrit.backend.services.attack_service import AttackService from pyrit.backend.services.scenario_run_service import ( + _LAUNCH_REFERENCES_METADATA_KEY, _LAUNCH_REQUEST_FIELDS, _LAUNCH_REQUEST_METADATA_KEY, _OPTIONAL_LAUNCH_FIELDS, @@ -26,11 +27,18 @@ ) from pyrit.datasets import SeedDatasetProvider from pyrit.exceptions import ScenarioPartialFailureException -from pyrit.executor.attack import AttackScoringConfig, ManyShotJailbreakAttack, PromptSendingAttack +from pyrit.executor.attack import ( + AttackScoringConfig, + ManyShotJailbreakAttack, + PromptSendingAttack, + SequentialAttack, + SequentialChildAttack, +) from pyrit.executor.attack.single_turn import many_shot_jailbreak from pyrit.memory import CentralMemory, SQLiteMemory from pyrit.models import ( SCENARIO_RUN_PLAN_METADATA_KEY, + AtomicAttackIdentifier, AttackOutcome, ComponentIdentifier, DatasetFetchPolicy, @@ -41,6 +49,7 @@ Parameter, ScenarioDatasetRequest, ScenarioResult, + ScenarioRunPlanGroupKind, ScenarioRunState, Seed, SeedDataset, @@ -48,7 +57,7 @@ ) from pyrit.models.catalog.scenario import RunScenarioRequest, ScenarioRunSummary from pyrit.models.seeds import yaml_seed_loader -from pyrit.registry import AttackTechniqueRegistry, ScenarioRegistry, TargetRegistry +from pyrit.registry import AttackTechniqueRegistry, ScenarioRegistry, ScorerRegistry, TargetRegistry from pyrit.scenario import DatasetAttackConfiguration, IncompatibleTechniquePolicy from pyrit.scenario.core import ( AtomicAttack, @@ -175,6 +184,108 @@ async def _shutdown_after_launch_async( await service.shutdown_async() +@pytest.mark.parametrize("depth", [0, 1, 2]) +@pytest.mark.parametrize("cancel", [False]) +async def test_real_scenario_live_retry_observation_and_terminal_ownership_async( + *, resume_environment: tuple[ScenarioRunService, MockPromptTarget], depth: int, cancel: bool +) -> None: + service, target = resume_environment + entered, release = asyncio.Event(), asyncio.Event() + conversations: list[str] = [] + original_send = target._send_prompt_to_target_async + + async def send_async(*, normalized_conversation: list[Message]) -> list[Message]: + conversations.append(normalized_conversation[-1].get_piece().conversation_id) + if len(conversations) == 2: + entered.set() + await release.wait() + return await original_send(normalized_conversation=normalized_conversation) + + async def build_async(self: Scenario, *, context: ScenarioContext) -> list[AtomicAttack]: + seed = context.seed_groups[0] + scorer = SubStringScorer(substring="never matches the response") + strategy = PromptSendingAttack( + objective_target=context.objective_target, + attack_scoring_config=AttackScoringConfig(objective_scorer=scorer), + max_attempts_on_failure=1, + ) + for _ in range(depth): + strategy = SequentialAttack( + objective_target=context.objective_target, + child_attacks=[ + SequentialChildAttack( + strategy=strategy, + seed_group=seed, + atomic_attack_identifier=AtomicAttackIdentifier.build( + attack_identifier=strategy.get_identifier(), seed_group=seed + ), + ) + ], + ) + return [ + AtomicAttack( + atomic_attack_name="live", + attack_technique=AttackTechnique(attack=strategy), + seed_groups=[seed], + memory_labels=context.memory_labels, + group_kind=ScenarioRunPlanGroupKind.ADAPTIVE if depth else ScenarioRunPlanGroupKind.ATTACK, + ) + ] + + with ( + patch.object(_OfflineResumeScenario, "_build_atomic_attacks_async", new=build_async), + patch.object(target, "_send_prompt_to_target_async", side_effect=send_async), + ): + response = await service.start_run_async( + request=RunScenarioRequest( + scenario_name=_SCENARIO_NAME, + target_name=_TARGET_NAME, + include_baseline=False, + max_concurrency=1, + max_retries=0, + ) + ) + run_id = response.scenario_result_id + try: + await asyncio.wait_for(entered.wait(), TEST_WATCHDOG_SECONDS) + progress = await service.get_run_progress_async(scenario_result_id=run_id, since=None, limit=500) + assert progress is not None and len(progress.active_conversations) == 1 + active = progress.active_conversations[0] + assert active.conversation_id == conversations[-1] + executions = service._active_tasks[run_id].scenario.active_executions + observed = next(item for item in executions if item.attack_result_id == active.attack_result_id) + assert observed.conversation_ids == tuple(conversations) + assert observed.current_conversation_id == conversations[-1] + assert ( + await service.get_conversation_async(scenario_result_id=run_id, conversation_id=conversations[0]) + is not None + ) + assert ( + await service.get_conversation_async( + scenario_result_id=str(uuid.uuid4()), conversation_id=conversations[-1] + ) + is None + ) + if cancel: + await service.cancel_run_async(scenario_result_id=run_id) + else: + release.set() + await _wait_for_run_completion_async(service=service, scenario_result_id=run_id) + final = await service.get_run_progress_async(scenario_result_id=run_id, since=None, limit=500) + assert final is not None and not final.active_conversations + assert final.run.status == (ScenarioRunState.CANCELLED if cancel else ScenarioRunState.COMPLETED) + assert ( + await service.get_conversation_async(scenario_result_id=run_id, conversation_id=conversations[-1]) + is not None + ) + assert ( + await service.get_conversation_async(scenario_result_id=run_id, conversation_id=conversations[0]) + is not None + ) + finally: + release.set() + + async def test_real_matrix_launch_keeps_heartbeat_alive_during_disk_reads_async( *, patch_central_database: object, sqlite_instance: SQLiteMemory ) -> None: @@ -923,6 +1034,83 @@ async def test_rerun_rejects_changed_target_instead_of_using_current_defaults_as await service.get_launch_configuration_async(str(stored.id)) +@pytest.mark.parametrize("implicit", [False, True]) +async def test_rerun_pins_selected_adversarial_identity_and_rejects_replacement_async( + *, resume_environment: tuple[ScenarioRunService, MockPromptTarget], implicit: bool +) -> None: + service, _ = resume_environment + registry = TargetRegistry.get_registry_singleton() + adversarial = MockPromptTarget() + registry_name = "adversarial_chat" if implicit else "saved-adversarial" + registry.instances.register(adversarial, name=registry_name) + with patch.object( + _OfflineResumeScenario, "uses_default_adversarial_target", new_callable=PropertyMock, return_value=True + ): + response = await service.start_run_async( + request=RunScenarioRequest( + scenario_name=_SCENARIO_NAME, + target_name=_TARGET_NAME, + adversarial_target_name=None if implicit else registry_name, + include_baseline=False, + ) + ) + await _wait_for_run_completion_async(service=service, scenario_result_id=response.scenario_result_id) + stored = await service._memory.get_scenario_result_header_async(scenario_result_id=response.scenario_result_id) + assert stored is not None + assert ( + stored.metadata[_LAUNCH_REFERENCES_METADATA_KEY]["adversarial_target_hash"] == adversarial.get_identifier().hash + ) + restored = await service.get_launch_configuration_async(response.scenario_result_id) + assert restored is not None and restored.adversarial_target_name == (None if implicit else registry_name) + + class ReplacementAdversarial(MockPromptTarget): + pass + + registry.instances.unregister(registry_name) + registry.instances.register(ReplacementAdversarial(), name=registry_name) + with pytest.raises(ScenarioRunConflictError, match="saved adversarial target changed"): + await service.get_launch_configuration_async(response.scenario_result_id) + + +async def test_rerun_rejects_unverifiable_old_adversarial_selection_async( + resume_environment: tuple[ScenarioRunService, MockPromptTarget], +) -> None: + service, target = resume_environment + stored = await _create_failed_run_async(target=target, legacy=False) + stored.metadata[_LAUNCH_REQUEST_METADATA_KEY]["adversarial_target_name"] = _TARGET_NAME + with patch.object(service._memory, "get_scenario_result_header_async", new_callable=AsyncMock, return_value=stored): + with pytest.raises(ScenarioRunConflictError, match="adversarial target identity is unavailable"): + await service.get_launch_configuration_async(str(stored.id)) + + +@pytest.mark.parametrize("selection", ["unchanged", "registry-drift", "different-valid-reference"]) +async def test_rerun_validates_named_scorer_against_source_and_saved_result_async( + *, resume_environment: tuple[ScenarioRunService, MockPromptTarget], selection: str +) -> None: + service, target = resume_environment + stored = await _create_failed_run_async(target=target, legacy=False) + scorer = SubStringScorer(substring="default" if selection == "unchanged" else "changed") + original_hash = stored.objective_scorer_identifier.hash + stored.metadata[_LAUNCH_REQUEST_METADATA_KEY]["objective_scorer"] = { + "registry_name": "saved-objective", + "identifier_hash": scorer.get_identifier().hash if selection == "different-valid-reference" else original_hash, + } + with patch.object(ScorerRegistry, "_discover"): + scorers = ScorerRegistry() + scorers.instances.register(scorer, name="saved-objective") + with ( + patch.object(ScorerRegistry, "get_registry_singleton", return_value=scorers), + patch.object(ScenarioRegistry, "supports_objective_scorer", return_value=True), + patch.object(service._memory, "get_scenario_result_header_async", new_callable=AsyncMock, return_value=stored), + ): + if selection == "unchanged": + restored = await service.get_launch_configuration_async(str(stored.id)) + assert restored is not None and restored.objective_scorer.identifier_hash == original_hash + else: + with pytest.raises(ValueError, match="changed"): + await service.get_launch_configuration_async(str(stored.id)) + + async def test_rerun_canonical_dataset_request_remains_a_valid_response_async( resume_environment: tuple[ScenarioRunService, MockPromptTarget], ) -> None: diff --git a/tests/unit/backend/test_scenario_run_service.py b/tests/unit/backend/test_scenario_run_service.py index 3c69e0c390..356fea456d 100644 --- a/tests/unit/backend/test_scenario_run_service.py +++ b/tests/unit/backend/test_scenario_run_service.py @@ -975,6 +975,7 @@ def get_aggregate_tags(cls) -> set[str]: initial_metadata={ _svc_mod._SCHEDULER_METADATA_KEY: _svc_mod._SCHEDULER_METADATA_VALUE, _svc_mod._LAUNCH_REQUEST_METADATA_KEY: ANY, + _svc_mod._LAUNCH_REFERENCES_METADATA_KEY: {}, }, objective_target=objective_target, max_concurrency=10, diff --git a/tests/unit/backend/test_scenario_service.py b/tests/unit/backend/test_scenario_service.py index 3cab986263..c50c5be04c 100644 --- a/tests/unit/backend/test_scenario_service.py +++ b/tests/unit/backend/test_scenario_service.py @@ -27,6 +27,7 @@ _metadata_to_registered_scenario, get_scenario_service, ) +from pyrit.executor.attack import PromptSendingAttack from pyrit.models import ( Parameter, ScenarioDatasetSizeCap, @@ -41,6 +42,7 @@ from pyrit.registry import AttackTechniqueRegistry, ScenarioMetadata, ScenarioRegistry, TargetRegistry from pyrit.scenario import Scenario from pyrit.scenario.core import ( + AttackTechniqueFactory, DatasetAttackConfiguration, ScenarioTechnique, get_default_adversarial_target, @@ -162,6 +164,23 @@ def test_catalog_lists_only_external_scenario_parameters() -> None: assert summary.supported_parameter_names == ["dataset_config", "max_concurrency"] +def test_catalog_does_not_invent_an_unconfigured_baseline_identity() -> None: + summary = _metadata_to_registered_scenario(metadata=_make_scenario_metadata()) + assert summary.baseline_identifier is None + + +@pytest.mark.usefixtures("patch_central_database") +def test_catalog_technique_identity_comes_from_factory_without_constructing_an_attack() -> None: + factory = AttackTechniqueFactory(name="identity_preview", attack_class=PromptSendingAttack) + with patch.object(PromptSendingAttack, "__init__", side_effect=AssertionError("Must not construct an attack")): + techniques = AttackTechniqueRegistry.build_technique_class_from_factories( + class_name="IdentityPreview", factories=[factory] + ) + member = techniques.get_all_techniques()[0] + assert member.identifier == factory.get_identifier() + assert _EstimateTechnique.PROMPT_SENDING.identifier is None + + @pytest.mark.usefixtures("patch_central_database") @pytest.mark.parametrize("scenario_class", [Scam, Exploitation]) def test_catalog_support_names_match_real_scenario_declarations(scenario_class: type[Scenario]) -> None: diff --git a/tests/unit/executor/attack/core/test_attack_executor.py b/tests/unit/executor/attack/core/test_attack_executor.py index 12f118c00a..9b67c1076f 100644 --- a/tests/unit/executor/attack/core/test_attack_executor.py +++ b/tests/unit/executor/attack/core/test_attack_executor.py @@ -15,14 +15,17 @@ import pytest from unit.async_utils import wait_for_completion_async, wait_for_event_async +from unit.mocks import MockPromptTarget from pyrit.executor.attack import ( AttackExecutor, AttackParameters, AttackStrategy, + PromptSendingAttack, SingleTurnAttackContext, ) from pyrit.executor.attack.core.attack_executor import AttackExecutorResult +from pyrit.memory import CentralMemory from pyrit.models import ( AtomicAttackIdentifier, AttackOutcome, @@ -69,31 +72,33 @@ def create_seed_group(objective: str) -> AttackSeedGroup: async def test_active_execution_snapshot_is_read_only_and_removed_on_cancellation() -> None: started = asyncio.Event() release = asyncio.Event() - result_id = str(uuid.uuid4()) - conversation_id = str(uuid.uuid4()) - attack = create_mock_attack() + target = MockPromptTarget() + attack = PromptSendingAttack(objective_target=target) executor = AttackExecutor() + original_send = target._send_prompt_to_target_async - async def run_async(*, context: SingleTurnAttackContext) -> AttackResult: - context._attack_result_id = result_id - context._objective_conversation_ids.add(conversation_id) + async def send_async(*, normalized_conversation: list[Message]) -> list[Message]: started.set() await release.wait() - return create_attack_result(context.objective) - - attack.execute_with_context_async.side_effect = run_async - task = asyncio.create_task(executor.execute_attack_async(attack=attack, objectives=["Observe the attempt"])) - try: - await wait_for_event_async(event=started, tasks=[task]) - snapshot = executor.active_executions - assert len(snapshot) == 1 - assert snapshot[0].attack_result_id == result_id - assert snapshot[0].conversation_ids == (conversation_id,) - assert executor.active_executions == snapshot - finally: - task.cancel() - release.set() - await asyncio.gather(task, return_exceptions=True) + return await original_send(normalized_conversation=normalized_conversation) + + with patch.object(target, "_send_prompt_to_target_async", side_effect=send_async): + task = asyncio.create_task(executor.execute_attack_async(attack=attack, objectives=["Observe the attempt"])) + try: + await wait_for_event_async(event=started, tasks=[task]) + snapshot = executor.active_executions + assert len(snapshot) == 1 + observed = snapshot[0] + assert observed.conversation_ids == (observed.current_conversation_id,) + metadata = await CentralMemory.get_memory_instance().get_conversation_metadata_async( + conversation_id=observed.current_conversation_id + ) + assert metadata is not None and metadata.attack_result_id == observed.attack_result_id + assert executor.active_executions == snapshot + finally: + task.cancel() + release.set() + await asyncio.gather(task, return_exceptions=True) assert executor.active_executions == () From 0818b8975bb29529ece5a653031b53bd24ff5fbb Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Thu, 29 Oct 2026 10:33:00 -0700 Subject: [PATCH 20/65] Propose durable cancelled-attempt evidence for user review HELD PROPOSAL, not approved for publication. Reuses ERROR/CancelledError records to retain interrupted conversations and joins publication before releasing execution. This changes historical attempt/error counts and all-outcome denominators; user review must choose this policy or attribution-only persistence before release. Keep this commit separate from confirmed repairs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- doc/gui/0_gui.md | 2 + ...narioConversationPage.transitions.test.tsx | 129 ++++++++++++++++++ .../Scenarios/ScenarioConversationPage.tsx | 19 ++- pyrit/executor/attack/core/attack_strategy.py | 63 +++++++-- tests/unit/backend/test_scenario_resume.py | 2 +- .../attack/core/test_attack_executor.py | 79 +++++++++++ 6 files changed, 274 insertions(+), 20 deletions(-) create mode 100644 frontend/src/components/Scenarios/ScenarioConversationPage.transitions.test.tsx diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index 7fa5e90999..7141f4d50e 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -81,6 +81,8 @@ The results page does not include a paired-outcome comparison. is read-only and refreshes saved messages; it has no send controls. **Cancel scan** stops remaining scan work and keeps saved results. Ordinary manual chats do not have scan cancellation controls. +After cancellation, saved messages remain available in the read-only chat. +Interrupted attempts are recorded as errors so Resume can retry them. **Rerun** opens the scenario launch page with saved settings filled in. It does not start work or change the original run. Review or edit the settings before diff --git a/frontend/src/components/Scenarios/ScenarioConversationPage.transitions.test.tsx b/frontend/src/components/Scenarios/ScenarioConversationPage.transitions.test.tsx new file mode 100644 index 0000000000..55a35a94e3 --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioConversationPage.transitions.test.tsx @@ -0,0 +1,129 @@ +import { act } from 'react' + +import { FluentProvider, webLightTheme } from '@fluentui/react-components' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router' + +import { scenariosApi } from '@/services/api' +import type { ConversationMessagesResponse, ScenarioRunProgress, ScenarioRunSummary } from '@/types' + +import ScenarioConversationPage from './ScenarioConversationPage' + +jest.mock('@/services/api', () => ({ + scenariosApi: { getRunProgress: jest.fn(), getRunConversation: jest.fn(), cancelRun: jest.fn() }, + attacksApi: { getMessages: jest.fn() }, +})) + +const RUN_ID = '123e4567-e89b-12d3-a456-426614174000' +const LIVE_PATH = `/scanner/history/${RUN_ID}/conversations/conversation` +const active = { + attack_result_id: 'attempt', conversation_id: 'conversation', atomic_attack_name: 'PromptSendingAttack', + objective: 'Review the response', started_at: '2026-01-01T00:00:00Z', seed_group_id: 'seed', seed_identifiers: [], +} +const transcript: ConversationMessagesResponse = { + conversation_id: 'conversation', target_response_status: null, + messages: [{ + turn_number: 0, role: 'user', created_at: '2026-01-01T00:00:00Z', + message_pieces: [{ + id: 'piece', original_value_data_type: 'text', converted_value_data_type: 'text', + converted_value: 'A saved prompt', response_error: 'none', scores: [], + }], + }], +} + +function progress(status: 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED'): ScenarioRunProgress { + const terminal = status !== 'IN_PROGRESS' + return { + run: { + scenario_result_id: RUN_ID, scenario_name: 'review.scan', scenario_version: 1, status, + created_at: '2026-01-01T00:00:00Z', + }, + summary: { + overall: { completed: terminal ? 1 : 0, planned: 1, succeeded: status === 'COMPLETED' ? 1 : 0, + success_percentage: null, errors: status === 'CANCELLED' ? 1 : 0, retries: 0 }, + techniques: [], seed_groups: [], atomic_groups: [], + }, + plan: { version: 1, atomic_groups: [], seed_groups: [{ + id: 'seed', objective_sha256: 'hash', objective: active.objective, prompts: [], + }] }, + results: terminal ? [{ + ...active, atomic_group_id: 'group', seed_group_id: 'seed', + outcome: status === 'CANCELLED' ? 'error' : 'success', execution_time_ms: 1000, + timestamp: '2026-01-01T00:00:01Z', total_retries: 0, retries: [], + error_type: status === 'CANCELLED' ? 'CancelledError' : undefined, + }] : [], + active_conversations: terminal ? [] : [active], + next_cursor: terminal ? 'finished' : null, has_more: false, plan_complete: true, + } +} + +function LocationTrace() { + const location = useLocation() + return {location.pathname}{location.search} +} + +function Wrapper() { + return + + } /> + Completed conversation} /> + + +} + +beforeEach(() => { + jest.clearAllMocks() + jest.useFakeTimers() + jest.mocked(scenariosApi.getRunProgress).mockResolvedValue(progress('IN_PROGRESS')) + jest.mocked(scenariosApi.getRunConversation).mockResolvedValue(transcript) +}) +afterEach(() => { jest.useRealTimers() }) + +it('retains the transcript, objective and stopped state after Cancel and a fresh reload using real hooks', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) + jest.mocked(scenariosApi.cancelRun).mockImplementation(async () => { + jest.mocked(scenariosApi.getRunProgress).mockResolvedValue(progress('CANCELLED')) + return { + ...progress('CANCELLED').run, updated_at: '2026-01-01T00:00:01Z', techniques_used: [], + total_attacks: 1, completed_attacks: 1, objective_achieved_rate: 0, + failed_attacks: [], attack_retries: [], total_retries: 0, labels: {}, + } satisfies ScenarioRunSummary + }) + const view = render() + expect(await screen.findByText('A saved prompt')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Cancel scan' })) + await user.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Cancel scan' })) + expect(await screen.findByText('Stopped before finishing')).toBeInTheDocument() + expect(screen.getByText(active.objective)).toBeInTheDocument() + expect(screen.getByText('A saved prompt')).toBeInTheDocument() + expect(screen.queryByText('This conversation is not part of this scan.')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Cancel scan' })).not.toBeInTheDocument() + view.unmount() + render() + expect(await screen.findByText('Stopped before finishing')).toBeInTheDocument() + expect(await screen.findByText('A saved prompt')).toBeInTheDocument() + expect(screen.getByText(active.objective)).toBeInTheDocument() + expect(screen.getByLabelText('Location')).toHaveTextContent(LIVE_PATH) + expect(scenariosApi.getRunConversation).toHaveBeenLastCalledWith(RUN_ID, 'conversation') +}) + +it('moves a live conversation to the canonical completed chat after the progress poll', async () => { + render() + await screen.findByText('A saved prompt') + jest.mocked(scenariosApi.getRunProgress).mockResolvedValue(progress('COMPLETED')) + await act(async () => { await jest.advanceTimersByTimeAsync(2500) }) + expect(await screen.findByRole('heading', { name: 'Completed conversation' })).toBeInTheDocument() + expect(screen.getByLabelText('Location')).toHaveTextContent( + `/attack/chat/attempt/conversations/conversation?scenarioResultId=${RUN_ID}`, + ) +}) + +it('does not expose cancellation when the scoped transcript API rejects ownership', async () => { + jest.mocked(scenariosApi.getRunConversation).mockRejectedValue({ + isAxiosError: true, response: { status: 404, data: { detail: 'Not owned by this scan' } }, + }) + render() + expect(await screen.findByText(/Not owned by this scan/)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Cancel scan' })).not.toBeInTheDocument() +}) diff --git a/frontend/src/components/Scenarios/ScenarioConversationPage.tsx b/frontend/src/components/Scenarios/ScenarioConversationPage.tsx index 0a54f2cab5..4ca1e91b5b 100644 --- a/frontend/src/components/Scenarios/ScenarioConversationPage.tsx +++ b/frontend/src/components/Scenarios/ScenarioConversationPage.tsx @@ -9,6 +9,7 @@ import { useConversationMessages } from '@/hooks/useConversationMessages' import { SCENARIO_RUN_POLL_INTERVAL_MS, useScenarioRunProgress } from '@/hooks/useScenarioRunProgress' import { scenariosApi } from '@/services/api' import { toApiError } from '@/services/errors' +import type { ScenarioActiveConversation } from '@/types' import { attackConversationRoutePath, routerPathParamValue, scenarioRunRoutePath } from '@/utils/routeParams' import { isTerminalRunState } from '@/utils/scenarioRunProgress' @@ -34,7 +35,8 @@ function ScanConversation({ scenarioResultId, conversationId }: ScanConversation const { state, applyRunSummary, retry } = useScenarioRunProgress(scenarioResultId) const active = state.activeConversations.find((conversation) => conversation.conversation_id === conversationId) const saved = state.results.find((attempt) => attempt.conversation_id === conversationId || attempt.related_conversation_ids?.includes(conversationId)) - const attackId = active?.attack_result_id ?? saved?.attack_result_id ?? null + const [lastActive, setLastActive] = useState(null) + const attackId = active?.attack_result_id ?? saved?.attack_result_id ?? lastActive?.attack_result_id ?? null const running = Boolean(state.run && !isTerminalRunState(state.run.status)) const readConversation = useCallback((_attackId: string, selectedId: string) => scenariosApi.getRunConversation(scenarioResultId, selectedId), [scenarioResultId]) @@ -47,11 +49,15 @@ function ScanConversation({ scenarioResultId, conversationId }: ScanConversation const [cancelling, setCancelling] = useState(false) const [cancelError, setCancelError] = useState(null) const verified = transcript.loadedConversationId === conversationId && !transcript.conversationLoadIssue + if (active && verified && lastActive !== active) setLastActive(active) + const display = active ?? lastActive + const stopped = saved ? saved.error_type === 'CancelledError' + : state.run?.status === 'CANCELLED' && lastActive !== null useEffect(() => { - if (!active && saved && verified) { + if (!active && saved && verified && !stopped) { navigate(attackConversationRoutePath(saved.attack_result_id, conversationId, scenarioResultId), { replace: true }) } - }, [active, saved, verified, navigate, conversationId, scenarioResultId]) + }, [active, saved, verified, stopped, navigate, conversationId, scenarioResultId]) async function cancel(): Promise { setCancelling(true) @@ -72,14 +78,15 @@ function ScanConversation({ scenarioResultId, conversationId }: ScanConversation Back to scan
-
{active?.atomic_attack_name ?? saved?.atomic_attack_name ?? 'Conversation'} - {active ? 'Executing' : 'Saved conversation'} +
{display?.atomic_attack_name ?? saved?.atomic_attack_name ?? 'Conversation'} + {stopped ? 'Stopped before finishing' : active ? 'Executing' : 'Saved conversation'}
Read-only · {state.run?.scenario_registry_name ?? state.run?.scenario_name}
{running && active && verified && }
- {active?.objective ?? state.plan?.seed_groups.find((seed) => seed.id === saved?.seed_group_id)?.objective} + {display?.objective ?? state.plan?.seed_groups.find((seed) => seed.id === saved?.seed_group_id)?.objective} + {stopped && Saved messages remain available.} {state.error && {state.error} } {transcript.conversationLoadIssue && {transcript.conversationLoadIssue.detail} diff --git a/pyrit/executor/attack/core/attack_strategy.py b/pyrit/executor/attack/core/attack_strategy.py index 7d4ae48490..6d00400d09 100644 --- a/pyrit/executor/attack/core/attack_strategy.py +++ b/pyrit/executor/attack/core/attack_strategy.py @@ -669,6 +669,10 @@ async def _on_error_async( context = event_data.context if not error or not context: return + await self._save_error_result_async(context=context, error=error) + + async def _save_error_result_async(self, *, context: AttackStrategyContextT, error: BaseException) -> None: + """Preserve interrupted execution evidence through the normal attributed-result path.""" if not context._persist_attack_result: self._logger.error(f"Attack failed with {type(error).__name__}: {error}") return @@ -678,7 +682,7 @@ async def _on_error_async( retry_events = collector.events if collector else [] # Multi-turn contexts keep the active ID on their conversation session. - conversation_id = getattr(context, "conversation_id", None) + conversation_id = context.current_objective_conversation_id or getattr(context, "conversation_id", None) if not conversation_id: conversation_id = getattr(getattr(context, "session", None), "conversation_id", None) conversation_id = conversation_id or str(uuid.uuid4()) @@ -691,7 +695,12 @@ async def _on_error_async( outcome_reason=f"Exception: {type(error).__name__}: {str(error)}", atomic_attack_identifier=self._build_error_result_identifier(context=context), labels=context.memory_labels, - related_conversations=context.related_conversations, + related_conversations=context.related_conversations + | { + ConversationReference(conversation_id=observed, conversation_type=ConversationType.PRUNED) + for observed in context.objective_conversation_ids + if observed != conversation_id + }, error_message=str(error), error_type=type(error).__name__, error_traceback="".join(traceback.format_exception(type(error), error, error.__traceback__)), @@ -1090,21 +1099,49 @@ async def execute_with_context_async(self, *, context: AttackStrategyContextT) - [attack_error, persistence_error], ) from None raise + if context._persist_attack_result: + try: + await self._default_event_handler._persist_result_async(result=result) + except Exception: + await self._default_event_handler._confirm_result_persistence_async( + context=context, attack_result_id=result.attack_result_id + ) + raise + context._persisted_attack_result_id = result.attack_result_id + return result + except asyncio.CancelledError as cancellation: + if ( + context._persist_attack_result + and context.objective_conversation_ids + and context._persisted_attack_result_id is None + ): + + async def preserve_async() -> None: + if context.attack_result_id is not None: + await self._default_event_handler._confirm_result_persistence_async( + context=context, attack_result_id=context.attack_result_id + ) + if context._persisted_attack_result_id is None: + await self._default_event_handler._save_error_result_async(context=context, error=cancellation) + + publication = asyncio.create_task(preserve_async()) + while not publication.done(): + try: + await asyncio.shield(publication) + except asyncio.CancelledError: + continue + publication.result() + if context._error_result_persistence_error is not None: + self._logger.error( + "Could not preserve cancelled attack result %s.", + context.attack_result_id, + exc_info=context._error_result_persistence_error, + ) + raise finally: context._objective_target_conversation_lifecycle = None context._error_result_identifier_builder = None - if context._persist_attack_result: - try: - await self._default_event_handler._persist_result_async(result=result) - except Exception: - await self._default_event_handler._confirm_result_persistence_async( - context=context, attack_result_id=result.attack_result_id - ) - raise - context._persisted_attack_result_id = result.attack_result_id - return result - async def _resolve_operation_async(self, *, context: AttackStrategyContextT) -> None: """ Resolve the execution's saved Operation before setup, accepting deprecated Operation names. diff --git a/tests/unit/backend/test_scenario_resume.py b/tests/unit/backend/test_scenario_resume.py index ca0f9a3d75..432da08ca4 100644 --- a/tests/unit/backend/test_scenario_resume.py +++ b/tests/unit/backend/test_scenario_resume.py @@ -185,7 +185,7 @@ async def _shutdown_after_launch_async( @pytest.mark.parametrize("depth", [0, 1, 2]) -@pytest.mark.parametrize("cancel", [False]) +@pytest.mark.parametrize("cancel", [False, True]) async def test_real_scenario_live_retry_observation_and_terminal_ownership_async( *, resume_environment: tuple[ScenarioRunService, MockPromptTarget], depth: int, cancel: bool ) -> None: diff --git a/tests/unit/executor/attack/core/test_attack_executor.py b/tests/unit/executor/attack/core/test_attack_executor.py index 9b67c1076f..7953ec9f06 100644 --- a/tests/unit/executor/attack/core/test_attack_executor.py +++ b/tests/unit/executor/attack/core/test_attack_executor.py @@ -100,6 +100,85 @@ async def send_async(*, normalized_conversation: list[Message]) -> list[Message] release.set() await asyncio.gather(task, return_exceptions=True) assert executor.active_executions == () + stored = await CentralMemory.get_memory_instance().get_attack_results_async( + attack_result_ids=[observed.attack_result_id] + ) + assert len(stored) == 1 and stored[0].error_type == "CancelledError" + + +@pytest.mark.usefixtures("patch_central_database") +async def test_repeated_cancellation_keeps_execution_observable_until_publication_finishes() -> None: + entered, publication_started, release = asyncio.Event(), asyncio.Event(), asyncio.Event() + target = MockPromptTarget() + memory = CentralMemory.get_memory_instance() + executor = AttackExecutor() + attack = PromptSendingAttack(objective_target=target) + original_save = memory.add_attack_results_to_memory_async + + async def send_async(*, normalized_conversation: list[Message]) -> list[Message]: + entered.set() + await asyncio.Event().wait() + return [] + + async def save_async(*, attack_results: list[AttackResult]) -> None: + publication_started.set() + await release.wait() + await original_save(attack_results=attack_results) + + with ( + patch.object(target, "_send_prompt_to_target_async", side_effect=send_async), + patch.object(memory, "add_attack_results_to_memory_async", side_effect=save_async), + ): + task = asyncio.create_task(executor.execute_attack_async(attack=attack, objectives=["Preserve ownership"])) + try: + await wait_for_event_async(event=entered, tasks=[task]) + task.cancel() + await wait_for_event_async(event=publication_started, tasks=[task]) + task.cancel() + await asyncio.sleep(0) + assert not task.done() + assert len(executor.active_executions) == 1 + release.set() + with pytest.raises(asyncio.CancelledError): + await wait_for_completion_async(future=task) + finally: + release.set() + await asyncio.gather(task, return_exceptions=True) + assert executor.active_executions == () + + +@pytest.mark.usefixtures("patch_central_database") +@pytest.mark.parametrize("published", [False, True]) +async def test_cancel_during_result_publication_preserves_one_authoritative_result(published: bool) -> None: + entered = asyncio.Event() + memory = CentralMemory.get_memory_instance() + original_save = memory.add_attack_results_to_memory_async + executor = AttackExecutor() + attack = PromptSendingAttack(objective_target=MockPromptTarget()) + + async def save_async(*, attack_results: list[AttackResult]) -> None: + if attack_results[0].error_type == "CancelledError": + await original_save(attack_results=attack_results) + return + if published: + await original_save(attack_results=attack_results) + entered.set() + await asyncio.Event().wait() + + with patch.object(memory, "add_attack_results_to_memory_async", side_effect=save_async): + task = asyncio.create_task(executor.execute_attack_async(attack=attack, objectives=["Preserve the result"])) + try: + await wait_for_event_async(event=entered, tasks=[task]) + observed = executor.active_executions[0] + task.cancel() + with pytest.raises(asyncio.CancelledError): + await wait_for_completion_async(future=task) + finally: + await asyncio.gather(task, return_exceptions=True) + results = await memory.get_attack_results_async(attack_result_ids=[observed.attack_result_id]) + assert len(results) == 1 + assert results[0].error_type == (None if published else "CancelledError") + assert executor.active_executions == () class _ParameterBuildAbort(BaseException): From 1180149768e2935df6e768460c835be4dd3b86ea Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Thu, 29 Oct 2026 10:43:10 -0700 Subject: [PATCH 21/65] Keep cancellation metric policy unchanged while preparing provenance Remove the unapproved ERROR cancellation proposal from the active candidate. The separate proposal commit and cancellation-semantics.json remain available for joint review. Attribution-only preparation must preserve outcome, completion, denominator and retry semantics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- doc/gui/0_gui.md | 2 - ...narioConversationPage.transitions.test.tsx | 129 ------------------ .../Scenarios/ScenarioConversationPage.tsx | 19 +-- pyrit/executor/attack/core/attack_strategy.py | 63 ++------- tests/unit/backend/test_scenario_resume.py | 2 +- .../attack/core/test_attack_executor.py | 79 ----------- 6 files changed, 20 insertions(+), 274 deletions(-) delete mode 100644 frontend/src/components/Scenarios/ScenarioConversationPage.transitions.test.tsx diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index 7141f4d50e..7fa5e90999 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -81,8 +81,6 @@ The results page does not include a paired-outcome comparison. is read-only and refreshes saved messages; it has no send controls. **Cancel scan** stops remaining scan work and keeps saved results. Ordinary manual chats do not have scan cancellation controls. -After cancellation, saved messages remain available in the read-only chat. -Interrupted attempts are recorded as errors so Resume can retry them. **Rerun** opens the scenario launch page with saved settings filled in. It does not start work or change the original run. Review or edit the settings before diff --git a/frontend/src/components/Scenarios/ScenarioConversationPage.transitions.test.tsx b/frontend/src/components/Scenarios/ScenarioConversationPage.transitions.test.tsx deleted file mode 100644 index 55a35a94e3..0000000000 --- a/frontend/src/components/Scenarios/ScenarioConversationPage.transitions.test.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { act } from 'react' - -import { FluentProvider, webLightTheme } from '@fluentui/react-components' -import { render, screen, within } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { MemoryRouter, Route, Routes, useLocation } from 'react-router' - -import { scenariosApi } from '@/services/api' -import type { ConversationMessagesResponse, ScenarioRunProgress, ScenarioRunSummary } from '@/types' - -import ScenarioConversationPage from './ScenarioConversationPage' - -jest.mock('@/services/api', () => ({ - scenariosApi: { getRunProgress: jest.fn(), getRunConversation: jest.fn(), cancelRun: jest.fn() }, - attacksApi: { getMessages: jest.fn() }, -})) - -const RUN_ID = '123e4567-e89b-12d3-a456-426614174000' -const LIVE_PATH = `/scanner/history/${RUN_ID}/conversations/conversation` -const active = { - attack_result_id: 'attempt', conversation_id: 'conversation', atomic_attack_name: 'PromptSendingAttack', - objective: 'Review the response', started_at: '2026-01-01T00:00:00Z', seed_group_id: 'seed', seed_identifiers: [], -} -const transcript: ConversationMessagesResponse = { - conversation_id: 'conversation', target_response_status: null, - messages: [{ - turn_number: 0, role: 'user', created_at: '2026-01-01T00:00:00Z', - message_pieces: [{ - id: 'piece', original_value_data_type: 'text', converted_value_data_type: 'text', - converted_value: 'A saved prompt', response_error: 'none', scores: [], - }], - }], -} - -function progress(status: 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED'): ScenarioRunProgress { - const terminal = status !== 'IN_PROGRESS' - return { - run: { - scenario_result_id: RUN_ID, scenario_name: 'review.scan', scenario_version: 1, status, - created_at: '2026-01-01T00:00:00Z', - }, - summary: { - overall: { completed: terminal ? 1 : 0, planned: 1, succeeded: status === 'COMPLETED' ? 1 : 0, - success_percentage: null, errors: status === 'CANCELLED' ? 1 : 0, retries: 0 }, - techniques: [], seed_groups: [], atomic_groups: [], - }, - plan: { version: 1, atomic_groups: [], seed_groups: [{ - id: 'seed', objective_sha256: 'hash', objective: active.objective, prompts: [], - }] }, - results: terminal ? [{ - ...active, atomic_group_id: 'group', seed_group_id: 'seed', - outcome: status === 'CANCELLED' ? 'error' : 'success', execution_time_ms: 1000, - timestamp: '2026-01-01T00:00:01Z', total_retries: 0, retries: [], - error_type: status === 'CANCELLED' ? 'CancelledError' : undefined, - }] : [], - active_conversations: terminal ? [] : [active], - next_cursor: terminal ? 'finished' : null, has_more: false, plan_complete: true, - } -} - -function LocationTrace() { - const location = useLocation() - return {location.pathname}{location.search} -} - -function Wrapper() { - return - - } /> - Completed conversation} /> - - -} - -beforeEach(() => { - jest.clearAllMocks() - jest.useFakeTimers() - jest.mocked(scenariosApi.getRunProgress).mockResolvedValue(progress('IN_PROGRESS')) - jest.mocked(scenariosApi.getRunConversation).mockResolvedValue(transcript) -}) -afterEach(() => { jest.useRealTimers() }) - -it('retains the transcript, objective and stopped state after Cancel and a fresh reload using real hooks', async () => { - const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) - jest.mocked(scenariosApi.cancelRun).mockImplementation(async () => { - jest.mocked(scenariosApi.getRunProgress).mockResolvedValue(progress('CANCELLED')) - return { - ...progress('CANCELLED').run, updated_at: '2026-01-01T00:00:01Z', techniques_used: [], - total_attacks: 1, completed_attacks: 1, objective_achieved_rate: 0, - failed_attacks: [], attack_retries: [], total_retries: 0, labels: {}, - } satisfies ScenarioRunSummary - }) - const view = render() - expect(await screen.findByText('A saved prompt')).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'Cancel scan' })) - await user.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Cancel scan' })) - expect(await screen.findByText('Stopped before finishing')).toBeInTheDocument() - expect(screen.getByText(active.objective)).toBeInTheDocument() - expect(screen.getByText('A saved prompt')).toBeInTheDocument() - expect(screen.queryByText('This conversation is not part of this scan.')).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'Cancel scan' })).not.toBeInTheDocument() - view.unmount() - render() - expect(await screen.findByText('Stopped before finishing')).toBeInTheDocument() - expect(await screen.findByText('A saved prompt')).toBeInTheDocument() - expect(screen.getByText(active.objective)).toBeInTheDocument() - expect(screen.getByLabelText('Location')).toHaveTextContent(LIVE_PATH) - expect(scenariosApi.getRunConversation).toHaveBeenLastCalledWith(RUN_ID, 'conversation') -}) - -it('moves a live conversation to the canonical completed chat after the progress poll', async () => { - render() - await screen.findByText('A saved prompt') - jest.mocked(scenariosApi.getRunProgress).mockResolvedValue(progress('COMPLETED')) - await act(async () => { await jest.advanceTimersByTimeAsync(2500) }) - expect(await screen.findByRole('heading', { name: 'Completed conversation' })).toBeInTheDocument() - expect(screen.getByLabelText('Location')).toHaveTextContent( - `/attack/chat/attempt/conversations/conversation?scenarioResultId=${RUN_ID}`, - ) -}) - -it('does not expose cancellation when the scoped transcript API rejects ownership', async () => { - jest.mocked(scenariosApi.getRunConversation).mockRejectedValue({ - isAxiosError: true, response: { status: 404, data: { detail: 'Not owned by this scan' } }, - }) - render() - expect(await screen.findByText(/Not owned by this scan/)).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'Cancel scan' })).not.toBeInTheDocument() -}) diff --git a/frontend/src/components/Scenarios/ScenarioConversationPage.tsx b/frontend/src/components/Scenarios/ScenarioConversationPage.tsx index 4ca1e91b5b..0a54f2cab5 100644 --- a/frontend/src/components/Scenarios/ScenarioConversationPage.tsx +++ b/frontend/src/components/Scenarios/ScenarioConversationPage.tsx @@ -9,7 +9,6 @@ import { useConversationMessages } from '@/hooks/useConversationMessages' import { SCENARIO_RUN_POLL_INTERVAL_MS, useScenarioRunProgress } from '@/hooks/useScenarioRunProgress' import { scenariosApi } from '@/services/api' import { toApiError } from '@/services/errors' -import type { ScenarioActiveConversation } from '@/types' import { attackConversationRoutePath, routerPathParamValue, scenarioRunRoutePath } from '@/utils/routeParams' import { isTerminalRunState } from '@/utils/scenarioRunProgress' @@ -35,8 +34,7 @@ function ScanConversation({ scenarioResultId, conversationId }: ScanConversation const { state, applyRunSummary, retry } = useScenarioRunProgress(scenarioResultId) const active = state.activeConversations.find((conversation) => conversation.conversation_id === conversationId) const saved = state.results.find((attempt) => attempt.conversation_id === conversationId || attempt.related_conversation_ids?.includes(conversationId)) - const [lastActive, setLastActive] = useState(null) - const attackId = active?.attack_result_id ?? saved?.attack_result_id ?? lastActive?.attack_result_id ?? null + const attackId = active?.attack_result_id ?? saved?.attack_result_id ?? null const running = Boolean(state.run && !isTerminalRunState(state.run.status)) const readConversation = useCallback((_attackId: string, selectedId: string) => scenariosApi.getRunConversation(scenarioResultId, selectedId), [scenarioResultId]) @@ -49,15 +47,11 @@ function ScanConversation({ scenarioResultId, conversationId }: ScanConversation const [cancelling, setCancelling] = useState(false) const [cancelError, setCancelError] = useState(null) const verified = transcript.loadedConversationId === conversationId && !transcript.conversationLoadIssue - if (active && verified && lastActive !== active) setLastActive(active) - const display = active ?? lastActive - const stopped = saved ? saved.error_type === 'CancelledError' - : state.run?.status === 'CANCELLED' && lastActive !== null useEffect(() => { - if (!active && saved && verified && !stopped) { + if (!active && saved && verified) { navigate(attackConversationRoutePath(saved.attack_result_id, conversationId, scenarioResultId), { replace: true }) } - }, [active, saved, verified, stopped, navigate, conversationId, scenarioResultId]) + }, [active, saved, verified, navigate, conversationId, scenarioResultId]) async function cancel(): Promise { setCancelling(true) @@ -78,15 +72,14 @@ function ScanConversation({ scenarioResultId, conversationId }: ScanConversation Back to scan
-
{display?.atomic_attack_name ?? saved?.atomic_attack_name ?? 'Conversation'} - {stopped ? 'Stopped before finishing' : active ? 'Executing' : 'Saved conversation'} +
{active?.atomic_attack_name ?? saved?.atomic_attack_name ?? 'Conversation'} + {active ? 'Executing' : 'Saved conversation'}
Read-only · {state.run?.scenario_registry_name ?? state.run?.scenario_name}
{running && active && verified && }
- {display?.objective ?? state.plan?.seed_groups.find((seed) => seed.id === saved?.seed_group_id)?.objective} - {stopped && Saved messages remain available.} + {active?.objective ?? state.plan?.seed_groups.find((seed) => seed.id === saved?.seed_group_id)?.objective} {state.error && {state.error} } {transcript.conversationLoadIssue && {transcript.conversationLoadIssue.detail} diff --git a/pyrit/executor/attack/core/attack_strategy.py b/pyrit/executor/attack/core/attack_strategy.py index 6d00400d09..7d4ae48490 100644 --- a/pyrit/executor/attack/core/attack_strategy.py +++ b/pyrit/executor/attack/core/attack_strategy.py @@ -669,10 +669,6 @@ async def _on_error_async( context = event_data.context if not error or not context: return - await self._save_error_result_async(context=context, error=error) - - async def _save_error_result_async(self, *, context: AttackStrategyContextT, error: BaseException) -> None: - """Preserve interrupted execution evidence through the normal attributed-result path.""" if not context._persist_attack_result: self._logger.error(f"Attack failed with {type(error).__name__}: {error}") return @@ -682,7 +678,7 @@ async def _save_error_result_async(self, *, context: AttackStrategyContextT, err retry_events = collector.events if collector else [] # Multi-turn contexts keep the active ID on their conversation session. - conversation_id = context.current_objective_conversation_id or getattr(context, "conversation_id", None) + conversation_id = getattr(context, "conversation_id", None) if not conversation_id: conversation_id = getattr(getattr(context, "session", None), "conversation_id", None) conversation_id = conversation_id or str(uuid.uuid4()) @@ -695,12 +691,7 @@ async def _save_error_result_async(self, *, context: AttackStrategyContextT, err outcome_reason=f"Exception: {type(error).__name__}: {str(error)}", atomic_attack_identifier=self._build_error_result_identifier(context=context), labels=context.memory_labels, - related_conversations=context.related_conversations - | { - ConversationReference(conversation_id=observed, conversation_type=ConversationType.PRUNED) - for observed in context.objective_conversation_ids - if observed != conversation_id - }, + related_conversations=context.related_conversations, error_message=str(error), error_type=type(error).__name__, error_traceback="".join(traceback.format_exception(type(error), error, error.__traceback__)), @@ -1099,49 +1090,21 @@ async def execute_with_context_async(self, *, context: AttackStrategyContextT) - [attack_error, persistence_error], ) from None raise - if context._persist_attack_result: - try: - await self._default_event_handler._persist_result_async(result=result) - except Exception: - await self._default_event_handler._confirm_result_persistence_async( - context=context, attack_result_id=result.attack_result_id - ) - raise - context._persisted_attack_result_id = result.attack_result_id - return result - except asyncio.CancelledError as cancellation: - if ( - context._persist_attack_result - and context.objective_conversation_ids - and context._persisted_attack_result_id is None - ): - - async def preserve_async() -> None: - if context.attack_result_id is not None: - await self._default_event_handler._confirm_result_persistence_async( - context=context, attack_result_id=context.attack_result_id - ) - if context._persisted_attack_result_id is None: - await self._default_event_handler._save_error_result_async(context=context, error=cancellation) - - publication = asyncio.create_task(preserve_async()) - while not publication.done(): - try: - await asyncio.shield(publication) - except asyncio.CancelledError: - continue - publication.result() - if context._error_result_persistence_error is not None: - self._logger.error( - "Could not preserve cancelled attack result %s.", - context.attack_result_id, - exc_info=context._error_result_persistence_error, - ) - raise finally: context._objective_target_conversation_lifecycle = None context._error_result_identifier_builder = None + if context._persist_attack_result: + try: + await self._default_event_handler._persist_result_async(result=result) + except Exception: + await self._default_event_handler._confirm_result_persistence_async( + context=context, attack_result_id=result.attack_result_id + ) + raise + context._persisted_attack_result_id = result.attack_result_id + return result + async def _resolve_operation_async(self, *, context: AttackStrategyContextT) -> None: """ Resolve the execution's saved Operation before setup, accepting deprecated Operation names. diff --git a/tests/unit/backend/test_scenario_resume.py b/tests/unit/backend/test_scenario_resume.py index 432da08ca4..ca0f9a3d75 100644 --- a/tests/unit/backend/test_scenario_resume.py +++ b/tests/unit/backend/test_scenario_resume.py @@ -185,7 +185,7 @@ async def _shutdown_after_launch_async( @pytest.mark.parametrize("depth", [0, 1, 2]) -@pytest.mark.parametrize("cancel", [False, True]) +@pytest.mark.parametrize("cancel", [False]) async def test_real_scenario_live_retry_observation_and_terminal_ownership_async( *, resume_environment: tuple[ScenarioRunService, MockPromptTarget], depth: int, cancel: bool ) -> None: diff --git a/tests/unit/executor/attack/core/test_attack_executor.py b/tests/unit/executor/attack/core/test_attack_executor.py index 7953ec9f06..9b67c1076f 100644 --- a/tests/unit/executor/attack/core/test_attack_executor.py +++ b/tests/unit/executor/attack/core/test_attack_executor.py @@ -100,85 +100,6 @@ async def send_async(*, normalized_conversation: list[Message]) -> list[Message] release.set() await asyncio.gather(task, return_exceptions=True) assert executor.active_executions == () - stored = await CentralMemory.get_memory_instance().get_attack_results_async( - attack_result_ids=[observed.attack_result_id] - ) - assert len(stored) == 1 and stored[0].error_type == "CancelledError" - - -@pytest.mark.usefixtures("patch_central_database") -async def test_repeated_cancellation_keeps_execution_observable_until_publication_finishes() -> None: - entered, publication_started, release = asyncio.Event(), asyncio.Event(), asyncio.Event() - target = MockPromptTarget() - memory = CentralMemory.get_memory_instance() - executor = AttackExecutor() - attack = PromptSendingAttack(objective_target=target) - original_save = memory.add_attack_results_to_memory_async - - async def send_async(*, normalized_conversation: list[Message]) -> list[Message]: - entered.set() - await asyncio.Event().wait() - return [] - - async def save_async(*, attack_results: list[AttackResult]) -> None: - publication_started.set() - await release.wait() - await original_save(attack_results=attack_results) - - with ( - patch.object(target, "_send_prompt_to_target_async", side_effect=send_async), - patch.object(memory, "add_attack_results_to_memory_async", side_effect=save_async), - ): - task = asyncio.create_task(executor.execute_attack_async(attack=attack, objectives=["Preserve ownership"])) - try: - await wait_for_event_async(event=entered, tasks=[task]) - task.cancel() - await wait_for_event_async(event=publication_started, tasks=[task]) - task.cancel() - await asyncio.sleep(0) - assert not task.done() - assert len(executor.active_executions) == 1 - release.set() - with pytest.raises(asyncio.CancelledError): - await wait_for_completion_async(future=task) - finally: - release.set() - await asyncio.gather(task, return_exceptions=True) - assert executor.active_executions == () - - -@pytest.mark.usefixtures("patch_central_database") -@pytest.mark.parametrize("published", [False, True]) -async def test_cancel_during_result_publication_preserves_one_authoritative_result(published: bool) -> None: - entered = asyncio.Event() - memory = CentralMemory.get_memory_instance() - original_save = memory.add_attack_results_to_memory_async - executor = AttackExecutor() - attack = PromptSendingAttack(objective_target=MockPromptTarget()) - - async def save_async(*, attack_results: list[AttackResult]) -> None: - if attack_results[0].error_type == "CancelledError": - await original_save(attack_results=attack_results) - return - if published: - await original_save(attack_results=attack_results) - entered.set() - await asyncio.Event().wait() - - with patch.object(memory, "add_attack_results_to_memory_async", side_effect=save_async): - task = asyncio.create_task(executor.execute_attack_async(attack=attack, objectives=["Preserve the result"])) - try: - await wait_for_event_async(event=entered, tasks=[task]) - observed = executor.active_executions[0] - task.cancel() - with pytest.raises(asyncio.CancelledError): - await wait_for_completion_async(future=task) - finally: - await asyncio.gather(task, return_exceptions=True) - results = await memory.get_attack_results_async(attack_result_ids=[observed.attack_result_id]) - assert len(results) == 1 - assert results[0].error_type == (None if published else "CancelledError") - assert executor.active_executions == () class _ParameterBuildAbort(BaseException): From de1c016b706b10b28a67d738bb8a38f27222f4b1 Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Thu, 29 Oct 2026 11:04:55 -0700 Subject: [PATCH 22/65] Preserve interrupted scan conversations without adding outcomes Prepare attribution-only option A: SDK workers collect observed conversation links and finalize existing ScenarioResult metadata after draining. Scoped reads verify Conversation.attack_result_id. Restore read-only stopped-chat reload without creating AttackResults or changing completion, outcome, denominator or retry semantics. Publication remains held for joint review and the corrected predecessor. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- doc/gui/0_gui.md | 2 + ...narioConversationPage.transitions.test.tsx | 127 ++++++++++++ .../Scenarios/ScenarioConversationPage.tsx | 15 +- frontend/src/types/index.ts | 1 + frontend/src/utils/scenarioRunProgress.ts | 4 + .../backend/services/scenario_run_service.py | 23 ++- pyrit/executor/attack/core/attack_executor.py | 27 ++- pyrit/models/__init__.py | 2 + pyrit/models/scenario_progress.py | 4 +- pyrit/scenario/core/scenario.py | 70 ++++++- tests/unit/backend/test_scenario_resume.py | 182 +++++++++++++++++- .../unit/backend/test_scenario_run_service.py | 1 + 12 files changed, 447 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/Scenarios/ScenarioConversationPage.transitions.test.tsx diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index 7fa5e90999..2f95747fce 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -81,6 +81,8 @@ The results page does not include a paired-outcome comparison. is read-only and refreshes saved messages; it has no send controls. **Cancel scan** stops remaining scan work and keeps saved results. Ordinary manual chats do not have scan cancellation controls. +After cancellation, saved messages remain available in the read-only chat. +Saving these conversation links does not add attack outcomes or change success rates. **Rerun** opens the scenario launch page with saved settings filled in. It does not start work or change the original run. Review or edit the settings before diff --git a/frontend/src/components/Scenarios/ScenarioConversationPage.transitions.test.tsx b/frontend/src/components/Scenarios/ScenarioConversationPage.transitions.test.tsx new file mode 100644 index 0000000000..52c2bf0f7e --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioConversationPage.transitions.test.tsx @@ -0,0 +1,127 @@ +import { act } from 'react' + +import { FluentProvider, webLightTheme } from '@fluentui/react-components' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router' + +import { scenariosApi } from '@/services/api' +import type { ConversationMessagesResponse, ScenarioRunProgress, ScenarioRunSummary } from '@/types' + +import ScenarioConversationPage from './ScenarioConversationPage' + +jest.mock('@/services/api', () => ({ + scenariosApi: { getRunProgress: jest.fn(), getRunConversation: jest.fn(), cancelRun: jest.fn() }, + attacksApi: { getMessages: jest.fn() }, +})) + +const RUN_ID = '123e4567-e89b-12d3-a456-426614174000' +const LIVE_PATH = `/scanner/history/${RUN_ID}/conversations/conversation` +const active = { + attack_result_id: 'attempt', conversation_id: 'conversation', atomic_attack_name: 'PromptSendingAttack', + objective: 'Review the response', started_at: '2026-01-01T00:00:00Z', seed_group_id: 'seed', seed_identifiers: [], +} +const transcript: ConversationMessagesResponse = { + conversation_id: 'conversation', target_response_status: null, + messages: [{ + turn_number: 0, role: 'user', created_at: '2026-01-01T00:00:00Z', + message_pieces: [{ + id: 'piece', original_value_data_type: 'text', converted_value_data_type: 'text', + converted_value: 'A saved prompt', response_error: 'none', scores: [], + }], + }], +} + +function progress(status: 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED'): ScenarioRunProgress { + const completed = status === 'COMPLETED' + return { + run: { + scenario_result_id: RUN_ID, scenario_name: 'review.scan', scenario_version: 1, status, + created_at: '2026-01-01T00:00:00Z', + }, + summary: { + overall: { completed: completed ? 1 : 0, planned: 1, succeeded: completed ? 1 : 0, + success_percentage: null, errors: 0, retries: 0 }, + techniques: [], seed_groups: [], atomic_groups: [], + }, + plan: { version: 1, atomic_groups: [], seed_groups: [{ + id: 'seed', objective_sha256: 'hash', objective: active.objective, prompts: [], + }] }, + results: completed ? [{ + ...active, atomic_group_id: 'group', seed_group_id: 'seed', outcome: 'success', + execution_time_ms: 1000, timestamp: '2026-01-01T00:00:01Z', total_retries: 0, retries: [], + }] : [], + active_conversations: status === 'IN_PROGRESS' ? [active] : [], + recorded_conversations: status === 'CANCELLED' ? [active] : [], + next_cursor: completed ? 'finished' : null, has_more: false, plan_complete: true, + } +} + +function LocationTrace() { + const location = useLocation() + return {location.pathname}{location.search} +} + +function Wrapper() { + return + + } /> + Completed conversation} /> + + +} + +beforeEach(() => { + jest.clearAllMocks() + jest.useFakeTimers() + jest.mocked(scenariosApi.getRunProgress).mockResolvedValue(progress('IN_PROGRESS')) + jest.mocked(scenariosApi.getRunConversation).mockResolvedValue(transcript) +}) +afterEach(() => { jest.useRealTimers() }) + +it('retains the transcript and stopped state after Cancel and reload without adding an outcome', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) + jest.mocked(scenariosApi.cancelRun).mockImplementation(async () => { + jest.mocked(scenariosApi.getRunProgress).mockResolvedValue(progress('CANCELLED')) + return { + ...progress('CANCELLED').run, updated_at: '2026-01-01T00:00:01Z', techniques_used: [], + total_attacks: 1, completed_attacks: 0, objective_achieved_rate: 0, + failed_attacks: [], attack_retries: [], total_retries: 0, labels: {}, + } satisfies ScenarioRunSummary + }) + const view = render() + expect(await screen.findByText('A saved prompt')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Cancel scan' })) + await user.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Cancel scan' })) + expect(await screen.findByText('Stopped before finishing')).toBeInTheDocument() + expect(screen.getByText(active.objective)).toBeInTheDocument() + expect(screen.getByText('A saved prompt')).toBeInTheDocument() + expect(screen.queryByText('This conversation is not part of this scan.')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Cancel scan' })).not.toBeInTheDocument() + view.unmount() + render() + expect(await screen.findByText('Stopped before finishing')).toBeInTheDocument() + expect(await screen.findByText('A saved prompt')).toBeInTheDocument() + expect(screen.getByLabelText('Location')).toHaveTextContent(LIVE_PATH) + expect(scenariosApi.getRunConversation).toHaveBeenLastCalledWith(RUN_ID, 'conversation') +}) + +it('moves a live conversation to the canonical completed chat after the progress poll', async () => { + render() + await screen.findByText('A saved prompt') + jest.mocked(scenariosApi.getRunProgress).mockResolvedValue(progress('COMPLETED')) + await act(async () => { await jest.advanceTimersByTimeAsync(2500) }) + expect(await screen.findByRole('heading', { name: 'Completed conversation' })).toBeInTheDocument() + expect(screen.getByLabelText('Location')).toHaveTextContent( + `/attack/chat/attempt/conversations/conversation?scenarioResultId=${RUN_ID}`, + ) +}) + +it('does not expose cancellation when the scoped transcript API rejects ownership', async () => { + jest.mocked(scenariosApi.getRunConversation).mockRejectedValue({ + isAxiosError: true, response: { status: 404, data: { detail: 'Not owned by this scan' } }, + }) + render() + expect(await screen.findByText(/Not owned by this scan/)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Cancel scan' })).not.toBeInTheDocument() +}) diff --git a/frontend/src/components/Scenarios/ScenarioConversationPage.tsx b/frontend/src/components/Scenarios/ScenarioConversationPage.tsx index 0a54f2cab5..1d9c0e8772 100644 --- a/frontend/src/components/Scenarios/ScenarioConversationPage.tsx +++ b/frontend/src/components/Scenarios/ScenarioConversationPage.tsx @@ -9,6 +9,7 @@ import { useConversationMessages } from '@/hooks/useConversationMessages' import { SCENARIO_RUN_POLL_INTERVAL_MS, useScenarioRunProgress } from '@/hooks/useScenarioRunProgress' import { scenariosApi } from '@/services/api' import { toApiError } from '@/services/errors' +import type { ScenarioActiveConversation } from '@/types' import { attackConversationRoutePath, routerPathParamValue, scenarioRunRoutePath } from '@/utils/routeParams' import { isTerminalRunState } from '@/utils/scenarioRunProgress' @@ -34,7 +35,9 @@ function ScanConversation({ scenarioResultId, conversationId }: ScanConversation const { state, applyRunSummary, retry } = useScenarioRunProgress(scenarioResultId) const active = state.activeConversations.find((conversation) => conversation.conversation_id === conversationId) const saved = state.results.find((attempt) => attempt.conversation_id === conversationId || attempt.related_conversation_ids?.includes(conversationId)) - const attackId = active?.attack_result_id ?? saved?.attack_result_id ?? null + const recorded = state.recordedConversations.find((conversation) => conversation.conversation_id === conversationId) + const [lastActive, setLastActive] = useState(null) + const attackId = active?.attack_result_id ?? saved?.attack_result_id ?? recorded?.attack_result_id ?? lastActive?.attack_result_id ?? null const running = Boolean(state.run && !isTerminalRunState(state.run.status)) const readConversation = useCallback((_attackId: string, selectedId: string) => scenariosApi.getRunConversation(scenarioResultId, selectedId), [scenarioResultId]) @@ -47,6 +50,9 @@ function ScanConversation({ scenarioResultId, conversationId }: ScanConversation const [cancelling, setCancelling] = useState(false) const [cancelError, setCancelError] = useState(null) const verified = transcript.loadedConversationId === conversationId && !transcript.conversationLoadIssue + if (active && verified && lastActive !== active) setLastActive(active) + const display = active ?? recorded ?? lastActive + const stopped = !active && !saved && (recorded !== undefined || (lastActive !== null && state.run?.status === 'CANCELLED')) useEffect(() => { if (!active && saved && verified) { navigate(attackConversationRoutePath(saved.attack_result_id, conversationId, scenarioResultId), { replace: true }) @@ -72,14 +78,15 @@ function ScanConversation({ scenarioResultId, conversationId }: ScanConversation Back to scan
-
{active?.atomic_attack_name ?? saved?.atomic_attack_name ?? 'Conversation'} - {active ? 'Executing' : 'Saved conversation'} +
{display?.atomic_attack_name ?? saved?.atomic_attack_name ?? 'Conversation'} + {stopped ? 'Stopped before finishing' : active ? 'Executing' : 'Saved conversation'}
Read-only · {state.run?.scenario_registry_name ?? state.run?.scenario_name}
{running && active && verified && }
- {active?.objective ?? state.plan?.seed_groups.find((seed) => seed.id === saved?.seed_group_id)?.objective} + {display?.objective ?? state.plan?.seed_groups.find((seed) => seed.id === saved?.seed_group_id)?.objective} + {stopped && Saved messages remain available.} {state.error && {state.error} } {transcript.conversationLoadIssue && {transcript.conversationLoadIssue.detail} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 696cbc5222..1660b8f51b 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -2160,6 +2160,7 @@ export interface ScenarioRunProgress { has_more: boolean plan_complete: boolean active_conversations?: ScenarioActiveConversation[] + recorded_conversations?: ScenarioActiveConversation[] } export interface RuntimeReadiness { ready: boolean diff --git a/frontend/src/utils/scenarioRunProgress.ts b/frontend/src/utils/scenarioRunProgress.ts index 75e7e670e6..ff3c59fa24 100644 --- a/frontend/src/utils/scenarioRunProgress.ts +++ b/frontend/src/utils/scenarioRunProgress.ts @@ -23,6 +23,7 @@ export interface ScenarioRunProgressState { readonly stale: boolean readonly overloadSummaries: ScenarioOverloadSummary[] readonly activeConversations: ScenarioActiveConversation[] + readonly recordedConversations: ScenarioActiveConversation[] } export type ScenarioRunProgressAction = @@ -44,6 +45,7 @@ export const INITIAL_SCENARIO_RUN_PROGRESS_STATE: ScenarioRunProgressState = { stale: false, overloadSummaries: [], activeConversations: [], + recordedConversations: [], } export function isTerminalRunState(status: ScenarioRunState): boolean { @@ -103,6 +105,7 @@ export function scenarioRunProgressReducer( stale: false, overloadSummaries: state.overloadSummaries, activeConversations: isTerminalRunState(action.run.status) ? [] : state.activeConversations, + recordedConversations: state.recordedConversations, } } @@ -132,6 +135,7 @@ export function scenarioRunProgressReducer( stale: false, overloadSummaries, activeConversations: action.page.active_conversations ?? [], + recordedConversations: action.page.recorded_conversations ?? [], } } diff --git a/pyrit/backend/services/scenario_run_service.py b/pyrit/backend/services/scenario_run_service.py index 4fe79d5e8a..df3766a932 100644 --- a/pyrit/backend/services/scenario_run_service.py +++ b/pyrit/backend/services/scenario_run_service.py @@ -60,6 +60,7 @@ ScenarioHistoryRunRecord, ) from pyrit.models import ( + SCENARIO_CONVERSATIONS_METADATA_KEY, SCENARIO_RUN_PLAN_METADATA_KEY, SCENARIO_RUN_STARTED_AT_METADATA_KEY, AtomicAttackIdentifier, @@ -127,6 +128,7 @@ _SCHEDULER_METADATA_KEY, _LAUNCH_REQUEST_METADATA_KEY, _LAUNCH_REFERENCES_METADATA_KEY, + SCENARIO_CONVERSATIONS_METADATA_KEY, SCENARIO_RUN_PLAN_METADATA_KEY, SCENARIO_RUN_STARTED_AT_METADATA_KEY, } @@ -439,7 +441,12 @@ async def get_conversation_async( # Publication can finish between the stored read and the active snapshot. results = await self._memory.get_attack_results_async(attack_result_ids=[attack_id]) if not results: - return None + header = await self._memory.get_scenario_result_header_async(scenario_result_id=scenario_result_id) + if header is None or not any( + record.attack_result_id == attack_id and record.conversation_id == conversation_id + for record in self._recorded_conversations(header) + ): + return None if results: if ( results[0].attribution_parent_id != scenario_result_id @@ -456,6 +463,19 @@ async def get_conversation_async( target_response_status=None, ) + @staticmethod + def _recorded_conversations(header: ScenarioResult) -> list[ScenarioActiveConversation]: + """ + Read SDK-owned provenance, not caller labels or a runtime ownership ledger. + + Returns: + list[ScenarioActiveConversation]: Recorded objective-conversation links. + """ + records = header.metadata.get(SCENARIO_CONVERSATIONS_METADATA_KEY, []) + if not isinstance(records, list): + raise ValueError("Stored scenario conversation provenance is invalid.") + return [ScenarioActiveConversation.model_validate(record) for record in records] + @contextlib.asynccontextmanager async def _reserve_resume_request_async(self, scenario_result_id: str | None) -> AsyncGenerator[None, None]: """Reject overlapping resume requests even if the first attempt fails immediately.""" @@ -2631,6 +2651,7 @@ async def get_run_progress_from_storage_async( queue_position=queue_position, active_scenario_result_id=active_scenario_result_id, ) + progress.recorded_conversations = self._recorded_conversations(header_result) if header_result.operation_id is not None: operations = await self._memory.get_operations_async(operation_ids=[header_result.operation_id]) progress.run.operation_name = operations[0].name if operations else None diff --git a/pyrit/executor/attack/core/attack_executor.py b/pyrit/executor/attack/core/attack_executor.py index 717eb77d6c..db8a7d88c4 100644 --- a/pyrit/executor/attack/core/attack_executor.py +++ b/pyrit/executor/attack/core/attack_executor.py @@ -9,7 +9,9 @@ import asyncio import copy -from collections.abc import Iterator, Mapping, Sequence +from collections.abc import Callable, Generator, Iterator, Mapping, Sequence +from contextlib import contextmanager +from contextvars import ContextVar from dataclasses import dataclass, field from datetime import UTC, datetime from typing import ( @@ -34,6 +36,21 @@ from pyrit.score import TrueFalseScorer AttackResultT = TypeVar("AttackResultT") +_interrupted_observer: ContextVar[Callable[[AttackContext[Any], datetime], None] | None] = ContextVar( + "interrupted_attack_observer", default=None +) + + +@contextmanager +def _observe_interrupted_executions( + observer: Callable[[AttackContext[Any], datetime], None], +) -> Generator[None, None, None]: + """Collect interrupted execution provenance in the caller's SDK scope.""" + token = _interrupted_observer.set(observer) + try: + yield + finally: + _interrupted_observer.reset(token) @dataclass(frozen=True, slots=True) @@ -499,9 +516,15 @@ async def run_one_async(index: int, params: AttackParameters) -> AttackStrategyR if task_attribution is not None: context._attribution = task_attribution context_key = id(context) - self._active_contexts[context_key] = (context, datetime.now(UTC), attack) + started_at = datetime.now(UTC) + self._active_contexts[context_key] = (context, started_at, attack) try: return await attack.execute_with_context_async(context=context) + except asyncio.CancelledError: + observer = _interrupted_observer.get() + if observer is not None and context.attack_result_id is not None: + observer(context, started_at) + raise finally: persisted_result_ids[index] = context._persisted_attack_result_id self._active_contexts.pop(context_key, None) diff --git a/pyrit/models/__init__.py b/pyrit/models/__init__.py index d2a13c08e1..ebaebdd636 100644 --- a/pyrit/models/__init__.py +++ b/pyrit/models/__init__.py @@ -176,6 +176,7 @@ ScenarioOrchestrationParentSummary, ) from pyrit.models.scenario_progress import ( + SCENARIO_CONVERSATIONS_METADATA_KEY, SCENARIO_RUN_PLAN_METADATA_KEY, SCENARIO_RUN_PLAN_VERSION, SCENARIO_RUN_STARTED_AT_METADATA_KEY, @@ -550,6 +551,7 @@ "ScenarioTechniqueSummary": "pyrit.models.catalog", "ScenarioResult": "pyrit.models.results.scenario_result", "ScenarioRunState": "pyrit.models.results.scenario_result", + "SCENARIO_CONVERSATIONS_METADATA_KEY": "pyrit.models.scenario_progress", "SCENARIO_RUN_PLAN_METADATA_KEY": "pyrit.models.scenario_progress", "SCENARIO_RUN_PLAN_VERSION": "pyrit.models.scenario_progress", "SCENARIO_RUN_STARTED_AT_METADATA_KEY": "pyrit.models.scenario_progress", diff --git a/pyrit/models/scenario_progress.py b/pyrit/models/scenario_progress.py index 20520dbfef..ed36364e69 100644 --- a/pyrit/models/scenario_progress.py +++ b/pyrit/models/scenario_progress.py @@ -23,6 +23,7 @@ from pyrit.models.score.score import ScoreStatus SCENARIO_RUN_PLAN_METADATA_KEY = "run_plan" +SCENARIO_CONVERSATIONS_METADATA_KEY = "scenario_conversations_v1" SCENARIO_RUN_STARTED_AT_METADATA_KEY = "started_at" SCENARIO_RUN_PLAN_VERSION = 1 @@ -361,7 +362,7 @@ class ScenarioProgressSummary(BaseModel): class ScenarioActiveConversation(BaseModel): - """A saved objective conversation owned by a currently executing attempt.""" + """A saved objective conversation and the scan execution that produced it.""" attack_result_id: str conversation_id: str @@ -384,6 +385,7 @@ class ScenarioRunProgress(BaseModel): has_more: bool = False plan_complete: bool active_conversations: list[ScenarioActiveConversation] = Field(default_factory=list) + recorded_conversations: list[ScenarioActiveConversation] = Field(default_factory=list) class ScenarioQueueEntry(BaseModel): diff --git a/pyrit/scenario/core/scenario.py b/pyrit/scenario/core/scenario.py index c51ceaab68..6c8281c9c2 100644 --- a/pyrit/scenario/core/scenario.py +++ b/pyrit/scenario/core/scenario.py @@ -14,6 +14,7 @@ import uuid from abc import ABC, abstractmethod from collections.abc import Mapping, Sequence +from datetime import datetime from enum import Enum from pathlib import Path from typing import TYPE_CHECKING, Any, ClassVar, cast, final @@ -24,10 +25,12 @@ from pyrit.common.utils import to_sha256 from pyrit.exceptions import ScenarioPartialFailureException from pyrit.executor.attack import AttackExecutor, AttackExecutorResult -from pyrit.executor.attack.core.attack_executor import ActiveAttackExecution +from pyrit.executor.attack.core.attack_executor import ActiveAttackExecution, _observe_interrupted_executions from pyrit.executor.attack.core.attack_preparation import AttackPreparationFailure +from pyrit.executor.attack.core.attack_strategy import AttackContext from pyrit.memory import CentralMemory from pyrit.models import ( + SCENARIO_CONVERSATIONS_METADATA_KEY, SCENARIO_RUN_PLAN_METADATA_KEY, AllAvailableDatasetSize, AttackOutcome, @@ -1948,6 +1951,21 @@ async def _execute_atomic_attacks_parallel_async( # the narrowed value in a local so the type checker can verify all uses below. assert self._max_concurrency is not None, "Scenario not initialized; call initialize_async first." max_concurrency: int = self._max_concurrency + interrupted: dict[tuple[str, str], dict[str, Any]] = {} + + def record_interrupted(context: AttackContext[Any], started_at: datetime) -> None: + attribution = context._attribution + if attribution is None or attribution.parent_id != scenario_result_id or context.attack_result_id is None: + return + for conversation_id in context.objective_conversation_ids: + interrupted[(context.attack_result_id, conversation_id)] = { + "attack_result_id": context.attack_result_id, + "conversation_id": conversation_id, + "atomic_attack_name": attribution.parent_collection, + "seed_group_id": attribution.seed_group_id, + "objective": context.objective, + "started_at": started_at.isoformat(), + } shared_executor = AttackExecutor(max_concurrency=max_concurrency) self._active_executor = shared_executor @@ -2015,7 +2033,9 @@ async def worker_async() -> None: # without losing parallelism for the common case where remaining_attacks fits in # the budget. worker_count = min(max_concurrency, len(remaining_attacks)) - workers = [asyncio.create_task(worker_async()) for _ in range(worker_count)] + # Workers and nested executors inherit this observation scope, not new scheduler state. + with _observe_interrupted_executions(record_interrupted): + workers = [asyncio.create_task(worker_async()) for _ in range(worker_count)] group = asyncio.gather(*workers) try: # The supervisor owns cancellation; gather must not forward it ahead of this handler. @@ -2034,6 +2054,25 @@ async def worker_async() -> None: except asyncio.CancelledError as cancellation: caller_cancellation = cancellation drain.result() + if interrupted: + publication = asyncio.create_task( + self._persist_conversation_provenance_async( + scenario_result_id=scenario_result_id, records=interrupted + ) + ) + while not publication.done(): + try: + await asyncio.shield(publication) + except asyncio.CancelledError as cancellation: + caller_cancellation = cancellation + except Exception: + break + try: + publication.result() + except Exception: + logger.exception( + "Could not preserve interrupted conversations for scenario %s.", scenario_result_id + ) if caller_cancellation is not None: raise caller_cancellation from None raise @@ -2052,6 +2091,33 @@ async def worker_async() -> None: ) raise final_error + async def _persist_conversation_provenance_async( + self, *, scenario_result_id: str, records: Mapping[tuple[str, str], dict[str, Any]] + ) -> None: + """ + Persist SDK-observed conversation links without producing attack outcomes. + + Raises: + ValueError: If the scenario or its existing provenance is invalid. + """ + stored = await self._memory.get_scenario_result_header_async(scenario_result_id=scenario_result_id) + if stored is None: + raise ValueError(f"Scenario result {scenario_result_id} is unavailable for conversation provenance.") + previous = stored.metadata.get(SCENARIO_CONVERSATIONS_METADATA_KEY, []) + if not isinstance(previous, list) or any( + not isinstance(record, dict) + or not isinstance(record.get("attack_result_id"), str) + or not isinstance(record.get("conversation_id"), str) + for record in previous + ): + raise ValueError("Stored scenario conversation provenance is invalid.") + merged = {(record["attack_result_id"], record["conversation_id"]): record for record in previous} + merged.update(records) + await self._memory.update_scenario_metadata_fields_async( + scenario_result_id=scenario_result_id, + fields={SCENARIO_CONVERSATIONS_METADATA_KEY: list(merged.values())}, + ) + def _collect_errors_from_outcomes( self, *, diff --git a/tests/unit/backend/test_scenario_resume.py b/tests/unit/backend/test_scenario_resume.py index ca0f9a3d75..fdf4a97265 100644 --- a/tests/unit/backend/test_scenario_resume.py +++ b/tests/unit/backend/test_scenario_resume.py @@ -37,6 +37,7 @@ from pyrit.executor.attack.single_turn import many_shot_jailbreak from pyrit.memory import CentralMemory, SQLiteMemory from pyrit.models import ( + SCENARIO_CONVERSATIONS_METADATA_KEY, SCENARIO_RUN_PLAN_METADATA_KEY, AtomicAttackIdentifier, AttackOutcome, @@ -185,7 +186,7 @@ async def _shutdown_after_launch_async( @pytest.mark.parametrize("depth", [0, 1, 2]) -@pytest.mark.parametrize("cancel", [False]) +@pytest.mark.parametrize("cancel", [False, True]) async def test_real_scenario_live_retry_observation_and_terminal_ownership_async( *, resume_environment: tuple[ScenarioRunService, MockPromptTarget], depth: int, cancel: bool ) -> None: @@ -266,6 +267,8 @@ async def build_async(self: Scenario, *, context: ScenarioContext) -> list[Atomi ) is None ) + before_counts = progress.summary.overall.model_dump(mode="json") + before_results = await service._memory.get_attack_results_async(scenario_result_id=run_id) if cancel: await service.cancel_run_async(scenario_result_id=run_id) else: @@ -274,6 +277,29 @@ async def build_async(self: Scenario, *, context: ScenarioContext) -> list[Atomi final = await service.get_run_progress_async(scenario_result_id=run_id, since=None, limit=500) assert final is not None and not final.active_conversations assert final.run.status == (ScenarioRunState.CANCELLED if cancel else ScenarioRunState.COMPLETED) + if cancel: + assert final.summary.overall.model_dump(mode="json") == before_counts + assert await service._memory.get_attack_results_async(scenario_result_id=run_id) == before_results + assert {record.conversation_id for record in final.recorded_conversations} == set(conversations) + assert ( + await service.get_conversation_async( + scenario_result_id=str(uuid.uuid4()), conversation_id=conversations[0] + ) + is None + ) + stored_header = await service._memory.get_scenario_result_header_async(scenario_result_id=run_id) + mismatched = stored_header.model_copy(deep=True) + mismatched.metadata[SCENARIO_CONVERSATIONS_METADATA_KEY] = [ + {**record, "attack_result_id": str(uuid.uuid4())} + for record in mismatched.metadata[SCENARIO_CONVERSATIONS_METADATA_KEY] + ] + with patch.object(service._memory, "get_scenario_result_header_async", return_value=mismatched): + assert ( + await service.get_conversation_async( + scenario_result_id=run_id, conversation_id=conversations[0] + ) + is None + ) assert ( await service.get_conversation_async(scenario_result_id=run_id, conversation_id=conversations[-1]) is not None @@ -282,10 +308,164 @@ async def build_async(self: Scenario, *, context: ScenarioContext) -> list[Atomi await service.get_conversation_async(scenario_result_id=run_id, conversation_id=conversations[0]) is not None ) + if cancel: + resumed = await service.resume_run_async(scenario_result_id=run_id) + await _wait_for_run_completion_async(service=service, scenario_result_id=run_id) + after_resume = await service.get_run_progress_async(scenario_result_id=run_id, since=None, limit=500) + assert resumed.scenario_result_id == run_id + assert after_resume is not None and after_resume.run.status == ScenarioRunState.COMPLETED + assert after_resume.summary.overall.completed == 1 + assert after_resume.summary.overall.errors == 0 + assert after_resume.summary.overall.retries == 0 + assert ( + await service.get_conversation_async(scenario_result_id=run_id, conversation_id=conversations[0]) + is not None + ) finally: release.set() +async def test_cancelled_provenance_preserves_mixed_outcome_denominators_and_resume_async( + resume_environment: tuple[ScenarioRunService, MockPromptTarget], +) -> None: + service, target = resume_environment + memory = service._memory + await memory.add_seeds_to_memory_async( + seeds=[SeedObjective(value=f"{index} goal", dataset_name="l6-metric-regression") for index in range(4)], + added_by="offline-test", + ) + entered, release = asyncio.Event(), asyncio.Event() + blocked = False + + async def send_async(*, normalized_conversation: list[Message]) -> list[Message]: + nonlocal blocked + message = normalized_conversation[-1] + if message.get_value().startswith("2") and not blocked: + blocked = True + entered.set() + await release.wait() + return [ + MessagePiece( + role="assistant", + original_value="MATCH" if not message.get_value().startswith("1") else "Denied", + conversation_id=message.get_piece().conversation_id, + ).to_message() + ] + + async def build_async(self: Scenario, *, context: ScenarioContext) -> list[AtomicAttack]: + return [ + AtomicAttack( + atomic_attack_name="metrics", + seed_groups=sorted(context.seed_groups, key=lambda group: group.objective.value), + attack_technique=AttackTechnique( + attack=PromptSendingAttack( + objective_target=context.objective_target, + attack_scoring_config=AttackScoringConfig(objective_scorer=SubStringScorer(substring="MATCH")), + ) + ), + memory_labels=context.memory_labels, + ) + ] + + with ( + patch.object(_OfflineResumeScenario, "_build_atomic_attacks_async", new=build_async), + patch.object(target, "_send_prompt_to_target_async", side_effect=send_async), + ): + response = await service.start_run_async( + request=RunScenarioRequest( + scenario_name=_SCENARIO_NAME, + target_name=_TARGET_NAME, + include_baseline=False, + max_concurrency=1, + max_retries=0, + datasets=ScenarioDatasetRequest( + sources=[DatasetSourceInput(name="l6-metric-regression", fetch="never")], max_total="all" + ), + ) + ) + run_id = response.scenario_result_id + try: + await asyncio.wait_for(entered.wait(), TEST_WATCHDOG_SECONDS) + before = await service.get_run_progress_async(scenario_result_id=run_id, since=None, limit=500) + assert before is not None + assert before.summary.overall.completed == 2 and before.summary.overall.succeeded == 1 + assert before.summary.overall.outcomes.success_rate_decided == 0.5 + assert before.summary.overall.outcomes.success_rate_all == 0.5 + await service.cancel_run_async(scenario_result_id=run_id) + after = await service.get_run_progress_async(scenario_result_id=run_id, since=None, limit=500) + assert after is not None and after.summary.overall == before.summary.overall + assert len(await memory.get_attack_results_async(scenario_result_id=run_id)) == 2 + assert after.recorded_conversations + await service.resume_run_async(scenario_result_id=run_id) + await _wait_for_run_completion_async(service=service, scenario_result_id=run_id) + resumed = await service.get_run_progress_async(scenario_result_id=run_id, since=None, limit=500) + assert resumed is not None and resumed.summary.overall.completed == 4 + assert resumed.summary.overall.succeeded == 3 + assert resumed.summary.overall.errors == 0 and resumed.summary.overall.retries == 0 + assert len(await memory.get_attack_results_async(scenario_result_id=run_id)) == 4 + finally: + release.set() + + +@pytest.mark.parametrize("publication_fails", [False, True]) +async def test_provenance_publication_drains_repeated_cancel_before_scheduler_handoff_async( + *, + resume_environment: tuple[ScenarioRunService, MockPromptTarget], + publication_fails: bool, + caplog: pytest.LogCaptureFixture, +) -> None: + service, target = resume_environment + entered, publication_started, release = asyncio.Event(), asyncio.Event(), asyncio.Event() + original_update = service._memory.update_scenario_metadata_fields_async + + async def send_async(*, normalized_conversation: list[Message]) -> list[Message]: + entered.set() + await asyncio.Event().wait() + return [] + + async def update_async(*, scenario_result_id: str, fields: Mapping[str, Any]) -> None: + if SCENARIO_CONVERSATIONS_METADATA_KEY in fields: + publication_started.set() + await release.wait() + if publication_fails: + raise RuntimeError("Provenance storage is unavailable") + await original_update(scenario_result_id=scenario_result_id, fields=fields) + + with patch.object(target, "_send_prompt_to_target_async", side_effect=send_async): + response = await service.start_run_async( + request=RunScenarioRequest( + scenario_name=_SCENARIO_NAME, + target_name=_TARGET_NAME, + include_baseline=False, + max_concurrency=1, + max_retries=1, + ) + ) + run_id = response.scenario_result_id + await asyncio.wait_for(entered.wait(), TEST_WATCHDOG_SECONDS) + with patch.object(service._memory, "update_scenario_metadata_fields_async", side_effect=update_async): + first = asyncio.create_task(service.cancel_run_async(scenario_result_id=run_id)) + second = None + try: + await asyncio.wait_for(publication_started.wait(), TEST_WATCHDOG_SECONDS) + second = asyncio.create_task(service.cancel_run_async(scenario_result_id=run_id)) + await asyncio.sleep(0) + assert not first.done() and not second.done() + assert service.get_queue_snapshot().active.scenario_result_id == run_id + release.set() + await wait_for_completion_async(future=asyncio.gather(first, second)) + progress = await service.get_run_progress_async(scenario_result_id=run_id, since=None, limit=500) + assert progress is not None and progress.run.status == ScenarioRunState.CANCELLED + assert bool(progress.recorded_conversations) is not publication_fails + assert progress.summary.overall.completed == 0 and progress.summary.overall.errors == 0 + assert progress.summary.overall.retries == 0 + if publication_fails: + assert "Could not preserve interrupted conversations" in caplog.text + finally: + release.set() + await asyncio.gather(first, *([second] if second is not None else []), return_exceptions=True) + + async def test_real_matrix_launch_keeps_heartbeat_alive_during_disk_reads_async( *, patch_central_database: object, sqlite_instance: SQLiteMemory ) -> None: diff --git a/tests/unit/backend/test_scenario_run_service.py b/tests/unit/backend/test_scenario_run_service.py index 356fea456d..4bf0494e05 100644 --- a/tests/unit/backend/test_scenario_run_service.py +++ b/tests/unit/backend/test_scenario_run_service.py @@ -464,6 +464,7 @@ def _make_db_scenario_result( class_name=scenario_name, class_module="tests", version=1, pyrit_version="0.10.0" ) sr.scenario_description = "A stored scenario description." + sr.metadata = {} sr.get_techniques_used.return_value = [] sr.attack_results = attack_results or {} sr.number_tries = 1 From d7dc88c86a5551f2c3051b80ecb5bf09ed0dcbe8 Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Thu, 29 Oct 2026 11:13:21 -0700 Subject: [PATCH 23/65] Keep interrupted conversations reachable from scan results Expose saved provenance links without inventing result rows or rates, and describe a cancelled scan truthfully rather than reporting zero attack errors as a failure. Attribution-only candidate remains local and unpublished. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Scenarios/ScenarioRunPage.test.tsx | 22 +++++++++++++++++++ .../components/Scenarios/ScenarioRunPage.tsx | 22 +++++++++++++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx b/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx index 825a1174c0..b1d7ad7483 100644 --- a/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx +++ b/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx @@ -197,6 +197,21 @@ describe('ScenarioRunPage', () => { expect(screen.queryByRole('heading', { name: 'Paired outcomes' })).not.toBeInTheDocument() }) + it('keeps interrupted saved conversations reachable without inventing an outcome row', () => { + state = makeState({ + results: [], activeConversations: [], + recordedConversations: [{ + attack_result_id: 'interrupted', conversation_id: 'saved', atomic_attack_name: 'direct', + objective: 'First objective', started_at: RUN.started_at, seed_identifiers: [], + }], + }) + render() + expect(screen.getByRole('link', { name: 'Open saved conversation' })).toHaveAttribute( + 'href', `/scanner/history/${RUN_ID}/conversations/saved`, + ) + expect(screen.queryByRole('row', { name: /View details for/ })).not.toBeInTheDocument() + }) + it('keeps elapsed-clock updates local instead of rebuilding result groups every second', async () => { jest.useFakeTimers() jest.setSystemTime(new Date('2026-01-01T00:00:02Z')) @@ -304,6 +319,13 @@ describe('ScenarioRunPage', () => { expect(screen.getByText('Detailed execution diagnostics')).toBeVisible() }) + it('does not describe a cancellation with no error outcomes as an attack error', () => { + state = makeState({ run: { ...RUN, status: 'CANCELLED', error: 'Run was cancelled by user' } }) + render() + expect(screen.getByText('Scan cancelled.')).toBeInTheDocument() + expect(screen.queryByText(/0 attacks ended with an error/)).not.toBeInTheDocument() + }) + it.each(['FAILED', 'CANCELLED'] as const)('resumes the original %s run rather than launching a new one', async (status) => { const user = userEvent.setup() state = makeState({ run: { ...RUN, status } }) diff --git a/frontend/src/components/Scenarios/ScenarioRunPage.tsx b/frontend/src/components/Scenarios/ScenarioRunPage.tsx index 3c976aa111..730087ad90 100644 --- a/frontend/src/components/Scenarios/ScenarioRunPage.tsx +++ b/frontend/src/components/Scenarios/ScenarioRunPage.tsx @@ -17,7 +17,7 @@ import { useScenarioRunProgress } from '@/hooks/useScenarioRunProgress' import { useScenarioRunResume } from '@/hooks/useScenarioRunResume' import { scenariosApi } from '@/services/api' import { toApiError } from '@/services/errors' -import type { ScenarioComponentIdentity, ScenarioProgressResult, ScenarioRunPlanSeedGroup, ScenarioRunState } from '@/types' +import type { ScenarioActiveConversation, ScenarioComponentIdentity, ScenarioProgressResult, ScenarioRunPlanSeedGroup, ScenarioRunState } from '@/types' import { attackConversationRoutePath, routerPathParamValue, scenarioConversationRoutePath, scenarioRerunRoutePath, scenarioRunAttackRoutePath, scenarioRunRoutePath } from '@/utils/routeParams' import { isTerminalRunState } from '@/utils/scenarioRunProgress' @@ -95,6 +95,14 @@ function ScenarioRunContent({ scenarioResultId, attackResultId }: ScenarioRunCon } return new Map([...identities].map(([id, seeds]) => [id, seedDatasetNames(seeds)])) }, [state.results, state.activeConversations]) + const savedConversations = useMemo(() => { + const knownResults = new Set(state.results.map((attempt) => attempt.attack_result_id)) + const recorded = new Map() + for (const conversation of state.recordedConversations) { + if (!knownResults.has(conversation.attack_result_id)) recorded.set(conversation.attack_result_id, conversation) + } + return [...recorded.values()] + }, [state.results, state.recordedConversations]) const attemptsByGroup = useMemo(() => { const groups = new Map() for (const attempt of [...state.results].reverse()) { @@ -220,8 +228,10 @@ function ScenarioRunContent({ scenarioResultId, attackResultId }: ScenarioRunCon
{Object.entries(run.labels ?? {}).map(([key, value]) => {key}: {value})}
- {(run.status === 'FAILED' || run.error || overall.errors > 0) && + {(run.status === 'FAILED' || run.error || overall.errors > 0) && {run.status === 'FAILED' ? 'Scan failed. Resume to continue remaining attacks.' + : run.status === 'CANCELLED' ? 'Scan cancelled.' : `${overall.errors} attack${overall.errors === 1 ? '' : 's'} ended with an error.`} {firstError && } {run.error && @@ -266,6 +276,14 @@ function ScenarioRunContent({ scenarioResultId, attackResultId }: ScenarioRunCon ))} } + {savedConversations.length > 0 &&
+ Saved conversations + {savedConversations.map((conversation) =>
+ {conversation.atomic_attack_name} + {conversation.objective} + Open saved conversation +
)} +
}
Atomic attack groups{groups.length} groups
{groups.map((group) => { From c5af4c08f0d42bcc1afef4bc1c047b4a38a36694 Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Thu, 29 Oct 2026 14:44:30 -0700 Subject: [PATCH 24/65] TEST: Stabilize scan launch and queue browser coverage Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- frontend/e2e/scenario-history.spec.ts | 141 +++++++++++++++----------- 1 file changed, 80 insertions(+), 61 deletions(-) diff --git a/frontend/e2e/scenario-history.spec.ts b/frontend/e2e/scenario-history.spec.ts index 006f6733ed..4b2722598d 100644 --- a/frontend/e2e/scenario-history.spec.ts +++ b/frontend/e2e/scenario-history.spec.ts @@ -227,6 +227,11 @@ const progressAttempt = { timestamp: "2026-08-07T00:00:30Z", total_retries: 1, retries: [], + seed_identifiers: [{ + component_name: "SeedObjective", + parameters: { dataset_name: "harmbench", value: plan.seed_groups[0].objective }, + children: {}, + }], }; interface ScenarioMocks { @@ -235,7 +240,10 @@ interface ScenarioMocks { getProgressRequests: () => number; } -async function mockScenarioAPIs(page: Page): Promise { +async function mockScenarioAPIs( + page: Page, + options: { readonly isRunCompleted?: () => boolean } = {}, +): Promise { let progressRequests = 0; let launchRequest: Record | undefined; const estimateRequests: Record[] = []; @@ -367,7 +375,7 @@ async function mockScenarioAPIs(page: Page): Promise { await page.route(new RegExp(`/api/scenarios/runs/${RUN_ID}/progress(?:\\?|$)`), async (route) => { progressRequests += 1; const isInitialPage = !new URL(route.request().url()).searchParams.has("since"); - const completed = progressRequests > 1; + const completed = options.isRunCompleted?.() ?? progressRequests > 1; await route.fulfill({ status: 200, contentType: "application/json", @@ -582,7 +590,8 @@ test.describe("Scenario catalog, history, and live run routing", () => { }); test("sends one exact configuration to estimate and launch, then completes live polling", async ({ page }) => { - const mocks = await mockScenarioAPIs(page); + let completed = false; + const mocks = await mockScenarioAPIs(page, { isRunCompleted: () => completed }); await page.goto(`/scanner/launch/${SCENARIO_NAME}`); const form = page.getByRole("form", { name: "Scenario run configuration" }); @@ -632,11 +641,15 @@ test.describe("Scenario catalog, history, and live run routing", () => { expect(mocks.getLaunchRequest()?.techniques).not.toContain("context_compliance"); await expect(page).toHaveURL(`/scanner/history/${RUN_ID}`); - await expect(page.getByTestId("run-state-badge")).toHaveText("In progress"); + await expect(page.getByTestId("run-state-badge")).toHaveText("Executing"); await expect(page.getByText("gpt-4o").first()).toBeVisible(); await expect(page.getByText("harmbench")).toBeVisible(); + await expect(page.getByRole("progressbar", { name: "Scan progress", exact: true })).toBeVisible(); + const liveProgressRequests = mocks.getProgressRequests(); + completed = true; await expect(page.getByTestId("run-state-badge")).toHaveText("Completed", { timeout: 6_000 }); - expect(mocks.getProgressRequests()).toBeGreaterThanOrEqual(2); + await expect(page.getByRole("progressbar", { name: "Scan progress", exact: true })).toHaveCount(0); + expect(mocks.getProgressRequests()).toBeGreaterThan(liveProgressRequests); }); test("stacks the configured run preview without overflow and keeps touch controls usable", async ({ page }) => { @@ -744,28 +757,16 @@ test.describe("Scenario catalog, history, and live run routing", () => { await mockScenarioAPIs(page); let progressRequests = 0; let queueRequests = 0; - let releasePositionOne: () => void = () => {}; - let releaseInProgress: () => void = () => {}; - let releaseCompleted: () => void = () => {}; - const positionOneGate = new Promise((resolve) => { - releasePositionOne = resolve; - }); - const inProgressGate = new Promise((resolve) => { - releaseInProgress = resolve; - }); - const completedGate = new Promise((resolve) => { - releaseCompleted = resolve; - }); + let phase: "position-two" | "position-one" | "running" | "completed" = "position-two"; + const revisions = { "position-two": 1, "position-one": 2, running: 3, completed: 4 }; await page.route(new RegExp(`/api/scenarios/runs/${QUEUED_RUN_ID}/progress(?:\\?|$)`), async (route) => { progressRequests += 1; - const gate = [undefined, positionOneGate, inProgressGate, completedGate][progressRequests - 1]; - if (gate) { - await gate; - } - const statuses = ["QUEUED", "QUEUED", "IN_PROGRESS", "COMPLETED"] as const; - const status = statuses[Math.min(progressRequests - 1, statuses.length - 1)]; - const queuePosition = status === "QUEUED" ? (progressRequests === 1 ? 2 : 1) : null; + const currentPhase = phase; + const status = currentPhase === "completed" ? "COMPLETED" + : currentPhase === "running" ? "IN_PROGRESS" : "QUEUED"; + const queuePosition = status === "QUEUED" ? (currentPhase === "position-two" ? 2 : 1) : null; + const isInitialPage = !new URL(route.request().url()).searchParams.has("since"); await route.fulfill({ status: 200, contentType: "application/json", @@ -786,7 +787,7 @@ test.describe("Scenario catalog, history, and live run routing", () => { latest_timestamp: "2026-08-07T00:00:45Z", }], }, - plan: progressRequests === 1 ? plan : null, + plan: isInitialPage ? plan : null, summary: { overall: { completed: status === "COMPLETED" ? 1 : 0, @@ -802,10 +803,10 @@ test.describe("Scenario catalog, history, and live run routing", () => { atomic_groups: [], unattributed_attempts: 0, }, - reset: progressRequests === 1, + reset: isInitialPage, active_atomic_group_ids: status === "IN_PROGRESS" ? ["group-1"] : [], results: status === "COMPLETED" ? [progressAttempt] : [], - next_cursor: `queue-progress-${progressRequests}`, + next_cursor: `queue-progress-${revisions[currentPhase]}`, has_more: false, plan_complete: true, }), @@ -814,6 +815,8 @@ test.describe("Scenario catalog, history, and live run routing", () => { await page.route(/\/api\/scenarios\/runs\/queue(?:\?|$)/, async (route) => { queueRequests += 1; + const currentPhase = phase; + const waiting = currentPhase === "position-two" || currentPhase === "position-one"; const active = { scenario_result_id: ACTIVE_RUN_ID, scenario_name: "Active scenario", @@ -828,7 +831,7 @@ test.describe("Scenario catalog, history, and live run routing", () => { scenario_name: "Jailbreak", scenario_registry_name: SCENARIO_NAME, state: "QUEUED", - position: queueRequests === 1 ? 2 : 1, + position: currentPhase === "position-two" ? 2 : 1, created_at: runSummary.created_at, enqueued_at: runSummary.created_at, }; @@ -836,12 +839,12 @@ test.describe("Scenario catalog, history, and live run routing", () => { status: 200, contentType: "application/json", body: JSON.stringify({ - revision: queueRequests, - snapshot_at: `2026-08-07T00:00:0${Math.min(queueRequests, 9)}Z`, - active: queueRequests < 3 ? active : queueRequests === 3 ? { ...queued, state: "IN_PROGRESS", position: null } : null, - queued: queueRequests < 3 + revision: revisions[currentPhase], + snapshot_at: `2026-08-07T00:00:0${revisions[currentPhase]}Z`, + active: waiting ? active : currentPhase === "running" ? { ...queued, state: "IN_PROGRESS", position: null } : null, + queued: waiting ? [ - ...(queueRequests === 1 ? [{ ...queued, scenario_result_id: RUN_ID, position: 1 }] : []), + ...(currentPhase === "position-two" ? [{ ...queued, scenario_result_id: RUN_ID, position: 1 }] : []), queued, ] : [], @@ -851,34 +854,50 @@ test.describe("Scenario catalog, history, and live run routing", () => { await page.goto(`/scanner/history/${QUEUED_RUN_ID}`); - try { - await expect(page.getByTestId("run-state-badge")).toHaveText("Queued"); - await expect(page.getByTestId("queued-run-progress")).toContainText("Position 2"); - await expect(page.getByTestId("queued-run-progress")).not.toContainText("%"); - await expect(page.getByRole("link", { name: new RegExp(ACTIVE_RUN_ID) })).toHaveAttribute( - "href", - `/scanner/history/${ACTIVE_RUN_ID}`, - ); - const warning = page.getByTestId("scenario-overload-warning"); - await expect(warning).toContainText("Objective target"); - await expect(warning).toContainText("2 × HTTP 429/503"); - await expect(warning).toContainText("without adaptive throttling"); - await page.setViewportSize({ width: 390, height: 844 }); - expect((await page.getByRole("button", { name: "Cancel run" }).boundingBox())?.height).toBeGreaterThanOrEqual(44); - - releasePositionOne(); - await expect(page.getByTestId("queued-run-progress")).toContainText("Position 1"); - releaseInProgress(); - await expect(page.getByTestId("run-state-badge")).toHaveText("In progress"); - releaseCompleted(); - await expect(page.getByTestId("run-state-badge")).toHaveText("Completed"); - expect(progressRequests).toBe(4); - - expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); - } finally { - releasePositionOne(); - releaseInProgress(); - releaseCompleted(); + await expect(page.getByTestId("run-state-badge")).toHaveText("Queued"); + const progress = page.getByRole("region", { name: "Scan progress", exact: true }); + await expect(progress).toContainText("Position 2"); + await expect(progress).not.toContainText("%"); + await expect(page.getByTestId("scenario-queue")).toHaveCount(0); + const warning = page.getByLabel("Scan notices", { exact: true }); + await expect(warning).toContainText("Service overload detected."); + await warning.getByRole("button", { name: "Overload details" }).click(); + await expect(warning).toContainText("objective target: 2 (HTTP 429, 503)"); + const client = await page.context().newCDPSession(page); + await client.send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 }); + await page.setViewportSize({ width: 390, height: 844 }); + expect((await page.getByRole("button", { name: "Cancel", exact: true }).boundingBox())?.height).toBeGreaterThanOrEqual(44); + + await page.getByRole("link", { name: "View queue", exact: true }).click(); + await expect(page).toHaveURL("/scanner/queue"); + const queue = page.getByRole("list", { name: "Scenario queue order", exact: true }); + await expect(queue.getByRole("listitem")).toHaveCount(3); + for (const [index, runId] of [ACTIVE_RUN_ID, RUN_ID, QUEUED_RUN_ID].entries()) { + await expect(queue.getByRole("link").nth(index)).toHaveAttribute("href", `/scanner/history/${runId}`); } + const ownQueueEntry = queue.getByRole("listitem").filter({ has: page.getByRole("link", { name: new RegExp(QUEUED_RUN_ID) }) }); + await expect(ownQueueEntry).toContainText("Position 2"); + const initialQueueRequests = queueRequests; + phase = "position-one"; + await expect(ownQueueEntry).toContainText("Position 1"); + await expect(queue.getByRole("listitem")).toHaveCount(2); + expect(queueRequests).toBeGreaterThan(initialQueueRequests); + phase = "running"; + await expect(queue.getByRole("listitem")).toHaveCount(1); + await expect(ownQueueEntry).toContainText("Active"); + await expect(queue.getByRole("link", { name: new RegExp(ACTIVE_RUN_ID) })).toHaveCount(0); + await ownQueueEntry.getByRole("link").click(); + await expect(page).toHaveURL(`/scanner/history/${QUEUED_RUN_ID}`); + await expect(page.getByTestId("run-state-badge")).toHaveText("Executing"); + await expect(progress).not.toContainText("Queued"); + const liveProgressRequests = progressRequests; + phase = "completed"; + await expect(page.getByTestId("run-state-badge")).toHaveText("Completed"); + await expect(progress).toHaveCount(0); + expect(progressRequests).toBeGreaterThan(liveProgressRequests); + await page.goBack(); + await expect(page).toHaveURL("/scanner/queue"); + await expect(page.getByText("No active or queued scenarios.", { exact: true })).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); }); }); From ce32f0194c151303b021caf73a073f884340676d Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Thu, 29 Oct 2026 16:02:13 -0700 Subject: [PATCH 25/65] FIX: Include saved scan conversations in history lookup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pyrit/memory/_scenario_history.py | 56 ++++++- tests/unit/backend/test_scenario_resume.py | 16 ++ .../test_interface_scenario_history.py | 149 ++++++++++++++++++ 3 files changed, 219 insertions(+), 2 deletions(-) diff --git a/pyrit/memory/_scenario_history.py b/pyrit/memory/_scenario_history.py index 0bbd7331df..f275d7a4f9 100644 --- a/pyrit/memory/_scenario_history.py +++ b/pyrit/memory/_scenario_history.py @@ -10,13 +10,17 @@ from datetime import datetime from typing import TYPE_CHECKING, Any -from sqlalchemy import and_, or_, select +from sqlalchemy import String, and_, case, cast, or_, select +from pyrit.memory.analytics_sql import JsonArrayItems, JsonArrayValue, JsonScalar, UuidHex from pyrit.memory.memory_models import AttackResultEntry, ConversationEntry, FindingEvidenceEntry, ScenarioResultEntry +from pyrit.models import SCENARIO_CONVERSATIONS_METADATA_KEY if TYPE_CHECKING: from collections.abc import Mapping, Sequence + from sqlalchemy.sql import ColumnElement + from pyrit.memory.memory_interface import ( MemoryInterface, ScenarioHistoryKeysetCursor, @@ -90,7 +94,13 @@ def get_page( AttackResultEntry.id.in_(finding_attacks), ), ) - conditions.append(or_(ScenarioResultEntry.id == reference_id, matching_attack.exists())) + conditions.append( + or_( + ScenarioResultEntry.id == reference_id, + matching_attack.exists(), + self._recorded_conversation_match(reference_id), + ) + ) if operation_ids: conditions.append(ScenarioResultEntry.operation_id.in_(operation_ids)) effective_names = sorted({name.strip() for name in scenario_names or [] if name.strip()}) @@ -186,6 +196,48 @@ def get_page( ] return records, len(rows) > limit + @staticmethod + def _recorded_conversation_match(reference_id: uuid.UUID) -> ColumnElement[bool]: + """ + Match verified SDK provenance when no result row has been persisted. + + Returns: + ColumnElement[bool]: A correlated match against the saved conversation's owner. + """ + records = JsonArrayItems( + JsonArrayValue(ScenarioResultEntry.scenario_metadata, f"$.{SCENARIO_CONVERSATIONS_METADATA_KEY}") + ).table_valued("value", "type") + document = case((cast(records.c.type, String(8)).in_(("object", "5")), records.c.value), else_="{}") + verified_link = ( + select(records.c.value) + .where( + JsonScalar(document, "$.conversation_id") == ConversationEntry.conversation_id, + UuidHex(JsonScalar(document, "$.attack_result_id")) + == UuidHex(cast(ConversationEntry.attack_result_id, String(36))), + ) + .correlate(ScenarioResultEntry, ConversationEntry) + .exists() + ) + persisted_result = ( + select(AttackResultEntry.id) + .where(AttackResultEntry.id == ConversationEntry.attack_result_id) + .correlate(ConversationEntry) + .exists() + ) + return ( + select(ConversationEntry.conversation_id) + .where( + or_( + ConversationEntry.conversation_id == str(reference_id), + ConversationEntry.attack_result_id == reference_id, + ), + ~persisted_result, + verified_link, + ) + .correlate(ScenarioResultEntry) + .exists() + ) + def _parse_scenario_started_at(*, raw_value: Any) -> datetime | None: """ diff --git a/tests/unit/backend/test_scenario_resume.py b/tests/unit/backend/test_scenario_resume.py index fdf4a97265..ee782fb067 100644 --- a/tests/unit/backend/test_scenario_resume.py +++ b/tests/unit/backend/test_scenario_resume.py @@ -281,6 +281,12 @@ async def build_async(self: Scenario, *, context: ScenarioContext) -> list[Atomi assert final.summary.overall.model_dump(mode="json") == before_counts assert await service._memory.get_attack_results_async(scenario_result_id=run_id) == before_results assert {record.conversation_id for record in final.recorded_conversations} == set(conversations) + interrupted_references = {active.attack_result_id, *conversations} + for reference_id in interrupted_references: + history = await service.list_runs_async(reference_id=uuid.UUID(reference_id)) + assert [item.scenario_result_id for item in history.items] == [run_id] + after_lookup = await service.get_run_progress_async(scenario_result_id=run_id, since=None, limit=500) + assert after_lookup is not None and after_lookup.summary.overall == final.summary.overall assert ( await service.get_conversation_async( scenario_result_id=str(uuid.uuid4()), conversation_id=conversations[0] @@ -317,6 +323,16 @@ async def build_async(self: Scenario, *, context: ScenarioContext) -> list[Atomi assert after_resume.summary.overall.completed == 1 assert after_resume.summary.overall.errors == 0 assert after_resume.summary.overall.retries == 0 + completed_results = await service._memory.get_attack_results_async(scenario_result_id=run_id) + completed = completed_results[0] + for reference_id in interrupted_references | {completed.attack_result_id, completed.conversation_id}: + history = await service.list_runs_async(reference_id=uuid.UUID(reference_id)) + assert [item.scenario_result_id for item in history.items] == [run_id] + assert history.items[0].completed_attacks == after_resume.summary.overall.completed + assert history.items[0].error_attacks == after_resume.summary.overall.errors + assert history.items[0].total_retries == after_resume.summary.overall.retries + after_lookup = await service.get_run_progress_async(scenario_result_id=run_id, since=None, limit=500) + assert after_lookup is not None and after_lookup.summary.overall == after_resume.summary.overall assert ( await service.get_conversation_async(scenario_result_id=run_id, conversation_id=conversations[0]) is not None diff --git a/tests/unit/memory/memory_interface/test_interface_scenario_history.py b/tests/unit/memory/memory_interface/test_interface_scenario_history.py index 859213a8fd..9cccdfcd27 100644 --- a/tests/unit/memory/memory_interface/test_interface_scenario_history.py +++ b/tests/unit/memory/memory_interface/test_interface_scenario_history.py @@ -11,12 +11,15 @@ import pytest from sqlalchemy import and_, select, text +from sqlalchemy.dialects import mssql, sqlite from unit.mocks import get_mock_target_identifier, make_scenario_result, run_memory_session_async from pyrit.common.utils import to_sha256 from pyrit.memory import MemoryInterface, ScenarioHistoryKeysetCursor, SQLiteMemory +from pyrit.memory._scenario_history import _ScenarioHistoryQueries from pyrit.memory.memory_models import AttackResultEntry, ConversationEntry, ScenarioResultEntry from pyrit.models import ( + SCENARIO_CONVERSATIONS_METADATA_KEY, SCENARIO_RUN_PLAN_METADATA_KEY, AttackOutcome, AttackResult, @@ -24,6 +27,7 @@ Finding, FindingEvidence, Operation, + ScenarioActiveConversation, ScenarioRunPlan, ScenarioRunPlanAtomicGroup, ScenarioRunPlanSeedGroup, @@ -230,6 +234,151 @@ async def test_history_id_lookup_matches_stored_runs_before_pagination(sqlite_in assert unknown == [] +@pytest.mark.parametrize("state", [ScenarioRunState.CANCELLED, ScenarioRunState.COMPLETED]) +async def test_history_id_lookup_matches_verified_interrupted_conversations( + *, sqlite_instance: MemoryInterface, state: ScenarioRunState +) -> None: + timestamp = datetime(2026, 8, 7, tzinfo=UTC) + conversation_id, attack_id = uuid.uuid4(), uuid.uuid4() + recorded = ScenarioActiveConversation( + attack_result_id=str(attack_id), + conversation_id=str(conversation_id), + atomic_attack_name="attack", + objective="objective", + started_at=timestamp, + ).model_dump(mode="json") + scenarios = [ + _make_scenario( + result_id=uuid.UUID(int=index + 10), + timestamp=timestamp + timedelta(minutes=index), + name=f"Scenario{index}", + state=state, + labels={"operator": "alice" if index == 0 else "bob"}, + registry_name="stored.scenario", + ) + for index in range(3) + ] + scenarios[0].metadata[SCENARIO_CONVERSATIONS_METADATA_KEY] = [recorded, recorded] + await sqlite_instance.add_scenario_results_to_memory_async(scenario_results=scenarios) + await sqlite_instance.add_conversation_to_memory_async( + conversation=Conversation(conversation_id=str(conversation_id), attack_result_id=str(attack_id)) + ) + + for reference_id in (conversation_id, attack_id): + records, aggregates, has_more = await sqlite_instance.get_scenario_run_history_page_async( + reference_id=reference_id, limit=1 + ) + assert [record.scenario_result_id for record in records] == [str(scenarios[0].id)] + assert not has_more + assert aggregates[str(scenarios[0].id)].unit_count == 0 + assert await sqlite_instance.get_attack_results_async(scenario_result_id=str(scenarios[0].id)) == [] + excluded, _, _ = await sqlite_instance.get_scenario_run_history_page_async( + reference_id=reference_id, labels={"operator": "bob"} + ) + assert excluded == [] + + +@pytest.mark.parametrize( + "invalid", + [ + "labels-only", + "non-array", + "non-object", + "wrong-attack", + "wrong-conversation", + "missing-conversation", + "unowned-conversation", + "persisted-other-owner", + ], +) +async def test_history_id_lookup_rejects_unverified_interrupted_attribution( + *, sqlite_instance: MemoryInterface, invalid: str +) -> None: + timestamp = datetime(2026, 8, 7, tzinfo=UTC) + conversation_id, attack_id = uuid.uuid4(), uuid.uuid4() + recorded = ScenarioActiveConversation( + attack_result_id=str(attack_id), + conversation_id=str(conversation_id), + atomic_attack_name="attack", + objective="objective", + started_at=timestamp, + ).model_dump(mode="json") + scenarios = [ + _make_scenario( + result_id=uuid.UUID(int=index + 10), + timestamp=timestamp, + name=f"Scenario{index}", + state=ScenarioRunState.CANCELLED, + labels={ + "operator": str(index), + "conversation_id": str(conversation_id), + "attack_result_id": str(attack_id), + }, + ) + for index in range(2) + ] + records: object = [recorded] + if invalid == "labels-only": + records = [] + elif invalid == "non-array": + records = recorded + elif invalid == "non-object": + records = ["invalid", 5, None, [recorded]] + elif invalid == "wrong-attack": + recorded["attack_result_id"] = str(uuid.uuid4()) + elif invalid == "wrong-conversation": + recorded["conversation_id"] = str(uuid.uuid4()) + scenarios[0].metadata[SCENARIO_CONVERSATIONS_METADATA_KEY] = records + await sqlite_instance.add_scenario_results_to_memory_async(scenario_results=scenarios) + if invalid != "missing-conversation": + await sqlite_instance.add_conversation_to_memory_async( + conversation=Conversation( + conversation_id=str(conversation_id), + attack_result_id=None if invalid == "unowned-conversation" else str(attack_id), + ) + ) + expected: list[str] = [] + if invalid == "persisted-other-owner": + await sqlite_instance.add_attack_results_to_memory_async( + attack_results=[ + AttackResult( + attack_result_id=str(attack_id), + conversation_id=str(conversation_id), + objective="objective", + outcome=AttackOutcome.FAILURE, + execution_time_ms=1, + timestamp=timestamp, + attribution_parent_id=str(scenarios[1].id), + ) + ] + ) + expected = [str(scenarios[1].id)] + + for reference_id in (conversation_id, attack_id): + page, _, has_more = await sqlite_instance.get_scenario_run_history_page_async(reference_id=reference_id) + assert [record.scenario_result_id for record in page] == expected + assert not has_more + excluded, _, _ = await sqlite_instance.get_scenario_run_history_page_async( + reference_id=reference_id, labels={"operator": "0"} + ) + assert excluded == [] + + +@pytest.mark.parametrize("dialect_name", ["sqlite", "mssql"]) +def test_recorded_history_lookup_compiles_correlated_verified_read(dialect_name: str) -> None: + reference_id = uuid.uuid4() + statement = select(ScenarioResultEntry.id).where(_ScenarioHistoryQueries._recorded_conversation_match(reference_id)) + dialect = sqlite.dialect() if dialect_name == "sqlite" else mssql.dialect() + compiled = statement.compile(dialect=dialect, compile_kwargs={"literal_binds": True}) + sql = str(compiled) + assert str(reference_id) in sql + assert SCENARIO_CONVERSATIONS_METADATA_KEY in sql + assert ("json_each(" if dialect_name == "sqlite" else "OPENJSON(") in sql + scenario_table = 'FROM "ScenarioResultEntries"' if dialect_name == "sqlite" else "FROM [ScenarioResultEntries]" + assert sql.count(scenario_table) == 1 + assert "NOT (EXISTS" in sql + + async def test_history_creation_order_is_stable_when_scenario_entry_is_rebuilt( sqlite_instance: MemoryInterface, ) -> None: From 3dde572b02a4f1b28b72b9768c88af18a05ef5d7 Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Thu, 29 Oct 2026 04:16:48 -0700 Subject: [PATCH 26/65] FEAT: Focus Home on attacks, scans, and recent work Use real summaries and saved operation links. Keep intentional New attack resets separate from general navigation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- doc/gui/0_gui.md | 16 +- doc/gui/images/home.png | Bin 68946 -> 92061 bytes frontend/e2e/accessibility.spec.ts | 2 +- frontend/e2e/touch-targets.spec.ts | 9 +- frontend/src/App.test.tsx | 22 +- frontend/src/App.tsx | 5 +- frontend/src/components/Home/Home.styles.ts | 256 +++++----- frontend/src/components/Home/Home.test.tsx | 491 ++++++++------------ frontend/src/components/Home/Home.tsx | 471 ++++++++++--------- frontend/src/components/Tour/tourSteps.ts | 10 +- frontend/src/hooks/useTour.test.ts | 22 +- 11 files changed, 639 insertions(+), 665 deletions(-) diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index 2f95747fce..184d30d2d7 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -23,12 +23,16 @@ For other setup options, see [Docker](../getting_started/install_docker.md) or t ## Home -Home shows your last-used objective target and recent attacks grouped by operation. -Open an attack to return to its conversation, or go to Registry to configure a -target. The shared labels bar sets the operator, operation, and custom labels -for new work; it does not change saved results. - -![Home page with target and recent work](images/home.png) +Home puts **Attacks**, **Scans**, **Recent work**, and **Operations** first. +Select **New attack** to start a fresh chat, or **Configure scan** to open scanner +setup. These actions do not send prompts or start a scan. The sidebar's +**Attacks** entry returns to your last conversation or unsent draft. +Each workflow has its own history link. Recent work shows up to five saved +attacks and scans; filter by type or select **Refresh Home** to update the list. +Open a saved operation to review its work and findings. Registry remains +available below these sections for setup. + +![Home page with workflows and recent work](images/home.png) ## Chat diff --git a/doc/gui/images/home.png b/doc/gui/images/home.png index 94f5bb385d250b7c27b89cbd669ddade128a2b11..4d32bda9bf822e41762463447fdbeabc95a8d72f 100644 GIT binary patch literal 92061 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

9}gAWZ&HO41+o(P7ePCxRVBCK(L)P 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

nNxE7EZ$ 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 z { await expect(navigation).toHaveCount(1); await expect( - page.getByRole("heading", { level: 1, name: "Welcome to Co-PyRIT" }) + page.getByRole("heading", { level: 1, name: "Home", exact: true }) ).toBeVisible(); await expect(navigation.getByRole("button", { name: "Home" })).toHaveAttribute( "aria-current", diff --git a/frontend/e2e/touch-targets.spec.ts b/frontend/e2e/touch-targets.spec.ts index ad02c557b8..79d53c3c5c 100644 --- a/frontend/e2e/touch-targets.spec.ts +++ b/frontend/e2e/touch-targets.spec.ts @@ -520,14 +520,7 @@ test.describe("Mobile touch targets", () => { await expect(page.getByTestId("home-view")).toBeVisible(); await expectMinimumTouchTargets( - page.getByTestId("home-view").locator( - [ - '[data-testid="labels-icon-btn"]', - '[data-testid="home-configure-target-btn"]', - '[data-testid="home-view-all-history-btn"]', - '[data-testid="home-open-attack-home-attack-001"]', - ].join(",") - ) + page.getByTestId("home-view").locator("button") ); await expectMinimumTouchTarget( page.getByRole("button", { name: "Home", exact: true }) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index ce9726c8ad..1628930b56 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -447,17 +447,19 @@ jest.mock("./components/History/AttackHistory", () => { jest.mock("./components/Home/Home", () => { const MockHome = ({ - activeTarget, onNavigate, + onNewAttack, onOpenAttack, }: { - activeTarget: unknown; onNavigate: (view: string) => void; + onNewAttack: () => void; onOpenAttack: (attackResultId: string) => void; }) => { return (

- {activeTarget ? "yes" : "no"} + @@ -1326,6 +1328,20 @@ describe("App", () => { expect(screen.getByTestId("target-config")).toBeInTheDocument(); }); + it("returns to the unsent draft through navigation but resets it through Home New attack", async () => { + const user = userEvent.setup(); + renderApp("/attack/chat"); + await user.click(await screen.findByTestId("preserve-draft")); + await user.click(screen.getByTestId("nav-home")); + await screen.findByTestId("home-view"); + await user.click(screen.getByTestId("nav-chat")); + expect(await screen.findByTestId("preserved-draft-text")).toHaveTextContent("preserved prompt draft"); + await user.click(screen.getByTestId("nav-home")); + await user.click(await screen.findByTestId("home-new-attack")); + expect(await screen.findByTestId("preserved-draft-text")).toHaveTextContent("none"); + expect(screen.getByTestId("attack-result-id")).toHaveTextContent("none"); + }); + it("shows the not-found UX when an attack returns 404", async () => { mockGetAttack.mockRejectedValue({ isAxiosError: true, response: { status: 404, data: {} } }); renderApp(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b78fc155f5..9591c9548f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -898,10 +898,11 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { path="/" element={ navigate(HISTORY_ATTACKS_PATH + lastHistorySearch.current)} + onOpenAttackHistory={() => navigate(HISTORY_ATTACKS_PATH + lastHistorySearch.current)} + onOpenScannerHistory={() => navigate(HISTORY_SCANNER_PATH + lastScenarioHistorySearch.current)} /> } /> diff --git a/frontend/src/components/Home/Home.styles.ts b/frontend/src/components/Home/Home.styles.ts index 9e8bf75f59..1798998f5b 100644 --- a/frontend/src/components/Home/Home.styles.ts +++ b/frontend/src/components/Home/Home.styles.ts @@ -1,23 +1,26 @@ import { makeStyles, tokens } from '@fluentui/react-components' -import { mobileTouchTarget, mobileTouchTargetHeight } from '../../styles/touchTargets' +import { mobileTouchTarget, mobileTouchTargetHeight, NARROW_VIEWPORT_QUERY } from '@/styles/touchTargets' import { WORKSPACE_CANVAS_BACKGROUND } from '@/styles/workspaceBackground' export const useHomeStyles = makeStyles({ + touchTarget: { ...mobileTouchTarget }, root: { flex: 1, + minHeight: 0, overflowY: 'auto', backgroundColor: WORKSPACE_CANVAS_BACKGROUND, }, container: { - maxWidth: '1100px', + maxWidth: '1120px', margin: '0 auto', - padding: `${tokens.spacingVerticalXXL} ${tokens.spacingHorizontalXXL}`, + padding: `${tokens.spacingVerticalXXL} ${tokens.spacingHorizontalXXXL}`, display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalXXL, - '@media (max-width: 600px)': { + [NARROW_VIEWPORT_QUERY]: { padding: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalM}`, + gap: tokens.spacingVerticalXL, }, }, hero: { @@ -26,159 +29,200 @@ export const useHomeStyles = makeStyles({ gap: tokens.spacingVerticalS, }, heroTitle: { - color: tokens.colorBrandForeground1, + margin: 0, + color: tokens.colorNeutralForeground1, }, - heroSubtitle: { + secondary: { color: tokens.colorNeutralForeground3, + lineHeight: tokens.lineHeightBase300, }, - setupGrid: { + workflowGrid: { display: 'grid', - gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', + gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: tokens.spacingHorizontalL, - '@media (max-width: 600px)': { - gridTemplateColumns: 'minmax(0, 1fr)', - }, + '@media (max-width: 800px)': { gridTemplateColumns: 'minmax(0, 1fr)' }, }, - card: { + workflowCard: { backgroundColor: tokens.colorNeutralBackground1, border: `1px solid ${tokens.colorNeutralStroke2}`, - borderRadius: tokens.borderRadiusLarge, - padding: tokens.spacingHorizontalL, + borderRadius: tokens.borderRadiusXLarge, + padding: tokens.spacingHorizontalXXL, display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalM, + boxShadow: tokens.shadow2, + [NARROW_VIEWPORT_QUERY]: { padding: tokens.spacingHorizontalL }, }, - cardHeader: { + workflowHeading: { display: 'flex', alignItems: 'center', - gap: tokens.spacingHorizontalS, + gap: tokens.spacingHorizontalM, + '& h2': { margin: 0 }, }, - cardIcon: { - fontSize: '24px', - color: tokens.colorBrandForeground1, + workflowIcon: { display: 'inline-flex', + padding: tokens.spacingHorizontalS, + backgroundColor: tokens.colorBrandBackground2, + color: tokens.colorBrandForeground1, + borderRadius: tokens.borderRadiusLarge, + fontSize: tokens.fontSizeHero700, }, - cardBody: { - display: 'flex', - flexDirection: 'column', - gap: tokens.spacingVerticalS, - }, - cardFooter: { + cardActions: { display: 'flex', - justifyContent: 'flex-end', + alignItems: 'center', + flexWrap: 'wrap', gap: tokens.spacingHorizontalS, - }, - touchTarget: { - ...mobileTouchTarget, - }, - targetSummary: { - display: 'flex', - flexDirection: 'column', - gap: tokens.spacingVerticalXXS, - }, - targetName: { - fontWeight: tokens.fontWeightSemibold, - }, - targetMeta: { - color: tokens.colorNeutralForeground3, - }, - emptyHint: { - color: tokens.colorNeutralForeground3, - fontStyle: 'italic', + paddingTop: tokens.spacingVerticalS, + marginTop: 'auto', }, sectionHeader: { - display: 'flex', - alignItems: 'baseline', - justifyContent: 'space-between', - marginBottom: tokens.spacingVerticalM, - }, - operationsGrid: { - display: 'grid', - gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', - gap: tokens.spacingHorizontalL, - '@media (max-width: 600px)': { - gridTemplateColumns: 'minmax(0, 1fr)', - }, - }, - operationCard: { - backgroundColor: tokens.colorNeutralBackground1, - border: `1px solid ${tokens.colorNeutralStroke2}`, - borderRadius: tokens.borderRadiusLarge, - padding: tokens.spacingHorizontalL, - display: 'flex', - flexDirection: 'column', - gap: tokens.spacingVerticalS, - }, - operationHeader: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', + flexWrap: 'wrap', gap: tokens.spacingHorizontalS, + marginBottom: tokens.spacingVerticalM, + '& h2': { margin: 0 }, }, - operationName: { - fontWeight: tokens.fontWeightSemibold, + recentList: { + listStyleType: 'none', + padding: 0, + margin: 0, overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - }, - operationMeta: { - color: tokens.colorNeutralForeground3, - }, - attackList: { - display: 'flex', - flexDirection: 'column', - gap: tokens.spacingVerticalXS, - marginTop: tokens.spacingVerticalXS, + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusLarge, + backgroundColor: tokens.colorNeutralBackground1, + '& li + li': { borderTop: `1px solid ${tokens.colorNeutralStroke2}` }, }, - attackRow: { + recentRow: { display: 'flex', alignItems: 'center', - gap: tokens.spacingHorizontalS, - padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`, - borderRadius: tokens.borderRadiusMedium, - backgroundColor: 'transparent', - border: 'none', - cursor: 'pointer', + gap: tokens.spacingHorizontalM, + padding: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalL}`, width: '100%', + minWidth: 0, + border: 'none', + backgroundColor: 'transparent', + color: tokens.colorNeutralForeground1, + fontFamily: tokens.fontFamilyBase, textAlign: 'left', - color: 'inherit', + cursor: 'pointer', + ':hover': { backgroundColor: tokens.colorNeutralBackground1Hover }, + ':focus-visible': { outline: `2px solid ${tokens.colorStrokeFocus2}`, outlineOffset: '-2px' }, ...mobileTouchTargetHeight, - ':hover': { - backgroundColor: tokens.colorNeutralBackground1Hover, - }, - ':focus-visible': { - outline: `2px solid ${tokens.colorStrokeFocus2}`, - outlineOffset: '1px', + [NARROW_VIEWPORT_QUERY]: { + display: 'grid', + gridTemplateColumns: 'auto minmax(0, 1fr) auto', + gap: tokens.spacingHorizontalS, + padding: tokens.spacingHorizontalM, }, }, - attackPreview: { + rowIcon: { + fontSize: tokens.fontSizeBase600, + color: tokens.colorBrandForeground1, + display: 'inline-flex', + flexShrink: 0, + }, + rowContent: { + display: 'flex', + flexDirection: 'column', flex: 1, minWidth: 0, + gap: tokens.spacingVerticalXS, + }, + rowTitle: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', - color: tokens.colorNeutralForeground2, }, - attackTimestamp: { + rowMeta: { + display: 'flex', + flexWrap: 'wrap', + overflowWrap: 'anywhere', + gap: tokens.spacingHorizontalXS, + color: tokens.colorNeutralForeground3, + alignItems: 'center', + }, + rowChevron: { + flexShrink: 0, + color: tokens.colorNeutralForeground3, + [NARROW_VIEWPORT_QUERY]: { display: 'none' }, + }, + status: { + flexShrink: 0, + display: 'inline-flex', + [NARROW_VIEWPORT_QUERY]: { gridColumn: '2 / 4', gridRow: 2, justifySelf: 'start' }, + }, + timestamp: { color: tokens.colorNeutralForeground3, fontSize: tokens.fontSizeBase200, - whiteSpace: 'nowrap', + width: '5em', + textAlign: 'right', + flexShrink: 0, + [NARROW_VIEWPORT_QUERY]: { width: 'auto', gridColumn: 3, gridRow: 1 }, + }, + operationsGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', + gap: tokens.spacingHorizontalM, + '@media (max-width: 1000px)': { gridTemplateColumns: 'minmax(0, 1fr)' }, + }, + operationDescription: { + color: tokens.colorNeutralForeground3, + overflowWrap: 'anywhere', + overflow: 'hidden', + display: '-webkit-box', + WebkitLineClamp: 2, + WebkitBoxOrient: 'vertical', }, - loadingState: { + operationCard: { display: 'flex', - justifyContent: 'center', - padding: tokens.spacingVerticalXXL, + alignItems: 'center', + gap: tokens.spacingHorizontalM, + padding: tokens.spacingHorizontalL, + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusLarge, + backgroundColor: tokens.colorNeutralBackground1, + color: tokens.colorNeutralForeground1, + textAlign: 'left', + fontFamily: tokens.fontFamilyBase, + cursor: 'pointer', + ':hover': { backgroundColor: tokens.colorNeutralBackground1Hover, border: `1px solid ${tokens.colorBrandStroke1}` }, + ':focus-visible': { outline: `2px solid ${tokens.colorStrokeFocus2}`, outlineOffset: '2px' }, + ...mobileTouchTargetHeight, }, - emptyOperations: { + emptyState: { display: 'flex', flexDirection: 'column', alignItems: 'center', - justifyContent: 'center', gap: tokens.spacingVerticalS, - padding: tokens.spacingVerticalXXL, - color: tokens.colorNeutralForeground3, + padding: `${tokens.spacingVerticalXXXL} ${tokens.spacingHorizontalL}`, + border: `1px dashed ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusLarge, backgroundColor: tokens.colorNeutralBackground1, - border: `1px dashed ${tokens.colorNeutralStroke2}`, + textAlign: 'center', + }, + emptyIcon: { + color: tokens.colorNeutralForeground3, + fontSize: tokens.fontSizeHero800, + marginBottom: tokens.spacingVerticalS, + }, + emptyOperations: { + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalM, + padding: tokens.spacingHorizontalL, + border: `1px solid ${tokens.colorNeutralStroke2}`, borderRadius: tokens.borderRadiusLarge, + backgroundColor: tokens.colorNeutralBackground1, + }, + setupFooter: { + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalS, + color: tokens.colorNeutralForeground3, + paddingBottom: tokens.spacingVerticalM, }, }) diff --git a/frontend/src/components/Home/Home.test.tsx b/frontend/src/components/Home/Home.test.tsx index fa2c6d9615..dd86321654 100644 --- a/frontend/src/components/Home/Home.test.tsx +++ b/frontend/src/components/Home/Home.test.tsx @@ -1,309 +1,198 @@ -/** - * Copyright (c) Microsoft Corporation. - * Licensed under the MIT license. - */ - -import { render, screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { FluentProvider, webLightTheme } from "@fluentui/react-components"; -import { makeTarget } from "@/test-utils/targetFixtures"; -import Home from "./Home"; -import { attacksApi } from "../../services/api"; -import type { AttackSummary, TargetInstance } from "../../types"; - -jest.mock("../../services/api", () => ({ - attacksApi: { - listAttacks: jest.fn(), - }, -})); +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, useLocation } from 'react-router' +import type { ReactNode } from 'react' + +import { useRuntime } from '@/hooks/useRuntime' +import { attacksApi, operationsApi, scenariosApi } from '@/services/api' +import type { AttackSummary, OperationListItem, ScenarioRunListItem, ScenarioRunState } from '@/types' + +import Home from './Home' + +jest.mock('@/services/api', () => ({ + attacksApi: { listAttacks: jest.fn() }, + scenariosApi: { listRuns: jest.fn() }, + operationsApi: { list: jest.fn() }, +})) +jest.mock('@/hooks/useRuntime', () => ({ useRuntime: jest.fn() })) + +const mockAttacks = jest.mocked(attacksApi.listAttacks) +const mockScans = jest.mocked(scenariosApi.listRuns) +const mockOperations = jest.mocked(operationsApi.list) +const mockRuntime = jest.mocked(useRuntime) +const PAGINATION = { limit: 5, has_more: false, next_cursor: null, prev_cursor: null } + +function Location() { + return {useLocation().pathname} +} -const mockListAttacks = attacksApi.listAttacks as jest.Mock; +function TestWrapper({ children }: { readonly children: ReactNode }) { + return {children} +} -const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - {children} -); +function attack(overrides: Partial = {}): AttackSummary { + return { + attack_result_id: 'attack-1', conversation_id: 'conversation-1', attack_type: 'ManualAttack', + objective: 'Check the support assistant', converters: [], outcome: 'undetermined', message_count: 2, + related_conversation_ids: [], labels: {}, created_at: '2026-10-28T10:00:00Z', + updated_at: '2026-10-28T11:00:00Z', operation_name: 'Current operation name', ...overrides, + } +} -function makeAttack(overrides: Partial = {}): AttackSummary { - const attack: AttackSummary = { - attack_result_id: "ar-1", - conversation_id: "conv-1", - attack_type: "TestAttack", - objective: "Test objective", - converters: [], - outcome: "success", - last_message_preview: "preview", - message_count: 1, - related_conversation_ids: [], - operator: "alice", - operation: "op_alpha", - labels: {}, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), +function scan(overrides: Partial = {}): ScenarioRunListItem { + return { + scenario_result_id: 'run-1', scenario_name: 'Safety scan', scenario_version: 1, status: 'IN_PROGRESS', + created_at: '2026-10-28T09:00:00Z', updated_at: '2026-10-28T12:00:00Z', techniques_used: [], + total_attacks: null, completed_attacks: 0, objective_achieved_rate: 0, total_retries: 0, + labels: {}, operation_name: 'Saved scan operation', datasets_used: [], scenario_parameters: {}, + planned_total_available: false, successful_attacks: 0, error_attacks: 0, attack_details_available: false, ...overrides, - }; - // Like the API, name legacy activity by its stored operation text unless a current name is given. - return { operation_name: attack.operation, ...attack }; + } } -const defaultProps = { - activeTarget: null as TargetInstance | null, - onNavigate: jest.fn(), - onOpenAttack: jest.fn(), - onOpenHistory: jest.fn(), -}; - -describe("Home", () => { - beforeEach(() => { - jest.clearAllMocks(); - mockListAttacks.mockResolvedValue({ - items: [], - pagination: { has_more: false, next_cursor: null }, - }); - }); - - it("renders the welcome hero", async () => { - render(); - expect( - screen.getByRole("heading", { level: 1, name: /welcome to co-pyrit/i }) - ).toBeInTheDocument(); - expect(screen.getByRole("heading", { level: 2, name: "Labels" })).toBeInTheDocument(); - expect(screen.getByRole("heading", { level: 2, name: "Default objective target" })).toBeInTheDocument(); - expect(screen.getByText(/Use the bar above/)).toBeInTheDocument(); - expect(screen.queryByTestId("labels-bar")).not.toBeInTheDocument(); - expect( - screen.getByRole("heading", { level: 2, name: "Recent operations" }) - ).toBeInTheDocument(); - await waitFor(() => expect(mockListAttacks).toHaveBeenCalled()); - }); - - it("shows an empty target hint when no target is set", async () => { - render(); - expect(screen.getByTestId("home-target-empty")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /configure a target/i })).toBeInTheDocument(); - await waitFor(() => expect(mockListAttacks).toHaveBeenCalled()); - }); - - it("renders active target summary and 'Manage targets' button when a target is set", async () => { - const target: TargetInstance = makeTarget({ - target_registry_name: "my_target", - target_type: "OpenAIChatTarget", - endpoint: "https://example.com", - model_name: "gpt-test", - }); - render(); - expect(screen.getByTestId("home-target-active")).toHaveTextContent("gpt-test"); - expect(screen.getByRole("button", { name: /manage targets/i })).toBeInTheDocument(); - await waitFor(() => expect(mockListAttacks).toHaveBeenCalled()); - }); - - it("navigates to the registry when 'Configure a target' is clicked", async () => { - const user = userEvent.setup(); - const onNavigate = jest.fn(); - render(); - await user.click(screen.getByTestId("home-configure-target-btn")); - expect(onNavigate).toHaveBeenCalledWith("registry"); - }); - - it("shows the empty state when there are no attacks", async () => { - render(); - expect(await screen.findByTestId("home-empty")).toBeInTheDocument(); - expect(screen.getByText(/no attacks yet/i)).toBeInTheDocument(); - }); - - it("navigates to chat when 'Go to chat' is clicked from the empty state", async () => { - const user = userEvent.setup(); - const onNavigate = jest.fn(); - render(); - const btn = await screen.findByTestId("home-start-attack-btn"); - await user.click(btn); - expect(onNavigate).toHaveBeenCalledWith("chat"); - }); - - it("renders recent operations grouped by operation label", async () => { - const now = Date.now(); - mockListAttacks.mockResolvedValue({ - items: [ - makeAttack({ - attack_result_id: "ar-1", - operator: "alice", - operation: "op_alpha", - updated_at: new Date(now).toISOString(), - }), - makeAttack({ - attack_result_id: "ar-2", - operator: "alice", - operation: "op_alpha", - updated_at: new Date(now - 60_000).toISOString(), - }), - makeAttack({ - attack_result_id: "ar-3", - operator: "alice", - operation: "op_beta", - updated_at: new Date(now - 120_000).toISOString(), - }), - ], - pagination: { has_more: false, next_cursor: null }, - }); - - render(); - expect(await screen.findByTestId("home-operation-op_alpha")).toBeInTheDocument(); - expect(screen.getByTestId("home-operation-op_beta")).toBeInTheDocument(); - // op_alpha has 2 attacks - expect(screen.getByTestId("home-operation-op_alpha")).toHaveTextContent(/2 attacks/i); - // op_beta has 1 attack - expect(screen.getByTestId("home-operation-op_beta")).toHaveTextContent(/1 attack/); - }); - - it("updates last-activity for newer attacks and formats hour/day/older timestamps", async () => { - const now = Date.now(); - const HOUR = 60 * 60 * 1000; - const DAY = 24 * HOUR; - mockListAttacks.mockResolvedValue({ - items: [ - // Oldest first, so a later (newer) attack in the same operation updates - // the group's last-activity — exercising the "newer than current" branch. - makeAttack({ - attack_result_id: "ar-old", - operator: "alice", - operation: "op_time", - last_message_preview: "older than a week", - updated_at: new Date(now - 10 * DAY).toISOString(), - }), - makeAttack({ - attack_result_id: "ar-hours", - operator: "alice", - operation: "op_time", - last_message_preview: "a few hours ago", - updated_at: new Date(now - 3 * HOUR).toISOString(), - }), - makeAttack({ - attack_result_id: "ar-days", - operator: "alice", - operation: "op_time", - last_message_preview: "a few days ago", - updated_at: new Date(now - 3 * DAY).toISOString(), - }), - ], - pagination: { has_more: false, next_cursor: null }, - }); - - render(); - - const card = await screen.findByTestId("home-operation-op_time"); - // All three attacks render, exercising the hour/day/older formatting paths. - expect(within(card).getByText("a few hours ago")).toBeInTheDocument(); - expect(within(card).getByText("a few days ago")).toBeInTheDocument(); - expect(within(card).getByText("older than a week")).toBeInTheDocument(); - // The newer (3h-ago) attack wins as the group's most recent activity. - expect(within(card).getAllByText(/3h ago/).length).toBeGreaterThan(0); - expect(within(card).getAllByText(/3d ago/).length).toBeGreaterThan(0); - }); - - it("caps visible attacks and falls back for missing preview, outcome, and timestamp", async () => { - const now = Date.now(); - mockListAttacks.mockResolvedValue({ - items: [ - makeAttack({ - attack_result_id: "f1", - operator: "alice", - operation: "op_full", - outcome: "success", - last_message_preview: "first preview", - updated_at: new Date(now - 60_000).toISOString(), - }), - makeAttack({ - attack_result_id: "f2", - operator: "alice", - operation: "op_full", - outcome: null, // unknown outcome -> default icon via the ?? 'undetermined' branch - last_message_preview: null, // missing preview -> falls back to attack_type - updated_at: new Date(now - 120_000).toISOString(), - }), - makeAttack({ - attack_result_id: "f3", - operator: "alice", - operation: "op_full", - // Outcome not present in the icon map -> exercises the icon fallback branch. - outcome: "mystery" as unknown as AttackSummary["outcome"], - last_message_preview: "third preview", - updated_at: "not-a-date", // invalid -> empty relative time (NaN guard) - }), - makeAttack({ - attack_result_id: "f4", - operator: "alice", - operation: "op_full", - last_message_preview: "fourth preview", - updated_at: new Date(now - 240_000).toISOString(), - }), - ], - pagination: { has_more: false, next_cursor: null }, - }); - - render(); - - const card = await screen.findByTestId("home-operation-op_full"); - // Only the first three attacks render; the fourth is summarized as overflow. - expect(within(card).getByText("first preview")).toBeInTheDocument(); - expect(within(card).getByText("third preview")).toBeInTheDocument(); - expect(within(card).queryByText("fourth preview")).not.toBeInTheDocument(); - expect(within(card).getByText(/\+1 more in history/)).toBeInTheDocument(); - // f2 has no preview, so its row shows the attack type instead. - expect(within(card).getByText("TestAttack")).toBeInTheDocument(); - }); - - it("groups ID-linked attacks under their saved Operation's current name", async () => { - mockListAttacks.mockResolvedValue({ - items: [ - makeAttack({ attack_result_id: "ar-1", operation: null, operation_id: "id-1", operation_name: "Renamed" }), - makeAttack({ attack_result_id: "ar-2", operation: null, operation_id: "id-1", operation_name: "Renamed" }), - ], - pagination: { has_more: false, next_cursor: null }, - }); - - render(); - expect(await screen.findByTestId("home-operation-Renamed")).toHaveTextContent(/2 attacks/i); - expect(screen.queryByTestId("home-operation-unlabeled")).not.toBeInTheDocument(); - }); - - it("groups attacks with no operation label under '(no operation)'", async () => { - mockListAttacks.mockResolvedValue({ - items: [ - makeAttack({ - attack_result_id: "ar-x", - operation: null, - }), - ], - pagination: { has_more: false, next_cursor: null }, - }); - - render(); - expect(await screen.findByTestId("home-operation-unlabeled")).toHaveTextContent(/no operation/i); - }); - - it("calls onOpenAttack when a recent attack row is clicked", async () => { - const user = userEvent.setup(); - const onOpenAttack = jest.fn(); - mockListAttacks.mockResolvedValue({ - items: [makeAttack({ attack_result_id: "ar-click" })], - pagination: { has_more: false, next_cursor: null }, - }); - - render(); - const row = await screen.findByTestId("home-open-attack-ar-click"); - await user.click(row); - expect(onOpenAttack).toHaveBeenCalledWith("ar-click"); - }); +function operation(overrides: Partial = {}): OperationListItem { + return { id: 'saved-operation', name: 'Saved operation', description: 'A real saved engagement', + created_at: '2026-10-28T08:00:00Z', finding_counts: {}, ...overrides } +} - it("renders error message when listAttacks fails", async () => { - mockListAttacks.mockRejectedValueOnce(new Error("boom")); - render(); - expect(await screen.findByTestId("home-error")).toBeInTheDocument(); - }); +describe('Home', () => { + const defaultProps = { + onNavigate: jest.fn(), onNewAttack: jest.fn(), onOpenAttack: jest.fn(), + onOpenAttackHistory: jest.fn(), onOpenScannerHistory: jest.fn(), + } - it("navigates to history when 'View all history' is clicked", async () => { - const user = userEvent.setup(); - render(); - await user.click(screen.getByTestId("home-view-all-history-btn")); - expect(defaultProps.onOpenHistory).toHaveBeenCalled(); - }); -}); + beforeEach(() => { + jest.clearAllMocks() + mockRuntime.mockReturnValue({ ready: true, generation: 'first', state: 'ready' }) + mockAttacks.mockResolvedValue({ items: [], pagination: PAGINATION }) + mockScans.mockResolvedValue({ items: [], pagination: PAGINATION }) + mockOperations.mockResolvedValue({ items: [] }) + }) + + it('should prioritize workflows and only read summaries on entry', async () => { + render() + expect(screen.getAllByRole('heading', { level: 2 }).map((heading: HTMLElement) => heading.textContent)) + .toEqual(['Attacks', 'Scans', 'Recent work', 'Operations']) + expect(screen.queryByRole('heading', { name: 'Default objective target' })).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Labels' })).not.toBeInTheDocument() + await screen.findByText('Your recent work will appear here') + expect(mockAttacks).toHaveBeenCalledWith({ limit: 5, include_scenario_attacks: false }) + expect(mockScans).toHaveBeenCalledWith({ limit: 5 }) + expect(mockOperations).toHaveBeenCalledTimes(1) + for (const callback of Object.values(defaultProps)) expect(callback).not.toHaveBeenCalled() + }) + + it('should use the intentional new-attack action and each workflow history', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'New attack', exact: true })) + expect(defaultProps.onNewAttack).toHaveBeenCalledTimes(1) + await user.click(screen.getByRole('button', { name: 'Attack history', exact: true })) + expect(defaultProps.onOpenAttackHistory).toHaveBeenCalledTimes(1) + await user.click(screen.getByRole('button', { name: 'Configure scan', exact: true })) + expect(defaultProps.onNavigate).toHaveBeenCalledWith('scenarios') + await user.click(screen.getByRole('button', { name: 'Scan history', exact: true })) + expect(defaultProps.onOpenScannerHistory).toHaveBeenCalledTimes(1) + await user.click(screen.getByRole('button', { name: 'View operations', exact: true })) + expect(defaultProps.onNavigate).toHaveBeenCalledWith('operations') + await user.click(screen.getByRole('button', { name: 'Open Registry', exact: true })) + expect(defaultProps.onNavigate).toHaveBeenCalledWith('registry') + expect(defaultProps.onNavigate).not.toHaveBeenCalledWith('chat') + }) + + it('should merge recent work by activity, filter without fetching, and open saved destinations', async () => { + const user = userEvent.setup() + mockAttacks.mockResolvedValue({ items: [attack()], pagination: PAGINATION }) + mockScans.mockResolvedValue({ items: [scan({ scenario_result_id: 'run/one' })], pagination: PAGINATION }) + render() + const list = await screen.findByRole('list') + await waitFor(() => { expect(within(list).getAllByRole('button')).toHaveLength(2) }) + expect(within(list).getAllByRole('button')[0]).toHaveTextContent('Safety scan') + expect(within(list).getByText('Executing')).toBeInTheDocument() + expect(within(list).getByText('Undetermined')).toBeInTheDocument() + await user.click(screen.getByRole('tab', { name: 'Attacks', exact: true })) + expect(within(list).queryByText('Safety scan')).not.toBeInTheDocument() + await user.click(within(list).getByRole('button')) + expect(defaultProps.onOpenAttack).toHaveBeenCalledWith('attack-1') + await user.click(screen.getByRole('tab', { name: 'Scans', exact: true })) + await user.click(within(list).getByRole('button')) + expect(screen.getByLabelText('Current path')).toHaveTextContent('/scanner/history/run%2Fone') + expect(mockAttacks).toHaveBeenCalledTimes(1) + expect(mockScans).toHaveBeenCalledTimes(1) + }) + + it('should limit the combined list and use meaningful title fallbacks', async () => { + mockAttacks.mockResolvedValue({ items: Array.from({ length: 5 }, (_, index: number) => + attack({ attack_result_id: `attack-${index}`, objective: '', last_message_preview: `Preview ${index}` })), + pagination: PAGINATION }) + mockScans.mockResolvedValue({ items: [scan()], pagination: PAGINATION }) + render() + await screen.findByText('Preview 0') + const list = screen.getByRole('list') + expect(within(list).getAllByRole('button')).toHaveLength(5) + expect(within(list).getAllByRole('button')[0]).toHaveTextContent('Safety scan') + }) + + it.each<[ScenarioRunState, string]>([ + ['CREATED', 'Created'], ['QUEUED', 'Queued'], ['IN_PROGRESS', 'Executing'], + ['COMPLETED', 'Completed'], ['FAILED', 'Failed'], ['CANCELLED', 'Cancelled'], + ])('should show scan status %s without assuming completion', async (status: ScenarioRunState, label: string) => { + mockScans.mockResolvedValue({ items: [scan({ status })], pagination: PAGINATION }) + render() + expect(await screen.findByText(label)).toBeInTheDocument() + }) + + it('should show saved Operations independently of attack labels, with encoded IDs and no invented totals', async () => { + const user = userEvent.setup() + mockAttacks.mockResolvedValue({ items: [attack({ operation_name: 'Legacy name only' })], pagination: PAGINATION }) + mockOperations.mockResolvedValue({ items: [operation({ id: 'operation/one' }), operation({ id: 'two', name: 'Second' }), + operation({ id: 'three', name: 'Third' }), operation({ id: 'four', name: 'Fourth' })] }) + render() + const section = screen.getByRole('region', { name: 'Operations' }) + await within(section).findByText('Saved operation') + expect(within(section).queryByText('Legacy name only')).not.toBeInTheDocument() + expect(within(section).queryByText('Fourth')).not.toBeInTheDocument() + expect(within(section).queryByText(/attacks|total/i)).not.toBeInTheDocument() + await user.click(within(section).getByRole('button', { name: /Saved operation A real saved engagement/ })) + expect(screen.getByLabelText('Current path')).toHaveTextContent('/operations/operation%2Fone') + }) + + it('should keep successful sources visible when another fails and retry without an empty-success fallback', async () => { + const user = userEvent.setup() + mockAttacks.mockRejectedValueOnce(new Error('Attack service unavailable')) + mockScans.mockResolvedValue({ items: [scan()], pagination: PAGINATION }) + mockOperations.mockRejectedValueOnce(new Error('Operations unavailable')) + render() + expect(await screen.findByText(/Could not load recent attacks/)).toBeInTheDocument() + expect(screen.getByText('Safety scan')).toBeInTheDocument() + expect(await screen.findByText(/Could not load operations:/)).toBeInTheDocument() + expect(screen.queryByText('Your recent work will appear here')).not.toBeInTheDocument() + expect(screen.queryByText('Keep related work together')).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Retry operations' })) + await screen.findByText('Keep related work together') + expect(mockAttacks).toHaveBeenCalledTimes(2) + expect(screen.queryByText(/Could not load recent attacks/)).not.toBeInTheDocument() + }) + + it('should show loading rather than empty while summaries are pending and ignore stale runtime results', async () => { + let resolveOld: ((value: { items: AttackSummary[]; pagination: typeof PAGINATION }) => void) | undefined + mockAttacks.mockReturnValueOnce(new Promise((resolve) => { resolveOld = resolve })) + const { rerender } = render() + expect(screen.getByText('Loading recent work...')).toBeInTheDocument() + expect(screen.queryByText('Your recent work will appear here')).not.toBeInTheDocument() + mockRuntime.mockReturnValue({ ready: true, generation: 'second', state: 'ready' }) + rerender() + await screen.findByText('Your recent work will appear here') + await act(async () => { resolveOld?.({ items: [attack()], pagination: PAGINATION }) }) + expect(screen.queryByText('Check the support assistant')).not.toBeInTheDocument() + }) + + it('should not fetch while the runtime is unavailable', () => { + mockRuntime.mockReturnValue({ ready: false, generation: '', state: 'connecting' }) + render() + expect(mockAttacks).not.toHaveBeenCalled() + expect(mockScans).not.toHaveBeenCalled() + expect(mockOperations).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/Home/Home.tsx b/frontend/src/components/Home/Home.tsx index 6df1de6b21..7d24bb6b56 100644 --- a/frontend/src/components/Home/Home.tsx +++ b/frontend/src/components/Home/Home.tsx @@ -1,283 +1,312 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useState } from 'react' +import type { ReactNode } from 'react' +import { useNavigate } from 'react-router' import { Badge, Button, MessageBar, MessageBarBody, Spinner, + Tab, + TabList, Text, } from '@fluentui/react-components' +import type { BadgeProps } from '@fluentui/react-components' import { + AddRegular, + ArrowClockwiseRegular, ArrowRightRegular, - TagMultipleRegular, + BriefcaseRegular, + ChatRegular, + ChevronRightRegular, + HistoryRegular, + ScriptRegular, TargetRegular, } from '@fluentui/react-icons' -import { attacksApi } from '../../services/api' -import { toApiError } from '../../services/errors' -import { OutcomeIcon } from '@/components/OutcomeBadge' -import type { AttackSummary, TargetInstance } from '../../types' -import { targetEndpoint, targetModelName, targetType } from '../../utils/targetIdentity' -import type { ViewName } from '../Sidebar/Navigation' + +import OutcomeBadge from '@/components/OutcomeBadge' +import type { ViewName } from '@/components/Sidebar/Navigation' +import { useRuntime } from '@/hooks/useRuntime' +import { attacksApi, operationsApi, scenariosApi } from '@/services/api' +import { toApiError } from '@/services/errors' +import type { AttackOutcome, AttackSummary, OperationListItem, ScenarioRunListItem, ScenarioRunState } from '@/types' +import { scenarioRunRoutePath } from '@/utils/routeParams' + import { useHomeStyles } from './Home.styles' -const RECENT_ATTACKS_LIMIT = 50 -const MAX_OPERATIONS = 5 -const MAX_ATTACKS_PER_OPERATION = 3 -const NO_OPERATION_KEY = '__no_operation__' +const RECENT_WORK_LIMIT = 5 +const VISIBLE_OPERATIONS_LIMIT = 3 +const ATTACK_OUTCOME_LABELS: Record = { + success: 'Objective achieved', failure: 'Not achieved', error: 'Error', undetermined: 'Undetermined', +} +const SCAN_STATUS_LABELS: Record = { + CREATED: 'Created', QUEUED: 'Queued', IN_PROGRESS: 'Executing', + COMPLETED: 'Completed', FAILED: 'Failed', CANCELLED: 'Cancelled', +} +const SCAN_STATUS_COLORS: Record = { + CREATED: 'informative', QUEUED: 'informative', IN_PROGRESS: 'brand', + COMPLETED: 'success', FAILED: 'danger', CANCELLED: 'warning', +} interface HomeProps { - activeTarget: TargetInstance | null - onNavigate: (view: ViewName) => void - onOpenAttack: (attackResultId: string) => void - onOpenHistory: () => void + readonly onNavigate: (view: ViewName) => void + readonly onNewAttack: () => void + readonly onOpenAttack: (attackResultId: string) => void + readonly onOpenAttackHistory: () => void + readonly onOpenScannerHistory: () => void } -interface OperationGroup { - key: string - name: string - isUnlabeled: boolean - attacks: AttackSummary[] - lastActivity: number +interface LoadedSummaries { + readonly key: string + readonly items: T[] + readonly error: string | null } -function groupAttacksByOperation(attacks: AttackSummary[]): OperationGroup[] { - const groups = new Map() - - for (const attack of attacks) { - const opLabel = attack.operation_name - const isUnlabeled = !opLabel - // Saved Operations group by ID so a rename cannot split them; legacy activity groups by its stored name. - const key = isUnlabeled ? NO_OPERATION_KEY : attack.operation_id ? `id:${attack.operation_id}` : `name:${opLabel}` - const updatedAt = new Date(attack.updated_at).getTime() +interface RecentItem { + readonly id: string + readonly kind: 'Attack' | 'Scan' + readonly title: string + readonly operationName?: string | null + readonly updatedAt: string + readonly badge: ReactNode + readonly open: () => void +} - const existing = groups.get(key) - if (existing) { - existing.attacks.push(attack) - if (updatedAt > existing.lastActivity) { - existing.lastActivity = updatedAt - } - } else { - groups.set(key, { - key, - name: isUnlabeled ? '(no operation)' : opLabel, - isUnlabeled, - attacks: [attack], - lastActivity: updatedAt, - }) - } - } +type RecentFilter = 'all' | 'attacks' | 'scans' - return Array.from(groups.values()).sort((a, b) => b.lastActivity - a.lastActivity) +function activityTime(timestamp: string): number { + const parsed = Date.parse(timestamp) + return Number.isNaN(parsed) ? 0 : parsed } -function formatRelativeTime(iso: string): string { - const then = new Date(iso).getTime() - if (Number.isNaN(then)) return '' - const diffMs = Date.now() - then - const seconds = Math.round(diffMs / 1000) - if (seconds < 60) return 'just now' - const minutes = Math.round(seconds / 60) +function formatRelativeTime(timestamp: string): string { + const parsed = Date.parse(timestamp) + if (Number.isNaN(parsed)) return 'Time unavailable' + const minutes = Math.floor(Math.max(0, Date.now() - parsed) / 60_000) + if (minutes < 1) return 'Just now' if (minutes < 60) return `${minutes}m ago` - const hours = Math.round(minutes / 60) + const hours = Math.floor(minutes / 60) if (hours < 24) return `${hours}h ago` - const days = Math.round(hours / 24) + const days = Math.floor(hours / 24) if (days < 7) return `${days}d ago` - return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) -} - -function targetDisplayName(target: TargetInstance): string { - return targetModelName(target) || target.target_registry_name || targetType(target) + return new Date(timestamp).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) } export default function Home({ - activeTarget, - onNavigate, - onOpenAttack, - onOpenHistory, + onNavigate, onNewAttack, onOpenAttack, onOpenAttackHistory, onOpenScannerHistory, }: HomeProps) { const styles = useHomeStyles() - const [attacks, setAttacks] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) + const navigate = useNavigate() + const { generation, ready } = useRuntime() + const [filter, setFilter] = useState('all') + const [revision, setRevision] = useState(0) + const [attackResult, setAttackResult] = useState | null>(null) + const [scanResult, setScanResult] = useState | null>(null) + const [operationResult, setOperationResult] = useState | null>(null) + const requestKey = `${generation}:${revision}` useEffect(() => { + if (!ready) return let ignore = false - attacksApi - .listAttacks({ limit: RECENT_ATTACKS_LIMIT }) - .then(resp => { - if (ignore) return - setAttacks(resp.items.map(item => ({ ...item, labels: item.labels ?? {} }))) - setError(null) - }) - .catch(err => { - if (ignore) return - setAttacks([]) - setError(toApiError(err).detail) - }) - .finally(() => { - if (!ignore) setLoading(false) + const load = ( + request: Promise<{ items: T[] }>, + commit: (result: LoadedSummaries) => void, + ): void => { + request.then((response: { items: T[] }) => { + if (!ignore) commit({ key: requestKey, items: response.items, error: null }) + }).catch((cause: unknown) => { + if (!ignore) commit({ key: requestKey, items: [], error: toApiError(cause).detail }) }) + } + load(attacksApi.listAttacks({ limit: RECENT_WORK_LIMIT, include_scenario_attacks: false }), setAttackResult) + load(scenariosApi.listRuns({ limit: RECENT_WORK_LIMIT }), setScanResult) + load(operationsApi.list(), setOperationResult) return () => { ignore = true } - }, []) + }, [requestKey, ready]) - const operations = useMemo( - () => groupAttacksByOperation(attacks).slice(0, MAX_OPERATIONS), - [attacks], - ) + const attacksLoading = !ready || attackResult?.key !== requestKey + const scansLoading = !ready || scanResult?.key !== requestKey + const operationsLoading = !ready || operationResult?.key !== requestKey + const attacks = !attacksLoading && attackResult ? attackResult.items : [] + const scans = !scansLoading && scanResult ? scanResult.items : [] + const operations = !operationsLoading && operationResult + ? operationResult.items.slice(0, VISIBLE_OPERATIONS_LIMIT) : [] + const recentLoading = (filter !== 'scans' && attacksLoading) || (filter !== 'attacks' && scansLoading) + const recentErrors = [ + filter !== 'scans' && !attacksLoading && attackResult?.error + ? `Could not load recent attacks: ${attackResult.error}` : null, + filter !== 'attacks' && !scansLoading && scanResult?.error + ? `Could not load recent scans: ${scanResult.error}` : null, + ].filter((error: string | null): error is string => error !== null) + const operationsError = !operationsLoading ? operationResult?.error : null + const recent: RecentItem[] = [ + ...attacks.map((attack: AttackSummary): RecentItem => ({ + id: attack.attack_result_id, + kind: 'Attack', + title: attack.objective || attack.last_message_preview || attack.attack_type, + operationName: attack.operation_name, + updatedAt: attack.updated_at, + badge: , + open: () => { onOpenAttack(attack.attack_result_id) }, + })), + ...scans.map((scan: ScenarioRunListItem): RecentItem => ({ + id: scan.scenario_result_id, + kind: 'Scan', + title: scan.scenario_registry_name || scan.scenario_name, + operationName: scan.operation_name, + updatedAt: scan.updated_at, + badge: {SCAN_STATUS_LABELS[scan.status]}, + open: () => { void navigate(scenarioRunRoutePath(scan.scenario_result_id)) }, + })), + ].filter((item: RecentItem) => filter === 'all' || (filter === 'attacks' ? item.kind === 'Attack' : item.kind === 'Scan')) + .sort((first: RecentItem, second: RecentItem) => activityTime(second.updatedAt) - activityTime(first.updatedAt)) + .slice(0, RECENT_WORK_LIMIT) + const refresh = (): void => { setRevision((value: number) => value + 1) } return (
-
- - Welcome to Co-PyRIT - - - Set your labels, pick a target, and start a new attack — or jump back into a recent operation. - -
+
+
+ Home + + Start an attack, configure a scan, or return to recent work. + +
+ +
-
-
-
- - Labels +
+
+
+ + Attacks
-
- - Use the bar above to set your operator, - {' '}operation, and other labels before starting an attack or scan. - Your choices stay with you as you navigate. Existing runs keep their original labels. - + Explore a target through a conversation. Try prompts and review responses. +
+ +
- -
-
- - Default objective target +
+
+ + Scans
-
- {activeTarget ? ( -
- {targetDisplayName(activeTarget)} - - {targetType(activeTarget)} - {targetEndpoint(activeTarget) ? ` · ${targetEndpoint(activeTarget)}` : ''} - -
- ) : ( - - No default objective target selected. Choose a target in Chat or set a default in the registry. - - )} -
-
- + Evaluate targets with automated attack techniques and datasets. +
+ +
-
+
- Recent operations - + Recent work + { + if (data.value === 'all' || data.value === 'attacks' || data.value === 'scans') setFilter(data.value) + }}> + All + Attacks + Scans +
- - {loading ? ( -
- -
- ) : error ? ( - - {error} - - ) : operations.length === 0 ? ( -
- No attacks yet - - Select a target and start a new attack from the Chat tab. + {recentErrors.map((error: string) => ( + + {error} + + ))} + {recentLoading && } + {recent.length > 0 && ( +
    + {recent.map((item: RecentItem) => ( +
  • + +
  • + ))} +
+ )} + {!recentLoading && recentErrors.length === 0 && recent.length === 0 && ( +
+
- ) : ( + )} +
+ +
+
+ Operations + +
+ {operationsLoading ? : operationsError ? ( + + Could not load operations: {operationsError}{' '} + + + ) : operations.length > 0 ? (
- {operations.map(op => { - const visibleAttacks = op.attacks.slice(0, MAX_ATTACKS_PER_OPERATION) - const hiddenCount = op.attacks.length - visibleAttacks.length - return ( -
-
- - {op.name} - - - {op.attacks.length} {op.attacks.length === 1 ? 'attack' : 'attacks'} - -
- - Last activity {formatRelativeTime(new Date(op.lastActivity).toISOString())} - -
- {visibleAttacks.map(attack => ( - - ))} - {hiddenCount > 0 && ( - - +{hiddenCount} more in history - - )} -
-
- ) - })} + {operations.map((operation: OperationListItem) => ( + + ))} +
+ ) : ( +
+
)}
+ +
+
) diff --git a/frontend/src/components/Tour/tourSteps.ts b/frontend/src/components/Tour/tourSteps.ts index 4c81ea9baa..f8d4e942ad 100644 --- a/frontend/src/components/Tour/tourSteps.ts +++ b/frontend/src/components/Tour/tourSteps.ts @@ -49,12 +49,10 @@ export function createTourSteps({ viewRequired: 'home', }, { - target: '[data-tour="target-card"]', - content: hasActiveTarget - ? 'This card shows your last-used objective target for new chats and scanner runs. Use Manage targets ' + - 'to register targets or change your adversarial default in the Target Registry.' - : 'Targets are the AI endpoints you test. Select a target from the Chat dropdown, or use the Target Registry ' + - 'to register targets and set an adversarial default for your account in this browser.', + target: '[data-tour="home-workflows"]', + content: + 'Use New attack to start a conversation, or Configure scan to choose an automated evaluation. ' + + 'Each workflow has its own history. Recent work and saved operations are below.', placement: 'bottom', skipBeacon: true, viewRequired: 'home', diff --git a/frontend/src/hooks/useTour.test.ts b/frontend/src/hooks/useTour.test.ts index 7612269936..522b93cd01 100644 --- a/frontend/src/hooks/useTour.test.ts +++ b/frontend/src/hooks/useTour.test.ts @@ -29,7 +29,7 @@ function makeEvent(overrides: Record = {}) { const TOUR_ANCHORS = [ 'sidebar-nav', 'labels-card', - 'target-card', + 'home-workflows', 'chat-prerequisite', 'converter-toggle', 'scanner-catalog', @@ -88,21 +88,21 @@ describe('useTour', () => { const { result } = renderHook(() => useTour(onNavigate, true, 'home', false)) const steps = result.current.tourProps.steps - expect(steps[2].content).toContain('Select a target from the Chat dropdown') - expect(steps[2].content).toContain('Target Registry') - expect(steps[2].content).toContain('adversarial default for your account') + expect(steps[2].content).toContain('Use New attack') + expect(steps[2].content).toContain('Configure scan') + expect(steps[2].content).toContain('Recent work and saved operations') expect(steps[3].target).toBe('[data-tour="chat-prerequisite"]') expect(steps[3].content).toContain('to enable the message composer') expect(steps[3].content).toContain('automatically select their original registered target') }) - it('uses the active target card and visible converter control when a target is active', () => { + it('uses workflow guidance and the visible converter control when a target is active', () => { const { result } = renderHook(() => useTour(onNavigate, true, 'home', true)) const steps = result.current.tourProps.steps - expect(steps[2].content).toContain('last-used objective target') - expect(steps[2].content).toContain('new chats and scanner runs') - expect(steps[2].content).toContain('adversarial default in the Target Registry') + expect(steps[2].target).toBe('[data-tour="home-workflows"]') + expect(steps[2].content).toContain('Use New attack') + expect(steps[2].content).toContain('Configure scan') expect(steps[3].target).toBe('[data-tour="converter-toggle"]') expect(steps[3].content).toContain('Chat shows the message composer') expect(steps[3].content).toContain('Toggle converter panel') @@ -115,7 +115,7 @@ describe('useTour', () => { expect(steps.map((step) => step.target)).toEqual([ '[data-tour="sidebar-nav"]', '[data-tour="labels-card"]', - '[data-tour="target-card"]', + '[data-tour="home-workflows"]', '[data-tour="chat-prerequisite"]', '[data-tour="scanner-catalog"]', '[data-tour="feature-tabs"]', @@ -254,11 +254,11 @@ describe('useTour', () => { }) rerender({ currentView: 'registry', hasActiveTarget: false }) - expect(result.current.tourProps.steps[2].content).toContain('Select a target') + expect(result.current.tourProps.steps[2].content).toContain('Use New attack') rerender({ currentView: 'registry', hasActiveTarget: true }) expect(result.current.tourProps.steps[2].target).toBe('body') - expect(result.current.tourProps.steps[2].content).toContain('last-used objective target') + expect(result.current.tourProps.steps[2].content).toContain('Use New attack') expect(result.current.tourProps.steps[3].target).toBe('[data-tour="converter-toggle"]') act(() => { From b101117882c7bbcea440faee221388d389673ddc Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Thu, 29 Oct 2026 09:28:13 -0700 Subject: [PATCH 27/65] FIX: Reuse shared Home outcome labels and restore coverage Remove the Home-only outcome label override. Cover outcome defaults, relative-time boundaries and invalid dates, and empty filtered recent work without changing held Home design choices. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- frontend/src/components/Home/Home.test.tsx | 70 +++++++++++++++++++++- frontend/src/components/Home/Home.tsx | 8 +-- 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/Home/Home.test.tsx b/frontend/src/components/Home/Home.test.tsx index dd86321654..18d5ca662b 100644 --- a/frontend/src/components/Home/Home.test.tsx +++ b/frontend/src/components/Home/Home.test.tsx @@ -70,6 +70,8 @@ describe('Home', () => { mockOperations.mockResolvedValue({ items: [] }) }) + afterEach(() => { jest.restoreAllMocks() }) + it('should prioritize workflows and only read summaries on entry', async () => { render() expect(screen.getAllByRole('heading', { level: 2 }).map((heading: HTMLElement) => heading.textContent)) @@ -110,7 +112,7 @@ describe('Home', () => { await waitFor(() => { expect(within(list).getAllByRole('button')).toHaveLength(2) }) expect(within(list).getAllByRole('button')[0]).toHaveTextContent('Safety scan') expect(within(list).getByText('Executing')).toBeInTheDocument() - expect(within(list).getByText('Undetermined')).toBeInTheDocument() + expect(within(list).getByText('undetermined')).toBeInTheDocument() await user.click(screen.getByRole('tab', { name: 'Attacks', exact: true })) expect(within(list).queryByText('Safety scan')).not.toBeInTheDocument() await user.click(within(list).getByRole('button')) @@ -134,6 +136,72 @@ describe('Home', () => { expect(within(list).getAllByRole('button')[0]).toHaveTextContent('Safety scan') }) + it.each<[AttackSummary['outcome'], string]>([ + ['success', 'success'], ['failure', 'failure'], ['error', 'error'], ['undetermined', 'undetermined'], + [null, 'undetermined'], [undefined, 'undetermined'], + ])('should use the shared attack outcome label for %s', async (outcome: AttackSummary['outcome'], label: string) => { + mockAttacks.mockResolvedValue({ items: [attack({ outcome })], pagination: PAGINATION }) + render() + const row = await screen.findByRole('button', { name: /Check the support assistant/ }) + expect(within(row).getByText(label, { exact: true })).toBeVisible() + expect(within(row).queryByText(/Objective achieved|Not achieved/)).not.toBeInTheDocument() + }) + + it.each<[number, string]>([ + [60_000, 'Just now'], + [0, 'Just now'], + [-59_000, 'Just now'], + [-60_000, '1m ago'], + [-3_599_000, '59m ago'], + [-3_600_000, '1h ago'], + [-86_399_000, '23h ago'], + [-86_400_000, '1d ago'], + [-518_400_000, '6d ago'], + ])('should format an activity time offset of %s milliseconds as %s', async (offset: number, expected: string) => { + const now = Date.parse('2026-10-29T12:00:00Z') + jest.spyOn(Date, 'now').mockReturnValue(now) + mockAttacks.mockResolvedValue({ + items: [attack({ updated_at: new Date(now + offset).toISOString() })], pagination: PAGINATION, + }) + render() + expect(await screen.findByText(expected, { exact: true })).toBeVisible() + }) + + it('should show a local date for activity at least a week old', async () => { + jest.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-10-29T12:00:00Z')) + const timestamp = '2026-10-22T12:00:00Z' + mockAttacks.mockResolvedValue({ items: [attack({ updated_at: timestamp })], pagination: PAGINATION }) + render() + expect(await screen.findByText(new Date(timestamp).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }))) + .toBeVisible() + }) + + it('should identify an invalid activity time without hiding its saved attack', async () => { + mockAttacks.mockResolvedValue({ items: [attack({ updated_at: 'not-a-date' })], pagination: PAGINATION }) + render() + const row = await screen.findByRole('button', { name: /Check the support assistant/ }) + expect(within(row).getByText('Time unavailable', { exact: true })).toBeVisible() + }) + + it.each<[string, string]>([ + ['Attacks', 'No recent attacks'], ['Scans', 'No recent scans'], + ])('should show the %s filter empty state when only the other kind has saved work', async (tab: string, message: string) => { + const user = userEvent.setup() + const availableTitle = tab === 'Attacks' ? 'Safety scan' : 'Check the support assistant' + if (tab === 'Attacks') mockScans.mockResolvedValue({ items: [scan()], pagination: PAGINATION }) + else mockAttacks.mockResolvedValue({ items: [attack()], pagination: PAGINATION }) + render() + await screen.findByRole('button', { name: new RegExp(availableTitle) }) + await user.click(screen.getByRole('tab', { name: tab, exact: true })) + expect(await screen.findByText(message, { exact: true })).toBeVisible() + expect(screen.queryByRole('list')).not.toBeInTheDocument() + expect(screen.queryByText('Your recent work will appear here')).not.toBeInTheDocument() + await user.click(screen.getByRole('tab', { name: 'All', exact: true })) + expect(screen.getByRole('button', { name: new RegExp(availableTitle) })).toBeVisible() + expect(mockAttacks).toHaveBeenCalledTimes(1) + expect(mockScans).toHaveBeenCalledTimes(1) + }) + it.each<[ScenarioRunState, string]>([ ['CREATED', 'Created'], ['QUEUED', 'Queued'], ['IN_PROGRESS', 'Executing'], ['COMPLETED', 'Completed'], ['FAILED', 'Failed'], ['CANCELLED', 'Cancelled'], diff --git a/frontend/src/components/Home/Home.tsx b/frontend/src/components/Home/Home.tsx index 7d24bb6b56..ec84109d06 100644 --- a/frontend/src/components/Home/Home.tsx +++ b/frontend/src/components/Home/Home.tsx @@ -29,16 +29,13 @@ import type { ViewName } from '@/components/Sidebar/Navigation' import { useRuntime } from '@/hooks/useRuntime' import { attacksApi, operationsApi, scenariosApi } from '@/services/api' import { toApiError } from '@/services/errors' -import type { AttackOutcome, AttackSummary, OperationListItem, ScenarioRunListItem, ScenarioRunState } from '@/types' +import type { AttackSummary, OperationListItem, ScenarioRunListItem, ScenarioRunState } from '@/types' import { scenarioRunRoutePath } from '@/utils/routeParams' import { useHomeStyles } from './Home.styles' const RECENT_WORK_LIMIT = 5 const VISIBLE_OPERATIONS_LIMIT = 3 -const ATTACK_OUTCOME_LABELS: Record = { - success: 'Objective achieved', failure: 'Not achieved', error: 'Error', undetermined: 'Undetermined', -} const SCAN_STATUS_LABELS: Record = { CREATED: 'Created', QUEUED: 'Queued', IN_PROGRESS: 'Executing', COMPLETED: 'Completed', FAILED: 'Failed', CANCELLED: 'Cancelled', @@ -146,8 +143,7 @@ export default function Home({ title: attack.objective || attack.last_message_preview || attack.attack_type, operationName: attack.operation_name, updatedAt: attack.updated_at, - badge: , + badge: , open: () => { onOpenAttack(attack.attack_result_id) }, })), ...scans.map((scan: ScenarioRunListItem): RecentItem => ({ From 48fef3a0a5dc256df87f75a74984ebcdfc956cef Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Thu, 29 Oct 2026 09:46:20 -0700 Subject: [PATCH 28/65] TEST: Supply Home scan summaries in the shared-label fixture Add the existing listRuns API read to the unit mock with an empty paginated response. Keep production Home and E2E behavior unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- frontend/src/App.labels.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/App.labels.test.tsx b/frontend/src/App.labels.test.tsx index 7855a41b09..4d10b2286c 100644 --- a/frontend/src/App.labels.test.tsx +++ b/frontend/src/App.labels.test.tsx @@ -65,6 +65,7 @@ jest.mock('@/services/api', () => ({ targetsApi: { listTargets: jest.fn(), getTarget: jest.fn() }, scenariosApi: { listCatalog: jest.fn(), + listRuns: jest.fn(), getScenario: jest.fn(), estimateRun: jest.fn(), startRun: jest.fn(), @@ -186,6 +187,9 @@ describe('Shared new run labels', () => { jest.mocked(scenariosApi.listCatalog).mockResolvedValue({ items: [SCENARIO], pagination: { limit: 200, has_more: false }, }) + jest.mocked(scenariosApi.listRuns).mockResolvedValue({ + items: [], pagination: { limit: 5, has_more: false }, + }) jest.mocked(scenariosApi.estimateRun).mockResolvedValue(SCENARIO.default_run_size) jest.mocked(scenarioPresetsApi.list).mockResolvedValue({ source: '', items: [] }) jest.mocked(scenarioSuitesApi.list).mockResolvedValue({ source: '', items: [] }) From dbe36b7c90cdef694b357bf9766a15a2ac9fbd2c Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Thu, 29 Oct 2026 11:18:27 -0700 Subject: [PATCH 29/65] FIX: Prioritize saved Home links and secondary target setup Put the selected saved operation first, then newest-created records. Reuse the secondary Registry action only for a confirmed empty full target registry. Keep workflow entries and approved Scans wording unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- doc/gui/0_gui.md | 5 +- frontend/src/App.labels.test.tsx | 40 +++++++++++ frontend/src/App.test.tsx | 1 + frontend/src/App.tsx | 2 + frontend/src/components/Home/Home.test.tsx | 67 ++++++++++++++++- frontend/src/components/Home/Home.tsx | 21 ++++-- frontend/src/hooks/useTargetRegistry.test.ts | 75 +++++++++++++++++++- frontend/src/hooks/useTargetRegistry.ts | 8 ++- 8 files changed, 208 insertions(+), 11 deletions(-) diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index 184d30d2d7..ce92416519 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -30,7 +30,10 @@ setup. These actions do not send prompts or start a scan. The sidebar's Each workflow has its own history link. Recent work shows up to five saved attacks and scans; filter by type or select **Refresh Home** to update the list. Open a saved operation to review its work and findings. Registry remains -available below these sections for setup. +available below these sections for setup. Operation links put the selected saved +operation first, then the newest-created records, not the most recently active +records. When the complete registry confirms that no targets are registered, the +secondary setup action offers **Register a target**. ![Home page with workflows and recent work](images/home.png) diff --git a/frontend/src/App.labels.test.tsx b/frontend/src/App.labels.test.tsx index 4d10b2286c..872929b210 100644 --- a/frontend/src/App.labels.test.tsx +++ b/frontend/src/App.labels.test.tsx @@ -271,6 +271,46 @@ describe('Shared new run labels', () => { expect(screen.getByRole('combobox', { name: 'Operation' })).toHaveValue('remembered_op') }) + it('promotes only the saved Operation selected in the labels bar on Home', async () => { + const user = userEvent.setup() + renderApp('/') + await chooseOperation(user, 'remembered_op') + const operations = screen.getByRole('region', { name: 'Operations', exact: true }) + await waitFor(() => { + expect(within(operations).getAllByRole('button')[1]).toHaveAccessibleName('remembered_op') + }) + }) + + it('offers target registration only in the secondary setup area after an authoritative empty registry load', async () => { + let finish: ((response: Awaited>) => void) | undefined + jest.mocked(targetsApi.listTargets).mockReturnValueOnce(new Promise((resolve) => { finish = resolve })) + renderApp('/') + await screen.findByText('Your recent work will appear here') + expect(screen.queryByRole('button', { name: 'Register a target', exact: true })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Open Registry', exact: true })).toBeVisible() + await act(async () => { + if (!finish) throw new Error('Registry load did not start') + finish({ items: [], pagination: { limit: 200, has_more: false } }) + }) + const register = await screen.findByRole('button', { name: 'Register a target', exact: true }) + expect(register.closest('footer')).not.toBeNull() + expect(screen.getByRole('button', { name: 'New attack', exact: true })).toBeEnabled() + expect(screen.getByRole('button', { name: 'Configure scan', exact: true })).toBeEnabled() + expect(targetsApi.listTargets).toHaveBeenCalledTimes(1) + }) + + it('does not treat hidden targets or an unselected objective target as an empty registry', async () => { + writeUserPreferences('local', { + ...DEFAULT_USER_PREFERENCES, + hiddenTargetRegistryNames: [TARGET.target_registry_name], + }) + renderApp('/') + await screen.findByText('Your recent work will appear here') + expect(screen.getByRole('button', { name: 'Open Registry', exact: true })).toBeVisible() + expect(screen.queryByRole('button', { name: 'Register a target', exact: true })).not.toBeInTheDocument() + expect(targetsApi.listTargets).toHaveBeenCalledTimes(1) + }) + it('uses the signed-in alias ahead of stored and backend operators when launching', async () => { const user = userEvent.setup() writeUserPreferences('tenant:signed-in', { diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 1628930b56..c299aa64c5 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -25,6 +25,7 @@ jest.mock("./hooks/useTargetRegistry", () => ({ targets, loading: false, error: null, + isEmpty: false, refresh: jest.fn(), synchronizeTargets: setTargets, rememberTarget: (target: import("./types").TargetInstance) => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9591c9548f..df8d8426dd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -898,6 +898,8 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { path="/" element={ = {}): OperationListIte describe('Home', () => { const defaultProps = { + selectedOperation: null, noRegisteredTargets: false, onNavigate: jest.fn(), onNewAttack: jest.fn(), onOpenAttack: jest.fn(), onOpenAttackHistory: jest.fn(), onOpenScannerHistory: jest.fn(), } @@ -82,7 +83,10 @@ describe('Home', () => { expect(mockAttacks).toHaveBeenCalledWith({ limit: 5, include_scenario_attacks: false }) expect(mockScans).toHaveBeenCalledWith({ limit: 5 }) expect(mockOperations).toHaveBeenCalledTimes(1) - for (const callback of Object.values(defaultProps)) expect(callback).not.toHaveBeenCalled() + for (const callback of [ + defaultProps.onNavigate, defaultProps.onNewAttack, defaultProps.onOpenAttack, + defaultProps.onOpenAttackHistory, defaultProps.onOpenScannerHistory, + ]) expect(callback).not.toHaveBeenCalled() }) it('should use the intentional new-attack action and each workflow history', async () => { @@ -103,6 +107,17 @@ describe('Home', () => { expect(defaultProps.onNavigate).not.toHaveBeenCalledWith('chat') }) + it('should reuse only the secondary setup action when zero registered targets are confirmed', async () => { + const user = userEvent.setup() + render() + const setup = within(screen.getByRole('contentinfo')) + await user.click(setup.getByRole('button', { name: 'Register a target', exact: true })) + expect(defaultProps.onNavigate).toHaveBeenCalledWith('registry') + expect(defaultProps.onNewAttack).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: 'New attack', exact: true })).toBeEnabled() + expect(screen.getByRole('button', { name: 'Configure scan', exact: true })).toBeEnabled() + }) + it('should merge recent work by activity, filter without fetching, and open saved destinations', async () => { const user = userEvent.setup() mockAttacks.mockResolvedValue({ items: [attack()], pagination: PAGINATION }) @@ -226,6 +241,56 @@ describe('Home', () => { expect(screen.getByLabelText('Current path')).toHaveTextContent('/operations/operation%2Fone') }) + it('should place the selected saved ID first, then newest-created available operations without mutating the list', async () => { + const oldest = operation({ id: 'old', name: 'A oldest', created_at: '2026-10-01T00:00:00Z' }) + const selected = operation({ id: 'selected', name: 'B selected', created_at: '2026-10-02T00:00:00Z' }) + const recent = operation({ id: 'recent', name: 'Y newer', created_at: '2026-10-27T00:00:00Z' }) + const newest = operation({ id: 'newest', name: 'Z newest', created_at: '2026-10-28T00:00:00Z' }) + const items = [oldest, selected, recent, newest] + mockOperations.mockResolvedValue({ items }) + const { rerender } = render( + , + ) + const section = screen.getByRole('region', { name: 'Operations', exact: true }) + await within(section).findByText('B selected') + const links = within(section).getAllByRole('button').slice(1) + expect(links).toHaveLength(3) + expect(links[0]).toHaveAccessibleName(/^B selected/) + expect(links[1]).toHaveAccessibleName(/^Z newest/) + expect(links[2]).toHaveAccessibleName(/^Y newer/) + expect(items.map((item: OperationListItem) => item.id)).toEqual(['old', 'selected', 'recent', 'newest']) + expect(within(section).queryByText(/last activity|attacks|total/i)).not.toBeInTheDocument() + rerender() + expect(within(section).getAllByRole('button')[1]).toHaveAccessibleName(/^A oldest/) + rerender() + expect(within(section).getAllByRole('button')[1]).toHaveAccessibleName(/^Z newest/) + expect(mockOperations).toHaveBeenCalledTimes(1) + }) + + it('should rank newest-created operations when no saved operation is selected', async () => { + mockOperations.mockResolvedValue({ items: [ + operation({ id: 'old', name: 'A old', created_at: '2026-10-01T00:00:00Z' }), + operation({ id: 'new', name: 'Z new', created_at: '2026-10-28T00:00:00Z' }), + ] }) + render() + const section = screen.getByRole('region', { name: 'Operations', exact: true }) + await within(section).findByText('Z new') + const links = within(section).getAllByRole('button').slice(1) + expect(links[0]).toHaveAccessibleName(/^Z new/) + expect(links[1]).toHaveAccessibleName(/^A old/) + }) + + it('should keep a newly saved selected operation available before the directory includes it', async () => { + const selected = operation({ id: 'newly-saved', name: 'Newly saved' }) + mockOperations.mockResolvedValue({ items: [operation()] }) + render() + const section = screen.getByRole('region', { name: 'Operations', exact: true }) + await within(section).findByText('Saved operation') + const links = within(section).getAllByRole('button').slice(1) + expect(links[0]).toHaveAccessibleName(/^Newly saved/) + expect(links[1]).toHaveAccessibleName(/^Saved operation/) + }) + it('should keep successful sources visible when another fails and retry without an empty-success fallback', async () => { const user = userEvent.setup() mockAttacks.mockRejectedValueOnce(new Error('Attack service unavailable')) diff --git a/frontend/src/components/Home/Home.tsx b/frontend/src/components/Home/Home.tsx index ec84109d06..6219c92709 100644 --- a/frontend/src/components/Home/Home.tsx +++ b/frontend/src/components/Home/Home.tsx @@ -29,7 +29,7 @@ import type { ViewName } from '@/components/Sidebar/Navigation' import { useRuntime } from '@/hooks/useRuntime' import { attacksApi, operationsApi, scenariosApi } from '@/services/api' import { toApiError } from '@/services/errors' -import type { AttackSummary, OperationListItem, ScenarioRunListItem, ScenarioRunState } from '@/types' +import type { AttackSummary, Operation, OperationListItem, ScenarioRunListItem, ScenarioRunState } from '@/types' import { scenarioRunRoutePath } from '@/utils/routeParams' import { useHomeStyles } from './Home.styles' @@ -46,6 +46,8 @@ const SCAN_STATUS_COLORS: Record = { } interface HomeProps { + readonly selectedOperation: Operation | null + readonly noRegisteredTargets: boolean readonly onNavigate: (view: ViewName) => void readonly onNewAttack: () => void readonly onOpenAttack: (attackResultId: string) => void @@ -90,7 +92,7 @@ function formatRelativeTime(timestamp: string): string { } export default function Home({ - onNavigate, onNewAttack, onOpenAttack, onOpenAttackHistory, onOpenScannerHistory, + selectedOperation, noRegisteredTargets, onNavigate, onNewAttack, onOpenAttack, onOpenAttackHistory, onOpenScannerHistory, }: HomeProps) { const styles = useHomeStyles() const navigate = useNavigate() @@ -126,8 +128,15 @@ export default function Home({ const operationsLoading = !ready || operationResult?.key !== requestKey const attacks = !attacksLoading && attackResult ? attackResult.items : [] const scans = !scansLoading && scanResult ? scanResult.items : [] - const operations = !operationsLoading && operationResult - ? operationResult.items.slice(0, VISIBLE_OPERATIONS_LIMIT) : [] + const availableOperations = !operationsLoading && operationResult + ? [...operationResult.items].sort((first: Operation, second: Operation) => + activityTime(second.created_at) - activityTime(first.created_at)) : [] + const selected = selectedOperation + ? availableOperations.find((operation: Operation) => operation.id === selectedOperation.id) ?? selectedOperation + : null + const operations = (selected + ? [selected, ...availableOperations.filter((operation: Operation) => operation.id !== selected.id)] + : availableOperations).slice(0, VISIBLE_OPERATIONS_LIMIT) const recentLoading = (filter !== 'scans' && attacksLoading) || (filter !== 'attacks' && scansLoading) const recentErrors = [ filter !== 'scans' && !attacksLoading && attackResult?.error @@ -273,7 +282,7 @@ export default function Home({ ) : operations.length > 0 ? (
- {operations.map((operation: OperationListItem) => ( + {operations.map((operation: Operation) => ( + onClick={() => { onNavigate('registry') }}>{noRegisteredTargets ? 'Register a target' : 'Open Registry'}
diff --git a/frontend/src/hooks/useTargetRegistry.test.ts b/frontend/src/hooks/useTargetRegistry.test.ts index f15b9e5a58..26ed0d9cc8 100644 --- a/frontend/src/hooks/useTargetRegistry.test.ts +++ b/frontend/src/hooks/useTargetRegistry.test.ts @@ -1,6 +1,7 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { targetsApi } from '@/services/api' +import { useRuntime } from '@/hooks/useRuntime' import { makeTarget } from '@/test-utils/targetFixtures' import { useTargetRegistry } from './useTargetRegistry' @@ -8,13 +9,18 @@ import { useTargetRegistry } from './useTargetRegistry' jest.mock('@/services/api', () => ({ targetsApi: { listTargets: jest.fn() }, })) +jest.mock('@/hooks/useRuntime', () => ({ useRuntime: jest.fn() })) const listTargets = jest.mocked(targetsApi.listTargets) +const runtime = jest.mocked(useRuntime) const first = makeTarget({ target_registry_name: 'first' }) const second = makeTarget({ target_registry_name: 'second' }) describe('useTargetRegistry', () => { - beforeEach(() => jest.resetAllMocks()) + beforeEach(() => { + jest.resetAllMocks() + runtime.mockReturnValue({ ready: true, state: 'ready', generation: 'first' }) + }) it('loads every page and removes duplicate registry names', async () => { listTargets.mockResolvedValueOnce({ @@ -25,6 +31,7 @@ describe('useTargetRegistry', () => { const { result } = renderHook(useTargetRegistry) await waitFor(() => expect(result.current.loading).toBe(false)) expect(result.current.targets).toEqual([first, second]) + expect(result.current.isEmpty).toBe(false) expect(listTargets).toHaveBeenLastCalledWith(200, 'next') }) @@ -35,6 +42,7 @@ describe('useTargetRegistry', () => { const { result } = renderHook(useTargetRegistry) await waitFor(() => expect(result.current.error).not.toBeNull()) expect(result.current.targets).toEqual([]) + expect(result.current.isEmpty).toBe(false) listTargets.mockResolvedValue({ items: [second], pagination: { limit: 200, has_more: false }, }) @@ -82,4 +90,69 @@ describe('useTargetRegistry', () => { expect(result.current.loading).toBe(false) expect(result.current.error).toBeNull() }) + + it('confirms an empty registry only after a complete successful load', async () => { + let finish: ((response: Awaited>) => void) | undefined + listTargets.mockReturnValueOnce(new Promise((resolve) => { finish = resolve })) + const { result } = renderHook(useTargetRegistry) + expect(result.current.isEmpty).toBe(false) + await act(async () => { + if (!finish) throw new Error('Registry load did not start') + finish({ items: [], pagination: { limit: 200, has_more: false } }) + }) + expect(result.current.isEmpty).toBe(true) + expect(listTargets).toHaveBeenCalledTimes(1) + }) + + it('does not treat an empty first page as an empty registry', async () => { + listTargets.mockResolvedValueOnce({ + items: [], pagination: { limit: 200, has_more: true, next_cursor: 'next' }, + }).mockResolvedValueOnce({ + items: [first], pagination: { limit: 200, has_more: false }, + }) + const { result } = renderHook(useTargetRegistry) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.isEmpty).toBe(false) + expect(result.current.targets).toEqual([first]) + expect(listTargets).toHaveBeenCalledTimes(2) + }) + + it('does not reuse a previous runtime generation as confirmation of an empty registry', async () => { + listTargets.mockResolvedValueOnce({ items: [], pagination: { limit: 200, has_more: false } }) + const { result, rerender } = renderHook(useTargetRegistry) + await waitFor(() => expect(result.current.isEmpty).toBe(true)) + let finish: ((response: Awaited>) => void) | undefined + listTargets.mockReturnValueOnce(new Promise((resolve) => { finish = resolve })) + runtime.mockReturnValue({ ready: true, state: 'ready', generation: 'second' }) + rerender() + expect(result.current.isEmpty).toBe(false) + await act(async () => { + if (!finish) throw new Error('New generation load did not start') + finish({ items: [first], pagination: { limit: 200, has_more: false } }) + }) + expect(result.current.isEmpty).toBe(false) + }) + + it('does not confirm empty while refreshing or after a failed refresh', async () => { + listTargets.mockResolvedValueOnce({ items: [], pagination: { limit: 200, has_more: false } }) + const { result } = renderHook(useTargetRegistry) + await waitFor(() => expect(result.current.isEmpty).toBe(true)) + let fail: ((cause: Error) => void) | undefined + listTargets.mockReturnValueOnce(new Promise((_, reject) => { fail = reject })) + act(() => { result.current.refresh() }) + expect(result.current.isEmpty).toBe(false) + await act(async () => { + if (!fail) throw new Error('Registry refresh did not start') + fail(new Error('Registry unavailable')) + }) + expect(result.current.error).not.toBeNull() + expect(result.current.isEmpty).toBe(false) + }) + + it('does not fetch or confirm empty when the runtime is unavailable', () => { + runtime.mockReturnValue({ ready: false, state: 'unavailable', generation: 'first' }) + const { result } = renderHook(useTargetRegistry) + expect(result.current.isEmpty).toBe(false) + expect(listTargets).not.toHaveBeenCalled() + }) }) diff --git a/frontend/src/hooks/useTargetRegistry.ts b/frontend/src/hooks/useTargetRegistry.ts index 93b7ea919c..34da956e49 100644 --- a/frontend/src/hooks/useTargetRegistry.ts +++ b/frontend/src/hooks/useTargetRegistry.ts @@ -10,6 +10,7 @@ export function useTargetRegistry() { const [targets, setTargets] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [loadedGeneration, setLoadedGeneration] = useState(null) const [revision, setRevision] = useState(0) const pendingUpdates = useRef | null>(null) @@ -25,6 +26,7 @@ export function useTargetRegistry() { setTargets([...merged.values()]) pendingUpdates.current = null setError(null) + setLoadedGeneration(generation) setLoading(false) }).catch((cause: unknown) => { if (cancelled || pendingUpdates.current !== updates) return @@ -53,8 +55,10 @@ export function useTargetRegistry() { pendingUpdates.current = null setTargets(items) setError(null) + setLoadedGeneration(generation) setLoading(false) - }, []) + }, [generation]) - return { targets, loading, error, refresh, rememberTarget, synchronizeTargets } + const isEmpty = ready && !loading && !error && loadedGeneration === generation && targets.length === 0 + return { targets, loading, error, isEmpty, refresh, rememberTarget, synchronizeTargets } } From e08649b87f491205cdb60528ef74a7e41aecf1d6 Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Thu, 29 Oct 2026 12:44:12 -0700 Subject: [PATCH 30/65] TEST: Preserve composer sequence ownership in the App fixture Match the inherited draft handoff sequence guard in the lightweight ChatWindow mock so a New attack reset cannot restore its old unmount snapshot. Leave production draft and navigation protections unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- frontend/src/App.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index c299aa64c5..82a2be2048 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -224,6 +224,7 @@ jest.mock("./components/Chat/ChatWindow", () => { }) => { const location = useLocation(); const draftHandoff = useChatDraftHandoff(); + const composerSequenceRef = useRef(draftHandoff.activeSequence); const [composerText, setComposerText] = useState(() => draftHandoff.preservedDraft?.draft?.text ?? "none"); const activeTextRef = useRef(composerText); useLayoutEffect(() => { @@ -258,6 +259,7 @@ jest.mock("./components/Chat/ChatWindow", () => { }, [attackResultId, activeConversationId, conversationId, location.pathname, location.search, location.hash]); useEffect(() => { + const composerSequence = composerSequenceRef.current; return () => { if (activeTextRef.current && activeTextRef.current !== "none") { preserveDraftRef.current({ @@ -265,7 +267,7 @@ jest.mock("./components/Chat/ChatWindow", () => { originatingConversationId: routeRef.current.convId, originatingPath: routeRef.current.path, draft: { text: activeTextRef.current, attachments: [] }, - }); + }, composerSequence); } }; }, []); From a3faf99069a9c0012cf4f42fc6a8cfc806746419 Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Thu, 29 Oct 2026 14:32:07 -0700 Subject: [PATCH 31/65] TEST: Match picker HTTP fixtures to Home list contracts Return empty paginated attack and scan-run collections from the existing fixture handler and include saved-operation finding counts. Preserve all picker interactions and assertions without production fallbacks or new routes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- frontend/e2e/labels-operation-picker.spec.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/e2e/labels-operation-picker.spec.ts b/frontend/e2e/labels-operation-picker.spec.ts index bc21922b92..ac2d77dbbf 100644 --- a/frontend/e2e/labels-operation-picker.spec.ts +++ b/frontend/e2e/labels-operation-picker.spec.ts @@ -61,6 +61,7 @@ async function setupMocks( id: operationId(index), name, created_at: "2026-01-01T00:00:00Z", + finding_counts: {}, })); // Everything the app calls while booting, so the run does not depend on a // dev-server proxy with no backend behind it. @@ -106,8 +107,11 @@ async function setupMocks( ...json({ detail: "Operation not found" }), status: 404, }); } - if (path === "/attacks") { - return route.fulfill(json({ items: [], total: 0, limit: 5, offset: 0 })); + if (path === "/attacks" || path === "/scenarios/runs") { + return route.fulfill(json({ + items: [], + pagination: { limit: 5, has_more: false, next_cursor: null, prev_cursor: null }, + })); } return route.fulfill(json({})); }); From c9ffd77ccd8648ee919bf64e6e3aaf60eaba4d80 Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Thu, 29 Oct 2026 14:44:00 -0700 Subject: [PATCH 32/65] TEST: Align Home tour cases with workflow-focused entry points Update only the three Home/target onboarding cases for the current workflow anchor and New attack entry. Preserve Registry navigation, last-used target restoration, labels/back navigation, target selection and no-objective-default persistence checks. Add no API mocks or production changes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- frontend/e2e/onboarding-tour.spec.ts | 75 ++++++++++++++++------------ 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/frontend/e2e/onboarding-tour.spec.ts b/frontend/e2e/onboarding-tour.spec.ts index 974214b2fd..1b6842af25 100644 --- a/frontend/e2e/onboarding-tour.spec.ts +++ b/frontend/e2e/onboarding-tour.spec.ts @@ -98,27 +98,25 @@ test.describe("Onboarding tour", () => { await page.getByRole("button", { name: "Take a tour" }).click(); const dialog = page.getByRole("alertdialog"); + await page.getByRole("navigation", { name: "Primary" }) + .getByRole("button", { name: "Registry", exact: true }).click(); + await expect(page).toHaveURL(/\/registry\/targets$/); + await expect(page.getByRole("heading", { name: "Target Registry" })).toBeVisible(); + await expect(dialog).toBeVisible(); await dialog.getByRole("button", { name: "Next", exact: true }).click(); await dialog.getByRole("button", { name: "Next", exact: true }).click(); - await expect(dialog).toContainText( - "Select a target from the Chat dropdown" - ); - await expect(dialog).toContainText("set an adversarial default"); - await expect(dialog).toContainText("for your account in this browser"); - await expect(page.locator('[data-tour="target-card"]')).toBeVisible(); + await expect(dialog).toContainText("Use New attack to start a conversation"); + await expect(dialog).toContainText("Configure scan to choose an automated evaluation"); + await expect(dialog).toContainText("Recent work and saved operations are below"); + await expect(page.locator('[data-tour="home-workflows"]')).toBeVisible(); await page - .getByRole("button", { name: "Configure a target", exact: true }) + .getByRole("button", { name: "New attack", exact: true }) .click(); - await expect(page).toHaveURL(/\/registry\/targets$/); - await expect( - page.getByRole("heading", { name: "Target Registry" }) - ).toBeVisible(); + await expect(page).toHaveURL(/\/attack\/chat$/); await expect(dialog).toBeVisible(); - await expect(dialog).toContainText( - "Select a target from the Chat dropdown" - ); + await expect(dialog).toContainText("Use New attack to start a conversation"); await dialog.getByRole("button", { name: "Back", exact: true }).click(); await expect(page).toHaveURL(/\/$/); @@ -131,9 +129,9 @@ test.describe("Onboarding tour", () => { await dialog.getByRole("button", { name: "Next", exact: true }).click(); await page - .getByRole("button", { name: "Configure a target", exact: true }) + .getByRole("button", { name: "New attack", exact: true }) .click(); - await expect(page).toHaveURL(/\/registry\/targets$/); + await expect(page).toHaveURL(/\/attack\/chat$/); await expect(dialog).toBeVisible(); await dialog.getByRole("button", { name: "Next", exact: true }).click(); @@ -182,38 +180,44 @@ test.describe("Onboarding tour", () => { await seedLastUsedTarget(page); await page.goto("/"); - await expect(page.getByTestId("home-target-active")).toContainText("gpt-4o"); + await expect(page.locator('[data-tour="home-workflows"]')).toBeVisible(); await page.getByRole("button", { name: "Take a tour" }).click(); const dialog = page.getByRole("alertdialog"); + await page.getByRole("navigation", { name: "Primary" }) + .getByRole("button", { name: "Registry", exact: true }).click(); + await expect(page).toHaveURL(/\/registry\/targets$/); + await expect(page.getByRole("heading", { name: "Target Registry" })).toBeVisible(); + await expect(dialog).toBeVisible(); await dialog.getByRole("button", { name: "Next", exact: true }).click(); await dialog.getByRole("button", { name: "Next", exact: true }).click(); - await expect(dialog).toContainText("last-used objective target for new chats and scanner runs"); - await expect(dialog).toContainText("change your adversarial default"); - await expect(page.locator('[data-tour="target-card"]')).toBeVisible(); + await expect(dialog).toContainText("Use New attack to start a conversation"); + await expect(dialog).toContainText("Configure scan to choose an automated evaluation"); + await expect(page.locator('[data-tour="home-workflows"]')).toBeVisible(); await page - .getByRole("button", { name: "Manage targets", exact: true }) + .getByRole("button", { name: "New attack", exact: true }) .click(); - await expect(page).toHaveURL(/\/registry\/targets$/); - await expect( - page.getByRole("heading", { name: "Target Registry" }) - ).toBeVisible(); + await expect(page).toHaveURL(/\/attack\/chat$/); await expect(dialog).toBeVisible(); - await expect(dialog).toContainText("last-used objective target for new chats and scanner runs"); + await expect(dialog).toContainText("Use New attack to start a conversation"); await dialog.getByRole("button", { name: "Next", exact: true }).click(); await expect(page).toHaveURL(/\/attack\/chat$/); await expect(dialog).toContainText("Chat shows the message composer"); await expect(dialog).toContainText("Toggle converter panel"); + await expect(page.getByRole("combobox", { name: "Chat target", exact: true })).toHaveValue("tour-target"); await expect(page.getByRole("textbox", { name: "Type prompt here", exact: true })).toBeVisible(); await expect(page.locator('[data-tour="converter-toggle"]')).toHaveAttribute( "aria-label", "Toggle converter panel" ); await expect(page.getByTestId("no-target-banner")).toHaveCount(0); + expect(await page.evaluate(() => JSON.parse( + localStorage.getItem("pyrit.userPreferences.v1.local") ?? "{}", + ).lastUsedTargets.objective.registryName)).toBe("tour-target"); }); test("selects a per-chat target during the tour without creating an objective default", async ({ @@ -246,17 +250,21 @@ test.describe("Onboarding tour", () => { await page.getByRole("button", { name: "Take a tour" }).click(); const dialog = page.getByRole("alertdialog"); - await dialog.getByRole("button", { name: "Next", exact: true }).click(); - await dialog.getByRole("button", { name: "Next", exact: true }).click(); - await page - .getByRole("button", { name: "Configure a target", exact: true }) - .click(); + await page.getByRole("navigation", { name: "Primary" }) + .getByRole("button", { name: "Registry", exact: true }).click(); await expect(page).toHaveURL(/\/registry\/targets$/); await expect(dialog).toBeVisible(); await expect(page.getByRole("combobox", { name: "Default objective target", exact: true })).toHaveCount(0); - await expect(dialog).toContainText("Select a target from the Chat dropdown"); + await expect(dialog).toContainText("Registry is where you manage targets and converters"); + await dialog.getByRole("button", { name: "Next", exact: true }).click(); + await dialog.getByRole("button", { name: "Next", exact: true }).click(); + await expect(page.locator('[data-tour="home-workflows"]')).toBeVisible(); + await expect(dialog).toContainText("Use New attack to start a conversation"); + await page.getByRole("button", { name: "New attack", exact: true }).click(); + await expect(page).toHaveURL(/\/attack\/chat$/); + await expect(dialog).toBeVisible(); await dialog.getByRole("button", { name: "Next", exact: true }).click(); await expect(page).toHaveURL(/\/attack\/chat$/); @@ -271,6 +279,9 @@ test.describe("Onboarding tour", () => { expect(await page.evaluate(() => JSON.parse( localStorage.getItem("pyrit.userPreferences.v1.local") ?? "{}", ).lastUsedTargets)).toBeUndefined(); + expect(await page.evaluate(() => JSON.parse( + localStorage.getItem("pyrit.userPreferences.v1.local") ?? "{}", + ).targets?.objective)).toBeUndefined(); }); test("recovers the Scanner step when Next is pressed from a scenario detail route", async ({ From 708654e0f1250b0a378298c07e924a24d930ec00 Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Thu, 29 Oct 2026 04:55:01 -0700 Subject: [PATCH 33/65] 8 - FEAT: Contextual Help Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- frontend/README.md | 8 ++ .../src/components/Chat/ChatScorerPicker.tsx | 9 +- .../Chat/ChatTargetPicker.styles.ts | 5 +- .../components/Chat/ChatTargetPicker.test.tsx | 2 + .../src/components/Chat/ChatTargetPicker.tsx | 4 + frontend/src/components/Chat/ChatWindow.tsx | 7 +- .../src/components/Chat/ObjectiveHeader.tsx | 11 ++- .../src/components/Chat/SystemPromptSetup.tsx | 5 +- .../src/components/Config/TargetConfig.tsx | 7 +- .../src/components/Config/TargetSelect.tsx | 9 +- .../components/ContextualHelpLabel.styles.ts | 35 +++++++ .../src/components/ContextualHelpLabel.tsx | 65 +++++++++++++ .../Converters/ConverterChainField.tsx | 9 +- frontend/src/components/HelpField.test.tsx | 94 +++++++++++++++++++ frontend/src/components/HelpField.tsx | 25 +++++ .../Parameters/ParameterField.test.tsx | 44 +++++++++ .../components/Parameters/ParameterField.tsx | 39 ++++---- .../components/Parameters/ReferenceField.tsx | 10 +- .../components/Registry/ConverterRegistry.tsx | 15 ++- .../Registry/CreateConverterDialog.test.tsx | 3 +- .../Registry/CreateConverterDialog.tsx | 40 ++++---- .../components/Registry/RegistryLayout.tsx | 22 +++-- .../components/Registry/ScorerRegistry.tsx | 11 ++- .../components/Registry/TechniqueRegistry.tsx | 13 ++- .../Scenarios/ScenarioDetail.test.tsx | 4 +- .../components/Scenarios/ScenarioDetail.tsx | 64 +++++++------ .../components/Techniques/TechniqueEditor.tsx | 70 +++++++++----- 27 files changed, 502 insertions(+), 128 deletions(-) create mode 100644 frontend/src/components/ContextualHelpLabel.styles.ts create mode 100644 frontend/src/components/ContextualHelpLabel.tsx create mode 100644 frontend/src/components/HelpField.test.tsx create mode 100644 frontend/src/components/HelpField.tsx create mode 100644 frontend/src/components/Parameters/ParameterField.test.tsx diff --git a/frontend/README.md b/frontend/README.md index 4a62c698aa..3ef1a4ef04 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -13,6 +13,14 @@ Your choice is saved in this browser. System follows the operating system's light/dark preference. High-contrast mode overrides every palette and hides decorations without forgetting the selected preset. +## Contextual help + +Hover over a term or field label to read its explanation. You can also focus +help text with Tab and dismiss it with Escape. On touch screens, tap the text +to show help, then tap it again or tap the field to close it. Field descriptions +remain available to assistive technology while the help is closed. Required +markers, validation errors, and input-format guidance stay visible. + ## Conversation selection Opening a saved attack with related conversations automatically opens the diff --git a/frontend/src/components/Chat/ChatScorerPicker.tsx b/frontend/src/components/Chat/ChatScorerPicker.tsx index 79d3ac5eea..691abfbf31 100644 --- a/frontend/src/components/Chat/ChatScorerPicker.tsx +++ b/frontend/src/components/Chat/ChatScorerPicker.tsx @@ -1,5 +1,6 @@ -import { Field, Select, Text } from '@fluentui/react-components' +import { Select, Text } from '@fluentui/react-components' +import HelpField from '@/components/HelpField' import type { RegisteredComponentReference, ScorerInstance } from '@/types' import { findRegisteredScorer, isEligibleChatScorer } from '@/utils/scorerPresentation' @@ -34,7 +35,9 @@ export default function ChatScorerPicker({ : selectedScorer && eligibleScorers.includes(selectedScorer) ? `registered:${selectedScorer.scorer_registry_name}` : value ? 'unavailable' : '' return ( - + - + ) } diff --git a/frontend/src/components/ContextualHelpLabel.styles.ts b/frontend/src/components/ContextualHelpLabel.styles.ts new file mode 100644 index 0000000000..386fd04083 --- /dev/null +++ b/frontend/src/components/ContextualHelpLabel.styles.ts @@ -0,0 +1,35 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +import { mobileTouchTarget } from '@/styles/touchTargets' + +export const useContextualHelpLabelStyles = makeStyles({ + label: { + display: 'inline-flex', + alignItems: 'center', + maxWidth: '100%', + cursor: 'help', + fontSize: 'inherit', + lineHeight: 'inherit', + borderRadius: tokens.borderRadiusSmall, + ':focus-visible': { + outline: `2px solid ${tokens.colorStrokeFocus2}`, + outlineOffset: '2px', + }, + ...mobileTouchTarget, + }, + content: { + maxWidth: '20rem', + maxHeight: '50vh', + overflowY: 'auto', + overflowWrap: 'anywhere', + whiteSpace: 'pre-line', + }, + srOnly: { + position: 'absolute', + width: '1px', + height: '1px', + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + }, +}) diff --git a/frontend/src/components/ContextualHelpLabel.tsx b/frontend/src/components/ContextualHelpLabel.tsx new file mode 100644 index 0000000000..93e11ac1f0 --- /dev/null +++ b/frontend/src/components/ContextualHelpLabel.tsx @@ -0,0 +1,65 @@ +import { useEffect, useRef, useState, type PointerEvent, type ReactNode } from 'react' + +import { + Label, Tooltip, mergeClasses, type LabelProps, type TooltipProps, +} from '@fluentui/react-components' + +import { useContextualHelpLabelStyles } from './ContextualHelpLabel.styles' + +interface ContextualHelpLabelProps { + readonly term: string + readonly children: ReactNode + readonly labelProps?: LabelProps +} + +export default function ContextualHelpLabel({ term, children, labelProps }: ContextualHelpLabelProps) { + const styles = useContextualHelpLabelStyles() + const [visible, setVisible] = useState(false) + const touchOpen = useRef(false) + const labelRef = useRef(null) + const contentRef = useRef(null) + + useEffect(() => { + if (!visible) return + const dismiss = (): void => { + touchOpen.current = false + setVisible(false) + } + const dismissOutside = (event: Event): void => { + const target = event.target + if (target instanceof Node && (labelRef.current?.contains(target) || contentRef.current?.contains(target))) return + dismiss() + } + document.addEventListener('pointerdown', dismissOutside, true) + document.addEventListener('scroll', dismissOutside, true) + document.addEventListener('input', dismiss, true) + return () => { + document.removeEventListener('pointerdown', dismissOutside, true) + document.removeEventListener('scroll', dismissOutside, true) + document.removeEventListener('input', dismiss, true) + } + }, [visible]) + + return ( + >[0], + data: Parameters>[1], + ) => { + if (touchOpen.current && !data.visible && (event?.type === 'blur' || event?.type === 'pointerleave')) return + if (!data.visible) touchOpen.current = false + setVisible(data.visible) + }}> + + + ) +} diff --git a/frontend/src/components/Converters/ConverterChainField.tsx b/frontend/src/components/Converters/ConverterChainField.tsx index 72b08533ca..21053f7cdc 100644 --- a/frontend/src/components/Converters/ConverterChainField.tsx +++ b/frontend/src/components/Converters/ConverterChainField.tsx @@ -1,7 +1,8 @@ import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react' -import { MessageBar, MessageBarBody, Text } from '@fluentui/react-components' +import { MessageBar, MessageBarBody } from '@fluentui/react-components' +import ContextualHelpLabel from '@/components/ContextualHelpLabel' import CreateConverterDialog from '@/components/Registry/CreateConverterDialog' import { convertersApi } from '@/services/api' import { toApiError } from '@/services/errors' @@ -68,7 +69,11 @@ export default function ConverterChainField({ } return
- {label} + + {label === 'Response converters' + ? 'Transform target responses before scoring. Converters run in the order shown.' + : 'Transform prompts before they reach the target, for example by translating or encoding text. Converters run in the order shown.'} + setCreateOpen(true)} diff --git a/frontend/src/components/HelpField.test.tsx b/frontend/src/components/HelpField.test.tsx new file mode 100644 index 0000000000..71da926d8b --- /dev/null +++ b/frontend/src/components/HelpField.test.tsx @@ -0,0 +1,94 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Button, FluentProvider, Input, webLightTheme } from '@fluentui/react-components' + +import HelpField from './HelpField' + +const HELP = 'The model or endpoint you are testing.' + +function ExampleField() { + return ( + + + + + + + ) +} + +describe('HelpField', () => { + beforeEach(() => jest.clearAllMocks()) + + it('should retain the label, description, required state, and visible validation without an information button', () => { + render() + + const input = screen.getByRole('textbox', { name: 'Objective target' }) + expect(input).toBeRequired() + expect(input).toBeInvalid() + expect(input).toHaveAccessibleDescription(expect.stringContaining(HELP)) + expect(screen.getByText('Use a registered name.')).toBeVisible() + expect(screen.getByText('Choose an available target.')).toBeVisible() + expect(screen.getAllByRole('button')).toHaveLength(1) + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() + }) + + it('should show help on the term and keep it available while the pointer is over the help', async () => { + const user = userEvent.setup() + render() + + await user.hover(screen.getByText('Objective target')) + const tooltip = await screen.findByRole('tooltip') + expect(tooltip).toHaveTextContent(HELP) + await user.hover(tooltip) + expect(tooltip).toBeVisible() + await user.unhover(tooltip) + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()) + }) + + it('should support keyboard focus and Escape without trapping focus in the help', async () => { + const user = userEvent.setup() + render() + + await user.tab() + expect(screen.getByText('Objective target')).toHaveFocus() + expect(await screen.findByRole('tooltip')).toHaveTextContent(HELP) + await user.keyboard('{Escape}') + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()) + expect(screen.getByText('Objective target')).toHaveFocus() + await user.tab() + expect(screen.getByRole('textbox', { name: 'Objective target' })).toHaveFocus() + }) + + it('should keep normal label clicks focused on the field without pinning desktop help', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByText('Objective target')) + expect(screen.getByRole('textbox', { name: 'Objective target' })).toHaveFocus() + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()) + }) + + it('should let touch users open help and dismiss it by tapping the field', async () => { + const user = userEvent.setup() + render() + + await user.pointer([ + { keys: '[TouchA>]', target: screen.getByText('Objective target') }, + { keys: '[/TouchA]' }, + ]) + expect(await screen.findByRole('tooltip')).toHaveTextContent(HELP) + await user.click(screen.getByRole('textbox', { name: 'Objective target' })) + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()) + }) + + it('should leave fields without help as normal fields', async () => { + const user = userEvent.setup() + render() + + await user.tab() + expect(screen.getByRole('textbox', { name: 'Name' })).toHaveFocus() + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/HelpField.tsx b/frontend/src/components/HelpField.tsx new file mode 100644 index 0000000000..23e7feffd0 --- /dev/null +++ b/frontend/src/components/HelpField.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react' + +import { Field, type FieldProps, type LabelProps } from '@fluentui/react-components' + +import ContextualHelpLabel from './ContextualHelpLabel' +import { useContextualHelpLabelStyles } from './ContextualHelpLabel.styles' + +interface HelpFieldProps extends Omit { + readonly label: string + readonly help?: string + readonly hint?: ReactNode +} + +/** Keeps the field description available to assistive technology even when its help is closed. */ +export default function HelpField({ label, help, hint, ...props }: HelpFieldProps) { + const styles = useContextualHelpLabelStyles() + return ( + ( + {help} + ) } : label} + hint={help || hint != null ? <>{help && {help}}{hint} : undefined} + /> + ) +} diff --git a/frontend/src/components/Parameters/ParameterField.test.tsx b/frontend/src/components/Parameters/ParameterField.test.tsx new file mode 100644 index 0000000000..7819b69a23 --- /dev/null +++ b/frontend/src/components/Parameters/ParameterField.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' + +import ParameterField from './ParameterField' + +describe('ParameterField help', () => { + beforeEach(() => jest.clearAllMocks()) + + it.each(['str', 'int', 'float', 'Path'])('should not show a raw %s type hint', (typeName: string) => { + render( + + ) + + expect(screen.queryByText(typeName)).not.toBeInTheDocument() + expect(screen.getByLabelText('Setting')).toBeEnabled() + }) + + it('should expose the declared description through hover help and the control description', async () => { + const user = userEvent.setup() + render( + + ) + + expect(screen.getByRole('textbox', { name: 'Objective' })).toBeRequired() + expect(screen.getByRole('textbox', { name: 'Objective' })).toHaveAccessibleDescription('The behavior you want to test.') + await user.hover(screen.getByText('Objective')) + expect(await screen.findByRole('tooltip')).toHaveTextContent('The behavior you want to test.') + }) + + it('should keep list syntax and default guidance visible', () => { + render( + + ) + + expect(screen.getByText(/Defaults to one, two\. Comma-separated list of values\./)).toBeVisible() + }) +}) diff --git a/frontend/src/components/Parameters/ParameterField.tsx b/frontend/src/components/Parameters/ParameterField.tsx index e7b593d49c..9a4c9776e2 100644 --- a/frontend/src/components/Parameters/ParameterField.tsx +++ b/frontend/src/components/Parameters/ParameterField.tsx @@ -1,11 +1,11 @@ import { Checkbox, - Field, Input, Select, Textarea, } from '@fluentui/react-components' +import HelpField from '@/components/HelpField' import type { Parameter } from '@/types' import { useParameterFieldStyles } from './ParameterField.styles' @@ -69,7 +69,8 @@ export default function ParameterField({ const defaultHint = showDefaultHint && hasDisplayDefault ? `Defaults to ${defaultText}.` : null - const descriptiveHint = [parameter.description, defaultHint, extraHint] + const help = parameter.description?.trim() + const descriptiveHint = [defaultHint, extraHint] .filter((part): part is string => Boolean(part)) .join(' ') @@ -77,8 +78,9 @@ export default function ParameterField({ const current = isStructuredParameterFormValue(value) ? value : { type: '', values: {} } return ( <> - {type} ))} - + {(parameter.variants?.[current.type] ?? []).map((nested) => ( Boolean(part)) .join(' ') return ( - onChange(parameter.name, data.value)} data-testid={testId} /> - + ) } if (kind === 'boolean') { const current = value === 'true' || value === 'false' ? value : '' return ( - + - + ) } if (kind === 'multiselect') { const selected = Array.isArray(value) ? value : [] return ( - +
{label} @@ -201,7 +204,7 @@ export default function ParameterField({ ) })}
-
+ ) } @@ -210,7 +213,7 @@ export default function ParameterField({ if (kind === 'select') { return ( - + - + ) } const placeholder = hasDisplayDefault ? showDefaultHint ? `Defaults to ${defaultText}` : defaultText : undefined - const fallbackHint = kind === 'list' ? 'Comma-separated list of values.' : parameter.type_name - const hint = descriptiveHint || fallbackHint + const hint = [descriptiveHint, kind === 'list' ? 'Comma-separated list of values.' : undefined] + .filter((part): part is string => Boolean(part)).join(' ') || undefined if (kind === 'multiline') { return ( - +