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.stagePendingImport({
+ destinationType: 'new_attack',
+ content: { text: '' },
+ conversation: {
+ messages: [
+ { id: 'm1', role: 'user', pieces: [{ draftId: 'p1', data_type: 'text', original_value: 'Imported user turn' }] },
+ { id: 'm2', role: 'simulated_assistant', pieces: [{ draftId: 'p2', data_type: 'text', original_value: 'Imported assistant turn' }] },
+ ],
+ objective: 'Imported test objective',
+ },
+ replaceDirty: true,
+ })}>Stage conversation import
+ handoff.stagePendingImport({
+ destinationType: 'new_attack', content: { text: '' }, replaceDirty: true,
+ conversation: { messages: [
+ { id: 'import-system', role: 'system', pieces: [{ draftId: 'system-piece', data_type: 'text', original_value: 'Imported instructions' }] },
+ { id: 'import-user', role: 'user', pieces: [{ draftId: 'user-piece', data_type: 'text', original_value: 'Imported history' }] },
+ ] },
+ })}>Stage system import
+ handoff.stagePendingImport({
+ destinationType: 'current_conversation',
+ targetAttackId: 'saved',
+ targetConversationId: 'main',
+ content: { text: 'Unsent user prompt' },
+ conversation: {
+ messages: [
+ { id: 'm-prepended', role: 'simulated_assistant', pieces: [{ draftId: 'p-prep', data_type: 'text', original_value: 'Prepended assistant turn' }] },
+ ],
+ objective: 'Saved objective',
+ },
+ replaceDirty: true,
+ })}>Stage saved conversation import
+ handoff.stagePendingImport({
+ destinationType: 'current_conversation',
+ targetAttackId: 'saved',
+ targetConversationId: 'main',
+ content: {
+ text: 'Turn C prompt',
+ attachments: [{ draftId: 'att-c', type: 'image', name: 'img-c.png', url: 'blob:img-c', mimeType: 'image/png' }],
+ },
+ conversation: {
+ messages: [
+ { id: 'm-a', role: 'user', pieces: [{ draftId: 'p-a', data_type: 'text', original_value: 'Turn A message' }] },
+ { id: 'm-b', role: 'simulated_assistant', pieces: [{ draftId: 'p-b', data_type: 'text', original_value: 'Turn B message' }] },
+ ],
+ composerDraft: {
+ text: 'Turn C prompt',
+ attachments: [{ draftId: 'att-c', type: 'image', name: 'img-c.png', url: 'blob:img-c', mimeType: 'image/png', metadata: { version: '1' } }],
+ textMetadata: { video_id: 'vid-c' },
+ },
+ objective: 'Imported objective B',
+ },
+ replaceDirty: true,
+ })}>Stage A-B-C with attachment import
+ handoff.stagePendingImport({
+ destinationType: 'current_conversation',
+ targetAttackId: 'saved',
+ targetConversationId: 'main',
+ content: { text: '' },
+ conversation: {
+ messages: [
+ { id: 'm-a2', role: 'user', pieces: [{ draftId: 'p-a2', data_type: 'text', original_value: 'Turn A only' }] },
+ { id: 'm-b2', role: 'simulated_assistant', pieces: [{ draftId: 'p-b2', data_type: 'text', original_value: 'Turn B assistant' }] },
+ ],
+ },
+ replaceDirty: true,
+ })}>Stage ending in assistant import
+ handoff.stagePendingImport({
+ destinationType: 'current_conversation',
+ targetAttackId: 'saved',
+ targetConversationId: 'main',
+ content: { text: '' },
+ conversation: {
+ messages: [
+ { id: 'm-h1', role: 'user', pieces: [{ draftId: 'p-h1', data_type: 'text', original_value: 'History user 1' }] },
+ { id: 'm-h2', role: 'simulated_assistant', pieces: [{ draftId: 'p-h2', data_type: 'text', original_value: 'History assistant 2' }] },
+ { id: 'm-h3', role: 'user', pieces: [{ draftId: 'p-h3', data_type: 'text', original_value: 'History user 3' }] },
+ ],
+ },
+ replaceDirty: true,
+ })}>Stage history ending in user message import
+ handoff.stagePendingImport({
+ destinationType: 'current_conversation',
+ targetAttackId: 'saved',
+ targetConversationId: 'main',
+ content: { text: '' },
+ conversation: {
+ messages: [
+ { id: 'm-no-obj', role: 'user', pieces: [{ draftId: 'p-no-obj', data_type: 'text', original_value: 'No obj turn' }] },
+ ],
+ },
+ replaceDirty: true,
+ })}>Stage no-objective import
+ {
+ handoff.preserveDraft({
+ originatingAttackId: 'saved',
+ originatingConversationId: 'main',
+ draft: {
+ text: 'Preserved with missing target',
+ attachments: [],
+ conversation: {
+ messages: [{ id: 'm1', role: 'user', pieces: [{ draftId: 'p1', data_type: 'text', original_value: 'Hello' }] }],
+ target: { target_type: 'NonExistent', target_registry_name: 'missing', identifier_hash: '999' },
+ objective: 'Test objective',
+ },
+ },
+ })
+ }}>Preserve draft with missing target
{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.
+ setImportSystemChoice({ id: pending.id, choice: 'destination' })}>Keep destination setup
+ setImportSystemChoice({ id: pending.id, choice: 'imported' })}>Use imported setup
+ draftHandoff.dismissPendingImport(pending.id)}>Cancel import
+
+ }
{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({
{ setDiscardOpen(false); if (blocker.state === 'blocked') blocker.reset() }}>Keep editing
{
+ const isNavigating = blocker.state === 'blocked'
controller.discard()
+ onDiscard?.(isNavigating)
if (blocker.state === 'blocked') blocker.proceed()
}}>Discard draft
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'}
+ navigate('/away')}>Leave examples
{
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 ? (
+
+
+ }
+ data-testid="import-into-chat-button"
+ >
+ Import into Chat
+
+
+
+
+ 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,
+ }
+}