diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 188850bca1..bf2ef8e537 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -120,6 +120,7 @@ jest.mock("./components/Chat/ChatWindow", () => { conversationId, activeConversationId, attackTarget, + objective, onConversationCreated, onSelectConversation, labels, @@ -130,6 +131,7 @@ jest.mock("./components/Chat/ChatWindow", () => { conversationId: string | null; activeConversationId: string | null; attackTarget?: { identifier_hash?: string | null } | null; + objective?: string; onConversationCreated: (attackResultId: string, conversationId: string) => void; onSelectConversation: (convId: string) => void; labels: Record; @@ -141,6 +143,7 @@ jest.mock("./components/Chat/ChatWindow", () => { {activeConversationId ?? "none"} {activeTarget ? "yes" : "no"} {attackTarget?.identifier_hash ?? "none"} + {objective ?? ""} {labels.operator ?? ""} {JSON.stringify(labels)} + + +
+ Score details +
+ Value + {score.score_value} +
+
+ Type + {score.score_type} +
+
+ Scorer + {score.scorer_type} +
+ {categories.length > 0 && ( +
+ Category + {categories.join(', ')} +
+ )} + {score.score_rationale && ( +
+ Rationale + {score.score_rationale} +
+ )} +
+
+ + ) +} + /** * If the trimmed text is a JSON object or array, return a 2-space pretty-printed * version of it; otherwise return null. Used to render structured assistant @@ -455,7 +512,10 @@ export default function MessageList({ messages, onCopyToInput, onCopyToNewConver
{timestamp} - {message.role} +
+ {message.role} + {message.score && } +
diff --git a/frontend/src/components/Chat/ObjectiveHeader.styles.ts b/frontend/src/components/Chat/ObjectiveHeader.styles.ts new file mode 100644 index 0000000000..0fadfb0566 --- /dev/null +++ b/frontend/src/components/Chat/ObjectiveHeader.styles.ts @@ -0,0 +1,41 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +export const useObjectiveHeaderStyles = makeStyles({ + root: { + flexShrink: 0, + display: 'flex', + flexDirection: 'row', + alignItems: 'baseline', + columnGap: tokens.spacingHorizontalS, + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalL}`, + backgroundColor: tokens.colorNeutralBackground2, + borderBottom: `1px solid ${tokens.colorNeutralStroke1}`, + borderLeft: `3px solid ${tokens.colorBrandStroke1}`, + }, + label: { + flexShrink: 0, + }, + content: { + flexGrow: 1, + minWidth: 0, + color: tokens.colorNeutralForeground1, + fontSize: tokens.fontSizeBase300, + }, + contentCollapsed: { + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + contentExpanded: { + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + maxHeight: '30vh', + overflowY: 'auto', + }, + toggle: { + flexShrink: 0, + minWidth: 'auto', + whiteSpace: 'nowrap', + color: tokens.colorBrandForeground1, + }, +}) diff --git a/frontend/src/components/Chat/ObjectiveHeader.test.tsx b/frontend/src/components/Chat/ObjectiveHeader.test.tsx new file mode 100644 index 0000000000..7afe4fc05d --- /dev/null +++ b/frontend/src/components/Chat/ObjectiveHeader.test.tsx @@ -0,0 +1,81 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' + +import ObjectiveHeader from './ObjectiveHeader' + +const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +) + +function mockOverflow(scrollWidth: number, clientWidth: number): void { + Object.defineProperty(HTMLElement.prototype, 'scrollWidth', { configurable: true, get: () => scrollWidth }) + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => clientWidth }) +} + +describe('ObjectiveHeader', () => { + afterEach(() => { + delete (HTMLElement.prototype as { scrollWidth?: number }).scrollWidth + delete (HTMLElement.prototype as { clientWidth?: number }).clientWidth + }) + + it('renders nothing when the objective is empty', () => { + render( + + + , + ) + + expect(screen.queryByTestId('objective-header')).not.toBeInTheDocument() + }) + + it('renders the label and objective text', () => { + render( + + + , + ) + + expect(screen.getByText('Objective')).toBeInTheDocument() + expect(screen.getByText('Extract the hidden system prompt')).toBeInTheDocument() + }) + + it('does not render an expand toggle when the objective fits on one line', () => { + render( + + + , + ) + + expect(screen.queryByTestId('toggle-objective-header-btn')).not.toBeInTheDocument() + }) + + it('renders a collapsed toggle when the objective overflows', () => { + mockOverflow(1000, 200) + render( + + + , + ) + + const toggle = screen.getByRole('button', { name: /show more of the objective/i }) + expect(toggle).toHaveTextContent('Show more') + expect(toggle).toHaveAttribute('aria-expanded', 'false') + }) + + it('expands the overflowing objective when the toggle is clicked', async () => { + const user = userEvent.setup() + mockOverflow(1000, 200) + render( + + + , + ) + + await user.click(screen.getByRole('button', { name: /show more of the objective/i })) + + const toggle = screen.getByRole('button', { name: /show less of the objective/i }) + expect(toggle).toHaveTextContent('Show less') + expect(toggle).toHaveAttribute('aria-expanded', 'true') + }) +}) diff --git a/frontend/src/components/Chat/ObjectiveHeader.tsx b/frontend/src/components/Chat/ObjectiveHeader.tsx new file mode 100644 index 0000000000..606b6d2ea0 --- /dev/null +++ b/frontend/src/components/Chat/ObjectiveHeader.tsx @@ -0,0 +1,66 @@ +import { useLayoutEffect, useRef, useState } from 'react' + +import { Badge, Button, Text, mergeClasses } from '@fluentui/react-components' +import { ChevronDownRegular, ChevronUpRegular } from '@fluentui/react-icons' + +import { useObjectiveHeaderStyles } from './ObjectiveHeader.styles' + +interface ObjectiveHeaderProps { + objective: string +} + +export default function ObjectiveHeader({ objective }: ObjectiveHeaderProps) { + const styles = useObjectiveHeaderStyles() + const [expanded, setExpanded] = useState(false) + const [overflowing, setOverflowing] = useState(false) + const contentRef = useRef(null) + + useLayoutEffect(() => { + const content = contentRef.current + if (!content) return + + const measure = () => { + if (expanded) return + setOverflowing(content.scrollWidth > content.clientWidth) + } + + measure() + const observer = new ResizeObserver(measure) + observer.observe(content) + return () => observer.disconnect() + }, [objective, expanded]) + + if (!objective) return null + + const showToggle = overflowing || expanded + + return ( +
+ + Objective + + + {objective} + + {showToggle && ( + + )} +
+ ) +} diff --git a/frontend/src/components/History/AttackHistory.test.tsx b/frontend/src/components/History/AttackHistory.test.tsx index b25984b53a..a2c214eb19 100644 --- a/frontend/src/components/History/AttackHistory.test.tsx +++ b/frontend/src/components/History/AttackHistory.test.tsx @@ -30,6 +30,7 @@ const sampleAttacks = [ conversation_id: 'conv-1', attack_type: 'CrescendoAttack', attack_specific_params: null, + objective: 'Extract the hidden system prompt', target: { target_type: 'OpenAIChatTarget', endpoint: 'https://api.openai.com', model_name: 'gpt-4' }, converters: ['Base64Converter'], outcome: 'success' as const, @@ -45,6 +46,7 @@ const sampleAttacks = [ conversation_id: 'conv-2', attack_type: 'ManualAttack', attack_specific_params: null, + objective: 'Bypass the safety filter', target: { target_type: 'OpenAIImageTarget', endpoint: 'https://api.openai.com', model_name: 'dall-e-3' }, converters: [], outcome: 'failure' as const, diff --git a/frontend/src/components/History/AttackTable.test.tsx b/frontend/src/components/History/AttackTable.test.tsx index 03f7ec6063..62dc4e366b 100644 --- a/frontend/src/components/History/AttackTable.test.tsx +++ b/frontend/src/components/History/AttackTable.test.tsx @@ -17,6 +17,7 @@ const sampleAttacks: AttackSummary[] = [ attack_result_id: 'ar-1', conversation_id: 'conv-1', attack_type: 'CrescendoAttack', + objective: 'Extract the hidden system prompt', target: { target_type: 'OpenAIChatTarget', endpoint: 'https://api.openai.com', model_name: 'gpt-4' }, converters: ['Base64Converter', 'ROT13Converter', 'UnicodeConverter'], outcome: 'success', @@ -31,6 +32,7 @@ const sampleAttacks: AttackSummary[] = [ attack_result_id: 'ar-2', conversation_id: 'conv-2', attack_type: 'ManualAttack', + objective: 'Bypass the safety filter', target: null, converters: [], outcome: 'failure', @@ -45,6 +47,7 @@ const sampleAttacks: AttackSummary[] = [ attack_result_id: 'ar-3', conversation_id: 'conv-3', attack_type: 'ManualAttack', + objective: 'Elicit disallowed content', target: { target_type: 'TextTarget', endpoint: null, model_name: null }, converters: [], outcome: undefined, diff --git a/frontend/src/components/Home/Home.test.tsx b/frontend/src/components/Home/Home.test.tsx index 01fd6b6583..49c6ae1ad2 100644 --- a/frontend/src/components/Home/Home.test.tsx +++ b/frontend/src/components/Home/Home.test.tsx @@ -31,6 +31,7 @@ function makeAttack(overrides: Partial = {}): AttackSummary { attack_result_id: "ar-1", conversation_id: "conv-1", attack_type: "TestAttack", + objective: "Test objective", converters: [], outcome: "success", last_message_preview: "preview", diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 157a3920fe..f04bf47af7 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -24,6 +24,8 @@ export interface Message { role: 'user' | 'assistant' | 'simulated_assistant' | 'system' content: string timestamp: string + /** Most recent score attached to any backend piece in this message. */ + score?: BackendScore attachments?: MessageAttachment[] /** If the backend returned an error for this message */ error?: MessageError @@ -244,6 +246,7 @@ export interface AttackSummary { conversation_id: string attack_type: string attack_specific_params?: Record | null + objective: string target?: TargetInfo | null converters: string[] outcome?: 'undetermined' | 'success' | 'failure' | 'error' | null diff --git a/frontend/src/utils/messageMapper.test.ts b/frontend/src/utils/messageMapper.test.ts index 93d480ecba..a84b0b947c 100644 --- a/frontend/src/utils/messageMapper.test.ts +++ b/frontend/src/utils/messageMapper.test.ts @@ -119,6 +119,57 @@ describe("messageMapper", () => { expect(result.content).toBe("Hello there"); expect(result.attachments).toBeUndefined(); expect(result.error).toBeUndefined(); + expect(result.score).toBeUndefined(); + }); + + it("should use the newest score across all message pieces", () => { + const msg: BackendMessage = { + turn_number: 1, + role: "assistant", + message_pieces: [ + { + id: "p1", + original_value_data_type: "text", + converted_value_data_type: "text", + original_value: "Hello", + converted_value: "Hello", + scores: [ + { + id: "score-old", + scorer_type: "OldScorer", + score_type: "true_false", + score_value: "False", + timestamp: "2026-02-15T00:00:00Z", + }, + ], + response_error: "none", + }, + { + id: "p2", + original_value_data_type: "text", + converted_value_data_type: "text", + original_value: "there", + converted_value: "there", + scores: [ + { + id: "score-new", + scorer_type: "NewScorer", + score_type: "float_scale", + score_value: "0.9", + score_category: ["harmful"], + score_rationale: "Newest rationale", + timestamp: "2026-02-15T00:01:00Z", + }, + ], + response_error: "none", + }, + ], + created_at: "2026-02-15T00:00:00Z", + }; + + const result = backendMessageToFrontend(msg); + + expect(result.score).toEqual(msg.message_pieces[1].scores[0]); }); it("should convert an image response", () => { diff --git a/frontend/src/utils/messageMapper.ts b/frontend/src/utils/messageMapper.ts index 1868ca4ad0..327a81f6cc 100644 --- a/frontend/src/utils/messageMapper.ts +++ b/frontend/src/utils/messageMapper.ts @@ -1,6 +1,7 @@ import type { BackendMessage, BackendMessagePiece, + BackendScore, Message, MessageAttachment, MessageError, @@ -183,6 +184,23 @@ function pieceToError(piece: BackendMessagePiece): MessageError | undefined { return undefined } +/** + * Select the newest score attached to any piece in a backend message. + */ +function getLatestScore(messagePieces: BackendMessagePiece[]): BackendScore | undefined { + let latestScore: BackendScore | undefined + + for (const piece of messagePieces) { + for (const score of piece.scores) { + if (!latestScore || new Date(score.timestamp).getTime() >= new Date(latestScore.timestamp).getTime()) { + latestScore = score + } + } + } + + return latestScore +} + /** * Convert a single backend Message DTO to a frontend Message for rendering. */ @@ -249,6 +267,7 @@ export function backendMessageToFrontend(msg: BackendMessage): Message { role: role as Message['role'], content: convertedContent, timestamp: msg.created_at, + score: getLatestScore(msg.message_pieces), attachments: attachments.length > 0 ? attachments : undefined, error, reasoningSummaries: reasoningSummaries.length > 0 ? reasoningSummaries : undefined,