From 6c997728b9a9fd055b725e6100fca984b2da0128 Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Tue, 13 Oct 2026 10:06:35 -0700 Subject: [PATCH 1/4] FEAT: Improve conversation branching and technique reuse Add per-turn branching, target-free prompt drafting, and scanner conversation editing. Open technique.execute with the saved conversation ID and expose history conversion as a toggle. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9d6ee85f-fe71-4f31-93fd-d1955453f763 --- frontend/README.md | 22 ++ frontend/src/App.test.tsx | 3 +- frontend/src/App.tsx | 4 +- .../components/Chat/ChatInputArea.test.tsx | 14 +- .../src/components/Chat/ChatInputArea.tsx | 3 - .../src/components/Chat/ChatWindow.test.tsx | 140 ++++++++-- frontend/src/components/Chat/ChatWindow.tsx | 46 +++- .../components/Chat/ConversationEditor.tsx | 29 ++- .../src/components/Chat/MessageList.test.tsx | 16 ++ frontend/src/components/Chat/MessageList.tsx | 8 +- .../Scenarios/ScenarioDetail.styles.ts | 8 + .../Scenarios/ScenarioDetail.test.tsx | 245 +++++++++++++++++- .../components/Scenarios/ScenarioDetail.tsx | 143 ++++++++-- frontend/src/hooks/useConversationDraft.ts | 3 +- frontend/src/services/api.ts | 5 + frontend/src/utils/conversationDraft.test.ts | 18 +- frontend/src/utils/conversationDraft.ts | 15 ++ pyrit/backend/README.md | 8 + pyrit/backend/routes/attacks.py | 14 + pyrit/backend/services/attack_service.py | 20 ++ .../unit/backend/test_conversation_editor.py | 99 ++++++- 21 files changed, 798 insertions(+), 65 deletions(-) diff --git a/frontend/README.md b/frontend/README.md index 690cce7374..0566bb96e0 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -21,6 +21,28 @@ 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 conversation** menu. +**New conversation** and **New attack** copy history through that turn, 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. You can enter and edit prompts without a target. +Send stays disabled until you select a target; no target-warning box is shown. + +**Technique.execute** saves a target-free copy through the selected turn +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. +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/src/App.test.tsx b/frontend/src/App.test.tsx index e856f9e0db..3c9bf35f3a 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1228,7 +1228,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 ccd71ceb99..79a1533f97 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -536,11 +536,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..6053e43b72 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(); diff --git a/frontend/src/components/Chat/ChatInputArea.tsx b/frontend/src/components/Chat/ChatInputArea.tsx index 5fd0efe4d7..9637c35084 100644 --- a/frontend/src/components/Chat/ChatInputArea.tsx +++ b/frontend/src/components/Chat/ChatInputArea.tsx @@ -164,9 +164,6 @@ const ChatInputArea = forwardRef(functi 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.') } diff --git a/frontend/src/components/Chat/ChatWindow.test.tsx b/frontend/src/components/Chat/ChatWindow.test.tsx index 7e4a9c6725..f2129c305a 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,6 +98,11 @@ const actualMessageMapper = jest.requireActual{location.pathname}{location.search}; +} + async function findRecoveryAction(user: ReturnType): Promise { const buttons = await screen.findAllByRole("button", { name: "Copy conversation" }); const lastButton = buttons.at(-1); @@ -960,6 +965,87 @@ 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 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 conversation" })); + await user.click(screen.getByRole("menuitem", { name: "New 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 +1178,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 +1227,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" })); @@ -1155,8 +1244,13 @@ 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) => { + ["Technique.execute", "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 === "Technique.execute"; 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 +1268,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 +1283,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(); }); @@ -1707,14 +1815,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(); }); diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 41a4ffd687..ea5cea6f70 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' @@ -315,6 +315,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 @@ -1469,7 +1470,28 @@ 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) + inputBoxRef.current?.focus() + } + }, history) + } + + const copyConversation = async ( + messageIndex: number, destination: 'same_attack' | 'new_attack', useInTechnique = false, + ): Promise => { if (!attackResultId || !viewedConversationId || copyingRef.current || isSending || isLoadingEdit) return if (destination === 'same_attack' && isMutationLocked) return if (destination === 'new_attack' && (!runtime.ready || !defaultsReady)) return @@ -1483,6 +1505,7 @@ 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' && ( @@ -1491,7 +1514,7 @@ export default function ChatWindow({ )) { 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 = !useInTechnique && recoverableSend && messageIndex === recoverableSend.failedRequestMessageIndex const sourceMessages = isFailedPrompt ? source.messages.filter((message: BackendMessage) => message.turn_number <= recoverableSend.historyCutoffIndex) : source.messages.slice(0, messageIndex + 1) @@ -1499,7 +1522,8 @@ export default function ChatWindow({ if (isFailedPrompt && activeTarget && editorTargetDisabledReason(activeTarget, draftDataTypes(copiedMessages))) { copiedMessages = [] } - const target = destination === 'new_attack' && activeTarget + const target = useInTechnique || (destination === 'same_attack' && targetResolutionStatus === 'unbound') + ? null : destination === 'new_attack' && activeTarget && !isFailedPrompt && editorTargetDisabledReason(activeTarget, draftDataTypes(copiedMessages)) ? null : activeTarget if (isFailedPrompt && copiedMessages.length === 0) { @@ -1511,6 +1535,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 } @@ -1524,11 +1549,17 @@ export default function ChatWindow({ messages: copiedMessages, }, destination) if (viewedConvRef.current !== sourceId) return + if (useInTechnique) { + 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) @@ -1778,12 +1809,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, 'new_attack', true) }} 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 +1869,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()) @@ -190,12 +195,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) @@ -296,16 +311,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..4f58603f21 100644 --- a/frontend/src/components/Chat/MessageList.test.tsx +++ b/frontend/src/components/Chat/MessageList.test.tsx @@ -111,6 +111,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 conversation" })); + await user.click(screen.getByRole("menuitem", { name: "New conversation" })); + expect(onCopyToNewConversation).toHaveBeenCalledWith(0); + await user.click(screen.getByRole("button", { name: "Copy conversation" })); + await user.click(screen.getByRole("menuitem", { name: "Technique.execute" })); + expect(onUseInTechnique).toHaveBeenCalledWith(0); + }); + it("should render user messages", () => { const userMessages: Message[] = [ { diff --git a/frontend/src/components/Chat/MessageList.tsx b/frontend/src/components/Chat/MessageList.tsx index c34c0ce8d7..49751e3ca9 100644 --- a/frontend/src/components/Chat/MessageList.tsx +++ b/frontend/src/components/Chat/MessageList.tsx @@ -43,6 +43,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 */ @@ -542,7 +543,7 @@ function getRenderMessagePieces(message: Message, messageIndex: number): RenderM } 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() @@ -767,7 +768,7 @@ export default function MessageList({ {!message.isLoading && !message.error && (
- {onCopyToInput && (isUser || isSimulated || message.role === 'assistant') && ( + {onCopyToInput && ( )} 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 f63684c869..3178e1cf22 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, ScenarioRunSizeEstimateResponse, TargetInstance, @@ -16,6 +17,7 @@ import ScenarioDetail from './ScenarioDetail' jest.mock('@/hooks/useRuntime', () => ({ useRuntime: jest.fn() })) jest.mock('@/services/api', () => ({ + attacksApi: { getConversation: jest.fn(), saveConversation: jest.fn() }, scenariosApi: { estimateRun: jest.fn(), getScenario: jest.fn(), @@ -31,6 +33,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('') @@ -165,6 +168,7 @@ async function confirmRunPreview(user: ReturnType): Prom function renderDetail( path: string, props: Partial> = {}, + dataRouter = false, ) { const defaultProps = { targets: [makeTarget('target-a'), makeTarget('target-b')], @@ -177,14 +181,16 @@ function renderDetail( const merged = { ...defaultProps, ...props } const renderPage = (pageProps: React.ComponentProps): React.ReactElement => ( - + {dataRouter ? , + }], { initialEntries: [path] })} /> : } /> - + } ) const view = render(renderPage(merged)) @@ -213,6 +219,237 @@ 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 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() + }) + + 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 afdacba996..6651e6f02d 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, @@ -18,6 +18,7 @@ import { mergeClasses, Select, Spinner, + Switch, Text, Tooltip, ToggleButton, @@ -28,10 +29,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 ParameterField from '@/components/Parameters/ParameterField' import SingleStepSpinButton from '@/components/Parameters/SingleStepSpinButton' @@ -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, @@ -54,6 +58,7 @@ import type { ScenarioTechniqueSummary, TargetInstance, } from '@/types' +import { newDraftMessage, toConversationDraft } from '@/utils/conversationDraft' import { routerPathParamValue, scenarioRunRoutePath } from '@/utils/routeParams' import { sameTarget, targetModelName } from '@/utils/targetIdentity' @@ -427,18 +432,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, @@ -551,6 +560,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') @@ -645,12 +660,59 @@ 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 configuredConversationId = scenarioParamValues.prepended_conversation_id + const canEditConversation = typeof configuredConversationId === 'string' && Boolean(configuredConversationId.trim()) + + const beginConversation = async (parameterName: string, edit: boolean): Promise => { + 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. @@ -1173,15 +1235,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 && ( + {conversation.draft && { + if (!data.open) conversationEditorRef.current?.cancel() + }}> + + + {conversation.draft.sourceConversationId ? 'Edit conversation' : 'New conversation'} + + { + updateScenarioParam(conversationParameter, response.messages.conversation_id) + conversation.discard() + }} /> + + + + } + { diff --git a/frontend/src/hooks/useConversationDraft.ts b/frontend/src/hooks/useConversationDraft.ts index b7ac3c38a9..31a770de74 100644 --- a/frontend/src/hooks/useConversationDraft.ts +++ b/frontend/src/hooks/useConversationDraft.ts @@ -133,13 +133,14 @@ export function useConversationDraft(newAttackContext?: NewAttackContext) { } const save = async ( destination: SaveConversationRequest['destination'], onSaved: (response: AddMessageResponse) => void, + messages: ConversationDraftMessage[] = draft?.messages ?? [], ): Promise => { if (!draft || workflow.saving) return const id = draft.id setError(null) try { if (validationError || targetError) throw new Error(validationError ?? targetError) - const response = await workflow.save(draft, destination) + const response = await workflow.save({ ...draft, messages }, destination) if (activeId.current !== id) return saved.current = true onSaved(response) diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6758ef71a8..10d3126834 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -356,6 +356,11 @@ export const attacksApi = { return response.data }, + getConversation: async (conversationId: string): Promise => { + const response = await apiClient.get(`/attacks/conversations/${encodeURIComponent(conversationId)}`) + return response.data + }, + getMessages: async (attackResultId: string, conversationId: string): Promise => { const response = await apiClient.get( `/attacks/${encodeURIComponent(attackResultId)}/messages`, diff --git a/frontend/src/utils/conversationDraft.test.ts b/frontend/src/utils/conversationDraft.test.ts index ef8ce3383d..49d12cbbd4 100644 --- a/frontend/src/utils/conversationDraft.test.ts +++ b/frontend/src/utils/conversationDraft.test.ts @@ -1,9 +1,25 @@ import type { ConversationDraftMessage, TargetCapabilities } from '@/types' import { makeTarget } from '@/test-utils/targetFixtures' -import { draftConverterInputs, draftDataTypes, editorTargetDisabledReason, serializeDraft, toConversationDraft, unansweredToolCallId, validateDraft } from './conversationDraft' +import { draftConverterInputs, draftDataTypes, draftMessageToInput, editorTargetDisabledReason, serializeDraft, toConversationDraft, unansweredToolCallId, validateDraft } from './conversationDraft' describe('conversation drafts', () => { + it('moves text and media to the composer without losing converted content or local files', () => { + const file = new File(['image'], 'image.png', { type: 'image/png' }) + const prompt = draftMessageToInput({ + id: 'user', role: 'user', pieces: [ + { draftId: 'text', data_type: 'text', original_value: 'Original', converted_value: 'Converted' }, + { draftId: 'file', data_type: 'image_path', original_value: '', file, previewUrl: 'blob:local' }, + { draftId: 'converted', data_type: 'text', original_value: 'Image prompt', + converted_value: 'output.png', converted_value_data_type: 'image_path' }, + ], + }) + expect(prompt.text).toBe('Converted') + expect(prompt.attachments).toHaveLength(2) + expect(prompt.attachments[0]).toMatchObject({ file, url: 'blob:local' }) + expect(prompt.attachments[1]).toMatchObject({ sourceValue: 'output.png', sourceDataType: 'image_path' }) + }) + it.each([ ['assistant', 'simulated_assistant'], ['tool', 'simulated_tool'], diff --git a/frontend/src/utils/conversationDraft.ts b/frontend/src/utils/conversationDraft.ts index db4ad261b7..5ffe37365b 100644 --- a/frontend/src/utils/conversationDraft.ts +++ b/frontend/src/utils/conversationDraft.ts @@ -134,6 +134,21 @@ export function draftAttachment(piece: ConversationDraftPiece): MessageAttachmen } } +export function draftMessageToInput(message: ConversationDraftMessage): { text: string; attachments: MessageAttachment[] } { + const pieces = message.pieces.map((piece: ConversationDraftPiece) => piece.converted_value === undefined ? piece : { + ...piece, + original_value: piece.converted_value, + data_type: piece.converted_value_data_type ?? piece.data_type, + file: undefined, + previewUrl: undefined, + }) + return { + text: pieces.filter((piece: ConversationDraftPiece) => !isPathDataType(piece.data_type)) + .map((piece: ConversationDraftPiece) => piece.original_value).join('\n'), + attachments: pieces.filter((piece: ConversationDraftPiece) => isPathDataType(piece.data_type)).map(draftAttachment), + } +} + export function attachmentDraft(attachment: MessageAttachment): ConversationDraftPiece { return { draftId: attachment.draftId ?? generateClientId(), diff --git a/pyrit/backend/README.md b/pyrit/backend/README.md index f425c5e850..c75464022c 100644 --- a/pyrit/backend/README.md +++ b/pyrit/backend/README.md @@ -32,6 +32,14 @@ The API will be available at `http://localhost:8000` - `GET /api/targets` - List available prompt targets - `GET /api/targets/{id}` - Get target details +### Saved conversations + +`GET /api/attacks/conversations/{conversation_id}` returns the saved chat history +and its owning attack. The scanner uses this read-only lookup to open its +conversation editor. Missing IDs and conversations outside a saved chat return +404. Saving from the scanner creates a copy through `POST /api/attacks/save-conversation`; +it does not change the source or send to a target. + ### Techniques | Endpoint | Result | diff --git a/pyrit/backend/routes/attacks.py b/pyrit/backend/routes/attacks.py index bb75f1f33b..6fb61ab3dd 100644 --- a/pyrit/backend/routes/attacks.py +++ b/pyrit/backend/routes/attacks.py @@ -71,6 +71,20 @@ async def save_conversation_async(*, body: SaveConversationRequest, request: Req raise HTTPException(status_code=422, detail=str(exc)) from exc +@router.get("/conversations/{conversation_id}", response_model=AddMessageResponse) +async def get_saved_conversation_async(conversation_id: IdentifierStr) -> AddMessageResponse: + """ + Get a saved chat conversation and its attack for the scanner editor. + + Returns: + The stored attack and conversation. + """ + response = await get_attack_service().get_saved_conversation_async(conversation_id=conversation_id) + if response is None: + raise HTTPException(status_code=404, detail="Saved chat conversation not found") + return response + + @router.get( "", response_model=AttackListResponse, diff --git a/pyrit/backend/services/attack_service.py b/pyrit/backend/services/attack_service.py index 14d4dfb2ec..9cfeaad983 100644 --- a/pyrit/backend/services/attack_service.py +++ b/pyrit/backend/services/attack_service.py @@ -383,6 +383,26 @@ async def get_conversation_messages_async( target_response_status=_get_latest_target_response_status(backend_messages), ) + async def get_saved_conversation_async(self, *, conversation_id: str) -> AddMessageResponse | None: + """ + Resolve a saved chat conversation by ID without changing its history. + + Returns: + The stored attack and conversation, or None if no saved chat owns the ID. + """ + conversation = await self._memory.get_conversation_metadata_async(conversation_id=conversation_id) + if conversation is None or conversation.attack_result_id is None: + return None + attack = await self.get_attack_async(attack_result_id=conversation.attack_result_id) + if attack is None or conversation_id not in attack.get_active_conversation_ids(): + return None + messages = await self.get_conversation_messages_async( + attack_result_id=conversation.attack_result_id, conversation_id=conversation_id + ) + if messages is None: + return None + return AddMessageResponse(attack=attack, messages=messages) + async def create_attack_async(self, *, request: CreateAttackRequest) -> CreateAttackResponse: """ Create a new attack. diff --git a/tests/unit/backend/test_conversation_editor.py b/tests/unit/backend/test_conversation_editor.py index 68732c6a24..234ea146f6 100644 --- a/tests/unit/backend/test_conversation_editor.py +++ b/tests/unit/backend/test_conversation_editor.py @@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from fastapi import HTTPException from sqlalchemy.orm import Session from pyrit.backend.mappers.target_mappers import target_object_to_instance @@ -25,12 +26,13 @@ from pyrit.backend.models.common import PaginationInfo from pyrit.backend.models.message_sends import MessageSendRequest, MessageSendState from pyrit.backend.models.targets import TargetListResponse +from pyrit.backend.routes.attacks import get_saved_conversation_async as get_saved_conversation_route_async from pyrit.backend.services.attack_service import AttackService from pyrit.backend.services.target_service import TargetService from pyrit.memory import SQLiteMemory from pyrit.memory.memory_interface import AttackStateConflictError from pyrit.memory.memory_models import PromptMemoryEntry -from pyrit.models import Conversation, MessagePiece, PromptDataType, Score +from pyrit.models import Conversation, ConversationReference, ConversationType, MessagePiece, PromptDataType, Score from pyrit.models.catalog.target import TargetInstance from pyrit.models.target.request_trace_context import RequestTraceContext from pyrit.models.target.target_capabilities import TargetCapabilities @@ -97,6 +99,101 @@ def response_target(patch_central_database: None) -> Iterator[OpenAIResponseTarg @pytest.mark.usefixtures("patch_central_database") class TestConversationEditor: + @pytest.mark.parametrize("related", [False, True]) + async def test_lookup_saved_conversation_and_edit_copy_async( + self, *, sqlite_instance: SQLiteMemory, related: bool + ) -> None: + service = AttackService() + source = await service.save_conversation_async(request=draft()) + if related: + branch = draft(value="Related history") + branch.destination = "same_attack" + branch.attack_result_id = source.attack.attack_result_id + source = await service.save_conversation_async(request=branch) + + loaded = await service.get_saved_conversation_async(conversation_id=source.messages.conversation_id) + assert loaded is not None + assert loaded.attack.attack_result_id == source.attack.attack_result_id + assert loaded.messages == source.messages + request = draft(value="Edited copy") + request.source_attack_result_id = loaded.attack.attack_result_id + request.source_conversation_id = uuid.UUID(loaded.messages.conversation_id) + request.messages[0].pieces[0].source_piece_id = loaded.messages.messages[0].message_pieces[0].id + copied = await service.save_conversation_async(request=request) + assert copied.messages.conversation_id != loaded.messages.conversation_id + assert copied.messages.messages[0].message_pieces[0].original_value == "Edited copy" + assert await service.get_saved_conversation_async(conversation_id=loaded.messages.conversation_id) == loaded + + with patch("pyrit.backend.routes.attacks.get_attack_service", return_value=service): + response = await get_saved_conversation_route_async(conversation_id=loaded.messages.conversation_id) + assert response == loaded + + async def test_lookup_missing_or_unowned_conversation_async(self, sqlite_instance: SQLiteMemory) -> None: + service = AttackService() + missing = str(uuid.uuid4()) + assert await service.get_saved_conversation_async(conversation_id=missing) is None + unowned = str(uuid.uuid4()) + await sqlite_instance.add_message_pieces_to_memory_async( + message_pieces=[MessagePiece(role="user", original_value="Standalone history", conversation_id=unowned)] + ) + assert await service.get_saved_conversation_async(conversation_id=unowned) is None + with patch("pyrit.backend.routes.attacks.get_attack_service", return_value=service): + with pytest.raises(HTTPException) as error: + await get_saved_conversation_route_async(conversation_id=missing) + assert error.value.status_code == 404 + + @pytest.mark.parametrize("conversation_type", [ConversationType.ADVERSARIAL, ConversationType.PREPARATION]) + async def test_lookup_excludes_internal_conversation_async(self, conversation_type: ConversationType) -> None: + service = AttackService() + source = await service.save_conversation_async(request=draft()) + internal = str(uuid.uuid4()) + source.attack.related_conversations.add( + ConversationReference(conversation_id=internal, conversation_type=conversation_type) + ) + with ( + patch.object( + service._memory, + "get_conversation_metadata_async", + new_callable=AsyncMock, + return_value=Conversation(conversation_id=internal, attack_result_id=source.attack.attack_result_id), + ), + patch.object(service, "get_attack_async", new_callable=AsyncMock, return_value=source.attack), + patch.object(service, "get_conversation_messages_async", new_callable=AsyncMock) as messages, + ): + assert await service.get_saved_conversation_async(conversation_id=internal) is None + messages.assert_not_awaited() + + @pytest.mark.parametrize("bound", [False, True]) + async def test_copy_keeps_existing_target_async( + self, *, editor_target: MockPromptTarget, sqlite_instance: SQLiteMemory, bound: bool + ) -> None: + service = AttackService() + initial = draft() + initial.messages[0].role = "system" + initial.target_registry_name = "selected" if bound else None + source = await service.save_conversation_async(request=initial) + request = draft() + request.destination = "same_attack" + request.attack_result_id = source.attack.attack_result_id + request.source_attack_result_id = source.attack.attack_result_id + request.source_conversation_id = uuid.UUID(source.messages.conversation_id) + request.target_registry_name = "selected" if bound else None + request.messages = initial.messages + + saved = await service.save_conversation_async(request=request) + + assert saved.attack.attack_result_id == source.attack.attack_result_id + assert saved.messages.conversation_id != source.messages.conversation_id + assert saved.messages.messages[0].role == "system" + assert saved.attack.target_unbound is (not bound) + original = await sqlite_instance.get_conversation_metadata_async( + conversation_id=source.messages.conversation_id + ) + copied = await sqlite_instance.get_conversation_metadata_async(conversation_id=saved.messages.conversation_id) + assert original is not None and copied is not None + assert original.target_identifier == copied.target_identifier + assert editor_target.prompt_sent == [] + @pytest.mark.parametrize("data_type", ["audio_path", "video_path", "binary_path"]) @pytest.mark.parametrize("same_attack", [False, True]) @pytest.mark.parametrize("converted", [False, True]) From 699781444b7e489401c7ed9657e0b0cd8c289f08 Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Tue, 13 Oct 2026 12:54:29 -0700 Subject: [PATCH 2/4] FIX: Preserve conversation metadata and prevent launch races Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9d6ee85f-fe71-4f31-93fd-d1955453f763 --- frontend/README.md | 4 +- frontend/e2e/chat.spec.ts | 9 ++- frontend/e2e/config.spec.ts | 7 +- frontend/src/components/Chat/ChatWindow.tsx | 27 ++++---- .../Scenarios/ScenarioDetail.test.tsx | 65 +++++++++++++++++++ .../components/Scenarios/ScenarioDetail.tsx | 13 ++-- frontend/src/utils/conversationDraft.test.ts | 39 +++++++++++ frontend/src/utils/conversationDraft.ts | 4 ++ 8 files changed, 146 insertions(+), 22 deletions(-) diff --git a/frontend/README.md b/frontend/README.md index 0566bb96e0..ddfb74a8b5 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -27,7 +27,8 @@ 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. You can enter and edit prompts without a target. +box instead of saved history. Attachments retain their metadata, including 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. **Technique.execute** saves a target-free copy through the selected turn @@ -38,6 +39,7 @@ 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 diff --git a/frontend/e2e/chat.spec.ts b/frontend/e2e/chat.spec.ts index 4ccfb4241d..17052facae 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("Chat").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 b0d048e8cd..cee0180ce8 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("Chat").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/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index ea5cea6f70..a92577b51e 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -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 @@ -1490,11 +1492,11 @@ export default function ChatWindow({ } const copyConversation = async ( - messageIndex: number, destination: 'same_attack' | 'new_attack', useInTechnique = false, + 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() @@ -1508,13 +1510,13 @@ export default function ChatWindow({ 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 = !useInTechnique && 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) @@ -1522,10 +1524,13 @@ export default function ChatWindow({ if (isFailedPrompt && activeTarget && editorTargetDisabledReason(activeTarget, draftDataTypes(copiedMessages))) { copiedMessages = [] } - const target = useInTechnique || (destination === 'same_attack' && targetResolutionStatus === 'unbound') - ? null : 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, @@ -1547,9 +1552,9 @@ export default function ChatWindow({ target, labels, messages: copiedMessages, - }, destination) + }, destination === 'same_attack' ? 'same_attack' : 'new_attack') if (viewedConvRef.current !== sourceId) return - if (useInTechnique) { + if (destination === 'technique') { const params = new URLSearchParams({ prepended_conversation_id: response.messages.conversation_id }) navigate(`/scanner/technique.execute?${params.toString()}`) return @@ -1816,7 +1821,7 @@ export default function ChatWindow({ onCopyToInput={handleCopyToInput} onCopyToNewConversation={(index: number) => { void copyConversation(index, 'same_attack') }} onCopyToNewAttack={newAttackDisabledReason ? undefined : (index: number) => { void copyConversation(index, 'new_attack') }} - onUseInTechnique={newAttackDisabledReason ? undefined : (index: number) => { void copyConversation(index, 'new_attack', true) }} + 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} diff --git a/frontend/src/components/Scenarios/ScenarioDetail.test.tsx b/frontend/src/components/Scenarios/ScenarioDetail.test.tsx index 3178e1cf22..2d61bb1c55 100644 --- a/frontend/src/components/Scenarios/ScenarioDetail.test.tsx +++ b/frontend/src/components/Scenarios/ScenarioDetail.test.tsx @@ -412,6 +412,70 @@ describe('ScenarioDetail', () => { 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) @@ -434,6 +498,7 @@ describe('ScenarioDetail', () => { 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 () => { diff --git a/frontend/src/components/Scenarios/ScenarioDetail.tsx b/frontend/src/components/Scenarios/ScenarioDetail.tsx index 6651e6f02d..fc9d4e5dd0 100644 --- a/frontend/src/components/Scenarios/ScenarioDetail.tsx +++ b/frontend/src/components/Scenarios/ScenarioDetail.tsx @@ -680,10 +680,12 @@ function ScenarioLaunchForm({ 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) @@ -983,7 +985,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 } @@ -1014,6 +1016,7 @@ function ScenarioLaunchForm({ const handleFormSubmit = (event: FormEvent): void => { event.preventDefault() + if (conversationBusy || isSubmittingRef.current) return setApiError(null) if (!requestResult.ok) { setValidationError(requestResult.error) @@ -1247,12 +1250,12 @@ function ScenarioLaunchForm({ {parameter.name === 'prepended_conversation_id' && <>
@@ -1452,7 +1455,7 @@ function ScenarioLaunchForm({ className={styles.launchButton} appearance="primary" type="submit" - disabled={!runtime.ready || !defaultsReady || staleSelection || unavailableSelection || submitting || techniqueSelectionError !== null} + disabled={!runtime.ready || !defaultsReady || staleSelection || unavailableSelection || submitting || conversationBusy || techniqueSelectionError !== null} data-testid="launch-scenario-btn" > Launch scan @@ -1596,7 +1599,7 @@ function ScenarioLaunchForm({ + 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 prompt" aria-describedby={`${copyMenuId}-${index}-prompt`} + subText={{ id: `${copyMenuId}-${index}-prompt`, children: 'Copy only this message to the prompt box.' }}> + Copy to prompt onCopyToNewConversation?.(index)}>New conversation + aria-label="Branch conversation" aria-describedby={`${copyMenuId}-${index}-conversation`} + subText={{ id: `${copyMenuId}-${index}-conversation`, + children: `Copy history through 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 through this turn into a new attack.' }} + onClick={() => onCopyToNewAttack?.(index)}>Branch attack {onUseInTechnique && onUseInTechnique(index)}>Technique.execute} + aria-label="Use in scan" aria-describedby={`${copyMenuId}-${index}-scan`} + subText={{ id: `${copyMenuId}-${index}-scan`, children: 'Open Technique.execute with history through this turn.' }} + onClick={() => onUseInTechnique(index)}>Use in scan} )} From 3d72a857b4a2c0e40f6a94a640dd96ce3c9076b2 Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Thu, 15 Oct 2026 13:57:00 -0700 Subject: [PATCH 4/4] FIX: Clarify conversation copy and branch descriptions Rename Copy to prompt to Copy to next message and explain inclusive history, attack groups, and scan preparation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9d6ee85f-fe71-4f31-93fd-d1955453f763 --- frontend/README.md | 8 +++++--- frontend/e2e/touch-targets.spec.ts | 8 ++++---- .../src/components/Chat/ChatWindow.test.tsx | 6 +++--- .../src/components/Chat/MessageList.test.tsx | 20 +++++++++---------- frontend/src/components/Chat/MessageList.tsx | 10 +++++----- 5 files changed, 27 insertions(+), 25 deletions(-) diff --git a/frontend/README.md b/frontend/README.md index 5ed7e24fa5..6ab29a507c 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -22,9 +22,10 @@ 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 prompt** copies only that message +visible descriptions for each action. **Copy to next message** copies only that message to the prompt box. **Branch conversation** and **Branch attack** copy history -through that turn into the same attack or a new attack, open the +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. @@ -33,7 +34,8 @@ 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** saves a target-free copy through the selected turn +**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. diff --git a/frontend/e2e/touch-targets.spec.ts b/frontend/e2e/touch-targets.spec.ts index d82a2aed78..e8934250ff 100644 --- a/frontend/e2e/touch-targets.spec.ts +++ b/frontend/e2e/touch-targets.spec.ts @@ -726,13 +726,13 @@ test.describe("Mobile touch targets", () => { await expectNoDocumentOverflow(page); await page.getByTestId("copy-to-input-btn-1").click(); - for (const name of ["Copy to prompt", "Branch conversation", "Branch attack", "Use in scan"]) { + 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 through this turn into the same attack.", { exact: true })).toBeVisible(); - await expect(page.getByText("Copy history through this turn into a new attack.", { exact: true })).toBeVisible(); - await expect(page.getByText("Open Technique.execute with history through this turn.", { 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"); diff --git a/frontend/src/components/Chat/ChatWindow.test.tsx b/frontend/src/components/Chat/ChatWindow.test.tsx index f26992f7f7..8277801a2c 100644 --- a/frontend/src/components/Chat/ChatWindow.test.tsx +++ b/frontend/src/components/Chat/ChatWindow.test.tsx @@ -5491,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: "Copy to prompt" })); + await userEvent.click(screen.getByRole("menuitem", { name: "Copy to next message" })); // The text should appear in the input area await waitFor(() => { @@ -6087,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: "Copy to prompt" })); + await user.click(screen.getByRole("menuitem", { name: "Copy to next message" })); await waitFor(() => { const textarea = screen.getByRole("textbox") as HTMLTextAreaElement; @@ -6160,7 +6160,7 @@ describe("ChatWindow Integration", () => { }); await userEvent.click(screen.getByTestId("copy-to-input-btn-1")); - await userEvent.click(screen.getByRole("menuitem", { name: "Copy to prompt" })); + await userEvent.click(screen.getByRole("menuitem", { name: "Copy to next message" })); await waitFor(() => { const textarea = screen.getByRole("textbox") as HTMLTextAreaElement; diff --git a/frontend/src/components/Chat/MessageList.test.tsx b/frontend/src/components/Chat/MessageList.test.tsx index e51eb23028..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(["Copy to prompt", "Branch conversation", "Branch 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(); @@ -59,7 +59,7 @@ describe("MessageList", () => { 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 === "Copy to prompt" ? onCopyToInput + 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); @@ -73,9 +73,9 @@ describe("MessageList", () => { 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 through this turn into the same attack. This attack is read-only."); + 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 prompt" })).not.toHaveAttribute("aria-disabled", "true"); + 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"); }); @@ -88,10 +88,10 @@ describe("MessageList", () => { screen.getByRole("button", { name: "Copy or Branch" }).focus(); await user.keyboard("{Enter}"); const descriptions = [ - ["Copy to prompt", "Copy only this message to the prompt box."], - ["Branch conversation", "Copy history through this turn into the same attack."], - ["Branch attack", "Copy history through this turn into a new attack."], - ["Use in scan", "Open Technique.execute with history through this turn."], + ["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 }); @@ -1123,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: "Copy to prompt" })); + await user.click(screen.getByRole("menuitem", { name: "Copy to next message" })); expect(onCopyToInput).toHaveBeenCalledWith(0); }); @@ -1755,7 +1755,7 @@ describe("MessageList", () => { ); await user.click(screen.getByTestId("copy-to-input-btn-0")); - await user.click(screen.getByRole("menuitem", { name: "Copy to prompt" })); + 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 29819ac5d2..ea364ca777 100644 --- a/frontend/src/components/Chat/MessageList.tsx +++ b/frontend/src/components/Chat/MessageList.tsx @@ -717,22 +717,22 @@ export default function MessageList({ onCopyToInput(index)} - aria-label="Copy to prompt" aria-describedby={`${copyMenuId}-${index}-prompt`} + 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 prompt + Copy to next message onCopyToNewConversation?.(index)}>Branch conversation onCopyToNewAttack?.(index)}>Branch attack {onUseInTechnique && onUseInTechnique(index)}>Use in scan}