From 181c4cf93ba699d7143a701c18f8c468900f4536 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Fri, 19 Jun 2026 15:26:30 -0400 Subject: [PATCH 1/2] Add system prompt input to the CoPyRIT chat composer Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/components/Chat/ChatInputArea.tsx | 15 +- .../src/components/Chat/ChatWindow.test.tsx | 170 ++++++++++++++++++ frontend/src/components/Chat/ChatWindow.tsx | 10 +- .../Chat/SystemPromptSetup.styles.ts | 45 +++++ .../Chat/SystemPromptSetup.test.tsx | 104 +++++++++++ .../src/components/Chat/SystemPromptSetup.tsx | 64 +++++++ frontend/src/types/index.ts | 6 + frontend/src/utils/messageMapper.test.ts | 28 +++ frontend/src/utils/messageMapper.ts | 10 ++ 9 files changed, 450 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/Chat/SystemPromptSetup.styles.ts create mode 100644 frontend/src/components/Chat/SystemPromptSetup.test.tsx create mode 100644 frontend/src/components/Chat/SystemPromptSetup.tsx diff --git a/frontend/src/components/Chat/ChatInputArea.tsx b/frontend/src/components/Chat/ChatInputArea.tsx index afb2b8cfd7..1e3f999bd0 100644 --- a/frontend/src/components/Chat/ChatInputArea.tsx +++ b/frontend/src/components/Chat/ChatInputArea.tsx @@ -10,6 +10,7 @@ import { import { SendRegular, AttachRegular, DismissRegular, InfoRegular, AddRegular, CopyRegular, WarningRegular, SettingsRegular, ArrowShuffleRegular, OpenRegular } from '@fluentui/react-icons' import { MessageAttachment, TargetInstance } from '../../types' import { useChatInputAreaStyles } from './ChatInputArea.styles' +import SystemPromptSetup from './SystemPromptSetup' import { PIECE_TYPE_TO_DATA_TYPE } from './converterTypes' // --------------------------------------------------------------------------- @@ -329,9 +330,13 @@ interface ChatInputAreaProps { /** Chip describing a text→file conversion (e.g. PDFConverter output). */ convertedFileChip?: ConvertedFileChip | null onClearConvertedFileChip?: () => void + /** Whether to show the system-prompt setup (only for a brand-new conversation). */ + showSystemPrompt?: boolean + systemPrompt?: string + onSystemPromptChange?: (value: string) => void } -const ChatInputArea = forwardRef(function ChatInputArea({ onSend, disabled = false, activeTarget, singleTurnLimitReached = false, onNewConversation, operatorLocked = false, crossTargetLocked = false, onUseAsTemplate, attackOperator, noTargetSelected = false, onConfigureTarget, onToggleConverterPanel, isConverterPanelOpen = false, onInputChange, onAttachmentsChange, convertedValue, originalValue: _originalValue, onClearConversion, onConvertedValueChange, converterOutputDataTypes = [], mediaConversions = [], onClearMediaConversion, convertedFileChip, onClearConvertedFileChip }, ref) { +const ChatInputArea = forwardRef(function ChatInputArea({ onSend, disabled = false, activeTarget, singleTurnLimitReached = false, onNewConversation, operatorLocked = false, crossTargetLocked = false, onUseAsTemplate, attackOperator, noTargetSelected = false, onConfigureTarget, onToggleConverterPanel, isConverterPanelOpen = false, onInputChange, onAttachmentsChange, convertedValue, originalValue: _originalValue, onClearConversion, onConvertedValueChange, converterOutputDataTypes = [], mediaConversions = [], onClearMediaConversion, convertedFileChip, onClearConvertedFileChip, showSystemPrompt = false, systemPrompt = '', onSystemPromptChange }, ref) { const styles = useChatInputAreaStyles() const [input, setInput] = useState('') const [attachments, setAttachments] = useState([]) @@ -537,6 +542,14 @@ const ChatInputArea = forwardRef(functi ) : ( <>
+ {showSystemPrompt && onSystemPromptChange && ( + + )} ({ jest.mock("../../utils/messageMapper", () => ({ buildMessagePieces: jest.fn(), backendMessagesToFrontend: jest.fn(), + buildSystemPrompt: jest.fn(), })); const mockedAttacksApi = attacksApi as jest.Mocked; @@ -468,6 +469,175 @@ describe("ChatWindow Integration", () => { }); }); + // ----------------------------------------------------------------------- + // System prompt (prepended_conversation) wiring + // ----------------------------------------------------------------------- + + describe("system prompt", () => { + const supportedTarget: TargetInstance = { + ...mockTarget, + capabilities: buildCapabilities({ supports_system_prompt: true }), + }; + + const sentinelPrepended = [ + { role: "system", pieces: [{ data_type: "text", original_value: "SYS" }] }, + ]; + + function primeSendMocks() { + mockedMapper.buildMessagePieces.mockResolvedValue([ + { data_type: "text", original_value: "Hello" }, + ]); + mockedAttacksApi.createAttack.mockResolvedValue({ + attack_result_id: "ar-sys", + conversation_id: "conv-sys", + created_at: "2026-01-01T00:00:00Z", + }); + mockedAttacksApi.addMessage.mockResolvedValue( + makeTextResponse("Hi") as never + ); + mockedMapper.backendMessagesToFrontend.mockReturnValue([ + { role: "assistant", content: "Hi", timestamp: "2026-01-01T00:00:01Z" }, + ]); + } + + it("renders the system prompt toggle for a new conversation", () => { + render( + + + + ); + + expect( + screen.getByRole("button", { name: /system prompt/i }) + ).toBeInTheDocument(); + }); + + it("hides the system prompt toggle once an attack exists", async () => { + mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] }); + mockedMapper.backendMessagesToFrontend.mockReturnValue([]); + + render( + + + + ); + + await waitFor(() => { + expect(screen.queryByTestId("loading-state")).not.toBeInTheDocument(); + }); + expect( + screen.queryByRole("button", { name: /system prompt/i }) + ).not.toBeInTheDocument(); + }); + + it("forwards the built prepended_conversation when the target supports it", async () => { + const user = userEvent.setup(); + primeSendMocks(); + mockedMapper.buildSystemPrompt.mockReturnValue(sentinelPrepended as never); + + render( + + + + ); + + await user.type(screen.getByRole("textbox"), "Hello"); + await user.click(screen.getByRole("button", { name: /send/i })); + + await waitFor(() => { + expect(mockedMapper.buildSystemPrompt).toHaveBeenCalled(); + expect(mockedAttacksApi.createAttack).toHaveBeenCalledWith( + expect.objectContaining({ prepended_conversation: sentinelPrepended }) + ); + }); + }); + + it("never builds a system prompt when the target does not support it", async () => { + const user = userEvent.setup(); + primeSendMocks(); + mockedMapper.buildSystemPrompt.mockReturnValue(sentinelPrepended as never); + + render( + + + + ); + + await user.type(screen.getByRole("textbox"), "Hello"); + await user.click(screen.getByRole("button", { name: /send/i })); + + await waitFor(() => { + expect(mockedAttacksApi.createAttack).toHaveBeenCalled(); + }); + expect(mockedMapper.buildSystemPrompt).not.toHaveBeenCalled(); + const createArgs = mockedAttacksApi.createAttack.mock.calls[0][0]; + expect(createArgs.prepended_conversation).toBeUndefined(); + }); + + it("disables the toggle and drops the prompt for an explicitly unsupported target", async () => { + const user = userEvent.setup(); + primeSendMocks(); + mockedMapper.buildSystemPrompt.mockReturnValue(sentinelPrepended as never); + + const unsupportedTarget: TargetInstance = { + ...mockTarget, + capabilities: buildCapabilities({ supports_system_prompt: false }), + }; + + render( + + + + ); + + expect( + screen.getByRole("button", { name: /system prompt/i }) + ).toBeDisabled(); + + await user.type(screen.getByPlaceholderText("Type prompt here"), "Hello"); + await user.click(screen.getByRole("button", { name: /send/i })); + + await waitFor(() => { + expect(mockedAttacksApi.createAttack).toHaveBeenCalled(); + }); + expect(mockedMapper.buildSystemPrompt).not.toHaveBeenCalled(); + const createArgs = mockedAttacksApi.createAttack.mock.calls[0][0]; + expect(createArgs.prepended_conversation).toBeUndefined(); + }); + + it("passes the operator's typed system prompt through to buildSystemPrompt", async () => { + const user = userEvent.setup(); + primeSendMocks(); + mockedMapper.buildSystemPrompt.mockReturnValue(sentinelPrepended as never); + + render( + + + + ); + + await user.click(screen.getByRole("button", { name: /system prompt/i })); + await user.type( + screen.getByRole("textbox", { name: /system prompt/i }), + "You are helpful" + ); + await user.type(screen.getByPlaceholderText("Type prompt here"), "Hello"); + await user.click(screen.getByRole("button", { name: /send/i })); + + await waitFor(() => { + expect(mockedMapper.buildSystemPrompt).toHaveBeenCalledWith( + "You are helpful" + ); + }); + }); + }); + // ----------------------------------------------------------------------- // Subsequent messages → reuse conversation ID // ----------------------------------------------------------------------- diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 0865ed331b..0b41d63af2 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -16,7 +16,7 @@ import LabelsBar from '../Labels/LabelsBar' import type { ChatInputAreaHandle } from './ChatInputArea' import { attacksApi } from '../../services/api' import { toApiError } from '../../services/errors' -import { buildMessagePieces, backendMessagesToFrontend } from '../../utils/messageMapper' +import { buildMessagePieces, backendMessagesToFrontend, buildSystemPrompt } from '../../utils/messageMapper' import type { Message, MessageAttachment, TargetInstance, TargetInfo } from '../../types' import type { ViewName } from '../Sidebar/Navigation' import { useChatWindowStyles } from './ChatWindow.styles' @@ -70,6 +70,7 @@ export default function ChatWindow({ const [isPanelOpen, setIsPanelOpen] = useState(false) const [isConverterPanelOpen, setIsConverterPanelOpen] = useState(false) const [chatInputText, setChatInputText] = useState('') + const [systemPrompt, setSystemPrompt] = useState('') const [attachmentTypes, setAttachmentTypes] = useState([]) const [attachmentData, setAttachmentData] = useState>({}) const [pieceConversions, setPieceConversions] = useState>({}) @@ -142,6 +143,7 @@ export default function ChatWindow({ if (!attackResultId) { setMessages([]) setLoadedConversationId(null) + setSystemPrompt('') } } @@ -289,6 +291,9 @@ export default function ChatWindow({ const createResponse = await attacksApi.createAttack({ target_registry_name: activeTarget.target_registry_name, labels: labels, + prepended_conversation: activeTarget.capabilities?.supports_system_prompt + ? buildSystemPrompt(systemPrompt) + : undefined, }) currentAttackResultId = createResponse.attack_result_id currentConversationId = createResponse.conversation_id @@ -617,6 +622,9 @@ export default function ChatWindow({ = ({ children }) => ( + {children} +) + +/** Stateful harness mirroring how ChatWindow owns the systemPrompt value. */ +function Harness({ + initial = '', + disabled = false, + disabledReason, +}: { + initial?: string + disabled?: boolean + disabledReason?: string +}) { + const [value, setValue] = useState(initial) + return ( + + + + ) +} + +describe('SystemPromptSetup', () => { + it('renders collapsed by default with no textarea visible', () => { + render() + expect(screen.getByRole('button', { name: /system prompt/i })).toBeInTheDocument() + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + }) + + it('expands to reveal the textarea when the toggle is clicked', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: /system prompt/i })) + + expect(screen.getByRole('textbox', { name: /system prompt/i })).toBeInTheDocument() + }) + + it('reflects typed text and updates the character counter', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: /system prompt/i })) + await user.type(screen.getByRole('textbox', { name: /system prompt/i }), 'hello') + + expect(screen.getByRole('textbox', { name: /system prompt/i })).toHaveValue('hello') + expect(screen.getByText(/5 characters/i)).toBeInTheDocument() + }) + + it('calls onChange with the new value as the user types', async () => { + const user = userEvent.setup() + const onChange = jest.fn() + render( + + + + ) + + await user.click(screen.getByRole('button', { name: /system prompt/i })) + await user.type(screen.getByRole('textbox', { name: /system prompt/i }), 'H') + + expect(onChange).toHaveBeenCalledWith('H') + }) + + it('flags the counter when the value exceeds the soft limit', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: /system prompt/i })) + + expect(screen.getByText(/2,001 characters/i)).toBeInTheDocument() + }) + + describe('when the target does not support system prompts', () => { + const disabledReason = 'This target does not support system prompts.' + + it('disables the toggle and shows the reason', () => { + render() + + expect(screen.getByRole('button', { name: /system prompt/i })).toBeDisabled() + expect(screen.getByText(disabledReason)).toBeInTheDocument() + }) + + it('does not expand when the disabled toggle is clicked', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: /system prompt/i })) + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + }) + }) +}) diff --git a/frontend/src/components/Chat/SystemPromptSetup.tsx b/frontend/src/components/Chat/SystemPromptSetup.tsx new file mode 100644 index 0000000000..1f381527e6 --- /dev/null +++ b/frontend/src/components/Chat/SystemPromptSetup.tsx @@ -0,0 +1,64 @@ +import { useState } from 'react' +import { Button, Caption1, Textarea, mergeClasses } from '@fluentui/react-components' +import { ChevronDownRegular, ChevronRightRegular, WarningRegular } from '@fluentui/react-icons' +import { useSystemPromptSetupStyles } from './SystemPromptSetup.styles' + +const SYSTEM_PROMPT_SOFT_LIMIT = 2000 + +interface SystemPromptSetupProps { + value: string + onChange: (value: string) => void + disabled?: boolean + disabledReason?: string +} + +export default function SystemPromptSetup({ value, onChange, disabled = false, disabledReason }: SystemPromptSetupProps) { + const styles = useSystemPromptSetupStyles() + const [expanded, setExpanded] = useState(false) + const overLimit = value.length > SYSTEM_PROMPT_SOFT_LIMIT + + return ( +
+
+ + {disabled && disabledReason && ( + + + {disabledReason} + + )} +
+ {expanded && !disabled && ( +
+