Skip to content
Merged
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
19 changes: 17 additions & 2 deletions frontend/src/components/Chat/ChatInputArea.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<FluentProvider theme={webLightTheme}>{children}</FluentProvider>
);

const buildCapabilities = (
overrides: Partial<TargetCapabilitiesInfo> = {}
): 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 });

Expand Down Expand Up @@ -367,7 +382,7 @@ describe("ChatInputArea", () => {
activeTarget={{
target_registry_name: "test",
target_type: "TextTarget",
supports_multi_turn: false,
capabilities: buildCapabilities({ supports_multi_turn: false }),
}}
/>
</TestWrapper>
Expand All @@ -388,7 +403,7 @@ describe("ChatInputArea", () => {
activeTarget={{
target_registry_name: "test",
target_type: "OpenAIChatTarget",
supports_multi_turn: true,
capabilities: buildCapabilities({ supports_multi_turn: true }),
}}
/>
</TestWrapper>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/Chat/ChatInputArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@ const ChatInputArea = forwardRef<ChatInputAreaHandle, ChatInputAreaProps>(functi
/>
</div>
<div className={styles.columnRight}>
{activeTarget && activeTarget.supports_multi_turn === false && (
{activeTarget && activeTarget.capabilities?.supports_multi_turn === false && (
<Tooltip
content="This target does not track conversation history — each turn is sent independently."
relationship="description"
Expand Down
23 changes: 19 additions & 4 deletions frontend/src/components/Chat/ChatWindow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,24 @@ import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import ChatWindow from "./ChatWindow";
import { Message, TargetInfo, TargetInstance } from "../../types";
import type { TargetCapabilitiesInfo } from "../../types";
import { attacksApi, convertersApi } from "../../services/api";
import * as messageMapper from "../../utils/messageMapper";

const buildCapabilities = (
overrides: Partial<TargetCapabilitiesInfo> = {}
): 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);

Expand Down Expand Up @@ -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[] = [
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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[] = [
Expand Down Expand Up @@ -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[] = [
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/Chat/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ export default function ChatWindow({
}
}, [attackResultId])

const singleTurnLimitReached = 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.
Expand Down Expand Up @@ -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 === false}
isOperatorLocked={isOperatorLocked}
isCrossTarget={isCrossTargetLocked}
noTargetSelected={!activeTarget}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/Config/TargetConfig.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
52 changes: 52 additions & 0 deletions frontend/src/components/Config/TargetTable.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand All @@ -20,4 +26,50 @@ export const useTargetTableStyles = makeStyles({
whiteSpace: 'pre-line',
wordBreak: 'break-word',
},
capabilityCell: {
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',
},
})
103 changes: 101 additions & 2 deletions frontend/src/components/Config/TargetTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,32 @@ 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,
supported_input_modalities: ['text', 'image_path'],
supported_output_modalities: ['text'],
},
},
{
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,
supported_input_modalities: ['text'],
supported_output_modalities: ['image_path'],
},
},
{
target_registry_name: 'text_target_basic',
Expand Down Expand Up @@ -58,7 +78,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, Inputs, Outputs, capability columns and Parameters columns', () => {
render(
<TestWrapper>
<TargetTable {...defaultProps} />
Expand All @@ -68,6 +88,14 @@ 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()
expect(screen.getByText('JSON Output')).toBeInTheDocument()
expect(screen.getByText('Edit History')).toBeInTheDocument()
expect(screen.getByText('System Prompt')).toBeInTheDocument()
expect(screen.getByText('Parameters')).toBeInTheDocument()
})

Expand Down Expand Up @@ -151,8 +179,79 @@ describe('TargetTable', () => {
</TestWrapper>
)

// Dashes for model, endpoint, inputs, outputs, 6 capability columns (all unknown), and params
const dashes = screen.getAllByText('—')
expect(dashes.length).toBeGreaterThanOrEqual(2)
expect(dashes).toHaveLength(11)
})

it('should show dash for capability columns when capabilities is absent', () => {
render(
<TestWrapper>
<TargetTable {...defaultProps} targets={[sampleTargets[2]]} />
</TestWrapper>
)

// TextTarget has no capabilities — all 6 should be dashes
const dashes = screen.getAllByText('—')
// model (—) + endpoint (—) + inputs (—) + outputs (—) + 6 capabilities (—) + params (—) = 11
expect(dashes).toHaveLength(11)
})

it('should render modality icons with tooltips for inputs and outputs', () => {
render(
<TestWrapper>
<TargetTable {...defaultProps} targets={[sampleTargets[0]]} />
</TestWrapper>
)

// 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(
<TestWrapper>
<TargetTable {...defaultProps} targets={[target]} />
</TestWrapper>
)

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', () => {
Expand Down
Loading
Loading