Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { ConnectionHealthProvider, useConnectionHealth } from './hooks/useConnec
import { DEFAULT_GLOBAL_LABELS } from './components/Labels/labelDefaults'
import { filtersFromSearchParams, filtersToSearchParams } from './components/History/historyFilters'
import type { ViewName } from './components/Sidebar/Navigation'
import type { TargetInstance, TargetInfo } from './types'
import type { TargetInstance, TargetInfo, AttackOutcome, ScoreView } from './types'
import { targetEndpoint, targetModelName, targetType } from './utils/targetIdentity'
import { attacksApi, versionApi } from './services/api'
import { toApiError } from './services/errors'
Expand Down Expand Up @@ -51,6 +51,9 @@ interface LoadedAttack {
labels: Record<string, string> | null
target: TargetInfo | null
relatedConversationIds: string[]
objective: string
outcome: AttackOutcome | null
lastScore: ScoreView | null
status: AttackLoadStatus
}

Expand Down Expand Up @@ -201,6 +204,9 @@ function App() {
labels: null,
target: null,
relatedConversationIds: [],
objective: '',
outcome: null,
lastScore: null,
})
attacksApi
.getAttack(routeAttackId)
Expand All @@ -212,6 +218,9 @@ function App() {
labels: attack.labels ?? {},
target: attack.target ?? null,
relatedConversationIds: attack.related_conversation_ids ?? [],
objective: attack.objective ?? '',
outcome: attack.outcome ?? null,
lastScore: attack.last_score ?? null,
status: 'success',
})
})
Expand All @@ -228,6 +237,9 @@ function App() {
labels: null,
target: null,
relatedConversationIds: [],
objective: '',
outcome: null,
lastScore: null,
})
})
// Drop a stale response once the route has moved on to another attack.
Expand Down Expand Up @@ -294,6 +306,9 @@ function App() {
labels: null,
target,
relatedConversationIds: [],
objective: '',
outcome: null,
lastScore: null,
status: 'success',
})
// Replace when promoting an empty /chat to its attack url (first message);
Expand Down Expand Up @@ -333,6 +348,9 @@ function App() {
attackTarget={readyAttack ? readyAttack.target : null}
isLoadingAttack={isLoadingAttack}
relatedConversationCount={readyAttack ? readyAttack.relatedConversationIds.length : 0}
objective={readyAttack ? readyAttack.objective : ''}
outcome={readyAttack ? readyAttack.outcome : null}
lastScore={readyAttack ? readyAttack.lastScore : null}
/>
)

Expand Down
51 changes: 51 additions & 0 deletions frontend/src/components/Chat/AttackVerdictChip.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { makeStyles, tokens } from '@fluentui/react-components'

export const useAttackVerdictChipStyles = makeStyles({
chip: {
display: 'inline-flex',
alignItems: 'center',
columnGap: tokens.spacingHorizontalXS,
},
// A related (non-main) conversation is active: the attack-level verdict does
// not belong to what's on screen, so it's dimmed to read as secondary.
dimmed: {
opacity: 0.55,
},
chipLabel: {
textTransform: 'capitalize',
},
surface: {
display: 'flex',
flexDirection: 'column',
rowGap: tokens.spacingVerticalXS,
minWidth: '240px',
maxWidth: '360px',
},
row: {
display: 'flex',
columnGap: tokens.spacingHorizontalS,
},
rowLabel: {
minWidth: '72px',
color: tokens.colorNeutralForeground2,
},
attackLevelNote: {
color: tokens.colorNeutralForeground3,
fontStyle: 'italic',
},
rationaleBlock: {
display: 'flex',
flexDirection: 'column',
rowGap: tokens.spacingVerticalXXS,
marginTop: tokens.spacingVerticalXS,
paddingTop: tokens.spacingVerticalXS,
borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
},
rationaleText: {
color: tokens.colorNeutralForeground2,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: '30vh',
overflowY: 'auto',
},
})
141 changes: 141 additions & 0 deletions frontend/src/components/Chat/AttackVerdictChip.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { FluentProvider, webLightTheme } from '@fluentui/react-components'
import AttackVerdictChip from './AttackVerdictChip'
import type { ScoreView } from '../../types'

const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<FluentProvider theme={webLightTheme}>{children}</FluentProvider>
)

