diff --git a/frontend/e2e/touch-targets.spec.ts b/frontend/e2e/touch-targets.spec.ts index ed4f096fc3..93c413cec5 100644 --- a/frontend/e2e/touch-targets.spec.ts +++ b/frontend/e2e/touch-targets.spec.ts @@ -74,7 +74,17 @@ const MESSAGES = [ converted_value_data_type: "text", original_value: "Deterministic assistant response for touch-target tests.", converted_value: "Deterministic assistant response for touch-target tests.", - scores: [], + scores: Array.from({ length: 9 }, (_unused: unknown, scoreIndex: number) => ({ + id: `mobile-assistant-score-${scoreIndex}`, + message_piece_id: "mobile-assistant-piece", + scorer_type: `SelfAskRefusalScorer${scoreIndex}`, + score_type: "true_false", + score_value: scoreIndex === 0 ? "true" : "false", + is_objective_score: scoreIndex === 0, + score_category: ["refusal"], + score_rationale: `Deterministic rationale ${scoreIndex} for touch-target tests.`, + timestamp: `2026-07-22T13:10:0${scoreIndex}.500Z`, + })), response_error: "none", }, ], @@ -217,6 +227,8 @@ async function installTouchTargetMocks(page: Page): Promise { attack_type: "PromptSendingAttack", conversation_id: "mobile-conversation-001", related_conversation_ids: [], + objective: + "Deterministic long objective that does not fit on a single line of the mobile objective header and must be truncated with a disclosure toggle.", labels: { operator: "mobile_operator", operation: "touch_targets", @@ -406,8 +418,39 @@ test.describe("Mobile touch targets", () => { test("keeps Chat message, input, and conversation controls at least 44px", async ({ page, }) => { - await page.goto("/"); - await startChatWithMessages(page); + // Deep-link directly into the attack (rather than creating one through + // the chat flow) so the objective is actually hydrated from the backend: + // the create-attack flow seeds the objective as "" client-side and never + // loads the long mocked objective, so the disclosure toggle would never + // render and this test would silently skip checking it. + await page.goto("/attacks/mobile-attack-001"); + await expect( + page.getByText("Deterministic assistant response for touch-target tests.") + ).toBeVisible(); + await expect( + page.getByTestId("toggle-objective-header-btn") + ).toBeVisible(); + + await page.getByRole("button", { name: "Configuration", exact: true }).click(); + await expect(page.getByText("gpt-4o-mobile")).toBeVisible(); + await page.getByRole("button", { name: "Set Active" }).first().click(); + await page.goBack(); + await expect( + page.getByTestId("toggle-objective-header-btn") + ).toBeVisible(); + + const scoreChips = page.locator('[data-testid^="message-score-1-"]'); + await expect(scoreChips).toHaveCount(1); + await expectMinimumTouchTargets(scoreChips); + const scoreMenu = page.getByTestId("message-score-menu-1"); + await expect(scoreMenu).toBeVisible(); + await expectMinimumTouchTarget(scoreMenu); + await scoreMenu.click(); + const scoreMenuItems = page.locator( + '[data-testid^="message-score-menu-item-1-"]' + ); + await expect(scoreMenuItems).toHaveCount(9); + await expectMinimumTouchTargets(scoreMenuItems); await expectMinimumTouchTargets( page.locator( @@ -418,6 +461,7 @@ test.describe("Mobile touch targets", () => { '[data-testid="new-attack-btn"]', '[aria-label="Attach files"]', '[data-testid="toggle-converter-panel-btn"]', + '[data-testid="toggle-objective-header-btn"]', '[data-testid="chat-input"]', '[data-testid="send-message-btn"]', '[data-testid="copy-to-input-btn-1"]', diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index bb8e88b004..0fad90a584 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -128,6 +128,7 @@ jest.mock("./components/Chat/ChatWindow", () => { conversationId, activeConversationId, attackTarget, + objective, targetResolutionStatus, onRetryTargetResolution, onConversationCreated, @@ -140,6 +141,7 @@ jest.mock("./components/Chat/ChatWindow", () => { conversationId: string | null; activeConversationId: string | null; attackTarget?: { identifier_hash?: string | null } | null; + objective?: string; targetResolutionStatus?: string; onRetryTargetResolution?: () => void; onConversationCreated: (attackResultId: string, conversationId: string) => void; @@ -156,6 +158,7 @@ jest.mock("./components/Chat/ChatWindow", () => { {(activeTarget as { target_registry_name?: string } | null)?.target_registry_name ?? "none"} {attackTarget?.identifier_hash ?? "none"} + {objective ?? ""} {targetResolutionStatus ?? "none"} {labels.operator ?? ""} {JSON.stringify(labels)} @@ -752,6 +755,7 @@ describe("App", () => { mockGetAttack.mockResolvedValue({ attack_result_id: "ar-1", conversation_id: "conv-main", + objective: "Extract the hidden system prompt", labels: {}, related_conversation_ids: [], }); @@ -763,6 +767,24 @@ describe("App", () => { expect(screen.getByTestId("conversation-id")).toHaveTextContent("conv-main") ); expect(screen.getByTestId("active-conversation-id")).toHaveTextContent("conv-main"); + expect(screen.getByTestId("objective")).toHaveTextContent("Extract the hidden system prompt"); + }); + + it("hides the normalized empty objective of an unnamed manual attack on reload", async () => { + mockGetAttack.mockResolvedValue({ + attack_result_id: "ar-1", + conversation_id: "conv-main", + objective: "", + labels: {}, + related_conversation_ids: [], + }); + renderApp("/attacks/ar-1"); + + await waitFor(() => expect(mockGetAttack).toHaveBeenCalledWith("ar-1")); + await waitFor(() => + expect(screen.getByTestId("conversation-id")).toHaveTextContent("conv-main") + ); + expect(screen.getByTestId("objective")).toHaveTextContent(""); }); it("uses the conversation from a deep link when it belongs to the attack", async () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8e05c3af03..42890c1b73 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -61,6 +61,7 @@ interface LoadedAttack { labels: Record | null target: TargetInfo | null relatedConversationIds: string[] + objective: string status: AttackLoadStatus } @@ -199,6 +200,7 @@ function App() { labels: null, target: null, relatedConversationIds: [], + objective: '', }) attacksApi .getAttack(routeAttackId) @@ -212,6 +214,7 @@ function App() { labels: attack.labels ?? {}, target: attack.target ?? null, relatedConversationIds: attack.related_conversation_ids ?? [], + objective: attack.objective ?? '', status: 'success', }) }) @@ -230,6 +233,7 @@ function App() { labels: null, target: null, relatedConversationIds: [], + objective: '', }) }) // Drop a stale response once the route has moved on to another attack. @@ -318,6 +322,7 @@ function App() { labels: null, target, relatedConversationIds: [], + objective: '', status: 'success', }) // Replace when promoting an empty /chat to its attack url (first message); @@ -359,6 +364,7 @@ function App() { onRetryTargetResolution={retryTargetResolution} isLoadingAttack={isLoadingAttack} relatedConversationCount={readyAttack ? readyAttack.relatedConversationIds.length : 0} + objective={readyAttack ? readyAttack.objective : ''} /> ) diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 1bcd7cf34d..61d4b535f6 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -23,6 +23,7 @@ import ChatInputArea from './ChatInputArea' import ConversationPanel from './ConversationPanel' import ConverterPanel from './ConverterPanel' import TargetBadge from './TargetBadge' +import ObjectiveHeader from './ObjectiveHeader' import type { PieceConversion } from './converterTypes' import { PIECE_TYPE_TO_DATA_TYPE, basenameFromValue, buildMediaUrl, dataTypeToAttachmentKind, isPathDataType } from './converterTypes' import LabelsBar from '../Labels/LabelsBar' @@ -94,6 +95,8 @@ interface ChatWindowProps { isLoadingAttack?: boolean /** Number of related (non-main) conversations in the loaded attack. */ relatedConversationCount?: number + /** The loaded attack's objective (empty for new/manual attacks). */ + objective?: string } export default function ChatWindow({ @@ -113,6 +116,7 @@ export default function ChatWindow({ onRetryTargetResolution, isLoadingAttack, relatedConversationCount, + objective = '', }: ChatWindowProps) { const styles = useChatWindowStyles() const restoreFocusTargetAttributes = useRestoreFocusTarget() @@ -809,6 +813,7 @@ export default function ChatWindow({ + {systemMessage && } = ({ children, @@ -109,6 +109,331 @@ describe("MessageList", () => { expect(screen.getByText("Assistant message test")).toBeInTheDocument(); }); + it("should show the message score and its details when present", async () => { + const user = userEvent.setup(); + const scoredMessages: Message[] = [ + { + role: "assistant", + content: "Scored response", + timestamp: new Date().toISOString(), + scores: [ + { + id: "score-1", + message_piece_id: "piece-1", + scorer_type: "SelfAskScaleScorer", + score_type: "float_scale", + score_value: "0.9", + is_objective_score: true, + pieceIndex: 0, + pieceType: "text", + sourceLabel: "Piece 1 · text", + score_category: ["harmful"], + score_rationale: "The response contains harmful content.", + timestamp: "2026-02-15T00:01:00Z", + }, + ], + }, + ]; + + render( + + + + ); + + const scoreButton = screen.getByRole("button", { + name: /score 0.9 from selfaskscalescorer, objective score/i, + }); + expect(scoreButton).toBeInTheDocument(); + expect(screen.getByText("0.9")).toBeInTheDocument(); + + await user.click(scoreButton); + + expect(screen.getByText("float_scale")).toBeInTheDocument(); + expect(screen.getByText("SelfAskScaleScorer")).toBeInTheDocument(); + expect(screen.getByText("Piece 1 · text")).toBeInTheDocument(); + expect(screen.getByText("harmful")).toBeInTheDocument(); + expect(screen.getByText("The response contains harmful content.")).toBeInTheDocument(); + }); + + it("should show only the newest score chip plus a dropdown to view others", async () => { + const user = userEvent.setup(); + const scoredMessages: Message[] = [ + { + role: "assistant", + content: "Scored response", + timestamp: new Date().toISOString(), + scores: [ + { + id: "score-new", + message_piece_id: "piece-2", + scorer_type: "NewScorer", + score_type: "float_scale", + score_value: "0.9", + pieceIndex: 1, + pieceType: "text", + sourceLabel: "Piece 2 · text", + timestamp: "2026-02-15T00:01:00Z", + }, + { + id: "score-old", + message_piece_id: "piece-1", + scorer_type: "OldScorer", + score_type: "true_false", + score_value: "False", + is_objective_score: true, + pieceIndex: 0, + pieceType: "text", + sourceLabel: "Piece 1 · text", + timestamp: "2026-02-15T00:00:00Z", + }, + ], + }, + ]; + + render( + + + + ); + + // The canonical objective score is shown by default, even when a newer + // auxiliary score exists. + expect( + screen.getByRole("button", { name: /score false from oldscorer, objective score/i }) + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /score 0.9 from newscorer/i }) + ).not.toBeInTheDocument(); + + // The "+" button opens a dropdown listing every score. + const menuButton = screen.getByRole("button", { name: /view score details/i }); + expect(menuButton).toBeInTheDocument(); + + await user.click(menuButton); + + expect( + screen.getByRole("menuitem", { + name: /false \*\(attack objective\) — oldscorer · piece 1 · text/i, + }) + ).toBeInTheDocument(); + const newScoreOption = await screen.findByRole("menuitem", { + name: /0.9 — newscorer · piece 2 · text/i, + }); + expect(newScoreOption).toBeInTheDocument(); + + // Selecting a score from the dropdown replaces the displayed chip. + await user.click(newScoreOption); + + expect( + screen.getByRole("button", { name: /score 0.9 from newscorer/i }) + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /score false from oldscorer/i }) + ).not.toBeInTheDocument(); + }); + + it("should not show a dropdown '+' button when the message has only one score", () => { + const scoredMessages: Message[] = [ + { + role: "assistant", + content: "Scored response", + timestamp: new Date().toISOString(), + scores: [ + { + id: "score-1", + message_piece_id: "piece-1", + scorer_type: "SoleScorer", + score_type: "true_false", + score_value: "True", + pieceIndex: 0, + pieceType: "text", + sourceLabel: "Piece 1 · text", + timestamp: "2026-02-15T00:00:00Z", + }, + ], + }, + ]; + + render( + + + + ); + + expect( + screen.getByRole("button", { name: /score true from solescorer/i }) + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /view score details/i }) + ).not.toBeInTheDocument(); + }); + + it("should distinguish text and attachment score controls by source label", () => { + const sharedScores: Array> = [ + { + id: "score-objective", + scorer_type: "SharedScorer", + score_type: "true_false", + score_value: "True", + is_objective_score: true, + timestamp: "2026-02-15T00:00:00Z", + }, + { + id: "score-auxiliary", + scorer_type: "AuxiliaryScorer", + score_type: "float_scale", + score_value: "0.5", + timestamp: "2026-02-15T00:01:00Z", + }, + ]; + const scoredMessages: Message[] = [ + { + role: "assistant", + content: "Scored text", + timestamp: new Date().toISOString(), + scores: sharedScores.map((score) => ({ + ...score, + id: `${score.id}-text`, + message_piece_id: "piece-1", + pieceIndex: 0, + pieceType: "text", + sourceLabel: "Piece 1 · text", + })), + attachments: [ + { + type: "image", + name: "test.png", + url: "data:image/png;base64,iVBORw0KGgo=", + mimeType: "image/png", + scores: sharedScores.map((score) => ({ + ...score, + id: `${score.id}-image`, + message_piece_id: "piece-2", + pieceIndex: 1, + pieceType: "image_path", + sourceLabel: "Piece 2 · image_path · test.png", + })), + }, + ], + }, + ]; + + render( + + + + ); + + expect( + screen.getByRole("button", { + name: "Score True from SharedScorer, objective score, Piece 1 · text", + }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "Score True from SharedScorer, objective score, Piece 2 · image_path · test.png", + }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "View score details for Piece 1 · text" }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "View score details for Piece 2 · image_path · test.png", + }) + ).toBeInTheDocument(); + }); + + it("should not show a score chip when the message has no score", () => { + render( + + + + ); + + expect( + screen.queryByRole("button", { name: /^score /i }) + ).not.toBeInTheDocument(); + }); + + it("should show a text score when the converted response is empty", () => { + const scoredMessages: Message[] = [ + { + role: "assistant", + content: "", + timestamp: new Date().toISOString(), + scores: [ + { + id: "score-empty-response", + message_piece_id: "piece-empty-response", + scorer_type: "EmptyResponseScorer", + score_type: "true_false", + score_value: "True", + pieceIndex: 0, + pieceType: "text", + sourceLabel: "Piece 1 · text", + timestamp: "2026-02-15T00:00:00Z", + }, + ], + }, + ]; + + render( + + + + ); + + expect( + screen.getByRole("button", { + name: "Score True from EmptyResponseScorer, Piece 1 · text", + }) + ).toBeInTheDocument(); + }); + + it("should show a score chip next to the attachment it was computed on", () => { + const messagesWithScoredAttachment: Message[] = [ + { + role: "assistant", + content: "Here is a caption and a picture", + timestamp: new Date().toISOString(), + attachments: [ + { + type: "image", + name: "test.png", + url: "data:image/png;base64,iVBORw0KGgo=", + mimeType: "image/png", + size: 1024, + scores: [ + { + id: "score-image", + message_piece_id: "piece-image", + scorer_type: "ImageScorer", + score_type: "true_false", + score_value: "True", + pieceIndex: 0, + pieceType: "image_path", + sourceLabel: "Piece 1 · image_path · test.png", + timestamp: "2026-02-15T00:00:00Z", + }, + ], + }, + ], + }, + ]; + + render( + + + + ); + + expect( + screen.getByRole("button", { name: /score true from imagescorer/i }) + ).toBeInTheDocument(); + }); + describe("structured JSON assistant responses", () => { // Targets like PromptShieldTarget return structured JSON instead of // natural-language text. Render these as pretty-printed JSON in a
diff --git a/frontend/src/components/Chat/MessageList.tsx b/frontend/src/components/Chat/MessageList.tsx
index b072b866f3..52f7308d87 100644
--- a/frontend/src/components/Chat/MessageList.tsx
+++ b/frontend/src/components/Chat/MessageList.tsx
@@ -6,12 +6,21 @@ import {
   MessageBar,
   MessageBarBody,
   Button,
+  Badge,
+  Popover,
+  PopoverSurface,
+  PopoverTrigger,
+  Menu,
+  MenuTrigger,
+  MenuPopover,
+  MenuList,
+  MenuItem,
   Tooltip,
   Spinner,
   mergeClasses,
 } from '@fluentui/react-components'
-import { ArrowDownloadRegular, ArrowReplyRegular, ArrowForwardRegular, ChatAddRegular, BranchForkRegular, OpenRegular } from '@fluentui/react-icons'
-import { Message, MessageAttachment } from '../../types'
+import { ArrowDownloadRegular, ArrowReplyRegular, ArrowForwardRegular, ChatAddRegular, BranchForkRegular, OpenRegular, AddRegular } from '@fluentui/react-icons'
+import type { DisplayScore, Message, MessageAttachment } from '../../types'
 import MarkdownContent from './MarkdownContent'
 import { useMessageListStyles } from './MessageList.styles'
 
@@ -84,6 +93,115 @@ function MediaWithFallback({ type, src, className }: { type: 'video' | 'audio';
   return