diff --git a/frontend/README.md b/frontend/README.md index 35811b6669..6ab29a507c 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -21,6 +21,34 @@ refreshes do not override your open/closed choice. Creating, copying, or repeati conversations still opens the desktop drawer. On narrow screens the drawer opens only when requested and closes after selecting a conversation. +Each saved turn, including a system turn, has a **Copy or Branch** menu with +visible descriptions for each action. **Copy to next message** copies only that message +to the prompt box. **Branch conversation** and **Branch attack** copy history +including that turn into the same attack or a new attack (a separate group of +conversations), open the +conversation panel, and select the copy at its full +`/attacks/{attack_id}/conversations/{conversation_id}` URL. Copying +within a target-free attack keeps it target-free until you send a prompt. +When you save an edited conversation, its final user turn moves to the prompt +box instead of saved history. Text and attachments retain their metadata, +including text-only video remix IDs. You can enter and edit prompts without a target. +Send stays disabled until you select a target; no target-warning box is shown. + +**Use in scan** opens the configuration to apply a PyRIT attack technique after +prepending history including the selected turn. It saves a target-free copy +and opens `/scanner/technique.execute?prepended_conversation_id={conversation_id}`. +The scanner configuration fills **prepended_conversation_id** from this URL. +There is no clipboard step, and opening the configuration does not launch a scan. +Select **Add new conversation** beside that field to build simulated history, +or **Edit conversation** to load its current history. **Add to attack** saves all +turns to a new conversation and updates the field. It does not change the source +history, send prompts, or launch a scan. These controls are not in Registry. +Scan launch is disabled while a conversation loads or its editor is open. +The **Apply converters to conversation** toggle beside **Edit conversation** is +off by default and disabled when the conversation ID is empty. When enabled, +request converters run on the stored history during the scan, without changing +the source conversation. New outgoing turns are always converted. + ## Dataset catalog The **Datasets** page shows summaries of datasets already loaded into the backend's diff --git a/frontend/e2e/chat-recovery.spec.ts b/frontend/e2e/chat-recovery.spec.ts index ddc5985f4c..49cdc8f0ac 100644 --- a/frontend/e2e/chat-recovery.spec.ts +++ b/frontend/e2e/chat-recovery.spec.ts @@ -524,9 +524,9 @@ test.describe("Chat processing recovery @seeded", () => { const bubbles = page.getByTestId(/^message-bubble-/); await expect(bubbles).toHaveCount(2); await expect(bubbles.first()).toContainText("Explain the target error"); - await expect(bubbles.first().getByRole("button", { name: "Copy conversation", exact: true })).toBeVisible(); + await expect(bubbles.first().getByRole("button", { name: "Copy or Branch", exact: true })).toBeVisible(); await expect(bubbles.last()).toContainText("JSONDecodeError"); - await expect(bubbles.last().getByRole("button", { name: "Copy conversation", exact: true })).toHaveCount(0); + await expect(bubbles.last().getByRole("button", { name: "Copy or Branch", exact: true })).toHaveCount(0); await page.reload(); await expect(bubbles).toHaveCount(2); await expect(bubbles.first()).toContainText("Explain the target error"); @@ -547,7 +547,7 @@ test.describe("Chat processing recovery @seeded", () => { contentType: "image/png", }); await page.keyboard.press("Escape"); - await bubbles.first().getByRole("button", { name: "Copy conversation", exact: true }).click(); + await bubbles.first().getByRole("button", { name: "Copy or Branch", exact: true }).click(); await test.info().attach("original-prompt-copy", { body: await page.screenshot({ animations: "disabled" }), contentType: "image/png", @@ -556,7 +556,7 @@ test.describe("Chat processing recovery @seeded", () => { const [cloneResponse] = await Promise.all([ page.waitForResponse((response) => response.request().method() === "POST" && new URL(response.url()).pathname === `/api/attacks/${attackId}/conversations`), - page.getByRole("menuitem", { name: "New conversation", exact: true }).click(), + page.getByRole("menuitem", { name: "Branch conversation", exact: true }).click(), ]); expect(cloneResponse.status()).toBe(201); expect(cloneResponse.request().postDataJSON()).toEqual({}); @@ -612,8 +612,8 @@ test.describe("Chat processing recovery @seeded", () => { await disabledPrompt.click({ position: { x: 10, y: 10 } }); await expect(page.getByText(/This conversation contains a target error/)).toBeVisible(); await page.keyboard.press("Escape"); - await page.getByRole("button", { name: "Copy conversation", exact: true }).last().click(); - const recover = page.getByRole("menuitem", { name: "New conversation", exact: true }); + await page.getByRole("button", { name: "Copy or Branch", exact: true }).last().click(); + const recover = page.getByRole("menuitem", { name: "Branch conversation", exact: true }); await expect(recover).toBeEnabled(); await test.info().attach("processing-recovery", { body: await page.screenshot(), @@ -665,11 +665,11 @@ test.describe("Chat processing recovery @seeded", () => { const failed = await sendFromComposer(page, "Edit this failed prompt"); const sourceAttackId = failed.attack.attack_result_id; if (reload) await page.reload(); - await page.getByRole("button", { name: "Copy conversation", exact: true }).last().click(); + await page.getByRole("button", { name: "Copy or Branch", exact: true }).last().click(); const [savedResponse] = await Promise.all([ page.waitForResponse((response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/attacks/save-conversation"), - page.getByRole("menuitem", { name: "New attack", exact: true }).click(), + page.getByRole("menuitem", { name: "Branch attack", exact: true }).click(), ]); expect(savedResponse.status(), await savedResponse.text()).toBe(200); const saved: AddMessageResponse = await savedResponse.json(); @@ -728,11 +728,11 @@ test.describe("Chat processing recovery @seeded", () => { const originalImage = failed.messages.messages[0].message_pieces[1]; expect(originalImage.original_filename).toBeTruthy(); await page.reload(); - await page.getByRole("button", { name: "Copy conversation", exact: true }).last().click(); + await page.getByRole("button", { name: "Copy or Branch", exact: true }).last().click(); const [createdResponse] = await Promise.all([ page.waitForResponse((response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/attacks"), - page.getByRole("menuitem", { name: "New attack", exact: true }).click(), + page.getByRole("menuitem", { name: "Branch attack", exact: true }).click(), ]); expect(createdResponse.status()).toBe(201); const created: CreateAttackResponse = await createdResponse.json(); @@ -871,8 +871,8 @@ test.describe("Chat processing recovery @seeded", () => { await page.getByTestId("remove-attachment-0").click(); await expect(page.getByTestId("clear-media-conversion-image")).toHaveCount(0); await selectConversation(page, first.attack.conversation_id); - await page.getByRole("button", { name: "Copy conversation", exact: true }).last().click(); - const recover = page.getByRole("menuitem", { name: "New conversation", exact: true }); + await page.getByRole("button", { name: "Copy or Branch", exact: true }).last().click(); + const recover = page.getByRole("menuitem", { name: "Branch conversation", exact: true }); await expect(recover).toBeEnabled(); await page.evaluate(() => { document.documentElement.dataset.deferRecoveryReads = "true"; }); try { diff --git a/frontend/e2e/chat.spec.ts b/frontend/e2e/chat.spec.ts index d75df13f55..61250a7f6d 100644 --- a/frontend/e2e/chat.spec.ts +++ b/frontend/e2e/chat.spec.ts @@ -372,12 +372,17 @@ test.describe("Multiple Messages", () => { }); test.describe("Chat without target", () => { - test("should disable input when no target is selected", async ({ page }) => { + test("should allow drafting but block sending when no target is selected", async ({ page }) => { await page.goto("/"); await page.getByTitle("Technique.execute").click(); - await expect(page.getByRole("textbox")).toBeDisabled(); + const input = page.getByRole("textbox"); + await expect(input).toBeEnabled(); + await input.fill("Unsent draft"); await expect(page.getByRole("button", { name: "Send message" })).toBeDisabled(); + await input.press("Enter"); + await expect(input).toHaveValue("Unsent draft"); + await expect(page.getByTestId("no-target-banner")).toHaveCount(0); }); }); diff --git a/frontend/e2e/config.spec.ts b/frontend/e2e/config.spec.ts index a77f466179..1a63eab1f9 100644 --- a/frontend/e2e/config.spec.ts +++ b/frontend/e2e/config.spec.ts @@ -710,15 +710,16 @@ test.describe("Target Config ↔ Chat Navigation", () => { await expect(badge).toContainText(/gpt-4o/); }); - test("should enable chat input after a target is set", async ({ page }) => { + test("should enable sending after a target is set", async ({ page }) => { await page.route(/\/api\/targets/, async (route) => { await route.fulfill(mockTargetsList(SAMPLE_TARGETS)); }); - // Start in chat with the composer disabled until a target is selected. await page.goto("/"); await page.getByTitle("Technique.execute").click(); - await expect(page.getByRole("textbox")).toBeDisabled(); + await expect(page.getByRole("textbox")).toBeEnabled(); + await page.getByRole("textbox").fill("Draft without a target"); + await expect(page.getByRole("button", { name: "Send message" })).toBeDisabled(); // Go to targets, set a target await page.getByTitle("Registry").click(); diff --git a/frontend/e2e/errors.spec.ts b/frontend/e2e/errors.spec.ts index 444c6cef12..5e5a4dfde2 100644 --- a/frontend/e2e/errors.spec.ts +++ b/frontend/e2e/errors.spec.ts @@ -352,8 +352,8 @@ test.describe("Error: accepted send with target processing failure", () => { await page.getByTestId(/^message-bubble-/) .filter({ hasText: "Preserve this draft" }) - .getByRole("button", { name: "Copy conversation", exact: true }).click(); - await page.getByRole("menuitem", { name: "New conversation", exact: true }).click(); + .getByRole("button", { name: "Copy or Branch", exact: true }).click(); + await page.getByRole("menuitem", { name: "Branch conversation", exact: true }).click(); await expect.poll(() => recoveryRequest).toEqual({ source_conversation_id: MOCK_CONV_ID, cutoff_index: 1, diff --git a/frontend/e2e/flows.spec.ts b/frontend/e2e/flows.spec.ts index ec8a478e13..dcbba48681 100644 --- a/frontend/e2e/flows.spec.ts +++ b/frontend/e2e/flows.spec.ts @@ -842,7 +842,7 @@ for (const variant of TARGET_VARIANTS) { } await page.getByTestId("copy-to-input-btn-1").click(); - await page.getByRole("menuitem", { name: "New conversation", exact: true }).click(); + await page.getByRole("menuitem", { name: "Branch conversation", exact: true }).click(); } else { // Single-turn targets disable branch buttons in the UI. // Branch via the API instead to test the backend operation. diff --git a/frontend/e2e/touch-targets.spec.ts b/frontend/e2e/touch-targets.spec.ts index 7618194d20..e8934250ff 100644 --- a/frontend/e2e/touch-targets.spec.ts +++ b/frontend/e2e/touch-targets.spec.ts @@ -726,9 +726,14 @@ test.describe("Mobile touch targets", () => { await expectNoDocumentOverflow(page); await page.getByTestId("copy-to-input-btn-1").click(); - for (const name of ["This conversation", "New conversation", "New attack"]) { + for (const name of ["Copy to next message", "Branch conversation", "Branch attack", "Use in scan"]) { await expectMinimumTouchTarget(page.getByRole("menuitem", { name, exact: true })); } + await expect(page.getByText("Copy only this message to the prompt box.", { exact: true })).toBeVisible(); + await expect(page.getByText("Copy history including this turn into the same attack.", { exact: true })).toBeVisible(); + await expect(page.getByText("Copy history including this turn into a new attack (i.e., separate group of conversations).", { exact: true })).toBeVisible(); + await expect(page.getByText("Apply a PyRIT attack technique after prepending history including this turn.", { exact: true })).toBeVisible(); + await expectNoDocumentOverflow(page); await page.keyboard.press("Escape"); const outcomeButton = page.getByRole("button", { diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 48fbe303a8..dafcac68ce 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1265,7 +1265,8 @@ describe("App", () => { mockCreatedAttackRender = null; finishRender(); }); - expect(screen.getByTestId("route-location")).toHaveTextContent("/attacks/ar-123"); + expect(screen.getByTestId("route-location").textContent).toBe("/attacks/ar-123/conversations/conv-123"); + expect(screen.getByTestId("active-conversation-id")).toHaveTextContent("conv-123"); expect(screen.getByRole("button", { name: "Set Conv" })).toBeEnabled(); expect(mockGetAttack).not.toHaveBeenCalled(); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bc1fea1e7e..1c0d020afe 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -546,11 +546,11 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { lastResponseMessagePieceId: null, status: 'success', }) - // Replace when promoting an empty /chat to its attack url (first message); + // Replace when promoting an empty /chat to its conversation URL (first message); // push when branching from an existing attack so Back returns to the source. // Keep sends blocked until the new route exposes the created attack's identity. startCreatedAttackTransition(() => { - navigate(attackRoutePath(arId), { replace: routeAttackId === null }) + navigate(attackConversationRoutePath(arId, convId), { replace: routeAttackId === null }) }) }, [activeTarget, generation, routeAttackId, navigate, startCreatedAttackTransition]) diff --git a/frontend/src/components/Chat/ChatInputArea.test.tsx b/frontend/src/components/Chat/ChatInputArea.test.tsx index afbc48c56e..3e9ad46be5 100644 --- a/frontend/src/components/Chat/ChatInputArea.test.tsx +++ b/frontend/src/components/Chat/ChatInputArea.test.tsx @@ -88,19 +88,21 @@ describe("ChatInputArea", () => { expect(onToggleConverterPanel).toHaveBeenCalledTimes(1); }); - it("should explain a missing target when the disabled composer is clicked", async () => { + it("should allow drafting without a target and keep Send disabled without a warning box", async () => { const user = userEvent.setup(); render( - + ); - expect(screen.getByRole("textbox")).toBeDisabled(); + expect(screen.getByRole("textbox")).toBeEnabled(); expect(getSendButton()).toBeDisabled(); + await user.type(screen.getByRole("textbox"), "Unsent draft"); expect(screen.queryByText(/No target is selected/)).not.toBeInTheDocument(); - await user.click(screen.getByLabelText("Why the prompt box is disabled")); - expect(screen.getByRole("listitem")).toHaveTextContent("No target is selected."); + expect(screen.queryByLabelText("Why the prompt box is disabled")).not.toBeVisible(); + await user.click(getSendButton()); + expect(defaultProps.onSend).not.toHaveBeenCalled(); }); it("should show all disabled reasons on hover and keyboard activation", async () => { @@ -110,7 +112,7 @@ describe("ChatInputArea", () => { const composer = screen.getByLabelText("Why the prompt box is disabled"); await user.hover(composer); expect(await screen.findByText("Prompt input is disabled")).toBeInTheDocument(); - expect(screen.getAllByRole("listitem")).toHaveLength(3); + expect(screen.getAllByRole("listitem")).toHaveLength(2); expect(screen.getByText("This conversation contains a target error.")).toBeInTheDocument(); await user.keyboard("{Escape}"); composer.focus(); @@ -946,6 +948,28 @@ describe("ChatInputArea", () => { expect(new Set(withNewCopy.map((attachment: MessageAttachment) => attachment.draftId)).size).toBe(3); }); + it("should retain restored text metadata on edits and retries and clear it with the draft", async () => { + const user = userEvent.setup(); + const ref = React.createRef(); + const metadata = { video_id: "existing-video" }; + const onSend = jest.fn().mockResolvedValueOnce(retryableOutcome).mockResolvedValue(sentOutcome); + render(); + React.act(() => { ref.current?.restoreDraft("Change the background", [], metadata); }); + await user.type(screen.getByRole("textbox"), " to blue"); + expect(ref.current?.getTextMetadata()).toEqual(metadata); + await user.click(getSendButton()); + expect(ref.current?.getTextMetadata()).toEqual(metadata); + await user.click(getSendButton()); + await waitFor(() => expect(screen.getByRole("textbox")).toHaveValue("")); + expect(ref.current?.getTextMetadata()).toBeUndefined(); + React.act(() => { ref.current?.restoreDraft("Another remix", [], metadata); }); + React.act(() => { ref.current?.setText("An unrelated copied prompt"); }); + expect(ref.current?.getTextMetadata()).toBeUndefined(); + React.act(() => { ref.current?.restoreDraft("Another remix", [], metadata); }); + await user.clear(screen.getByRole("textbox")); + expect(ref.current?.getTextMetadata()).toBeUndefined(); + }); + it("should keep uploaded duplicate filenames distinct and preserve remaining identity on removal and send", async () => { const user = userEvent.setup(); const onAttachmentsChange = jest.fn(); diff --git a/frontend/src/components/Chat/ChatInputArea.tsx b/frontend/src/components/Chat/ChatInputArea.tsx index 5fd0efe4d7..05c28a352a 100644 --- a/frontend/src/components/Chat/ChatInputArea.tsx +++ b/frontend/src/components/Chat/ChatInputArea.tsx @@ -97,9 +97,10 @@ function getUnsupportedConverterOutputTypes(converterOutputDataTypes: string[], export interface ChatInputAreaHandle { addAttachment: (attachment: MessageAttachment) => void setText: (text: string) => void - restoreDraft: (text: string, attachments: MessageAttachment[]) => void + restoreDraft: (text: string, attachments: MessageAttachment[], textMetadata?: Record) => void focus: () => void getDraftRevision: () => number + getTextMetadata: () => Record | undefined } interface ChatInputAreaProps { @@ -161,12 +162,10 @@ const ChatInputArea = forwardRef(functi const disabledOverlayRef = useRef(null) const inputRef = useRef(input) const attachmentsRef = useRef(attachments) + const textMetadataRef = useRef | undefined>(undefined) const draftRevisionRef = useRef(0) const previousConversionRevisionKeyRef = useRef(conversionRevisionKey) const inputDisabledReasons = [...disabledReasons] - if (!activeTarget && disabled) { - inputDisabledReasons.push('No target is selected. Select a target to enter a prompt.') - } if (disabled && inputDisabledReasons.length === 0) { inputDisabledReasons.push('The prompt box is temporarily unavailable. Wait for the current operation to finish.') } @@ -181,6 +180,7 @@ const ChatInputArea = forwardRef(functi useLayoutEffect(() => { attachmentsRef.current = attachments; onAttachmentsChange(attachments) }, [attachments, onAttachmentsChange]) const changeText = (text: string): void => { + if (!text) textMetadataRef.current = undefined inputRef.current = text draftRevisionRef.current += 1 setInput(text) @@ -197,13 +197,18 @@ const ChatInputArea = forwardRef(functi addAttachment: (attachment: MessageAttachment): void => { changeAttachments([...attachmentsRef.current, withDraftIdentity({ ...attachment, draftId: undefined })]) }, - setText: changeText, - restoreDraft: (text: string, draftAttachments: MessageAttachment[]): void => { + setText: (text: string): void => { + textMetadataRef.current = undefined + changeText(text) + }, + restoreDraft: (text: string, draftAttachments: MessageAttachment[], textMetadata?: Record): void => { changeText(text) + textMetadataRef.current = textMetadata changeAttachments(draftAttachments.map(withDraftIdentity)) }, focus: (): void => { textareaRef.current?.focus() }, getDraftRevision: (): number => draftRevisionRef.current, + getTextMetadata: (): Record | undefined => textMetadataRef.current, })) const unsupportedAttachmentTypes = getUnsupportedAttachmentTypes(attachments, activeTarget) diff --git a/frontend/src/components/Chat/ChatWindow.test.tsx b/frontend/src/components/Chat/ChatWindow.test.tsx index 7e4a9c6725..8277801a2c 100644 --- a/frontend/src/components/Chat/ChatWindow.test.tsx +++ b/frontend/src/components/Chat/ChatWindow.test.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; 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 { createMemoryRouter, MemoryRouter, RouterProvider, Route, Routes } from "react-router"; +import { createMemoryRouter, MemoryRouter, RouterProvider, Route, Routes, useLocation } from "react-router"; import ChatWindow from "./ChatWindow"; import { makeTarget } from "@/test-utils/targetFixtures"; import { UserPreferencesProvider } from "@/hooks/useUserPreferences"; @@ -98,12 +98,17 @@ const actualMessageMapper = jest.requireActual{location.pathname}{location.search}; +} + async function findRecoveryAction(user: ReturnType): Promise { - const buttons = await screen.findAllByRole("button", { name: "Copy conversation" }); + const buttons = await screen.findAllByRole("button", { name: "Copy or Branch" }); const lastButton = buttons.at(-1); if (!lastButton) throw new Error("The failed prompt has no copy menu."); await user.click(lastButton); - return screen.findByRole("menuitem", { name: "New conversation" }); + return screen.findByRole("menuitem", { name: "Branch conversation" }); } const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ @@ -960,6 +965,138 @@ describe("ChatWindow Integration", () => { // Basic rendering // ----------------------------------------------------------------------- + it.each([true, false])("should move the final fake user turn to the composer with a target: %s", async (hasTarget: boolean) => { + const user = userEvent.setup(); + mockedMapper.backendMessagesToFrontend.mockImplementation(actualMessageMapper.backendMessagesToFrontend); + const onConversationCreated = jest.fn(); + mockedAttacksApi.saveConversation.mockResolvedValue({ + attack: { + attack_result_id: "new", conversation_id: "saved", attack_type: "ManualAttack", + objective: "", converters: [], message_count: 1, related_conversation_ids: [], + labels: {}, created_at: "", updated_at: "", target_unbound: !hasTarget, + }, + messages: { + conversation_id: "saved", target_response_status: null, + messages: [{ + role: "system", turn_number: 0, created_at: "", + message_pieces: [{ id: "system", original_value_data_type: "text", original_value: "System context", + converted_value_data_type: "text", converted_value: "System context", scores: [], response_error: "none" }], + }], + }, + }); + const router = createMemoryRouter([{ + path: "/", element: , + }]); + render( + + ); + await user.click(screen.getByRole("button", { name: "Edit Conversation", exact: true })); + await user.click(await screen.findByRole("button", { name: "Insert message at start" })); + const system = screen.getByRole("region", { name: "Message 1" }); + await user.selectOptions(within(system).getByRole("combobox"), "system"); + await user.type(within(system).getByRole("textbox"), "System context"); + await user.click(screen.getByRole("button", { name: "Insert message after message 1" })); + await user.type(within(screen.getByRole("region", { name: "Message 2" })).getByRole("textbox"), "Ready to send"); + await user.click(screen.getByRole("button", { name: "Save conversation" })); + await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Save conversation" })); + await screen.findByRole("button", { name: "Send message" }); + expect(await screen.findByPlaceholderText("Type prompt here")).toHaveValue("Ready to send"); + const request = mockedAttacksApi.saveConversation.mock.calls[0][0]; + expect(request.messages).toEqual([expect.objectContaining({ + role: "system", pieces: [expect.objectContaining({ original_value: "System context" })], + })]); + expect(screen.getByText("System context")).toBeInTheDocument(); + expect(screen.queryAllByText("Ready to send").every((element: HTMLElement) => element.tagName === "TEXTAREA")).toBe(true); + if (!hasTarget) { + expect(screen.queryByTestId("no-target-banner")).not.toBeInTheDocument(); + expect(screen.getByPlaceholderText("Type prompt here")).toBeEnabled(); + expect(screen.getByRole("button", { name: "Send message" })).toBeDisabled(); + } + expect(mockedAttacksApi.submitMessageSend).not.toHaveBeenCalled(); + }); + + it("should send a text-only video remix with its metadata after saving the editor", async () => { + const user = userEvent.setup(); + const metadata = { video_id: "existing-video", custom: "preserved" }; + const source: BackendMessage[] = [{ + role: "user", turn_number: 0, created_at: "", + message_pieces: [{ + id: "text-remix", original_value_data_type: "text", original_value: "Change the background", + converted_value_data_type: "text", converted_value: "Change the background", + prompt_metadata: metadata, scores: [], response_error: "none", + }], + }]; + const empty = { conversation_id: "saved", messages: [], target_response_status: null }; + mockedAttacksApi.getMessages.mockImplementation(async (_attackId, conversationId) => ( + conversationId === "source" ? { ...empty, conversation_id: "source", messages: source } : empty + )); + mockedAttacksApi.saveConversation.mockResolvedValue({ + attack: { + attack_result_id: "existing", conversation_id: "saved", attack_type: "ManualAttack", + objective: "", converters: [], message_count: 0, related_conversation_ids: [], + labels: {}, created_at: "", updated_at: "", + }, + messages: empty, + }); + mockedMapper.buildMessagePieces.mockImplementation(actualMessageMapper.buildMessagePieces); + mockedMapper.backendMessagesToFrontend.mockImplementation(actualMessageMapper.backendMessagesToFrontend); + mockedAttacksApi.submitMessageSend.mockImplementation(() => new Promise(() => {})); + function SavedRemixHarness() { + const [conversationId, setConversationId] = useState("source"); + return ; + } + const router = createMemoryRouter([{ path: "/", element: }]); + render( + + ); + await user.click(await screen.findByRole("button", { name: "Edit Conversation", exact: true })); + await screen.findByRole("region", { name: "Message 1" }); + await user.click(screen.getByRole("button", { name: "Save conversation" })); + await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Save conversation" })); + const input = await screen.findByPlaceholderText("Type prompt here"); + await waitFor(() => expect(input).toHaveValue("Change the background")); + expect(mockedAttacksApi.saveConversation.mock.calls[0][0].messages).toEqual([]); + await user.type(input, " to blue"); + await user.click(screen.getByRole("button", { name: "Send message" })); + await waitFor(() => expect(mockedAttacksApi.submitMessageSend).toHaveBeenCalledWith( + "existing", expect.objectContaining({ + pieces: [{ data_type: "text", original_value: "Change the background to blue", prompt_metadata: metadata }], + }), + )); + }); + + it("should keep a target-free attack unbound when copying a system turn", async () => { + const user = userEvent.setup(); + const source: BackendMessage[] = [{ + role: "system", turn_number: 0, created_at: "", + message_pieces: [{ id: "system", original_value_data_type: "text", original_value: "Context", + converted_value_data_type: "text", converted_value: "Context", scores: [], response_error: "none" }], + }]; + mockedMapper.backendMessagesToFrontend.mockImplementation(actualMessageMapper.backendMessagesToFrontend); + mockedAttacksApi.getMessages.mockResolvedValue({ conversation_id: "source", messages: source, target_response_status: null }); + mockedAttacksApi.saveConversation.mockResolvedValue({ + attack: { + attack_result_id: "existing", conversation_id: "source", attack_type: "ManualAttack", + objective: "", converters: [], message_count: 1, related_conversation_ids: ["copy"], + labels: {}, created_at: "", updated_at: "", target_unbound: true, + }, + messages: { conversation_id: "copy", messages: source, target_response_status: null }, + }); + render(); + await user.click(await screen.findByRole("button", { name: "Copy or Branch" })); + await user.click(screen.getByRole("menuitem", { name: "Branch conversation", exact: true })); + await waitFor(() => expect(mockedAttacksApi.saveConversation).toHaveBeenCalledTimes(1)); + expect(mockedAttacksApi.saveConversation).toHaveBeenCalledWith(expect.objectContaining({ + destination: "same_attack", target_registry_name: undefined, + messages: [expect.objectContaining({ role: "system" })], + })); + expect(defaultProps.onSelectConversation).toHaveBeenCalledWith("copy"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + it.each(["Same attack", "New attack"])("edits locally, restores the composer on Cancel, and saves to %s", async (destination: string) => { const user = userEvent.setup(); mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] }); @@ -1092,12 +1229,12 @@ describe("ChatWindow Integration", () => { mockedAttacksApi.saveConversation.mockResolvedValue({ attack: { attack_result_id: "new", conversation_id: "saved", attack_type: "ManualAttack", - objective: "", converters: [], message_count: 2, related_conversation_ids: [], + objective: "", converters: [], message_count: 1, related_conversation_ids: [], labels: {}, created_at: "", updated_at: "", }, - messages: { conversation_id: "saved", messages: savedMessages, target_response_status: null }, + messages: { conversation_id: "saved", messages: savedMessages.slice(0, 1), target_response_status: null }, }); - mockedAttacksApi.getMessages.mockResolvedValue({ messages: savedMessages }); + mockedAttacksApi.getMessages.mockResolvedValue({ messages: savedMessages.slice(0, 1) }); mockedAttacksApi.submitMessageSend.mockImplementation(() => new Promise(() => {})); mockedMapper.buildMessagePieces.mockImplementation(actualMessageMapper.buildMessagePieces); mockedMapper.backendMessagesToFrontend.mockImplementation(actualMessageMapper.backendMessagesToFrontend); @@ -1141,10 +1278,13 @@ describe("ChatWindow Integration", () => { await waitFor(() => expect(mockedAttacksApi.saveConversation).toHaveBeenCalledTimes(1)); const request = mockedAttacksApi.saveConversation.mock.calls[0][0]; expect(request.destination).toBe("new_attack"); - expect(request.messages.map((message) => message.pieces[0].converted_value)).toEqual([btoa("First prompt"), btoa("Second prompt")]); + expect(request.messages.map((message) => message.pieces[0].converted_value)).toEqual([btoa("First prompt")]); expect(request.messages[0].pieces[0].applied_converter_ids).toEqual(["base64"]); expect(defaultProps.onNewAttack).not.toHaveBeenCalled(); expect(await screen.findByText("Conversation saved.")).toBeInTheDocument(); + await screen.findByRole("button", { name: "Send message" }); + expect(screen.getByPlaceholderText("Type prompt here")).toHaveValue(btoa("Second prompt")); + await user.clear(screen.getByPlaceholderText("Type prompt here")); await user.type(await screen.findByPlaceholderText("Type prompt here"), "Next prompt"); expect(screen.getByText("Conversation saved.")).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Send message" })); @@ -1153,10 +1293,15 @@ describe("ChatWindow Integration", () => { }); it.each([ - ["New conversation", "same_attack"], - ["New attack", "new_attack"], - ])("copies through the selected message to %s without sending", async (label: string, destination: string) => { + ["Branch conversation", "same_attack"], + ["Branch attack", "new_attack"], + ["Use in scan", "new_attack"], + ])("copies through the selected message to %s without sending", async ( + label: string, destination: string, + ) => { const user = userEvent.setup(); + const clipboard = jest.spyOn(navigator.clipboard, "writeText").mockRejectedValue(new Error("Clipboard denied")); + const useInTechnique = label === "Use in scan"; const source: BackendMessage[] = ["system", "user", "assistant"].map((role: string, index: number) => ({ turn_number: index, role, created_at: "2026-01-01T00:00:00Z", message_pieces: [{ @@ -1174,13 +1319,13 @@ describe("ChatWindow Integration", () => { messages: { conversation_id: "copied-conversation", messages: source.slice(0, 2), target_response_status: null }, }); mockedMapper.backendMessagesToFrontend.mockImplementation(actualMessageMapper.backendMessagesToFrontend); - const router = createMemoryRouter([{ - path: "/", element: , - }]); render( - + + } /> +