const sampleScore: ScoreView = {
id: 'score-1',
scorer_type: 'SelfAskRefusalScorer',
score_type: 'true_false',
score_value: 'true',
score_category: ['refusal'],
score_rationale: 'The model refused the request.',
timestamp: '2026-01-15T11:00:00Z',
}

describe('AttackVerdictChip', () => {
it('renders nothing when there is neither an outcome nor a score', () => {
render(
<TestWrapper>
<AttackVerdictChip outcome={null} score={null} />
</TestWrapper>
)

expect(screen.queryByTestId('attack-verdict-chip')).not.toBeInTheDocument()
expect(screen.queryByTestId('attack-score-chip')).not.toBeInTheDocument()
})

it('renders an outcome-only badge when there is an outcome but no score', () => {
render(
<TestWrapper>
<AttackVerdictChip outcome="failure" score={null} />
</TestWrapper>
)

// No score means no interactive score chip/popover, just the outcome badge.
expect(screen.queryByTestId('attack-score-chip')).not.toBeInTheDocument()
const chip = screen.getByTestId('attack-verdict-chip')
expect(within(chip).getByText('failure')).toBeInTheDocument()
})

it('shows a single chip labeled with the outcome, tinted by the score', () => {
render(
<TestWrapper>
<AttackVerdictChip outcome="failure" score={sampleScore} />
</TestWrapper>
)

const chip = screen.getByRole('button', { name: /verdict failure, score true/i })
// The chip shows the outcome; the raw score value lives in the popover
expect(within(chip).getByText('failure')).toBeInTheDocument()
expect(within(chip).queryByText('true')).not.toBeInTheDocument()
})

it('opens a popover with full verdict details when clicked', async () => {
const user = userEvent.setup()
render(
<TestWrapper>
<AttackVerdictChip outcome="failure" score={sampleScore} />
</TestWrapper>
)

await user.click(screen.getByRole('button', { name: /verdict failure, score true/i }))

const details = await screen.findByTestId('attack-score-details')
expect(within(details).getByText('true_false')).toBeInTheDocument()
expect(within(details).getByText('SelfAskRefusalScorer')).toBeInTheDocument()
expect(within(details).getByText('refusal')).toBeInTheDocument()
expect(within(details).getByText('The model refused the request.')).toBeInTheDocument()
})

it('shows the underlying scale score when a thresholded verdict provides one', async () => {
const user = userEvent.setup()
const thresholdScore: ScoreView = {
id: 'score-2',
scorer_type: 'FloatScaleThresholdScorer',
score_type: 'true_false',
score_value: 'false',
score_category: ['humor'],
score_rationale: 'Normalized scale score: 0.5 < threshold 0.6',
scale_score: 0.5,
timestamp: '2026-01-15T11:00:00Z',
}
render(
<TestWrapper>
<AttackVerdictChip outcome="failure" score={thresholdScore} />
</TestWrapper>
)

await user.click(screen.getByRole('button', { name: /verdict failure, score false/i }))

const details = await screen.findByTestId('attack-score-details')
expect(within(details).getByText('Scale score')).toBeInTheDocument()
expect(within(details).getByText('0.50')).toBeInTheDocument()
})

it('omits the scale score row for a plain true/false score', async () => {
const user = userEvent.setup()
render(
<TestWrapper>
<AttackVerdictChip outcome="failure" score={sampleScore} />
</TestWrapper>
)

await user.click(screen.getByRole('button', { name: /verdict failure, score true/i }))

const details = await screen.findByTestId('attack-score-details')
expect(within(details).queryByText('Scale score')).not.toBeInTheDocument()
})

it('marks the verdict as attack-level when a related conversation is active', async () => {
const user = userEvent.setup()
render(
<TestWrapper>
<AttackVerdictChip outcome="failure" score={sampleScore} appliesToActiveConversation={false} />
</TestWrapper>
)

// The accessible name signals the verdict is attack-level, not this conversation's.
const chip = screen.getByRole('button', { name: /attack-level verdict failure/i })
await user.click(chip)

const details = await screen.findByTestId('attack-score-details')
expect(within(details).getByTestId('attack-level-note')).toBeInTheDocument()
})

it('does not mark the verdict as attack-level on the main conversation', () => {
render(
<TestWrapper>
<AttackVerdictChip outcome="failure" score={sampleScore} appliesToActiveConversation={true} />
</TestWrapper>
)

expect(screen.queryByRole('button', { name: /attack-level verdict/i })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /verdict failure, score true/i })).toBeInTheDocument()
})
})
Loading