From 5dff6e522473d303651e6bf87fe71305a00a51f3 Mon Sep 17 00:00:00 2001 From: Nimit Jain Date: Fri, 30 Oct 2026 11:43:15 +0530 Subject: [PATCH 1/5] FEAT GUI: Import dataset prompts and conversations into chat (#2751) --- doc/gui/0_gui.md | 13 + frontend/src/App.drafts.test.tsx | 22 ++ frontend/src/App.tsx | 11 +- frontend/src/components/Chat/ChatWindow.tsx | 43 ++- .../components/Datasets/DatasetSelection.tsx | 15 +- .../Datasets/SeedExampleBrowser.tsx | 114 ++++++- .../Datasets/SeedExampleDetail.test.tsx | 73 +++++ .../components/Datasets/SeedExampleDetail.tsx | 95 +++++- .../src/hooks/useChatDraftHandoff.test.tsx | 17 ++ frontend/src/types/index.ts | 17 ++ frontend/src/utils/chatDraft.ts | 10 +- frontend/src/utils/routeParams.ts | 29 ++ frontend/src/utils/seedImport.test.ts | 288 +++++++++++++++++ frontend/src/utils/seedImport.ts | 289 ++++++++++++++++++ 14 files changed, 1023 insertions(+), 13 deletions(-) create mode 100644 frontend/src/utils/seedImport.test.ts create mode 100644 frontend/src/utils/seedImport.ts diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index b653464ac1..d644b2620f 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -95,6 +95,19 @@ Browse datasets already loaded into backend memory. Open a dataset to inspect its examples, search text, or filter by seed type. Supported media can be previewed on request. +Use **Import into Chat** to stage an example directly into Chat without copying +text or reattaching files: +- **Single prompt or multimodal message**: loads the text and attachments into the + editable composer. +- **Multi-turn conversation**: opens the conversation editor with all turns intact, + mapping assistant and tool messages to simulated roles. If the conversation ends + in a user turn, earlier turns become prepended history while the final user turn + remains editable in the composer. +- **Import destinations**: choose between starting a new attack, continuing the + current conversation, or creating a new conversation within the current attack. +- **No automatic sending**: importing and saving do not make model calls or execute + tools; edits remain under your control until you explicitly send or save. + Load data through PyRIT before refreshing this catalog. See [datasets](../code/datasets/0_dataset.md) for loading options. diff --git a/frontend/src/App.drafts.test.tsx b/frontend/src/App.drafts.test.tsx index 731215a3b8..127fc7ac49 100644 --- a/frontend/src/App.drafts.test.tsx +++ b/frontend/src/App.drafts.test.tsx @@ -69,6 +69,18 @@ jest.mock('@/components/Layout/MainLayout', () => { content: { text: 'Import for saved main' }, replaceDirty: true, })}>Stage saved import + {handoff.pendingImport ? 'waiting' : 'none'} {location.pathname} {children} @@ -677,4 +689,14 @@ describe('App draft handoff with the real chat and composer', () => { expect(screen.getByRole('link', { name: 'Back to datasets' })).toHaveAttribute('href', '/datasets?q=example&sort=name') expect(screen.getByRole('button', { name: 'Previous page' })).toBeEnabled() }) + + it('should consume a conversation import and open ConversationEditor', async () => { + const user = userEvent.setup() + renderChat('/chat') + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) + await user.click(screen.getByRole('button', { name: 'Stage conversation import' })) + expect(screen.getByLabelText('Pending import')).toHaveTextContent('none') + await waitFor(() => expect(screen.getByText('Imported user turn')).toBeInTheDocument()) + expect(screen.getByText('Imported assistant turn')).toBeInTheDocument() + }) }) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 793c3fb743..6fc7d44582 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -80,6 +80,7 @@ import { routerPathParamValue, scenarioRunProvenance, findingEvidenceOrigin, + parseChatRoute, scenarioRunRoutePath, } from './utils/routeParams' @@ -932,7 +933,15 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { /> } /> } /> - } /> + + } + /> } /> } /> } /> diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index a4dca960d1..8aceeacde3 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -714,6 +714,18 @@ export default function ChatWindow({ target: draftTargetReference !== undefined ? draftTargetReference : activeTarget ? targetReference(activeTarget) : null, converters: converters.snapshot(), + ...(editDraft + ? { + conversation: { + messages: editDraft.messages, + objective: editDraft.objective, + initialObjective: editDraft.initialObjective, + sourceConversationId: editDraft.sourceConversationId, + sourceAttackId: editDraft.sourceAttackId, + temperature: editDraft.temperature, + }, + } + : {}), } const isActiveDraftDirty = isChatDraftDirty(activeDraft) useLayoutEffect(() => { @@ -827,20 +839,48 @@ export default function ChatWindow({ if (!consumed) return handoffRestoredRef.current = preserved.sequence draft = { ...consumed, systemPrompt: consumed.systemPrompt ?? '' } + if (consumed.conversation) { + editor.begin({ + messages: consumed.conversation.messages, + objective: consumed.conversation.objective ?? currentObjective, + initialObjective: consumed.conversation.initialObjective ?? currentObjective, + sourceConversationId: consumed.conversation.sourceConversationId ?? null, + sourceAttackId: consumed.conversation.sourceAttackId ?? draftAttackId, + target: activeTarget, + labels, + }) + } } else { if (!canApplyImportNow || !pending) return const consumed = draftHandoff.consumePendingImport(pending.id, draftAttackId, draftConversationId) if (!consumed) return converters.clearAll() + if (consumed.conversation) { + editor.begin({ + messages: consumed.conversation.messages, + objective: consumed.conversation.objective ?? currentObjective, + initialObjective: consumed.conversation.objective ?? currentObjective, + sourceConversationId: null, + sourceAttackId: draftAttackId, + target: activeTarget, + labels, + }) + if (consumed.conversation.systemPrompt) { + if (systemPrompt && systemPrompt.trim() !== consumed.conversation.systemPrompt.trim()) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setEditorNotice('The imported example includes a system prompt that differs from the existing system prompt. The existing system prompt was preserved.') + } + } + } draft = { text: consumed.content.text, attachments: consumed.content.attachments ?? [], textMetadata: consumed.content.textMetadata, + ...(consumed.conversation?.systemPrompt && !systemPrompt ? { systemPrompt: consumed.conversation.systemPrompt } : {}), } focus = consumed.focus === true } // Claiming a handoff synchronizes the imperative input and its parent-owned draft atomically. - // eslint-disable-next-line react-hooks/set-state-in-effect const attachments = restoreComposerDraft(draft) if (restoringPreserved) { if (draft.converters?.generation === runtime.generation) { @@ -858,6 +898,7 @@ export default function ChatWindow({ canApplyImportNow, pending, draftHandoff, draftAttackId, draftConversationId, converters, isDraftDestinationReady, matchingPreservedDraft, isActiveDraftDirty, runtime.ready, runtime.generation, restoreComposerDraft, restoreConverterSnapshot, restoreConversions, + activeTarget, currentObjective, editor, labels, setEditorNotice, systemPrompt, ]) // Handle conversation selection from the panel diff --git a/frontend/src/components/Datasets/DatasetSelection.tsx b/frontend/src/components/Datasets/DatasetSelection.tsx index 91c3d912df..478603f689 100644 --- a/frontend/src/components/Datasets/DatasetSelection.tsx +++ b/frontend/src/components/Datasets/DatasetSelection.tsx @@ -19,11 +19,16 @@ function shownSelectionKey(selectionKey: string): string { return `${selectionKey.slice(0, MAX_SHOWN_SELECTION_KEY_LENGTH - 1)}…` } +interface DatasetSelectionProps { + activeAttackId?: string | null + activeConversationId?: string | null +} + /** * Selected dataset summary plus a read-only example table. * A missing or repeated selection key does not choose a dataset or request seeds. */ -export default function DatasetSelection() { +export default function DatasetSelection({ activeAttackId, activeConversationId }: DatasetSelectionProps = {}) { const styles = useDatasetSelectionStyles() const headingRef = useRef(null) const retryRef = useRef(null) @@ -79,7 +84,13 @@ export default function DatasetSelection() {
- +
) : ( diff --git a/frontend/src/components/Datasets/SeedExampleBrowser.tsx b/frontend/src/components/Datasets/SeedExampleBrowser.tsx index 6767019100..9e6f5604a0 100644 --- a/frontend/src/components/Datasets/SeedExampleBrowser.tsx +++ b/frontend/src/components/Datasets/SeedExampleBrowser.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useMemo, useRef } from 'react' +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import type { KeyboardEvent } from 'react' import { @@ -18,10 +18,14 @@ import { Text, } from '@fluentui/react-components' import { ArrowSyncRegular, ChevronLeftRegular, ChevronRightRegular, SearchRegular } from '@fluentui/react-icons' +import { useNavigate } from 'react-router' import SearchableMultiCombobox from '@/components/SearchableMultiCombobox' +import { useChatDraftHandoff } from '@/hooks/useChatDraftHandoff' import { useSeedExampleDetail, useSeedExamples } from '@/hooks/useSeedExamples' -import type { DatasetInfo, FilterOption, SeedExampleSummary } from '@/types' +import type { DatasetInfo, FilterOption, ImportDestinationType, SeedExampleSummary } from '@/types' +import { attackConversationRoutePath, attackRoutePath } from '@/utils/routeParams' +import { buildSeedImport } from '@/utils/seedImport' import SeedExampleDetail from './SeedExampleDetail' import { useSeedExampleBrowserStyles } from './SeedExampleBrowser.styles' @@ -47,6 +51,8 @@ const SEARCH_HELP = 'Search looks through stored text prompts and objectives. Si interface SeedExampleBrowserProps { dataset: DatasetInfo onCatalogStale: () => void + activeAttackId?: string | null + activeConversationId?: string | null } function toOptions(values: readonly string[]): FilterOption[] { @@ -90,18 +96,39 @@ function pageSizeChoices(limit: number): number[] { } /** Read-only example table for one loaded dataset. */ -export default function SeedExampleBrowser({ dataset, onCatalogStale }: SeedExampleBrowserProps) { +export default function SeedExampleBrowser({ + dataset, + onCatalogStale, + activeAttackId, + activeConversationId, +}: SeedExampleBrowserProps) { const browserState = useSeedExampleBrowserState(dataset.selection_key) - return + return ( + + ) } export function SeedExampleBrowserView({ dataset, onCatalogStale, browserState, + activeAttackId, + activeConversationId, }: SeedExampleBrowserProps & { browserState: SeedExampleBrowserState }) { const styles = useSeedExampleBrowserStyles() const helpId = useId() + const navigate = useNavigate() + const draftHandoff = useChatDraftHandoff() + const [importError, setImportError] = useState(null) + + const effectiveAttackId = activeAttackId ?? draftHandoff.preservedDraft?.originatingAttackId ?? null + const effectiveConversationId = activeConversationId ?? draftHandoff.preservedDraft?.originatingConversationId ?? null const { query, visitedCursors, generation, commit } = browserState const pageState = useSeedExamples(dataset.selection_key, query, generation) const detailState = useSeedExampleDetail( @@ -229,6 +256,81 @@ export function SeedExampleBrowserView({ buttonRefs.current.get(nextKey)?.focus() } + const handleImport = useCallback( + (destination: ImportDestinationType) => { + setImportError(null) + if (!detailState.detail?.members) return + + const outcome = buildSeedImport(detailState.detail.members) + if (outcome.type === 'unsupported') { + setImportError(outcome.reason) + return + } + + const expectedSeq = draftHandoff.activeSequence + if (outcome.type === 'single_prompt') { + const staged = draftHandoff.stagePendingImport( + { + destinationType: destination, + targetAttackId: destination === 'new_attack' ? null : effectiveAttackId, + targetConversationId: + destination === 'current_conversation' ? effectiveConversationId : null, + content: { + text: outcome.text, + attachments: outcome.attachments, + textMetadata: outcome.textMetadata, + }, + focus: true, + replaceDirty: false, + }, + expectedSeq, + ) + if (!staged) { + setImportError('Import staging was superseded by another action.') + return + } + } else { + const staged = draftHandoff.stagePendingImport( + { + destinationType: destination, + targetAttackId: destination === 'new_attack' ? null : effectiveAttackId, + targetConversationId: + destination === 'current_conversation' ? effectiveConversationId : null, + content: { + text: '', + attachments: [], + }, + conversation: { + messages: outcome.messages, + objective: outcome.objective, + systemPrompt: outcome.systemPrompt, + }, + focus: true, + replaceDirty: false, + }, + expectedSeq, + ) + if (!staged) { + setImportError('Import staging was superseded by another action.') + return + } + } + + if (destination === 'new_attack') { + navigate('/chat') + } else if ( + destination === 'current_conversation' && + effectiveAttackId && + effectiveConversationId + ) { + navigate(attackConversationRoutePath(effectiveAttackId, effectiveConversationId)) + } else if (destination === 'new_conversation' && effectiveAttackId) { + navigate(attackRoutePath(effectiveAttackId)) + } + }, + [detailState.detail, draftHandoff, effectiveAttackId, effectiveConversationId, navigate], + ) + return (
@@ -470,6 +572,10 @@ export function SeedExampleBrowserView({ detail={detailState.detail} headingRef={detailHeadingRef} onRefreshUrls={detailState.refreshDisplayUrls} + hasActiveAttack={Boolean(effectiveAttackId)} + hasActiveConversation={Boolean(effectiveAttackId && effectiveConversationId)} + onImport={handleImport} + importError={importError} /> )}
diff --git a/frontend/src/components/Datasets/SeedExampleDetail.test.tsx b/frontend/src/components/Datasets/SeedExampleDetail.test.tsx index 200fa44435..4054297fc6 100644 --- a/frontend/src/components/Datasets/SeedExampleDetail.test.tsx +++ b/frontend/src/components/Datasets/SeedExampleDetail.test.tsx @@ -437,4 +437,77 @@ describe('SeedExampleDetail', () => { expect(screen.getByRole('button', { name: 'Retry audio' })).toBeInTheDocument() expect(screen.queryByAltText('picture')).not.toBeInTheDocument() }) + + it('renders Import into Chat and invokes onImport with selected destination', async () => { + const user = userEvent.setup() + const handleImport = jest.fn() + + render( + + + , + ) + + const importBtn = screen.getByTestId('import-into-chat-button') + expect(importBtn).toBeInTheDocument() + + await user.click(importBtn) + + const newAttackItem = await screen.findByTestId('import-new-attack-item') + const currentConvItem = await screen.findByTestId('import-current-conversation-item') + const newConvItem = await screen.findByTestId('import-new-conversation-item') + + expect(newAttackItem).not.toBeDisabled() + expect(currentConvItem).not.toBeDisabled() + expect(newConvItem).not.toBeDisabled() + + await user.click(newAttackItem) + expect(handleImport).toHaveBeenCalledWith('new_attack') + }) + + it('disables current conversation and new conversation when there is no active attack', async () => { + const user = userEvent.setup() + const handleImport = jest.fn() + + render( + + + , + ) + + await user.click(screen.getByTestId('import-into-chat-button')) + + const newAttackItem = await screen.findByTestId('import-new-attack-item') + const currentConvItem = await screen.findByTestId('import-current-conversation-item') + const newConvItem = await screen.findByTestId('import-new-conversation-item') + + expect(newAttackItem).not.toBeDisabled() + expect(currentConvItem).toHaveAttribute('aria-disabled', 'true') + expect(newConvItem).toHaveAttribute('aria-disabled', 'true') + }) + + it('shows import error message bar when importError is provided', () => { + render( + + + , + ) + + expect(screen.getByTestId('import-error-bar')).toHaveTextContent( + 'This example contains simulated conversation configuration.', + ) + }) }) diff --git a/frontend/src/components/Datasets/SeedExampleDetail.tsx b/frontend/src/components/Datasets/SeedExampleDetail.tsx index 38febe74cb..1e67d3a38b 100644 --- a/frontend/src/components/Datasets/SeedExampleDetail.tsx +++ b/frontend/src/components/Datasets/SeedExampleDetail.tsx @@ -1,11 +1,23 @@ import { useState } from 'react' import type { ReactNode, Ref } from 'react' -import { Badge, Button, Text } from '@fluentui/react-components' -import { ClipboardRegular } from '@fluentui/react-icons' +import { + Badge, + Button, + Menu, + MenuItem, + MenuList, + MenuPopover, + MenuTrigger, + MessageBar, + MessageBarBody, + Text, + Tooltip, +} from '@fluentui/react-components' +import { ChatRegular, ClipboardRegular } from '@fluentui/react-icons' import MediaPreview, { MediaAccessLink } from '@/components/Media/MediaPreview' -import type { SeedCondition, SeedExampleDetailResponse, SeedRecord } from '@/types' +import type { ImportDestinationType, SeedCondition, SeedExampleDetailResponse, SeedRecord } from '@/types' import { basenameFromValue, mapMediaUrl } from '@/utils/media' import { useSeedExampleDetailStyles } from './SeedExampleDetail.styles' @@ -21,6 +33,10 @@ interface SeedExampleDetailProps { headingRef?: Ref /** Refetch signed display URLs for one member. The stored value stays unchanged. */ onRefreshUrls?: (memberId: string) => Promise + hasActiveAttack?: boolean + hasActiveConversation?: boolean + onImport?: (destination: ImportDestinationType) => void + importError?: string | null } function memberKeys(members: readonly SeedRecord[]): string[] { @@ -248,7 +264,15 @@ function MemberView({ } /** Inspection view for stored members. Text stays text. Media loads only when asked and never plays itself. */ -export default function SeedExampleDetail({ detail, headingRef, onRefreshUrls }: SeedExampleDetailProps) { +export default function SeedExampleDetail({ + detail, + headingRef, + onRefreshUrls, + hasActiveAttack = false, + hasActiveConversation = false, + onImport, + importError, +}: SeedExampleDetailProps) { const styles = useSeedExampleDetailStyles() const [copiedKey, setCopiedKey] = useState(null) const [failedKey, setFailedKey] = useState(null) @@ -290,7 +314,70 @@ export default function SeedExampleDetail({ detail, headingRef, onRefreshUrls }: > {copiedKey === exampleCopyKey ? 'Copied example ID' : 'Copy example ID'} + {onImport ? ( + + + + + + + onImport('new_attack')} + > + Import to new attack + + + onImport('current_conversation')} + > + Import to current conversation + + + + onImport('new_conversation')} + > + Import to new conversation + + + + + + ) : null}
+ {importError ? ( + + {importError} + + ) : null} {failedKey === exampleCopyKey ?

{COPY_FAILED_MESSAGE}

: null} {detail.members.length === 0 ? No stored members were returned. : null} {detail.members.map((member, index) => ( diff --git a/frontend/src/hooks/useChatDraftHandoff.test.tsx b/frontend/src/hooks/useChatDraftHandoff.test.tsx index 29bca6afe7..e89c7bb7cf 100644 --- a/frontend/src/hooks/useChatDraftHandoff.test.tsx +++ b/frontend/src/hooks/useChatDraftHandoff.test.tsx @@ -91,6 +91,23 @@ describe('useChatDraftHandoff and chatDraft utilities', () => { }), ).toBe(true) }) + + it('returns true when conversation draft messages are present', () => { + expect( + isChatDraftDirty({ + text: '', + conversation: { + messages: [ + { + id: 'm1', + role: 'user', + pieces: [{ draftId: 'p1', data_type: 'text', original_value: 'hello' }], + }, + ], + }, + }), + ).toBe(true) + }) }) it('preserves target and unapplied setup without marking prompt content dirty', () => { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 41ae65c7ac..ceb1874e16 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -98,6 +98,15 @@ export interface ChatConverterSnapshot { applied: Record } +export interface ConversationEditState { + messages: ConversationDraftMessage[] + objective?: string + initialObjective?: string + sourceConversationId?: string | null + sourceAttackId?: string | null + temperature?: string +} + export interface ChatDraftContent { text: string attachments: MessageAttachment[] @@ -105,6 +114,7 @@ export interface ChatDraftContent { target?: TargetReference | null converters?: ChatConverterSnapshot textMetadata?: Record + conversation?: ConversationEditState } export interface ChatDraftContextState { @@ -119,6 +129,12 @@ export interface ChatDraftContextState { export type ImportDestinationType = 'new_attack' | 'current_conversation' | 'new_conversation' | 'any' +export interface PendingImportConversation { + messages: ConversationDraftMessage[] + objective?: string + systemPrompt?: string +} + export interface PendingImportPayload { id: string sequence: number @@ -130,6 +146,7 @@ export interface PendingImportPayload { attachments?: MessageAttachment[] textMetadata?: Record } + conversation?: PendingImportConversation replaceDirty?: boolean focus?: boolean stagedAt: number diff --git a/frontend/src/utils/chatDraft.ts b/frontend/src/utils/chatDraft.ts index 6a62986e42..5e050ca18f 100644 --- a/frontend/src/utils/chatDraft.ts +++ b/frontend/src/utils/chatDraft.ts @@ -22,13 +22,14 @@ export function releaseSupersededUrls( revokeAttachmentUrls(previousAttachments?.filter((attachment: MessageAttachment) => !activeUrls.has(attachment.url))) } -/** Determines if draft content contains user-entered text, attachments, system prompt, or converters. */ +/** Determines if draft content contains user-entered text, attachments, system prompt, converters, or conversation draft. */ export function isChatDraftDirty(draft?: Partial | null): boolean { if (!draft) return false if (typeof draft.text === 'string' && draft.text.trim().length > 0) return true if (Array.isArray(draft.attachments) && draft.attachments.length > 0) return true if (typeof draft.systemPrompt === 'string' && draft.systemPrompt.trim().length > 0) return true if (draft.converters?.applied && Object.keys(draft.converters.applied).length > 0) return true + if (draft.conversation?.messages && draft.conversation.messages.length > 0) return true return false } @@ -38,6 +39,7 @@ export function hasChatDraftState(draft?: Partial | null): boo isChatDraftDirty(draft) || draft.target !== undefined || Object.values(draft.converters?.pipelines ?? {}).some((stages: ConverterPipelineStage[]) => stages.length > 0) + || Boolean(draft.conversation) )) } @@ -70,6 +72,12 @@ export function canApplyPendingImport( if (pending.destinationType === 'new_attack') { return attackId === null && conversationId == null } + if (pending.destinationType === 'new_conversation') { + if (pending.targetConversationId) { + return Boolean(pending.targetAttackId && attackId === pending.targetAttackId && conversationId === pending.targetConversationId) + } + return Boolean(pending.targetAttackId && attackId === pending.targetAttackId && conversationId == null) + } return Boolean(pending.targetAttackId && pending.targetConversationId && attackId === pending.targetAttackId && conversationId === pending.targetConversationId) } diff --git a/frontend/src/utils/routeParams.ts b/frontend/src/utils/routeParams.ts index 82ec7e77f7..598f94d9be 100644 --- a/frontend/src/utils/routeParams.ts +++ b/frontend/src/utils/routeParams.ts @@ -162,3 +162,32 @@ export function isChatPath(pathOrUrl: string): boolean { const cleanPath = pathOrUrl.split('?')[0].split('#')[0] return cleanPath === '/chat' || cleanPath.startsWith('/attacks/') } + +/** Parses attackId and conversationId from a chat or attack route path. */ +export function parseChatRoute(path: string): { attackId: string | null; conversationId: string | null } { + if (!path) return { attackId: null, conversationId: null } + const clean = path.split('?')[0].split('#')[0] + const matchWithConv = /^\/attacks\/([^/]+)\/conversations\/([^/]+)/.exec(clean) + if (matchWithConv) { + try { + return { + attackId: decodeURIComponent(matchWithConv[1]), + conversationId: decodeURIComponent(matchWithConv[2]), + } + } catch { + return { attackId: matchWithConv[1], conversationId: matchWithConv[2] } + } + } + const matchAttack = /^\/attacks\/([^/]+)/.exec(clean) + if (matchAttack) { + try { + return { + attackId: decodeURIComponent(matchAttack[1]), + conversationId: null, + } + } catch { + return { attackId: matchAttack[1], conversationId: null } + } + } + return { attackId: null, conversationId: null } +} diff --git a/frontend/src/utils/seedImport.test.ts b/frontend/src/utils/seedImport.test.ts new file mode 100644 index 0000000000..a637fbe9f9 --- /dev/null +++ b/frontend/src/utils/seedImport.test.ts @@ -0,0 +1,288 @@ +import type { SeedRecord } from '@/types' +import { buildSeedImport } from './seedImport' + +function makeSeed(overrides: Partial = {}): SeedRecord { + return { + id: 'seed-1', + seed_type: 'prompt', + value: 'Hello world', + value_sha256: 'hash-1', + data_type: 'text', + name: null, + dataset_name: 'test_dataset', + origin: 'local', + harm_categories: null, + description: null, + authors: null, + groups: null, + source: null, + date_added: '2026-01-01', + added_by: 'tester', + metadata: null, + prompt_group_id: null, + sequence: 0, + role: 'user', + parameters: null, + conditions: null, + response_json_schema: null, + ...overrides, + } +} + +describe('buildSeedImport', () => { + it('returns unsupported for empty members', () => { + const outcome = buildSeedImport([]) + expect(outcome.type).toBe('unsupported') + if (outcome.type === 'unsupported') { + expect(outcome.reason).toMatch(/No stored members/) + } + }) + + it('rejects simulated_conversation configs with clear explanation', () => { + const members: SeedRecord[] = [ + makeSeed({ + id: 'cfg-1', + seed_type: 'simulated_conversation', + value: '{"template": "chat"}', + }), + ] + const outcome = buildSeedImport(members) + expect(outcome.type).toBe('unsupported') + if (outcome.type === 'unsupported') { + expect(outcome.reason).toMatch(/simulated conversation configuration/) + } + }) + + it('rejects examples that contain only objective records', () => { + const members: SeedRecord[] = [ + makeSeed({ + id: 'obj-1', + seed_type: 'objective', + value: 'Obtain admin privileges', + role: null, + }), + ] + const outcome = buildSeedImport(members) + expect(outcome.type).toBe('unsupported') + if (outcome.type === 'unsupported') { + expect(outcome.reason).toMatch(/only objective records/) + } + }) + + it('builds a single prompt import for a single user text prompt', () => { + const members: SeedRecord[] = [ + makeSeed({ + id: 'p-1', + value: 'Give me the instructions', + role: 'user', + }), + makeSeed({ + id: 'obj-1', + seed_type: 'objective', + value: 'Safety test objective', + role: null, + }), + ] + const outcome = buildSeedImport(members) + expect(outcome.type).toBe('single_prompt') + if (outcome.type === 'single_prompt') { + expect(outcome.text).toBe('Give me the instructions') + expect(outcome.attachments).toHaveLength(0) + expect(outcome.objective).toBe('Safety test objective') + } + }) + + it('builds a single prompt with attachments for multimodal prompt', () => { + const members: SeedRecord[] = [ + makeSeed({ + id: 'p-text', + data_type: 'text', + value: 'What is in this image?', + role: 'user', + }), + makeSeed({ + id: 'p-img', + data_type: 'image_path', + value: 'sample.png', + value_url: 'https://storage/sample.png?token=expiring', + role: 'user', + }), + ] + const outcome = buildSeedImport(members) + expect(outcome.type).toBe('single_prompt') + if (outcome.type === 'single_prompt') { + expect(outcome.text).toBe('What is in this image?') + expect(outcome.attachments).toHaveLength(1) + expect(outcome.attachments[0].type).toBe('image') + expect(outcome.attachments[0].sourceValue).toBe('sample.png') + expect(outcome.attachments[0].url).toBe('https://storage/sample.png?token=expiring') + // source_piece_id must NOT be set to seed ID + expect(outcome.attachments[0].pieceId).toBeUndefined() + } + }) + + it('correctly maps assistant and tool turns to simulated roles in multi-turn conversation', () => { + const members: SeedRecord[] = [ + makeSeed({ + id: 'p-1', + value: 'Initial user turn', + role: 'user', + sequence: 0, + prompt_group_id: 'group-0', + }), + makeSeed({ + id: 'p-2', + value: 'Assistant answer', + role: 'assistant', + sequence: 1, + prompt_group_id: 'group-1', + }), + makeSeed({ + id: 'p-3', + value: 'Follow-up user turn', + role: 'user', + sequence: 2, + prompt_group_id: 'group-2', + }), + ] + const outcome = buildSeedImport(members) + expect(outcome.type).toBe('conversation') + if (outcome.type === 'conversation') { + expect(outcome.messages).toHaveLength(3) + expect(outcome.messages[0].role).toBe('user') + expect(outcome.messages[1].role).toBe('simulated_assistant') + expect(outcome.messages[2].role).toBe('user') + + // Check invariants on pieces + for (const msg of outcome.messages) { + for (const piece of msg.pieces) { + expect(piece.source_piece_id).toBeUndefined() + } + } + } + }) + + it('rejects ambiguous missing roles across multiple members', () => { + const members: SeedRecord[] = [ + makeSeed({ + id: 'p-1', + value: 'Turn 1', + role: 'user', + }), + makeSeed({ + id: 'p-2', + value: 'Turn 2 without role', + role: null, + }), + ] + const outcome = buildSeedImport(members) + expect(outcome.type).toBe('unsupported') + if (outcome.type === 'unsupported') { + expect(outcome.reason).toMatch(/ambiguous/) + } + }) + + it('rejects conflicting roles within the same prompt group', () => { + const members: SeedRecord[] = [ + makeSeed({ + id: 'p-1', + value: 'Piece 1', + role: 'user', + prompt_group_id: 'group-1', + }), + makeSeed({ + id: 'p-2', + value: 'Piece 2', + role: 'assistant', + prompt_group_id: 'group-1', + }), + ] + const outcome = buildSeedImport(members) + expect(outcome.type).toBe('unsupported') + if (outcome.type === 'unsupported') { + expect(outcome.reason).toMatch(/conflicting roles/) + } + }) + + it('groups multiple pieces in the same prompt group into one turn', () => { + const members: SeedRecord[] = [ + makeSeed({ + id: 'p-1', + data_type: 'text', + value: 'Look at this file and describe it', + role: 'user', + sequence: 0, + prompt_group_id: 'group-1', + }), + makeSeed({ + id: 'p-2', + data_type: 'image_path', + value: 'chart.png', + role: 'user', + sequence: 1, + prompt_group_id: 'group-1', + }), + makeSeed({ + id: 'p-3', + data_type: 'text', + value: 'Sure, here is the explanation', + role: 'assistant', + sequence: 2, + prompt_group_id: 'group-2', + }), + ] + const outcome = buildSeedImport(members) + expect(outcome.type).toBe('conversation') + if (outcome.type === 'conversation') { + expect(outcome.messages).toHaveLength(2) + expect(outcome.messages[0].role).toBe('user') + expect(outcome.messages[0].pieces).toHaveLength(2) + expect(outcome.messages[0].pieces[0].data_type).toBe('text') + expect(outcome.messages[0].pieces[1].data_type).toBe('image_path') + expect(outcome.messages[1].role).toBe('simulated_assistant') + expect(outcome.messages[1].pieces).toHaveLength(1) + } + }) + + it('extracts system prompt when conversation starts with a system turn', () => { + const members: SeedRecord[] = [ + makeSeed({ + id: 'p-sys', + data_type: 'text', + value: 'You are a helpful assistant.', + role: 'system', + sequence: 0, + }), + makeSeed({ + id: 'p-user', + data_type: 'text', + value: 'Hello!', + role: 'user', + sequence: 1, + }), + ] + const outcome = buildSeedImport(members) + expect(outcome.type).toBe('conversation') + if (outcome.type === 'conversation') { + expect(outcome.systemPrompt).toBe('You are a helpful assistant.') + expect(outcome.messages).toHaveLength(2) + expect(outcome.messages[0].role).toBe('system') + expect(outcome.messages[1].role).toBe('user') + } + }) + + it('rejects unrecognized role values with clear explanation', () => { + const members: SeedRecord[] = [ + makeSeed({ + id: 'p-invalid', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + role: 'unknown_alien_role' as any, + }), + ] + const outcome = buildSeedImport(members) + expect(outcome.type).toBe('unsupported') + if (outcome.type === 'unsupported') { + expect(outcome.reason).toMatch(/unsupported role/) + } + }) +}) diff --git a/frontend/src/utils/seedImport.ts b/frontend/src/utils/seedImport.ts new file mode 100644 index 0000000000..6dd58dac0f --- /dev/null +++ b/frontend/src/utils/seedImport.ts @@ -0,0 +1,289 @@ +import type { + ChatMessageRole, + ConversationDraftMessage, + ConversationDraftPiece, + ConversationDraftRole, + MessageAttachment, + SeedRecord, +} from '@/types' +import { generateClientId } from './clientId' +import { draftAttachment } from './conversationDraft' +import { basenameFromValue, isPathDataType } from './media' + +export interface SinglePromptImport { + type: 'single_prompt' + text: string + attachments: MessageAttachment[] + textMetadata?: Record + objective?: string +} + +export interface ConversationImport { + type: 'conversation' + messages: ConversationDraftMessage[] + objective?: string + systemPrompt?: string +} + +export interface UnsupportedImport { + type: 'unsupported' + reason: string +} + +export type SeedImportOutcome = SinglePromptImport | ConversationImport | UnsupportedImport + +/** + * Maps a stored SeedRecord role to a conversation draft role. + * Imported assistant and tool messages strictly use simulated roles. + */ +function mapToDraftRole(role: ChatMessageRole | null): ConversationDraftRole | null { + if (!role) return null + switch (role) { + case 'user': + return 'user' + case 'assistant': + case 'simulated_assistant': + return 'simulated_assistant' + case 'system': + return 'system' + case 'developer': + return 'developer' + case 'tool': + case 'simulated_tool': + return 'simulated_tool' + default: + return null + } +} + +/** + * Inspects a list of SeedRecords belonging to one logical example and produces + * either a single prompt (for the composer), a conversation draft (for the ConversationEditor), + * or an unsupported error with an explicit explanation. + * + * Strict invariants: + * 1. Never makes network or model calls. + * 2. Does not execute or generate simulated conversations. + * 3. Seed IDs are never assigned to source_piece_id. + * 4. Stored value remains the durable reference; value_url is preview only. + * 5. Does not guess ambiguous roles or sequences. + */ +export function buildSeedImport(members: readonly SeedRecord[]): SeedImportOutcome { + if (!members || members.length === 0) { + return { + type: 'unsupported', + reason: 'No stored members were returned for this example.', + } + } + + // 1. Simulated conversation configuration guard + if (members.some((m) => m.seed_type === 'simulated_conversation')) { + return { + type: 'unsupported', + reason: + 'This example contains a simulated conversation configuration that requires generation. It cannot be imported directly into chat.', + } + } + + // 2. Extract objective metadata + const objectiveRecords = members.filter((m) => m.seed_type === 'objective') + const objective = objectiveRecords + .map((m) => m.value?.trim()) + .filter(Boolean) + .join('\n\n') || undefined + + // 3. Extract prompt members + const promptRecords = members.filter((m) => m.seed_type === 'prompt') + if (promptRecords.length === 0) { + return { + type: 'unsupported', + reason: 'This example contains only objective records without any prompt content to import.', + } + } + + // 4. Validate roles & check for ambiguity + const hasRoles = promptRecords.map((m) => m.role) + const nonNullRoles = hasRoles.filter((r): r is ChatMessageRole => r !== null && r !== undefined) + + if (promptRecords.length > 1) { + if (nonNullRoles.length > 0 && nonNullRoles.length < promptRecords.length) { + return { + type: 'unsupported', + reason: 'Some prompt members are missing conversation roles, making the turn sequence ambiguous.', + } + } + } + + // 5. Build raw draft pieces + // Group pieces into logical turns: + // - If prompt_group_id is present, group by prompt_group_id. + // - If prompt_group_id is missing, group by consecutive matching roles or single turn if single member. + interface GroupedTurn { + role: ConversationDraftRole + groupId: string | null + sequence: number + pieces: ConversationDraftPiece[] + } + + const turns: GroupedTurn[] = [] + + // Check if prompt_group_id is used + const hasGroupIds = promptRecords.some((m) => Boolean(m.prompt_group_id)) + + if (hasGroupIds) { + const groupMap = new Map() + for (const record of promptRecords) { + const gId = record.prompt_group_id || `turn-${record.id}` + const existing = groupMap.get(gId) ?? [] + existing.push(record) + groupMap.set(gId, existing) + } + + for (const [gId, recordsInGroup] of groupMap.entries()) { + // Validate roles within group are consistent + const groupRoles = recordsInGroup + .map((r) => r.role) + .filter((r): r is ChatMessageRole => r !== null && r !== undefined) + const mappedGroupRoles = groupRoles.map(mapToDraftRole) + + if (mappedGroupRoles.some((r) => r === null)) { + return { + type: 'unsupported', + reason: `Group ${gId} contains unsupported role values.`, + } + } + + const distinctRoles = new Set(mappedGroupRoles) + if (distinctRoles.size > 1) { + return { + type: 'unsupported', + reason: `Members within the prompt group '${gId}' have conflicting roles.`, + } + } + + const finalRole = mappedGroupRoles[0] ?? 'user' + // Sort pieces by sequence within group + const sortedRecords = [...recordsInGroup].sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)) + const minSeq = Math.min(...recordsInGroup.map((r) => r.sequence ?? 0)) + + const pieces: ConversationDraftPiece[] = sortedRecords.map((record) => ({ + draftId: generateClientId(), + data_type: record.data_type, + original_value: record.value, + mime_type: undefined, + previewUrl: record.value_url ?? undefined, + filename: record.name ?? (isPathDataType(record.data_type) ? basenameFromValue(record.value, record.data_type) : undefined), + prompt_metadata: record.metadata ?? undefined, + source_piece_id: undefined, // Never use seed ID as chat piece ID + })) + + turns.push({ + role: finalRole, + groupId: gId, + sequence: minSeq, + pieces, + }) + } + + turns.sort((a, b) => a.sequence - b.sequence) + } else { + // No prompt_group_id: group records into turns by role and sequence. + // If consecutive records share the same role and sequence (e.g. text + image in a single turn), + // they are combined as pieces within that turn. + let currentTurn: GroupedTurn | null = null + for (const record of promptRecords) { + const mappedRole = mapToDraftRole(record.role) ?? (nonNullRoles.length === 0 ? 'user' : null) + if (!mappedRole) { + return { + type: 'unsupported', + reason: `Record ${record.id} has an unsupported role '${record.role}'.`, + } + } + + const piece: ConversationDraftPiece = { + draftId: generateClientId(), + data_type: record.data_type, + original_value: record.value, + mime_type: undefined, + previewUrl: record.value_url ?? undefined, + filename: + record.name ?? + (isPathDataType(record.data_type) + ? basenameFromValue(record.value, record.data_type) + : undefined), + prompt_metadata: record.metadata ?? undefined, + source_piece_id: undefined, + } + + const matchesCurrentTurn = + currentTurn && + currentTurn.role === mappedRole && + (record.sequence === null || + currentTurn.sequence === record.sequence || + promptRecords.every((r) => (r.role ?? 'user') === 'user')) + + if (matchesCurrentTurn && currentTurn) { + currentTurn.pieces.push(piece) + } else { + currentTurn = { + role: mappedRole, + groupId: null, + sequence: record.sequence ?? 0, + pieces: [piece], + } + turns.push(currentTurn) + } + } + turns.sort((a, b) => a.sequence - b.sequence) + } + + if (turns.length === 0) { + return { + type: 'unsupported', + reason: 'No valid turns could be constructed from this example.', + } + } + + // 6. Check if this is a Single Prompt vs. Multi-turn Conversation + // Single prompt: exactly 1 turn, role is 'user' + if (turns.length === 1 && turns[0].role === 'user') { + const turn = turns[0] + const textPieces = turn.pieces.filter((p) => !isPathDataType(p.data_type)) + const mediaPieces = turn.pieces.filter((p) => isPathDataType(p.data_type)) + + const text = textPieces.map((p) => p.original_value).join('\n\n') + const attachments: MessageAttachment[] = mediaPieces.map(draftAttachment) + const textMetadata = textPieces[0]?.prompt_metadata + + return { + type: 'single_prompt', + text, + attachments, + textMetadata, + objective, + } + } + + // Multi-turn or single non-user turn -> Conversation + // Extract system prompt if first turn is system or developer + let systemPrompt: string | undefined + if (turns[0].role === 'system' || turns[0].role === 'developer') { + const sysPieces = turns[0].pieces.filter((p) => !isPathDataType(p.data_type)) + if (sysPieces.length > 0) { + systemPrompt = sysPieces.map((p) => p.original_value).join('\n\n') + } + } + + const messages: ConversationDraftMessage[] = turns.map((turn) => ({ + id: generateClientId(), + role: turn.role, + pieces: turn.pieces, + })) + + return { + type: 'conversation', + messages, + objective, + systemPrompt, + } +} From 5ef7edd837e5dca2ebe06ad345b4c32280b093d8 Mon Sep 17 00:00:00 2001 From: Nimit Jain Date: Sun, 1 Nov 2026 16:11:58 +0530 Subject: [PATCH 2/5] fix(gui): address review comments for dataset prompt and conversation import - Group conversation turns by sequence rather than prompt_group_id (#3670) - Populate composer with template prompt and avoid collision with clean preserved drafts - Add ConfirmDialog before navigation when destination has an unsent dirty draft - Prepend imported turns to existing history when importing into current conversation - Associate editor with viewed conversation ID to prevent lifecycle discard - Start imported turns as unsaved changes to protect against accidental dismissal - Round-trip editor target and temperature in conversation edit state - Create explicit conversation before navigating for new_conversation destination - Add comprehensive integration tests covering template import from clean chat --- frontend/src/App.drafts.test.tsx | 62 ++++++ frontend/src/components/Chat/ChatWindow.tsx | 73 +++++-- .../Datasets/SeedExampleBrowser.tsx | 150 +++++++++----- frontend/src/hooks/useConversationDraft.ts | 3 +- frontend/src/types/index.ts | 3 + frontend/src/utils/seedImport.test.ts | 149 +++++++++---- frontend/src/utils/seedImport.ts | 195 ++++++++++-------- 7 files changed, 442 insertions(+), 193 deletions(-) diff --git a/frontend/src/App.drafts.test.tsx b/frontend/src/App.drafts.test.tsx index 127fc7ac49..502f80adbb 100644 --- a/frontend/src/App.drafts.test.tsx +++ b/frontend/src/App.drafts.test.tsx @@ -81,6 +81,30 @@ jest.mock('@/components/Layout/MainLayout', () => { }, replaceDirty: true, })}>Stage conversation import + + {handoff.pendingImport ? 'waiting' : 'none'} {location.pathname} {children} @@ -699,4 +723,42 @@ describe('App draft handoff with the real chat and composer', () => { await waitFor(() => expect(screen.getByText('Imported user turn')).toBeInTheDocument()) expect(screen.getByText('Imported assistant turn')).toBeInTheDocument() }) + + it('should populate composer with template prompt after visiting datasets from clean chat (Roman test)', async () => { + const user = userEvent.setup() + renderChat('/chat') + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) + expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('') + + // User visits datasets from clean chat + await visitDatasets(user) + await screen.findByRole('heading', { name: 'Dataset catalog' }) + + // User stages template import to new attack + await user.click(screen.getByRole('button', { name: 'Stage wikipedia template import' })) + + // User navigates back to chat + await returnToChat(user) + + // The prompt box MUST contain the template text without sending + await waitFor(() => { + expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue( + 'Write a wikipedia article on {{ prompt }}. Start the article with "Title:".', + ) + }) + expect(screen.getByLabelText('Pending import')).toHaveTextContent('none') + }) + + it('should prepend imported turns to existing saved conversation messages', async () => { + const user = userEvent.setup() + renderChat('/attacks/saved/conversations/main') + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) + + await user.click(screen.getByRole('button', { name: 'Stage saved conversation import' })) + + // Editor opens with prepended assistant turn + await waitFor(() => expect(screen.getByText('Prepended assistant turn')).toBeInTheDocument()) + const promptInputs = screen.getAllByPlaceholderText('Type prompt here') + expect(promptInputs[promptInputs.length - 1]).toHaveValue('Unsent user prompt') + }) }) diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 8aceeacde3..732645ec96 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -57,7 +57,7 @@ import { hasChatDraftState, isChatDraftDirty, } from '@/utils/chatDraft' -import { targetReference } from '@/utils/targetIdentity' +import { resolveTargetReference, targetReference } from '@/utils/targetIdentity' import type { PieceConversion } from './converterTypes' import { useChatConverters } from '@/hooks/useChatConverters' import { useRuntime } from '@/hooks/useRuntime' @@ -95,6 +95,7 @@ import type { ChatDraftContent, ChatDraftContextState, ConversationMessagesResponse, + ConversationDraftMessage, ConversationDraftPiece, ConverterInputPiece, ConverterPipelineStage, @@ -528,6 +529,7 @@ export default function ChatWindow({ const pendingUserMessagesRef = useRef>(new Map()) const pendingSendsRef = useRef>(new Map()) const loadedUserPieceIdsRef = useRef>>(new Map()) + const loadedBackendMessagesRef = useRef>(new Map()) const isSendInFlight = useCallback( (convId: string): boolean => sendingConvIdsRef.current.has(convId), [], @@ -538,6 +540,9 @@ export default function ChatWindow({ setConversationScoringFailure(response.scoring_failure ?? null) const savedUserIds = userPieceIds(response) loadedUserPieceIdsRef.current.set(convId, savedUserIds) + if (response.messages) { + loadedBackendMessagesRef.current.set(convId, response.messages) + } const persistedRecovery = getPersistedProcessingRecovery(convId, response) setRecoverableSends((currentRecoveries) => { const currentRecovery = currentRecoveries[convId] @@ -723,6 +728,7 @@ export default function ChatWindow({ sourceConversationId: editDraft.sourceConversationId, sourceAttackId: editDraft.sourceAttackId, temperature: editDraft.temperature, + target: editDraft.target ? targetReference(editDraft.target) : null, }, } : {}), @@ -813,7 +819,7 @@ export default function ChatWindow({ pending && isDraftDestinationReady && runtime.ready - && matchingPreservedDraft === null + && (matchingPreservedDraft === null || pending.replaceDirty || !draftHandoff.isDraftDirty(matchingPreservedDraft)) && canApplyPendingImport(pending, draftAttackId, draftConversationId) && pending.sequence >= draftHandoff.activeSequence && (!isActiveDraftDirty || pending.replaceDirty), @@ -827,7 +833,8 @@ export default function ChatWindow({ if (!runtime.ready || !isDraftDestinationReady || !inputBoxRef.current) return const preserved = draftHandoff.preservedDraft const restoringPreserved = Boolean(matchingPreservedDraft && preserved - && handoffRestoredRef.current !== preserved.sequence) + && handoffRestoredRef.current !== preserved.sequence + && !canApplyImportNow) let draft: ChatDraftContent let focus = false if (restoringPreserved && preserved) { @@ -840,13 +847,17 @@ export default function ChatWindow({ handoffRestoredRef.current = preserved.sequence draft = { ...consumed, systemPrompt: consumed.systemPrompt ?? '' } if (consumed.conversation) { + const editorTarget = consumed.conversation.target + ? resolveTargetReference(consumed.conversation.target, availableTargets) ?? activeTarget + : activeTarget editor.begin({ messages: consumed.conversation.messages, objective: consumed.conversation.objective ?? currentObjective, initialObjective: consumed.conversation.initialObjective ?? currentObjective, sourceConversationId: consumed.conversation.sourceConversationId ?? null, sourceAttackId: consumed.conversation.sourceAttackId ?? draftAttackId, - target: activeTarget, + target: editorTarget, + temperature: consumed.conversation.temperature ?? '', labels, }) } @@ -854,23 +865,46 @@ export default function ChatWindow({ if (!canApplyImportNow || !pending) return const consumed = draftHandoff.consumePendingImport(pending.id, draftAttackId, draftConversationId) if (!consumed) return + if (preserved) { + draftHandoff.clearPreservedDraft(preserved.sequence) + } converters.clearAll() if (consumed.conversation) { - editor.begin({ - messages: consumed.conversation.messages, - objective: consumed.conversation.objective ?? currentObjective, - initialObjective: consumed.conversation.objective ?? currentObjective, - sourceConversationId: null, - sourceAttackId: draftAttackId, - target: activeTarget, - labels, - }) - if (consumed.conversation.systemPrompt) { - if (systemPrompt && systemPrompt.trim() !== consumed.conversation.systemPrompt.trim()) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setEditorNotice('The imported example includes a system prompt that differs from the existing system prompt. The existing system prompt was preserved.') + const stageConversationImport = async () => { + let existingTurns: ConversationDraftMessage[] = [] + if (consumed.destinationType === 'current_conversation' && draftConversationId && draftAttackId) { + try { + let backendMsgs = loadedBackendMessagesRef.current.get(draftConversationId) + if (!backendMsgs || backendMsgs.length === 0) { + const res = await attacksApi.getMessages(draftAttackId, draftConversationId) + backendMsgs = res?.messages ?? [] + if (backendMsgs.length > 0) { + loadedBackendMessagesRef.current.set(draftConversationId, backendMsgs) + } + } + existingTurns = toConversationDraft(backendMsgs) + } catch { + existingTurns = [] + } + } + const allMessages = [...consumed.conversation!.messages, ...existingTurns] + editor.begin({ + messages: allMessages, + baselineMessages: [], + objective: consumed.conversation!.objective ?? currentObjective, + initialObjective: consumed.conversation!.objective ?? currentObjective, + sourceConversationId: draftConversationId ?? null, + sourceAttackId: draftAttackId, + target: activeTarget, + labels, + }) + if (consumed.conversation!.systemPrompt) { + if (systemPrompt && systemPrompt.trim() !== consumed.conversation!.systemPrompt.trim()) { + setEditorNotice('The imported example includes a system prompt that differs from the existing system prompt. The existing system prompt was preserved.') + } } } + void stageConversationImport() } draft = { text: consumed.content.text, @@ -898,7 +932,7 @@ export default function ChatWindow({ canApplyImportNow, pending, draftHandoff, draftAttackId, draftConversationId, converters, isDraftDestinationReady, matchingPreservedDraft, isActiveDraftDirty, runtime.ready, runtime.generation, restoreComposerDraft, restoreConverterSnapshot, restoreConversions, - activeTarget, currentObjective, editor, labels, setEditorNotice, systemPrompt, + activeTarget, currentObjective, editor, labels, setEditorNotice, systemPrompt, availableTargets, ]) // Handle conversation selection from the panel @@ -1016,6 +1050,9 @@ export default function ChatWindow({ } : recovery if (isLatestRead) { loadedUserPieceIdsRef.current.set(effectiveConvId, userPieceIds(response.messages)) + if (response.messages?.messages) { + loadedBackendMessagesRef.current.set(effectiveConvId, response.messages.messages) + } setRecoverableSends((currentRecoveries) => { const next = { ...currentRecoveries } if (recoveredDraft) { next[effectiveConvId] = recoveredDraft } else { delete next[effectiveConvId] } diff --git a/frontend/src/components/Datasets/SeedExampleBrowser.tsx b/frontend/src/components/Datasets/SeedExampleBrowser.tsx index 9e6f5604a0..7172510a1d 100644 --- a/frontend/src/components/Datasets/SeedExampleBrowser.tsx +++ b/frontend/src/components/Datasets/SeedExampleBrowser.tsx @@ -20,11 +20,14 @@ import { import { ArrowSyncRegular, ChevronLeftRegular, ChevronRightRegular, SearchRegular } from '@fluentui/react-icons' import { useNavigate } from 'react-router' +import ConfirmDialog from '@/components/ConfirmDialog' import SearchableMultiCombobox from '@/components/SearchableMultiCombobox' import { useChatDraftHandoff } from '@/hooks/useChatDraftHandoff' import { useSeedExampleDetail, useSeedExamples } from '@/hooks/useSeedExamples' +import { attacksApi } from '@/services/api' +import { toApiError } from '@/services/errors' import type { DatasetInfo, FilterOption, ImportDestinationType, SeedExampleSummary } from '@/types' -import { attackConversationRoutePath, attackRoutePath } from '@/utils/routeParams' +import { attackConversationRoutePath } from '@/utils/routeParams' import { buildSeedImport } from '@/utils/seedImport' import SeedExampleDetail from './SeedExampleDetail' @@ -126,6 +129,7 @@ export function SeedExampleBrowserView({ const navigate = useNavigate() const draftHandoff = useChatDraftHandoff() const [importError, setImportError] = useState(null) + const [pendingDirtyDestination, setPendingDirtyDestination] = useState(null) const effectiveAttackId = activeAttackId ?? draftHandoff.preservedDraft?.originatingAttackId ?? null const effectiveConversationId = activeConversationId ?? draftHandoff.preservedDraft?.originatingConversationId ?? null @@ -256,8 +260,26 @@ export function SeedExampleBrowserView({ buttonRefs.current.get(nextKey)?.focus() } - const handleImport = useCallback( - (destination: ImportDestinationType) => { + const isDestinationDirty = useCallback( + (destination: ImportDestinationType): boolean => { + const preserved = draftHandoff.preservedDraft + if (!preserved || !draftHandoff.isDraftDirty(preserved.draft)) return false + if (destination === 'new_attack') { + return preserved.originatingAttackId === null + } + if (destination === 'current_conversation') { + return ( + preserved.originatingAttackId === effectiveAttackId && + preserved.originatingConversationId === effectiveConversationId + ) + } + return false + }, + [draftHandoff, effectiveAttackId, effectiveConversationId], + ) + + const executeImport = useCallback( + async (destination: ImportDestinationType, replaceDirty: boolean) => { setImportError(null) if (!detailState.detail?.members) return @@ -267,53 +289,53 @@ export function SeedExampleBrowserView({ return } - const expectedSeq = draftHandoff.activeSequence - if (outcome.type === 'single_prompt') { - const staged = draftHandoff.stagePendingImport( - { - destinationType: destination, - targetAttackId: destination === 'new_attack' ? null : effectiveAttackId, - targetConversationId: - destination === 'current_conversation' ? effectiveConversationId : null, - content: { - text: outcome.text, - attachments: outcome.attachments, - textMetadata: outcome.textMetadata, - }, - focus: true, - replaceDirty: false, - }, - expectedSeq, - ) - if (!staged) { - setImportError('Import staging was superseded by another action.') + let targetConversationId: string | null = + destination === 'current_conversation' ? effectiveConversationId : null + + if (destination === 'new_conversation' && effectiveAttackId) { + try { + const newConv = await attacksApi.createConversation(effectiveAttackId, {}) + targetConversationId = newConv.conversation_id + } catch (e) { + setImportError(toApiError(e).detail) return } - } else { - const staged = draftHandoff.stagePendingImport( - { - destinationType: destination, - targetAttackId: destination === 'new_attack' ? null : effectiveAttackId, - targetConversationId: - destination === 'current_conversation' ? effectiveConversationId : null, - content: { - text: '', - attachments: [], - }, - conversation: { - messages: outcome.messages, - objective: outcome.objective, - systemPrompt: outcome.systemPrompt, - }, - focus: true, - replaceDirty: false, + } + + // If preserved draft is clean or user confirmed replacing it, clear it so it does not collide + if (draftHandoff.preservedDraft && (!draftHandoff.isDraftDirty(draftHandoff.preservedDraft.draft) || replaceDirty)) { + draftHandoff.clearPreservedDraft() + } + + const expectedSeq = draftHandoff.activeSequence + const staged = draftHandoff.stagePendingImport( + { + destinationType: destination, + targetAttackId: destination === 'new_attack' ? null : effectiveAttackId, + targetConversationId, + content: { + text: outcome.type === 'single_prompt' ? outcome.text : (outcome.composerDraft?.text ?? ''), + attachments: outcome.type === 'single_prompt' ? outcome.attachments : (outcome.composerDraft?.attachments ?? []), + textMetadata: outcome.type === 'single_prompt' ? outcome.textMetadata : outcome.composerDraft?.textMetadata, }, - expectedSeq, - ) - if (!staged) { - setImportError('Import staging was superseded by another action.') - return - } + ...(outcome.type === 'conversation' + ? { + conversation: { + messages: outcome.messages, + objective: outcome.objective, + systemPrompt: outcome.systemPrompt, + }, + } + : {}), + focus: true, + replaceDirty, + }, + expectedSeq, + ) + + if (!staged) { + setImportError('Import staging was superseded by another action.') + return } if (destination === 'new_attack') { @@ -324,13 +346,25 @@ export function SeedExampleBrowserView({ effectiveConversationId ) { navigate(attackConversationRoutePath(effectiveAttackId, effectiveConversationId)) - } else if (destination === 'new_conversation' && effectiveAttackId) { - navigate(attackRoutePath(effectiveAttackId)) + } else if (destination === 'new_conversation' && effectiveAttackId && targetConversationId) { + navigate(attackConversationRoutePath(effectiveAttackId, targetConversationId)) } }, [detailState.detail, draftHandoff, effectiveAttackId, effectiveConversationId, navigate], ) + const handleImport = useCallback( + (destination: ImportDestinationType) => { + setImportError(null) + if (isDestinationDirty(destination)) { + setPendingDirtyDestination(destination) + return + } + executeImport(destination, false) + }, + [executeImport, isDestinationDirty], + ) + return (
@@ -578,6 +612,24 @@ export function SeedExampleBrowserView({ importError={importError} /> )} + { + const dest = pendingDirtyDestination + setPendingDirtyDestination(null) + if (dest) { + executeImport(dest, true) + } + }} + onCancel={() => { + setPendingDirtyDestination(null) + }} + > + You have unsent changes at this destination. Importing this example will replace your unsent draft. +
) } diff --git a/frontend/src/hooks/useConversationDraft.ts b/frontend/src/hooks/useConversationDraft.ts index aa23ed36b3..8e73da5d63 100644 --- a/frontend/src/hooks/useConversationDraft.ts +++ b/frontend/src/hooks/useConversationDraft.ts @@ -62,7 +62,8 @@ export function useConversationDraft( saved.current = false setError(null) setDraft({ - ...input, id, baselineMessages: input.messages, baselineTarget: targetKey(input.target), temperature: '', + ...input, id, baselineMessages: input.baselineMessages ?? input.messages, + baselineTarget: targetKey(input.target), temperature: input.temperature ?? '', sourcePieceIds: input.messages.flatMap((message: ConversationDraftMessage) => message.pieces.flatMap( (piece: ConversationDraftPiece) => piece.source_piece_id ? [piece.source_piece_id] : [], )), diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ceb1874e16..26960319d4 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -105,6 +105,7 @@ export interface ConversationEditState { sourceConversationId?: string | null sourceAttackId?: string | null temperature?: string + target?: TargetReference | null } export interface ChatDraftContent { @@ -1014,6 +1015,8 @@ export interface ConversationSaveInput { sourceAttackId: string | null sourceConversationId: string | null labels?: Record + baselineMessages?: ConversationDraftMessage[] + temperature?: string } export interface NewAttackContext { diff --git a/frontend/src/utils/seedImport.test.ts b/frontend/src/utils/seedImport.test.ts index a637fbe9f9..6f7cd49467 100644 --- a/frontend/src/utils/seedImport.test.ts +++ b/frontend/src/utils/seedImport.test.ts @@ -19,7 +19,7 @@ function makeSeed(overrides: Partial = {}): SeedRecord { date_added: '2026-01-01', added_by: 'tester', metadata: null, - prompt_group_id: null, + prompt_group_id: 'shared-example-group', sequence: 0, role: 'user', parameters: null, @@ -75,6 +75,7 @@ describe('buildSeedImport', () => { id: 'p-1', value: 'Give me the instructions', role: 'user', + sequence: 0, }), makeSeed({ id: 'obj-1', @@ -92,13 +93,14 @@ describe('buildSeedImport', () => { } }) - it('builds a single prompt with attachments for multimodal prompt', () => { + it('builds a single prompt with attachments for multimodal prompt in a single turn', () => { const members: SeedRecord[] = [ makeSeed({ id: 'p-text', data_type: 'text', value: 'What is in this image?', role: 'user', + sequence: 0, }), makeSeed({ id: 'p-img', @@ -106,6 +108,7 @@ describe('buildSeedImport', () => { value: 'sample.png', value_url: 'https://storage/sample.png?token=expiring', role: 'user', + sequence: 0, }), ] const outcome = buildSeedImport(members) @@ -116,44 +119,36 @@ describe('buildSeedImport', () => { expect(outcome.attachments[0].type).toBe('image') expect(outcome.attachments[0].sourceValue).toBe('sample.png') expect(outcome.attachments[0].url).toBe('https://storage/sample.png?token=expiring') - // source_piece_id must NOT be set to seed ID expect(outcome.attachments[0].pieceId).toBeUndefined() } }) - it('correctly maps assistant and tool turns to simulated roles in multi-turn conversation', () => { + it('groups turns by sequence with one shared group ID and maps simulated roles (Comment 1)', () => { + // Normal SeedGroup: shared prompt_group_id across the example, distinct sequence per turn const members: SeedRecord[] = [ makeSeed({ id: 'p-1', value: 'Initial user turn', role: 'user', sequence: 0, - prompt_group_id: 'group-0', + prompt_group_id: 'group-shared', }), makeSeed({ id: 'p-2', value: 'Assistant answer', role: 'assistant', sequence: 1, - prompt_group_id: 'group-1', - }), - makeSeed({ - id: 'p-3', - value: 'Follow-up user turn', - role: 'user', - sequence: 2, - prompt_group_id: 'group-2', + prompt_group_id: 'group-shared', }), ] const outcome = buildSeedImport(members) expect(outcome.type).toBe('conversation') if (outcome.type === 'conversation') { - expect(outcome.messages).toHaveLength(3) + expect(outcome.messages).toHaveLength(2) expect(outcome.messages[0].role).toBe('user') expect(outcome.messages[1].role).toBe('simulated_assistant') - expect(outcome.messages[2].role).toBe('user') + expect(outcome.composerDraft).toBeUndefined() // ends in assistant -> empty composer - // Check invariants on pieces for (const msg of outcome.messages) { for (const piece of msg.pieces) { expect(piece.source_piece_id).toBeUndefined() @@ -162,49 +157,78 @@ describe('buildSeedImport', () => { } }) - it('rejects ambiguous missing roles across multiple members', () => { + it('preserves two consecutive user turns in the same group as distinct turns (Comment 1)', () => { const members: SeedRecord[] = [ makeSeed({ id: 'p-1', - value: 'Turn 1', + value: 'First user prompt', role: 'user', + sequence: 0, + prompt_group_id: 'group-shared', }), makeSeed({ id: 'p-2', - value: 'Turn 2 without role', - role: null, + value: 'Second user prompt', + role: 'user', + sequence: 1, + prompt_group_id: 'group-shared', }), ] const outcome = buildSeedImport(members) - expect(outcome.type).toBe('unsupported') - if (outcome.type === 'unsupported') { - expect(outcome.reason).toMatch(/ambiguous/) + expect(outcome.type).toBe('conversation') + if (outcome.type === 'conversation') { + // First turn is in history; second turn is split into composer draft + expect(outcome.messages).toHaveLength(1) + expect(outcome.messages[0].role).toBe('user') + expect(outcome.messages[0].pieces[0].original_value).toBe('First user prompt') + + expect(outcome.composerDraft).toBeDefined() + expect(outcome.composerDraft?.text).toBe('Second user prompt') } }) - it('rejects conflicting roles within the same prompt group', () => { + it('implements final-user-turn split convention for user -> assistant -> user', () => { const members: SeedRecord[] = [ makeSeed({ id: 'p-1', - value: 'Piece 1', + value: 'Initial user turn', role: 'user', - prompt_group_id: 'group-1', + sequence: 0, + prompt_group_id: 'group-shared', }), makeSeed({ id: 'p-2', - value: 'Piece 2', + value: 'Assistant answer', role: 'assistant', - prompt_group_id: 'group-1', + sequence: 1, + prompt_group_id: 'group-shared', + }), + makeSeed({ + id: 'p-3', + value: 'Follow-up user turn', + role: 'user', + sequence: 2, + prompt_group_id: 'group-shared', }), ] const outcome = buildSeedImport(members) - expect(outcome.type).toBe('unsupported') - if (outcome.type === 'unsupported') { - expect(outcome.reason).toMatch(/conflicting roles/) + expect(outcome.type).toBe('conversation') + if (outcome.type === 'conversation') { + // Earlier turns become prepended history + expect(outcome.messages).toHaveLength(2) + expect(outcome.messages[0].role).toBe('user') + expect(outcome.messages[0].pieces[0].original_value).toBe('Initial user turn') + expect(outcome.messages[1].role).toBe('simulated_assistant') + expect(outcome.messages[1].pieces[0].original_value).toBe('Assistant answer') + + // Final user turn becomes editable unsent composer draft + expect(outcome.composerDraft).toBeDefined() + expect(outcome.composerDraft?.text).toBe('Follow-up user turn') + expect(outcome.composerDraft?.attachments).toHaveLength(0) } }) - it('groups multiple pieces in the same prompt group into one turn', () => { + it('groups multiple pieces in the same sequence into one turn', () => { const members: SeedRecord[] = [ makeSeed({ id: 'p-1', @@ -212,23 +236,23 @@ describe('buildSeedImport', () => { value: 'Look at this file and describe it', role: 'user', sequence: 0, - prompt_group_id: 'group-1', + prompt_group_id: 'group-shared', }), makeSeed({ id: 'p-2', data_type: 'image_path', value: 'chart.png', role: 'user', - sequence: 1, - prompt_group_id: 'group-1', + sequence: 0, + prompt_group_id: 'group-shared', }), makeSeed({ id: 'p-3', data_type: 'text', value: 'Sure, here is the explanation', role: 'assistant', - sequence: 2, - prompt_group_id: 'group-2', + sequence: 1, + prompt_group_id: 'group-shared', }), ] const outcome = buildSeedImport(members) @@ -244,6 +268,30 @@ describe('buildSeedImport', () => { } }) + it('rejects conflicting roles within the same sequence', () => { + const members: SeedRecord[] = [ + makeSeed({ + id: 'p-1', + value: 'Piece 1', + role: 'user', + sequence: 0, + prompt_group_id: 'group-shared', + }), + makeSeed({ + id: 'p-2', + value: 'Piece 2', + role: 'assistant', + sequence: 0, + prompt_group_id: 'group-shared', + }), + ] + const outcome = buildSeedImport(members) + expect(outcome.type).toBe('unsupported') + if (outcome.type === 'unsupported') { + expect(outcome.reason).toMatch(/conflicting roles/) + } + }) + it('extracts system prompt when conversation starts with a system turn', () => { const members: SeedRecord[] = [ makeSeed({ @@ -265,9 +313,31 @@ describe('buildSeedImport', () => { expect(outcome.type).toBe('conversation') if (outcome.type === 'conversation') { expect(outcome.systemPrompt).toBe('You are a helpful assistant.') - expect(outcome.messages).toHaveLength(2) + expect(outcome.messages).toHaveLength(1) expect(outcome.messages[0].role).toBe('system') - expect(outcome.messages[1].role).toBe('user') + expect(outcome.composerDraft?.text).toBe('Hello!') + } + }) + + it('rejects ambiguous missing roles across multiple members', () => { + const members: SeedRecord[] = [ + makeSeed({ + id: 'p-1', + value: 'Turn 1', + role: 'user', + sequence: 0, + }), + makeSeed({ + id: 'p-2', + value: 'Turn 2 without role', + role: null, + sequence: 1, + }), + ] + const outcome = buildSeedImport(members) + expect(outcome.type).toBe('unsupported') + if (outcome.type === 'unsupported') { + expect(outcome.reason).toMatch(/ambiguous/) } }) @@ -277,6 +347,7 @@ describe('buildSeedImport', () => { id: 'p-invalid', // eslint-disable-next-line @typescript-eslint/no-explicit-any role: 'unknown_alien_role' as any, + sequence: 0, }), ] const outcome = buildSeedImport(members) diff --git a/frontend/src/utils/seedImport.ts b/frontend/src/utils/seedImport.ts index 6dd58dac0f..aaa812cc36 100644 --- a/frontend/src/utils/seedImport.ts +++ b/frontend/src/utils/seedImport.ts @@ -21,6 +21,11 @@ export interface SinglePromptImport { export interface ConversationImport { type: 'conversation' messages: ConversationDraftMessage[] + composerDraft?: { + text: string + attachments: MessageAttachment[] + textMetadata?: Record + } objective?: string systemPrompt?: string } @@ -56,6 +61,23 @@ function mapToDraftRole(role: ChatMessageRole | null): ConversationDraftRole | n } } +function recordToDraftPiece(record: SeedRecord): ConversationDraftPiece { + return { + draftId: generateClientId(), + data_type: record.data_type, + original_value: record.value, + mime_type: undefined, + previewUrl: record.value_url ?? undefined, + filename: + record.name ?? + (isPathDataType(record.data_type) + ? basenameFromValue(record.value, record.data_type) + : undefined), + prompt_metadata: record.metadata ?? undefined, + source_piece_id: undefined, // Never use seed ID as chat piece ID + } +} + /** * Inspects a list of SeedRecords belonging to one logical example and produces * either a single prompt (for the composer), a conversation draft (for the ConversationEditor), @@ -66,7 +88,8 @@ function mapToDraftRole(role: ChatMessageRole | null): ConversationDraftRole | n * 2. Does not execute or generate simulated conversations. * 3. Seed IDs are never assigned to source_piece_id. * 4. Stored value remains the durable reference; value_url is preview only. - * 5. Does not guess ambiguous roles or sequences. + * 5. Turns are grouped by stored sequence and compatible role (Comment 1). + * 6. Final user turn split retains all content unsent in composer (Issue spec). */ export function buildSeedImport(members: readonly SeedRecord[]): SeedImportOutcome { if (!members || members.length === 0) { @@ -114,127 +137,107 @@ export function buildSeedImport(members: readonly SeedRecord[]): SeedImportOutco } } - // 5. Build raw draft pieces - // Group pieces into logical turns: - // - If prompt_group_id is present, group by prompt_group_id. - // - If prompt_group_id is missing, group by consecutive matching roles or single turn if single member. + // 5. Group pieces into turns by stored sequence. + // In SeedGroup, members of an example share the same prompt_group_id, while + // different conversation turns have distinct sequence numbers. + // Records with the same sequence form pieces within the same turn. interface GroupedTurn { role: ConversationDraftRole - groupId: string | null sequence: number pieces: ConversationDraftPiece[] } const turns: GroupedTurn[] = [] - // Check if prompt_group_id is used - const hasGroupIds = promptRecords.some((m) => Boolean(m.prompt_group_id)) + // Check sequence definition across prompt records + const hasSequenceDefined = promptRecords.some((r) => r.sequence !== null && r.sequence !== undefined) + const missingSequenceCount = promptRecords.filter((r) => r.sequence === null || r.sequence === undefined).length - if (hasGroupIds) { - const groupMap = new Map() + if (promptRecords.length > 1 && hasSequenceDefined && missingSequenceCount > 0) { + return { + type: 'unsupported', + reason: 'Some prompt members are missing sequence numbers, making the turn order ambiguous.', + } + } + + if (hasSequenceDefined) { + // Group records by sequence + const sequenceMap = new Map() for (const record of promptRecords) { - const gId = record.prompt_group_id || `turn-${record.id}` - const existing = groupMap.get(gId) ?? [] + const seq = record.sequence ?? 0 + const existing = sequenceMap.get(seq) ?? [] existing.push(record) - groupMap.set(gId, existing) + sequenceMap.set(seq, existing) } - for (const [gId, recordsInGroup] of groupMap.entries()) { - // Validate roles within group are consistent - const groupRoles = recordsInGroup + const sortedSequences = Array.from(sequenceMap.keys()).sort((a, b) => a - b) + + for (const seq of sortedSequences) { + const recordsInSequence = sequenceMap.get(seq) ?? [] + const sequenceRoles = recordsInSequence .map((r) => r.role) .filter((r): r is ChatMessageRole => r !== null && r !== undefined) - const mappedGroupRoles = groupRoles.map(mapToDraftRole) + const mappedRoles = sequenceRoles.map(mapToDraftRole) - if (mappedGroupRoles.some((r) => r === null)) { + if (mappedRoles.some((r) => r === null)) { return { type: 'unsupported', - reason: `Group ${gId} contains unsupported role values.`, + reason: `Turn at sequence ${seq} contains unsupported role values.`, } } - const distinctRoles = new Set(mappedGroupRoles) + const distinctRoles = new Set(mappedRoles) if (distinctRoles.size > 1) { return { type: 'unsupported', - reason: `Members within the prompt group '${gId}' have conflicting roles.`, + reason: `Members within sequence ${seq} have conflicting roles.`, } } - const finalRole = mappedGroupRoles[0] ?? 'user' - // Sort pieces by sequence within group - const sortedRecords = [...recordsInGroup].sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)) - const minSeq = Math.min(...recordsInGroup.map((r) => r.sequence ?? 0)) - - const pieces: ConversationDraftPiece[] = sortedRecords.map((record) => ({ - draftId: generateClientId(), - data_type: record.data_type, - original_value: record.value, - mime_type: undefined, - previewUrl: record.value_url ?? undefined, - filename: record.name ?? (isPathDataType(record.data_type) ? basenameFromValue(record.value, record.data_type) : undefined), - prompt_metadata: record.metadata ?? undefined, - source_piece_id: undefined, // Never use seed ID as chat piece ID - })) + const finalRole = mappedRoles[0] ?? (nonNullRoles.length === 0 ? 'user' : null) + if (!finalRole) { + return { + type: 'unsupported', + reason: `Turn at sequence ${seq} has an ambiguous or missing role.`, + } + } + const pieces = recordsInSequence.map(recordToDraftPiece) turns.push({ role: finalRole, - groupId: gId, - sequence: minSeq, + sequence: seq, pieces, }) } - - turns.sort((a, b) => a.sequence - b.sequence) } else { - // No prompt_group_id: group records into turns by role and sequence. - // If consecutive records share the same role and sequence (e.g. text + image in a single turn), - // they are combined as pieces within that turn. - let currentTurn: GroupedTurn | null = null - for (const record of promptRecords) { - const mappedRole = mapToDraftRole(record.role) ?? (nonNullRoles.length === 0 ? 'user' : null) - if (!mappedRole) { + // No sequence numbers stored on records + if (promptRecords.length === 1) { + const record = promptRecords[0] + const mappedRole = mapToDraftRole(record.role) ?? 'user' + turns.push({ + role: mappedRole, + sequence: 0, + pieces: [recordToDraftPiece(record)], + }) + } else { + // Multiple records without sequence numbers: + // If all records share the same role (e.g. text + image pieces in a single prompt), + // combine them into a single turn at sequence 0. + const allRoles = promptRecords.map((r) => mapToDraftRole(r.role) ?? 'user') + const distinctRoles = new Set(allRoles) + if (distinctRoles.size === 1) { + turns.push({ + role: allRoles[0], + sequence: 0, + pieces: promptRecords.map(recordToDraftPiece), + }) + } else { return { type: 'unsupported', - reason: `Record ${record.id} has an unsupported role '${record.role}'.`, + reason: 'Prompt members have ambiguous turn sequence or missing sequence ordering.', } } - - const piece: ConversationDraftPiece = { - draftId: generateClientId(), - data_type: record.data_type, - original_value: record.value, - mime_type: undefined, - previewUrl: record.value_url ?? undefined, - filename: - record.name ?? - (isPathDataType(record.data_type) - ? basenameFromValue(record.value, record.data_type) - : undefined), - prompt_metadata: record.metadata ?? undefined, - source_piece_id: undefined, - } - - const matchesCurrentTurn = - currentTurn && - currentTurn.role === mappedRole && - (record.sequence === null || - currentTurn.sequence === record.sequence || - promptRecords.every((r) => (r.role ?? 'user') === 'user')) - - if (matchesCurrentTurn && currentTurn) { - currentTurn.pieces.push(piece) - } else { - currentTurn = { - role: mappedRole, - groupId: null, - sequence: record.sequence ?? 0, - pieces: [piece], - } - turns.push(currentTurn) - } } - turns.sort((a, b) => a.sequence - b.sequence) } if (turns.length === 0) { @@ -244,8 +247,8 @@ export function buildSeedImport(members: readonly SeedRecord[]): SeedImportOutco } } - // 6. Check if this is a Single Prompt vs. Multi-turn Conversation - // Single prompt: exactly 1 turn, role is 'user' + // 6. Differentiate Single Prompt vs. Multi-turn Conversation with Final User Turn Split + // If exactly 1 turn and role is 'user', import as single prompt to editable composer. if (turns.length === 1 && turns[0].role === 'user') { const turn = turns[0] const textPieces = turn.pieces.filter((p) => !isPathDataType(p.data_type)) @@ -274,7 +277,26 @@ export function buildSeedImport(members: readonly SeedRecord[]): SeedImportOutco } } - const messages: ConversationDraftMessage[] = turns.map((turn) => ({ + // Apply final-user-turn split convention: + // If the conversation ends in a user turn, earlier turns become prepended history + // and the final user turn remains editable and unsent in the composer. + // If it ends in an assistant/tool turn, retain the whole selected history and leave next prompt empty. + const lastTurn = turns[turns.length - 1] + let historyTurns = turns + let composerDraft: ConversationImport['composerDraft'] | undefined + + if (lastTurn.role === 'user' && turns.length > 1) { + historyTurns = turns.slice(0, -1) + const textPieces = lastTurn.pieces.filter((p) => !isPathDataType(p.data_type)) + const mediaPieces = lastTurn.pieces.filter((p) => isPathDataType(p.data_type)) + composerDraft = { + text: textPieces.map((p) => p.original_value).join('\n\n'), + attachments: mediaPieces.map(draftAttachment), + textMetadata: textPieces[0]?.prompt_metadata, + } + } + + const messages: ConversationDraftMessage[] = historyTurns.map((turn) => ({ id: generateClientId(), role: turn.role, pieces: turn.pieces, @@ -283,6 +305,7 @@ export function buildSeedImport(members: readonly SeedRecord[]): SeedImportOutco return { type: 'conversation', messages, + composerDraft, objective, systemPrompt, } From b2cfa2378309b886d49ccd781739e2d3825c303e Mon Sep 17 00:00:00 2001 From: Nimit Jain Date: Sun, 1 Nov 2026 18:44:38 +0530 Subject: [PATCH 3/5] fix(gui): correct conversation merge semantics, draft preservation, and target validation - Preserve destination existing messages first and append imported history after them in current_conversation - Handle history loading errors gracefully without consuming pending import - Cover dirty-draft protection and replace confirmation across new_attack, current_conversation, and new_conversation - Track unsaved imported state explicitly in useConversationDraft lifecycle - Validate and report missing conversation targets against registry without silent fallback - Add comprehensive user journey tests for all import flows and edge cases --- frontend/src/App.drafts.test.tsx | 109 ++++++++++++++++- frontend/src/components/Chat/ChatWindow.tsx | 110 ++++++++++++------ .../Datasets/SeedExampleBrowser.tsx | 3 + frontend/src/hooks/useConversationDraft.ts | 9 +- frontend/src/types/index.ts | 1 + 5 files changed, 189 insertions(+), 43 deletions(-) diff --git a/frontend/src/App.drafts.test.tsx b/frontend/src/App.drafts.test.tsx index 502f80adbb..4ec72430df 100644 --- a/frontend/src/App.drafts.test.tsx +++ b/frontend/src/App.drafts.test.tsx @@ -1,6 +1,6 @@ import { StrictMode, type ReactNode } from 'react' -import { act, render, screen, waitFor } from '@testing-library/react' +import { act, render, screen, waitFor, within } from '@testing-library/react' import userEvent, { type UserEvent } from '@testing-library/user-event' import { createMemoryRouter, MemoryRouter } from 'react-router' import { RouterProvider } from 'react-router/dom' @@ -105,6 +105,21 @@ jest.mock('@/components/Layout/MainLayout', () => { }, replaceDirty: true, })}>Stage saved conversation import + {handoff.pendingImport ? 'waiting' : 'none'} {location.pathname} {children} @@ -749,16 +764,102 @@ describe('App draft handoff with the real chat and composer', () => { expect(screen.getByLabelText('Pending import')).toHaveTextContent('none') }) - it('should prepend imported turns to existing saved conversation messages', async () => { + it('should preserve destination existing messages and append imported history after them in current_conversation', async () => { const user = userEvent.setup() + const backendMsgs = [ + { + role: 'user', + turn_number: 0, + created_at: '', + message_pieces: [ + { + id: 'p-orig-1', + original_value_data_type: 'text', + original_value: 'Existing user message', + converted_value_data_type: 'text', + converted_value: 'Existing user message', + scores: [], + response_error: 'none', + }, + ], + }, + { + role: 'assistant', + turn_number: 1, + created_at: '', + message_pieces: [ + { + id: 'p-orig-2', + original_value_data_type: 'text', + original_value: 'Existing assistant message', + converted_value_data_type: 'text', + converted_value: 'Existing assistant message', + scores: [], + response_error: 'none', + }, + ], + }, + ] + jest.mocked(attacksApi.getMessages).mockResolvedValueOnce({ + conversation_id: 'main', + messages: backendMsgs, + target_response_status: null, + }) + renderChat('/attacks/saved/conversations/main') await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) await user.click(screen.getByRole('button', { name: 'Stage saved conversation import' })) - // Editor opens with prepended assistant turn - await waitFor(() => expect(screen.getByText('Prepended assistant turn')).toBeInTheDocument()) + // Existing messages are loaded and preserved first, imported assistant turn appended + await waitFor(() => expect(screen.getByText('Existing user message')).toBeInTheDocument()) + expect(screen.getByText('Existing assistant message')).toBeInTheDocument() + expect(screen.getByText('Prepended assistant turn')).toBeInTheDocument() + + // Assert exact order: existing user message first, then existing assistant, then imported turn + const messageBodies = screen.getAllByRole('region').filter((r) => r.getAttribute('aria-label')?.startsWith('Message ')) + expect(messageBodies).toHaveLength(3) + expect(within(messageBodies[0]).getByText('Existing user message')).toBeInTheDocument() + expect(within(messageBodies[1]).getByText('Existing assistant message')).toBeInTheDocument() + expect(within(messageBodies[2]).getByText('Prepended assistant turn')).toBeInTheDocument() + + // And composer contains the unsent user prompt const promptInputs = screen.getAllByPlaceholderText('Type prompt here') expect(promptInputs[promptInputs.length - 1]).toHaveValue('Unsent user prompt') }) + + it('should display error and preserve pending import if history loading fails for current_conversation', async () => { + const user = userEvent.setup() + renderChat('/attacks/saved/conversations/main') + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) + + jest.mocked(attacksApi.getMessages).mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 500, data: { detail: 'Backend database unavailable' } }, + }) + + await user.click(screen.getByRole('button', { name: 'Stage saved conversation import' })) + + // Error message appears in the editor + await waitFor(() => expect(screen.getByText('Backend database unavailable')).toBeInTheDocument()) + // Pending import is preserved (not consumed) + expect(screen.getByLabelText('Pending import')).toHaveTextContent('waiting') + }) + + it('should display explicit error when conversation target is no longer available in the registry', async () => { + const user = userEvent.setup() + renderChat('/attacks/saved/conversations/branch') + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) + + await user.click(screen.getByRole('button', { name: 'Preserve draft with missing target' })) + + // Navigate to saved main where the draft was preserved + await user.click(screen.getByRole('button', { name: 'Open saved main' })) + + await waitFor(() => { + expect( + screen.getByText("The conversation's configured target is no longer available in the registry. Select an available target."), + ).toBeInTheDocument() + }) + }) }) diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 732645ec96..748d567ea5 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -508,6 +508,8 @@ export default function ChatWindow({ const undoInFlight = useRef(false) const handoffRestoredRef = useRef(null) + const importInFlightRef = useRef(null) + const importFailedRef = useRef(null) const ownsDraftRef = useRef(false) const discardDraftOnUnmountRef = useRef(false) const preserveOnUnmountRef = useRef<(() => void) | null>(null) @@ -826,6 +828,12 @@ export default function ChatWindow({ ) useEffect(() => { + if (!pending) { + importInFlightRef.current = null + importFailedRef.current = null + } else if (importFailedRef.current && importFailedRef.current !== pending.id) { + importFailedRef.current = null + } if (pending && pending.sequence < draftHandoff.activeSequence) { draftHandoff.dismissPendingImport(pending.id) return @@ -847,9 +855,18 @@ export default function ChatWindow({ handoffRestoredRef.current = preserved.sequence draft = { ...consumed, systemPrompt: consumed.systemPrompt ?? '' } if (consumed.conversation) { - const editorTarget = consumed.conversation.target - ? resolveTargetReference(consumed.conversation.target, availableTargets) ?? activeTarget - : activeTarget + let editorTarget: TargetInstance | null = activeTarget + if (consumed.conversation.target) { + const resolved = resolveTargetReference(consumed.conversation.target, availableTargets) + if (resolved) { + editorTarget = resolved + } else { + editorTarget = null + queueMicrotask(() => { + setEditorError("The conversation's configured target is no longer available in the registry. Select an available target.") + }) + } + } editor.begin({ messages: consumed.conversation.messages, objective: consumed.conversation.objective ?? currentObjective, @@ -863,56 +880,75 @@ export default function ChatWindow({ } } else { if (!canApplyImportNow || !pending) return - const consumed = draftHandoff.consumePendingImport(pending.id, draftAttackId, draftConversationId) - if (!consumed) return - if (preserved) { - draftHandoff.clearPreservedDraft(preserved.sequence) - } - converters.clearAll() - if (consumed.conversation) { - const stageConversationImport = async () => { - let existingTurns: ConversationDraftMessage[] = [] - if (consumed.destinationType === 'current_conversation' && draftConversationId && draftAttackId) { + if (importInFlightRef.current === pending.id || importFailedRef.current === pending.id) return + + const executeImportHandoff = async (): Promise => { + importInFlightRef.current = pending.id + let existingTurns: ConversationDraftMessage[] = [] + if (pending.conversation && pending.destinationType === 'current_conversation' && draftConversationId && draftAttackId) { + let backendMsgs = loadedBackendMessagesRef.current.get(draftConversationId) + if (!backendMsgs || backendMsgs.length === 0) { try { - let backendMsgs = loadedBackendMessagesRef.current.get(draftConversationId) - if (!backendMsgs || backendMsgs.length === 0) { - const res = await attacksApi.getMessages(draftAttackId, draftConversationId) - backendMsgs = res?.messages ?? [] - if (backendMsgs.length > 0) { - loadedBackendMessagesRef.current.set(draftConversationId, backendMsgs) - } + const res = await attacksApi.getMessages(draftAttackId, draftConversationId) + backendMsgs = res?.messages ?? [] + if (backendMsgs.length > 0) { + loadedBackendMessagesRef.current.set(draftConversationId, backendMsgs) } - existingTurns = toConversationDraft(backendMsgs) - } catch { - existingTurns = [] + } catch (err) { + importInFlightRef.current = null + importFailedRef.current = pending.id + setEditorError(toApiError(err).detail || 'Failed to load conversation history. Preserved pending import.') + return } } - const allMessages = [...consumed.conversation!.messages, ...existingTurns] + try { + existingTurns = toConversationDraft(backendMsgs) + } catch (err) { + importInFlightRef.current = null + importFailedRef.current = pending.id + setEditorError(toApiError(err).detail || 'Failed to parse conversation history. Preserved pending import.') + return + } + } + + const consumed = draftHandoff.consumePendingImport(pending.id, draftAttackId, draftConversationId) + importInFlightRef.current = null + if (!consumed) return + importFailedRef.current = null + if (preserved) { + draftHandoff.clearPreservedDraft(preserved.sequence) + } + converters.clearAll() + if (consumed.conversation) { + const allMessages = [...existingTurns, ...consumed.conversation.messages] editor.begin({ messages: allMessages, - baselineMessages: [], - objective: consumed.conversation!.objective ?? currentObjective, - initialObjective: consumed.conversation!.objective ?? currentObjective, + objective: consumed.conversation.objective ?? currentObjective, + initialObjective: consumed.conversation.objective ?? currentObjective, sourceConversationId: draftConversationId ?? null, sourceAttackId: draftAttackId, target: activeTarget, + unsavedImport: true, labels, }) - if (consumed.conversation!.systemPrompt) { - if (systemPrompt && systemPrompt.trim() !== consumed.conversation!.systemPrompt.trim()) { + if (consumed.conversation.systemPrompt) { + if (systemPrompt && systemPrompt.trim() !== consumed.conversation.systemPrompt.trim()) { setEditorNotice('The imported example includes a system prompt that differs from the existing system prompt. The existing system prompt was preserved.') } } } - void stageConversationImport() - } - draft = { - text: consumed.content.text, - attachments: consumed.content.attachments ?? [], - textMetadata: consumed.content.textMetadata, - ...(consumed.conversation?.systemPrompt && !systemPrompt ? { systemPrompt: consumed.conversation.systemPrompt } : {}), + draft = { + text: consumed.content.text, + attachments: consumed.content.attachments ?? [], + textMetadata: consumed.content.textMetadata, + ...(consumed.conversation?.systemPrompt && !systemPrompt ? { systemPrompt: consumed.conversation.systemPrompt } : {}), + } + focus = consumed.focus === true + restoreComposerDraft(draft) + if (focus) inputBoxRef.current?.focus() } - focus = consumed.focus === true + void executeImportHandoff() + return } // Claiming a handoff synchronizes the imperative input and its parent-owned draft atomically. const attachments = restoreComposerDraft(draft) diff --git a/frontend/src/components/Datasets/SeedExampleBrowser.tsx b/frontend/src/components/Datasets/SeedExampleBrowser.tsx index 7172510a1d..21cb4f14e9 100644 --- a/frontend/src/components/Datasets/SeedExampleBrowser.tsx +++ b/frontend/src/components/Datasets/SeedExampleBrowser.tsx @@ -273,6 +273,9 @@ export function SeedExampleBrowserView({ preserved.originatingConversationId === effectiveConversationId ) } + if (destination === 'new_conversation') { + return preserved.originatingAttackId === effectiveAttackId + } return false }, [draftHandoff, effectiveAttackId, effectiveConversationId], diff --git a/frontend/src/hooks/useConversationDraft.ts b/frontend/src/hooks/useConversationDraft.ts index 8e73da5d63..25c30e3668 100644 --- a/frontend/src/hooks/useConversationDraft.ts +++ b/frontend/src/hooks/useConversationDraft.ts @@ -25,6 +25,7 @@ interface DraftState extends ConversationSaveInput { baselineMessages: ConversationDraftMessage[] baselineTarget: string | undefined temperature: string + unsavedImport?: boolean } export function useConversationDraft( @@ -39,8 +40,11 @@ export function useConversationDraft( const urls = useRef(new Set()) const workflow = useConversationSave(newAttackContext) const dirty = draft !== null && ( - draft.messages !== draft.baselineMessages || draft.objective !== draft.initialObjective - || targetKey(draft.target) !== draft.baselineTarget || draft.temperature.trim() !== '' + draft.unsavedImport === true + || draft.messages !== draft.baselineMessages + || draft.objective !== draft.initialObjective + || targetKey(draft.target) !== draft.baselineTarget + || draft.temperature.trim() !== '' ) const validationError = draft ? validateDraft(draft.messages) : null const messages = draft?.messages ?? [] @@ -64,6 +68,7 @@ export function useConversationDraft( setDraft({ ...input, id, baselineMessages: input.baselineMessages ?? input.messages, baselineTarget: targetKey(input.target), temperature: input.temperature ?? '', + unsavedImport: input.unsavedImport, sourcePieceIds: input.messages.flatMap((message: ConversationDraftMessage) => message.pieces.flatMap( (piece: ConversationDraftPiece) => piece.source_piece_id ? [piece.source_piece_id] : [], )), diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 26960319d4..f520bda3fd 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1017,6 +1017,7 @@ export interface ConversationSaveInput { labels?: Record baselineMessages?: ConversationDraftMessage[] temperature?: string + unsavedImport?: boolean } export interface NewAttackContext { From 8b05fe3ea6c04d4a711200d25220b8508d265974 Mon Sep 17 00:00:00 2001 From: Nimit Jain Date: Sun, 1 Nov 2026 19:47:13 +0530 Subject: [PATCH 4/5] test(gui): verify production Roman journey, new_conversation drafts, and editor lifecycle --- frontend/src/App.drafts.test.tsx | 204 +++++++++++++++--- .../Chat/ConversationEditor.test.tsx | 29 +++ .../Datasets/SeedExampleBrowser.tsx | 44 ++-- 3 files changed, 221 insertions(+), 56 deletions(-) diff --git a/frontend/src/App.drafts.test.tsx b/frontend/src/App.drafts.test.tsx index 4ec72430df..af0da3f19a 100644 --- a/frontend/src/App.drafts.test.tsx +++ b/frontend/src/App.drafts.test.tsx @@ -5,7 +5,7 @@ import userEvent, { type UserEvent } from '@testing-library/user-event' import { createMemoryRouter, MemoryRouter } from 'react-router' import { RouterProvider } from 'react-router/dom' -import { attacksApi, convertersApi, runtimeApi, targetsApi } from '@/services/api' +import { attacksApi, convertersApi, datasetsApi, runtimeApi, targetsApi } from '@/services/api' import { deferred } from '@/test-utils/deferred' import { makeTarget } from '@/test-utils/targetFixtures' import { installRouterTestRequest } from '@/test-utils/routerRequest' @@ -29,10 +29,6 @@ jest.mock('@/hooks/useConnectionHealth', () => ({ ConnectionHealthProvider: ({ children }: { children: ReactNode }) => children, useConnectionHealth: () => ({ status: 'connected', reconnectCount: 0 }), })) -jest.mock('@/components/Datasets/DatasetCatalog', () => ({ - __esModule: true, - default: () =>

Dataset catalog

, -})) jest.mock('@/components/Layout/MainLayout', () => { const { useNavigate, useLocation } = jest.requireActual('react-router') const { useChatDraftHandoff } = jest.requireActual('@/hooks/useChatDraftHandoff') @@ -81,17 +77,6 @@ jest.mock('@/components/Layout/MainLayout', () => { }, replaceDirty: true, })}>Stage conversation import -