Technique configuration

} /> +
); await user.click(await screen.findByTestId("copy-to-input-btn-1")); await user.click(screen.getByRole("menuitem", { name: label, exact: true })); @@ -1189,17 +1334,31 @@ describe("ChatWindow Integration", () => { expect(request.destination).toBe(destination); expect(request.objective).toBe(destination === "new_attack" ? "Source objective" : undefined); expect(request.expected_objective).toBeUndefined(); - expect(request.target_registry_name).toBe(mockTarget.target_registry_name); + expect(request.target_registry_name).toBe(useInTechnique ? undefined : mockTarget.target_registry_name); expect(request.messages.map((message) => message.pieces[0].source_piece_id)).toEqual(["source-0", "source-1"]); expect(screen.queryByTestId("conversation-editor")).not.toBeInTheDocument(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - if (destination === "same_attack") { + if (useInTechnique) { + expect(await screen.findByRole("heading", { name: "Technique configuration" })).toBeInTheDocument(); + expect(screen.getByLabelText("Current URL")).toHaveTextContent( + "/scanner/technique.execute?prepended_conversation_id=copied-conversation", + ); + expect(defaultProps.onConversationCreated).not.toHaveBeenCalled(); + expect(defaultProps.onSelectConversation).not.toHaveBeenCalled(); + expect(screen.queryByText(/Conversation ID copied/)).not.toBeInTheDocument(); + } else if (destination === "same_attack") { await waitFor(() => expect(defaultProps.onSelectConversation).toHaveBeenCalledWith("copied-conversation")); } else { await waitFor(() => expect(defaultProps.onConversationCreated).toHaveBeenCalledWith( "copied", "copied-conversation", "Source objective", mockTarget, )); } + if (!useInTechnique) { + expect(await screen.findByRole("complementary", { name: "Attack Conversations" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Toggle conversations panel" })).toHaveAttribute("aria-expanded", "true"); + } + expect(clipboard).not.toHaveBeenCalled(); + expect(mockedAttacksApi.submitMessageSend).not.toHaveBeenCalled(); expect(mockedAttacksApi.addMessage).not.toHaveBeenCalled(); }); @@ -1227,7 +1386,7 @@ describe("ChatWindow Integration", () => { const rendered = render(); await findRecoveryAction(user); - await user.click(screen.getByRole("menuitem", { name: "New attack", exact: true })); + await user.click(screen.getByRole("menuitem", { name: "Branch attack", exact: true })); await waitFor(() => expect(mockedAttacksApi.saveConversation).toHaveBeenCalledTimes(1)); const request = mockedAttacksApi.saveConversation.mock.calls[0][0]; expect(request.destination).toBe("new_attack"); @@ -1280,7 +1439,7 @@ describe("ChatWindow Integration", () => { const rendered = render(); await findRecoveryAction(user); - await user.click(screen.getByRole("menuitem", { name: "New attack", exact: true })); + await user.click(screen.getByRole("menuitem", { name: "Branch attack", exact: true })); await waitFor(() => expect(defaultProps.onConversationCreated).toHaveBeenCalledWith( "retry-attack", "retry-conversation", "", target, )); @@ -1341,8 +1500,8 @@ describe("ChatWindow Integration", () => { render( ); - await user.click(await screen.findByRole("button", { name: "Copy conversation" })); - await user.click(screen.getByRole("menuitem", { name: "New attack", exact: true })); + await user.click(await screen.findByRole("button", { name: "Copy or Branch" })); + await user.click(screen.getByRole("menuitem", { name: "Branch attack", exact: true })); await waitFor(() => expect(mockedAttacksApi.saveConversation).toHaveBeenCalledTimes(1)); const request = mockedAttacksApi.saveConversation.mock.calls[0][0]; expect(request.destination).toBe("new_attack"); @@ -1707,14 +1866,14 @@ describe("ChatWindow Integration", () => { expect(onNewAttack).toHaveBeenCalled(); }); - it("should disable the composer when no target is selected", () => { + it("should allow drafting but disable sending when no target is selected", () => { render( ); - expect(screen.getByRole("textbox")).toBeDisabled(); + expect(screen.getByRole("textbox")).toBeEnabled(); expect(screen.getByRole("button", { name: "Send message" })).toBeDisabled(); }); @@ -3877,10 +4036,10 @@ describe("ChatWindow Integration", () => { ); it.each([ - { reinitialized: false, destination: "New conversation" }, - { reinitialized: true, destination: "New conversation" }, - { reinitialized: false, destination: "New attack" }, - { reinitialized: true, destination: "New attack" }, + { reinitialized: false, destination: "Branch conversation" }, + { reinitialized: true, destination: "Branch conversation" }, + { reinitialized: false, destination: "Branch attack" }, + { reinitialized: true, destination: "Branch attack" }, ])("should restore the matching live draft to $destination with reinitialization $reinitialized", async ({ reinitialized, destination, }: { reinitialized: boolean; destination: string }) => { @@ -3993,10 +4152,10 @@ describe("ChatWindow Integration", () => { await screen.findByRole("button", { name: "Select conversation conv-matching-failure" }) ); const recoveryButton = await findRecoveryAction(user); - await user.click(destination === "New conversation" ? recoveryButton - : screen.getByRole("menuitem", { name: "New attack", exact: true })); + await user.click(destination === "Branch conversation" ? recoveryButton + : screen.getByRole("menuitem", { name: "Branch attack", exact: true })); await waitFor(() => { - if (destination === "New conversation") { + if (destination === "Branch conversation") { expect(onSelectConversation).toHaveBeenCalledWith("conv-matching-recovery"); expect(mockedAttacksApi.createConversation).toHaveBeenCalledWith(props.attackResultId, {}); } else { @@ -4010,8 +4169,8 @@ describe("ChatWindow Integration", () => { }); rendered.rerender( + attackResultId={destination === "Branch attack" ? "recovered-attack" : props.attackResultId} + conversationId={destination === "Branch attack" ? "conv-matching-recovery" : props.conversationId} /> ); expect(await screen.findByText(/live\.png/)).toBeInTheDocument(); @@ -4026,7 +4185,7 @@ describe("ChatWindow Integration", () => { await user.click(screen.getByRole("button", { name: /send message/i })); await waitFor(() => { expect(mockSendResult).toHaveBeenLastCalledWith( - destination === "New attack" ? "recovered-attack" : props.attackResultId, + destination === "Branch attack" ? "recovered-attack" : props.attackResultId, expect.objectContaining({ target_conversation_id: "conv-matching-recovery", pieces: expect.arrayContaining([reinitialized @@ -4040,7 +4199,8 @@ describe("ChatWindow Integration", () => { } expect(mockedMapper.buildMessagePieces).toHaveBeenLastCalledWith( "live draft", - [expect.objectContaining({ file, name: "live.png" })] + [expect.objectContaining({ file, name: "live.png" })], + undefined, ); }); @@ -5331,7 +5491,7 @@ describe("ChatWindow Integration", () => { // Click copy-to-input on assistant message (index 1) const copyBtn = screen.getByTestId("copy-to-input-btn-1"); await userEvent.click(copyBtn); - await userEvent.click(screen.getByRole("menuitem", { name: "This conversation" })); + await userEvent.click(screen.getByRole("menuitem", { name: "Copy to next message" })); // The text should appear in the input area await waitFor(() => { @@ -5927,7 +6087,7 @@ describe("ChatWindow Integration", () => { const copyBtn = screen.getByTestId("copy-to-input-btn-1"); await user.click(copyBtn); - await user.click(screen.getByRole("menuitem", { name: "This conversation" })); + await user.click(screen.getByRole("menuitem", { name: "Copy to next message" })); await waitFor(() => { const textarea = screen.getByRole("textbox") as HTMLTextAreaElement; @@ -5946,7 +6106,8 @@ describe("ChatWindow Integration", () => { { ...copiedAttachments[0], draftId: expect.any(String) }, { ...copiedAttachments[1], draftId: expect.any(String) }, { ...copiedAttachments[2], draftId: expect.any(String) }, - ] + ], + undefined, ); }); }); @@ -5999,7 +6160,7 @@ describe("ChatWindow Integration", () => { }); await userEvent.click(screen.getByTestId("copy-to-input-btn-1")); - await userEvent.click(screen.getByRole("menuitem", { name: "This conversation" })); + await userEvent.click(screen.getByRole("menuitem", { name: "Copy to next message" })); await waitFor(() => { const textarea = screen.getByRole("textbox") as HTMLTextAreaElement; @@ -6973,7 +7134,7 @@ describe("ChatWindow Integration", () => { labels: { operation: "config_op_v2" }, })) expect(mockedMapper.buildMessagePieces).toHaveBeenLastCalledWith( - 'prepared draft', expect.arrayContaining([expect.objectContaining({ file })]), + 'prepared draft', expect.arrayContaining([expect.objectContaining({ file })]), undefined, ) }) @@ -6990,8 +7151,8 @@ describe("ChatWindow Integration", () => { activeConversationId: "conv-1", } const rendered = render() - await user.click(await screen.findByRole('button', { name: 'Copy conversation' })) - const create = screen.getByRole('menuitem', { name: 'New attack', exact: true }) + await user.click(await screen.findByRole('button', { name: 'Copy or Branch' })) + const create = screen.getByRole('menuitem', { name: 'Branch attack', exact: true }) expect(create).toHaveAttribute('aria-disabled', 'true') expect(mockedAttacksApi.saveConversation).not.toHaveBeenCalled() @@ -7024,10 +7185,10 @@ describe("ChatWindow Integration", () => { attackResultId: "existing-attack", conversationId: "conv-1", activeConversationId: "conv-1", } const rendered = render() - await user.click(await screen.findByRole('button', { name: 'Copy conversation' })) + await user.click(await screen.findByRole('button', { name: 'Copy or Branch' })) let finish: (value: typeof copiedSource) => void = () => {} mockedAttacksApi.getMessages.mockReturnValueOnce(new Promise((resolve) => { finish = resolve })) - await user.click(screen.getByRole('menuitem', { name: 'New attack', exact: true })) + await user.click(screen.getByRole('menuitem', { name: 'Branch attack', exact: true })) runtime.mockReturnValue({ ready: true, state: "ready", generation: "gen-2" }) rendered.rerender( @@ -7039,8 +7200,8 @@ describe("ChatWindow Integration", () => { expect(await screen.findByText(/Runtime or default labels changed while loading this conversation/)).toBeInTheDocument() expect(mockedAttacksApi.saveConversation).not.toHaveBeenCalled() - await user.click(screen.getByRole('button', { name: 'Copy conversation' })) - await user.click(screen.getByRole('menuitem', { name: 'New attack', exact: true })) + await user.click(screen.getByRole('button', { name: 'Copy or Branch' })) + await user.click(screen.getByRole('menuitem', { name: 'Branch attack', exact: true })) await waitFor(() => expect(mockedAttacksApi.saveConversation).toHaveBeenCalledWith(expect.objectContaining({ destination: 'new_attack', labels: { ...defaultProps.labels, operation: "config_op_v2" }, }))) diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 41a4ffd687..b6b79a729e 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -25,7 +25,7 @@ import { } from '@fluentui/react-components' import type { SwitchOnChangeData } from '@fluentui/react-components' import { AddRegular, ArrowDownloadRegular, ArrowShuffleRegular, EditRegular, PanelRightRegular } from '@fluentui/react-icons' -import { Link } from 'react-router' +import { Link, useNavigate } from 'react-router' import MessageList from './MessageList' import ChatInputArea from './ChatInputArea' import MultiSendProgress from './MultiSendProgress' @@ -37,7 +37,7 @@ import { generateClientId } from '@/utils/clientId' import ObjectiveHeader from './ObjectiveHeader' import ConversationEditor from './ConversationEditor' import type { ConversationEditorHandle } from './ConversationEditor' -import { draftDataTypes, editorTargetDisabledReason, toConversationDraft } from '@/utils/conversationDraft' +import { draftDataTypes, draftMessageToInput, editorTargetDisabledReason, toConversationDraft } from '@/utils/conversationDraft' import { useConversationSave } from '@/hooks/useConversationSave' import { useConversationDraft } from '@/hooks/useConversationDraft' import type { PieceConversion } from './converterTypes' @@ -95,6 +95,8 @@ import { useChatWindowStyles } from './ChatWindow.styles' const NARROW_SCREEN_QUERY = '(max-width: 600px)' const RETRYABLE_TARGET_RESPONSE_ERROR = 'processing' +type ConversationCopyDestination = 'same_attack' | 'new_attack' | 'technique' + interface RecoverableSendDraft { conversationId: string failedRequestTurnNumber: number @@ -102,6 +104,7 @@ interface RecoverableSendDraft { historyCutoffIndex: number failedRequestMessageIndex: number originalValue: string + textMetadata?: Record attachments: MessageAttachment[] conversions: Record source: 'live' | 'persisted' @@ -120,6 +123,7 @@ interface PendingSend { readonly controller: AbortController readonly draftRevision: number | undefined readonly originalValue: string + readonly textMetadata?: Record readonly attachments: MessageAttachment[] readonly conversions: Record readonly priorUserPieceIds: Set @@ -207,6 +211,8 @@ function getPersistedProcessingRecovery( } const originalDraft = backendMessageToOriginalDraft(failedRequest) + const textMetadata = failedRequest.message_pieces.filter((piece) => piece.original_value_data_type === 'text') + .reduce>((metadata: Record, piece) => ({ ...metadata, ...piece.prompt_metadata }), {}) return { conversationId, failedRequestTurnNumber: responseStatus.request_turn_number, @@ -214,6 +220,7 @@ function getPersistedProcessingRecovery( historyCutoffIndex: getRecoveryHistoryCutoff(response.messages, responseStatus.request_turn_number), failedRequestMessageIndex: response.messages.indexOf(failedRequest), originalValue: originalDraft.content, + textMetadata: Object.keys(textMetadata).length ? textMetadata : undefined, attachments: (originalDraft.attachments ?? []).map((attachment) => ({ ...attachment })), conversions: {}, source: 'persisted', @@ -315,6 +322,7 @@ export default function ChatWindow({ const styles = useChatWindowStyles() const restoreFocusTargetAttributes = useRestoreFocusTarget() const restoreFocusSourceAttributes = useRestoreFocusSource() + const navigate = useNavigate() const [messages, setMessages] = useState([]) const [pendingObjective, setPendingObjective] = useState('') const currentObjective = attackResultId ? objective : pendingObjective @@ -724,6 +732,7 @@ export default function ChatWindow({ const recoveredDraft: RecoverableSendDraft | undefined = processingFailure && recovery ? { ...recovery, originalValue: operation.originalValue, + textMetadata: operation.textMetadata, attachments: operation.attachments, conversions: operation.conversions, converterGeneration: operation.converterGeneration, @@ -908,7 +917,7 @@ export default function ChatWindow({ const attachments = operation.attachments.map(withDraftIdentity) setChatInputText(operation.originalValue) setDraftAttachments(attachments) - inputBoxRef.current?.restoreDraft(operation.originalValue, attachments) + inputBoxRef.current?.restoreDraft(operation.originalValue, attachments, operation.textMetadata) restoreConversions( operation.originalValue, attachments, sameGeneration ? operation.conversions : {}, sameGeneration ? operation.pipelines : undefined, @@ -965,6 +974,7 @@ export default function ChatWindow({ controller: new AbortController(), draftRevision: inputBoxRef.current?.getDraftRevision(), originalValue, + textMetadata: inputBoxRef.current?.getTextMetadata(), attachments: attachments.map((attachment) => ({ ...attachment })), conversions, priorUserPieceIds: new Set(loadedUserPieceIdsRef.current.get(initialSendConvId)), @@ -1032,7 +1042,7 @@ export default function ChatWindow({ try { // Build message pieces from text + attachments — always use original text const pieceIds = buildDraftPieceIds(originalValue, attachments, conversions) - const originalPieces = await buildMessagePieces(originalValue, attachments) + const originalPieces = await buildMessagePieces(originalValue, attachments, operation.textMetadata) if (textConversion && !originalValue.trim()) { originalPieces.unshift({ data_type: 'text', original_value: originalValue }) } @@ -1261,6 +1271,7 @@ export default function ChatWindow({ inputBoxRef.current?.restoreDraft( recoverableSend.originalValue, attachments, + recoverableSend.textMetadata, ) inputBoxRef.current?.focus() setRecoveredDraftWarningConversationId(recoverableSend.missingConverterSelections ? destinationConversationId : null) @@ -1469,10 +1480,31 @@ export default function ChatWindow({ setPanelRefreshKey((key: number) => key + 1) } - const copyConversation = async (messageIndex: number, destination: 'same_attack' | 'new_attack'): Promise => { + const handleSaveEditor = (destination: 'same_attack' | 'new_attack'): void => { + if (!editDraft) return + const lastMessage = editDraft.messages[editDraft.messages.length - 1] + const pendingPrompt = lastMessage?.role === 'user' ? draftMessageToInput(lastMessage) : undefined + const history = pendingPrompt ? editDraft.messages.slice(0, -1) : editDraft.messages + void editor.save(destination, (response: AddMessageResponse) => { + const attachments = pendingPrompt?.attachments.map((attachment: MessageAttachment) => attachment.file + ? { ...attachment, url: URL.createObjectURL(attachment.file) } : attachment) ?? [] + handleEditorSaved(response) + if (pendingPrompt) { + setChatInputText(pendingPrompt.text) + setDraftAttachments(attachments) + restoreConversions(pendingPrompt.text, attachments, {}, {}) + inputBoxRef.current?.restoreDraft(pendingPrompt.text, attachments, pendingPrompt.textMetadata) + inputBoxRef.current?.focus() + } + }, history) + } + + const copyConversation = async ( + messageIndex: number, destination: ConversationCopyDestination, + ): Promise => { if (!attackResultId || !viewedConversationId || copyingRef.current || isSending || isLoadingEdit) return if (destination === 'same_attack' && isMutationLocked) return - if (destination === 'new_attack' && (!runtime.ready || !defaultsReady)) return + if (destination !== 'same_attack' && (!runtime.ready || !defaultsReady)) return if (destination === 'same_attack' && recoverableSend && messageIndex === recoverableSend.failedRequestMessageIndex) { await handleRecoverProcessingError() @@ -1483,15 +1515,16 @@ export default function ChatWindow({ copyingRef.current = true setIsLoadingEdit(true) setEditorError(null) + setEditorNotice(null) try { const source = await attacksApi.getMessages(attackResultId, sourceId) - if (destination === 'new_attack' && ( + if (destination !== 'same_attack' && ( launchStateRef.current.generation !== runtime.generation || !launchStateRef.current.ready || !launchStateRef.current.defaultsReady )) { throw new Error('Runtime or default labels changed while loading this conversation. Retry after default labels finish loading.') } - const isFailedPrompt = recoverableSend && messageIndex === recoverableSend.failedRequestMessageIndex + const isFailedPrompt = destination === 'new_attack' && recoverableSend && messageIndex === recoverableSend.failedRequestMessageIndex const sourceMessages = isFailedPrompt ? source.messages.filter((message: BackendMessage) => message.turn_number <= recoverableSend.historyCutoffIndex) : source.messages.slice(0, messageIndex + 1) @@ -1499,9 +1532,13 @@ export default function ChatWindow({ if (isFailedPrompt && activeTarget && editorTargetDisabledReason(activeTarget, draftDataTypes(copiedMessages))) { copiedMessages = [] } - const target = destination === 'new_attack' && activeTarget - && !isFailedPrompt && editorTargetDisabledReason(activeTarget, draftDataTypes(copiedMessages)) - ? null : activeTarget + let target = activeTarget + if (destination === 'technique' || (destination === 'same_attack' && targetResolutionStatus === 'unbound')) { + target = null + } else if (destination === 'new_attack' && activeTarget && !isFailedPrompt + && editorTargetDisabledReason(activeTarget, draftDataTypes(copiedMessages))) { + target = null + } if (isFailedPrompt && copiedMessages.length === 0) { const response = await attacksApi.createAttack({ target_registry_name: target?.target_registry_name, @@ -1511,6 +1548,7 @@ export default function ChatWindow({ if (viewedConvRef.current !== sourceId || inputBoxRef.current?.getDraftRevision() !== draftRevision) return onConversationCreated(response.attack_result_id, response.conversation_id, objective, target) restoreRecoverableDraft(response.conversation_id) + setIsPanelOpen(true) setPanelRefreshKey((key: number) => key + 1) return } @@ -1522,13 +1560,19 @@ export default function ChatWindow({ target, labels, messages: copiedMessages, - }, destination) + }, destination === 'same_attack' ? 'same_attack' : 'new_attack') if (viewedConvRef.current !== sourceId) return + if (destination === 'technique') { + const params = new URLSearchParams({ prepended_conversation_id: response.messages.conversation_id }) + navigate(`/scanner/technique.execute?${params.toString()}`) + return + } if (isFailedPrompt && inputBoxRef.current?.getDraftRevision() !== draftRevision) return if (destination === 'same_attack') onSelectConversation(response.messages.conversation_id) else onConversationCreated(response.attack.attack_result_id, response.messages.conversation_id, response.attack.objective, target) onAttackChange?.(response.attack) if (isFailedPrompt) restoreRecoverableDraft(response.messages.conversation_id) + setIsPanelOpen(true) setPanelRefreshKey((key: number) => key + 1) } catch (error) { if (viewedConvRef.current === sourceId) setEditorError(toApiError(error).detail) @@ -1558,7 +1602,7 @@ export default function ChatWindow({ ...(isSending ? ['A message is being sent. Wait for the response.'] : []), ...(isLoadingAttack ? ['The attack is loading. Wait for it to finish.'] : []), ...(hasProcessingError - ? ['This conversation contains a target error. Use Copy conversation on the failed prompt to copy it to a new conversation or attack.'] + ? ['This conversation contains a target error. Use Copy or Branch on the failed prompt to branch into a new conversation or attack.'] : []), ] @@ -1778,12 +1822,14 @@ export default function ChatWindow({ sameAttackDisabledReason={sameAttackDisabledReason} newAttackDisabledReason={newAttackDisabledReason} onSaved={handleEditorSaved} + onSave={handleSaveEditor} />} {editDraft === null && { void copyConversation(index, 'same_attack') }} onCopyToNewAttack={newAttackDisabledReason ? undefined : (index: number) => { void copyConversation(index, 'new_attack') }} + onUseInTechnique={newAttackDisabledReason ? undefined : (index: number) => { void copyConversation(index, 'technique') }} copyConversationDisabled={isSending || isLoadingEdit || isRecoveringProcessingError || Boolean(sendIssue?.blocking)} newConversationDisabledReason={isMutationLocked ? "This attack is read-only. Copy to a new attack instead." : undefined} isLoading={isLoadingAttack || isLoadingMessages || awaitingConversationLoad} @@ -1836,7 +1882,7 @@ export default function ChatWindow({ void + onSave?: (destination: SaveConversationRequest['destination']) => void + saveDestination?: SaveConversationRequest['destination'] + saveLabel?: string } export interface ConversationEditorHandle { convertConversation: () => void saveToNewAttack: () => void + cancel: () => void } export default function ConversationEditor({ - controller, ref, sameAttackDisabledReason, newAttackDisabledReason, onSaved, + controller, ref, sameAttackDisabledReason, newAttackDisabledReason, onSaved, onSave, + saveDestination: directSaveDestination, saveLabel = 'Save conversation', }: ConversationEditorProps) { const styles = useConversationEditorStyles() const [selected, setSelected] = useState>(new Set()) @@ -195,12 +200,22 @@ export default function ConversationEditor({ setSelected(new Set(messageId ? [messageId] : messages.map((message: ConversationDraftMessage) => message.id))) setConvertersOpen(true) } + const cancel = (): void => { + if (saving) return + if (dirty) setDiscardOpen(true) + else controller.discard() + } + const save = (destination: SaveConversationRequest['destination']): void => { + if (onSave) onSave(destination) + else void controller.save(destination, onSaved) + } useImperativeHandle(ref, () => ({ convertConversation: () => openConverters(), saveToNewAttack: () => { setSaveDestination('new_attack') setSaveDialogOpen(true) }, + cancel, })) const removeMessage = (messageId: string): void => { controller.removeMessage(messageId) @@ -301,16 +316,22 @@ export default function ConversationEditor({ ))}
- - + +
+ {directSaveDestination && error && {error}} {saveDialogOpen && setSaveDialogOpen(false)} onSave={(destination) => { void controller.save(destination, onSaved) }} />} + onClose={() => setSaveDialogOpen(false)} onSave={save} />} { if (!data.open && !saving) { setDiscardOpen(false); if (blocker.state === 'blocked') blocker.reset() } }}> diff --git a/frontend/src/components/Chat/MessageList.test.tsx b/frontend/src/components/Chat/MessageList.test.tsx index 36cc0094a1..99743f683f 100644 --- a/frontend/src/components/Chat/MessageList.test.tsx +++ b/frontend/src/components/Chat/MessageList.test.tsx @@ -48,7 +48,7 @@ describe("MessageList", () => { ).toBeInTheDocument(); }); - it.each(["This conversation", "New conversation", "New attack"])("copies through one menu to %s", async (destination: string) => { + it.each(["Copy to next message", "Branch conversation", "Branch attack"])("copies through one menu to %s", async (destination: string) => { const user = userEvent.setup(); const onCopyToInput = jest.fn(); const onCopyToNewConversation = jest.fn(); @@ -56,11 +56,11 @@ describe("MessageList", () => { render(); - await user.click(screen.getAllByRole("button", { name: "Copy conversation" })[1]); + await user.click(screen.getAllByRole("button", { name: "Copy or Branch" })[1]); expect(screen.getAllByRole("menuitem")).toHaveLength(3); await user.click(screen.getByRole("menuitem", { name: destination })); - const handler = destination === "This conversation" ? onCopyToInput - : destination === "New conversation" ? onCopyToNewConversation : onCopyToNewAttack; + const handler = destination === "Copy to next message" ? onCopyToInput + : destination === "Branch conversation" ? onCopyToNewConversation : onCopyToNewAttack; expect(handler).toHaveBeenCalledWith(1); expect(onCopyToInput.mock.calls.length + onCopyToNewConversation.mock.calls.length + onCopyToNewAttack.mock.calls.length).toBe(1); }); @@ -70,11 +70,39 @@ describe("MessageList", () => { render(); - await user.click(screen.getAllByRole("button", { name: "Copy conversation" })[0]); - expect(screen.getByRole("menuitem", { name: "New conversation" })).toHaveAttribute("aria-disabled", "true"); - expect(screen.getByRole("menuitem", { name: "New conversation" })).toHaveAttribute("title", "This attack is read-only."); - expect(screen.getByRole("menuitem", { name: "This conversation" })).not.toHaveAttribute("aria-disabled", "true"); - expect(screen.getByRole("menuitem", { name: "New attack" })).not.toHaveAttribute("aria-disabled", "true"); + await user.click(screen.getAllByRole("button", { name: "Copy or Branch" })[0]); + const branch = screen.getByRole("menuitem", { name: "Branch conversation" }); + expect(branch).toHaveAttribute("aria-disabled", "true"); + expect(branch).toHaveAccessibleDescription("Copy history including this turn into the same attack. This attack is read-only."); + expect(within(branch).getByText(/This attack is read-only/)).toBeVisible(); + expect(screen.getByRole("menuitem", { name: "Copy to next message" })).not.toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("menuitem", { name: "Branch attack" })).not.toHaveAttribute("aria-disabled", "true"); + }); + + it("should show an accessible description for every copy or branch action and support keyboard selection", async () => { + const user = userEvent.setup(); + const onUseInTechnique = jest.fn(); + render(); + screen.getByRole("button", { name: "Copy or Branch" }).focus(); + await user.keyboard("{Enter}"); + const descriptions = [ + ["Copy to next message", "Copy only this message to the prompt box."], + ["Branch conversation", "Copy history including this turn into the same attack."], + ["Branch attack", "Copy history including this turn into a new attack (i.e., separate group of conversations)."], + ["Use in scan", "Apply a PyRIT attack technique after prepending history including this turn."], + ]; + for (const [index, [name, description]] of descriptions.entries()) { + const item = screen.getByRole("menuitem", { name, exact: true }); + expect(item).toHaveAccessibleDescription(description); + expect(within(item).getByText(description)).toBeVisible(); + if (index > 0) await user.keyboard("{ArrowDown}"); + expect(item).toHaveFocus(); + } + await user.keyboard("{Enter}"); + expect(onUseInTechnique).toHaveBeenCalledWith(0); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); }); it("should render all messages", () => { @@ -111,6 +139,22 @@ describe("MessageList", () => { expect(screen.getByText("Hello, how are you?")).toBeInTheDocument(); }); + it.each(["system", "developer", "simulated_tool"])("should allow branching at a %s turn", async (role: Message["role"]) => { + const user = userEvent.setup(); + const onCopyToNewConversation = jest.fn(); + const onUseInTechnique = jest.fn(); + render(); + await user.click(screen.getByRole("button", { name: "Copy or Branch" })); + await user.click(screen.getByRole("menuitem", { name: "Branch conversation" })); + expect(onCopyToNewConversation).toHaveBeenCalledWith(0); + await user.click(screen.getByRole("button", { name: "Copy or Branch" })); + await user.click(screen.getByRole("menuitem", { name: "Use in scan" })); + expect(onUseInTechnique).toHaveBeenCalledWith(0); + }); + it("should render user messages", () => { const userMessages: Message[] = [ { @@ -1079,7 +1123,7 @@ describe("MessageList", () => { expect(screen.queryByTestId("download-btn-0-0")).not.toBeInTheDocument(); await user.click(screen.getByTestId("copy-to-input-btn-0")); - await user.click(screen.getByRole("menuitem", { name: "This conversation" })); + await user.click(screen.getByRole("menuitem", { name: "Copy to next message" })); expect(onCopyToInput).toHaveBeenCalledWith(0); }); @@ -1711,7 +1755,7 @@ describe("MessageList", () => { ); await user.click(screen.getByTestId("copy-to-input-btn-0")); - await user.click(screen.getByRole("menuitem", { name: "This conversation" })); + await user.click(screen.getByRole("menuitem", { name: "Copy to next message" })); expect(onCopyToInput).toHaveBeenCalledWith(0); }); diff --git a/frontend/src/components/Chat/MessageList.tsx b/frontend/src/components/Chat/MessageList.tsx index 5b23c417c6..ea364ca777 100644 --- a/frontend/src/components/Chat/MessageList.tsx +++ b/frontend/src/components/Chat/MessageList.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react' import { Text, Avatar, @@ -23,7 +23,7 @@ import { } from '@fluentui/react-components' import { ArrowDownloadRegular, - ArrowReplyRegular, + BranchForkRegular, MoreHorizontalRegular, OpenRegular, } from '@fluentui/react-icons' @@ -40,6 +40,7 @@ interface MessageListProps { onCopyToInput?: (messageIndex: number) => void onCopyToNewConversation?: (messageIndex: number) => void onCopyToNewAttack?: (messageIndex: number) => void + onUseInTechnique?: (messageIndex: number) => void copyConversationDisabled?: boolean newConversationDisabledReason?: string /** True while loading a historical attack's messages */ @@ -476,11 +477,12 @@ function MessageScores({ scores, groupId }: { scores: DisplayScore[]; groupId: s * line of compact text. */ export default function MessageList({ - messages, onCopyToInput, onCopyToNewConversation, onCopyToNewAttack, copyConversationDisabled = false, + messages, onCopyToInput, onCopyToNewConversation, onCopyToNewAttack, onUseInTechnique, copyConversationDisabled = false, newConversationDisabledReason, isLoading, globalMarkdown = false, }: MessageListProps) { const styles = useMessageListStyles() const messagesEndRef = useRef(null) + const copyMenuId = useId() const handleDownload = async (att: MessageAttachment) => { try { @@ -701,28 +703,37 @@ export default function MessageList({ {!message.isLoading && !message.error && (
- {onCopyToInput && (isUser || isSimulated || message.role === 'assistant') && ( + {onCopyToInput && ( + aria-label="Copy or Branch" + >Copy or Branch - onCopyToInput(index)} title="Copy this message to the prompt box"> - This conversation + onCopyToInput(index)} + aria-label="Copy to next message" aria-describedby={`${copyMenuId}-${index}-prompt`} + subText={{ id: `${copyMenuId}-${index}-prompt`, children: 'Copy only this message to the prompt box.' }}> + Copy to next message onCopyToNewConversation?.(index)}>New conversation + aria-label="Branch conversation" aria-describedby={`${copyMenuId}-${index}-conversation`} + subText={{ id: `${copyMenuId}-${index}-conversation`, + children: `Copy history including this turn into the same attack.${newConversationDisabledReason ? ` ${newConversationDisabledReason}` : ''}` }} + onClick={() => onCopyToNewConversation?.(index)}>Branch conversation onCopyToNewAttack?.(index)}>New attack + aria-label="Branch attack" aria-describedby={`${copyMenuId}-${index}-attack`} + subText={{ id: `${copyMenuId}-${index}-attack`, children: 'Copy history including this turn into a new attack (i.e., separate group of conversations).' }} + onClick={() => onCopyToNewAttack?.(index)}>Branch attack + {onUseInTechnique && onUseInTechnique(index)}>Use in scan} )} diff --git a/frontend/src/components/Scenarios/ScenarioDetail.styles.ts b/frontend/src/components/Scenarios/ScenarioDetail.styles.ts index a1ba630c74..0a648ec5e6 100644 --- a/frontend/src/components/Scenarios/ScenarioDetail.styles.ts +++ b/frontend/src/components/Scenarios/ScenarioDetail.styles.ts @@ -119,6 +119,14 @@ export const useScenarioDetailStyles = makeStyles({ flexDirection: 'column', gap: tokens.spacingVerticalM, }, + conversationActions: { + display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: tokens.spacingHorizontalS, + }, + conversationSurface: { + width: '90vw', maxWidth: '75rem', height: '85vh', minWidth: 0, + '& > div': { height: '100%', minHeight: 0 }, + }, + conversationContent: { display: 'flex', minHeight: 0, overflow: 'hidden' }, touchTarget: { ...mobileTouchTarget, }, diff --git a/frontend/src/components/Scenarios/ScenarioDetail.test.tsx b/frontend/src/components/Scenarios/ScenarioDetail.test.tsx index 767130382c..f882f0df3e 100644 --- a/frontend/src/components/Scenarios/ScenarioDetail.test.tsx +++ b/frontend/src/components/Scenarios/ScenarioDetail.test.tsx @@ -1,11 +1,12 @@ import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { FluentProvider, webLightTheme } from '@fluentui/react-components' -import { MemoryRouter, Route, Routes } from 'react-router' +import { createMemoryRouter, Link, MemoryRouter, Route, RouterProvider, Routes } from 'react-router' -import { scenariosApi, targetsApi } from '@/services/api' +import { attacksApi, scenariosApi, targetsApi } from '@/services/api' import { useRuntime } from '@/hooks/useRuntime' import type { + AddMessageResponse, RegisteredScenario, ScenarioRunSizeEstimateRequest, ScenarioRunSizeEstimateResponse, @@ -17,6 +18,7 @@ import ScenarioDetail from './ScenarioDetail' jest.mock('@/hooks/useRuntime', () => ({ useRuntime: jest.fn() })) jest.mock('@/services/api', () => ({ + attacksApi: { getConversation: jest.fn(), saveConversation: jest.fn() }, datasetsApi: { listDatasets: jest.fn().mockResolvedValue({ items: ['harmbench', 'ds_a', 'ds_b', 'remote', 'stale-dataset'].map((name) => ({ name, selection_key: `dataset:named:${name}`, loaded: true, provider_available: true, logical_examples: 8, seed_pieces: null, objectives: null, modalities: ['text', 'image_path', 'audio_path'], @@ -37,6 +39,7 @@ const mockEstimateRun = scenariosApi.estimateRun as jest.Mock const mockStartRun = scenariosApi.startRun as jest.Mock const mockListTargets = targetsApi.listTargets as jest.Mock const mockUseRuntime = jest.mocked(useRuntime) +const mockAttacks = jest.mocked(attacksApi) const mockNavigate = jest.fn() const RAW_IMAGE_HTML = ['<', 'img src=x onerror="alert(1)">'].join('') @@ -185,6 +188,7 @@ async function setObjectiveLimit(user: ReturnType, value function renderDetail( path: string, props: Partial> = {}, + dataRouter = false, ) { const defaultProps = { targets: [makeTarget('target-a'), makeTarget('target-b')], @@ -197,14 +201,16 @@ function renderDetail( const merged = { ...defaultProps, ...props } const renderPage = (pageProps: React.ComponentProps): React.ReactElement => ( - + {dataRouter ? , + }], { initialEntries: [path] })} /> : } /> - + } ) const view = render(renderPage(merged)) @@ -233,6 +239,302 @@ describe('ScenarioDetail', () => { jest.useRealTimers() }) + describe('scanner conversation configuration', () => { + const saved: AddMessageResponse = { + attack: { + attack_result_id: 'history-attack', conversation_id: 'history-conversation', attack_type: 'ManualAttack', + objective: '', converters: [], message_count: 1, related_conversation_ids: [], + labels: {}, created_at: '', updated_at: '', target_unbound: true, + }, + messages: { + conversation_id: 'history-conversation', target_response_status: null, + messages: [{ + role: 'system', turn_number: 0, created_at: '', + message_pieces: [{ + id: 'history-piece', original_value_data_type: 'text', original_value: 'Saved context', + converted_value_data_type: 'text', converted_value: 'Saved context', scores: [], response_error: 'none', + }], + }], + }, + } + + beforeEach(() => { + mockGetScenario.mockResolvedValue(makeScenario({ + supported_parameters: [ + { name: 'prepended_conversation_id', type_name: 'str', required: false }, + { name: 'apply_converters_to_prepended_history', type_name: 'bool', required: false, default: 'False' }, + { name: 'max_turns', type_name: 'int', required: false }, + ], + })) + mockAttacks.getConversation.mockReset() + mockAttacks.saveConversation.mockReset() + mockAttacks.getConversation.mockResolvedValue(saved) + mockAttacks.saveConversation.mockResolvedValue({ + ...saved, messages: { ...saved.messages, conversation_id: 'edited-conversation' }, + }) + }) + + it('should save all builder turns into the scanner field without losing configuration or sending', async () => { + const user = userEvent.setup() + renderDetail('/scanner/technique.execute', {}, true) + await user.type(await screen.findByRole('spinbutton', { name: 'max_turns' }), '3') + expect(screen.getByRole('button', { name: 'Edit conversation' })).toBeDisabled() + await user.click(screen.getByRole('button', { name: 'Add new conversation' })) + const first = screen.getByRole('region', { name: 'Message 1' }) + await user.selectOptions(within(first).getByRole('combobox'), 'system') + await user.type(within(first).getByRole('textbox'), 'Simulated context') + await user.click(screen.getByRole('button', { name: 'Insert message after message 1' })) + await user.type(within(screen.getByRole('region', { name: 'Message 2' })).getByRole('textbox'), 'Final user turn') + expect(screen.queryByRole('button', { name: 'Send message' })).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Add to attack' })) + expect(await screen.findByRole('textbox', { name: 'prepended_conversation_id' })).toHaveValue('edited-conversation') + expect(screen.getByRole('spinbutton', { name: 'max_turns' })).toHaveValue(3) + expect(mockAttacks.saveConversation).toHaveBeenCalledWith(expect.objectContaining({ + destination: 'new_attack', target_registry_name: undefined, operator: 'roakey', + messages: [ + expect.objectContaining({ role: 'system', pieces: [expect.objectContaining({ original_value: 'Simulated context' })] }), + expect.objectContaining({ role: 'user', pieces: [expect.objectContaining({ original_value: 'Final user turn' })] }), + ], + })) + expect(mockAttacks.getConversation).not.toHaveBeenCalled() + expect(mockStartRun).not.toHaveBeenCalled() + await waitFor(() => expect(mockEstimateRun).toHaveBeenLastCalledWith('foundry.red_team_agent', expect.objectContaining({ + scenario_params: { max_turns: 3, prepended_conversation_id: 'edited-conversation', apply_converters_to_prepended_history: false }, + }), expect.any(Object))) + expect(screen.getByRole('switch', { name: 'Apply converters to conversation' })).toBeEnabled() + }) + + it('should enable the converters toggle only with a conversation ID and submit its boolean choice', async () => { + const user = userEvent.setup() + renderDetail('/scanner/technique.execute') + const toggle = await screen.findByRole('switch', { name: 'Apply converters to conversation' }) + const id = screen.getByRole('textbox', { name: 'prepended_conversation_id' }) + expect(toggle).not.toBeChecked() + expect(toggle).toBeDisabled() + expect(screen.queryByRole('combobox', { name: 'apply_converters_to_prepended_history' })).not.toBeInTheDocument() + await user.type(id, ' ') + expect(toggle).toBeDisabled() + await user.clear(id) + await user.type(id, 'history-conversation') + expect(toggle).toBeEnabled() + await user.click(toggle) + expect(toggle).toBeChecked() + await waitFor(() => expect(mockEstimateRun).toHaveBeenLastCalledWith('foundry.red_team_agent', expect.objectContaining({ + scenario_params: { prepended_conversation_id: 'history-conversation', apply_converters_to_prepended_history: true }, + }), expect.any(Object))) + await user.click(toggle) + expect(toggle).not.toBeChecked() + await waitFor(() => expect(mockEstimateRun).toHaveBeenLastCalledWith('foundry.red_team_agent', expect.objectContaining({ + scenario_params: { prepended_conversation_id: 'history-conversation', apply_converters_to_prepended_history: false }, + }), expect.any(Object))) + await user.clear(id) + expect(toggle).toBeDisabled() + expect(mockStartRun).not.toHaveBeenCalled() + }) + + it('should fill the conversation from the URL without opening an editor or launching a scan', async () => { + const user = userEvent.setup() + renderDetail('/scanner/technique.execute?prepended_conversation_id=history-conversation') + const id = await screen.findByRole('textbox', { name: 'prepended_conversation_id' }) + expect(id).toHaveValue('history-conversation') + expect(screen.getByRole('button', { name: 'Edit conversation' })).toBeEnabled() + expect(screen.getByRole('switch', { name: 'Apply converters to conversation' })).toBeEnabled() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(mockAttacks.getConversation).not.toHaveBeenCalled() + expect(mockStartRun).not.toHaveBeenCalled() + await waitFor(() => expect(mockEstimateRun).toHaveBeenLastCalledWith('foundry.red_team_agent', expect.objectContaining({ + scenario_params: { prepended_conversation_id: 'history-conversation', apply_converters_to_prepended_history: false }, + }), expect.any(Object))) + await user.clear(id) + await user.type(id, 'another-conversation') + await waitFor(() => expect(mockEstimateRun).toHaveBeenLastCalledWith('foundry.red_team_agent', expect.objectContaining({ + scenario_params: { prepended_conversation_id: 'another-conversation', apply_converters_to_prepended_history: false }, + }), expect.any(Object))) + expect(id).toHaveValue('another-conversation') + }) + + it('should fill a new conversation when the same scanner is opened with a different URL parameter', async () => { + const user = userEvent.setup() + render( + + Use another conversation + + } /> + + ) + expect(await screen.findByRole('textbox', { name: 'prepended_conversation_id' })).toHaveValue('first-conversation') + await user.click(screen.getByRole('link', { name: 'Use another conversation' })) + expect(await screen.findByRole('textbox', { name: 'prepended_conversation_id' })).toHaveValue('second-conversation') + expect(mockStartRun).not.toHaveBeenCalled() + }) + + it.each([ + '?prepended_conversation_id=%20%20', + '?prepended_conversation_id=first&prepended_conversation_id=second', + ])('should leave the conversation unset for an empty or ambiguous URL parameter: %s', async (query: string) => { + renderDetail(`/scanner/technique.execute${query}`) + expect(await screen.findByRole('textbox', { name: 'prepended_conversation_id' })).toHaveValue('') + expect(screen.getByRole('button', { name: 'Edit conversation' })).toBeDisabled() + expect(mockStartRun).not.toHaveBeenCalled() + }) + + it('should decode the conversation URL parameter', async () => { + renderDetail('/scanner/technique.execute?prepended_conversation_id=saved%2Fhistory%20%26%20context') + expect(await screen.findByRole('textbox', { name: 'prepended_conversation_id' })).toHaveValue('saved/history & context') + }) + + it('should preserve defaults and ignore conversation IDs that the scanner does not support', async () => { + mockGetScenario.mockResolvedValue(makeScenario({ + supported_parameters: [{ name: 'max_turns', type_name: 'int', required: false, default: '7' }], + })) + renderDetail('/scanner/foundry.red_team_agent?prepended_conversation_id=history-conversation') + expect(await screen.findByRole('spinbutton', { name: 'max_turns' })).toHaveValue(7) + expect(screen.queryByRole('textbox', { name: 'prepended_conversation_id' })).not.toBeInTheDocument() + await waitFor(() => expect(mockEstimateRun).toHaveBeenLastCalledWith('foundry.red_team_agent', expect.objectContaining({ + scenario_params: { max_turns: 7 }, + }), expect.any(Object))) + }) + + it.each(['False', 'True'])('should use the declared %s converters default', async (defaultValue: string) => { + mockGetScenario.mockResolvedValue(makeScenario({ + supported_parameters: [ + { name: 'prepended_conversation_id', type_name: 'str', required: false, default: 'history-conversation' }, + { name: 'apply_converters_to_prepended_history', type_name: 'bool', required: false, default: defaultValue }, + ], + })) + renderDetail('/scanner/technique.execute') + const toggle = await screen.findByRole('switch', { name: 'Apply converters to conversation' }) + expect(toggle).toBeEnabled() + if (defaultValue === 'True') expect(toggle).toBeChecked() + else expect(toggle).not.toBeChecked() + }) + + it('should load the field history for editing and replace its ID with a saved copy', async () => { + const user = userEvent.setup() + renderDetail('/scanner/technique.execute', {}, true) + await user.type(await screen.findByRole('textbox', { name: 'prepended_conversation_id' }), ' history-conversation ') + await user.click(screen.getByRole('button', { name: 'Edit conversation' })) + const first = await screen.findByRole('region', { name: 'Message 1' }) + expect(within(first).getByRole('textbox')).toHaveValue('Saved context') + await user.type(within(first).getByRole('textbox'), ' changed') + await user.click(screen.getByRole('button', { name: 'Add to attack' })) + expect(await screen.findByRole('textbox', { name: 'prepended_conversation_id' })).toHaveValue('edited-conversation') + expect(mockAttacks.getConversation).toHaveBeenCalledWith('history-conversation') + expect(mockAttacks.saveConversation).toHaveBeenCalledWith(expect.objectContaining({ + destination: 'new_attack', source_attack_result_id: 'history-attack', + source_conversation_id: 'history-conversation', target_registry_name: undefined, + messages: [expect.objectContaining({ + role: 'system', pieces: [expect.objectContaining({ original_value: 'Saved context changed' })], + })], + })) + expect(mockStartRun).not.toHaveBeenCalled() + }) + + it('should block scan preview while history loads or its editor is open', async () => { + let resolveConversation: (response: AddMessageResponse) => void = () => {} + mockAttacks.getConversation.mockReturnValue(new Promise((resolve) => { + resolveConversation = resolve + })) + const user = userEvent.setup() + renderDetail('/scanner/technique.execute?prepended_conversation_id=history-conversation', {}, true) + const launch = await screen.findByRole('button', { name: 'Launch scan' }) + const form = launch.closest('form') + if (!form) throw new Error('Launch scan must submit the configuration form.') + expect(launch).toBeEnabled() + await user.click(screen.getByRole('button', { name: 'Edit conversation' })) + expect(launch).toBeDisabled() + await user.click(launch) + fireEvent.submit(form) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(mockStartRun).not.toHaveBeenCalled() + + await act(async () => { resolveConversation(saved) }) + await screen.findByRole('region', { name: 'Message 1' }) + fireEvent.submit(form) + expect(screen.queryByText('Run preview')).not.toBeInTheDocument() + expect(mockStartRun).not.toHaveBeenCalled() + await user.click(screen.getByRole('button', { name: 'Cancel' })) + await screen.findByRole('textbox', { name: 'prepended_conversation_id' }) + expect(launch).toBeEnabled() + await confirmRunPreview(user) + expect(mockStartRun).toHaveBeenCalledTimes(1) + expect(mockStartRun).toHaveBeenCalledWith(expect.objectContaining({ + scenario_params: expect.objectContaining({ prepended_conversation_id: 'history-conversation' }), + })) + }) + + it('should block scan preview until the edited conversation is saved and launch with its new ID', async () => { + let resolveSave: (response: AddMessageResponse) => void = () => {} + mockAttacks.saveConversation.mockReturnValue(new Promise((resolve) => { + resolveSave = resolve + })) + const user = userEvent.setup() + renderDetail('/scanner/technique.execute?prepended_conversation_id=history-conversation', {}, true) + const launch = await screen.findByRole('button', { name: 'Launch scan' }) + const form = launch.closest('form') + if (!form) throw new Error('Launch scan must submit the configuration form.') + await user.click(screen.getByRole('button', { name: 'Edit conversation' })) + await user.type(within(await screen.findByRole('region', { name: 'Message 1' })).getByRole('textbox'), ' changed') + await user.click(screen.getByRole('button', { name: 'Add to attack' })) + expect(mockAttacks.saveConversation).toHaveBeenCalledTimes(1) + expect(launch).toBeDisabled() + fireEvent.submit(form) + expect(screen.queryByText('Run preview')).not.toBeInTheDocument() + expect(mockStartRun).not.toHaveBeenCalled() + + await act(async () => { + resolveSave({ ...saved, messages: { ...saved.messages, conversation_id: 'edited-conversation' } }) + }) + expect(await screen.findByRole('textbox', { name: 'prepended_conversation_id' })).toHaveValue('edited-conversation') + expect(launch).toBeEnabled() + await confirmRunPreview(user) + expect(mockStartRun).toHaveBeenCalledTimes(1) + expect(mockStartRun).toHaveBeenCalledWith(expect.objectContaining({ + scenario_params: expect.objectContaining({ prepended_conversation_id: 'edited-conversation' }), + })) + }) + + it('should keep the configured ID when an edited draft is discarded', async () => { + const user = userEvent.setup() + renderDetail('/scanner/technique.execute', {}, true) + await user.type(await screen.findByRole('textbox', { name: 'prepended_conversation_id' }), 'history-conversation') + await user.click(screen.getByRole('button', { name: 'Edit conversation' })) + await user.type(within(await screen.findByRole('region', { name: 'Message 1' })).getByRole('textbox'), ' discarded') + await user.click(screen.getByRole('button', { name: 'Cancel' })) + await user.click(await screen.findByRole('button', { name: 'Discard draft' })) + expect(await screen.findByRole('textbox', { name: 'prepended_conversation_id' })).toHaveValue('history-conversation') + expect(mockAttacks.saveConversation).not.toHaveBeenCalled() + }) + + it('should report a missing conversation without opening an empty editor or changing the field', async () => { + mockAttacks.getConversation.mockRejectedValue(new Error('Saved chat conversation not found')) + const user = userEvent.setup() + renderDetail('/scanner/technique.execute', {}, true) + await user.type(await screen.findByRole('textbox', { name: 'prepended_conversation_id' }), 'missing') + await user.click(screen.getByRole('button', { name: 'Edit conversation' })) + expect(await screen.findByText('Saved chat conversation not found')).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: 'prepended_conversation_id' })).toHaveValue('missing') + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(mockAttacks.saveConversation).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: 'Launch scan' })).toBeEnabled() + }) + + it('should keep the draft and scanner ID when saving fails', async () => { + mockAttacks.saveConversation.mockRejectedValue(new Error('Conversation save failed')) + const user = userEvent.setup() + renderDetail('/scanner/technique.execute', {}, true) + await user.type(await screen.findByRole('textbox', { name: 'prepended_conversation_id' }), 'history-conversation') + await user.click(screen.getByRole('button', { name: 'Edit conversation' })) + await user.type(within(await screen.findByRole('region', { name: 'Message 1' })).getByRole('textbox'), ' kept') + await user.click(screen.getByRole('button', { name: 'Add to attack' })) + expect(await screen.findByText('Conversation save failed')).toBeInTheDocument() + expect(within(screen.getByRole('region', { name: 'Message 1' })).getByRole('textbox')).toHaveValue('Saved context kept') + expect(mockStartRun).not.toHaveBeenCalled() + }) + }) + it('blocks a legacy catalog response without support names instead of guessing common support', async () => { const legacyScenario = makeScenario() Reflect.deleteProperty(legacyScenario, 'supported_parameter_names') diff --git a/frontend/src/components/Scenarios/ScenarioDetail.tsx b/frontend/src/components/Scenarios/ScenarioDetail.tsx index cb1215b1fb..b420d0f1c9 100644 --- a/frontend/src/components/Scenarios/ScenarioDetail.tsx +++ b/frontend/src/components/Scenarios/ScenarioDetail.tsx @@ -1,4 +1,4 @@ -import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react' +import { type FormEvent, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { Badge, @@ -17,6 +17,7 @@ import { mergeClasses, Select, Spinner, + Switch, Text, Tooltip, ToggleButton, @@ -27,10 +28,12 @@ import { InfoRegular, SettingsRegular, } from '@fluentui/react-icons' -import { Link, useNavigate, useParams } from 'react-router' +import { Link, useNavigate, useParams, useSearchParams } from 'react-router' import MarkdownContent from '@/components/Markdown/MarkdownContent' +import ConversationEditor, { type ConversationEditorHandle } from '@/components/Chat/ConversationEditor' import TargetSelect from '@/components/Config/TargetSelect' +import { useConversationDraft } from '@/hooks/useConversationDraft' import { useRuntime } from '@/hooks/useRuntime' import { useScenarioDatasets } from '@/hooks/useScenarioDatasets' import ParameterField from '@/components/Parameters/ParameterField' @@ -41,9 +44,10 @@ import { type ParameterFormValue, } from '@/components/Parameters/parameterForm' import type { ViewName } from '@/components/Sidebar/Navigation' -import { scenariosApi } from '@/services/api' +import { attacksApi, scenariosApi } from '@/services/api' import { toApiError } from '@/services/errors' import type { + AddMessageResponse, Parameter, RegisteredScenario, RunScenarioRequest, @@ -58,6 +62,7 @@ import type { ScenarioRunSizeEstimateResponse, TargetInstance, } from '@/types' +import { newDraftMessage, toConversationDraft } from '@/utils/conversationDraft' import { routerPathParamValue, scenarioRunRoutePath } from '@/utils/routeParams' import { sameTarget, targetModelName } from '@/utils/targetIdentity' @@ -395,18 +400,22 @@ interface ScenarioDetailProps { export default function ScenarioDetail(props: ScenarioDetailProps) { const { scenarioName: encodedScenarioName } = useParams<{ scenarioName: string }>() - // Keying on the raw URL param forces a full remount (and state reset to the - // initial "loading" values) whenever the route navigates from one scenario - // detail page directly to another. - return + const [searchParams] = useSearchParams() + const conversationIds = searchParams.getAll('prepended_conversation_id') + const initialConversationId = conversationIds.length === 1 ? conversationIds[0].trim() : '' + // A new scenario or conversation link starts a fresh configuration form. + return } interface ScenarioDetailContentProps extends ScenarioDetailProps { encodedScenarioName: string | undefined + initialConversationId: string } function ScenarioDetailContent({ encodedScenarioName, + initialConversationId, targets, defaultObjectiveTarget, defaultAdversarialTarget, @@ -519,6 +528,7 @@ function ScenarioDetailContent({ parameter.name === 'prepended_conversation_id') + ? dynamicParameters.find((parameter) => parameter.name === 'apply_converters_to_prepended_history') + : undefined const supportsObjectiveTarget = supportsParameter(scenario, 'objective_target') const supportsTechniques = supportsParameter(scenario, 'scenario_techniques') const supportsBaseline = supportsParameter(scenario, 'include_baseline') @@ -630,12 +645,61 @@ function ScenarioLaunchForm({ const [maxConcurrency, setMaxConcurrency] = useState(DEFAULT_MAX_CONCURRENCY) const [maxRetries, setMaxRetries] = useState(DEFAULT_MAX_RETRIES) const [scenarioParamValues, setScenarioParamValues] = useState>(() => - getInitialFormValues(dynamicParameters), + getInitialFormValues(dynamicParameters, initialConversationId ? { prepended_conversation_id: initialConversationId } : undefined), ) const [validationError, setValidationError] = useState(null) const [apiError, setApiError] = useState(null) const [submitting, setSubmitting] = useState(false) const [previewOpen, setPreviewOpen] = useState(false) + const newAttackContext = useMemo(() => ({ + generation: runtime.generation, + ready: runtime.ready && defaultsReady && !staleSelection, + labels, + }), [runtime.generation, runtime.ready, defaultsReady, staleSelection, labels]) + const conversation = useConversationDraft(newAttackContext) + const conversationEditorRef = useRef(null) + const conversationContextRef = useRef(newAttackContext) + useLayoutEffect(() => { conversationContextRef.current = newAttackContext }, [newAttackContext]) + const conversationLoadSequence = useRef(0) + useEffect(() => () => { conversationLoadSequence.current += 1 }, []) + const [conversationParameter, setConversationParameter] = useState('') + const [loadingConversation, setLoadingConversation] = useState(false) + const [conversationError, setConversationError] = useState(null) + const conversationBusy = loadingConversation || conversation.draft !== null + const configuredConversationId = scenarioParamValues.prepended_conversation_id + const canEditConversation = typeof configuredConversationId === 'string' && Boolean(configuredConversationId.trim()) + + const beginConversation = async (parameterName: string, edit: boolean): Promise => { + if (conversationBusy || previewOpen || isSubmittingRef.current) return + const sequence = ++conversationLoadSequence.current + const initialContext = conversationContextRef.current + setConversationParameter(parameterName) + setConversationError(null) + setLoadingConversation(edit) + try { + const value = scenarioParamValues[parameterName] + if (edit && (typeof value !== 'string' || !value.trim())) { + throw new Error('Enter a saved conversation ID before editing.') + } + const source = edit && typeof value === 'string' ? await attacksApi.getConversation(value.trim()) : null + if (sequence !== conversationLoadSequence.current) return + const currentContext = conversationContextRef.current + if (!currentContext.ready || currentContext.generation !== initialContext.generation) { + throw new Error('Runtime or default labels changed. Retry after default labels finish loading.') + } + conversation.begin({ + messages: source ? toConversationDraft(source.messages.messages) : [newDraftMessage()], + objective: '', initialObjective: '', + sourceAttackId: source?.attack.attack_result_id ?? null, + sourceConversationId: source?.messages.conversation_id ?? null, + target: null, labels: currentContext.labels, + }) + } catch (error) { + if (sequence === conversationLoadSequence.current) setConversationError(toApiError(error).detail) + } finally { + if (sequence === conversationLoadSequence.current) setLoadingConversation(false) + } + } const [estimateRequestState, setEstimateRequestState] = useState(null) const [lastGoodEstimate, setLastGoodEstimate] = useState(null) // Synchronous guard against a double-submit racing ahead of the state update. @@ -907,7 +971,7 @@ function ScenarioLaunchForm({ } const handleLaunchConfirmed = async (): Promise => { - if (isSubmittingRef.current || !runtime.ready || !defaultsReady || staleSelection || unavailableSelection) { + if (isSubmittingRef.current || conversationBusy || !runtime.ready || !defaultsReady || staleSelection || unavailableSelection) { return } @@ -939,6 +1003,7 @@ function ScenarioLaunchForm({ const handleFormSubmit = (event: FormEvent): void => { event.preventDefault() + if (conversationBusy || isSubmittingRef.current) return setApiError(null) if (!requestResult.ok) { setValidationError(requestResult.error) @@ -1177,15 +1242,43 @@ function ScenarioLaunchForm({ Parameters
- {dynamicParameters.map((parameter) => ( - + {dynamicParameters.filter((parameter) => parameter.name !== conversationConvertersParameter?.name).map((parameter) => ( +
+ + {parameter.name === 'prepended_conversation_id' && <> +
+ + + {conversationConvertersParameter && ( + + updateScenarioParam(conversationConvertersParameter.name, String(data.checked))} /> + + )} +
+ {loadingConversation && } + {conversationError && + {conversationError} + } + } +
))} {scenario.uses_default_adversarial_target && ( Launch scan @@ -1305,6 +1398,25 @@ function ScenarioLaunchForm({ + {conversation.draft && { + if (!data.open) conversationEditorRef.current?.cancel() + }}> + + + {conversation.draft.sourceConversationId ? 'Edit conversation' : 'New conversation'} + + { + updateScenarioParam(conversationParameter, response.messages.conversation_id) + conversation.discard() + }} /> + + + + } + { @@ -1422,7 +1534,7 @@ function ScenarioLaunchForm({