Skip to content
Draft
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
7 changes: 6 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ interface LoadedAttack {
targetSource: 'persisted' | 'active-selection'
mainConversationId: string | null
labels: Record<string, string> | null
operator: string | null
target: TargetInfo | null
relatedConversationIds: string[]
objective: string
Expand Down Expand Up @@ -319,6 +320,7 @@ function App() {
status: 'loading',
mainConversationId: null,
labels: null,
operator: null,
target: null,
relatedConversationIds: [],
objective: '',
Expand All @@ -333,6 +335,7 @@ function App() {
targetSource: 'persisted',
mainConversationId: attack.conversation_id,
labels: attack.labels ?? {},
operator: attack.operator ?? null,
target: attack.target ?? null,
relatedConversationIds: attack.related_conversation_ids ?? [],
objective: attack.objective ?? '',
Expand All @@ -352,6 +355,7 @@ function App() {
status: isMissing ? 'not-found' : 'error',
mainConversationId: null,
labels: null,
operator: null,
target: null,
relatedConversationIds: [],
objective: '',
Expand Down Expand Up @@ -441,6 +445,7 @@ function App() {
mainConversationId: convId,
// New attack uses the current user's labels, so it is never operator-locked.
labels: null,
operator: null,
target,
relatedConversationIds: [],
objective: '',
Expand Down Expand Up @@ -488,7 +493,7 @@ function App() {
labels={globalLabels}
onLabelsChange={handleGlobalLabelsChange}
onNavigate={handleNavigate}
attackLabels={readyAttack ? readyAttack.labels : null}
attackOperator={readyAttack ? readyAttack.operator : null}
attackTarget={readyAttack ? readyAttack.target : null}
targetResolutionStatus={targetResolutionStatus}
onRetryTargetResolution={retryTargetResolution}
Expand Down
9 changes: 5 additions & 4 deletions frontend/src/components/Chat/ChatWindow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -705,7 +705,9 @@ describe("ChatWindow Integration", () => {
await waitFor(() => {
expect(mockedAttacksApi.createAttack).toHaveBeenCalledWith({
target_registry_name: "openai_chat_1",
labels: { operator: 'testuser', operation: 'test_op' },
operator: 'testuser',
operation: 'test_op',
system_prompt: undefined,
});
expect(onConversationCreated).toHaveBeenCalledWith("ar-conv-1", "conv-1");
expect(mockedAttacksApi.addMessage).toHaveBeenCalledWith("ar-conv-1", {
Expand All @@ -714,7 +716,6 @@ describe("ChatWindow Integration", () => {
send: true,
target_registry_name: "openai_chat_1",
target_conversation_id: "conv-1",
labels: { operator: "testuser", operation: "test_op" },
});
});

Expand Down Expand Up @@ -2655,7 +2656,7 @@ describe("ChatWindow Integration", () => {
conversationId="conv-locked"
activeConversationId="conv-locked"
labels={{ operator: "alice", operation: "test_op" }}
attackLabels={{ operator: "bob", operation: "test_op" }}
attackOperator="bob"
/>
</TestWrapper>
);
Expand Down Expand Up @@ -3786,7 +3787,7 @@ describe("ChatWindow Integration", () => {
it("allows exporting a read-only historical conversation", async () => {
const user = userEvent.setup();
// Operator lock: the loaded attack belongs to a different operator.
await renderWithLoadedConversation({ attackLabels: { operator: "someone-else" } });
await renderWithLoadedConversation({ attackOperator: "someone-else" });
const { clickSpy } = spyOnDownloadAnchor();

const exportButton = screen.getByRole("button", { name: /export conversation/i });
Expand Down
41 changes: 28 additions & 13 deletions frontend/src/components/Chat/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ import { buildMessagePieces, backendMessagesToFrontend } from '../../utils/messa
import { exportConversation } from '../../utils/conversationExport'
import type { ExportFormat } from '../../utils/conversationExport'
import type {
AddMessageRequest,
AttackTargetResolutionStatus,
CreateAttackRequest,
Message,
MessageAttachment,
TargetInstance,
Expand Down Expand Up @@ -78,6 +80,19 @@ function matchesNarrowScreen(): boolean {
&& window.matchMedia(NARROW_SCREEN_QUERY).matches
}

function attackAttributionFromLabels(labels?: Record<string, string>): Pick<
CreateAttackRequest,
'operator' | 'operation' | 'labels'
> {
if (!labels) return {}
const { operator, operation, ...arbitraryLabels } = labels
const attribution: Pick<CreateAttackRequest, 'operator' | 'operation' | 'labels'> = {}
if (operator) attribution.operator = operator
if (operation) attribution.operation = operation
if (Object.keys(arbitraryLabels).length > 0) attribution.labels = arbitraryLabels
return attribution
}

interface ChatWindowProps {
onNewAttack: () => void
activeTarget: TargetInstance | null
Expand All @@ -89,8 +104,8 @@ interface ChatWindowProps {
labels?: Record<string, string>
onLabelsChange?: (labels: Record<string, string>) => void
onNavigate?: (view: ViewName) => void
/** Labels from the loaded attack (for operator locking). Null for new attacks. */
attackLabels?: Record<string, string> | null
/** Operator from the loaded attack (for operator locking). Null for new attacks. */
attackOperator?: string | null
/** Target info that the current attack was started with (for cross-target guard). */
attackTarget?: TargetInfo | null
/** Result of resolving the persisted attack target against the current registry. */
Expand Down Expand Up @@ -118,7 +133,7 @@ export default function ChatWindow({
labels,
onLabelsChange,
onNavigate,
attackLabels,
attackOperator,
attackTarget,
targetResolutionStatus = 'idle',
onRetryTargetResolution,
Expand Down Expand Up @@ -238,10 +253,9 @@ export default function ChatWindow({
&& isTargetResolutionBlocking(targetResolutionStatus),
)
const currentOperator = labels?.operator
const attackOperator = attackLabels?.operator
// Existing attacks are operator-locked when their operator differs from the current one.
const isOperatorLocked = Boolean(
attackResultId && attackLabels && attackOperator && currentOperator && attackOperator !== currentOperator,
attackResultId && attackOperator && currentOperator && attackOperator !== currentOperator,
)
// They are cross-target locked when the selected target's canonical hash differs from the persisted target.
const isCrossTargetLocked = Boolean(
Expand Down Expand Up @@ -428,11 +442,12 @@ export default function ChatWindow({
let currentConversationId = conversationId
let currentActiveConversationId = activeConversationId
if (!currentAttackResultId) {
const createResponse = await attacksApi.createAttack({
const createRequest: CreateAttackRequest = {
target_registry_name: activeTarget.target_registry_name,
labels: labels,
...attackAttributionFromLabels(labels),
system_prompt: supportsSystemPrompt ? systemPrompt.trim() || undefined : undefined,
})
}
const createResponse = await attacksApi.createAttack(createRequest)
currentAttackResultId = createResponse.attack_result_id
currentConversationId = createResponse.conversation_id
currentActiveConversationId = currentConversationId
Expand Down Expand Up @@ -465,15 +480,15 @@ export default function ChatWindow({

// Send message to target
const converterIds = allConverterIds.length > 0 ? allConverterIds : undefined
const response = await attacksApi.addMessage(currentAttackResultId!, {
const addMessageRequest: AddMessageRequest = {
role: 'user',
pieces,
send: true,
target_registry_name: activeTarget.target_registry_name,
target_conversation_id: effectiveConvId!,
labels: labels ?? undefined,
converter_ids: converterIds,
})
}
const response = await attacksApi.addMessage(currentAttackResultId!, addMessageRequest)

// Clear converter state after successful send
setPieceConversions({})
Expand Down Expand Up @@ -656,7 +671,7 @@ export default function ChatWindow({
try {
const createResponse = await attacksApi.createAttack({
target_registry_name: activeTarget.target_registry_name,
labels: labels,
...attackAttributionFromLabels(labels),
source_conversation_id: activeConversationId,
cutoff_index: messageIndex,
})
Expand Down Expand Up @@ -707,7 +722,7 @@ export default function ChatWindow({
// Let the backend clone the conversation with new labels
const createResponse = await attacksApi.createAttack({
target_registry_name: activeTarget.target_registry_name,
labels: labels,
...attackAttributionFromLabels(labels),
source_conversation_id: activeConversationId,
cutoff_index: lastIndex,
})
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/History/AttackHistory.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -687,9 +687,9 @@ describe('AttackHistory', () => {
})
mockedLabelsApi.getLabels.mockResolvedValue({
source: 'attacks',
operators: ['alice', 'bob'],
operations: ['op_one'],
labels: {
operator: ['alice', 'bob'],
operation: ['op_one'],
custom_tag: ['val1', 'val2'],
},
})
Expand Down
19 changes: 6 additions & 13 deletions frontend/src/components/History/AttackHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,14 @@ const PAGE_SIZE = 25
type ListParams = Parameters<typeof attacksApi.listAttacks>[0]

function buildListParams(filters: HistoryFilters, pageCursor: string | undefined): ListParams {
const labelParams: string[] = []
for (const op of filters.operator) { labelParams.push(`operator:${op}`) }
for (const op of filters.operation) { labelParams.push(`operation:${op}`) }
labelParams.push(...filters.otherLabels)
const labelParams = [...filters.otherLabels]

const params: ListParams = { limit: PAGE_SIZE }
if (pageCursor) params.cursor = pageCursor
if (filters.attackTypes.length > 0) params.attack_types = filters.attackTypes
if (filters.outcome) params.outcome = filters.outcome
if (filters.operator.length > 0) params.operator = filters.operator
if (filters.operation.length > 0) params.operation = filters.operation
if (filters.converter.length > 0) params.converter_types = filters.converter
// Match mode is only meaningful with >=2 converters selected.
if (filters.converter.length >= 2) params.converter_types_match = filters.converterMatchMode
Expand Down Expand Up @@ -110,22 +109,16 @@ export default function AttackHistory({
.catch(() => { /* ignore */ })
labelsApi.getLabels()
.then(resp => {
const operators: string[] = []
const operations: string[] = []
const others: string[] = []
for (const [key, values] of Object.entries(resp.labels)) {
if (key === 'operator') {
operators.push(...values)
} else if (key === 'operation') {
operations.push(...values)
} else if (key !== 'source') {
if (key !== 'source') {
for (const val of values) {
others.push(`${key}:${val}`)
}
}
}
setOperatorOptions(operators.sort())
setOperationOptions(operations.sort())
setOperatorOptions([...(resp.operators ?? resp.labels.operator ?? [])].sort())
setOperationOptions([...(resp.operations ?? resp.labels.operation ?? [])].sort())
setOtherLabelOptions(others.sort())
})
.catch(() => { /* ignore */ })
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/components/History/AttackTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ const sampleAttacks: AttackSummary[] = [
last_message_preview: 'Hello world',
message_count: 5,
related_conversation_ids: ['rel-1'],
labels: { operator: 'alice', operation: 'op_one', custom: 'val' },
operator: 'alice',
operation: 'op_one',
labels: { custom: 'val' },
created_at: '2026-01-15T10:30:00Z',
updated_at: '2026-01-15T11:00:00Z',
},
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/History/AttackTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,10 @@ export default function AttackTable({ attacks, onOpenAttack, formatDate }: Attac
)}
</TableCell>
<TableCell>
<Text size={200} className={styles.nowrap}>{attack.labels.operator || '—'}</Text>
<Text size={200} className={styles.nowrap}>{attack.operator || '—'}</Text>
</TableCell>
<TableCell>
<Text size={200} className={styles.nowrap}>{attack.labels.operation || '—'}</Text>
<Text size={200} className={styles.nowrap}>{attack.operation || '—'}</Text>
</TableCell>
<TableCell>
<Text size={200}>{attack.message_count}</Text>
Expand All @@ -133,7 +133,7 @@ export default function AttackTable({ attacks, onOpenAttack, formatDate }: Attac
</TableCell>
<TableCell>
{(() => {
const otherLabels = Object.entries(attack.labels ?? {}).filter(([k]) => k !== 'operator' && k !== 'operation' && k !== 'source')
const otherLabels = Object.entries(attack.labels ?? {}).filter(([k]) => k !== 'source')
return otherLabels.length > 0 ? (
<div className={styles.badgeGroup}>
{otherLabels.slice(0, 2).map(([k, v]) => (
Expand Down
36 changes: 24 additions & 12 deletions frontend/src/components/Home/Home.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ function makeAttack(overrides: Partial<AttackSummary> = {}): AttackSummary {
last_message_preview: "preview",
message_count: 1,
related_conversation_ids: [],
labels: { operator: "alice", operation: "op_alpha" },
operator: "alice",
operation: "op_alpha",
labels: {},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
...overrides,
Expand Down Expand Up @@ -125,17 +127,20 @@ describe("Home", () => {
items: [
makeAttack({
attack_result_id: "ar-1",
labels: { operator: "alice", operation: "op_alpha" },
operator: "alice",
operation: "op_alpha",
updated_at: new Date(now).toISOString(),
}),
makeAttack({
attack_result_id: "ar-2",
labels: { operator: "alice", operation: "op_alpha" },
operator: "alice",
operation: "op_alpha",
updated_at: new Date(now - 60_000).toISOString(),
}),
makeAttack({
attack_result_id: "ar-3",
labels: { operator: "alice", operation: "op_beta" },
operator: "alice",
operation: "op_beta",
updated_at: new Date(now - 120_000).toISOString(),
}),
],
Expand All @@ -161,19 +166,22 @@ describe("Home", () => {
// the group's last-activity — exercising the "newer than current" branch.
makeAttack({
attack_result_id: "ar-old",
labels: { operator: "alice", operation: "op_time" },
operator: "alice",
operation: "op_time",
last_message_preview: "older than a week",
updated_at: new Date(now - 10 * DAY).toISOString(),
}),
makeAttack({
attack_result_id: "ar-hours",
labels: { operator: "alice", operation: "op_time" },
operator: "alice",
operation: "op_time",
last_message_preview: "a few hours ago",
updated_at: new Date(now - 3 * HOUR).toISOString(),
}),
makeAttack({
attack_result_id: "ar-days",
labels: { operator: "alice", operation: "op_time" },
operator: "alice",
operation: "op_time",
last_message_preview: "a few days ago",
updated_at: new Date(now - 3 * DAY).toISOString(),
}),
Expand All @@ -199,29 +207,33 @@ describe("Home", () => {
items: [
makeAttack({
attack_result_id: "f1",
labels: { operator: "alice", operation: "op_full" },
operator: "alice",
operation: "op_full",
outcome: "success",
last_message_preview: "first preview",
updated_at: new Date(now - 60_000).toISOString(),
}),
makeAttack({
attack_result_id: "f2",
labels: { operator: "alice", operation: "op_full" },
operator: "alice",
operation: "op_full",
outcome: null, // unknown outcome -> default icon via the ?? 'undetermined' branch
last_message_preview: null, // missing preview -> falls back to attack_type
updated_at: new Date(now - 120_000).toISOString(),
}),
makeAttack({
attack_result_id: "f3",
labels: { operator: "alice", operation: "op_full" },
operator: "alice",
operation: "op_full",
// Outcome not present in the icon map -> exercises the icon fallback branch.
outcome: "mystery" as unknown as AttackSummary["outcome"],
last_message_preview: "third preview",
updated_at: "not-a-date", // invalid -> empty relative time (NaN guard)
}),
makeAttack({
attack_result_id: "f4",
labels: { operator: "alice", operation: "op_full" },
operator: "alice",
operation: "op_full",
last_message_preview: "fourth preview",
updated_at: new Date(now - 240_000).toISOString(),
}),
Expand All @@ -246,7 +258,7 @@ describe("Home", () => {
items: [
makeAttack({
attack_result_id: "ar-x",
labels: { operator: "alice" },
operation: null,
}),
],
pagination: { has_more: false, next_cursor: null },
Expand Down
Loading
Loading