Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
dcf012f
FEAT: Add expandable sidebar navigation
richlundeen Oct 29, 2026
4a72b94
FIX: Preserve sidebar tooltips and share responsive queries
richlundeen Oct 29, 2026
ca81736
FEAT: Consolidate attack and scanner history
richlundeen Oct 29, 2026
22faf6b
FIX: Keep editor drafts until new-attack navigation commits
richlundeen Oct 29, 2026
c8c3a56
FIX: Show unknown routes without losing view state
richlundeen Oct 29, 2026
9bd986a
FIX: Return to retained chat work after inspection
richlundeen Oct 29, 2026
2b4b45d
FEAT: Normalize registry identities
richlundeen Oct 29, 2026
9fc4c51
FIX: Align registry browser checks and identity docs
richlundeen Oct 29, 2026
65fe249
FEAT: Reuse last-used chat and scan selections
richlundeen Oct 29, 2026
c548838
FIX: Preserve scorer preferences and repair target setup coverage
richlundeen Oct 29, 2026
0ea5a7a
FIX: Preserve explicit unavailable scorer overrides
richlundeen Oct 29, 2026
4428222
DOC: Clarify the current adversarial fallback order
richlundeen Oct 29, 2026
287e94a
MAINT: Remove obsolete effect lint suppression
richlundeen Oct 29, 2026
7f925d7
Merge origin/main and stabilize chat scoring concurrency regression
richlundeen Oct 29, 2026
7279872
Merge origin/main into PR 3650
richlundeen Oct 31, 2026
050678a
Merge PyRIT 3642 dependency into PR 3650
richlundeen Oct 31, 2026
6aa0feb
Merge latest main before PR repair
richlundeen Oct 31, 2026
aa611d7
fix(frontend): repair consolidated history feedback
richlundeen Nov 1, 2026
3b01664
Merge main into PR 3642 repair baseline
richlundeen Nov 1, 2026
1ac07c1
Restore focused chat scoring test waits
richlundeen Nov 1, 2026
a9f4e93
Merge exact origin/main into PR 3650 preserving consolidated navigation
richlundeen Nov 1, 2026
9f85042
Merge prepared PyRIT PR 3642 dependency into PR 3650
richlundeen Nov 1, 2026
b1b7db5
fix: restore consolidated history validation contracts
richlundeen Nov 1, 2026
3221d05
Fix seeded flow setup for feature-owned New attack control
richlundeen Nov 1, 2026
9f5caca
Merge origin/main into PR #3651
richlundeen Nov 1, 2026
1e4014f
Merge prepared PR #3650 dependency into PR #3651
richlundeen Nov 1, 2026
176ffd4
Fix registry filters and target identity inspection
richlundeen Nov 1, 2026
53d7e77
Merge origin main into last-used defaults
richlundeen Nov 1, 2026
1951754
Merge reviewed PR 3651 dependency into last-used defaults
richlundeen Nov 1, 2026
4d399a1
Fix confirmed-use preferences and delayed target defaults
richlundeen Nov 1, 2026
dadec69
Fix successful chat mock send receipts and reset regression
richlundeen Nov 1, 2026
965afdc
Merge branch 'main' into richlundeen-converter-registry
richlundeen Nov 2, 2026
6a1f5e8
Merge selected main into PR #3651
richlundeen Nov 3, 2026
e4e63c6
Merge dependency PR #3651 into PR #3647
richlundeen Nov 3, 2026
fb492b8
Merge selected main into PR #3647
richlundeen Nov 3, 2026
58b4c8c
Preserve dispatch evidence and refresh stale send metadata
richlundeen Nov 3, 2026
b0b5b10
Cover retained invocation evidence in route contracts
richlundeen Nov 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions doc/gui/0_gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -37,6 +37,17 @@ 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.
Chat use is recorded only after a request turn is confirmed, not after preparation
fails, and stays with the initiating account even if you navigate away. A chat
with a temperature override remembers its registered source target, without
changing that chat's execution binding. While a remembered scorer is loading,
wait for its catalog. Explicit scorer selections, including **Off**, take
precedence; an unavailable remembered scorer is not saved as **Off**.

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
Expand Down Expand Up @@ -85,7 +96,10 @@ 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 explicit scan choice takes precedence. Otherwise, the current
adversarial-target order is a saved Registry default, the registered
`adversarial_chat` target, then last use. Objective targets do not have a Registry
default. Existing chats and runs keep their settings.

