Skip to content
Open
16 changes: 15 additions & 1 deletion frontend/src/components/Chat/ChatInputArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { SendRegular, AttachRegular, DismissRegular, InfoRegular, AddRegular, CopyRegular, WarningRegular, SettingsRegular, ArrowShuffleRegular, OpenRegular } from '@fluentui/react-icons'
import { MessageAttachment, TargetInstance } from '../../types'
import { useChatInputAreaStyles } from './ChatInputArea.styles'
import SystemPromptSetup from './SystemPromptSetup'
import { PIECE_TYPE_TO_DATA_TYPE } from './converterTypes'

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -329,9 +330,15 @@ interface ChatInputAreaProps {
/** Chip describing a text→file conversion (e.g. PDFConverter output). */
convertedFileChip?: ConvertedFileChip | null
onClearConvertedFileChip?: () => void
/** Whether to show the system-prompt setup (only for a brand-new conversation). */
showSystemPrompt?: boolean
/** Whether the active target supports system prompts (gates the setup's enabled state). */
supportsSystemPrompt?: boolean
systemPrompt?: string
onSystemPromptChange?: (value: string) => void
}

const ChatInputArea = forwardRef<ChatInputAreaHandle, ChatInputAreaProps>(function ChatInputArea({ onSend, disabled = false, activeTarget, singleTurnLimitReached = false, onNewConversation, operatorLocked = false, crossTargetLocked = false, onUseAsTemplate, attackOperator, noTargetSelected = false, onConfigureTarget, onToggleConverterPanel, isConverterPanelOpen = false, onInputChange, onAttachmentsChange, convertedValue, originalValue: _originalValue, onClearConversion, onConvertedValueChange, converterOutputDataTypes = [], mediaConversions = [], onClearMediaConversion, convertedFileChip, onClearConvertedFileChip }, ref) {
const ChatInputArea = forwardRef<ChatInputAreaHandle, ChatInputAreaProps>(function ChatInputArea({ onSend, disabled = false, activeTarget, singleTurnLimitReached = false, onNewConversation, operatorLocked = false, crossTargetLocked = false, onUseAsTemplate, attackOperator, noTargetSelected = false, onConfigureTarget, onToggleConverterPanel, isConverterPanelOpen = false, onInputChange, onAttachmentsChange, convertedValue, originalValue: _originalValue, onClearConversion, onConvertedValueChange, converterOutputDataTypes = [], mediaConversions = [], onClearMediaConversion, convertedFileChip, onClearConvertedFileChip, showSystemPrompt = false, supportsSystemPrompt = false, systemPrompt = '', onSystemPromptChange }, ref) {
const styles = useChatInputAreaStyles()
const [input, setInput] = useState('')
const [attachments, setAttachments] = useState<MessageAttachment[]>([])
Expand Down Expand Up @@ -537,6 +544,13 @@ const ChatInputArea = forwardRef<ChatInputAreaHandle, ChatInputAreaProps>(functi
) : (
<>
<div className={styles.inputWrapper}>
{showSystemPrompt && onSystemPromptChange && (
<SystemPromptSetup
value={systemPrompt}
onChange={onSystemPromptChange}
disabled={!!activeTarget && !supportsSystemPrompt}
/>
)}
<input
ref={fileInputRef}
type="file"
Expand Down
279 changes: 279 additions & 0 deletions frontend/src/components/Chat/ChatWindow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,285 @@ describe("ChatWindow Integration", () => {
});
});

// -----------------------------------------------------------------------
// System prompt (system_prompt) wiring
// -----------------------------------------------------------------------

describe("system prompt", () => {
const supportedTarget: TargetInstance = {
...mockTarget,
capabilities: buildCapabilities({ supports_system_prompt: true }),
};

function primeSendMocks() {
mockedMapper.buildMessagePieces.mockResolvedValue([
{ data_type: "text", original_value: "Hello" },
]);
mockedAttacksApi.createAttack.mockResolvedValue({
attack_result_id: "ar-sys",
conversation_id: "conv-sys",
created_at: "2026-01-01T00:00:00Z",
});
mockedAttacksApi.addMessage.mockResolvedValue(
makeTextResponse("Hi") as never
);
mockedMapper.backendMessagesToFrontend.mockReturnValue([
{ role: "assistant", content: "Hi", timestamp: "2026-01-01T00:00:01Z" },
]);
}

it("renders the system prompt toggle for a new conversation", () => {
render(
<TestWrapper>
<ChatWindow {...defaultProps} activeTarget={supportedTarget} />
</TestWrapper>
);

expect(
screen.getByRole("button", { name: /system prompt/i })
).toBeInTheDocument();
});

it("hides the system prompt toggle once an attack exists", async () => {
mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
mockedMapper.backendMessagesToFrontend.mockReturnValue([]);

render(
<TestWrapper>
<ChatWindow
{...defaultProps}
activeTarget={supportedTarget}
attackResultId="ar-existing"
conversationId="conv-existing"
activeConversationId="conv-existing"
/>
</TestWrapper>
);

await waitFor(() => {
expect(screen.queryByTestId("loading-state")).not.toBeInTheDocument();
});
expect(
screen.queryByRole("button", { name: /system prompt/i })
).not.toBeInTheDocument();
});

it("renders a system prompt banner when the loaded conversation has a system message", async () => {
mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
mockedMapper.backendMessagesToFrontend.mockReturnValue([
{ role: "system", content: "You are a pirate.", timestamp: "2026-01-01T00:00:00Z" },
{ role: "user", content: "Ahoy", timestamp: "2026-01-01T00:00:01Z" },
]);

render(
<TestWrapper>
<ChatWindow
{...defaultProps}
activeTarget={supportedTarget}
attackResultId="ar-existing"
conversationId="conv-existing"
activeConversationId="conv-existing"
/>
</TestWrapper>
);

expect(await screen.findByTestId("system-prompt-banner")).toBeInTheDocument();
expect(screen.getByText("You are a pirate.")).toBeInTheDocument();
});

it("forwards the typed system prompt when the target supports it", async () => {
const user = userEvent.setup();
primeSendMocks();

render(
<TestWrapper>
<ChatWindow {...defaultProps} activeTarget={supportedTarget} />
</TestWrapper>
);

await user.click(screen.getByRole("button", { name: /system prompt/i }));
await user.type(
screen.getByRole("textbox", { name: /system prompt/i }),
"You are helpful"
);
await user.type(screen.getByPlaceholderText("Type prompt here"), "Hello");
await user.click(screen.getByRole("button", { name: /send/i }));

await waitFor(() => {
expect(mockedAttacksApi.createAttack).toHaveBeenCalledWith(
expect.objectContaining({ system_prompt: "You are helpful" })
);
});
});

it("omits the system prompt when the target does not support it", async () => {
const user = userEvent.setup();
primeSendMocks();

render(
<TestWrapper>
<ChatWindow {...defaultProps} activeTarget={mockTarget} />
</TestWrapper>
);

await user.type(screen.getByPlaceholderText("Type prompt here"), "Hello");
await user.click(screen.getByRole("button", { name: /send/i }));

await waitFor(() => {
expect(mockedAttacksApi.createAttack).toHaveBeenCalled();
});
const createArgs = mockedAttacksApi.createAttack.mock.calls[0][0];
expect(createArgs.system_prompt).toBeUndefined();
});

it("disables the toggle and drops the prompt for an explicitly unsupported target", async () => {
const user = userEvent.setup();
primeSendMocks();

const unsupportedTarget: TargetInstance = {
...mockTarget,
capabilities: buildCapabilities({ supports_system_prompt: false }),
};

render(
<TestWrapper>
<ChatWindow {...defaultProps} activeTarget={unsupportedTarget} />
</TestWrapper>
);

expect(
screen.getByRole("button", { name: /system prompt/i })
).toBeDisabled();

await user.type(screen.getByPlaceholderText("Type prompt here"), "Hello");
await user.click(screen.getByRole("button", { name: /send/i }));

await waitFor(() => {
expect(mockedAttacksApi.createAttack).toHaveBeenCalled();
});
const createArgs = mockedAttacksApi.createAttack.mock.calls[0][0];
expect(createArgs.system_prompt).toBeUndefined();
});

it("omits the system prompt when left blank on a supporting target", async () => {
const user = userEvent.setup();
primeSendMocks();

render(
<TestWrapper>
<ChatWindow {...defaultProps} activeTarget={supportedTarget} />
</TestWrapper>
);

await user.type(screen.getByPlaceholderText("Type prompt here"), "Hello");
await user.click(screen.getByRole("button", { name: /send/i }));

await waitFor(() => {
expect(mockedAttacksApi.createAttack).toHaveBeenCalled();
});
const createArgs = mockedAttacksApi.createAttack.mock.calls[0][0];
expect(createArgs.system_prompt).toBeUndefined();
});

it("clears a retained system prompt when switching to an unsupported target", async () => {
const user = userEvent.setup();
primeSendMocks();

const supportedA: TargetInstance = {
...mockTarget,
target_registry_name: "supports_a",
capabilities: buildCapabilities({ supports_system_prompt: true }),
};
const unsupportedB: TargetInstance = {
...mockTarget,
target_registry_name: "no_support_b",
capabilities: buildCapabilities({ supports_system_prompt: false }),
};
const supportedC: TargetInstance = {
...mockTarget,
target_registry_name: "supports_c",
capabilities: buildCapabilities({ supports_system_prompt: true }),
};

const { rerender } = render(
<TestWrapper>
<ChatWindow {...defaultProps} activeTarget={supportedA} />
</TestWrapper>
);

await user.click(screen.getByRole("button", { name: /system prompt/i }));
await user.type(
screen.getByRole("textbox", { name: /system prompt/i }),
"You are helpful"
);

// Switch to an unsupported target (should clear), then to another
// supporting one so the cleared value is observable on send.
rerender(
<TestWrapper>
<ChatWindow {...defaultProps} activeTarget={unsupportedB} />
</TestWrapper>
);
rerender(
<TestWrapper>
<ChatWindow {...defaultProps} activeTarget={supportedC} />
</TestWrapper>
);

await user.type(screen.getByPlaceholderText("Type prompt here"), "Hello");
await user.click(screen.getByRole("button", { name: /send/i }));

await waitFor(() => {
expect(mockedAttacksApi.createAttack).toHaveBeenCalled();
});
const createArgs = mockedAttacksApi.createAttack.mock.calls[0][0];
expect(createArgs.system_prompt).toBeUndefined();
});

it("preserves the system prompt across supporting targets", async () => {
const user = userEvent.setup();
primeSendMocks();

const supportedA: TargetInstance = {
...mockTarget,
target_registry_name: "supports_a",
capabilities: buildCapabilities({ supports_system_prompt: true }),
};
const supportedB: TargetInstance = {
...mockTarget,
target_registry_name: "supports_b",
capabilities: buildCapabilities({ supports_system_prompt: true }),
};

const { rerender } = render(
<TestWrapper>
<ChatWindow {...defaultProps} activeTarget={supportedA} />
</TestWrapper>
);

await user.click(screen.getByRole("button", { name: /system prompt/i }));
await user.type(
screen.getByRole("textbox", { name: /system prompt/i }),
"You are helpful"
);

rerender(
<TestWrapper>
<ChatWindow {...defaultProps} activeTarget={supportedB} />
</TestWrapper>
);

await user.type(screen.getByPlaceholderText("Type prompt here"), "Hello");
await user.click(screen.getByRole("button", { name: /send/i }));

await waitFor(() => {
expect(mockedAttacksApi.createAttack).toHaveBeenCalledWith(
expect.objectContaining({ system_prompt: "You are helpful" })
);
});
});
});

// -----------------------------------------------------------------------
// Subsequent messages → reuse conversation ID
// -----------------------------------------------------------------------
Expand Down
Loading
Loading