Skip to content
10 changes: 10 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ available to assistive technology while the help is closed. Required markers,
validation errors, input-format guidance, and unavailable-estimate reasons
stay visible.

## GUI targets

`TextTarget` is an SDK demonstration and manual-workflow target that writes to
the Python host's text stream without returning a response. It remains available
in the SDK and REST API, but is excluded from GUI creation, registries, and target
choices, including converter, technique, and round-robin references. Registered
round-robin targets containing a `TextTarget` are excluded as well.
Saved history remains readable, but chats bound to a `TextTarget` cannot send
further prompts from the GUI.

## Conversation selection

Opening a saved attack with related conversations automatically opens the
Expand Down
2 changes: 1 addition & 1 deletion frontend/e2e/_targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export function makeTarget(flat: FlatTarget): TargetInstanceMock {
return {
target_registry_name: flat.target_registry_name,
identifier: {
class_name: flat.target_type ?? "TextTarget",
class_name: flat.target_type ?? "OpenAIChatTarget",
hash: `${flat.target_registry_name}-hash`,
endpoint: flat.endpoint ?? null,
model_name: flat.model_name ?? null,
Expand Down
8 changes: 7 additions & 1 deletion frontend/e2e/accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,10 +294,14 @@ test.describe("Accessibility", () => {
}),
makeTarget({
target_registry_name: "a11y-second-target",
target_type: "TextTarget",
target_type: "OpenAIResponseTarget",
endpoint: "https://test.com/text",
model_name: "text-model",
}),
makeTarget({
target_registry_name: "a11y-sdk-target",
target_type: "TextTarget",
}),
],
pagination: { limit: 200, has_more: false, next_cursor: null, prev_cursor: null },
}),
Expand All @@ -312,6 +316,8 @@ test.describe("Accessibility", () => {
const table = page.getByRole("table");
await expect(table).toBeVisible();
await expect(page.getByRole("combobox", { name: "Filter by type:", exact: true })).toBeVisible();
await expect(page.getByText("a11y-sdk-target", { exact: true })).toHaveCount(0);
await expect(page.getByText("TextTarget", { exact: true })).toHaveCount(0);
});