Expand a target, scorer, technique, or converter name to inspect its identity
JSON. Target identity inspection is independent of expanding composite target
Expand All @@ -96,6 +110,9 @@ 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.
Suite launch dialogs apply arriving target defaults only to untouched selections.
An explicit target choice, including clearing an optional adversarial target to
use the server default, is retained when the registry refreshes.

Targets, converters, and techniques added only to the live registry must be
recreated after a backend restart or reinitialization. Use
Expand Down
9 changes: 8 additions & 1 deletion frontend/e2e/_fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test as base } from "@playwright/test";
import { test as base, expect, type Page } from "@playwright/test";
import { mockVersion } from "./_compatibility";

export * from "@playwright/test";
Expand All @@ -19,3 +19,10 @@ export const test = base.extend<{ mockStartup: void }>({
await use();
}, { auto: true }],
});

export async function selectChatTarget(page: Page, registryName?: string): Promise<void> {
await page.goto("/attack/chat");
const picker = page.getByRole("combobox", { name: "Chat target", exact: true });
await expect(picker).toBeEnabled();
await picker.selectOption(registryName ?? { index: 1 });
}
18 changes: 3 additions & 15 deletions frontend/e2e/accessibility.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test, expect, type Locator, type Page } from "./_fixtures";
import { test, expect, selectChatTarget, type Locator, type Page } from "./_fixtures";
import { mockVersion } from "./_compatibility";
import { makeTarget } from "./_targets";
import { TOUR_STEPS } from "../src/components/Tour/tourSteps";
Expand Down Expand Up @@ -123,13 +123,7 @@ test.describe("Accessibility", () => {
});
});

// Save an objective default, then open a new chat with that target.
await page.getByRole("button", { name: "Registry", exact: true }).click();
await expect(page.getByText("Target Registry")).toBeVisible({ timeout: 10000 });
const objectiveDefault = page.getByRole("combobox", { name: "Default objective target", exact: true });
await expect(objectiveDefault).toBeVisible({ timeout: 5000 });
await objectiveDefault.selectOption({ index: 1 });
await page.getByRole("button", { name: "Attacks", exact: true }).click();
await selectChatTarget(page, "a11y-form-target");

// Input should be accessible
const input = page.getByRole("textbox", { name: "Type prompt here", exact: true });
Expand Down Expand Up @@ -266,13 +260,7 @@ test.describe("Accessibility", () => {
});
});

// Save an objective default, then open a new chat with that target.
await page.getByRole("button", { name: "Registry", exact: true }).click();
await expect(page.getByText("Target Registry")).toBeVisible({ timeout: 10000 });
const objectiveDefault = page.getByRole("combobox", { name: "Default objective target", exact: true });
await expect(objectiveDefault).toBeVisible({ timeout: 5000 });
await objectiveDefault.selectOption({ index: 1 });
await page.getByRole("button", { name: "Attacks", exact: true }).click();
await selectChatTarget(page, "a11y-focus-target");

