From e4ed297c73f483e13643ae0d3a7313f2a9c30cc8 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Tue, 5 May 2026 12:12:27 -0700 Subject: [PATCH 1/7] FEAT: Render target capabilities as structured columns in config table - Add TargetCapabilitiesInfo model (6 boolean capability fields) to backend - Populate capabilities in target mapper from target_obj.capabilities - Add capability columns with checkmark/dismiss/dash indicators to TargetTable - Update Chat components to prefer capabilities object with legacy fallback - Add backend + frontend tests for capabilities population and rendering Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/components/Chat/ChatInputArea.tsx | 2 +- frontend/src/components/Chat/ChatWindow.tsx | 4 +- .../components/Config/TargetTable.test.tsx | 38 ++++++++++++++- .../src/components/Config/TargetTable.tsx | 47 ++++++++++++++++++- frontend/src/types/index.ts | 10 ++++ pyrit/backend/mappers/target_mappers.py | 15 +++++- pyrit/backend/models/targets.py | 22 +++++++++ tests/unit/backend/test_mappers.py | 45 ++++++++++++++++++ 8 files changed, 176 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/Chat/ChatInputArea.tsx b/frontend/src/components/Chat/ChatInputArea.tsx index 615244d0e9..ec3b638ea7 100644 --- a/frontend/src/components/Chat/ChatInputArea.tsx +++ b/frontend/src/components/Chat/ChatInputArea.tsx @@ -439,7 +439,7 @@ const ChatInputArea = forwardRef(functi />
- {activeTarget && activeTarget.supports_multi_turn === false && ( + {activeTarget && (activeTarget.capabilities?.supports_multi_turn ?? activeTarget.supports_multi_turn) === false && ( m.role === 'user') + const singleTurnLimitReached = (activeTarget?.capabilities?.supports_multi_turn ?? activeTarget?.supports_multi_turn) === false && messages.some(m => m.role === 'user') // Operator locking: if the loaded attack's operator differs from the current // user's operator label, the conversation should be read-only. @@ -561,7 +561,7 @@ export default function ChatWindow({ onBranchConversation={attackResultId && activeConversationId ? handleBranchConversation : undefined} onBranchAttack={activeTarget && activeConversationId ? handleBranchAttack : undefined} isLoading={isLoadingAttack || isLoadingMessages || awaitingConversationLoad} - isSingleTurn={activeTarget?.supports_multi_turn === false} + isSingleTurn={(activeTarget?.capabilities?.supports_multi_turn ?? activeTarget?.supports_multi_turn) === false} isOperatorLocked={isOperatorLocked} isCrossTarget={isCrossTargetLocked} noTargetSelected={!activeTarget} diff --git a/frontend/src/components/Config/TargetTable.test.tsx b/frontend/src/components/Config/TargetTable.test.tsx index 52618ef2ff..d5b0e94c63 100644 --- a/frontend/src/components/Config/TargetTable.test.tsx +++ b/frontend/src/components/Config/TargetTable.test.tsx @@ -17,12 +17,28 @@ const sampleTargets: TargetInstance[] = [ target_type: 'OpenAIChatTarget', endpoint: 'https://api.openai.com', model_name: 'gpt-4', + capabilities: { + supports_multi_turn: true, + supports_multi_message_pieces: true, + supports_json_schema: true, + supports_json_output: true, + supports_editable_history: true, + supports_system_prompt: true, + }, }, { target_registry_name: 'azure_image_dalle', target_type: 'AzureImageTarget', endpoint: 'https://azure.openai.com', model_name: 'dall-e-3', + capabilities: { + supports_multi_turn: false, + supports_multi_message_pieces: false, + supports_json_schema: false, + supports_json_output: false, + supports_editable_history: false, + supports_system_prompt: false, + }, }, { target_registry_name: 'text_target_basic', @@ -58,7 +74,7 @@ describe('TargetTable', () => { expect(screen.getAllByText('TextTarget').length).toBeGreaterThanOrEqual(1) }) - it('should display Type, Model, Endpoint and Parameters columns', () => { + it('should display Type, Model, Endpoint, capability columns and Parameters columns', () => { render( @@ -68,6 +84,12 @@ describe('TargetTable', () => { expect(screen.getByText('Type')).toBeInTheDocument() expect(screen.getByText('Model')).toBeInTheDocument() expect(screen.getByText('Endpoint')).toBeInTheDocument() + expect(screen.getByText('Multi-turn')).toBeInTheDocument() + expect(screen.getByText('Multi-piece')).toBeInTheDocument() + expect(screen.getByText('JSON Schema')).toBeInTheDocument() + expect(screen.getByText('JSON Output')).toBeInTheDocument() + expect(screen.getByText('Edit History')).toBeInTheDocument() + expect(screen.getByText('System Prompt')).toBeInTheDocument() expect(screen.getByText('Parameters')).toBeInTheDocument() }) @@ -151,10 +173,24 @@ describe('TargetTable', () => { ) + // Dashes for model, endpoint, and 6 capability columns (all unknown) const dashes = screen.getAllByText('—') expect(dashes.length).toBeGreaterThanOrEqual(2) }) + it('should show dash for capability columns when capabilities is absent', () => { + render( + + + + ) + + // TextTarget has no capabilities — all 6 should be dashes + const dashes = screen.getAllByText('—') + // model (—) + endpoint (—) + 6 capabilities (—) + params (—) = 9 + expect(dashes.length).toBeGreaterThanOrEqual(8) + }) + it('should display target_specific_params when present', () => { const targetWithParams: TargetInstance[] = [ { diff --git a/frontend/src/components/Config/TargetTable.tsx b/frontend/src/components/Config/TargetTable.tsx index 4b9011e33d..f7b9b9b575 100644 --- a/frontend/src/components/Config/TargetTable.tsx +++ b/frontend/src/components/Config/TargetTable.tsx @@ -12,7 +12,7 @@ import { Tooltip, Select, } from '@fluentui/react-components' -import { CheckmarkRegular } from '@fluentui/react-icons' +import { CheckmarkRegular, DismissRegular } from '@fluentui/react-icons' import type { TargetInstance } from '../../types' import { useTargetTableStyles } from './TargetTable.styles' @@ -39,6 +39,27 @@ function formatParams(params?: Record | null): string { return parts.join('\n') } +/** Capability column definitions with tooltip descriptions. */ +const CAPABILITY_COLUMNS = [ + { key: 'supports_multi_turn', label: 'Multi-turn', tooltip: 'Supports multi-turn conversation history' }, + { key: 'supports_multi_message_pieces', label: 'Multi-piece', tooltip: 'Supports multiple message pieces in a single request' }, + { key: 'supports_json_schema', label: 'JSON Schema', tooltip: 'Supports constraining output to a JSON schema' }, + { key: 'supports_json_output', label: 'JSON Output', tooltip: 'Supports JSON output format' }, + { key: 'supports_editable_history', label: 'Edit History', tooltip: 'Allows attack history to be modified' }, + { key: 'supports_system_prompt', label: 'System Prompt', tooltip: 'Supports system prompts' }, +] as const + +/** Render a capability indicator: ✓ (green) / ✗ (red) / — (unknown). */ +function CapabilityCell({ value }: { value: boolean | undefined }) { + if (value === undefined) { + return + } + if (value) { + return + } + return +} + /** Render the model cell with a tooltip when underlying model differs. */ function ModelCell({ target }: { target: TargetInstance }) { const displayName = target.model_name || '—' @@ -62,6 +83,21 @@ function ModelCell({ target }: { target: TargetInstance }) { return {displayName} } +/** Render capability cells for a target. */ +function CapabilityCells({ target }: { target: TargetInstance }) { + return ( + <> + {CAPABILITY_COLUMNS.map(({ key }) => ( + + + + ))} + + ) +} + export default function TargetTable({ targets, activeTarget, onSetActiveTarget }: TargetTableProps) { const styles = useTargetTableStyles() const [typeFilter, setTypeFilter] = useState('') @@ -99,6 +135,7 @@ export default function TargetTable({ targets, activeTarget, onSetActiveTarget } {activeTarget.endpoint || '—'} + {formatParams(activeTarget.target_specific_params) || '—'} @@ -132,6 +169,13 @@ export default function TargetTable({ targets, activeTarget, onSetActiveTarget } Type Model Endpoint + {CAPABILITY_COLUMNS.map(({ key, label, tooltip }) => ( + + + {label} + + + ))} Parameters @@ -167,6 +211,7 @@ export default function TargetTable({ targets, activeTarget, onSetActiveTarget } {target.endpoint || '—'} + {formatParams(target.target_specific_params) || '—'} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 07d5865123..edf3ad1e74 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -53,6 +53,15 @@ export interface PaginationInfo { // --- Targets --- +export interface TargetCapabilitiesInfo { + supports_multi_turn: boolean + supports_multi_message_pieces: boolean + supports_json_schema: boolean + supports_json_output: boolean + supports_editable_history: boolean + supports_system_prompt: boolean +} + export interface TargetInstance { target_registry_name: string target_type: string @@ -63,6 +72,7 @@ export interface TargetInstance { top_p?: number | null max_requests_per_minute?: number | null supports_multi_turn?: boolean + capabilities?: TargetCapabilitiesInfo | null target_specific_params?: Record | null } diff --git a/pyrit/backend/mappers/target_mappers.py b/pyrit/backend/mappers/target_mappers.py index 1d72822690..50b6e7be83 100644 --- a/pyrit/backend/mappers/target_mappers.py +++ b/pyrit/backend/mappers/target_mappers.py @@ -5,7 +5,7 @@ Target mappers – domain → DTO translation for target-related models. """ -from pyrit.backend.models.targets import TargetInstance +from pyrit.backend.models.targets import TargetCapabilitiesInfo, TargetInstance from pyrit.prompt_target import PromptTarget @@ -43,6 +43,16 @@ def target_object_to_instance(target_registry_name: str, target_obj: PromptTarge extra = {k: v for k, v in params.items() if k not in extracted_keys and v is not None} combined_specific = {**extra, **explicit_specific} or None + caps = target_obj.capabilities + capabilities = TargetCapabilitiesInfo( + supports_multi_turn=caps.supports_multi_turn, + supports_multi_message_pieces=caps.supports_multi_message_pieces, + supports_json_schema=caps.supports_json_schema, + supports_json_output=caps.supports_json_output, + supports_editable_history=caps.supports_editable_history, + supports_system_prompt=caps.supports_system_prompt, + ) + return TargetInstance( target_registry_name=target_registry_name, target_type=identifier.class_name, @@ -52,6 +62,7 @@ def target_object_to_instance(target_registry_name: str, target_obj: PromptTarge temperature=params.get("temperature"), top_p=params.get("top_p"), max_requests_per_minute=params.get("max_requests_per_minute"), - supports_multi_turn=target_obj.capabilities.supports_multi_turn, + supports_multi_turn=caps.supports_multi_turn, + capabilities=capabilities, target_specific_params=combined_specific, ) diff --git a/pyrit/backend/models/targets.py b/pyrit/backend/models/targets.py index fef7cbe41e..81c12550f0 100644 --- a/pyrit/backend/models/targets.py +++ b/pyrit/backend/models/targets.py @@ -18,6 +18,27 @@ from pyrit.backend.models.common import PaginationInfo +class TargetCapabilitiesInfo(BaseModel): + """Structured capability flags for a target instance.""" + + supports_multi_turn: bool = Field(..., description="Whether the target supports multi-turn conversation history") + supports_multi_message_pieces: bool = Field( + ..., description="Whether the target supports multiple message pieces in a single request" + ) + supports_json_schema: bool = Field( + ..., description="Whether the target supports constraining output to a JSON schema" + ) + supports_json_output: bool = Field( + ..., description="Whether the target supports JSON output format" + ) + supports_editable_history: bool = Field( + ..., description="Whether the target allows the attack history to be modified" + ) + supports_system_prompt: bool = Field( + ..., description="Whether the target supports system prompts" + ) + + class TargetInstance(BaseModel): """ A runtime target instance. @@ -37,6 +58,7 @@ class TargetInstance(BaseModel): top_p: Optional[float] = Field(None, description="Top-p parameter for generation") max_requests_per_minute: Optional[int] = Field(None, description="Maximum requests per minute") supports_multi_turn: bool = Field(True, description="Whether the target supports multi-turn conversation history") + capabilities: Optional[TargetCapabilitiesInfo] = Field(None, description="Structured capability flags") target_specific_params: Optional[dict[str, Any]] = Field(None, description="Additional target-specific parameters") diff --git a/tests/unit/backend/test_mappers.py b/tests/unit/backend/test_mappers.py index 9eda90ab5b..45c505ccb8 100644 --- a/tests/unit/backend/test_mappers.py +++ b/tests/unit/backend/test_mappers.py @@ -1257,6 +1257,51 @@ def test_chat_target_extra_params_preserved(self) -> None: assert result.target_specific_params["seed"] == 42 assert result.target_specific_params["max_completion_tokens"] == 2048 + def test_capabilities_populated_from_target_object(self) -> None: + """Test that all 6 capability fields are populated from target_obj.capabilities.""" + target_obj = MagicMock(spec=PromptTarget) + target_obj.capabilities = TargetCapabilities( + supports_multi_turn=True, + supports_multi_message_pieces=True, + supports_json_schema=False, + supports_json_output=True, + supports_editable_history=False, + supports_system_prompt=True, + ) + mock_identifier = ComponentIdentifier( + class_name="OpenAIChatTarget", + class_module="pyrit.prompt_target", + params={"endpoint": "https://api.openai.com", "model_name": "gpt-4"}, + ) + target_obj.get_identifier.return_value = mock_identifier + + result = target_object_to_instance("t-1", target_obj) + + assert result.capabilities is not None + assert result.capabilities.supports_multi_turn is True + assert result.capabilities.supports_multi_message_pieces is True + assert result.capabilities.supports_json_schema is False + assert result.capabilities.supports_json_output is True + assert result.capabilities.supports_editable_history is False + assert result.capabilities.supports_system_prompt is True + + def test_capabilities_matches_legacy_supports_multi_turn(self) -> None: + """Test that legacy supports_multi_turn field matches capabilities.supports_multi_turn.""" + target_obj = MagicMock(spec=PromptTarget) + target_obj.capabilities = TargetCapabilities(supports_multi_turn=False) + mock_identifier = ComponentIdentifier( + class_name="TextTarget", + class_module="pyrit.prompt_target", + ) + target_obj.get_identifier.return_value = mock_identifier + + result = target_object_to_instance("t-1", target_obj) + + assert result.supports_multi_turn is False + assert result.capabilities is not None + assert result.capabilities.supports_multi_turn is False + assert result.supports_multi_turn == result.capabilities.supports_multi_turn + # ============================================================================ # Converter Mapper Tests From 4e6aa88af3aabf7b82852b44f72442c01bf45193 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Tue, 5 May 2026 12:56:39 -0700 Subject: [PATCH 2/7] FIX: Filter target_configuration from params, improve capability icons - Add target_configuration to extracted_keys so the verbose capabilities blob does not leak into the Parameters column - Make checkmark/dismiss icons 16px bold for better visibility - Add regression test for target_configuration filtering Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/components/Config/TargetTable.tsx | 4 ++-- pyrit/backend/mappers/target_mappers.py | 2 ++ tests/unit/backend/test_mappers.py | 22 +++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Config/TargetTable.tsx b/frontend/src/components/Config/TargetTable.tsx index f7b9b9b575..61de5d7f43 100644 --- a/frontend/src/components/Config/TargetTable.tsx +++ b/frontend/src/components/Config/TargetTable.tsx @@ -55,9 +55,9 @@ function CapabilityCell({ value }: { value: boolean | undefined }) { return } if (value) { - return + return } - return + return } /** Render the model cell with a tooltip when underlying model differs. */ diff --git a/pyrit/backend/mappers/target_mappers.py b/pyrit/backend/mappers/target_mappers.py index 50b6e7be83..526e382a41 100644 --- a/pyrit/backend/mappers/target_mappers.py +++ b/pyrit/backend/mappers/target_mappers.py @@ -27,6 +27,7 @@ def target_object_to_instance(target_registry_name: str, target_obj: PromptTarge params = identifier.params # Keys that are extracted as top-level TargetInstance fields + # or are internal-only (target_configuration is the verbose capabilities blob). extracted_keys = { "endpoint", "model_name", @@ -36,6 +37,7 @@ def target_object_to_instance(target_registry_name: str, target_obj: PromptTarge "max_requests_per_minute", "supports_multi_turn", "target_specific_params", + "target_configuration", } # Collect remaining params as target_specific_params so the frontend can display them diff --git a/tests/unit/backend/test_mappers.py b/tests/unit/backend/test_mappers.py index 45c505ccb8..5eb26b2ea9 100644 --- a/tests/unit/backend/test_mappers.py +++ b/tests/unit/backend/test_mappers.py @@ -1302,6 +1302,28 @@ def test_capabilities_matches_legacy_supports_multi_turn(self) -> None: assert result.capabilities.supports_multi_turn is False assert result.supports_multi_turn == result.capabilities.supports_multi_turn + def test_target_configuration_excluded_from_target_specific_params(self) -> None: + """Test that the verbose target_configuration blob is filtered from target_specific_params.""" + target_obj = MagicMock(spec=PromptTarget) + target_obj.capabilities = TargetCapabilities(supports_multi_turn=True) + mock_identifier = ComponentIdentifier( + class_name="OpenAIChatTarget", + class_module="pyrit.prompt_target", + params={ + "endpoint": "https://api.openai.com", + "model_name": "gpt-4", + "target_configuration": {"capabilities": {"supports_multi_turn": True}}, + "reasoning_effort": "high", + }, + ) + target_obj.get_identifier.return_value = mock_identifier + + result = target_object_to_instance("t-1", target_obj) + + assert result.target_specific_params is not None + assert "target_configuration" not in result.target_specific_params + assert result.target_specific_params["reasoning_effort"] == "high" + # ============================================================================ # Converter Mapper Tests From d336751070e6df2966095747f2b70389810ef94b Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Tue, 5 May 2026 13:01:17 -0700 Subject: [PATCH 3/7] FEAT: Use filled circle icons, sticky table header - Switch to CheckmarkCircleFilled/DismissCircleFilled for better visibility - Add sticky header so column names stay visible when scrolling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- frontend/src/components/Config/TargetTable.styles.ts | 6 ++++++ frontend/src/components/Config/TargetTable.tsx | 8 ++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/Config/TargetTable.styles.ts b/frontend/src/components/Config/TargetTable.styles.ts index 6c02a7077c..b7ff07ca81 100644 --- a/frontend/src/components/Config/TargetTable.styles.ts +++ b/frontend/src/components/Config/TargetTable.styles.ts @@ -9,6 +9,12 @@ export const useTargetTableStyles = makeStyles({ tableLayout: 'fixed', width: '100%', }, + stickyHeader: { + position: 'sticky', + top: 0, + backgroundColor: tokens.colorNeutralBackground1, + zIndex: 1, + }, activeRow: { backgroundColor: tokens.colorBrandBackground2, }, diff --git a/frontend/src/components/Config/TargetTable.tsx b/frontend/src/components/Config/TargetTable.tsx index 61de5d7f43..a9b76a62ba 100644 --- a/frontend/src/components/Config/TargetTable.tsx +++ b/frontend/src/components/Config/TargetTable.tsx @@ -12,7 +12,7 @@ import { Tooltip, Select, } from '@fluentui/react-components' -import { CheckmarkRegular, DismissRegular } from '@fluentui/react-icons' +import { CheckmarkRegular, CheckmarkCircleFilled, DismissCircleFilled } from '@fluentui/react-icons' import type { TargetInstance } from '../../types' import { useTargetTableStyles } from './TargetTable.styles' @@ -55,9 +55,9 @@ function CapabilityCell({ value }: { value: boolean | undefined }) { return } if (value) { - return + return } - return + return } /** Render the model cell with a tooltip when underlying model differs. */ @@ -163,7 +163,7 @@ export default function TargetTable({ targets, activeTarget, onSetActiveTarget } )} - + Type From e7bdfce4ff1b121e3cb228fe72659416e3a3acb7 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Tue, 5 May 2026 13:19:40 -0700 Subject: [PATCH 4/7] MAINT: Apply ruff formatting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/backend/models/targets.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pyrit/backend/models/targets.py b/pyrit/backend/models/targets.py index 81c12550f0..94a45ac234 100644 --- a/pyrit/backend/models/targets.py +++ b/pyrit/backend/models/targets.py @@ -28,15 +28,11 @@ class TargetCapabilitiesInfo(BaseModel): supports_json_schema: bool = Field( ..., description="Whether the target supports constraining output to a JSON schema" ) - supports_json_output: bool = Field( - ..., description="Whether the target supports JSON output format" - ) + supports_json_output: bool = Field(..., description="Whether the target supports JSON output format") supports_editable_history: bool = Field( ..., description="Whether the target allows the attack history to be modified" ) - supports_system_prompt: bool = Field( - ..., description="Whether the target supports system prompts" - ) + supports_system_prompt: bool = Field(..., description="Whether the target supports system prompts") class TargetInstance(BaseModel): From 4d617fa635047a64b3b0011b4d5b997f6121f097 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Wed, 6 May 2026 15:49:35 -0700 Subject: [PATCH 5/7] FIX: Apply PR review feedback for TargetTable - Move capability icon colors and cell width into makeStyles using design tokens (colorPaletteGreenForeground1, colorPaletteRedForeground1); drop hardcoded fontSize so icons inherit. - Drop `Text size={200}` wrapper on table headers so they match other tables; switch tooltip trigger to a span with cursor:help class. - Add tooltips for Type, Model, Endpoint, and Parameters columns. - Tighten dash-count assertions to `toHaveLength(9)` and update inline comment to include the params dash. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../components/Config/TargetTable.styles.ts | 13 ++++++ .../components/Config/TargetTable.test.tsx | 6 +-- .../src/components/Config/TargetTable.tsx | 43 +++++++++++++++---- 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/Config/TargetTable.styles.ts b/frontend/src/components/Config/TargetTable.styles.ts index b7ff07ca81..35f7aca482 100644 --- a/frontend/src/components/Config/TargetTable.styles.ts +++ b/frontend/src/components/Config/TargetTable.styles.ts @@ -26,4 +26,17 @@ export const useTargetTableStyles = makeStyles({ whiteSpace: 'pre-line', wordBreak: 'break-word', }, + capabilityCell: { + width: '80px', + textAlign: 'center', + }, + capabilityIconSupported: { + color: tokens.colorPaletteGreenForeground1, + }, + capabilityIconUnsupported: { + color: tokens.colorPaletteRedForeground1, + }, + helpHeader: { + cursor: 'help', + }, }) diff --git a/frontend/src/components/Config/TargetTable.test.tsx b/frontend/src/components/Config/TargetTable.test.tsx index d5b0e94c63..2db78187a1 100644 --- a/frontend/src/components/Config/TargetTable.test.tsx +++ b/frontend/src/components/Config/TargetTable.test.tsx @@ -173,9 +173,9 @@ describe('TargetTable', () => { ) - // Dashes for model, endpoint, and 6 capability columns (all unknown) + // Dashes for model, endpoint, 6 capability columns (all unknown), and params const dashes = screen.getAllByText('—') - expect(dashes.length).toBeGreaterThanOrEqual(2) + expect(dashes).toHaveLength(9) }) it('should show dash for capability columns when capabilities is absent', () => { @@ -188,7 +188,7 @@ describe('TargetTable', () => { // TextTarget has no capabilities — all 6 should be dashes const dashes = screen.getAllByText('—') // model (—) + endpoint (—) + 6 capabilities (—) + params (—) = 9 - expect(dashes.length).toBeGreaterThanOrEqual(8) + expect(dashes).toHaveLength(9) }) it('should display target_specific_params when present', () => { diff --git a/frontend/src/components/Config/TargetTable.tsx b/frontend/src/components/Config/TargetTable.tsx index a9b76a62ba..373e84edd7 100644 --- a/frontend/src/components/Config/TargetTable.tsx +++ b/frontend/src/components/Config/TargetTable.tsx @@ -49,15 +49,23 @@ const CAPABILITY_COLUMNS = [ { key: 'supports_system_prompt', label: 'System Prompt', tooltip: 'Supports system prompts' }, ] as const +const COLUMN_TOOLTIPS = { + type: 'Target class implementation', + model: 'Configured model name. A dotted underline indicates the deployment alias differs from the underlying model — hover the value to see it.', + endpoint: 'API endpoint URL the target sends requests to', + parameters: 'Target-specific configuration parameters (e.g., reasoning_effort, max_output_tokens)', +} as const + /** Render a capability indicator: ✓ (green) / ✗ (red) / — (unknown). */ function CapabilityCell({ value }: { value: boolean | undefined }) { + const styles = useTargetTableStyles() if (value === undefined) { return } if (value) { - return + return } - return + return } /** Render the model cell with a tooltip when underlying model differs. */ @@ -85,10 +93,11 @@ function ModelCell({ target }: { target: TargetInstance }) { /** Render capability cells for a target. */ function CapabilityCells({ target }: { target: TargetInstance }) { + const styles = useTargetTableStyles() return ( <> {CAPABILITY_COLUMNS.map(({ key }) => ( - + @@ -166,17 +175,33 @@ export default function TargetTable({ targets, activeTarget, onSetActiveTarget } - Type - Model - Endpoint + + + Type + + + + + Model + + + + + Endpoint + + {CAPABILITY_COLUMNS.map(({ key, label, tooltip }) => ( - + - {label} + {label} ))} - Parameters + + + Parameters + + From 790b603e7557b60a8085835d6b771c37f8ba2a13 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Thu, 7 May 2026 10:32:07 -0700 Subject: [PATCH 6/7] FEAT: Add Inputs/Outputs modality columns and polish TargetTable - Backend: expose flattened `supported_input_modalities` and `supported_output_modalities` on `TargetCapabilitiesInfo` so the frontend can render modality icons without re-deriving them. - Frontend: new Inputs and Outputs columns rendering Fluent UI icons with hover tooltips. Canonical icon order: text, image, audio, video, reasoning, function call, function call output, tool call, binary, url. `function_call_output` is rendered as a composite `f(x)` plus return-arrow badge in the top-right corner. - Adjust column widths so Endpoint has 1.5x more space (~450px) by trimming Type/Model/Capabilities/Parameters; Inputs gets a wider 160px column to accommodate up to 7 modality icons. - Replace `Badge` for the Type column with plain `Text` to avoid the pill outline visual and prevent overlap with Model. - Tighten `TargetConfig` test regex (`/reasoning_effort:/`) so the new Parameters tooltip text doesn't trigger a false positive. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../components/Config/TargetConfig.test.tsx | 2 +- .../components/Config/TargetTable.styles.ts | 35 ++++- .../components/Config/TargetTable.test.tsx | 73 +++++++++- .../src/components/Config/TargetTable.tsx | 132 ++++++++++++++++-- frontend/src/types/index.ts | 2 + pyrit/backend/mappers/target_mappers.py | 4 + pyrit/backend/models/targets.py | 8 ++ tests/unit/backend/test_mappers.py | 41 ++++++ 8 files changed, 277 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/Config/TargetConfig.test.tsx b/frontend/src/components/Config/TargetConfig.test.tsx index 690e91672b..c798373087 100644 --- a/frontend/src/components/Config/TargetConfig.test.tsx +++ b/frontend/src/components/Config/TargetConfig.test.tsx @@ -340,7 +340,7 @@ describe("TargetConfig", () => { }); // No reasoning or other special params should be displayed - expect(screen.queryByText(/reasoning_effort/)).not.toBeInTheDocument(); + expect(screen.queryByText(/reasoning_effort:/)).not.toBeInTheDocument(); }); it("should open dialog when Create First Target button is clicked in empty state", async () => { diff --git a/frontend/src/components/Config/TargetTable.styles.ts b/frontend/src/components/Config/TargetTable.styles.ts index 35f7aca482..906db7578c 100644 --- a/frontend/src/components/Config/TargetTable.styles.ts +++ b/frontend/src/components/Config/TargetTable.styles.ts @@ -27,14 +27,47 @@ export const useTargetTableStyles = makeStyles({ wordBreak: 'break-word', }, capabilityCell: { - width: '80px', + width: '75px', textAlign: 'center', }, + modalityCell: { + width: '110px', + textAlign: 'center', + }, + inputsModalityCell: { + width: '160px', + textAlign: 'center', + }, + modalityRow: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: tokens.spacingHorizontalXS, + flexWrap: 'wrap', + }, + modalityIcon: { + fontSize: tokens.fontSizeBase500, + color: tokens.colorNeutralForeground2, + }, + compositeIcon: { + position: 'relative', + display: 'inline-flex', + lineHeight: 0, + }, + compositeBadge: { + position: 'absolute', + top: '-4px', + right: '-6px', + fontSize: tokens.fontSizeBase300, + color: tokens.colorNeutralForeground2, + }, capabilityIconSupported: { color: tokens.colorPaletteGreenForeground1, + fontSize: tokens.fontSizeBase500, }, capabilityIconUnsupported: { color: tokens.colorPaletteRedForeground1, + fontSize: tokens.fontSizeBase500, }, helpHeader: { cursor: 'help', diff --git a/frontend/src/components/Config/TargetTable.test.tsx b/frontend/src/components/Config/TargetTable.test.tsx index 2db78187a1..51289614ec 100644 --- a/frontend/src/components/Config/TargetTable.test.tsx +++ b/frontend/src/components/Config/TargetTable.test.tsx @@ -24,6 +24,8 @@ const sampleTargets: TargetInstance[] = [ supports_json_output: true, supports_editable_history: true, supports_system_prompt: true, + supported_input_modalities: ['text', 'image_path'], + supported_output_modalities: ['text'], }, }, { @@ -38,6 +40,8 @@ const sampleTargets: TargetInstance[] = [ supports_json_output: false, supports_editable_history: false, supports_system_prompt: false, + supported_input_modalities: ['text'], + supported_output_modalities: ['image_path'], }, }, { @@ -74,7 +78,7 @@ describe('TargetTable', () => { expect(screen.getAllByText('TextTarget').length).toBeGreaterThanOrEqual(1) }) - it('should display Type, Model, Endpoint, capability columns and Parameters columns', () => { + it('should display Type, Model, Endpoint, Inputs, Outputs, capability columns and Parameters columns', () => { render( @@ -84,6 +88,8 @@ describe('TargetTable', () => { expect(screen.getByText('Type')).toBeInTheDocument() expect(screen.getByText('Model')).toBeInTheDocument() expect(screen.getByText('Endpoint')).toBeInTheDocument() + expect(screen.getByText('Inputs')).toBeInTheDocument() + expect(screen.getByText('Outputs')).toBeInTheDocument() expect(screen.getByText('Multi-turn')).toBeInTheDocument() expect(screen.getByText('Multi-piece')).toBeInTheDocument() expect(screen.getByText('JSON Schema')).toBeInTheDocument() @@ -173,9 +179,9 @@ describe('TargetTable', () => { ) - // Dashes for model, endpoint, 6 capability columns (all unknown), and params + // Dashes for model, endpoint, inputs, outputs, 6 capability columns (all unknown), and params const dashes = screen.getAllByText('—') - expect(dashes).toHaveLength(9) + expect(dashes).toHaveLength(11) }) it('should show dash for capability columns when capabilities is absent', () => { @@ -187,8 +193,65 @@ describe('TargetTable', () => { // TextTarget has no capabilities — all 6 should be dashes const dashes = screen.getAllByText('—') - // model (—) + endpoint (—) + 6 capabilities (—) + params (—) = 9 - expect(dashes).toHaveLength(9) + // model (—) + endpoint (—) + inputs (—) + outputs (—) + 6 capabilities (—) + params (—) = 11 + expect(dashes).toHaveLength(11) + }) + + it('should render modality icons with tooltips for inputs and outputs', () => { + render( + + + + ) + + // Modality tooltips are accessible labels; multiple identical labels can appear + // (e.g. one "Text" for input and one for output). + expect(screen.getAllByLabelText('Text').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByLabelText('Image').length).toBeGreaterThanOrEqual(1) + }) + + it('should render modality icons in canonical order: text, image, audio, video, reasoning, function_call, tool_call', () => { + const target: TargetInstance = { + target_registry_name: 'multi_modal', + target_type: 'CustomTarget', + endpoint: null, + model_name: null, + capabilities: { + supports_multi_turn: true, + supports_multi_message_pieces: true, + supports_json_schema: false, + supports_json_output: false, + supports_editable_history: false, + supports_system_prompt: false, + // Backend returns alphabetically sorted; UI must reorder. + supported_input_modalities: [ + 'audio_path', + 'function_call', + 'image_path', + 'reasoning', + 'text', + 'tool_call', + 'video_path', + ], + supported_output_modalities: ['text'], + }, + } + render( + + + + ) + + const expectedOrder = ['Text', 'Image', 'Audio', 'Video', 'Reasoning', 'Function call', 'Tool call'] + // The first set of modality icons belongs to the Inputs column. + const labels = expectedOrder.map((label) => screen.getAllByLabelText(label)[0]) + const positions = labels.map((el) => el.compareDocumentPosition(labels[0])) + // Each subsequent label should follow (or be) the first; verify monotonic ordering pairwise. + for (let i = 0; i < labels.length - 1; i += 1) { + const relation = labels[i].compareDocumentPosition(labels[i + 1]) + expect(relation & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + } + expect(positions).toBeDefined() }) it('should display target_specific_params when present', () => { diff --git a/frontend/src/components/Config/TargetTable.tsx b/frontend/src/components/Config/TargetTable.tsx index 373e84edd7..ba7b224d4e 100644 --- a/frontend/src/components/Config/TargetTable.tsx +++ b/frontend/src/components/Config/TargetTable.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react' +import { useState, useMemo, forwardRef } from 'react' import { Table, TableHeader, @@ -12,7 +12,21 @@ import { Tooltip, Select, } from '@fluentui/react-components' -import { CheckmarkRegular, CheckmarkCircleFilled, DismissCircleFilled } from '@fluentui/react-icons' +import { + CheckmarkRegular, + CheckmarkCircleFilled, + DismissCircleFilled, + TextTRegular, + ImageRegular, + MicRegular, + VideoRegular, + DocumentRegular, + LinkRegular, + LightbulbRegular, + MathFormulaRegular, + WrenchRegular, + ArrowHookUpLeftRegular, +} from '@fluentui/react-icons' import type { TargetInstance } from '../../types' import { useTargetTableStyles } from './TargetTable.styles' @@ -41,7 +55,7 @@ function formatParams(params?: Record | null): string { /** Capability column definitions with tooltip descriptions. */ const CAPABILITY_COLUMNS = [ - { key: 'supports_multi_turn', label: 'Multi-turn', tooltip: 'Supports multi-turn conversation history' }, + { key: 'supports_multi_turn', label: 'Multi-turn', tooltip: 'Supports multi-turn conversations' }, { key: 'supports_multi_message_pieces', label: 'Multi-piece', tooltip: 'Supports multiple message pieces in a single request' }, { key: 'supports_json_schema', label: 'JSON Schema', tooltip: 'Supports constraining output to a JSON schema' }, { key: 'supports_json_output', label: 'JSON Output', tooltip: 'Supports JSON output format' }, @@ -54,8 +68,78 @@ const COLUMN_TOOLTIPS = { model: 'Configured model name. A dotted underline indicates the deployment alias differs from the underlying model — hover the value to see it.', endpoint: 'API endpoint URL the target sends requests to', parameters: 'Target-specific configuration parameters (e.g., reasoning_effort, max_output_tokens)', + inputs: 'Modalities the target accepts as input', + outputs: 'Modalities the target can produce as output', } as const +/** Composite icon: f(x) with a small return-arrow badge for function call outputs. */ +const FunctionCallOutputIcon = forwardRef & { className?: string }>( + function FunctionCallOutputIcon({ className, ...rest }, ref) { + const styles = useTargetTableStyles() + return ( + + + + + ) + } +) + +/** Modality → (icon, label) for input/output column rendering. The renderer accepts + * arbitrary props so Tooltip can inject event handlers / ARIA attributes. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const MODALITY_RENDERERS: Record; label: string }> = { + text: { Icon: TextTRegular, label: 'Text' }, + image_path: { Icon: ImageRegular, label: 'Image' }, + audio_path: { Icon: MicRegular, label: 'Audio' }, + video_path: { Icon: VideoRegular, label: 'Video' }, + reasoning: { Icon: LightbulbRegular, label: 'Reasoning' }, + function_call: { Icon: MathFormulaRegular, label: 'Function call' }, + function_call_output: { Icon: FunctionCallOutputIcon, label: 'Function call output' }, + tool_call: { Icon: WrenchRegular, label: 'Tool call' }, + binary_path: { Icon: DocumentRegular, label: 'Binary' }, + url: { Icon: LinkRegular, label: 'URL' }, +} + +/** Canonical display order for modality icons; unknown values are appended last. */ +const MODALITY_ORDER: readonly string[] = [ + 'text', + 'image_path', + 'audio_path', + 'video_path', + 'reasoning', + 'function_call', + 'function_call_output', + 'tool_call', + 'binary_path', + 'url', +] + +/** Render a row of modality icons; falls back to "—" when empty. */ +function ModalityCell({ modalities }: { modalities: string[] | undefined }) { + const styles = useTargetTableStyles() + if (!modalities || modalities.length === 0) { + return + } + const ordered = MODALITY_ORDER.filter((m) => modalities.includes(m)) + const extras = modalities.filter((m) => !MODALITY_ORDER.includes(m)) + const sorted = [...ordered, ...extras] + return ( +
+ {sorted.map((modality) => { + const renderer = MODALITY_RENDERERS[modality] + const label = renderer?.label ?? modality + const Icon = renderer?.Icon ?? DocumentRegular + return ( + + + + ) + })} +
+ ) +} + /** Render a capability indicator: ✓ (green) / ✗ (red) / — (unknown). */ function CapabilityCell({ value }: { value: boolean | undefined }) { const styles = useTargetTableStyles() @@ -133,19 +217,25 @@ export default function TargetTable({ targets, activeTarget, onSetActiveTarget } }>Active - - {activeTarget.target_type} + + {activeTarget.target_type} - + - + {activeTarget.endpoint || '—'} + + + + + + - + {formatParams(activeTarget.target_specific_params) || '—'} @@ -175,21 +265,31 @@ export default function TargetTable({ targets, activeTarget, onSetActiveTarget } - + Type - + Model - + Endpoint + + + Inputs + + + + + Outputs + + {CAPABILITY_COLUMNS.map(({ key, label, tooltip }) => ( @@ -197,7 +297,7 @@ export default function TargetTable({ targets, activeTarget, onSetActiveTarget } ))} - + Parameters @@ -226,7 +326,7 @@ export default function TargetTable({ targets, activeTarget, onSetActiveTarget } )} - {target.target_type} + {target.target_type} @@ -236,6 +336,12 @@ export default function TargetTable({ targets, activeTarget, onSetActiveTarget } {target.endpoint || '—'} + + + + + + diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index edf3ad1e74..f3db442914 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -60,6 +60,8 @@ export interface TargetCapabilitiesInfo { supports_json_output: boolean supports_editable_history: boolean supports_system_prompt: boolean + supported_input_modalities: string[] + supported_output_modalities: string[] } export interface TargetInstance { diff --git a/pyrit/backend/mappers/target_mappers.py b/pyrit/backend/mappers/target_mappers.py index 526e382a41..e39de13ba8 100644 --- a/pyrit/backend/mappers/target_mappers.py +++ b/pyrit/backend/mappers/target_mappers.py @@ -46,6 +46,8 @@ def target_object_to_instance(target_registry_name: str, target_obj: PromptTarge combined_specific = {**extra, **explicit_specific} or None caps = target_obj.capabilities + input_modalities = sorted({modality for combo in caps.input_modalities for modality in combo}) + output_modalities = sorted({modality for combo in caps.output_modalities for modality in combo}) capabilities = TargetCapabilitiesInfo( supports_multi_turn=caps.supports_multi_turn, supports_multi_message_pieces=caps.supports_multi_message_pieces, @@ -53,6 +55,8 @@ def target_object_to_instance(target_registry_name: str, target_obj: PromptTarge supports_json_output=caps.supports_json_output, supports_editable_history=caps.supports_editable_history, supports_system_prompt=caps.supports_system_prompt, + supported_input_modalities=input_modalities, + supported_output_modalities=output_modalities, ) return TargetInstance( diff --git a/pyrit/backend/models/targets.py b/pyrit/backend/models/targets.py index 94a45ac234..e9a28fb89f 100644 --- a/pyrit/backend/models/targets.py +++ b/pyrit/backend/models/targets.py @@ -33,6 +33,14 @@ class TargetCapabilitiesInfo(BaseModel): ..., description="Whether the target allows the attack history to be modified" ) supports_system_prompt: bool = Field(..., description="Whether the target supports system prompts") + supported_input_modalities: list[str] = Field( + default_factory=list, + description="Flattened, sorted list of supported input modality data types (e.g., 'text', 'image_path')", + ) + supported_output_modalities: list[str] = Field( + default_factory=list, + description="Flattened, sorted list of supported output modality data types (e.g., 'text', 'audio_path')", + ) class TargetInstance(BaseModel): diff --git a/tests/unit/backend/test_mappers.py b/tests/unit/backend/test_mappers.py index 5eb26b2ea9..c6758bbaf0 100644 --- a/tests/unit/backend/test_mappers.py +++ b/tests/unit/backend/test_mappers.py @@ -1285,6 +1285,47 @@ def test_capabilities_populated_from_target_object(self) -> None: assert result.capabilities.supports_editable_history is False assert result.capabilities.supports_system_prompt is True + def test_capabilities_modalities_flattened_and_sorted(self) -> None: + """Test that input/output modality combinations are flattened to a sorted list of types.""" + target_obj = MagicMock(spec=PromptTarget) + target_obj.capabilities = TargetCapabilities( + input_modalities=frozenset( + { + frozenset({"text"}), + frozenset({"image_path"}), + frozenset({"text", "image_path"}), + } + ), + output_modalities=frozenset({frozenset({"audio_path", "video_path"})}), + ) + mock_identifier = ComponentIdentifier( + class_name="CustomTarget", + class_module="pyrit.prompt_target", + ) + target_obj.get_identifier.return_value = mock_identifier + + result = target_object_to_instance("t-1", target_obj) + + assert result.capabilities is not None + assert result.capabilities.supported_input_modalities == ["image_path", "text"] + assert result.capabilities.supported_output_modalities == ["audio_path", "video_path"] + + def test_capabilities_default_modalities_are_text(self) -> None: + """Targets that don't override modalities should default to ['text'].""" + target_obj = MagicMock(spec=PromptTarget) + target_obj.capabilities = TargetCapabilities() + mock_identifier = ComponentIdentifier( + class_name="TextTarget", + class_module="pyrit.prompt_target", + ) + target_obj.get_identifier.return_value = mock_identifier + + result = target_object_to_instance("t-1", target_obj) + + assert result.capabilities is not None + assert result.capabilities.supported_input_modalities == ["text"] + assert result.capabilities.supported_output_modalities == ["text"] + def test_capabilities_matches_legacy_supports_multi_turn(self) -> None: """Test that legacy supports_multi_turn field matches capabilities.supports_multi_turn.""" target_obj = MagicMock(spec=PromptTarget) From 7965afa82c45b84a0bfc167838f377721e641d1e Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Thu, 7 May 2026 11:41:09 -0700 Subject: [PATCH 7/7] FIX: Drop legacy supports_multi_turn fallback in ChatInputArea/ChatWindow The frontend was reading the deprecated top-level `supports_multi_turn` field via `capabilities.supports_multi_turn ?? supports_multi_turn`. Since the backend now always populates `capabilities`, the fallback is dead code and creates a two-source-of-truth issue. The legacy field will be removed in #1692; tightening the read sites here keeps that follow-up cleaner. - ChatInputArea single-turn warning: read only from `capabilities`. - ChatWindow single-turn limit and `isSingleTurn` flag: same. - Tests: replace top-level `supports_multi_turn` fixtures with `capabilities` populated via a small `buildCapabilities` helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../components/Chat/ChatInputArea.test.tsx | 19 +++++++++++++-- .../src/components/Chat/ChatInputArea.tsx | 2 +- .../src/components/Chat/ChatWindow.test.tsx | 23 +++++++++++++++---- frontend/src/components/Chat/ChatWindow.tsx | 4 ++-- 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/Chat/ChatInputArea.test.tsx b/frontend/src/components/Chat/ChatInputArea.test.tsx index 570fb93039..9411313151 100644 --- a/frontend/src/components/Chat/ChatInputArea.test.tsx +++ b/frontend/src/components/Chat/ChatInputArea.test.tsx @@ -4,12 +4,27 @@ import userEvent from "@testing-library/user-event"; import { FluentProvider, webLightTheme } from "@fluentui/react-components"; import ChatInputArea from "./ChatInputArea"; import type { ChatInputAreaHandle } from "./ChatInputArea"; +import type { TargetCapabilitiesInfo } from "../../types"; // Wrapper component for Fluent UI context const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( {children} ); +const buildCapabilities = ( + overrides: Partial = {} +): TargetCapabilitiesInfo => ({ + supports_multi_turn: true, + supports_multi_message_pieces: false, + supports_json_schema: false, + supports_json_output: false, + supports_editable_history: false, + supports_system_prompt: false, + supported_input_modalities: [], + supported_output_modalities: [], + ...overrides, +}); + // Helper to get the send button specifically const getSendButton = () => screen.getByRole("button", { name: /send/i }); @@ -367,7 +382,7 @@ describe("ChatInputArea", () => { activeTarget={{ target_registry_name: "test", target_type: "TextTarget", - supports_multi_turn: false, + capabilities: buildCapabilities({ supports_multi_turn: false }), }} /> @@ -388,7 +403,7 @@ describe("ChatInputArea", () => { activeTarget={{ target_registry_name: "test", target_type: "OpenAIChatTarget", - supports_multi_turn: true, + capabilities: buildCapabilities({ supports_multi_turn: true }), }} /> diff --git a/frontend/src/components/Chat/ChatInputArea.tsx b/frontend/src/components/Chat/ChatInputArea.tsx index ec3b638ea7..0a3cf6ce0d 100644 --- a/frontend/src/components/Chat/ChatInputArea.tsx +++ b/frontend/src/components/Chat/ChatInputArea.tsx @@ -439,7 +439,7 @@ const ChatInputArea = forwardRef(functi />
- {activeTarget && (activeTarget.capabilities?.supports_multi_turn ?? activeTarget.supports_multi_turn) === false && ( + {activeTarget && activeTarget.capabilities?.supports_multi_turn === false && ( = {} +): TargetCapabilitiesInfo => ({ + supports_multi_turn: true, + supports_multi_message_pieces: false, + supports_json_schema: false, + supports_json_output: false, + supports_editable_history: false, + supports_system_prompt: false, + supported_input_modalities: [], + supported_output_modalities: [], + ...overrides, +}); + // Fluent UI Combobox portal interactions are slow in JSDOM under full test load jest.setTimeout(60000); @@ -1227,7 +1242,7 @@ describe("ChatWindow Integration", () => { const singleTurnTarget: TargetInstance = { target_registry_name: "openai_image_1", target_type: "OpenAIImageTarget", - supports_multi_turn: false, + capabilities: buildCapabilities({ supports_multi_turn: false }), }; const messagesWithUser: Message[] = [ @@ -1261,7 +1276,7 @@ describe("ChatWindow Integration", () => { const singleTurnTarget: TargetInstance = { target_registry_name: "openai_image_1", target_type: "OpenAIImageTarget", - supports_multi_turn: false, + capabilities: buildCapabilities({ supports_multi_turn: false }), }; render( @@ -1310,7 +1325,7 @@ describe("ChatWindow Integration", () => { const singleTurnTarget: TargetInstance = { target_registry_name: "openai_tts_1", target_type: "OpenAITTSTarget", - supports_multi_turn: false, + capabilities: buildCapabilities({ supports_multi_turn: false }), }; const messagesWithUser: Message[] = [ @@ -1523,7 +1538,7 @@ describe("ChatWindow Integration", () => { const singleTurnTarget: TargetInstance = { target_registry_name: "openai_image_1", target_type: "OpenAIImageTarget", - supports_multi_turn: false, + capabilities: buildCapabilities({ supports_multi_turn: false }), }; const messagesWithUser: Message[] = [ diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 7f71a44c96..8810e19182 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -446,7 +446,7 @@ export default function ChatWindow({ } }, [attackResultId]) - const singleTurnLimitReached = (activeTarget?.capabilities?.supports_multi_turn ?? activeTarget?.supports_multi_turn) === false && messages.some(m => m.role === 'user') + const singleTurnLimitReached = activeTarget?.capabilities?.supports_multi_turn === false && messages.some(m => m.role === 'user') // Operator locking: if the loaded attack's operator differs from the current // user's operator label, the conversation should be read-only. @@ -561,7 +561,7 @@ export default function ChatWindow({ onBranchConversation={attackResultId && activeConversationId ? handleBranchConversation : undefined} onBranchAttack={activeTarget && activeConversationId ? handleBranchAttack : undefined} isLoading={isLoadingAttack || isLoadingMessages || awaitingConversationLoad} - isSingleTurn={(activeTarget?.capabilities?.supports_multi_turn ?? activeTarget?.supports_multi_turn) === false} + isSingleTurn={activeTarget?.capabilities?.supports_multi_turn === false} isOperatorLocked={isOperatorLocked} isCrossTarget={isCrossTargetLocked} noTargetSelected={!activeTarget}