test("major views expose page headings and one primary navigation landmark", async ({ page }) => {
Expand Down
5 changes: 2 additions & 3 deletions frontend/e2e/converters.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -530,11 +530,10 @@ test.describe("Shared per-piece converter pipelines @seeded", () => {
expect(readiness.ok()).toBeTruthy();
return (await readiness.json()).ready;
}, { timeout: 60_000 }).toBe(true);
const targetResponse = await request.post("/api/targets", {
const targetResponse = await request.get("/api/targets/browser_offline", {
headers: compatibilityHeaders(),
data: { type: "TextTarget", name: `e2e-converters-${randomUUID()}`, params: {} },
});
expect(targetResponse.status()).toBe(201);
expect(targetResponse.status()).toBe(200);
const target: TargetInstance = await targetResponse.json();
targetRegistryName = target.target_registry_name;

Expand Down
5 changes: 2 additions & 3 deletions frontend/e2e/registry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@ test("creates and selects a runtime technique with the real backend @seeded", as
for (const name of ["objective_scorer_chat", "adversarial_chat", "registry_local_objective"]) {
const existing = await request.get(`/api/targets/${name}`, { headers });
if (existing.status() === 404) {
const created = await request.post("/api/targets", { headers, data: name === "registry_local_objective"
? { name, type: "TextTarget", params: {} }
: { name, type: "OpenAIChatTarget", auth_mode: "api_key", params: {
const created = await request.post("/api/targets", { headers, data:
{ name, type: "OpenAIChatTarget", auth_mode: "api_key", params: {
endpoint: "http://127.0.0.1:9/v1", model_name: "local-test", api_key: "local-test-only",
} } });
expect(created.ok(), await created.text()).toBe(true);
Expand Down
38 changes: 36 additions & 2 deletions frontend/src/App.targets.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { MemoryRouter } from 'react-router'

import { attacksApi, targetsApi } from '@/services/api'
import { makeTarget } from '@/test-utils/targetFixtures'
import type { AttackMetadata, AttackSummary, ConversationMessagesResponse, RegisteredComponentReference, ScorerInstance } from '@/types'
import type { AttackMetadata, AttackSummary, ConversationMessagesResponse, RegisteredComponentReference, ScorerInstance, TargetInfo } from '@/types'
import { DEFAULT_USER_PREFERENCES, readUserPreferences, writeUserPreferences } from '@/utils/userPreferences'

import App from './App'
Expand Down Expand Up @@ -78,7 +78,7 @@ const savedAttack: AttackSummary = {
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
target: {
target_type: 'TextTarget',
target_type: 'OpenAIChatTarget',
target_registry_name: 'target-b',
identifier_hash: targetB.identifier.hash,
},
Expand Down Expand Up @@ -183,6 +183,40 @@ describe('App target selection with the chat composer', () => {
await waitFor(() => expect(screen.getByRole('combobox', { name: 'Objective target' })).toHaveValue('target-b'))
})

it('does not offer an SDK-only target or restore it as the last-used target', async () => {
const sdkTarget = makeTarget({
target_registry_name: 'sdk-demo', target_type: 'TextTarget',
capabilities: { supports_multi_turn: true },
})
saveDefault('alice', sdkTarget)
jest.mocked(targetsApi.listTargets).mockResolvedValue({
items: [sdkTarget, targetA], pagination: { limit: 200, has_more: false },
})
render(<App />, { wrapper: TestWrapper })
const selector = screen.getByRole('combobox', { name: 'Objective target' })
await waitFor(() => expect(selector).toBeEnabled())
expect(selector).toHaveValue('')
expect(screen.queryByRole('option', { name: /sdk-demo/ })).not.toBeInTheDocument()
expect(screen.getByRole('option', { name: /target-a/ })).toBeInTheDocument()
})

it('keeps saved TextTarget history readable without allowing another send', async () => {
const user = userEvent.setup()
const sdkInfo: TargetInfo = {
target_type: 'TextTarget', target_registry_name: 'target-b', identifier_hash: targetB.identifier.hash,
}
jest.mocked(attacksApi.getAttack).mockResolvedValue({ ...savedAttack, target: sdkInfo })
jest.mocked(attacksApi.getAttackMetadata).mockResolvedValue({ ...savedMetadata, target: sdkInfo })
render(<App />, { wrapper: TestWrapper })
await user.click(screen.getByRole('link', { name: 'Open saved chat' }))
expect(await screen.findByText('Saved reply from target B')).toBeInTheDocument()
expect(screen.getByTestId('target-resolution-unavailable-banner')).toHaveTextContent('unavailable in the GUI. Sending is disabled')
expect(screen.queryByPlaceholderText('Type prompt here')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Send message' })).not.toBeInTheDocument()
expect(targetsApi.getTarget).not.toHaveBeenCalled()
expect(attacksApi.submitMessageSend).not.toHaveBeenCalled()
})

it('selects a new-chat target from the badge without changing the default', async () => {
const user = userEvent.setup()
render(<App />, { wrapper: TestWrapper })
Expand Down
28 changes: 14 additions & 14 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1223,7 +1223,7 @@ describe("App", () => {
labels: {},
related_conversation_ids: [],
target: {
target_type: "TextTarget",
target_type: "OpenAIChatTarget",
identifier_hash: "branch-target-hash",
},
});
Expand Down Expand Up @@ -1981,7 +1981,7 @@ describe("App", () => {
labels: {},
related_conversation_ids: [],
target: {
target_type: "TextTarget",
target_type: "OpenAIChatTarget",
target_registry_name: "persisted-alias",
identifier_hash: "persisted-alias-hash",
},
Expand Down Expand Up @@ -2013,7 +2013,7 @@ describe("App", () => {
labels: {},
related_conversation_ids: [],
target: {
target_type: "TextTarget",
target_type: "OpenAIChatTarget",
target_registry_name: "reused-alias",
identifier_hash: "original-hash",
},
Expand Down Expand Up @@ -2044,7 +2044,7 @@ describe("App", () => {
labels: {},
related_conversation_ids: [],
target: {
target_type: "TextTarget",
target_type: "OpenAIChatTarget",
target_registry_name: "removed-alias",
identifier_hash: "original-hash",
},
Expand Down Expand Up @@ -2074,7 +2074,7 @@ describe("App", () => {
labels: {},
related_conversation_ids: [],
target: {
target_type: "TextTarget",
target_type: "OpenAIChatTarget",
target_registry_name: "catalog",
identifier_hash: "catalog-target-hash",
},
Expand Down Expand Up @@ -2105,7 +2105,7 @@ describe("App", () => {
labels: {},
related_conversation_ids: [],
target: {
target_type: "TextTarget",
target_type: "OpenAIChatTarget",
target_registry_name: "persisted-alias",
identifier_hash: "persisted-alias-hash",
},
Expand Down Expand Up @@ -2139,7 +2139,7 @@ describe("App", () => {
labels: {},
related_conversation_ids: [],
target: {
target_type: "TextTarget",
target_type: "OpenAIChatTarget",
identifier_hash: "pagination-hash",
},
});
Expand Down Expand Up @@ -2172,7 +2172,7 @@ describe("App", () => {
labels: {},
related_conversation_ids: [],
target: {
target_type: "TextTarget",
target_type: "OpenAIChatTarget",
identifier_hash: "remount-hash",
},
});
Expand Down Expand Up @@ -2205,7 +2205,7 @@ describe("App", () => {
labels: {},
related_conversation_ids: [],
target: {
target_type: "TextTarget",
target_type: "OpenAIChatTarget",
identifier_hash: "removed-hash",
},
});
Expand Down Expand Up @@ -2245,7 +2245,7 @@ describe("App", () => {
labels: {},
related_conversation_ids: [],
target: {
target_type: "TextTarget",
target_type: "OpenAIChatTarget",
identifier_hash: "revisited-hash",
},
};
Expand Down Expand Up @@ -2336,7 +2336,7 @@ describe("App", () => {
labels: {},
related_conversation_ids: [],
target: {
target_type: "TextTarget",
target_type: "OpenAIChatTarget",
identifier_hash: "duplicate-hash",
},
});
Expand Down Expand Up @@ -2377,7 +2377,7 @@ describe("App", () => {
labels: {},
related_conversation_ids: [],
target: {
target_type: "TextTarget",
target_type: "OpenAIChatTarget",
identifier_hash: "other-target-hash",
},
});
Expand Down Expand Up @@ -2481,7 +2481,7 @@ describe("App", () => {
labels: {},
related_conversation_ids: [],
target: {
target_type: "TextTarget",
target_type: "OpenAIChatTarget",
identifier_hash: "retry-hash",
},
});
Expand Down Expand Up @@ -2516,7 +2516,7 @@ describe("App", () => {
labels: {},
related_conversation_ids: [],
target: {
target_type: "TextTarget",
target_type: "OpenAIChatTarget",
identifier_hash: `${attackId}-hash`,
},
}));
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 @@ -59,7 +59,7 @@ function TargetResolutionBanner({ status, activeTarget, onRetry, onConfigureTarg
const text = status === 'error'
? 'Target verification failed. Sending is disabled; human scores can still be changed by the same operator.'
: status === 'unavailable'
? 'The target used by this attack is not currently registered. Sending is disabled; human scores can still be changed by the same operator.'
? 'The target used by this attack is unavailable in the GUI. Sending is disabled; human scores can still be changed by the same operator.'
: "Multiple registered targets have this attack's identity. Remove duplicate registrations, then retry."
return <StatusBanner className={styles.statusBanner} textClassName={styles.statusBannerText}
icon={<WarningRegular fontSize={18} />} text={text} buttonText="Retry" buttonIcon={<ArrowSyncRegular />}
Expand Down
52 changes: 51 additions & 1 deletion frontend/src/components/Config/CreateTargetDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,12 @@ const TARGET_TYPES: TargetTypeListResponse = {
supported_auth_modes: ["api_key"],
description: "A prompt target that distributes requests across multiple inner targets using weighted round-robin selection.",
},
{
target_type: "TextTarget",
parameters: [],
supported_auth_modes: [],
description: "Writes prompts to a local text stream.",
},
{
target_type: "HTTPTarget",
parameters: [
Expand Down Expand Up @@ -346,6 +352,7 @@ const TARGET_DISPLAY_NAMES: Record<string, string> = {
OpenAIVideoTarget: "OpenAI video",
RoundRobinTarget: "Weighted round robin",
HTTPTarget: "HTTPTarget",
TextTarget: "TextTarget",
AzureBlobStorageTarget: "AzureBlobStorageTarget",
};

Expand Down Expand Up @@ -543,7 +550,7 @@ describe("CreateTargetDialog", () => {

const options = screen.getAllByRole("option");
expect(options).toHaveLength(11);
for (const entry of TARGET_TYPES.items.filter((item) => item.target_type !== "PlaywrightTarget")) {
for (const entry of TARGET_TYPES.items.filter((item) => !["PlaywrightTarget", "TextTarget"].includes(item.target_type))) {
const option = screen.getByRole("option", {
name: new RegExp(`Implementation: ${entry.target_type}`),
});
Expand Down Expand Up @@ -583,6 +590,49 @@ describe("CreateTargetDialog", () => {
expect(selectedDetails).toHaveTextContent("Supported authentication: API key");
});

it("should not offer the SDK-only TextTarget", async () => {
render(
<TestWrapper>
<CreateTargetDialog {...defaultProps} />
</TestWrapper>
);

await openTargetTypePicker();

expect(screen.queryByRole("option", { name: /Implementation: TextTarget/ })).not.toBeInTheDocument();
expect(screen.queryByText("Writes prompts to a local text stream.")).not.toBeInTheDocument();
});

it("should still describe other credential-free target types", async () => {
mockedTargetsApi.listTargetTypes.mockResolvedValue({
items: [...TARGET_TYPES.items, {
target_type: "CredentialFreeTarget",
parameters: [],
supported_auth_modes: [],
description: "A credential-free endpoint.",
}],
});
render(<TestWrapper><CreateTargetDialog {...defaultProps} /></TestWrapper>);
await openTargetTypePicker();
const option = screen.getByRole("option", { name: /Implementation: CredentialFreeTarget/ });
expect(option).toHaveTextContent("No authentication required");
expect(option).not.toHaveTextContent("API key");
});

it("should not offer SDK-only inner targets in a round-robin target", async () => {
render(
<TestWrapper>
<CreateTargetDialog {...defaultProps} existingTargets={[
makeTarget({ target_registry_name: "demo", target_type: "TextTarget" }),
makeTarget({ target_registry_name: "endpoint", target_type: "OpenAIChatTarget" }),
]} />
</TestWrapper>
);
await selectTargetType("RoundRobinTarget");
expect(screen.queryByRole("option", { name: /demo/ })).not.toBeInTheDocument();
expect(screen.getByRole("option", { name: /endpoint/ })).toBeInTheDocument();
});

it("should keep the selected target displayed after focus moves to another field", async () => {
const user = userEvent.setup();

Expand Down
Loading
Loading