const input = page.getByRole("textbox", { name: "Type prompt here", exact: true });
await expect(input).toBeEnabled({ timeout: 5000 });
Expand Down
7 changes: 2 additions & 5 deletions frontend/e2e/chat-recovery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht
import {
test as base,
expect,
selectChatTarget,
type APIRequestContext,
type Page,
type Request,
Expand Down Expand Up @@ -236,11 +237,7 @@ test.describe("Chat processing recovery @seeded", () => {
test.setTimeout(90_000);

test.beforeEach(async ({ page, localTarget }) => {
await page.goto("/");
await page.getByRole("button", { name: "Registry", exact: true }).click();
await page.getByRole("combobox", { name: "Default objective target", exact: true })
.selectOption(localTarget.registryName);
await page.getByRole("button", { name: "Attacks", exact: true }).click();
await selectChatTarget(page, localTarget.registryName);
});

test("keeps the drawer closed when a delayed send refresh discovers another conversation", async ({
Expand Down
90 changes: 62 additions & 28 deletions frontend/e2e/chat.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readFileSync } from "node:fs";
import { test, expect, type Locator, type Page, type Request } from "./_fixtures";
import type { BackendMessage, BackendMessagePiece } from "@/types";
import { test, expect, selectChatTarget, type Locator, type Page, type Request } from "./_fixtures";
import type { BackendMessage, BackendMessagePiece, TargetResponseStatus } from "@/types";
import { fulfillMessageSend, makeAddMessageResponse } from "./_attacks";
import { READY_RUNTIME } from "./_runtime";
import { makeTarget } from "./_targets";
Expand All @@ -21,6 +21,19 @@ function getMessageByText(page: Page, text: string): Locator {
return page.getByTestId("message-list").getByText(text, { exact: true });
}

async function readMockMessages(page: Page): Promise<{
messages: BackendMessage[];
target_response_status: TargetResponseStatus;
}> {
return page.evaluate(async (conversationId: string) => {
const response = await fetch(`/api/attacks/e2e-attack-001/messages?conversation_id=${conversationId}`);
if (!response.ok) {
throw new Error(`Message read failed: ${response.status}`);
}
return response.json();
}, MOCK_CONVERSATION_ID);
}

/** Intercept targets & attacks APIs so the chat flow can run without real keys. */
async function mockBackendAPIs(page: Page) {
// Accumulate messages so multi-turn tests get full history back
Expand Down Expand Up @@ -64,7 +77,7 @@ async function mockBackendAPIs(page: Page) {
// Ignore parse errors
}

const turnNumber = Math.floor(accumulatedMessages.length / 2) + 1;
const turnNumber = accumulatedMessages.length;
const userMsg: BackendMessage = {
turn_number: turnNumber,
role: "user",
Expand All @@ -82,7 +95,7 @@ async function mockBackendAPIs(page: Page) {
],
};
const assistantMsg: BackendMessage = {
turn_number: turnNumber,
turn_number: turnNumber + 1,
role: "assistant",
created_at: new Date().toISOString(),
message_pieces: [
Expand All @@ -103,7 +116,11 @@ async function mockBackendAPIs(page: Page) {

await fulfillMessageSend(route, makeAddMessageResponse(
"e2e-attack-001", MOCK_CONVERSATION_ID, [...accumulatedMessages],
null, {
{
response_error: "none",
request_turn_number: turnNumber,
response_turn_number: turnNumber + 1,
}, {
target: {
target_type: "OpenAIChatTarget", identifier_hash: "mock-openai-chat-hash",
endpoint: "https://mock.openai.com", model_name: "gpt-4o-mock",
Expand Down Expand Up @@ -140,20 +157,9 @@ async function mockBackendAPIs(page: Page) {
});
}

/** Save the mock target as the objective default, then open a new chat. */
/** Select the mock target in a new chat. */
async function activateMockTarget(page: Page) {
// Click the Registry button in the sidebar
await page.getByRole("button", { name: "Registry", exact: true }).click();
await expect(page.getByText("Target Registry")).toBeVisible({ timeout: 10000 });

// Set the objective default for this browser profile.
const objectiveDefault = page.getByRole("combobox", { name: "Default objective target", exact: true });
await expect(objectiveDefault).toBeVisible({ timeout: 5000 });
await objectiveDefault.selectOption({ index: 1 });

// Return to Chat view
await page.getByRole("button", { name: "Attacks", exact: true }).click();
await expect(page.getByRole("button", { name: "New attack", exact: true })).toBeVisible({ timeout: 5000 });
await selectChatTarget(page);
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -776,6 +782,9 @@ test.describe("Multi-turn conversation flow", () => {
await expect(
page.getByText("Mock response for: First turn"),
).toBeVisible({ timeout: 10000 });
expect((await readMockMessages(page)).target_response_status).toEqual({
response_error: "none", request_turn_number: 0, response_turn_number: 1,
});

// Turn 2
await input.fill("Second turn");
Expand All @@ -784,6 +793,9 @@ test.describe("Multi-turn conversation flow", () => {
await expect(
page.getByText("Mock response for: Second turn"),
).toBeVisible({ timeout: 10000 });
expect((await readMockMessages(page)).target_response_status).toEqual({
response_error: "none", request_turn_number: 2, response_turn_number: 3,
});

// Turn 3
await input.fill("Third turn");
Expand All @@ -798,10 +810,31 @@ test.describe("Multi-turn conversation flow", () => {
await expect(getMessageByText(page, "Second turn")).toBeVisible();
await expect(getMessageByText(page, "Third turn")).toBeVisible();
expect(createdAttacks).toBe(1);
const messages = await readMockMessages(page);
expect(messages.target_response_status).toEqual({
response_error: "none", request_turn_number: 4, response_turn_number: 5,
});
expect(messages.messages.map((message: BackendMessage) => ({
role: message.role,
turn_number: message.turn_number,
}))).toEqual([
{ role: "user", turn_number: 0 },
{ role: "assistant", turn_number: 1 },
{ role: "user", turn_number: 2 },
{ role: "assistant", turn_number: 3 },
{ role: "user", turn_number: 4 },
{ role: "assistant", turn_number: 5 },
]);
});

test("should reset conversation on New Chat and send again", async ({ page }) => {
const input = page.getByTestId("chat-input");
let createdAttacks = 0;
page.on("request", (request: Request) => {
if (request.method() === "POST" && new URL(request.url()).pathname === "/api/attacks") {
createdAttacks += 1;
}
});

// Send a message
await input.fill("Before reset");
Expand All @@ -810,10 +843,16 @@ test.describe("Multi-turn conversation flow", () => {
await expect(
page.getByText("Mock response for: Before reset"),
).toBeVisible({ timeout: 10000 });
expect((await readMockMessages(page)).target_response_status).toEqual({
response_error: "none", request_turn_number: 0, response_turn_number: 1,
});

// New Attack
await page.getByRole("button", { name: "New attack", exact: true }).click();
await expect(getMessageByText(page, "Before reset")).not.toBeVisible();
await expect(input).toHaveValue("");
await expect(page.getByRole("combobox", { name: "Chat target", exact: true }))
.toHaveValue("mock-openai-chat");

// Send new message in fresh conversation
await input.fill("After reset");
Expand All @@ -822,6 +861,10 @@ test.describe("Multi-turn conversation flow", () => {
await expect(
page.getByText("Mock response for: After reset"),
).toBeVisible({ timeout: 10000 });
expect((await readMockMessages(page)).target_response_status).toEqual({
response_error: "none", request_turn_number: 0, response_turn_number: 1,
});
expect(createdAttacks).toBe(2);
});
});

Expand Down Expand Up @@ -892,16 +935,7 @@ test.describe("Target type scenarios", () => {
}
});

await page.goto("/");
await page.getByRole("button", { name: "Registry", exact: true }).click();
await expect(page.getByText("dall-e-3", { exact: true })).toBeVisible({ timeout: 10000 });

// Save the DALL-E target as the objective default.
await page.getByRole("combobox", { name: "Default objective target", exact: true })
.selectOption("dall-e-image-gen");

// Navigate to chat
await page.getByRole("button", { name: "Attacks", exact: true }).click();
await selectChatTarget(page, "dall-e-image-gen");
// 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");
Expand Down
40 changes: 13 additions & 27 deletions frontend/e2e/config.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test, expect, type Locator, type Page } from "./_fixtures";
import { test, expect, selectChatTarget, type Locator, type Page } from "./_fixtures";
import { makeTarget, type FlatTarget } from "./_targets";
import { READY_RUNTIME } from "./_runtime";

Expand All @@ -24,6 +24,7 @@ const SAMPLE_TARGETS = [
target_type: "OpenAIChatTarget",
endpoint: "https://api.openai.com",
model_name: "gpt-4o",
capabilities: { supports_multi_turn: true },
},
{
target_registry_name: "target-image-1",
Expand Down Expand Up @@ -272,7 +273,7 @@ test.describe("Target Registry Page", () => {
await expect(page.getByText(/error/i)).toBeVisible({ timeout: 10000 });
});

test("should set a default objective target", async ({ page }) => {
test("should retain the adversarial default without an objective default control", async ({ page }) => {
await page.route(/\/api\/targets/, async (route) => {
await route.fulfill(mockTargetsList(SAMPLE_TARGETS));
});
Expand All @@ -281,12 +282,12 @@ test.describe("Target Registry Page", () => {
await expect(page.getByText("gpt-4o", { exact: true })).toBeVisible({ timeout: 10000 });

const defaults = page.getByRole("region", { name: "Target defaults" });
const objectiveDefault = defaults.getByRole("combobox", { name: "Default objective target", exact: true });
await expect(objectiveDefault).toBeVisible();
await objectiveDefault.selectOption("target-chat-1");
await expect(defaults.getByRole("combobox", { name: "Default objective target", exact: true })).toHaveCount(0);
const adversarialDefault = defaults.getByRole("combobox", { name: "Default adversarial target", exact: true });
await adversarialDefault.selectOption("target-chat-1");

await expect(page.getByTestId("target-row-target-chat-1").getByText("Objective", { exact: true })).toBeVisible();
await expect(objectiveDefault).toHaveValue("target-chat-1");
await expect(page.getByTestId("target-row-target-chat-1").getByText("Adversarial", { exact: true })).toBeVisible();
await expect(adversarialDefault).toHaveValue("target-chat-1");
});

test("should open create target dialog", async ({ page }) => {
Expand Down Expand Up @@ -671,20 +672,16 @@ test.describe("Responsive Target Registry", () => {
});

test.describe("Target Config ↔ Chat Navigation", () => {
test("should preselect the objective default in a new chat", async ({ page }) => {
test("should select a target in a new chat without a Registry objective default", async ({ page }) => {
await page.route(/\/api\/targets/, async (route) => {
await route.fulfill(mockTargetsList(SAMPLE_TARGETS));
});

await goToTargets(page);
await expect(page.getByText("gpt-4o", { exact: true })).toBeVisible({ timeout: 10000 });

// Set the first target as the objective default.
await page.getByRole("combobox", { name: "Default objective target", exact: true }).selectOption({ index: 1 });

// Navigate back to chat
await page.getByRole("button", { name: "Attacks", exact: true }).click();
await expect(page.getByRole("button", { name: "New attack", exact: true })).toBeVisible();
await expect(page.getByRole("combobox", { name: "Default objective target", exact: true })).toHaveCount(0);
await selectChatTarget(page, "target-chat-1");

// Chat should show the selected target type. Scope to the badge to
// avoid matching the (hidden) tooltip copy of the same text.
Expand All @@ -706,20 +703,9 @@ test.describe("Target Config ↔ Chat Navigation", () => {
await draft.fill("Draft without a target");
await expect(page.getByRole("button", { name: "Send message" })).toBeDisabled();

// Go to targets, set a target
await page.getByRole("button", { name: "Registry", exact: true }).click();
await expect(page.getByText("gpt-4o", { exact: true })).toBeVisible({ timeout: 10000 });
await page.getByRole("combobox", { name: "Default objective target", exact: true }).selectOption({ index: 1 });

// Returning retains the original draft; a new attack adopts the new default.
await page.getByRole("button", { name: "Attacks", exact: true }).click();
await expect(draft).toHaveValue("Draft without a target");
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled();
await page.getByRole("button", { name: "New attack", exact: true }).click();
await page.getByRole("combobox", { name: "Chat target", exact: true }).selectOption("target-chat-1");
const input = page.getByRole("textbox", { name: "Type prompt here", exact: true });
await expect(input).toHaveValue("");
await expect(page.getByRole("combobox", { name: "Chat target", exact: true })).toHaveValue("target-chat-1");
await input.fill("Hello");
await expect(input).toHaveValue("Draft without a target");
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
});
});
Loading
Loading