/* @vitest-environment jsdom */ import { act, fireEvent, render as testingLibraryRender, screen, waitFor, within, } from '@testing-library/react'; import type { ReactElement, ReactNode } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { EditorAgentConversationDetail, EditorAgentMessage, EditorAgentMessageResponse, } from '@/packages/shared/src/contracts'; import { setPlainTextEditorValue } from '@/src/components/common/AutoGrowTextArea.test-utils.ts'; import type { EditorAgentConversationClient } from '@/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts'; import { EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS } from '@/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts'; import { ImageCanvasActionsProvider } from '@/src/components/image-editor/ImageCanvasActionsProvider.tsx'; import { useImageCanvasContextStore } from '@/src/components/image-editor/useImageCanvasContextStore.ts'; import { EditorAgentConversationPanelView } from './EditorAgentConversationPanelView.tsx'; const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn()); const probeImageFileDimensionsMock = vi.hoisted(() => vi.fn()); const focusResourceMock = vi.fn(); const refreshCanvasMock = vi.fn(); function render(ui: ReactElement) { return testingLibraryRender(ui, { wrapper: ({ children }: { children: ReactNode }) => ( {children} ), }); } const ATTACHMENT_PROMPT = '请参考附件'; vi.mock('@/src/services/image-editor/editorProjectClient.ts', async () => { const actual = await vi.importActual< typeof import('../../../services/image-editor/editorProjectClient.ts') >('@/src/services/image-editor/editorProjectClient.ts'); return { ...actual, createEditorProjectResource: createEditorProjectResourceMock, }; }); vi.mock('@/src/services/image-editor/editorMediaAssetUploadClient.ts', () => ({ uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock, })); vi.mock('@/src/components/image-editor/ImageCanvasFileModel.ts', async () => { const actual = await vi.importActual< typeof import('../ImageCanvasFileModel.ts') >('@/src/components/image-editor/ImageCanvasFileModel.ts'); return { ...actual, probeImageFileDimensions: probeImageFileDimensionsMock, }; }); function createClient(): EditorAgentConversationClient { return { listConversations: vi.fn().mockResolvedValue([ { conversationId: 'conversation-1', projectId: 'project-1', title: '角色参考', updatedAt: '2026-07-03T00:00:00.000Z', }, ]), createConversation: vi.fn().mockResolvedValue({ conversationId: 'conversation-2', projectId: 'project-1', title: '新对话', messages: [], createdAt: '2026-07-03T00:01:00.000Z', updatedAt: '2026-07-03T00:01:00.000Z', }), getConversation: vi.fn().mockResolvedValue({ conversationId: 'conversation-1', projectId: 'project-1', title: '角色参考', messages: [ { id: 0, role: 'assistant', text: '已经看到画布内容', attachments: [], toolCall: null, createdAt: '2026-07-03T00:00:10.000Z', }, ], createdAt: '2026-07-03T00:00:00.000Z', updatedAt: '2026-07-03T00:00:10.000Z', }), deleteConversation: vi.fn().mockResolvedValue({ conversationId: 'conversation-1', projectId: 'project-1', title: '角色参考', messages: [], createdAt: '2026-07-03T00:00:00.000Z', updatedAt: '2026-07-03T00:00:00.000Z', }), sendMessage: vi.fn().mockResolvedValue({ conversation: { conversationId: 'conversation-1', projectId: 'project-1', title: '角色参考', updatedAt: '2026-07-03T00:00:10.000Z', }, deltaMessages: [ { id: 1, role: 'assistant', text: '收到,我会参考这张图。', attachments: [], toolCall: null, createdAt: '2026-07-03T00:00:00.000Z', }, ], errorMessage: null, } as EditorAgentMessageResponse), confirmToolCall: vi.fn(), cancelToolCall: vi.fn(), }; } function enterAttachmentPrompt() { setPlainTextEditorValue( screen.getByLabelText('发送给画布 Agent'), ATTACHMENT_PROMPT, ); } afterEach(() => { vi.useRealTimers(); }); function createPendingToolCallMessage(): EditorAgentMessage { return { id: 2, role: 'system', text: 'internal system prompt that must stay hidden', attachments: [], toolCall: { toolName: 'edit-image', status: 'not_completed', args: { object_image_id: 'source-image-1', reference_image_ids: ['reference-image-1', 'reference-image-2'], prompt: '把角色换成像素风', }, displayArgs: { stringArgs: [ { name: 'prompt', label: '提示词', value: '把角色换成像素风', }, ], imageArgs: [ { name: 'object_image_id', label: '目标图片', refs: [ { imageId: 'source-image-1', imageSrc: 'data:image/png;base64,c291cmNl', label: '原角色', width: 512, height: 512, }, ], }, { name: 'reference_image_ids', label: '参考图片', refs: [ { imageId: 'reference-image-1', imageSrc: 'data:image/png;base64,cmVmZXJlbmNlLTE=', label: '像素风参考', }, { imageId: 'reference-image-2', imageSrc: 'data:image/png;base64,cmVmZXJlbmNlLTI=', label: '配色参考', }, ], }, ], extras: { priceMudPoints: 3 }, }, images: [], error: null, }, createdAt: '2026-07-10T00:00:00.000Z', }; } describe('EditorAgentConversationPanelView', () => { beforeEach(() => { focusResourceMock.mockReset(); useImageCanvasContextStore.getState().setProjectId('project-1'); uploadEditorMediaAssetFileMock.mockReset(); uploadEditorMediaAssetFileMock.mockResolvedValue({ src: '/generated/pasted.png', objectKey: 'generated-character-drafts/editor/agent-paste/image/pasted.png', assetObjectId: 'asset-object-pasted', legacyPublicPath: '/generated/pasted.png', }); probeImageFileDimensionsMock.mockReset(); probeImageFileDimensionsMock.mockResolvedValue({ width: 320, height: 240 }); createEditorProjectResourceMock.mockReset(); createEditorProjectResourceMock.mockResolvedValue({ resourceId: 'resource-pasted', imageSrc: '/generated/pasted.png', objectKey: 'generated-character-drafts/editor/agent-paste/image/pasted.png', label: '粘贴图片', width: 320, height: 240, }); }); it('manages conversations, attachments and message sending inside the panel', async () => { const client = createClient(); render( , ); await waitFor(() => { expect(screen.getByText('已经看到画布内容')).toBeTruthy(); }); const newConversationButton = screen.getByRole('button', { name: '新建对话', }) as HTMLButtonElement; expect(newConversationButton.disabled).toBe(false); setPlainTextEditorValue( screen.getByLabelText('发送给画布 Agent'), '应保留到新会话的草稿', ); fireEvent.click(screen.getByRole('button', { name: '添加附件' })); const attachmentDialog = screen.getByRole('dialog', { name: '选择图片附件', }); expect(attachmentDialog.parentElement?.className).toContain( 'platform-theme--light', ); expect(attachmentDialog.className).toContain('platform-remap-surface'); expect( within(attachmentDialog).queryByRole('checkbox', { name: '选择画布图片 未入库图层', }), ).toBeNull(); fireEvent.click( within(attachmentDialog).getByRole('checkbox', { name: '选择画布图片 一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯', }), ); fireEvent.click( within(attachmentDialog).getByRole('button', { name: '应用' }), ); expect( screen.getByText('一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯'), ).toBeTruthy(); fireEvent.click(newConversationButton); await waitFor(() => { expect(client.createConversation).toHaveBeenCalledWith('project-1', {}); }); await waitFor(() => { expect( (screen.getByLabelText('当前对话') as HTMLSelectElement).value, ).toBe('conversation-2'); expect(newConversationButton.disabled).toBe(true); }); expect(screen.getByLabelText('发送给画布 Agent').textContent).toBe( '应保留到新会话的草稿', ); expect( screen.getByText('一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯'), ).toBeTruthy(); expect(screen.getByRole('option', { name: '角色参考' })).toBeTruthy(); expect(screen.getByRole('option', { name: '新对话' })).toBeTruthy(); setPlainTextEditorValue( screen.getByLabelText('发送给画布 Agent'), '尚未发送的草稿', ); expect(newConversationButton.disabled).toBe(true); fireEvent.click(newConversationButton); expect(client.createConversation).toHaveBeenCalledTimes(1); setPlainTextEditorValue( screen.getByLabelText('发送给画布 Agent'), '参考附件做像素风', ); fireEvent.click(screen.getByRole('button', { name: '发送' })); await waitFor(() => { expect(screen.getByText('收到,我会参考这张图。')).toBeTruthy(); }); expect(client.sendMessage).toHaveBeenCalledWith( 'conversation-2', expect.objectContaining({ text: '参考附件做像素风', attachments: [ expect.objectContaining({ source: 'canvas_resource', referenceId: 'resource-1', label: '一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯', }), ], }), expect.any(Object), ); }); it('adds a generated result to the composer through the existing right click menu', async () => { const client = createClient(); vi.mocked(client.getConversation).mockResolvedValue({ conversationId: 'conversation-1', projectId: 'project-1', title: '生成结果', messages: [ { id: 9, role: 'system', text: 'internal tool result', attachments: [], toolCall: { toolName: 'generate_image', status: 'completed', args: {}, displayArgs: { stringArgs: [], imageArgs: [], extras: { priceMudPoints: 1 }, }, images: [ { resourceId: 'resource-generated-1', objectKey: 'editor/generated-1.png', imageSrc: '/generated-editor-images/generated-1.png', width: 512, height: 512, }, ], }, createdAt: '2026-07-20T00:00:00.000Z', }, ], createdAt: '2026-07-20T00:00:00.000Z', updatedAt: '2026-07-20T00:00:10.000Z', }); render( , ); const messageLog = await screen.findByRole('log', { name: '画布 Agent 消息流', }); await waitFor(() => expect(messageLog.querySelector('.grid.grid-cols-3 > div')).toBeTruthy(), ); fireEvent.contextMenu( messageLog.querySelector('.grid.grid-cols-3 > div')!, { clientX: 30, clientY: 40 }, ); fireEvent.click(screen.getByRole('menuitem', { name: '引用' })); expect(await screen.findByText('Agent生成图片-1')).toBeTruthy(); enterAttachmentPrompt(); fireEvent.click(screen.getByRole('button', { name: '发送' })); await waitFor(() => { expect(client.sendMessage).toHaveBeenCalledWith( 'conversation-1', expect.objectContaining({ text: ATTACHMENT_PROMPT, attachments: [ expect.objectContaining({ source: 'canvas_resource', referenceId: 'resource-generated-1', objectKey: 'editor/generated-1.png', imageSrc: '/generated-editor-images/generated-1.png', }), ], }), expect.any(Object), ); }); }); it('keeps new conversation disabled when the project has no conversation history', async () => { const client = createClient(); vi.mocked(client.listConversations).mockResolvedValueOnce([]); render( , ); await waitFor(() => { expect(client.listConversations).toHaveBeenCalledWith('project-1'); expect(screen.getByText('暂无消息')).toBeTruthy(); }); const newConversationButton = screen.getByRole('button', { name: '新建对话', }) as HTMLButtonElement; expect(newConversationButton.disabled).toBe(true); setPlainTextEditorValue( screen.getByLabelText('发送给画布 Agent'), '未发送内容', ); expect(newConversationButton.disabled).toBe(true); fireEvent.click(newConversationButton); expect(client.createConversation).not.toHaveBeenCalled(); }); it('preserves the current conversation draft when creating a conversation fails', async () => { const client = createClient(); vi.mocked(client.createConversation).mockRejectedValueOnce( new Error('创建新会话失败'), ); render( , ); await waitFor(() => { expect(screen.getByText('已经看到画布内容')).toBeTruthy(); }); setPlainTextEditorValue( screen.getByLabelText('发送给画布 Agent'), '需要保留的草稿', ); fireEvent.click(screen.getByRole('button', { name: '新建对话' })); await waitFor(() => { expect(screen.getByText('创建新会话失败')).toBeTruthy(); }); expect(screen.getByLabelText('发送给画布 Agent').textContent).toBe( '需要保留的草稿', ); expect(screen.getByText('已经看到画布内容')).toBeTruthy(); expect((screen.getByLabelText('当前对话') as HTMLSelectElement).value).toBe( 'conversation-1', ); }); it('preserves newer draft edits while creating a conversation', async () => { const client = createClient(); let resolveCreate!: (detail: EditorAgentConversationDetail) => void; vi.mocked(client.createConversation).mockImplementationOnce( () => new Promise((resolve) => { resolveCreate = resolve; }), ); render( , ); await waitFor(() => { expect(screen.getByText('已经看到画布内容')).toBeTruthy(); }); const draftInput = screen.getByLabelText( '发送给画布 Agent', ) as HTMLElement; setPlainTextEditorValue(draftInput, '旧项目草稿'); fireEvent.click(screen.getByRole('button', { name: '新建对话' })); await waitFor(() => { expect(client.createConversation).toHaveBeenCalledWith('project-1', {}); expect(draftInput.getAttribute('aria-disabled')).toBeNull(); }); const conversationSelect = screen.getByLabelText( '当前对话', ) as HTMLSelectElement; const sendButton = screen.getByRole('button', { name: '发送', }) as HTMLButtonElement; expect(conversationSelect.disabled).toBe(true); expect(sendButton.disabled).toBe(true); expect( (screen.getByRole('button', { name: '添加附件' }) as HTMLButtonElement) .disabled, ).toBe(false); setPlainTextEditorValue(draftInput, '创建期间更新的草稿'); fireEvent.click(sendButton); expect(client.sendMessage).not.toHaveBeenCalled(); expect(draftInput.textContent).toBe('创建期间更新的草稿'); await act(async () => { resolveCreate({ conversationId: 'conversation-2', projectId: 'project-1', title: '新对话', messages: [], createdAt: '2026-07-03T00:01:00.000Z', updatedAt: '2026-07-03T00:01:00.000Z', }); await Promise.resolve(); }); expect(draftInput.textContent).toBe('创建期间更新的草稿'); expect(conversationSelect.value).toBe('conversation-2'); expect(conversationSelect.disabled).toBe(false); expect(sendButton.disabled).toBe(false); }); it('preserves the draft when submission is attempted during conversation loading', async () => { const client = createClient(); let resolveConversationLoad!: ( detail: EditorAgentConversationDetail, ) => void; vi.mocked(client.listConversations).mockResolvedValueOnce([ { conversationId: 'conversation-1', projectId: 'project-1', title: '角色参考', updatedAt: '2026-07-03T00:01:00.000Z', }, { conversationId: 'conversation-2', projectId: 'project-1', title: '背景参考', updatedAt: '2026-07-03T00:00:00.000Z', }, ]); vi.mocked(client.getConversation) .mockResolvedValueOnce({ conversationId: 'conversation-1', projectId: 'project-1', title: '角色参考', messages: [ { id: 0, role: 'assistant', text: '已经看到画布内容', attachments: [], toolCall: null, createdAt: '2026-07-03T00:00:10.000Z', }, ], createdAt: '2026-07-03T00:00:00.000Z', updatedAt: '2026-07-03T00:00:10.000Z', }) .mockImplementationOnce( () => new Promise((resolve) => { resolveConversationLoad = resolve; }), ); render( , ); await waitFor(() => { expect(screen.getByText('已经看到画布内容')).toBeTruthy(); }); const draftInput = screen.getByLabelText( '发送给画布 Agent', ) as HTMLElement; const conversationSelect = screen.getByLabelText( '当前对话', ) as HTMLSelectElement; setPlainTextEditorValue(draftInput, '切换期间必须保留的草稿'); fireEvent.change(conversationSelect, { target: { value: 'conversation-2' }, }); const sendButton = screen.getByRole('button', { name: '发送', }) as HTMLButtonElement; await waitFor(() => { expect(client.getConversation).toHaveBeenCalledWith('conversation-2'); expect(sendButton.disabled).toBe(true); }); fireEvent.click(sendButton); expect(client.sendMessage).not.toHaveBeenCalled(); expect(draftInput.textContent).toBe('切换期间必须保留的草稿'); await act(async () => { resolveConversationLoad({ conversationId: 'conversation-2', projectId: 'project-1', title: '背景参考', messages: [ { id: 1, role: 'assistant', text: '背景会话内容', attachments: [], toolCall: null, createdAt: '2026-07-03T00:02:00.000Z', }, ], createdAt: '2026-07-03T00:00:00.000Z', updatedAt: '2026-07-03T00:02:00.000Z', }); await Promise.resolve(); }); expect(await screen.findByText('背景会话内容')).toBeTruthy(); expect(draftInput.textContent).toBe('切换期间必须保留的草稿'); expect(sendButton.disabled).toBe(false); }); it('disables sending while a message request is pending without showing stop', async () => { const client = createClient(); let resolveSend!: (response: EditorAgentMessageResponse) => void; vi.mocked(client.sendMessage).mockImplementation( () => new Promise((resolve) => { resolveSend = resolve; }), ); render( , ); await waitFor(() => { expect(screen.getByText('已经看到画布内容')).toBeTruthy(); }); vi.useFakeTimers(); setPlainTextEditorValue( screen.getByLabelText('发送给画布 Agent'), '继续规划', ); fireEvent.click(screen.getByRole('button', { name: '发送' })); await act(async () => { await Promise.resolve(); }); expect( screen.getByRole('button', { name: '发送' }).hasAttribute('disabled'), ).toBe(true); expect(screen.queryByRole('button', { name: '停止' })).toBeNull(); expect(screen.queryByText('仍在处理中,请耐心等待')).toBeNull(); act(() => { vi.advanceTimersByTime(EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS); }); expect(screen.getByText('仍在处理中,请耐心等待')).toBeTruthy(); await act(async () => { resolveSend({ conversation: { conversationId: 'conversation-1', projectId: 'project-1', title: '角色参考', updatedAt: '2026-07-03T00:00:20.000Z', }, deltaMessages: [], errorMessage: null, }); await Promise.resolve(); }); expect(screen.queryByText('仍在处理中,请耐心等待')).toBeNull(); }); it('uploads pasted images as canvas attachments before sending', async () => { const client = createClient(); render( , ); await waitFor(() => { expect(screen.getByText('已经看到画布内容')).toBeTruthy(); }); const pastedImage = new File(['pasted-image'], 'pasted.png', { type: 'image/png', }); const input = screen.getByLabelText('发送给画布 Agent'); fireEvent.paste(input, { clipboardData: { files: [pastedImage], }, }); await waitFor(() => { expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith( pastedImage, 'image', expect.objectContaining({ entityId: 'project-1', metadata: { source: 'agent-input-paste' }, }), ); }); expect(probeImageFileDimensionsMock).toHaveBeenCalledWith(pastedImage); expect(createEditorProjectResourceMock).toHaveBeenCalledWith( 'project-1', expect.objectContaining({ imageSrc: '/generated/pasted.png', objectKey: 'generated-character-drafts/editor/agent-paste/image/pasted.png', assetObjectId: 'asset-object-pasted', width: 320, height: 240, sourceType: 'uploaded', }), ); await waitFor(() => { expect(screen.getByText('粘贴图片')).toBeTruthy(); }); enterAttachmentPrompt(); fireEvent.click(screen.getByRole('button', { name: '发送' })); await waitFor(() => { expect(client.sendMessage).toHaveBeenCalledWith( 'conversation-1', expect.objectContaining({ text: ATTACHMENT_PROMPT, attachments: [ expect.objectContaining({ source: 'canvas_resource', referenceId: 'resource-pasted', imageSrc: '/generated/pasted.png', width: 320, height: 240, }), ], }), expect.any(Object), ); }); expect(screen.getByRole('option', { name: '角色参考' })).toBeTruthy(); expect(screen.queryByRole('option', { name: 'conversation-1' })).toBeNull(); }); it('normalizes a referenced historical attachment outside the current canvas', async () => { const client = createClient(); vi.mocked(client.getConversation).mockResolvedValue({ conversationId: 'conversation-1', projectId: 'project-1', title: '历史附件', messages: [ { id: 8, role: 'user', text: '参考这张粘贴图', attachments: [ { source: 'canvas_resource', referenceId: 'resource-historical-paste', objectKey: 'editor/agent-paste/historical.png', imageSrc: '/generated/historical-paste.png', thumbnailSrc: null, label: ' 历史(粘贴):图 ', width: 320, height: 240, }, ], toolCall: null, createdAt: '2026-07-20T00:00:00.000Z', }, ], createdAt: '2026-07-20T00:00:00.000Z', updatedAt: '2026-07-20T00:00:10.000Z', }); render( , ); const historicalAttachment = await screen.findByText('历史(粘贴):图'); fireEvent.contextMenu(historicalAttachment.closest('.group')!); fireEvent.click(screen.getByRole('menuitem', { name: '引用' })); await waitFor(() => { expect(screen.getByText('历史粘贴图')).toBeTruthy(); }); enterAttachmentPrompt(); fireEvent.click(screen.getByRole('button', { name: '发送' })); await waitFor(() => { expect(client.sendMessage).toHaveBeenCalledWith( 'conversation-1', expect.objectContaining({ attachments: [ { source: 'canvas_resource', referenceId: 'resource-historical-paste', objectKey: 'editor/agent-paste/historical.png', imageSrc: '/generated/historical-paste.png', thumbnailSrc: null, label: '历史粘贴图', width: 320, height: 240, }, ], }), expect.any(Object), ); }); expect(screen.getByText('历史(粘贴):图')).toBeTruthy(); expect(screen.getByText('历史粘贴图')).toBeTruthy(); }); it('refreshes an attachment matched by source and reference id when sending', async () => { const client = createClient(); vi.mocked(client.getConversation).mockResolvedValue({ conversationId: 'conversation-1', projectId: 'project-1', title: '附件更新', messages: [ { id: 8, role: 'user', text: '参考旧版图片', attachments: [ { source: 'canvas_resource', referenceId: 'resource-updated', objectKey: 'editor/old.png', imageSrc: '/generated/old.png', label: '旧版附件', }, ], toolCall: null, createdAt: '2026-07-20T00:00:00.000Z', }, ], createdAt: '2026-07-20T00:00:00.000Z', updatedAt: '2026-07-20T00:00:10.000Z', }); render( , ); const historicalAttachment = await screen.findByText('旧版附件'); fireEvent.contextMenu(historicalAttachment.closest('.group')!); fireEvent.click(screen.getByRole('menuitem', { name: '引用' })); await waitFor(() => { expect(screen.getAllByText('旧版附件')).toHaveLength(2); }); expect(screen.queryByText('最新附件')).toBeNull(); enterAttachmentPrompt(); fireEvent.click(screen.getByRole('button', { name: '发送' })); await waitFor(() => { expect(client.sendMessage).toHaveBeenCalledWith( 'conversation-1', expect.objectContaining({ attachments: [ expect.objectContaining({ source: 'canvas_resource', referenceId: 'resource-updated', objectKey: 'editor/current.png', imageSrc: '/generated/current.png', }), ], }), expect.any(Object), ); }); }); it('keeps a referenced image when a pending paste upload finishes', async () => { const client = createClient(); vi.mocked(client.getConversation).mockResolvedValue({ conversationId: 'conversation-1', projectId: 'project-1', title: '并发附件', messages: [ { id: 9, role: 'system', text: 'internal tool result', attachments: [], toolCall: { toolName: 'generate_image', status: 'completed', args: {}, displayArgs: { stringArgs: [], imageArgs: [], extras: { priceMudPoints: 1 }, }, images: [ { resourceId: 'resource-referenced', objectKey: 'editor/referenced.png', imageSrc: '/generated-editor-images/referenced.png', width: 512, height: 512, }, ], }, createdAt: '2026-07-20T00:00:00.000Z', }, ], createdAt: '2026-07-20T00:00:00.000Z', updatedAt: '2026-07-20T00:00:10.000Z', }); let resolveUpload!: (upload: { src: string; objectKey: string; assetObjectId: string; legacyPublicPath: string; }) => void; uploadEditorMediaAssetFileMock.mockImplementationOnce( () => new Promise((resolve) => { resolveUpload = resolve; }), ); render( , ); const messageLog = await screen.findByRole('log', { name: '画布 Agent 消息流', }); await waitFor(() => expect(messageLog.querySelector('.grid.grid-cols-3 > div')).toBeTruthy(), ); const input = screen.getByLabelText('发送给画布 Agent'); fireEvent.paste(input, { clipboardData: { files: [ new File(['pending-paste'], 'pending.png', { type: 'image/png' }), ], }, }); await waitFor(() => expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(1), ); fireEvent.contextMenu( messageLog.querySelector('.grid.grid-cols-3 > div')!, { clientX: 30, clientY: 40 }, ); fireEvent.click(screen.getByRole('menuitem', { name: '引用' })); expect(await screen.findByText('并发引用图')).toBeTruthy(); await act(async () => { resolveUpload({ src: '/generated/pasted.png', objectKey: 'generated-character-drafts/editor/agent-paste/image/pasted.png', assetObjectId: 'asset-object-pasted', legacyPublicPath: '/generated/pasted.png', }); }); expect(await screen.findByText('粘贴图片')).toBeTruthy(); enterAttachmentPrompt(); fireEvent.click(screen.getByRole('button', { name: '发送' })); await waitFor(() => { expect(client.sendMessage).toHaveBeenCalledWith( 'conversation-1', expect.objectContaining({ attachments: [ expect.objectContaining({ referenceId: 'resource-referenced', }), expect.objectContaining({ referenceId: 'resource-pasted' }), ], }), expect.any(Object), ); }); }); it('evaluates the nine-image limit against references added during paste', async () => { const client = createClient(); const layers = Array.from({ length: 9 }, (_, index) => ({ id: `layer-${index + 1}`, resourceId: `resource-${index + 1}`, title: `附件-${index + 1}`, src: `/generated/attachment-${index + 1}.png`, objectKey: `editor/attachment-${index + 1}.png`, x: 0, y: 0, width: 512, height: 512, originalWidth: 512, originalHeight: 512, zIndex: index + 1, sourceType: 'generated' as const, })); vi.mocked(client.getConversation).mockResolvedValue({ conversationId: 'conversation-1', projectId: 'project-1', title: '附件上限', messages: [ { id: 9, role: 'system', text: 'internal tool result', attachments: [], toolCall: { toolName: 'generate_image', status: 'completed', args: {}, displayArgs: { stringArgs: [], imageArgs: [], extras: { priceMudPoints: 1 }, }, images: [ { resourceId: 'resource-9', objectKey: 'editor/attachment-9.png', imageSrc: '/generated/attachment-9.png', width: 512, height: 512, }, ], }, createdAt: '2026-07-20T00:00:00.000Z', }, ], createdAt: '2026-07-20T00:00:00.000Z', updatedAt: '2026-07-20T00:00:10.000Z', }); let resolveUpload!: (upload: { src: string; objectKey: string; assetObjectId: string; legacyPublicPath: string; }) => void; uploadEditorMediaAssetFileMock.mockImplementationOnce( () => new Promise((resolve) => { resolveUpload = resolve; }), ); render( , ); const messageLog = await screen.findByRole('log', { name: '画布 Agent 消息流', }); fireEvent.click(screen.getByRole('button', { name: '添加附件' })); const attachmentDialog = screen.getByRole('dialog', { name: '选择图片附件', }); for (let index = 1; index <= 8; index += 1) { fireEvent.click( within(attachmentDialog).getByRole('checkbox', { name: `选择画布图片 附件-${index}`, }), ); } fireEvent.click( within(attachmentDialog).getByRole('button', { name: '应用' }), ); const input = screen.getByLabelText('发送给画布 Agent'); fireEvent.paste(input, { clipboardData: { files: [ new File(['pending-paste'], 'pending.png', { type: 'image/png' }), ], }, }); await waitFor(() => expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(1), ); await waitFor(() => expect(messageLog.querySelector('.grid.grid-cols-3 > div')).toBeTruthy(), ); fireEvent.contextMenu( messageLog.querySelector('.grid.grid-cols-3 > div')!, { clientX: 30, clientY: 40 }, ); fireEvent.click(screen.getByRole('menuitem', { name: '引用' })); expect(await screen.findByText('附件-9')).toBeTruthy(); await act(async () => { resolveUpload({ src: '/generated/pasted.png', objectKey: 'generated-character-drafts/editor/agent-paste/image/pasted.png', assetObjectId: 'asset-object-pasted', legacyPublicPath: '/generated/pasted.png', }); }); expect(await screen.findByText('最多 9 张')).toBeTruthy(); expect(screen.queryByText('粘贴图片')).toBeNull(); enterAttachmentPrompt(); fireEvent.click(screen.getByRole('button', { name: '发送' })); await waitFor(() => { const request = vi.mocked(client.sendMessage).mock.calls[0]?.[1]; expect(request?.attachments).toHaveLength(9); expect(request?.attachments).toEqual( expect.arrayContaining([ expect.objectContaining({ referenceId: 'resource-9' }), ]), ); expect(request?.attachments).not.toEqual( expect.arrayContaining([ expect.objectContaining({ referenceId: 'resource-pasted' }), ]), ); }); }); it('rejects selected attachments when the text input is empty', async () => { const client = createClient(); render( , ); await waitFor(() => { expect(screen.getByText('已经看到画布内容')).toBeTruthy(); }); fireEvent.click(screen.getByRole('button', { name: '添加附件' })); const attachmentDialog = screen.getByRole('dialog', { name: '选择图片附件', }); fireEvent.click( within(attachmentDialog).getByRole('checkbox', { name: '选择画布图片 角色图层', }), ); fireEvent.click( within(attachmentDialog).getByRole('button', { name: '应用' }), ); const sendButton = screen.getByRole('button', { name: '发送', }) as HTMLButtonElement; expect(sendButton.disabled).toBe(true); fireEvent.click(sendButton); expect(client.sendMessage).not.toHaveBeenCalled(); expect(screen.getByText('角色图层')).toBeTruthy(); }); it('restores the draft and selected attachments when sending fails', async () => { const client = createClient(); vi.mocked(client.sendMessage).mockRejectedValueOnce( new Error('Network error'), ); render( , ); await waitFor(() => { expect(screen.getByText('已经看到画布内容')).toBeTruthy(); }); fireEvent.click(screen.getByRole('button', { name: '添加附件' })); const attachmentDialog = screen.getByRole('dialog', { name: '选择图片附件', }); fireEvent.click( within(attachmentDialog).getByRole('checkbox', { name: '选择画布图片 角色图层', }), ); fireEvent.click( within(attachmentDialog).getByRole('button', { name: '应用' }), ); setPlainTextEditorValue( screen.getByLabelText('发送给画布 Agent'), '失败后恢复这条草稿', ); fireEvent.click(screen.getByRole('button', { name: '发送' })); expect(await screen.findByText('Network error')).toBeTruthy(); await waitFor(() => expect(screen.getByLabelText('发送给画布 Agent').textContent).toBe( '失败后恢复这条草稿', ), ); expect(screen.getByText('角色图层')).toBeTruthy(); expect( within( screen.getByRole('log', { name: '画布 Agent 消息流' }), ).queryByText('失败后恢复这条草稿'), ).toBeNull(); }); it('merges sent attachments with references added while a failed request is pending', async () => { const client = createClient(); let rejectSend!: (reason?: unknown) => void; vi.mocked(client.sendMessage).mockImplementationOnce( () => new Promise((_resolve, reject) => { rejectSend = reject; }), ); vi.mocked(client.getConversation).mockResolvedValue({ conversationId: 'conversation-1', projectId: 'project-1', title: '失败恢复并发附件', messages: [ { id: 9, role: 'system', text: 'internal tool result', attachments: [], toolCall: { toolName: 'generate_image', status: 'completed', args: {}, displayArgs: { stringArgs: [], imageArgs: [], extras: { priceMudPoints: 1 }, }, images: [ { resourceId: 'resource-b', objectKey: 'editor/attachment-b.png', imageSrc: '/generated-editor-images/attachment-b.png', width: 512, height: 512, }, ], }, createdAt: '2026-07-20T00:00:00.000Z', }, ], createdAt: '2026-07-20T00:00:00.000Z', updatedAt: '2026-07-20T00:00:10.000Z', }); render( , ); const messageLog = await screen.findByRole('log', { name: '画布 Agent 消息流', }); fireEvent.click(screen.getByRole('button', { name: '添加附件' })); const attachmentDialog = screen.getByRole('dialog', { name: '选择图片附件', }); fireEvent.click( within(attachmentDialog).getByRole('checkbox', { name: '选择画布图片 附件 A', }), ); fireEvent.click( within(attachmentDialog).getByRole('button', { name: '应用' }), ); enterAttachmentPrompt(); fireEvent.click(screen.getByRole('button', { name: '发送' })); await waitFor(() => { expect(client.sendMessage).toHaveBeenCalledWith( 'conversation-1', expect.objectContaining({ attachments: [expect.objectContaining({ referenceId: 'resource-a' })], }), expect.any(Object), ); }); await waitFor(() => expect(messageLog.querySelector('.grid.grid-cols-3 > div')).toBeTruthy(), ); fireEvent.contextMenu( messageLog.querySelector('.grid.grid-cols-3 > div')!, { clientX: 30, clientY: 40 }, ); fireEvent.click(screen.getByRole('menuitem', { name: '引用' })); expect(await screen.findByText('附件 B')).toBeTruthy(); await act(async () => { rejectSend(new Error('Network error')); }); expect(await screen.findByText('Network error')).toBeTruthy(); expect(screen.getByText('附件 A')).toBeTruthy(); expect(screen.getByText('附件 B')).toBeTruthy(); }); it('keeps the current snapshot when failed recovery has the same attachment identity', async () => { const client = createClient(); let rejectSend!: (reason?: unknown) => void; vi.mocked(client.sendMessage).mockImplementationOnce( () => new Promise((_resolve, reject) => { rejectSend = reject; }), ); const oldLayer = { id: 'layer-updated', resourceId: 'resource-updated', title: '旧版附件', src: '/generated-editor-images/old.png', objectKey: 'editor/old.png', x: 0, y: 0, width: 512, height: 512, originalWidth: 512, originalHeight: 512, zIndex: 1, sourceType: 'generated' as const, }; const currentLayer = { ...oldLayer, title: '当前附件', src: '/generated-editor-images/current.png', objectKey: 'editor/current.png', }; const { rerender } = render( , ); await waitFor(() => { expect(screen.getByText('已经看到画布内容')).toBeTruthy(); }); fireEvent.click(screen.getByRole('button', { name: '添加附件' })); let attachmentDialog = screen.getByRole('dialog', { name: '选择图片附件', }); fireEvent.click( within(attachmentDialog).getByRole('checkbox', { name: '选择画布图片 旧版附件', }), ); fireEvent.click( within(attachmentDialog).getByRole('button', { name: '应用' }), ); enterAttachmentPrompt(); fireEvent.click(screen.getByRole('button', { name: '发送' })); await waitFor(() => { expect(client.sendMessage).toHaveBeenCalledWith( 'conversation-1', expect.objectContaining({ attachments: [ expect.objectContaining({ referenceId: 'resource-updated', objectKey: 'editor/old.png', }), ], }), expect.any(Object), ); }); rerender( , ); fireEvent.click(screen.getByRole('button', { name: '添加附件' })); attachmentDialog = screen.getByRole('dialog', { name: '选择图片附件', }); fireEvent.click( within(attachmentDialog).getByRole('checkbox', { name: '选择画布图片 当前附件', }), ); fireEvent.click( within(attachmentDialog).getByRole('button', { name: '应用' }), ); await act(async () => { rejectSend(new Error('Network error')); }); expect(await screen.findByText('Network error')).toBeTruthy(); expect(screen.getByText('当前附件')).toBeTruthy(); expect(screen.queryByText('旧版附件')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '发送' })); await waitFor(() => { expect(client.sendMessage).toHaveBeenCalledTimes(2); expect( vi.mocked(client.sendMessage).mock.calls[1]?.[1].attachments, ).toEqual([ expect.objectContaining({ referenceId: 'resource-updated', objectKey: 'editor/current.png', }), ]); }); }); it('keeps the latest nine attachments when failed attachment recovery would exceed the limit', async () => { const client = createClient(); let rejectSend!: (reason?: unknown) => void; vi.mocked(client.sendMessage).mockImplementationOnce( () => new Promise((_resolve, reject) => { rejectSend = reject; }), ); const layers = Array.from({ length: 18 }, (_, index) => ({ id: `layer-${index + 1}`, resourceId: `resource-${index + 1}`, title: `附件-${index + 1}`, src: `/generated-editor-images/attachment-${index + 1}.png`, objectKey: `editor/attachment-${index + 1}.png`, x: 0, y: 0, width: 512, height: 512, originalWidth: 512, originalHeight: 512, zIndex: index + 1, sourceType: 'generated' as const, })); render( , ); await waitFor(() => { expect(screen.getByText('已经看到画布内容')).toBeTruthy(); }); fireEvent.click(screen.getByRole('button', { name: '添加附件' })); let attachmentDialog = screen.getByRole('dialog', { name: '选择图片附件', }); for (let index = 1; index <= 9; index += 1) { fireEvent.click( within(attachmentDialog).getByRole('checkbox', { name: `选择画布图片 附件-${index}`, }), ); } fireEvent.click( within(attachmentDialog).getByRole('button', { name: '应用' }), ); enterAttachmentPrompt(); fireEvent.click(screen.getByRole('button', { name: '发送' })); await waitFor(() => { expect(client.sendMessage).toHaveBeenCalledWith( 'conversation-1', expect.objectContaining({ attachments: expect.any(Array) }), expect.any(Object), ); expect( vi.mocked(client.sendMessage).mock.calls[0]?.[1].attachments, ).toHaveLength(9); }); fireEvent.click(screen.getByRole('button', { name: '添加附件' })); attachmentDialog = screen.getByRole('dialog', { name: '选择图片附件', }); for (let index = 10; index <= 18; index += 1) { fireEvent.click( within(attachmentDialog).getByRole('checkbox', { name: `选择画布图片 附件-${index}`, }), ); } fireEvent.click( within(attachmentDialog).getByRole('button', { name: '应用' }), ); await act(async () => { rejectSend(new Error('Network error')); }); expect( await screen.findByText('最多 9 张,发送失败的附件未恢复'), ).toBeTruthy(); for (let index = 1; index <= 9; index += 1) { expect(screen.queryByText(`附件-${index}`)).toBeNull(); } for (let index = 10; index <= 18; index += 1) { expect(screen.getByText(`附件-${index}`)).toBeTruthy(); } }); it('preserves messages when the panel is collapsed and reopened', async () => { const client = createClient(); vi.mocked(client.sendMessage).mockResolvedValue({ conversation: { conversationId: 'conversation-1', projectId: 'project-1', title: '角色参考', updatedAt: '2026-07-03T00:00:10.000Z', }, deltaMessages: [ { id: 1, role: 'assistant', text: '我来生成图片。', attachments: [], toolCall: null, createdAt: '2026-07-03T00:00:00.000Z', }, ], errorMessage: null, } as EditorAgentMessageResponse); const { rerender } = render( , ); await waitFor(() => { expect(screen.getByText('已经看到画布内容')).toBeTruthy(); }); setPlainTextEditorValue( screen.getByLabelText('发送给画布 Agent'), '生成一张图片', ); fireEvent.click(screen.getByRole('button', { name: '发送' })); await waitFor(() => { expect(screen.getByText('我来生成图片。')).toBeTruthy(); }); rerender( , ); expect(screen.getByRole('button', { name: '打开画布 Agent' })).toBeTruthy(); rerender( , ); expect(screen.getByText('已经看到画布内容')).toBeTruthy(); expect(screen.getByText('我来生成图片。')).toBeTruthy(); }); it('shows structured pending details, hides system text and confirms once', async () => { const client = createClient(); const pendingMessage = createPendingToolCallMessage(); const confirmedMessage: EditorAgentMessage = { ...pendingMessage, text: 'internal completed tool output that must stay hidden', toolCall: { ...pendingMessage.toolCall!, externalJobId: 'task-edit-panel-1', }, }; vi.mocked(client.getConversation) .mockResolvedValueOnce({ conversationId: 'conversation-1', projectId: 'project-1', title: '角色参考', messages: [pendingMessage], createdAt: '2026-07-03T00:00:00.000Z', updatedAt: '2026-07-03T00:00:10.000Z', }) .mockResolvedValue({ conversationId: 'conversation-1', projectId: 'project-1', title: '角色参考', messages: [confirmedMessage], createdAt: '2026-07-03T00:00:00.000Z', updatedAt: '2026-07-03T00:00:10.000Z', }); let resolveConfirmation: () => void = () => {}; vi.mocked(client.confirmToolCall).mockImplementation( () => new Promise((resolve) => { resolveConfirmation = resolve; }), ); render( , ); expect(await screen.findByText('把角色换成像素风')).toBeTruthy(); expect(screen.getByText('提示词')).toBeTruthy(); expect(screen.getByText('2 张')).toBeTruthy(); expect(screen.getByAltText('目标图片:原角色')).toBeTruthy(); expect(screen.getByAltText('参考图片:像素风参考')).toBeTruthy(); expect(screen.getByAltText('参考图片:配色参考')).toBeTruthy(); expect(screen.getByText('预计消耗 3泥点')).toBeTruthy(); expect(screen.queryByText('source-image-1')).toBeNull(); expect(screen.queryByText('reference-image-1')).toBeNull(); expect( screen.queryByText('internal system prompt that must stay hidden'), ).toBeNull(); const getConversationCallsBeforeConfirm = vi.mocked(client.getConversation) .mock.calls.length; fireEvent.click(screen.getByRole('button', { name: '确认' })); await waitFor(() => { expect(client.confirmToolCall).toHaveBeenCalledWith('conversation-1', 2); }); expect(screen.getByRole('button', { name: '执行中' })).toBeTruthy(); expect(screen.queryByRole('button', { name: '确认' })).toBeNull(); expect( screen.getByRole('button', { name: '取消' }).hasAttribute('disabled'), ).toBe(true); await act(async () => { resolveConfirmation(); }); expect(await screen.findByText('执行中')).toBeTruthy(); expect(client.getConversation).toHaveBeenCalledTimes( getConversationCallsBeforeConfirm + 1, ); expect( screen.queryByText( 'internal completed tool output that must stay hidden', ), ).toBeNull(); expect(refreshCanvasMock).not.toHaveBeenCalled(); expect(screen.queryByRole('button', { name: '确认' })).toBeNull(); }); it('cancels a pending tool call without exposing its system text', async () => { const client = createClient(); const pendingMessage = createPendingToolCallMessage(); const cancelledMessage: EditorAgentMessage = { ...pendingMessage, text: 'internal cancelled tool output that must stay hidden', toolCall: { ...pendingMessage.toolCall!, status: 'cancelled', }, }; vi.mocked(client.getConversation) .mockResolvedValueOnce({ conversationId: 'conversation-1', projectId: 'project-1', title: '角色参考', messages: [pendingMessage], createdAt: '2026-07-03T00:00:00.000Z', updatedAt: '2026-07-03T00:00:10.000Z', }) .mockResolvedValue({ conversationId: 'conversation-1', projectId: 'project-1', title: '角色参考', messages: [cancelledMessage], createdAt: '2026-07-03T00:00:00.000Z', updatedAt: '2026-07-03T00:00:10.000Z', }); vi.mocked(client.cancelToolCall).mockResolvedValue(undefined); render( , ); const cancelButton = await screen.findByRole('button', { name: '取消' }); const getConversationCallsBeforeCancel = vi.mocked(client.getConversation) .mock.calls.length; fireEvent.click(cancelButton); expect(await screen.findByText('已取消')).toBeTruthy(); expect(client.cancelToolCall).toHaveBeenCalledWith('conversation-1', 2); expect(client.getConversation).toHaveBeenCalledTimes( getConversationCallsBeforeCancel + 1, ); expect( screen.queryByText( 'internal cancelled tool output that must stay hidden', ), ).toBeNull(); expect(screen.queryByRole('button', { name: '确认' })).toBeNull(); }); it('keeps wheel scrolling inside the message history and input', async () => { const client = createClient(); const parentWheel = vi.fn(); render(
, ); await waitFor(() => { expect(screen.getByText('已经看到画布内容')).toBeTruthy(); }); const messageWheel = new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 120, }); fireEvent( screen.getByRole('log', { name: '画布 Agent 消息流' }), messageWheel, ); expect(parentWheel).not.toHaveBeenCalled(); expect(messageWheel.defaultPrevented).toBe(false); const inputWheel = new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 120, }); fireEvent(screen.getByLabelText('发送给画布 Agent'), inputWheel); expect(parentWheel).not.toHaveBeenCalled(); expect(inputWheel.defaultPrevented).toBe(false); }); it('confirms conversation deletion from an independent dialog', async () => { const client = createClient(); render( , ); await waitFor(() => { expect(screen.getByText('已经看到画布内容')).toBeTruthy(); }); fireEvent.click(screen.getByRole('button', { name: '删除当前对话' })); const confirmDialog = screen.getByRole('dialog', { name: '删除对话' }); expect(confirmDialog.parentElement?.className).toContain( 'platform-theme--light', ); expect(confirmDialog.parentElement?.className).toContain('z-[140]'); expect(confirmDialog.className).toContain('platform-remap-surface'); expect( within(confirmDialog).getByText('确认删除这个对话吗?'), ).toBeTruthy(); fireEvent.click( within(confirmDialog).getByRole('button', { name: '确认删除' }), ); await waitFor(() => { expect(client.deleteConversation).toHaveBeenCalledWith('conversation-1'); }); }); });