diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index 87b8491f20..6309357c60 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -189,6 +189,26 @@ 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, 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. + Load data through PyRIT; the visible catalog checks for changes each minute. See [datasets](../code/datasets/0_dataset.md) for loading options. 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 27b2d0b1d6..cf2b4f6ec4 100644 --- a/frontend/src/App.drafts.test.tsx +++ b/frontend/src/App.drafts.test.tsx @@ -5,12 +5,13 @@ 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' +import { computeAttachmentSignature } from '@/utils/chatDraft' import type { - AttackSummary, BackendMessage, ConverterInstance, ConverterPreviewResponse, MessageSendStatus, ScorerInstance, + AttackSummary, BackendMessage, ConverterInstance, ConverterPreviewResponse, MessageSendStatus, SaveConversationRequest, ScorerInstance, } from '@/types' import { targetReference } from '@/utils/targetIdentity' import { DEFAULT_USER_PREFERENCES, readUserPreferences, writeUserPreferences } from '@/utils/userPreferences' @@ -39,10 +40,6 @@ jest.mock('@/components/Home/Home', () => ({ __esModule: true, default: () =>

Home

, })) -jest.mock('@/components/Datasets/DatasetCatalog', () => ({ - __esModule: true, - default: () =>

Dataset catalog

, -})) jest.mock('@/components/Scenarios/ScenarioCatalog', () => ({ __esModule: true, default: () =>

Scanner launch

, @@ -84,6 +81,114 @@ jest.mock('@/components/Layout/MainLayout', () => { content: { text: 'Import for saved main' }, replaceDirty: true, })}>Stage saved import + + + + + + + + {handoff.pendingImport ? 'waiting' : 'none'} {location.pathname} {featureNavigation} @@ -99,6 +204,7 @@ jest.mock('@/services/api', () => ({ targetsApi: { listTargets: jest.fn(), getTarget: jest.fn() }, attacksApi: { createAttack: jest.fn(), + createConversation: jest.fn(), listAttacks: jest.fn(), getAttackOptions: jest.fn(), getConverterOptions: jest.fn(), @@ -125,6 +231,7 @@ jest.mock('@/services/api', () => ({ listSeedExamples: jest.fn().mockResolvedValue({ items: [], total: 2, pagination: { limit: 20, has_more: false, next_cursor: null, prev_cursor: null }, }), + getSeedExample: jest.fn(), }, })) @@ -165,11 +272,29 @@ function savedAttack(id = 'saved'): AttackSummary { return { attack_result_id: id, conversation_id: 'main', attack_type: 'ManualAttack', objective: '', converters: [], message_count: 0, labels: {}, operator: null, - related_conversation_ids: ['branch'], target: null, target_unbound: true, + related_conversation_ids: ['branch', 'new-conv-id'], target: null, target_unbound: true, created_at: '', updated_at: '', } } +function savedMessages(request: SaveConversationRequest): BackendMessage[] { + return request.messages.map((message, index: number) => ({ + role: message.role, + turn_number: index, + created_at: '', + message_pieces: message.pieces.map((piece, pieceIndex: number) => ({ + id: `saved-${index}-${pieceIndex}`, + original_value: piece.original_value, + original_value_data_type: piece.data_type, + converted_value: piece.converted_value ?? piece.original_value, + converted_value_data_type: piece.converted_value_data_type ?? piece.data_type, + prompt_metadata: piece.prompt_metadata, + scores: [], + response_error: 'none', + })), + })) +} + function renderChat(path = '/attack/chat', strict = false): ReturnType { const router = createMemoryRouter([{ path: '*', element: }], { initialEntries: [path] }) const app = @@ -177,12 +302,12 @@ function renderChat(path = '/attack/chat', strict = false): ReturnType { - await user.click(screen.getByRole('button', { name: 'Datasets', exact: true })) - await screen.findByRole('heading', { name: 'Dataset catalog' }) + await user.click(await screen.findByRole('button', { name: 'Datasets', exact: true })) + await screen.findByRole('heading', { name: 'Datasets' }) } async function returnToChat(user: UserEvent): Promise { - await user.click(screen.getByRole('button', { name: 'Chat', exact: true })) + await user.click(await screen.findByRole('button', { name: 'Chat', exact: true })) await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) } @@ -197,6 +322,12 @@ async function addConverter(user: UserEvent): Promise { await user.click(await screen.findByRole('option', { name: /draft-converter/ })) } +async function selectDraftTarget(user: UserEvent): Promise { + const picker = await screen.findByRole('combobox', { name: 'Objective target' }) + await waitFor(() => expect(picker).toBeEnabled()) + await user.selectOptions(picker, TARGET.target_registry_name) +} + describe('App draft handoff with the real chat and composer', () => { let restoreRequest: () => void beforeAll(() => { restoreRequest = installRouterTestRequest() }) @@ -226,8 +357,79 @@ describe('App draft handoff with the real chat and composer', () => { conversation_id: conversationId, messages: [], target_response_status: null, })) jest.mocked(attacksApi.getConversations).mockResolvedValue({ conversations: [], main_conversation_id: 'main' }) + jest.mocked(attacksApi.createConversation).mockResolvedValue({ + conversation_id: 'new-conv-id', + attack_result_id: 'saved', + created_at: '2026-01-01T00:00:00Z', + }) jest.mocked(convertersApi.listConverters).mockResolvedValue({ items: [CONVERTER] }) jest.mocked(convertersApi.previewConversion).mockResolvedValue(convertedText()) + jest.mocked(datasetsApi.listDatasets).mockResolvedValue({ + items: [ + { + name: 'Example dataset', selection_key: 'example-dataset', + loaded: true, provider_available: true, logical_examples: 2, seed_pieces: 2, objectives: 0, + modalities: ['text'], harm_categories: [], has_unlabeled_harm_categories: true, + }, + { + name: 'jailbreak_templates', selection_key: 'dataset:named:jailbreak_templates', + loaded: true, provider_available: true, logical_examples: 1, seed_pieces: 1, objectives: 0, + modalities: ['text'], harm_categories: [], has_unlabeled_harm_categories: false, + }, + ], + }) + jest.mocked(datasetsApi.listSeedExamples).mockResolvedValue({ + items: [{ + example_id: 'wikipedia_with_title', + name: 'wikipedia with title', + preview: 'Write a wikipedia article on {{ prompt }}. Start the article with "Title:".', + preview_truncated: false, + modalities: ['text'], + seed_types: ['prompt'], + piece_count: 1, + objective_count: 0, + harm_categories: [], + has_unlabeled_harm: false, + }], + total: 1, + pagination: { limit: 20, has_more: false, next_cursor: null, prev_cursor: null }, + }) + jest.mocked(datasetsApi.getSeedExample).mockResolvedValue({ + example_id: 'wikipedia_with_title', + name: 'wikipedia with title', + preview: 'Write a wikipedia article on {{ prompt }}. Start the article with "Title:".', + preview_truncated: false, + modalities: ['text'], + seed_types: ['prompt'], + piece_count: 1, + objective_count: 0, + harm_categories: [], + has_unlabeled_harm: false, + members: [{ + id: 'member-wiki-1', + seed_type: 'prompt', + value: 'Write a wikipedia article on {{ prompt }}. Start the article with "Title:".', + value_sha256: 'abc123', + data_type: 'text', + name: 'wikipedia with title', + dataset_name: 'jailbreak_templates', + origin: 'local', + harm_categories: [], + description: 'Template prompt', + authors: ['PyRIT team'], + groups: [], + source: 'jailbreak_templates', + date_added: '2026-01-01T00:00:00Z', + added_by: 'tester', + metadata: null, + prompt_group_id: null, + sequence: 0, + role: 'user', + parameters: ['prompt'], + conditions: [], + response_json_schema: null, + }], + }) }) afterEach(() => { jest.useRealTimers() }) @@ -359,7 +561,7 @@ describe('App draft handoff with the real chat and composer', () => { }) }) expect(readUserPreferences('local').lastUsedTargets?.objective).toEqual(targetReference(TARGET)) expect(readUserPreferences('local').lastUsedChatScorer).toEqual(rememberedScorer) - expect(screen.getByRole('heading', { name: 'Dataset catalog' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Datasets' })).toBeInTheDocument() }) it.each([false, true])('should preserve an unsaved draft across repeated visits with StrictMode=%s', async (strict: boolean) => { @@ -932,7 +1134,7 @@ describe('App draft handoff with the real chat and composer', () => { await user.click(screen.getByRole('button', { name: 'Back', exact: true })) await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('Imported prompt with my edit')) await user.click(screen.getByRole('button', { name: 'Forward', exact: true })) - await screen.findByRole('heading', { name: 'Dataset catalog' }) + await screen.findByRole('heading', { name: 'Datasets' }) await returnToChat(user) expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('Imported prompt with my edit') }) @@ -986,4 +1188,961 @@ 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('/attack/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() + }) + + it('should populate composer with template prompt after visiting datasets from clean chat through real UI (Roman journey)', async () => { + const user = userEvent.setup() + renderChat('/attack/chat') + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) + expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('') + + // 1. User visits datasets from clean chat + await visitDatasets(user) + await screen.findByRole('heading', { name: 'Datasets' }) + + // 2. User selects jailbreak_templates dataset + await user.click(await screen.findByRole('link', { name: 'jailbreak_templates' })) + await screen.findByRole('heading', { name: 'jailbreak_templates' }) + + // 3. User selects the wikipedia with title example + await user.click(await screen.findByRole('button', { name: /Show wikipedia with title/i })) + await screen.findByRole('heading', { level: 2, name: 'wikipedia with title' }) + + // 4. User clicks real Import into Chat -> Import to new attack + await user.click(screen.getByTestId('import-into-chat-button')) + const importItem = await screen.findByTestId('import-new-attack-item') + await user.click(importItem) + + // 5. App navigates back to Chat + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) + + // 6. Assertions: + // - The prompt box contains the template text + expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue( + 'Write a wikipedia article on {{ prompt }}. Start the article with "Title:".', + ) + // - No prompt was sent + expect(attacksApi.submitMessageSend).not.toHaveBeenCalled() + // - Pending import was consumed exactly once + expect(screen.getByLabelText('Pending import')).toHaveTextContent('none') + }) + + it('should retain system setup and an unapplied pipeline while importing a single prompt objective', async () => { + jest.useFakeTimers() + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) + const detail = await datasetsApi.getSeedExample('wikipedia_with_title', 'dataset:named:jailbreak_templates') + jest.mocked(datasetsApi.getSeedExample).mockResolvedValue({ + ...detail, members: [...detail.members, { ...detail.members[0], id: 'objective', seed_type: 'objective', value: 'Imported single objective' }], + }) + renderChat() + await selectDraftTarget(user) + await user.click(screen.getByRole('button', { name: 'System Prompt', exact: true })) + await user.type(screen.getByRole('textbox', { name: 'System prompt', exact: true }), 'Destination instructions') + await addConverter(user) + await visitDatasets(user) + await user.click(await screen.findByRole('link', { name: 'jailbreak_templates' })) + await user.click(await screen.findByRole('button', { name: /Show wikipedia with title/i })) + await user.click(await screen.findByRole('button', { name: 'Import into Chat', exact: true })) + await user.click(screen.getByTestId('import-new-attack-item')) + await user.click(await screen.findByRole('button', { name: 'Replace', exact: true })) + await act(async () => { jest.advanceTimersByTime(1000) }) + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue(detail.members[0].value)) + expect(screen.getByTestId('objective-header-content')).toHaveTextContent('Imported single objective') + await user.click(await screen.findByRole('button', { name: 'System Prompt', exact: true })) + expect(screen.getByRole('textbox', { name: 'System prompt', exact: true })).toHaveValue('Destination instructions') + await user.click(screen.getByRole('button', { name: 'Toggle converter panel' })) + expect(await screen.findByRole('button', { name: 'Remove converter draft-converter' })).toBeVisible() + expect(screen.queryByRole('textbox', { name: 'Stage 1 output - Text' })).not.toBeInTheDocument() + expect(attacksApi.submitMessageSend).not.toHaveBeenCalled() + }) + + it.each(['destination', 'imported'] as const)('should explicitly reconcile %s setup before consuming an import', async (choice: 'destination' | 'imported') => { + const user = userEvent.setup() + renderChat() + await selectDraftTarget(user) + await user.click(screen.getByRole('button', { name: 'System Prompt', exact: true })) + await user.type(screen.getByRole('textbox', { name: 'System prompt', exact: true }), 'Destination instructions') + await user.click(screen.getByRole('button', { name: 'Stage system import' })) + expect(await screen.findByText(/different system or developer instructions/)).toBeVisible() + expect(screen.getByLabelText('Pending import')).toHaveTextContent('waiting') + expect(screen.queryByTestId('conversation-editor')).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { + name: choice === 'destination' ? 'Keep destination setup' : 'Use imported setup', + })) + const imported = await screen.findByTestId('conversation-editor') + expect(within(imported).getByDisplayValue(choice === 'destination' ? 'Destination instructions' : 'Imported instructions')).toBeVisible() + expect(within(imported).getAllByRole('region')).toHaveLength(2) + expect(screen.getByLabelText('Pending import')).toHaveTextContent('none') + }) + + it('should retain a template imported directly from datasets when StrictMode replays the first chat mount', async () => { + const user = userEvent.setup() + renderChat('/datasets/detail?selection_key=dataset%3Anamed%3Ajailbreak_templates&example=wikipedia_with_title', true) + await screen.findByRole('heading', { level: 2, name: 'wikipedia with title' }) + await user.click(screen.getByTestId('import-into-chat-button')) + await user.click(screen.getByTestId('import-new-attack-item')) + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue( + 'Write a wikipedia article on {{ prompt }}. Start the article with "Title:".', + )) + await user.click(screen.getByRole('button', { name: 'Datasets', exact: true })) + await screen.findByRole('heading', { level: 1, name: 'jailbreak_templates' }) + await returnToChat(user) + expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue( + 'Write a wikipedia article on {{ prompt }}. Start the article with "Title:".', + ) + }) + + it.each(['assistant', 'tool', 'trace'] as const)('should require a new destination rather than silently branching over %s evidence', async (evidence: 'assistant' | 'tool' | 'trace') => { + const user = userEvent.setup() + jest.mocked(attacksApi.getMessages).mockResolvedValue({ + conversation_id: 'main', target_response_status: null, messages: [{ + role: evidence === 'trace' ? 'user' : evidence, turn_number: 0, created_at: '', + message_pieces: [{ + id: 'sent-piece', original_value_data_type: 'text', original_value: 'Sent evidence', + converted_value_data_type: 'text', converted_value: 'Sent evidence', + prompt_metadata: evidence === 'trace' ? { pyrit_request_trace: { version: 1 } } : {}, + scores: [], response_error: 'none', + }], + }], + }) + renderChat('/attack/chat/saved/conversations/main') + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) + await user.click(screen.getByRole('button', { name: 'Stage saved conversation import' })) + expect(await screen.findByText(/This conversation contains sent evidence/)).toBeVisible() + expect(screen.getByLabelText('Pending import')).toHaveTextContent('waiting') + expect(screen.queryByTestId('conversation-editor')).not.toBeInTheDocument() + expect(attacksApi.saveConversation).not.toHaveBeenCalled() + }) + + it('should handle dirty draft confirmation for new_conversation import (cancel vs confirm)', async () => { + const user = userEvent.setup() + // Start at a saved attack conversation + renderChat('/attacks/saved/conversations/main') + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) + + // User types a dirty draft + await user.type(screen.getByPlaceholderText('Type prompt here'), 'Unsaved attack message') + + // User visits datasets + await visitDatasets(user) + await user.click(await screen.findByRole('link', { name: 'jailbreak_templates' })) + await user.click(await screen.findByRole('button', { name: /Show wikipedia with title/i })) + + // User attempts to import to new conversation + await user.click(screen.getByTestId('import-into-chat-button')) + const newConvItem = await screen.findByTestId('import-new-conversation-item') + await user.click(newConvItem) + + // Confirmation dialog appears because destination attack has a dirty draft + expect(await screen.findByRole('dialog', { name: 'Replace Existing Draft?' })).toBeInTheDocument() + + // 1. Clicking Cancel leaves the dirty draft and stays on datasets without creating conversation + await user.click(screen.getByRole('button', { name: 'Cancel' })) + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Replace Existing Draft?' })).not.toBeInTheDocument()) + expect(attacksApi.createConversation).not.toHaveBeenCalled() + + // Return to chat: original dirty draft is intact! + await returnToChat(user) + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('Unsaved attack message')) + + // 2. Now user visits datasets again and confirms replacement + await user.click(await screen.findByRole('button', { name: 'Datasets', exact: true })) + await screen.findByRole('heading', { name: 'jailbreak_templates' }) + await user.click(await screen.findByTestId('import-into-chat-button')) + await user.click(await screen.findByTestId('import-new-conversation-item')) + + expect(await screen.findByRole('dialog', { name: 'Replace Existing Draft?' })).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Replace' })) + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Replace Existing Draft?' })).not.toBeInTheDocument()) + + // New conversation is created, navigated, and populated with template prompt + await waitFor(() => expect(attacksApi.createConversation).toHaveBeenCalledWith('saved', {})) + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue( + 'Write a wikipedia article on {{ prompt }}. Start the article with "Title:".', + )) + expect(screen.getByLabelText('Current route')).toHaveTextContent('/attack/chat/saved/conversations/new-conv-id') + }) + + it('should import to new_conversation immediately without confirmation when draft is clean', async () => { + const user = userEvent.setup() + renderChat('/attacks/saved/conversations/main') + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) + expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('') + + await visitDatasets(user) + await user.click(await screen.findByRole('link', { name: 'jailbreak_templates' })) + await user.click(await screen.findByRole('button', { name: /Show wikipedia with title/i })) + + await user.click(screen.getByTestId('import-into-chat-button')) + await user.click(await screen.findByTestId('import-new-conversation-item')) + + // No dialog shown; proceeds directly + expect(screen.queryByRole('dialog', { name: 'Replace Existing Draft?' })).not.toBeInTheDocument() + await waitFor(() => expect(attacksApi.createConversation).toHaveBeenCalledWith('saved', {})) + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue( + 'Write a wikipedia article on {{ prompt }}. Start the article with "Title:".', + )) + expect(screen.getByLabelText('Current route')).toHaveTextContent('/attack/chat/saved/conversations/new-conv-id') + }) + + 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: 'simulated_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, + }) + jest.mocked(attacksApi.saveConversation).mockImplementation(async (request: SaveConversationRequest) => ({ + attack: savedAttack(), messages: { conversation_id: 'main', messages: savedMessages(request), 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' })) + + // 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(4) + 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 + expect(within(messageBodies[3]).getByDisplayValue('Unsent user prompt')).toBeVisible() + await user.click(screen.getByRole('button', { name: 'Save', exact: true })) + await waitFor(() => expect(attacksApi.saveConversation).toHaveBeenCalledWith(expect.objectContaining({ + source_conversation_id: 'main', reuse_unsent_conversation: true, + expected_source_piece_ids: ['p-orig-1', 'p-orig-2'], + }))) + }) + + 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() + }) + }) + + describe('Final user turn preservation during Import -> Save', () => { + it('does not rebind an explicitly cleared imported editor target after a datasets round-trip', async () => { + const user = userEvent.setup() + renderChat() + await selectDraftTarget(user) + await user.click(screen.getByRole('button', { name: 'Stage conversation import' })) + await screen.findByTestId('conversation-editor') + await user.selectOptions(screen.getByRole('combobox', { name: 'Objective target' }), '') + await visitDatasets(user) + await user.click(await screen.findByRole('button', { name: 'Chat', exact: true })) + await screen.findByTestId('conversation-editor') + expect(screen.getByRole('combobox', { name: 'Objective target' })).toHaveValue('') + }) + + it('does not move an earlier history user turn to the composer after the pending turn is deleted', async () => { + const user = userEvent.setup() + jest.mocked(attacksApi.saveConversation).mockImplementation(async (request: SaveConversationRequest) => ({ + attack: savedAttack(), messages: { conversation_id: 'main', messages: savedMessages(request), target_response_status: null }, + })) + renderChat('/attack/chat/saved/conversations/main') + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) + await user.click(screen.getByRole('button', { name: 'Stage A-B-C with attachment import' })) + await user.click(await screen.findByRole('button', { name: 'Delete message 3' })) + await user.click(screen.getByRole('button', { name: 'Delete message 2' })) + await user.click(screen.getByRole('button', { name: 'Save', exact: true })) + await waitFor(() => expect(attacksApi.saveConversation).toHaveBeenCalledWith(expect.objectContaining({ + messages: [{ role: 'user', pieces: [expect.objectContaining({ original_value: 'Turn A message' })] }], + }))) + await waitFor(() => expect(screen.queryByTestId('conversation-editor')).not.toBeInTheDocument()) + expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('') + }) + + it('preserves user C with attachment after Save, without duplicating C in history', async () => { + const user = userEvent.setup() + let savedPayload: SaveConversationRequest | null = null + jest.mocked(attacksApi.saveConversation).mockImplementation(async (req) => { + savedPayload = req + return { + attack: { ...savedAttack('saved'), objective: req.objective ?? '' }, + messages: { conversation_id: 'main', messages: savedMessages(req), target_response_status: null }, + } + }) + + renderChat('/attacks/saved/conversations/main') + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) + + // Stage import: User A -> Assistant B -> User C (with attachment on C) + await user.click(screen.getByRole('button', { name: 'Stage A-B-C with attachment import' })) + + // ConversationEditor is opened + await waitFor(() => expect(screen.getByTestId('conversation-editor')).toBeInTheDocument()) + + // The final unsent turn is visible and editable before saving history. + const editorElement = screen.getByTestId('conversation-editor') + expect(within(editorElement).getByText('Turn A message')).toBeInTheDocument() + expect(within(editorElement).getByText('Turn B message')).toBeInTheDocument() + const finalPrompt = within(editorElement).getByDisplayValue('Turn C prompt') + expect(finalPrompt).toBeVisible() + await user.clear(finalPrompt) + await user.type(finalPrompt, 'Edited C prompt') + + // Click Save in editor + await user.click(screen.getByRole('button', { name: 'Save' })) + + // Verify save API payload + await waitFor(() => expect(attacksApi.saveConversation).toHaveBeenCalled()) + expect(savedPayload).not.toBeNull() + // History sent to backend has only turns A and B: Turn C is NOT duplicated in saved history + expect(savedPayload!.messages).toHaveLength(2) + expect(savedPayload!.messages[0].pieces[0].original_value).toBe('Turn A message') + expect(savedPayload!.messages[1].pieces[0].original_value).toBe('Turn B message') + expect(savedPayload).toMatchObject({ + source_conversation_id: 'main', reuse_unsent_conversation: true, expected_source_piece_ids: [], + }) + + // Editor closes, visible composer has Turn C with its attachment + await waitFor(() => expect(screen.queryByTestId('conversation-editor')).not.toBeInTheDocument()) + expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('Edited C prompt') + expect(screen.getByRole('button', { name: 'Remove img-c.png' })).toBeInTheDocument() + }) + + it('does not create a false pending composer prompt when conversation ends in assistant content', async () => { + const user = userEvent.setup() + let savedPayload: SaveConversationRequest | null = null + jest.mocked(attacksApi.saveConversation).mockImplementation(async (req) => { + savedPayload = req + return { + attack: { ...savedAttack('saved'), objective: req.objective ?? '' }, + messages: { conversation_id: 'main', messages: savedMessages(req), 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 ending in assistant import' })) + await waitFor(() => expect(screen.getByTestId('conversation-editor')).toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(attacksApi.saveConversation).toHaveBeenCalled()) + expect(savedPayload!.messages).toHaveLength(2) + + // Composer is empty + await waitFor(() => expect(screen.queryByTestId('conversation-editor')).not.toBeInTheDocument()) + expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('') + expect(screen.queryByRole('button', { name: /Remove/i })).not.toBeInTheDocument() + }) + + it('does not mistake an earlier history user message for a pending prompt when editor history ends in user', async () => { + const user = userEvent.setup() + let savedPayload: SaveConversationRequest | null = null + jest.mocked(attacksApi.saveConversation).mockImplementation(async (req) => { + savedPayload = req + return { + attack: { ...savedAttack('saved'), objective: req.objective ?? '' }, + messages: { conversation_id: 'main', messages: savedMessages(req), target_response_status: null }, + } + }) + + renderChat('/attacks/saved/conversations/main') + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) + + // There is no separate pending prompt in this imported history. + await user.click(screen.getByRole('button', { name: 'Stage history ending in user message import' })) + await waitFor(() => expect(screen.getByTestId('conversation-editor')).toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(attacksApi.saveConversation).toHaveBeenCalled()) + + // History sent to backend includes all 3 messages (including 'History user 3') + expect(savedPayload!.messages).toHaveLength(3) + expect(savedPayload!.messages[2].pieces[0].original_value).toBe('History user 3') + + // Composer is empty (History user 3 was NOT sliced out into the composer) + await waitFor(() => expect(screen.queryByTestId('conversation-editor')).not.toBeInTheDocument()) + expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('') + }) + + it('preserves the visible final user turn across navigation and restores it on save', async () => { + const user = userEvent.setup() + let savedPayload: SaveConversationRequest | null = null + jest.mocked(attacksApi.saveConversation).mockImplementation(async (req) => { + savedPayload = req + return { + attack: { ...savedAttack('saved'), objective: req.objective ?? '' }, + messages: { conversation_id: 'main', messages: savedMessages(req), target_response_status: null }, + } + }) + + renderChat('/attacks/saved/conversations/main') + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) + + // 1. Stage an A/B/C import where C includes an attachment + await user.click(screen.getByRole('button', { name: 'Stage A-B-C with attachment import' })) + + // 2. Open the ConversationEditor + await waitFor(() => expect(screen.getByTestId('conversation-editor')).toBeInTheDocument()) + expect(within(screen.getByTestId('conversation-editor')).getByText('Turn A message')).toBeInTheDocument() + expect(within(screen.getByTestId('conversation-editor')).getByText('Turn B message')).toBeInTheDocument() + + // 3. Navigate away before saving + await visitDatasets(user) + + // 4. Navigate back to original destination + await returnToChat(user) + + // 5. Verify the editor is restored + await waitFor(() => expect(screen.getByTestId('conversation-editor')).toBeInTheDocument()) + expect(within(screen.getByTestId('conversation-editor')).getByText('Turn A message')).toBeInTheDocument() + expect(within(screen.getByTestId('conversation-editor')).getByText('Turn B message')).toBeInTheDocument() + + // 6. Save the editor + await user.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(attacksApi.saveConversation).toHaveBeenCalled()) + + // 7. Verify: + // - A and B are saved as history + // - C is not duplicated in saved history + expect(savedPayload).not.toBeNull() + expect(savedPayload!.messages).toHaveLength(2) + expect(savedPayload!.messages[0].pieces[0].original_value).toBe('Turn A message') + expect(savedPayload!.messages[1].pieces[0].original_value).toBe('Turn B message') + + // - C and its attachment appear in the visible composer after Save + await waitFor(() => expect(screen.queryByTestId('conversation-editor')).not.toBeInTheDocument()) + expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('Turn C prompt') + expect(screen.getByRole('button', { name: 'Remove img-c.png' })).toBeInTheDocument() + }) + + it('does not restore C when imported editor is intentionally discarded', async () => { + const user = userEvent.setup() + + renderChat('/attacks/saved/conversations/main') + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) + + // Stage A-B-C import + await user.click(screen.getByRole('button', { name: 'Stage A-B-C with attachment import' })) + await waitFor(() => expect(screen.getByTestId('conversation-editor')).toBeInTheDocument()) + + // Discard/Cancel the editor + await user.click(screen.getByRole('button', { name: 'Cancel' })) + await user.click(await screen.findByRole('button', { name: 'Discard draft' })) + + // Editor closes, C is not restored to composer + await waitFor(() => expect(screen.queryByTestId('conversation-editor')).not.toBeInTheDocument()) + expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('') + expect(screen.queryByRole('button', { name: 'Remove img-c.png' })).not.toBeInTheDocument() + + // Navigating away and back does not restore C + await visitDatasets(user) + await returnToChat(user) + expect(screen.queryByTestId('conversation-editor')).not.toBeInTheDocument() + expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('') + expect(screen.queryByRole('button', { name: 'Remove img-c.png' })).not.toBeInTheDocument() + }) + }) + + describe('ConversationEditor navigation and dirty-draft protection', () => { + it('opens discard confirmation when navigating away with a dirty normal editor and keep-editing preserves the editor', async () => { + const user = userEvent.setup() + const source: BackendMessage[] = [{ + turn_number: 0, + role: 'user', + created_at: '2026-01-01T00:00:00Z', + message_pieces: [{ + id: 'p1', original_value_data_type: 'text', original_value: 'Original turn 1', + converted_value_data_type: 'text', converted_value: 'Original turn 1', scores: [], response_error: 'none', + }], + }] + jest.mocked(attacksApi.getMessages).mockResolvedValue({ conversation_id: 'main', messages: source, target_response_status: null }) + + renderChat('/attacks/saved/conversations/main') + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) + + // Open normal editor + await user.click(screen.getByRole('button', { name: 'Edit Conversation', exact: true })) + await screen.findByRole('region', { name: 'Message 1' }) + + // Edit message to make editor dirty + const promptInput = screen.getAllByPlaceholderText('Type prompt here')[0] + await user.type(promptInput, ' modified') + + // Attempt navigation to Datasets + await user.click(await screen.findByRole('button', { name: 'Datasets', exact: true })) + + // Confirmation dialog opens + expect(await screen.findByRole('dialog', { name: 'Discard conversation draft?' })).toBeInTheDocument() + expect(screen.getByText('Your unsaved changes will be lost.')).toBeInTheDocument() + + // Choosing "Keep editing" preserves the editor and unsaved edits + await user.click(screen.getByRole('button', { name: 'Keep editing' })) + expect(screen.queryByRole('dialog', { name: 'Discard conversation draft?' })).not.toBeInTheDocument() + expect(screen.getByTestId('conversation-editor')).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Datasets' })).not.toBeInTheDocument() + expect(screen.getByDisplayValue('Original turn 1 modified')).toBeInTheDocument() + }) + + it('completes navigation and clears the draft when choosing Discard draft from normal dirty editor', async () => { + const user = userEvent.setup() + const source: BackendMessage[] = [{ + turn_number: 0, + role: 'user', + created_at: '2026-01-01T00:00:00Z', + message_pieces: [{ + id: 'p1', original_value_data_type: 'text', original_value: 'Original turn 1', + converted_value_data_type: 'text', converted_value: 'Original turn 1', scores: [], response_error: 'none', + }], + }] + jest.mocked(attacksApi.getMessages).mockResolvedValue({ conversation_id: 'main', messages: source, target_response_status: null }) + + renderChat('/attacks/saved/conversations/main') + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) + + // Open normal editor and edit + await user.click(screen.getByRole('button', { name: 'Edit Conversation', exact: true })) + await screen.findByRole('region', { name: 'Message 1' }) + const promptInput = screen.getAllByPlaceholderText('Type prompt here')[0] + await user.type(promptInput, ' modified') + + // Attempt navigation to Datasets + await user.click(await screen.findByRole('button', { name: 'Datasets', exact: true })) + expect(await screen.findByRole('dialog', { name: 'Discard conversation draft?' })).toBeInTheDocument() + + // Choosing "Discard draft" completes navigation to Datasets + await user.click(screen.getByRole('button', { name: 'Discard draft' })) + await screen.findByRole('heading', { name: 'Datasets' }) + + // Navigating back to Chat confirms editor is closed and dirty draft was cleared + await returnToChat(user) + expect(screen.queryByTestId('conversation-editor')).not.toBeInTheDocument() + expect(screen.getByText('Original turn 1')).toBeInTheDocument() + expect(screen.queryByText('Original turn 1 modified')).not.toBeInTheDocument() + }) + + it('preserves imported conversation edits across navigation without losing unsaved content', async () => { + const user = userEvent.setup() + renderChat('/attacks/saved/conversations/main') + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) + + // Stage A-B-C import + await user.click(screen.getByRole('button', { name: 'Stage A-B-C with attachment import' })) + await waitFor(() => expect(screen.getByTestId('conversation-editor')).toBeInTheDocument()) + expect(within(screen.getByTestId('conversation-editor')).getByText('Turn A message')).toBeInTheDocument() + + // Edit Turn A inside the imported conversation + const promptInput = within(screen.getByRole('region', { name: 'Message 1' })).getByPlaceholderText('Type prompt here') + await user.type(promptInput, ' edited by user') + + // Navigate away to Datasets (does not lose draft, preserves in draftHandoff) + await visitDatasets(user) + + // Navigate back to Chat + await returnToChat(user) + + // Verify the editor is restored with user's edits preserved + await waitFor(() => expect(screen.getByTestId('conversation-editor')).toBeInTheDocument()) + expect(screen.getByDisplayValue('Turn A message edited by user')).toBeInTheDocument() + expect(within(screen.getByTestId('conversation-editor')).getByText('Turn B message')).toBeInTheDocument() + }) + + it('preserves and restores a new composer draft created after cancelling a clean editor', async () => { + const user = userEvent.setup() + const source: BackendMessage[] = [{ + turn_number: 0, + role: 'user', + created_at: '2026-01-01T00:00:00Z', + message_pieces: [{ + id: 'p1', original_value_data_type: 'text', original_value: 'Initial message', + converted_value_data_type: 'text', converted_value: 'Initial message', scores: [], response_error: 'none', + }], + }] + jest.mocked(attacksApi.getMessages).mockResolvedValue({ conversation_id: 'main', messages: source, target_response_status: null }) + + renderChat('/attacks/saved/conversations/main') + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) + + // 1. Open editor + await user.click(screen.getByRole('button', { name: 'Edit Conversation', exact: true })) + await screen.findByRole('region', { name: 'Message 1' }) + + // 2. Cancel/discard clean editor without editing + await user.click(screen.getByRole('button', { name: 'Cancel' })) + await waitFor(() => expect(screen.queryByTestId('conversation-editor')).not.toBeInTheDocument()) + + // 3. Create a new composer draft + const composer = screen.getByPlaceholderText('Type prompt here') + await user.type(composer, 'Prompt after cancelling editor') + + // 4. Navigate away to Datasets + await visitDatasets(user) + + // 5. Navigate back to Chat + await returnToChat(user) + + // 6. Verify the new draft was preserved and is restored + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) + expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('Prompt after cancelling editor') + }) + + it('preserves and restores a new composer draft created after discarding a dirty editor', async () => { + const user = userEvent.setup() + const source: BackendMessage[] = [{ + turn_number: 0, + role: 'user', + created_at: '2026-01-01T00:00:00Z', + message_pieces: [{ + id: 'p1', original_value_data_type: 'text', original_value: 'Initial message', + converted_value_data_type: 'text', converted_value: 'Initial message', scores: [], response_error: 'none', + }], + }] + jest.mocked(attacksApi.getMessages).mockResolvedValue({ conversation_id: 'main', messages: source, target_response_status: null }) + + renderChat('/attacks/saved/conversations/main') + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) + + // 1. Open editor and modify message + await user.click(screen.getByRole('button', { name: 'Edit Conversation', exact: true })) + await screen.findByRole('region', { name: 'Message 1' }) + const promptInput = screen.getAllByPlaceholderText('Type prompt here')[0] + await user.type(promptInput, ' dirty edit') + + // 2. Discard dirty editor on-screen + await user.click(screen.getByRole('button', { name: 'Cancel' })) + await user.click(await screen.findByRole('button', { name: 'Discard draft' })) + await waitFor(() => expect(screen.queryByTestId('conversation-editor')).not.toBeInTheDocument()) + + // 3. Create a new composer draft + const composer = screen.getByPlaceholderText('Type prompt here') + await user.type(composer, 'Prompt after discarding dirty editor') + + // 4. Navigate away to Datasets + await visitDatasets(user) + + // 5. Navigate back to Chat + await returnToChat(user) + + // 6. Verify the new draft was preserved and is restored + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) + expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('Prompt after discarding dirty editor') + }) + }) + + describe('Imported objective baseline and Save payload', () => { + it('sets initialObjective to persisted objective A and objective to imported B, sending both in Save payload', async () => { + const user = userEvent.setup() + let savedPayload: SaveConversationRequest | null = null + jest.mocked(attacksApi.getAttack).mockImplementation(async (id: string) => ({ + ...savedAttack(id), + objective: 'Objective A', + })) + jest.mocked(attacksApi.saveConversation).mockImplementation(async (req) => { + savedPayload = req + return { + attack: { ...savedAttack('saved'), objective: req.objective ?? 'Objective A' }, + messages: { conversation_id: 'main', messages: [], target_response_status: null }, + } + }) + + renderChat('/attacks/saved/conversations/main') + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) + + // Import with Objective B into attack with Objective A + await user.click(screen.getByRole('button', { name: 'Stage A-B-C with attachment import' })) + await waitFor(() => expect(screen.getByTestId('conversation-editor')).toBeInTheDocument()) + + // Editor displays proposed objective B in ObjectiveHeader + expect(screen.getByTestId('objective-header-content')).toHaveTextContent('Imported objective B') + + // Save to same attack + await user.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(attacksApi.saveConversation).toHaveBeenCalled()) + + // Verify payload + expect(savedPayload).not.toBeNull() + expect(savedPayload!.expected_objective).toBe('Objective A') + expect(savedPayload!.objective).toBe('Imported objective B') + + // UI updates to B and does not silently revert to A + await waitFor(() => expect(screen.queryByTestId('conversation-editor')).not.toBeInTheDocument()) + expect(screen.getByTestId('objective-header-content')).toHaveTextContent('Imported objective B') + expect(screen.queryByText('Objective A')).not.toBeInTheDocument() + }) + + it('retains destination objective A when imported example has no objective', async () => { + const user = userEvent.setup() + let savedPayload: SaveConversationRequest | null = null + jest.mocked(attacksApi.getAttack).mockImplementation(async (id: string) => ({ + ...savedAttack(id), + objective: 'Objective A', + })) + jest.mocked(attacksApi.saveConversation).mockImplementation(async (req) => { + savedPayload = req + return { + attack: { ...savedAttack('saved'), objective: req.objective ?? 'Objective A' }, + messages: { conversation_id: 'main', messages: [], 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 no-objective import' })) + await waitFor(() => expect(screen.getByTestId('conversation-editor')).toBeInTheDocument()) + + // ObjectiveHeader displays Objective A + expect(screen.getByTestId('objective-header-content')).toHaveTextContent('Objective A') + + await user.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(attacksApi.saveConversation).toHaveBeenCalled()) + + // Neither objective nor expected_objective should be sent since objective did not change + expect(savedPayload!.expected_objective).toBeUndefined() + expect(savedPayload!.objective).toBeUndefined() + await waitFor(() => expect(screen.queryByTestId('conversation-editor')).not.toBeInTheDocument()) + expect(screen.getByTestId('objective-header-content')).toHaveTextContent('Objective A') + }) + }) + + describe('Guard async history-loading continuation', () => { + it('does not overwrite typed text or cache stale history while an import is loading', async () => { + const user = userEvent.setup() + renderChat('/attacks/saved/conversations/main') + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) + + const getMessagesDeferred = deferred>>() + jest.mocked(attacksApi.getMessages).mockReturnValue(getMessagesDeferred.promise) + + // Stage current conversation import (triggers async getMessages) + await user.click(screen.getByRole('button', { name: 'Stage saved conversation import' })) + await waitFor(() => expect(attacksApi.getMessages).toHaveBeenCalled()) + + // While getMessages is in flight, user types into composer + const composer = screen.getByPlaceholderText('Type prompt here') + await user.type(composer, 'My newly typed text') + expect(composer).toHaveValue('My newly typed text') + + // Resolve getMessages + await act(async () => { + getMessagesDeferred.resolve({ + conversation_id: 'main', target_response_status: null, + messages: [{ + turn_number: 0, role: 'user', created_at: '2026-01-01T00:00:00Z', + message_pieces: [{ + id: 'stale-piece', original_value_data_type: 'text', original_value: 'Stale history', + converted_value_data_type: 'text', converted_value: 'Stale history', scores: [], response_error: 'none', + }], + }], + }) + }) + + // Newly typed prompt remains intact, not overwritten + expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('My newly typed text') + // Pending import was not consumed (remains waiting) + expect(screen.getByLabelText('Pending import')).toHaveTextContent('waiting') + expect(screen.queryByTestId('conversation-editor')).not.toBeInTheDocument() + + jest.mocked(attacksApi.getMessages).mockResolvedValue({ + conversation_id: 'main', messages: [], target_response_status: null, + }) + const requestCount = jest.mocked(attacksApi.getMessages).mock.calls.length + await user.clear(composer) + await screen.findByTestId('conversation-editor') + expect(attacksApi.getMessages).toHaveBeenCalledTimes(requestCount + 1) + expect(screen.queryByDisplayValue('Stale history')).not.toBeInTheDocument() + }) + + it('Test B: does not apply import to new destination when destination switches while history is loading', async () => { + const user = userEvent.setup() + renderChat('/attacks/saved/conversations/main') + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) + + const getMessagesDeferred = deferred>>() + jest.mocked(attacksApi.getMessages).mockReturnValue(getMessagesDeferred.promise) + + // Stage current conversation import for main (triggers async getMessages) + await user.click(screen.getByRole('button', { name: 'Stage saved conversation import' })) + + // User switches to branch conversation before getMessages resolves + await user.click(screen.getByRole('button', { name: 'Open another conversation' })) + await waitFor(() => expect(screen.getByLabelText('Current route')).toHaveTextContent('/attack/chat/saved/conversations/branch')) + + // Resolve getMessages for main + await act(async () => { + getMessagesDeferred.resolve({ conversation_id: 'main', messages: [], target_response_status: null }) + }) + + // Branch conversation is unaffected, editor not opened, pending import not applied + expect(screen.queryByTestId('conversation-editor')).not.toBeInTheDocument() + expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('') + expect(screen.getByLabelText('Pending import')).toHaveTextContent('waiting') + }) + + it('Test C: ignores superseded import when a newer import is staged during request', async () => { + const user = userEvent.setup() + renderChat('/attacks/saved/conversations/main') + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) + + const getMessagesDeferred1 = deferred>>() + jest.mocked(attacksApi.getMessages).mockReturnValueOnce(getMessagesDeferred1.promise) + + // Trigger import 1 (which calls getMessages with deferred promise) + await user.click(screen.getByRole('button', { name: 'Stage saved conversation import' })) + + // Stage import 2 (supersedes import 1 for saved main) + await user.click(screen.getByRole('button', { name: 'Stage saved import' })) + + // Resolve import 1 + await act(async () => { + getMessagesDeferred1.resolve({ conversation_id: 'main', messages: [], target_response_status: null }) + }) + + // Import 1 did not apply. Only import 2 applies (single prompt 'Import for saved main') + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toHaveValue('Import for saved main')) + expect(screen.queryByTestId('conversation-editor')).not.toBeInTheDocument() + expect(screen.getByLabelText('Pending import')).toHaveTextContent('none') + }) + + it('does not overwrite composer or consume import when user replaces attachment with same count during request', async () => { + const user = userEvent.setup() + renderChat('/attacks/saved/conversations/main') + await waitFor(() => expect(screen.getByPlaceholderText('Type prompt here')).toBeEnabled()) + + // Upload initial attachment + await user.upload(screen.getByTestId('file-input'), new File(['initial'], 'initial.png', { type: 'image/png' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Remove initial.png' })).toBeInTheDocument()) + + const getMessagesDeferred = deferred>>() + jest.mocked(attacksApi.getMessages).mockReturnValue(getMessagesDeferred.promise) + + // Stage current conversation import (triggers async getMessages) + await user.click(screen.getByRole('button', { name: 'Stage saved conversation import' })) + await waitFor(() => expect(attacksApi.getMessages).toHaveBeenCalled()) + + // Replace attachment while getMessages is in flight: remove initial, upload replacement (array length stays 1) + await user.click(screen.getByRole('button', { name: 'Remove initial.png' })) + await user.upload(screen.getByTestId('file-input'), new File(['replacement'], 'replacement.png', { type: 'image/png' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Remove replacement.png' })).toBeInTheDocument()) + + // Resolve getMessages + await act(async () => { + getMessagesDeferred.resolve({ conversation_id: 'main', messages: [], target_response_status: null }) + }) + + // Replacement attachment remains intact, editor is not opened, import not consumed + expect(screen.queryByTestId('conversation-editor')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Remove replacement.png' })).toBeInTheDocument() + expect(screen.getByLabelText('Pending import')).toHaveTextContent('waiting') + }) + + it('computeAttachmentSignature accurately distinguishes metadata changes and identical attachments', () => { + const att1 = { + draftId: 'd1', url: 'blob:url1', name: 'img.png', mimeType: 'image/png', type: 'image' as const, size: 100, metadata: { custom: 'v1' }, + } + const att1Identical = { ...att1 } + const att1SameMetaDifferentKeyOrder = { + ...att1, + metadata: { a: '1', b: '2' }, + } + const att1ReverseKeyOrder = { + ...att1, + metadata: { b: '2', a: '1' }, + } + const att1ModifiedMeta = { + ...att1, + metadata: { custom: 'v2' }, + } + const att2 = { + draftId: 'd2', url: 'blob:url2', name: 'img2.png', mimeType: 'image/png', type: 'image' as const, size: 200, + } + + // Identical attachment produces identical signature (no unnecessary revision advance) + expect(computeAttachmentSignature([att1])).toBe(computeAttachmentSignature([att1Identical])) + expect(computeAttachmentSignature([att1SameMetaDifferentKeyOrder])).toBe(computeAttachmentSignature([att1ReverseKeyOrder])) + + // Changing metadata changes signature even when array count and all other fields are identical + expect(computeAttachmentSignature([att1])).not.toBe(computeAttachmentSignature([att1ModifiedMeta])) + + // Replacing attachment changes signature even when array count is identical + expect(computeAttachmentSignature([att1])).not.toBe(computeAttachmentSignature([att2])) + + // Empty or undefined + expect(computeAttachmentSignature([])).toBe('') + expect(computeAttachmentSignature(undefined)).toBe('') + }) + }) }) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7e4e7ae2bf..24e4249dca 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -85,6 +85,7 @@ import { routerPathParamValue, scenarioRunProvenance, findingEvidenceOrigin, + parseChatRoute, scenarioRunRoutePath, } from './utils/routeParams' @@ -976,7 +977,15 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { /> } /> } /> - } /> + + } + /> } /> } /> } /> diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 7f7abbd6be..6e615cc288 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -57,8 +57,11 @@ import { canApplyPendingImport, hasChatDraftState, isChatDraftDirty, + computeAttachmentSignature, + stableMetadataString, } from '@/utils/chatDraft' -import { targetReference } from '@/utils/targetIdentity' +import { resolveTargetReference, targetReference } from '@/utils/targetIdentity' +import { mergeImportedHistory } from '@/utils/seedImport' import { resolveChatScorerReference } from '@/utils/scorerPresentation' import type { PieceConversion } from './converterTypes' import { useChatConverters } from '@/hooks/useChatConverters' @@ -99,6 +102,7 @@ import type { ChatDraftContent, ChatDraftContextState, ConversationMessagesResponse, + ConversationDraftMessage, ConversationDraftPiece, ConverterInputPiece, ConverterPipelineStage, @@ -511,6 +515,8 @@ export default function ChatWindow({ return () => { window.clearTimeout(fade); window.clearTimeout(dismiss) } }, [noticeId]) const [editorError, setEditorError] = useState(null) + const [importSystemConflict, setImportSystemConflict] = useState(null) + const [importSystemChoice, setImportSystemChoice] = useState<{ id: string; choice: 'destination' | 'imported' } | null>(null) const exitSaveRef = useRef | null>(null) // Track sending state per conversation so parallel conversations can send independently const [sendingConversations, setSendingConversations] = useState>(new Set()) @@ -541,6 +547,29 @@ export default function ChatWindow({ const [chatInputText, setChatInputText] = useState('') const [systemPrompt, setSystemPrompt] = useState('') const [draftAttachments, setDraftAttachments] = useState([]) + + const composerRevisionRef = useRef(0) + const discardDraftOnUnmountRef = useRef(false) + const lastTextRef = useRef(chatInputText) + const lastAttachmentsSignatureRef = useRef(computeAttachmentSignature(draftAttachments)) + + const handleComposerInputChange = useCallback((text: string): void => { + discardDraftOnUnmountRef.current = false + if (lastTextRef.current !== text) { + lastTextRef.current = text + composerRevisionRef.current += 1 + } + setChatInputText(text) + }, []) + const handleComposerAttachmentsChange = useCallback((attachments: MessageAttachment[]): void => { + discardDraftOnUnmountRef.current = false + const nextSignature = computeAttachmentSignature(attachments) + if (lastAttachmentsSignatureRef.current !== nextSignature) { + lastAttachmentsSignatureRef.current = nextSignature + composerRevisionRef.current += 1 + } + setDraftAttachments(attachments) + }, []) const converters = useChatConverters(chatInputText, draftAttachments) const convertersRef = useRef(converters) useLayoutEffect(() => { convertersRef.current = converters }, [converters]) @@ -550,6 +579,24 @@ export default function ChatWindow({ const undoInFlight = useRef(false) const handoffRestoredRef = useRef(null) + const importInFlightRef = useRef(null) + const importFailedRef = useRef(null) + const importMountedRef = useRef(true) + useEffect(() => { + importMountedRef.current = true + return () => { importMountedRef.current = false } + }, []) + const currentAttackIdRef = useRef(draftAttackId) + const currentConversationIdRef = useRef(draftConversationId) + const pendingImportRef = useRef(draftHandoff.pendingImport) + const activeSequenceRef = useRef(draftHandoff.activeSequence) + const isActiveDraftDirtyRef = useRef(false) + useLayoutEffect(() => { + currentAttackIdRef.current = draftAttackId + currentConversationIdRef.current = draftConversationId + pendingImportRef.current = draftHandoff.pendingImport + activeSequenceRef.current = draftHandoff.activeSequence + }, [draftAttackId, draftConversationId, draftHandoff.pendingImport, draftHandoff.activeSequence]) const ownsDraftRef = useRef(false) const preserveOnUnmountRef = useRef<(() => void) | null>(null) useEffect(() => () => { preserveOnUnmountRef.current?.() }, []) @@ -570,6 +617,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), [], @@ -580,6 +628,9 @@ export default function ChatWindow({ setConversationScoring(conversationScoringOf(convId, response)) 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] @@ -792,9 +843,24 @@ 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, + target: editDraft.target ? targetReference(editDraft.target) : null, + pendingPromptId: editDraft.pendingPromptId, + sourcePieceIds: editDraft.sourcePieceIds, + unsavedImport: editDraft.unsavedImport, + }, + } : {}), } const isActiveDraftDirty = isChatDraftDirty(activeDraft) useLayoutEffect(() => { + isActiveDraftDirtyRef.current = isActiveDraftDirty if (isDraftDestinationReady || isActiveDraftDirty) ownsDraftRef.current = true const savedDraft = { ...activeDraft, textMetadata: inputBoxRef.current?.getTextMetadata() } const isWaitingForRestore = matchingPreservedDraft !== null @@ -804,7 +870,9 @@ export default function ChatWindow({ const hasEditedContent = isActiveDraftDirty || Boolean(pendingObjective.trim() || temperature) || draftScorer !== undefined || Object.values(activeDraft.converters?.pipelines ?? {}).some((stages: ConverterPipelineStage[]) => stages.length > 0) preserveOnUnmountRef.current = (): void => { - if (!ownsDraftRef.current || isWaitingForRestore + const shouldDiscard = discardDraftOnUnmountRef.current + discardDraftOnUnmountRef.current = false + if (shouldDiscard || !ownsDraftRef.current || isWaitingForRestore || !hasChatDraftState(activeDraft) || (hasUnrelatedDraft && !hasEditedContent)) return draftHandoff.preserveDraft({ originatingAttackId: draftAttackId, @@ -818,6 +886,10 @@ export default function ChatWindow({ const restoreComposerDraft = useCallback((draft: ChatDraftContent): MessageAttachment[] => { const attachments = draft.attachments.map(withDraftIdentity) + // The next layout publishes the restored draft. Effect replay must not preserve the old snapshot. + preserveOnUnmountRef.current = null + lastTextRef.current = draft.text + lastAttachmentsSignatureRef.current = computeAttachmentSignature(attachments) setChatInputText(draft.text) setDraftAttachments(attachments) if (draft.systemPrompt !== undefined) setSystemPrompt(draft.systemPrompt) @@ -859,13 +931,19 @@ 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), ) 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 @@ -873,7 +951,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) { @@ -885,20 +964,217 @@ export default function ChatWindow({ if (!consumed) return handoffRestoredRef.current = preserved.sequence draft = { ...consumed, systemPrompt: consumed.systemPrompt ?? '' } + if (consumed.conversation) { + let editorTarget: TargetInstance | null = consumed.conversation.target === null ? 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, + initialObjective: consumed.conversation.initialObjective ?? currentObjective, + sourceConversationId: consumed.conversation.sourceConversationId ?? null, + sourceAttackId: consumed.conversation.sourceAttackId ?? draftAttackId, + target: editorTarget, + temperature: consumed.conversation.temperature ?? '', + labels, + pendingPromptId: consumed.conversation.pendingPromptId, + sourcePieceIds: consumed.conversation.sourcePieceIds, + unsavedImport: consumed.conversation.unsavedImport, + }) + } } else { if (!canApplyImportNow || !pending) return - const consumed = draftHandoff.consumePendingImport(pending.id, draftAttackId, draftConversationId) - if (!consumed) return - converters.clearAll() - draft = { - text: consumed.content.text, - attachments: consumed.content.attachments ?? [], - textMetadata: consumed.content.textMetadata, + if (importInFlightRef.current === pending.id || importFailedRef.current === pending.id) return + + const executeImportHandoff = async (): Promise => { + const snapshotGeneration = runtime.generation + const snapshotPendingId = pending.id + const snapshotTargetAttackId = draftAttackId + const snapshotTargetConversationId = draftConversationId + const snapshotDestinationType = pending.destinationType + const snapshotSequence = pending.sequence + const snapshotComposerRevision = composerRevisionRef.current + const snapshotConverterEditRevision = convertersRef.current.editRevision + const snapshotTextMetadata = stableMetadataString(inputBoxRef.current?.getTextMetadata()) + const snapshotReplaceDirty = pending.replaceDirty === true + + importInFlightRef.current = snapshotPendingId + let existingTurns: ConversationDraftMessage[] = [] + let backendMsgs: BackendMessage[] = [] + if (pending.conversation && pending.destinationType === 'current_conversation' && draftConversationId && draftAttackId) { + backendMsgs = loadedBackendMessagesRef.current.get(draftConversationId) ?? [] + if (backendMsgs.length === 0) { + try { + const res = await attacksApi.getMessages(draftAttackId, draftConversationId) + backendMsgs = res?.messages ?? [] + } catch (err) { + importInFlightRef.current = null + if (!importMountedRef.current || pendingImportRef.current?.id !== snapshotPendingId + || currentAttackIdRef.current !== snapshotTargetAttackId + || currentConversationIdRef.current !== snapshotTargetConversationId + || launchStateRef.current.generation !== snapshotGeneration) return + importFailedRef.current = snapshotPendingId + setEditorError(toApiError(err).detail || 'Failed to load conversation history. Preserved pending import.') + return + } + } + } + + // Validate state before proceeding to consume the import + const currentPending = pendingImportRef.current + if (!importMountedRef.current || launchStateRef.current.generation !== snapshotGeneration + || !launchStateRef.current.ready || !currentPending || currentPending.id !== snapshotPendingId) { + importInFlightRef.current = null + return + } + if (currentPending.sequence !== snapshotSequence) { + importInFlightRef.current = null + return + } + if (currentPending.destinationType !== snapshotDestinationType) { + importInFlightRef.current = null + return + } + if ( + currentAttackIdRef.current !== snapshotTargetAttackId || + currentConversationIdRef.current !== snapshotTargetConversationId + ) { + importInFlightRef.current = null + return + } + if ( + composerRevisionRef.current !== snapshotComposerRevision || + convertersRef.current.editRevision !== snapshotConverterEditRevision || + stableMetadataString(inputBoxRef.current?.getTextMetadata()) !== snapshotTextMetadata + ) { + importInFlightRef.current = null + return + } + if (isActiveDraftDirtyRef.current && !snapshotReplaceDirty) { + importInFlightRef.current = null + return + } + if ( + !canApplyPendingImport(currentPending, currentAttackIdRef.current, currentConversationIdRef.current) || + currentPending.sequence < activeSequenceRef.current + ) { + importInFlightRef.current = null + return + } + if (importInFlightRef.current !== snapshotPendingId || importFailedRef.current === snapshotPendingId) { + return + } + + if (pending.conversation && pending.destinationType === 'current_conversation' && draftConversationId && draftAttackId) { + if (backendMsgs.length > 0) loadedBackendMessagesRef.current.set(draftConversationId, backendMsgs) + if (backendMsgs.some((message: BackendMessage) => message.role === 'assistant' || message.role === 'tool' + || message.message_pieces.some((piece: BackendMessagePiece) => piece.prompt_metadata?.pyrit_request_trace + || piece.prompt_metadata?.pyrit_target_request))) { + importInFlightRef.current = null + importFailedRef.current = snapshotPendingId + setEditorError('This conversation contains sent evidence. Choose Import to new conversation or Import to new attack instead.') + return + } + try { + existingTurns = toConversationDraft(backendMsgs) + } catch (err) { + importInFlightRef.current = null + importFailedRef.current = snapshotPendingId + setEditorError(toApiError(err).detail || 'Failed to parse conversation history. Preserved pending import.') + return + } + } + + const setup = pending.setup ?? matchingPreservedDraft + const sourcePieceIds = existingTurns.flatMap((message: ConversationDraftMessage) => message.pieces + .flatMap((piece: ConversationDraftPiece) => piece.source_piece_id ? [piece.source_piece_id] : [])) + if (pending.conversation && !existingTurns.some((message: ConversationDraftMessage) => + message.role === 'system' || message.role === 'developer') && (setup?.systemPrompt || systemPrompt)) { + existingTurns.unshift({ id: generateClientId(), role: 'system', + pieces: [{ ...newDraftPiece(), original_value: setup?.systemPrompt || systemPrompt }] }) + } + const importedMessages = pending.conversation ? mergeImportedHistory(existingTurns, pending.conversation.messages, + importSystemChoice?.id === pending.id ? importSystemChoice.choice : undefined) : [] + if (!importedMessages) { + importInFlightRef.current = null + setImportSystemConflict(pending.id) + return + } + const consumed = draftHandoff.consumePendingImport( + snapshotPendingId, + currentAttackIdRef.current, + currentConversationIdRef.current, + ) + importInFlightRef.current = null + if (!consumed) return + importFailedRef.current = null + setEditorError(null) + setImportSystemConflict(null) + const importedObjective = consumed.objective ?? consumed.conversation?.objective + const composerDraft = consumed.conversation?.composerDraft + ?? (consumed.content.text || consumed.content.attachments?.length ? consumed.content : undefined) + let pendingPromptId: string | undefined + if (consumed.conversation) { + const allMessages = importedMessages + if (composerDraft) { + pendingPromptId = generateClientId() + allMessages.push({ + id: pendingPromptId, role: 'user', + pieces: [ + ...(composerDraft.text ? [{ ...newDraftPiece(), original_value: composerDraft.text, + prompt_metadata: composerDraft.textMetadata }] : []), + ...(composerDraft.attachments ?? []).map(attachmentDraft), + ], + }) + } + const isNewAttack = !currentAttackIdRef.current + const initialObjective = isNewAttack + ? (importedObjective ?? setup?.objective ?? currentObjective) + : currentObjective + const objective = importedObjective ?? setup?.objective ?? currentObjective + + editor.begin({ + messages: allMessages, + objective, + initialObjective, + sourceConversationId: currentConversationIdRef.current ?? null, + sourceAttackId: currentAttackIdRef.current, + sourcePieceIds, + target: activeTarget, + temperature: setup?.temperature ?? '', + unsavedImport: true, + labels, + pendingPromptId, + }) + } + if (preserved && (matchingPreservedDraft || (consumed.setup && consumed.replaceDirty))) { + draftHandoff.clearPreservedDraft(preserved.sequence) + } + draft = { + ...setup, + text: consumed.conversation ? '' : consumed.content.text, + attachments: consumed.conversation ? [] : consumed.content.attachments ?? [], + textMetadata: consumed.conversation ? undefined : consumed.content.textMetadata, + objective: currentAttackIdRef.current ? undefined : importedObjective ?? setup?.objective, + } + focus = consumed.focus === true + const attachments = restoreComposerDraft(draft) + restoreConversions(draft.text, attachments, {}, draft.converters?.pipelines ?? {}) + 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. - // eslint-disable-next-line react-hooks/set-state-in-effect const attachments = restoreComposerDraft(draft) if (restoringPreserved) { if (draft.converters?.generation === runtime.generation) { @@ -916,6 +1192,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, availableTargets, importSystemChoice, ]) // Handle conversation selection from the panel @@ -1033,6 +1310,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] } @@ -1230,6 +1510,8 @@ export default function ChatWindow({ if (isCurrentSend(operation) && isViewingSend(operation) && outcome.clearDraft && inputBoxRef.current && inputBoxRef.current?.getDraftRevision() === operation.draftRevision) { + lastTextRef.current = '' + lastAttachmentsSignatureRef.current = '' inputBoxRef.current.restoreDraft('', []) setChatInputText('') setDraftAttachments([]) @@ -1244,6 +1526,8 @@ export default function ChatWindow({ if (!operation || !isCurrentSend(operation) || !isViewingSend(operation)) return const sameGeneration = operation.converterGeneration === runtime.generation const attachments = operation.attachments.map(withDraftIdentity) + lastTextRef.current = operation.originalValue + lastAttachmentsSignatureRef.current = computeAttachmentSignature(attachments) setChatInputText(operation.originalValue) setDraftAttachments(attachments) inputBoxRef.current?.restoreDraft(operation.originalValue, attachments, operation.textMetadata) @@ -1622,6 +1906,8 @@ export default function ChatWindow({ const restoreRecoverableDraft = useCallback((): void => { if (!recoverableSend) { return } const attachments = recoverableSend.attachments.map(withDraftIdentity) + lastTextRef.current = recoverableSend.originalValue + lastAttachmentsSignatureRef.current = computeAttachmentSignature(attachments) setChatInputText(recoverableSend.originalValue) setDraftAttachments(attachments) restoreConversions( @@ -1889,6 +2175,8 @@ export default function ChatWindow({ const text = prompt?.text ?? '' const attachments = prompt?.attachments.map((attachment: MessageAttachment) => attachment.file ? { ...attachment, url: URL.createObjectURL(attachment.file) } : attachment) ?? [] + lastTextRef.current = text + lastAttachmentsSignatureRef.current = computeAttachmentSignature(attachments) setChatInputText(text) setDraftAttachments(attachments) if (prompt && snapshot.generation === launchStateRef.current.generation) { @@ -1911,15 +2199,22 @@ export default function ChatWindow({ return Promise.resolve(false) } setEditorError(null) - const lastMessage = editDraft.messages[editDraft.messages.length - 1] const snapshot = editorRef.current?.getConverters() ?? converters.snapshot() - const pendingPrompt = lastMessage?.role === 'user' - ? draftMessageToComposer(lastMessage, snapshot) : undefined - const history = pendingPrompt ? editDraft.messages.slice(0, -1) : editDraft.messages + let pendingPrompt: ReturnType | undefined + let history: ConversationDraftMessage[] + if (editDraft.unsavedImport && editDraft.pendingPromptId !== editDraft.messages[editDraft.messages.length - 1]?.id) { + pendingPrompt = undefined + history = editDraft.messages + } else { + const lastMessage = editDraft.messages[editDraft.messages.length - 1] + pendingPrompt = lastMessage?.role === 'user' + ? draftMessageToComposer(lastMessage, snapshot) : undefined + history = pendingPrompt ? editDraft.messages.slice(0, -1) : editDraft.messages + } return editor.save(destination, (response: AddMessageResponse, target?: TargetInstance | null) => { handleEditorSaved(response, target) restoreEditorPrompt(pendingPrompt, snapshot) - }, history, reuseUnsentConversation && destination === 'same_attack' + }, history, (reuseUnsentConversation || editDraft.unsavedImport === true) && destination === 'same_attack' && Boolean(editDraft.sourceConversationId && editDraft.sourceAttackId === attackResultId)) } @@ -1929,9 +2224,14 @@ export default function ChatWindow({ } if (!editor.dirty) { const snapshot = editorRef.current?.getConverters() ?? converters.snapshot() - const lastMessage = editDraft?.messages[editDraft.messages.length - 1] - const prompt = (chatInputText || draftAttachments.length || activePieceConversions.text) && lastMessage?.role === 'user' - ? draftMessageToComposer(lastMessage, snapshot) : undefined + let prompt: ReturnType | undefined + if (editDraft?.unsavedImport) { + prompt = undefined + } else { + const lastMessage = editDraft?.messages[editDraft.messages.length - 1] + prompt = (chatInputText || draftAttachments.length || activePieceConversions.text) && lastMessage?.role === 'user' + ? draftMessageToComposer(lastMessage, snapshot) : undefined + } editor.discard() restoreEditorPrompt(prompt, snapshot) return Promise.resolve(true) @@ -2559,6 +2859,14 @@ export default function ChatWindow({ {scoringNotice.message} } {editorError && {editorError}} + {pending && importSystemConflict === pending.id && + + The destination and imported example have different system or developer instructions. Choose which setup to keep. + + + + + } {editorNotice && {editorNotice.message} @@ -2576,8 +2884,25 @@ export default function ChatWindow({ onSaved={handleEditorSaved} onSave={handleSaveEditor} onExit={handleExitEditor} + onDiscard={(navigatingAway) => { + if (navigatingAway) { + discardDraftOnUnmountRef.current = true + draftHandoff.clearPreservedDraft() + } else { + discardDraftOnUnmountRef.current = false + } + if (editDraft?.unsavedImport) { + lastTextRef.current = '' + lastAttachmentsSignatureRef.current = '' + setChatInputText('') + setDraftAttachments([]) + inputBoxRef.current?.restoreDraft('', []) + draftHandoff.clearPreservedDraft() + } + }} saveDestination={attackResultId ? 'same_attack' : 'new_attack'} saveLabel="Save" + blockNavigation={!editDraft?.unsavedImport} />} {editDraft === null && conversationLoadIssue?.conversationId === viewedConversationId && ( @@ -2677,8 +3002,8 @@ export default function ChatWindow({ onConfigureTarget={() => onNavigate?.('registry')} onToggleConverterPanel={() => setIsConverterPanelOpen(prev => !prev)} isConverterPanelOpen={isConverterPanelOpen} - onInputChange={setChatInputText} - onAttachmentsChange={setDraftAttachments} + onInputChange={handleComposerInputChange} + onAttachmentsChange={handleComposerAttachmentsChange} convertedValue={activePieceConversions['text']?.convertedDataType === 'text' ? (activePieceConversions['text']?.convertedValue ?? null) : null} originalValue={activePieceConversions['text']?.originalValue ?? null} onClearConversion={() => converters.clear('text')} diff --git a/frontend/src/components/Chat/ConversationEditor.test.tsx b/frontend/src/components/Chat/ConversationEditor.test.tsx index ff1257a394..0fecdc7e4c 100644 --- a/frontend/src/components/Chat/ConversationEditor.test.tsx +++ b/frontend/src/components/Chat/ConversationEditor.test.tsx @@ -43,6 +43,7 @@ function setupEditor( newAttackDisabledReason?: string, initialConverters?: ChatConverterSnapshot, contextualHelp = false, + unsavedImport?: boolean, ) { const onSaved = jest.fn() const onExit = jest.fn() @@ -53,6 +54,7 @@ function setupEditor( begin({ messages, initialObjective: 'Objective', objective: 'Objective', target: target ?? null, sourceAttackId: 'source-attack', sourceConversationId: 'source-conversation', labels: { operator: 'owner' }, + unsavedImport, }) }, [begin]) const leaveEditor = async (): Promise => { @@ -472,6 +474,34 @@ describe('ConversationEditor', () => { }) expect(onSaved).not.toHaveBeenCalled() }) + + it('protects untouched imported content from silent discard on cancel and navigation', async () => { + const user = userEvent.setup() + const { router } = setupEditor( + undefined, + undefined, + undefined, + undefined, + undefined, + false, + true, + ) + expect(screen.getByTestId('conversation-editor')).toBeInTheDocument() + + // 1. Clicking Cancel without editing prompts for discard confirmation + await user.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.getByRole('dialog', { name: 'Discard conversation draft?' })).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Keep editing' })) + expect(screen.queryByRole('dialog', { name: 'Discard conversation draft?' })).not.toBeInTheDocument() + expect(screen.getByTestId('conversation-editor')).toBeInTheDocument() + + // 2. Navigating away without editing blocks navigation and prompts for discard confirmation + await act(async () => { await router.navigate('/next') }) + expect(screen.getByRole('dialog', { name: 'Discard conversation draft?' })).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Keep editing' })) + expect(screen.queryByText('Next page')).not.toBeInTheDocument() + expect(screen.getByTestId('conversation-editor')).toBeInTheDocument() + }) }) it('revokes the blob URL when an attachment is removed from the editor', async () => { diff --git a/frontend/src/components/Chat/ConversationEditor.tsx b/frontend/src/components/Chat/ConversationEditor.tsx index ce1d0a0270..54a4941de9 100644 --- a/frontend/src/components/Chat/ConversationEditor.tsx +++ b/frontend/src/components/Chat/ConversationEditor.tsx @@ -165,9 +165,11 @@ interface ConversationEditorProps { onSaved: (response: AddMessageResponse, target?: TargetInstance | null) => void onSave?: (destination: SaveConversationRequest['destination']) => void onExit?: () => Promise + onDiscard?: (navigatingAway?: boolean) => void saveDestination?: SaveConversationRequest['destination'] saveLabel?: string initialConverters?: ChatConverterSnapshot + blockNavigation?: boolean } export interface ConversationEditorHandle { @@ -179,9 +181,9 @@ export interface ConversationEditorHandle { } export default function ConversationEditor({ - controller, ref, sameAttackDisabledReason, newAttackDisabledReason, onSaved, onSave, onExit, + controller, ref, sameAttackDisabledReason, newAttackDisabledReason, onSaved, onSave, onExit, onDiscard, saveDestination: directSaveDestination, saveLabel = 'Save conversation', - initialConverters, + initialConverters, blockNavigation = true, }: ConversationEditorProps) { const styles = useConversationEditorStyles() const [selected, setSelected] = useState>(new Set()) @@ -191,7 +193,11 @@ export default function ConversationEditor({ const { draft, saving, error, dirty, validationError, targetError, changePiece, changeAttachments, insert, addPiece } = controller const messages = useMemo(() => draft?.messages ?? [], [draft?.messages]) const target = draft?.target - const blocker = useBlocker(controller.shouldBlock) + const shouldBlock = useCallback(() => { + if (blockNavigation === false) return false + return controller.shouldBlock() + }, [blockNavigation, controller]) + const blocker = useBlocker(shouldBlock) useBeforeUnload(useCallback((event: BeforeUnloadEvent): void => { if (controller.shouldBlock()) { event.preventDefault() @@ -226,7 +232,10 @@ export default function ConversationEditor({ const cancel = (): void => { if (saving) return if (dirty) setDiscardOpen(true) - else controller.discard() + else { + controller.discard() + onDiscard?.(false) + } } const save = (destination: SaveConversationRequest['destination']): void => { if (converters.isConverting) return @@ -370,7 +379,9 @@ export default function ConversationEditor({ diff --git a/frontend/src/components/Datasets/DatasetSelection.tsx b/frontend/src/components/Datasets/DatasetSelection.tsx index f2e7808245..f5aea39735 100644 --- a/frontend/src/components/Datasets/DatasetSelection.tsx +++ b/frontend/src/components/Datasets/DatasetSelection.tsx @@ -18,11 +18,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 location = useLocation() @@ -63,7 +68,13 @@ export default function DatasetSelection() {
- +
) : !error && ( 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 97eeb1b4c6..21b48d4d4f 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, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { KeyboardEvent } from 'react' import { @@ -18,10 +18,18 @@ import { Text, } from '@fluentui/react-components' 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 { useRuntime } from '@/hooks/useRuntime' import { useSeedExampleDetail, useSeedExamples } from '@/hooks/useSeedExamples' -import type { DatasetInfo, FilterOption, SeedExampleSummary } from '@/types' +import { attacksApi } from '@/services/api' +import { toApiError } from '@/services/errors' +import type { DatasetInfo, FilterOption, ImportDestinationType, SeedExampleSummary } from '@/types' +import { attackConversationRoutePath } from '@/utils/routeParams' +import { buildSeedImport } from '@/utils/seedImport' import SeedExampleDetail from './SeedExampleDetail' import { useSeedExampleBrowserStyles } from './SeedExampleBrowser.styles' @@ -47,6 +55,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,19 +100,48 @@ 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 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, @@ -229,6 +268,121 @@ export function SeedExampleBrowserView({ buttonRefs.current.get(nextKey)?.focus() } + 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 + ) + } + if (destination === 'new_conversation') { + return preserved.originatingAttackId === effectiveAttackId + } + return false + }, + [draftHandoff, effectiveAttackId, effectiveConversationId], + ) + + const executeImport = useCallback( + async (destination: ImportDestinationType, replaceDirty: boolean) => { + setImportError(null) + 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') { + setImportError(outcome.reason) + return + } + + 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) { + if (importRequestRef.current === requestId) setImportError(toApiError(e).detail) + return + } + } + 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: { + 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, + }, + ...(outcome.type === 'conversation' + ? { + conversation: { + messages: outcome.messages, + objective: outcome.objective, + systemPrompt: outcome.systemPrompt, + composerDraft: outcome.composerDraft, + }, + } + : {}), + focus: true, + replaceDirty, + }, expectedSequence) + + if (!staged) { + setImportError('Import staging was superseded by another action.') + return + } + + if (destination === 'new_attack') { + navigate('/attack/chat') + } else if ( + destination === 'current_conversation' && + effectiveAttackId && + effectiveConversationId + ) { + navigate(attackConversationRoutePath(effectiveAttackId, effectiveConversationId)) + } else if (destination === 'new_conversation' && effectiveAttackId && targetConversationId) { + navigate(attackConversationRoutePath(effectiveAttackId, targetConversationId)) + } + }, + [detailState.detail, draftHandoff, effectiveAttackId, effectiveConversationId, navigate, runtime.ready], + ) + + const handleImport = useCallback( + (destination: ImportDestinationType) => { + setImportError(null) + if (isDestinationDirty(destination)) { + setPendingDirtyDestination(destination) + return + } + executeImport(destination, false) + }, + [executeImport, isDestinationDirty], + ) + return (
@@ -302,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 && ( @@ -472,8 +626,30 @@ export function SeedExampleBrowserView({ detail={detailState.detail} headingRef={detailHeadingRef} onRefreshUrls={detailState.refreshDisplayUrls} + hasActiveAttack={Boolean(effectiveAttackId)} + hasActiveConversation={Boolean(effectiveAttackId && effectiveConversationId)} + onImport={handleImport} + 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/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 372deaede0..649db55a58 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', () => { @@ -254,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 fa5b1ff9b5..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, PendingImportPayload, + ChatDraftContent, ChatDraftContextState, ChatDraftHandoffContextValue, ConversationDraftMessage, + ConversationDraftPiece, MessageAttachment, PendingImportPayload, } from '@/types' import { generateClientId } from '@/utils/clientId' +import { draftAttachment } from '@/utils/conversationDraft' import { canApplyPendingImport, isChatDraftDirty, @@ -14,6 +16,25 @@ import { const ChatDraftHandoffContext = createContext(null) +function allDraftAttachments(draft?: ChatDraftContent | null): MessageAttachment[] { + if (!draft) return [] + return [ + ...draft.attachments ?? [], + ...draft.conversation?.messages.flatMap((message: ConversationDraftMessage) => message.pieces + .filter((piece: ConversationDraftPiece) => piece.previewUrl?.startsWith('blob:')).map(draftAttachment)) ?? [], + ] +} + +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)) ?? [], + ] +} + export function ChatDraftProvider({ children }: { children: ReactNode }) { const [preservedDraft, setPreservedDraft] = useState(null) const [pendingImport, setPendingImport] = useState(null) @@ -28,8 +49,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 @@ -48,20 +69,20 @@ export function ChatDraftProvider({ children }: { children: ReactNode }) { ) => { // Parent effects are cleaned up before the active composer's unmount. if (!mountedRef.current) { - revokeAttachmentUrls(state.draft.attachments) + revokeAttachmentUrls(allDraftAttachments(state.draft)) return } // A new attack must not restore the old composer's unmount snapshot. if (expectedSequence !== undefined && expectedSequence !== sequenceRef.current) { - releaseSupersededUrls(state.draft.attachments, [ - ...preservedDraftRef.current?.draft.attachments ?? [], - ...pendingImportRef.current?.content.attachments ?? [], + releaseSupersededUrls(allDraftAttachments(state.draft), [ + ...allDraftAttachments(preservedDraftRef.current?.draft), + ...allImportAttachments(pendingImportRef.current), ]) 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, @@ -75,7 +96,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() @@ -85,16 +109,17 @@ export function ChatDraftProvider({ children }: { children: ReactNode }) { payload: Omit, expectedSequence?: number, ): PendingImportPayload | null => { + const incomingAttachments = allImportAttachments(payload) 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, @@ -117,7 +142,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 @@ -135,7 +160,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.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 53db4f9619..42062d2607 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,12 +40,16 @@ 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 ?? [] - 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 @@ -57,13 +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.messages, baselineTarget: targetKey(input.target), temperature: '', - sourcePieceIds: input.messages.flatMap((message: ConversationDraftMessage) => message.pieces.flatMap( + ...input, id, messages, baselineMessages: input.baselineMessages ?? messages, + baselineTarget: targetKey(input.target), temperature: input.temperature ?? '', + unsavedImport: input.unsavedImport, + 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 060be99542..6f9f6fce5c 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -98,6 +98,25 @@ export interface ChatConverterSnapshot { applied: Record } +export interface PendingComposerDraft { + text: string + attachments?: MessageAttachment[] + textMetadata?: Record +} + +export interface ConversationEditState { + messages: ConversationDraftMessage[] + objective?: string + initialObjective?: string + sourceConversationId?: string | null + sourceAttackId?: string | null + sourcePieceIds?: string[] + temperature?: string + target?: TargetReference | null + pendingPromptId?: string + unsavedImport?: boolean +} + export interface ChatDraftContent { text: string attachments: MessageAttachment[] @@ -108,6 +127,7 @@ export interface ChatDraftContent { target?: TargetReference | null converters?: ChatConverterSnapshot textMetadata?: Record + conversation?: ConversationEditState } export interface ChatDraftContextState { @@ -122,6 +142,13 @@ export interface ChatDraftContextState { export type ImportDestinationType = 'new_attack' | 'current_conversation' | 'new_conversation' | 'any' +export interface PendingImportConversation { + messages: ConversationDraftMessage[] + objective?: string + systemPrompt?: string + composerDraft?: PendingComposerDraft +} + export interface PendingImportPayload { id: string sequence: number @@ -133,6 +160,9 @@ export interface PendingImportPayload { attachments?: MessageAttachment[] textMetadata?: Record } + conversation?: PendingImportConversation + objective?: string + setup?: ChatDraftContent replaceDirty?: boolean focus?: boolean stagedAt: number @@ -1007,6 +1037,10 @@ export interface ConversationSaveInput { sourceAttackId: string | null sourceConversationId: string | null labels?: Record + baselineMessages?: ConversationDraftMessage[] + temperature?: string + unsavedImport?: boolean + pendingPromptId?: string } export interface NewAttackContext { diff --git a/frontend/src/utils/chatDraft.ts b/frontend/src/utils/chatDraft.ts index 190d85de31..c2d92b3931 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 } @@ -41,6 +42,7 @@ export function hasChatDraftState(draft?: Partial | null): boo || draft.temperature !== undefined || 'chatScorer' in draft || Object.values(draft.converters?.pipelines ?? {}).some((stages: ConverterPipelineStage[]) => stages.length > 0) + || Boolean(draft.conversation) )) } @@ -73,6 +75,38 @@ 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) } + +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' diff --git a/frontend/src/utils/routeParams.ts b/frontend/src/utils/routeParams.ts index ed8aab377b..19eddd0339 100644 --- a/frontend/src/utils/routeParams.ts +++ b/frontend/src/utils/routeParams.ts @@ -164,3 +164,32 @@ export function isChatPath(pathOrUrl: string): boolean { const cleanPath = pathOrUrl.split('?')[0].split('#')[0] return cleanPath === '/attack/chat' || cleanPath.startsWith('/attack/chat/') } + +/** 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..6c75bf8c42 --- /dev/null +++ b/frontend/src/utils/seedImport.test.ts @@ -0,0 +1,457 @@ +import type { ConversationDraftMessage, SeedRecord } from '@/types' +import { buildSeedImport, mergeImportedHistory } 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: 'shared-example-group', + sequence: 0, + role: 'user', + parameters: null, + conditions: null, + response_json_schema: null, + ...overrides, + } +} + +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') + if (outcome.type === 'unsupported') { + expect(outcome.reason).toMatch(/No stored members/) + } + }) + + 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({ + 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', + sequence: 0, + }), + 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 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', + data_type: 'image_path', + value: 'sample.png', + value_url: 'https://storage/sample.png?token=expiring', + role: 'user', + sequence: 0, + }), + ] + 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') + expect(outcome.attachments[0].pieceId).toBeUndefined() + } + }) + + 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-shared', + }), + makeSeed({ + id: 'p-2', + value: 'Assistant answer', + role: 'assistant', + sequence: 1, + prompt_group_id: 'group-shared', + }), + ] + 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[1].role).toBe('simulated_assistant') + expect(outcome.composerDraft).toBeUndefined() // ends in assistant -> empty composer + + for (const msg of outcome.messages) { + for (const piece of msg.pieces) { + expect(piece.source_piece_id).toBeUndefined() + } + } + } + }) + + it('preserves two consecutive user turns in the same group as distinct turns (Comment 1)', () => { + const members: SeedRecord[] = [ + makeSeed({ + id: 'p-1', + value: 'First user prompt', + role: 'user', + sequence: 0, + prompt_group_id: 'group-shared', + }), + makeSeed({ + id: 'p-2', + value: 'Second user prompt', + role: 'user', + sequence: 1, + prompt_group_id: 'group-shared', + }), + ] + const outcome = buildSeedImport(members) + 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('implements final-user-turn split convention for user -> assistant -> user', () => { + const members: SeedRecord[] = [ + makeSeed({ + id: 'p-1', + value: 'Initial user turn', + role: 'user', + sequence: 0, + prompt_group_id: 'group-shared', + }), + makeSeed({ + id: 'p-2', + value: 'Assistant answer', + role: 'assistant', + 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('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 sequence 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-shared', + }), + makeSeed({ + id: 'p-2', + data_type: 'image_path', + value: 'chart.png', + role: 'user', + sequence: 0, + prompt_group_id: 'group-shared', + }), + makeSeed({ + id: 'p-3', + data_type: 'text', + value: 'Sure, here is the explanation', + role: 'assistant', + sequence: 1, + prompt_group_id: 'group-shared', + }), + ] + 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('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({ + 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(1) + expect(outcome.messages[0].role).toBe('system') + 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/) + } + }) + + 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, + sequence: 0, + }), + ] + const outcome = buildSeedImport(members) + expect(outcome.type).toBe('unsupported') + if (outcome.type === 'unsupported') { + expect(outcome.reason).toMatch(/unsupported role/) + } + }) + + it('handles a single record without sequence numbers as a single prompt', () => { + const members: SeedRecord[] = [ + makeSeed({ + id: 'p-no-seq', + value: 'Single prompt without sequence', + sequence: null, + }), + ] + const outcome = buildSeedImport(members) + expect(outcome.type).toBe('single_prompt') + if (outcome.type === 'single_prompt') { + expect(outcome.text).toBe('Single prompt without sequence') + } + }) + + it('combines multiple records without sequence numbers sharing the same role into a single prompt', () => { + const members: SeedRecord[] = [ + makeSeed({ + id: 'p-no-seq-1', + value: 'Piece 1', + sequence: null, + role: 'user', + }), + makeSeed({ + id: 'p-no-seq-2', + value: 'Piece 2', + sequence: null, + role: 'user', + }), + ] + const outcome = buildSeedImport(members) + expect(outcome.type).toBe('single_prompt') + if (outcome.type === 'single_prompt') { + expect(outcome.text).toBe('Piece 1\n\nPiece 2') + } + }) + + it('rejects multiple records without sequence numbers when they have different roles', () => { + const members: SeedRecord[] = [ + makeSeed({ + id: 'p-no-seq-user', + value: 'User piece', + sequence: null, + role: 'user', + }), + makeSeed({ + id: 'p-no-seq-asst', + value: 'Assistant piece', + sequence: null, + role: 'assistant', + }), + ] + const outcome = buildSeedImport(members) + expect(outcome.type).toBe('unsupported') + if (outcome.type === 'unsupported') { + expect(outcome.reason).toMatch(/ambiguous turn sequence/) + } + }) +}) diff --git a/frontend/src/utils/seedImport.ts b/frontend/src/utils/seedImport.ts new file mode 100644 index 0000000000..1c84a8661a --- /dev/null +++ b/frontend/src/utils/seedImport.ts @@ -0,0 +1,337 @@ +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[] + composerDraft?: { + text: string + attachments: MessageAttachment[] + textMetadata?: Record + } + objective?: string + systemPrompt?: string +} + +export interface UnsupportedImport { + type: 'unsupported' + reason: string +} + +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. + */ +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 + } +} + +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), + * 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. 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) { + 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 (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) { + return { + type: 'unsupported', + reason: 'Some prompt members are missing conversation roles, making the turn sequence ambiguous.', + } + } + } + + // 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 + sequence: number + pieces: ConversationDraftPiece[] + } + + const turns: GroupedTurn[] = [] + + // 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 (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 seq = record.sequence ?? 0 + const existing = sequenceMap.get(seq) ?? [] + existing.push(record) + sequenceMap.set(seq, existing) + } + + 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 mappedRoles = sequenceRoles.map(mapToDraftRole) + + if (mappedRoles.some((r) => r === null)) { + return { + type: 'unsupported', + reason: `Turn at sequence ${seq} contains unsupported role values.`, + } + } + + const distinctRoles = new Set(mappedRoles) + if (distinctRoles.size > 1) { + return { + type: 'unsupported', + reason: `Members within sequence ${seq} have conflicting roles.`, + } + } + + 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, + sequence: seq, + pieces, + }) + } + } else { + // 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: 'Prompt members have ambiguous turn sequence or missing sequence ordering.', + } + } + } + } + + if (turns.length === 0) { + return { + type: 'unsupported', + reason: 'No valid turns could be constructed from this example.', + } + } + + // 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)) + 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') + } + } + + // 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, + })) + + return { + type: 'conversation', + messages, + composerDraft, + objective, + systemPrompt, + } +}