From 5dff6e522473d303651e6bf87fe71305a00a51f3 Mon Sep 17 00:00:00 2001 From: Nimit Jain Date: Fri, 30 Oct 2026 11:43:15 +0530 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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 - + + + + diff --git a/frontend/src/components/Datasets/SeedExampleBrowser.tsx b/frontend/src/components/Datasets/SeedExampleBrowser.tsx index 4f291363e7..32734f9466 100644 --- a/frontend/src/components/Datasets/SeedExampleBrowser.tsx +++ b/frontend/src/components/Datasets/SeedExampleBrowser.tsx @@ -325,6 +325,7 @@ export function SeedExampleBrowserView({ messages: outcome.messages, objective: outcome.objective, systemPrompt: outcome.systemPrompt, + composerDraft: outcome.composerDraft, }, } : {}), diff --git a/frontend/src/hooks/useChatDraftHandoff.tsx b/frontend/src/hooks/useChatDraftHandoff.tsx index 8c15232bac..914f77810a 100644 --- a/frontend/src/hooks/useChatDraftHandoff.tsx +++ b/frontend/src/hooks/useChatDraftHandoff.tsx @@ -1,7 +1,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import type { - ChatDraftContent, ChatDraftContextState, ChatDraftHandoffContextValue, PendingImportPayload, + ChatDraftContent, ChatDraftContextState, ChatDraftHandoffContextValue, MessageAttachment, PendingImportPayload, } from '@/types' import { generateClientId } from '@/utils/clientId' import { @@ -14,6 +14,22 @@ import { const ChatDraftHandoffContext = createContext(null) +function allDraftAttachments(draft?: ChatDraftContent | null): MessageAttachment[] { + if (!draft) return [] + return [ + ...draft.attachments ?? [], + ...draft.conversation?.pendingComposerDraft?.attachments ?? [], + ] +} + +function allImportAttachments(payload?: PendingImportPayload | null): MessageAttachment[] { + if (!payload) return [] + return [ + ...payload.content?.attachments ?? [], + ...payload.conversation?.composerDraft?.attachments ?? [], + ] +} + export function ChatDraftProvider({ children }: { children: ReactNode }) { const [preservedDraft, setPreservedDraft] = useState(null) const [pendingImport, setPendingImport] = useState(null) @@ -28,8 +44,8 @@ export function ChatDraftProvider({ children }: { children: ReactNode }) { return () => { mountedRef.current = false revokeAttachmentUrls([ - ...preservedDraftRef.current?.draft.attachments ?? [], - ...pendingImportRef.current?.content.attachments ?? [], + ...allDraftAttachments(preservedDraftRef.current?.draft), + ...allImportAttachments(pendingImportRef.current), ]) preservedDraftRef.current = null pendingImportRef.current = null @@ -45,12 +61,12 @@ export function ChatDraftProvider({ children }: { children: ReactNode }) { const preserveDraft = useCallback((state: Omit) => { // Parent effects are cleaned up before the active composer's unmount. if (!mountedRef.current) { - revokeAttachmentUrls(state.draft.attachments) + revokeAttachmentUrls(allDraftAttachments(state.draft)) return } - releaseSupersededUrls(preservedDraftRef.current?.draft.attachments, [ - ...state.draft.attachments, - ...pendingImportRef.current?.content.attachments ?? [], + releaseSupersededUrls(allDraftAttachments(preservedDraftRef.current?.draft), [ + ...allDraftAttachments(state.draft), + ...allImportAttachments(pendingImportRef.current), ]) const nextState: ChatDraftContextState = { ...state, @@ -64,7 +80,10 @@ export function ChatDraftProvider({ children }: { children: ReactNode }) { const clearPreservedDraft = useCallback((expectedSequence?: number) => { const current = preservedDraftRef.current if (expectedSequence !== undefined && current?.sequence !== expectedSequence) return - releaseSupersededUrls(current?.draft.attachments, pendingImportRef.current?.content.attachments) + releaseSupersededUrls( + allDraftAttachments(current?.draft), + allImportAttachments(pendingImportRef.current), + ) preservedDraftRef.current = null setPreservedDraft(null) advanceSequence() @@ -74,16 +93,20 @@ export function ChatDraftProvider({ children }: { children: ReactNode }) { payload: Omit, expectedSequence?: number, ): PendingImportPayload | null => { + const incomingAttachments = [ + ...payload.content.attachments ?? [], + ...payload.conversation?.composerDraft?.attachments ?? [], + ] if (!mountedRef.current || (expectedSequence !== undefined && expectedSequence !== sequenceRef.current)) { - releaseSupersededUrls(payload.content.attachments, [ - ...preservedDraftRef.current?.draft.attachments ?? [], - ...pendingImportRef.current?.content.attachments ?? [], + releaseSupersededUrls(incomingAttachments, [ + ...allDraftAttachments(preservedDraftRef.current?.draft), + ...allImportAttachments(pendingImportRef.current), ]) return null } - releaseSupersededUrls(pendingImportRef.current?.content.attachments, [ - ...payload.content.attachments ?? [], - ...preservedDraftRef.current?.draft.attachments ?? [], + releaseSupersededUrls(allImportAttachments(pendingImportRef.current), [ + ...incomingAttachments, + ...allDraftAttachments(preservedDraftRef.current?.draft), ]) const staged: PendingImportPayload = { ...payload, @@ -106,7 +129,7 @@ export function ChatDraftProvider({ children }: { children: ReactNode }) { if (id && current.id !== id) return null // Guard against stale asynchronous completion if (current.sequence < sequenceRef.current) { - releaseSupersededUrls(current.content.attachments, preservedDraftRef.current?.draft.attachments) + releaseSupersededUrls(allImportAttachments(current), allDraftAttachments(preservedDraftRef.current?.draft)) setPendingImport(null) pendingImportRef.current = null return null @@ -124,7 +147,10 @@ export function ChatDraftProvider({ children }: { children: ReactNode }) { const dismissPendingImport = useCallback((id?: string) => { const current = pendingImportRef.current if (id && current?.id !== id) return - releaseSupersededUrls(current?.content.attachments, preservedDraftRef.current?.draft.attachments) + releaseSupersededUrls( + allImportAttachments(current), + allDraftAttachments(preservedDraftRef.current?.draft), + ) pendingImportRef.current = null setPendingImport(null) advanceSequence() diff --git a/frontend/src/hooks/useConversationDraft.ts b/frontend/src/hooks/useConversationDraft.ts index 25c30e3668..7b9188a3cc 100644 --- a/frontend/src/hooks/useConversationDraft.ts +++ b/frontend/src/hooks/useConversationDraft.ts @@ -4,7 +4,7 @@ import { toApiError } from '@/services/errors' import { buildTemperatureTarget } from '@/services/targetRegistry' import type { AddMessageResponse, ConversationDraftMessage, ConversationDraftPiece, ConversationSaveInput, - MessageAttachment, NewAttackContext, PieceConversionUpdates, SaveConversationRequest, TargetInstance, + MessageAttachment, NewAttackContext, PendingComposerDraft, PieceConversionUpdates, SaveConversationRequest, TargetInstance, } from '@/types' import { generateClientId } from '@/utils/clientId' import { @@ -26,6 +26,7 @@ interface DraftState extends ConversationSaveInput { baselineTarget: string | undefined temperature: string unsavedImport?: boolean + pendingComposerDraft?: PendingComposerDraft } export function useConversationDraft( diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index f520bda3fd..9c794cdba4 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -98,6 +98,12 @@ export interface ChatConverterSnapshot { applied: Record } +export interface PendingComposerDraft { + text: string + attachments?: MessageAttachment[] + textMetadata?: Record +} + export interface ConversationEditState { messages: ConversationDraftMessage[] objective?: string @@ -106,6 +112,8 @@ export interface ConversationEditState { sourceAttackId?: string | null temperature?: string target?: TargetReference | null + pendingComposerDraft?: PendingComposerDraft + unsavedImport?: boolean } export interface ChatDraftContent { @@ -134,6 +142,7 @@ export interface PendingImportConversation { messages: ConversationDraftMessage[] objective?: string systemPrompt?: string + composerDraft?: PendingComposerDraft } export interface PendingImportPayload { @@ -1018,6 +1027,7 @@ export interface ConversationSaveInput { baselineMessages?: ConversationDraftMessage[] temperature?: string unsavedImport?: boolean + pendingComposerDraft?: PendingComposerDraft } export interface NewAttackContext { diff --git a/frontend/src/utils/chatDraft.ts b/frontend/src/utils/chatDraft.ts index 5e050ca18f..7eee81120f 100644 --- a/frontend/src/utils/chatDraft.ts +++ b/frontend/src/utils/chatDraft.ts @@ -30,6 +30,10 @@ export function isChatDraftDirty(draft?: Partial | null): bool 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 + if (draft.conversation?.pendingComposerDraft && ( + (typeof draft.conversation.pendingComposerDraft.text === 'string' && draft.conversation.pendingComposerDraft.text.trim().length > 0) + || (Array.isArray(draft.conversation.pendingComposerDraft.attachments) && draft.conversation.pendingComposerDraft.attachments.length > 0) + )) return true return false } @@ -81,3 +85,29 @@ export function canApplyPendingImport( return Boolean(pending.targetAttackId && pending.targetConversationId && attackId === pending.targetAttackId && conversationId === pending.targetConversationId) } + +export function stableMetadataString(metadata?: Record): string { + if (!metadata || typeof metadata !== 'object') return '' + const sortObject = (obj: unknown): unknown => { + if (obj === null || typeof obj !== 'object') return obj + if (Array.isArray(obj)) return obj.map(sortObject) + return Object.keys(obj as Record) + .sort() + .reduce>((acc, key) => { + acc[key] = sortObject((obj as Record)[key]) + return acc + }, {}) + } + try { + return JSON.stringify(sortObject(metadata)) + } catch { + return '' + } +} + +export function computeAttachmentSignature(attachments?: readonly MessageAttachment[]): string { + if (!attachments || attachments.length === 0) return '' + return attachments.map((att) => ( + `${att.draftId ?? ''}:${att.url ?? ''}:${att.name ?? ''}:${att.mimeType ?? ''}:${att.type ?? ''}:${att.size ?? ''}:${att.sourceValue ?? ''}:${att.sourceDataType ?? ''}:${att.pieceId ?? ''}:${stableMetadataString(att.metadata as Record | undefined)}` + )).join('|') +} diff --git a/frontend/src/utils/messageMapper.ts b/frontend/src/utils/messageMapper.ts index 15e5f829d8..0f40ccf131 100644 --- a/frontend/src/utils/messageMapper.ts +++ b/frontend/src/utils/messageMapper.ts @@ -30,7 +30,8 @@ export function fileToBase64(file: Blob): Promise { /** * Map a frontend MIME type to the backend PromptDataType convention. */ -export function mimeTypeToDataType(mimeType: string): string { +export function mimeTypeToDataType(mimeType?: string): string { + if (!mimeType) return 'binary_path' if (mimeType.startsWith('image/')) return 'image_path' if (mimeType.startsWith('audio/')) return 'audio_path' if (mimeType.startsWith('video/')) return 'video_path' From a9360f28de5c28d40a99813a679e3a00811428d3 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Tue, 3 Nov 2026 16:40:06 -0800 Subject: [PATCH 7/7] Fix dataset imports and preserve editable chat drafts Keep imported turns visible until save, reuse unsent destinations safely, reconcile setup explicitly, guard stale requests, and preserve draft media. Fix canonical chat navigation and StrictMode handoff replay. Add integration and real-backend browser regressions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- doc/gui/0_gui.md | 11 +- frontend/e2e/datasets.seeded.spec.ts | 46 ++++ frontend/src/App.drafts.test.tsx | 222 ++++++++++++++++-- frontend/src/components/Chat/ChatWindow.tsx | 148 +++++++----- .../Chat/ConversationEditor.test.tsx | 1 + .../Datasets/SeedExampleBrowser.test.tsx | 50 +++- .../Datasets/SeedExampleBrowser.tsx | 42 +++- .../src/hooks/useChatDraftHandoff.test.tsx | 21 ++ frontend/src/hooks/useChatDraftHandoff.tsx | 16 +- .../src/hooks/useConversationDraft.test.ts | 25 ++ frontend/src/hooks/useConversationDraft.ts | 21 +- frontend/src/types/index.ts | 7 +- frontend/src/utils/chatDraft.ts | 4 - frontend/src/utils/seedImport.test.ts | 43 +++- frontend/src/utils/seedImport.ts | 25 ++ 15 files changed, 566 insertions(+), 116 deletions(-) diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index f9b2c1d0f5..6309357c60 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -195,10 +195,17 @@ text or reattaching files: 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. + in a user turn, that turn is also visible and editable in the editor. Saving + stores the preceding history and restores the edited final turn in the prompt box, + ready for an explicit send. - **Import destinations**: choose between starting a new attack, continuing the current conversation, or creating a new conversation within the current attack. +- **Preserve evidence**: multi-turn imports can extend an unsent conversation in + place. For a conversation containing sent responses or request traces, choose a + new conversation or new attack instead. Conflicting system or developer instructions + require an explicit choice; they are never silently combined. +- **Keep setup**: replacing a prompt retains the destination's system prompt and + configured converter pipelines, but clears generated conversion results. - **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. diff --git a/frontend/e2e/datasets.seeded.spec.ts b/frontend/e2e/datasets.seeded.spec.ts index 4cf23081e7..e58e2fd677 100644 --- a/frontend/e2e/datasets.seeded.spec.ts +++ b/frontend/e2e/datasets.seeded.spec.ts @@ -50,6 +50,52 @@ test.describe("dataset examples against the real backend @seeded", () => { await waitForBackend(request); }); + for (const entry of ["chat", "dataset"]) { + test(`imports a template from ${entry} into the visible prompt box without sending @seeded`, async ({ page, request }) => { + test.setTimeout(90_000); + const headers = compatibilityHeaders(); + const list = await request.get( + `/api/datasets/seeds?selection_key=${encodeURIComponent(SELECTION_KEY)}&limit=20`, + { headers }, + ); + expect(list.ok(), await list.text()).toBe(true); + const examples = await list.json() as SeedExamplePage; + const selected = examples.items.find((example: SeedExampleSummary) => example.name === "wikipedia with title"); + if (!selected) throw new Error("Expected the bundled wikipedia with title template"); + const response = await request.get( + `/api/datasets/seeds/${selected.example_id}?selection_key=${encodeURIComponent(SELECTION_KEY)}`, + { headers }, + ); + expect(response.ok(), await response.text()).toBe(true); + const detail = await response.json() as SeedExampleDetail; + const prompt = detail.members.find((member: SeedMember) => member.data_type === "text"); + if (!prompt) throw new Error("Expected template text"); + const mutations: string[] = []; + page.on("request", (outgoing) => { + if (outgoing.method() === "POST") mutations.push(new URL(outgoing.url()).pathname); + }); + + if (entry === "chat") { + await page.goto("/attack/chat"); + await expect(page.getByPlaceholder("Type prompt here")).toBeEnabled(); + await page.getByRole("button", { name: "Datasets", exact: true }).click(); + await page.getByRole("link", { name: "jailbreak_templates", exact: true }).click(); + } else { + await page.goto(`/datasets/detail?selection_key=${encodeURIComponent(SELECTION_KEY)}`); + } + await page.getByRole("button", { name: "Show wikipedia with title", exact: true }).click(); + await page.getByTestId("import-into-chat-button").click(); + await page.getByTestId("import-new-attack-item").click(); + await expect(page).toHaveURL(/\/attack\/chat$/); + const composer = page.getByPlaceholder("Type prompt here"); + await expect(composer).toBeVisible(); + await expect(composer).toHaveValue(prompt.value); + await composer.fill("Edited imported template"); + await expect(composer).toHaveValue("Edited imported template"); + expect(mutations.filter((path: string) => path.startsWith("/api/attacks"))).toEqual([]); + }); + } + test("lists stored examples and opens one member @seeded", async ({ page, request }) => { test.setTimeout(90_000); const headers = compatibilityHeaders(); diff --git a/frontend/src/App.drafts.test.tsx b/frontend/src/App.drafts.test.tsx index 472c60ee2a..cf2b4f6ec4 100644 --- a/frontend/src/App.drafts.test.tsx +++ b/frontend/src/App.drafts.test.tsx @@ -93,6 +93,13 @@ jest.mock('@/components/Layout/MainLayout', () => { }, replaceDirty: true, })}>Stage conversation import + + + + + } {editorNotice && {editorNotice.message} diff --git a/frontend/src/components/Chat/ConversationEditor.test.tsx b/frontend/src/components/Chat/ConversationEditor.test.tsx index 27298847b9..0fecdc7e4c 100644 --- a/frontend/src/components/Chat/ConversationEditor.test.tsx +++ b/frontend/src/components/Chat/ConversationEditor.test.tsx @@ -483,6 +483,7 @@ describe('ConversationEditor', () => { undefined, undefined, undefined, + false, true, ) expect(screen.getByTestId('conversation-editor')).toBeInTheDocument() diff --git a/frontend/src/components/Datasets/SeedExampleBrowser.test.tsx b/frontend/src/components/Datasets/SeedExampleBrowser.test.tsx index 25bef6e59f..0116a8a6c7 100644 --- a/frontend/src/components/Datasets/SeedExampleBrowser.test.tsx +++ b/frontend/src/components/Datasets/SeedExampleBrowser.test.tsx @@ -1,10 +1,12 @@ import { useState } from 'react' -import { render, screen, waitFor, within } from '@testing-library/react' +import { act, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { FluentProvider, webLightTheme } from '@fluentui/react-components' import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router' -import { apiClient } from '@/services/api' +import { apiClient, attacksApi } from '@/services/api' +import { ChatDraftProvider, useChatDraftHandoff } from '@/hooks/useChatDraftHandoff' +import { deferred } from '@/test-utils/deferred' import { advanceAutoRefresh } from '@/test-utils/autoRefresh' import type { DatasetInfo, SeedExampleDetailResponse, SeedExampleListResponse, SeedExampleSummary, SeedRecord } from '@/types' @@ -118,29 +120,33 @@ function seedListCalls(): unknown[][] { return mockGet.mock.calls.filter((call) => call[0] === '/datasets/seeds') } -function renderBrowser(search = '', state?: unknown) { +function renderBrowser(search = '', state?: unknown, activeAttackId?: string) { const path = `/datasets/detail?selection_key=${encodeURIComponent(DATASET.selection_key)}${search}` const url = new URL(path, 'http://localhost') return render( - + , ) } -function Harness({ dataset }: { dataset: DatasetInfo }) { +function Harness({ dataset, activeAttackId }: { dataset: DatasetInfo; activeAttackId?: string }) { const location = useLocation() const navigate = useNavigate() + const handoff = useChatDraftHandoff() const [epoch, setEpoch] = useState(0) const [runtimeTick, setRuntimeTick] = useState(0) return ( <> - } /> + } /> + Another page} /> {`${location.pathname}${location.search}`} + {handoff.pendingImport ? 'waiting' : 'none'} + { location.state && typeof location.state === 'object' && 'catalogPath' in location.state ? String((location.state as { catalogPath?: unknown }).catalogPath) @@ -209,6 +215,38 @@ describe('SeedExampleBrowser', () => { jest.restoreAllMocks() }) + it.each(['navigation', 'runtime', 'selection'] as const)( + 'ignores a new-conversation completion after %s changes', + async (change: 'navigation' | 'runtime' | 'selection') => { + const user = userEvent.setup() + const creation = deferred>>() + const create = jest.spyOn(attacksApi, 'createConversation').mockReturnValue(creation.promise) + const detail = { ...DETAIL, members: [member({ id: 'prompt', value: 'Template', sequence: 0 })] } + mockGet.mockImplementation((url: string) => Promise.resolve({ + data: url === '/datasets/seeds' ? listPage([FIRST, SECOND]) : detail, + })) + renderBrowser('&example=example-1', undefined, 'attack') + await screen.findByRole('heading', { level: 2, name: 'alpha' }) + await user.click(screen.getByTestId('import-into-chat-button')) + await user.click(screen.getByTestId('import-new-conversation-item')) + await waitFor(() => expect(create).toHaveBeenCalledTimes(1)) + if (change === 'navigation') { + await user.click(screen.getByRole('button', { name: 'Leave examples' })) + } else if (change === 'runtime') { + generation = 'gen-2' + await user.click(screen.getByRole('button', { name: 'Apply runtime' })) + } else { + await user.click(screen.getByRole('button', { name: 'Show beta' })) + } + const before = currentUrl().href + await act(async () => { creation.resolve({ + conversation_id: 'stale', attack_result_id: 'attack', created_at: '', + }) }) + expect(currentUrl().href).toBe(before) + expect(screen.getByLabelText('Pending import')).toHaveTextContent('none') + }, + ) + it('should retain visible examples, open detail, focus and URL through a failed background check and recovery', async () => { jest.useFakeTimers({ advanceTimers: true }) renderBrowser('&example=example-1') diff --git a/frontend/src/components/Datasets/SeedExampleBrowser.tsx b/frontend/src/components/Datasets/SeedExampleBrowser.tsx index d2b737d016..21b48d4d4f 100644 --- a/frontend/src/components/Datasets/SeedExampleBrowser.tsx +++ b/frontend/src/components/Datasets/SeedExampleBrowser.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { KeyboardEvent } from 'react' import { @@ -23,6 +23,7 @@ import { useNavigate } from 'react-router' import ConfirmDialog from '@/components/ConfirmDialog' import SearchableMultiCombobox from '@/components/SearchableMultiCombobox' import { useChatDraftHandoff } from '@/hooks/useChatDraftHandoff' +import { useRuntime } from '@/hooks/useRuntime' import { useSeedExampleDetail, useSeedExamples } from '@/hooks/useSeedExamples' import { attacksApi } from '@/services/api' import { toApiError } from '@/services/errors' @@ -128,12 +129,19 @@ export function SeedExampleBrowserView({ const helpId = useId() const navigate = useNavigate() const draftHandoff = useChatDraftHandoff() + const runtime = useRuntime() + const importRequestRef = useRef(0) const [importError, setImportError] = useState(null) const [pendingDirtyDestination, setPendingDirtyDestination] = useState(null) const effectiveAttackId = activeAttackId ?? draftHandoff.preservedDraft?.originatingAttackId ?? null const effectiveConversationId = activeConversationId ?? draftHandoff.preservedDraft?.originatingConversationId ?? null const { query, visitedCursors, generation, commit } = browserState + useLayoutEffect(() => { + importRequestRef.current += 1 + return () => { importRequestRef.current += 1 } + }, [dataset.selection_key, query.exampleId, generation, effectiveAttackId, effectiveConversationId, + draftHandoff.activeSequence, runtime.generation, runtime.ready]) const pageState = useSeedExamples(dataset.selection_key, query, generation) const detailState = useSeedExampleDetail( dataset.selection_key, @@ -284,7 +292,9 @@ export function SeedExampleBrowserView({ const executeImport = useCallback( async (destination: ImportDestinationType, replaceDirty: boolean) => { setImportError(null) - if (!detailState.detail?.members) return + if (!detailState.detail?.members || !runtime.ready) return + const requestId = ++importRequestRef.current + const expectedSequence = draftHandoff.activeSequence const outcome = buildSeedImport(detailState.detail.members) if (outcome.type === 'unsupported') { @@ -300,18 +310,26 @@ export function SeedExampleBrowserView({ const newConv = await attacksApi.createConversation(effectiveAttackId, {}) targetConversationId = newConv.conversation_id } catch (e) { - setImportError(toApiError(e).detail) + if (importRequestRef.current === requestId) setImportError(toApiError(e).detail) return } } - - // 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() - } + if (importRequestRef.current !== requestId) return + const preserved = draftHandoff.preservedDraft + const setup = preserved && (destination === 'new_conversation' + ? preserved.originatingAttackId === effectiveAttackId + : preserved.originatingAttackId === (destination === 'new_attack' ? null : effectiveAttackId) + && preserved.originatingConversationId === targetConversationId) + ? { + ...preserved.draft, text: '', attachments: [], textMetadata: undefined, + conversation: undefined, + } + : undefined const staged = draftHandoff.stagePendingImport({ destinationType: destination, + objective: outcome.objective, + setup, targetAttackId: destination === 'new_attack' ? null : effectiveAttackId, targetConversationId, content: { @@ -331,7 +349,7 @@ export function SeedExampleBrowserView({ : {}), focus: true, replaceDirty, - }) + }, expectedSequence) if (!staged) { setImportError('Import staging was superseded by another action.') @@ -339,7 +357,7 @@ export function SeedExampleBrowserView({ } if (destination === 'new_attack') { - navigate('/chat') + navigate('/attack/chat') } else if ( destination === 'current_conversation' && effectiveAttackId && @@ -350,7 +368,7 @@ export function SeedExampleBrowserView({ navigate(attackConversationRoutePath(effectiveAttackId, targetConversationId)) } }, - [detailState.detail, draftHandoff, effectiveAttackId, effectiveConversationId, navigate], + [detailState.detail, draftHandoff, effectiveAttackId, effectiveConversationId, navigate, runtime.ready], ) const handleImport = useCallback( @@ -438,7 +456,7 @@ export function SeedExampleBrowserView({ )}

{SEARCH_HELP}

-

Newest examples are listed first. This view does not run examples, import them into chat, or play media.

+

Newest examples are listed first. Import into Chat prepares an editable draft without sending it.

{status}

{!query.cursorRepeated && !pageState.loading && pageState.error && ( diff --git a/frontend/src/hooks/useChatDraftHandoff.test.tsx b/frontend/src/hooks/useChatDraftHandoff.test.tsx index 5acaab1c07..649db55a58 100644 --- a/frontend/src/hooks/useChatDraftHandoff.test.tsx +++ b/frontend/src/hooks/useChatDraftHandoff.test.tsx @@ -271,6 +271,27 @@ describe('useChatDraftHandoff and chatDraft utilities', () => { {children} ) + it('releases uploaded history on discard but not while a newer handoff still owns it', () => { + const { result } = renderHook(() => useChatDraftHandoff(), { wrapper }) + const conversation = { messages: [{ + id: 'history', role: 'user' as const, pieces: [{ + draftId: 'image', data_type: 'image_path', original_value: '', previewUrl: 'blob:history', + file: new File(['image'], 'history.png', { type: 'image/png' }), + }], + }] } + act(() => result.current.preserveDraft({ + originatingAttackId: null, originatingConversationId: null, originatingPath: '/attack/chat', + draft: { text: '', attachments: [], conversation }, + })) + act(() => { result.current.stagePendingImport({ + destinationType: 'new_attack', content: { text: '' }, conversation, + }) }) + act(() => result.current.clearPreservedDraft()) + expect(revokeObjectURLSpy).not.toHaveBeenCalledWith('blob:history') + act(() => result.current.dismissPendingImport()) + expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:history') + }) + it('returns fallback default context safely when used outside of Provider', () => { const { result } = renderHook(() => useChatDraftHandoff()) expect(result.current.preservedDraft).toBeNull() diff --git a/frontend/src/hooks/useChatDraftHandoff.tsx b/frontend/src/hooks/useChatDraftHandoff.tsx index a6555e61aa..d6f08dc516 100644 --- a/frontend/src/hooks/useChatDraftHandoff.tsx +++ b/frontend/src/hooks/useChatDraftHandoff.tsx @@ -1,9 +1,11 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import type { - ChatDraftContent, ChatDraftContextState, ChatDraftHandoffContextValue, MessageAttachment, PendingImportPayload, + ChatDraftContent, ChatDraftContextState, ChatDraftHandoffContextValue, ConversationDraftMessage, + ConversationDraftPiece, MessageAttachment, PendingImportPayload, } from '@/types' import { generateClientId } from '@/utils/clientId' +import { draftAttachment } from '@/utils/conversationDraft' import { canApplyPendingImport, isChatDraftDirty, @@ -18,15 +20,18 @@ function allDraftAttachments(draft?: ChatDraftContent | null): MessageAttachment if (!draft) return [] return [ ...draft.attachments ?? [], - ...draft.conversation?.pendingComposerDraft?.attachments ?? [], + ...draft.conversation?.messages.flatMap((message: ConversationDraftMessage) => message.pieces + .filter((piece: ConversationDraftPiece) => piece.previewUrl?.startsWith('blob:')).map(draftAttachment)) ?? [], ] } -function allImportAttachments(payload?: PendingImportPayload | null): MessageAttachment[] { +function allImportAttachments(payload?: Pick | null): MessageAttachment[] { if (!payload) return [] return [ ...payload.content?.attachments ?? [], ...payload.conversation?.composerDraft?.attachments ?? [], + ...payload.conversation?.messages.flatMap((message: ConversationDraftMessage) => message.pieces + .filter((piece: ConversationDraftPiece) => piece.previewUrl?.startsWith('blob:')).map(draftAttachment)) ?? [], ] } @@ -104,10 +109,7 @@ export function ChatDraftProvider({ children }: { children: ReactNode }) { payload: Omit, expectedSequence?: number, ): PendingImportPayload | null => { - const incomingAttachments = [ - ...payload.content.attachments ?? [], - ...payload.conversation?.composerDraft?.attachments ?? [], - ] + const incomingAttachments = allImportAttachments(payload) if (!mountedRef.current || (expectedSequence !== undefined && expectedSequence !== sequenceRef.current)) { releaseSupersededUrls(incomingAttachments, [ ...allDraftAttachments(preservedDraftRef.current?.draft), diff --git a/frontend/src/hooks/useConversationDraft.test.ts b/frontend/src/hooks/useConversationDraft.test.ts index 7460960f6b..5061d3b6dc 100644 --- a/frontend/src/hooks/useConversationDraft.test.ts +++ b/frontend/src/hooks/useConversationDraft.test.ts @@ -19,6 +19,31 @@ const initial: ConversationSaveInput = { describe('useConversationDraft', () => { beforeEach(() => jest.resetAllMocks()) + it('rehydrates uploaded history from retained files and releases the replacement URLs', () => { + const file = new File(['image'], 'history.png', { type: 'image/png' }) + jest.mocked(URL.createObjectURL).mockReturnValue('blob:restored-history') + const { result, unmount } = renderHook(() => useConversationDraft()) + act(() => result.current.begin({ + ...initial, messages: [{ + id: 'image', role: 'user', pieces: [{ + draftId: 'image', data_type: 'image_path', original_value: '', file, previewUrl: 'blob:revoked-history', + }], + }], + })) + expect(result.current.draft?.messages[0].pieces[0]).toMatchObject({ file, previewUrl: 'blob:restored-history' }) + expect(result.current.dirty).toBe(false) + unmount() + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:restored-history') + }) + + it('only treats an imported final user turn as pending when explicitly marked', () => { + const { result } = renderHook(() => useConversationDraft(undefined, { pendingUserMessage: true })) + act(() => result.current.begin({ ...initial, unsavedImport: true })) + expect(result.current.history).toHaveLength(1) + act(() => result.current.begin({ ...initial, unsavedImport: true, pendingPromptId: 'message' })) + expect(result.current.history).toHaveLength(0) + }) + const temperatureTarget: TargetInstance = { ...makeTarget({ target_registry_name: 'source', target_type: 'OpenAIChatTarget', temperature: 0.2, diff --git a/frontend/src/hooks/useConversationDraft.ts b/frontend/src/hooks/useConversationDraft.ts index 99d27a9b33..42062d2607 100644 --- a/frontend/src/hooks/useConversationDraft.ts +++ b/frontend/src/hooks/useConversationDraft.ts @@ -4,7 +4,7 @@ import { toApiError } from '@/services/errors' import { buildTemperatureTarget } from '@/services/targetRegistry' import type { AddMessageResponse, ConversationDraftMessage, ConversationDraftPiece, ConversationSaveInput, - MessageAttachment, NewAttackContext, PendingComposerDraft, PieceConversionUpdates, SaveConversationRequest, TargetInstance, + MessageAttachment, NewAttackContext, PieceConversionUpdates, SaveConversationRequest, TargetInstance, } from '@/types' import { generateClientId } from '@/utils/clientId' import { @@ -26,7 +26,6 @@ interface DraftState extends ConversationSaveInput { baselineTarget: string | undefined temperature: string unsavedImport?: boolean - pendingComposerDraft?: PendingComposerDraft } export function useConversationDraft( @@ -49,7 +48,8 @@ export function useConversationDraft( ) const validationError = draft ? validateDraft(draft.messages) : null const messages = draft?.messages ?? [] - const history = options.pendingUserMessage && messages[messages.length - 1]?.role === 'user' + const history = options.pendingUserMessage && (!draft?.unsavedImport || draft.pendingPromptId === messages[messages.length - 1]?.id) + && messages[messages.length - 1]?.role === 'user' ? messages.slice(0, -1) : messages const targetError = draft?.target ? editorTargetDisabledReason(draft.target, draftDataTypes(messages), history) : undefined @@ -62,15 +62,26 @@ export function useConversationDraft( const begin = useCallback((input: ConversationSaveInput): void => { releaseUrls() + const messages = input.messages.some((message: ConversationDraftMessage) => message.pieces.some( + (piece: ConversationDraftPiece) => piece.file, + )) ? input.messages.map((message: ConversationDraftMessage) => ({ + ...message, + pieces: message.pieces.map((piece: ConversationDraftPiece) => { + if (!piece.file) return piece + const previewUrl = URL.createObjectURL(piece.file) + urls.current.add(previewUrl) + return { ...piece, previewUrl } + }), + })) : input.messages const id = generateClientId() activeId.current = id saved.current = false setError(null) setDraft({ - ...input, id, baselineMessages: input.baselineMessages ?? input.messages, + ...input, id, messages, baselineMessages: input.baselineMessages ?? messages, baselineTarget: targetKey(input.target), temperature: input.temperature ?? '', unsavedImport: input.unsavedImport, - sourcePieceIds: input.messages.flatMap((message: ConversationDraftMessage) => message.pieces.flatMap( + sourcePieceIds: input.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 38c1b38a87..6f9f6fce5c 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -110,9 +110,10 @@ export interface ConversationEditState { initialObjective?: string sourceConversationId?: string | null sourceAttackId?: string | null + sourcePieceIds?: string[] temperature?: string target?: TargetReference | null - pendingComposerDraft?: PendingComposerDraft + pendingPromptId?: string unsavedImport?: boolean } @@ -160,6 +161,8 @@ export interface PendingImportPayload { textMetadata?: Record } conversation?: PendingImportConversation + objective?: string + setup?: ChatDraftContent replaceDirty?: boolean focus?: boolean stagedAt: number @@ -1037,7 +1040,7 @@ export interface ConversationSaveInput { baselineMessages?: ConversationDraftMessage[] temperature?: string unsavedImport?: boolean - pendingComposerDraft?: PendingComposerDraft + pendingPromptId?: string } export interface NewAttackContext { diff --git a/frontend/src/utils/chatDraft.ts b/frontend/src/utils/chatDraft.ts index 46d4a76cf8..c2d92b3931 100644 --- a/frontend/src/utils/chatDraft.ts +++ b/frontend/src/utils/chatDraft.ts @@ -30,10 +30,6 @@ export function isChatDraftDirty(draft?: Partial | null): bool 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 - if (draft.conversation?.pendingComposerDraft && ( - (typeof draft.conversation.pendingComposerDraft.text === 'string' && draft.conversation.pendingComposerDraft.text.trim().length > 0) - || (Array.isArray(draft.conversation.pendingComposerDraft.attachments) && draft.conversation.pendingComposerDraft.attachments.length > 0) - )) return true return false } diff --git a/frontend/src/utils/seedImport.test.ts b/frontend/src/utils/seedImport.test.ts index 81b2030943..6c75bf8c42 100644 --- a/frontend/src/utils/seedImport.test.ts +++ b/frontend/src/utils/seedImport.test.ts @@ -1,5 +1,5 @@ -import type { SeedRecord } from '@/types' -import { buildSeedImport } from './seedImport' +import type { ConversationDraftMessage, SeedRecord } from '@/types' +import { buildSeedImport, mergeImportedHistory } from './seedImport' function makeSeed(overrides: Partial = {}): SeedRecord { return { @@ -30,6 +30,12 @@ function makeSeed(overrides: Partial = {}): SeedRecord { } describe('buildSeedImport', () => { + it('rejects roleless multi-turn records rather than guessing user roles', () => { + expect(buildSeedImport([ + makeSeed({ sequence: 0, role: null }), makeSeed({ sequence: 1, role: null }), + ])).toEqual({ type: 'unsupported', reason: expect.stringContaining('missing roles') }) + expect(buildSeedImport([makeSeed({ sequence: null, role: null })]).type).toBe('single_prompt') + }) it('returns unsupported for empty members', () => { const outcome = buildSeedImport([]) expect(outcome.type).toBe('unsupported') @@ -38,6 +44,39 @@ describe('buildSeedImport', () => { } }) + describe('mergeImportedHistory', () => { + const message = (id: string, role: ConversationDraftMessage['role'], value: string): ConversationDraftMessage => ({ + id, role, pieces: [{ draftId: id, data_type: 'text', original_value: value }], + }) + const system = message('system', 'system', 'Destination instructions') + const user = message('user', 'user', 'Existing prompt') + const importedSystem = message('imported-system', 'system', 'Imported instructions') + const assistant = message('assistant', 'simulated_assistant', 'Imported response') + + it('requires an explicit choice when system instructions differ', () => { + expect(mergeImportedHistory([system, user], [importedSystem, assistant])).toBeNull() + expect(mergeImportedHistory([system, user], [importedSystem, assistant], 'destination')) + .toEqual([system, user, assistant]) + expect(mergeImportedHistory([system, user], [importedSystem, assistant], 'imported')) + .toEqual([importedSystem, user, assistant]) + }) + + it('deduplicates identical setup and places imported setup before existing dialogue', () => { + expect(mergeImportedHistory([system, user], [{ ...system, id: 'copy' }, assistant])) + .toEqual([system, user, assistant]) + expect(mergeImportedHistory([user], [importedSystem, assistant])) + .toEqual([importedSystem, user, assistant]) + }) + + it('treats developer instructions and converted setup as part of the conflict', () => { + const developer = message('developer', 'developer', 'Developer instructions') + expect(mergeImportedHistory([system, developer], [system])).toBeNull() + const converted = { ...system, pieces: [{ ...system.pieces[0], converted_value: 'Changed instructions' }] } + expect(mergeImportedHistory([system], [converted])).toBeNull() + expect(mergeImportedHistory([], [system, developer, assistant])).toEqual([system, developer, assistant]) + }) + }) + it('rejects simulated_conversation configs with clear explanation', () => { const members: SeedRecord[] = [ makeSeed({ diff --git a/frontend/src/utils/seedImport.ts b/frontend/src/utils/seedImport.ts index aaa812cc36..1c84a8661a 100644 --- a/frontend/src/utils/seedImport.ts +++ b/frontend/src/utils/seedImport.ts @@ -37,6 +37,25 @@ export interface UnsupportedImport { export type SeedImportOutcome = SinglePromptImport | ConversationImport | UnsupportedImport +/** Reconciles setup before dialogue; null requires an explicit conflict choice. */ +export function mergeImportedHistory( + existing: ConversationDraftMessage[], imported: ConversationDraftMessage[], + choice?: 'destination' | 'imported', +): ConversationDraftMessage[] | null { + const isSetup = (message: ConversationDraftMessage): boolean => message.role === 'system' || message.role === 'developer' + const destinationSetup = existing.filter(isSetup) + const importedSetup = imported.filter(isSetup) + const signature = (messages: ConversationDraftMessage[]): string => JSON.stringify(messages.map( + (message: ConversationDraftMessage) => [message.role, message.pieces.map( + (piece: ConversationDraftPiece) => [piece.converted_value_data_type ?? piece.data_type, piece.converted_value ?? piece.original_value], + )], + )) + if (destinationSetup.length && importedSetup.length && signature(destinationSetup) !== signature(importedSetup) && !choice) return null + const setup = importedSetup.length && (!destinationSetup.length || choice === 'imported') ? importedSetup : destinationSetup + return [...setup, ...existing.filter((message: ConversationDraftMessage) => !isSetup(message)), + ...imported.filter((message: ConversationDraftMessage) => !isSetup(message))] +} + /** * Maps a stored SeedRecord role to a conversation draft role. * Imported assistant and tool messages strictly use simulated roles. @@ -127,6 +146,12 @@ export function buildSeedImport(members: readonly SeedRecord[]): SeedImportOutco // 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 (nonNullRoles.length === 0 && new Set(promptRecords.map((record: SeedRecord) => record.sequence ?? 0)).size > 1) { + return { type: 'unsupported', reason: 'Conversation turns are missing roles, making the dialogue ambiguous.' } + } + if (nonNullRoles.some((role: ChatMessageRole) => mapToDraftRole(role) === null)) { + return { type: 'unsupported', reason: 'This example contains unsupported role values.' } + } if (promptRecords.length > 1) { if (nonNullRoles.length > 0 && nonNullRoles.length < promptRecords.length) {