/* @vitest-environment jsdom */ import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { EditorAgentMessage, EditorAgentMessageResponse, } from '../../../../packages/shared/src/contracts/editorAgent.ts'; import { type EditorAgentConversationClient, useEditorAgentConversation, } from './useEditorAgentConversation.ts'; function createEditImageDisplayArgs(prompt: string) { return { stringArgs: [{ name: 'prompt', label: '修改要求', value: prompt }], imageArgs: [ { name: 'object_image_id', label: '目标图片', refs: [ { imageId: 'source-image', imageSrc: '/source-image.png', label: '源图片', }, ], }, ], extras: { priceMudPoints: 3 }, }; } 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:00:00.000Z', updatedAt: '2026-07-03T00:01:00.000Z', }), getConversation: 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', }), 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:01.000Z', }, deltaMessages: [ { id: 1, role: 'assistant', text: '我来处理', attachments: [], toolCall: { toolName: 'generate_image', status: 'completed', args: {}, displayArgs: { stringArgs: [], imageArgs: [], extras: { priceMudPoints: 0 }, }, images: [ { resourceId: 'resource-result-1', imageSrc: '/result.png', thumbnailSrc: '/result-thumb.png', width: 1024, height: 1024, }, ], error: null, }, createdAt: '2026-07-03T00:00:00.000Z', }, ], errorMessage: null, } as EditorAgentMessageResponse), confirmToolCall: vi.fn(), cancelToolCall: vi.fn(), }; } describe('useEditorAgentConversation', () => { beforeEach(() => { vi.clearAllMocks(); }); it('loads conversations and applies delta messages', async () => { const client = createClient(); const onCanvasRefreshRequested = vi.fn(); const { result } = renderHook(() => useEditorAgentConversation({ projectId: 'project-1', client, onCanvasRefreshRequested, }), ); await waitFor(() => { expect(result.current.activeConversation?.conversationId).toBe( 'conversation-1', ); }); await act(async () => { await result.current.sendMessage('把这个角色改成像素风'); }); expect(client.sendMessage).toHaveBeenCalledWith( 'conversation-1', expect.objectContaining({ text: '把这个角色改成像素风', attachments: [], }), expect.objectContaining({ signal: expect.any(AbortSignal), }), ); expect(result.current.isWaiting).toBe(false); expect(result.current.activeConversation?.title).toBe( '把这个角色改成像素风', ); expect(onCanvasRefreshRequested).toHaveBeenCalledTimes(1); expect(result.current.messages.map((message) => message.text)).toEqual([ '把这个角色改成像素风', '我来处理', ]); expect(result.current.messages[1]?.toolCall).toEqual( expect.objectContaining({ toolName: 'generate_image', status: 'completed', images: [ expect.objectContaining({ resourceId: 'resource-result-1', thumbnailSrc: '/result-thumb.png', }), ], }), ); }); it('creates a conversation before sending when the project has no history', async () => { const client = createClient(); vi.mocked(client.listConversations).mockResolvedValueOnce([]); const { result } = renderHook(() => useEditorAgentConversation({ projectId: 'project-1', client }), ); await waitFor(() => { expect(result.current.isLoadingConversations).toBe(false); }); await act(async () => { await result.current.sendMessage('新建后发送'); }); expect(client.createConversation).toHaveBeenCalledWith('project-1', {}); expect(client.sendMessage).toHaveBeenCalledWith( 'conversation-2', expect.objectContaining({ text: '新建后发送' }), expect.any(Object), ); }); it('allows sending an attachment-only message', async () => { const client = createClient(); const { result } = renderHook(() => useEditorAgentConversation({ projectId: 'project-1', client }), ); await waitFor(() => { expect(result.current.activeConversation?.conversationId).toBe( 'conversation-1', ); }); await act(async () => { await result.current.sendMessage('', [ { source: 'canvas_resource', referenceId: 'resource-1', objectKey: 'generated-editor-assets/resource-1.png', imageSrc: '/resource-1.png', label: '参考图', }, ]); }); expect(client.sendMessage).toHaveBeenCalledWith( 'conversation-1', expect.objectContaining({ text: '', attachments: [ expect.objectContaining({ source: 'canvas_resource', referenceId: 'resource-1', }), ], }), expect.any(Object), ); }); it('handles backend error responses', async () => { const client = createClient(); vi.mocked(client.sendMessage).mockResolvedValue({ conversation: { conversationId: 'conversation-1', projectId: 'project-1', title: '这是美术素材', updatedAt: '2026-07-03T00:00:01.000Z', }, deltaMessages: [], errorMessage: 'LLM 未配置,无法处理这句话。', } as EditorAgentMessageResponse); const { result } = renderHook(() => useEditorAgentConversation({ projectId: 'project-1', client }), ); await waitFor(() => { expect(result.current.activeConversation?.conversationId).toBe( 'conversation-1', ); }); await act(async () => { await result.current.sendMessage('这是美术素材'); }); expect(result.current.errorMessage).toBe('LLM 未配置,无法处理这句话。'); }); it('confirms a pending tool call, replaces its message and requests a canvas refresh', async () => { const client = createClient(); vi.mocked(client.sendMessage).mockResolvedValue({ conversation: { conversationId: 'conversation-1', projectId: 'project-1', title: '角色参考', updatedAt: '2026-07-03T00:00:01.000Z', }, deltaMessages: [ { id: 1, role: 'system', text: '需要生成一张图', attachments: [], toolCall: { toolName: 'edit-image', status: 'pending_confirmation', args: { object_image_id: 'source-image', prompt: '换成像素风', }, displayArgs: createEditImageDisplayArgs('换成像素风'), images: [], error: null, }, createdAt: '2026-07-03T00:00:00.000Z', }, ], errorMessage: null, } as EditorAgentMessageResponse); vi.mocked(client.confirmToolCall).mockResolvedValue({ id: 1, role: 'system', text: 'internal completed tool output', attachments: [], toolCall: { toolName: 'edit-image', status: 'completed', args: { object_image_id: 'source-image', prompt: '换成像素风', }, displayArgs: createEditImageDisplayArgs('换成像素风'), images: [ { resourceId: null, objectKey: 'generated/result.png', imageSrc: '/result.png', thumbnailSrc: null, width: 512, height: 512, }, ], error: null, }, createdAt: '2026-07-03T00:00:00.000Z', }); const onCanvasRefreshRequested = vi.fn(); const { result } = renderHook(() => useEditorAgentConversation({ projectId: 'project-1', client, onCanvasRefreshRequested, }), ); await waitFor(() => { expect(result.current.activeConversation?.conversationId).toBe( 'conversation-1', ); }); await act(async () => { await result.current.sendMessage('生成一张图'); }); const systemMsg = result.current.messages[1]; expect(systemMsg?.role).toBe('system'); expect(systemMsg?.toolCall?.status).toBe('pending_confirmation'); const messageCount = result.current.messages.length; await act(async () => { await result.current.confirmToolCall(1); }); expect(client.confirmToolCall).toHaveBeenCalledWith('conversation-1', 1); expect(result.current.messages).toHaveLength(messageCount); expect(result.current.messages[1]?.toolCall?.status).toBe('completed'); expect(result.current.messages[1]?.toolCall?.images[0]?.objectKey).toBe( 'generated/result.png', ); expect(onCanvasRefreshRequested).toHaveBeenCalledTimes(1); }); it('marks a confirmation as executing before the request finishes', async () => { const client = createClient(); const pendingMessage: EditorAgentMessage = { id: 0, role: 'system', text: '需要生成一张图', attachments: [], toolCall: { toolName: 'edit-image', status: 'pending_confirmation', args: { object_image_id: 'source-image', prompt: '换成像素风' }, displayArgs: createEditImageDisplayArgs('换成像素风'), images: [], error: null, }, createdAt: '2026-07-03T00:00:00.000Z', }; const completedMessage: EditorAgentMessage = { ...pendingMessage, toolCall: { ...pendingMessage.toolCall!, status: 'completed', images: [], }, }; let resolveConfirmation: ((message: EditorAgentMessage) => void) | undefined; vi.mocked(client.getConversation).mockResolvedValue({ conversationId: 'conversation-1', projectId: 'project-1', title: '角色参考', messages: [pendingMessage], createdAt: '2026-07-03T00:00:00.000Z', updatedAt: '2026-07-03T00:00:00.000Z', }); vi.mocked(client.confirmToolCall).mockImplementation( () => new Promise((resolve) => { resolveConfirmation = resolve; }), ); const { result } = renderHook(() => useEditorAgentConversation({ projectId: 'project-1', client }), ); await waitFor(() => { expect(result.current.messages[0]?.toolCall?.status).toBe( 'pending_confirmation', ); }); act(() => { void result.current.confirmToolCall(0); }); await waitFor(() => { expect(result.current.messages[0]?.toolCall?.status).toBe('executing'); }); await result.current.confirmToolCall(0); expect(client.confirmToolCall).toHaveBeenCalledTimes(1); await act(async () => { resolveConfirmation?.(completedMessage); }); await waitFor(() => { expect(result.current.messages[0]?.toolCall?.status).toBe('completed'); }); }); it('cancels a pending tool call and keeps the replacement in the same position', async () => { const client = createClient(); const pendingMessage = { id: 0, role: 'system' as const, text: 'internal pending tool prompt', attachments: [], toolCall: { toolName: 'edit-image', status: 'pending_confirmation' as const, args: { object_image_id: 'source-image', prompt: '换成像素风' }, displayArgs: createEditImageDisplayArgs('换成像素风'), images: [], error: null, }, createdAt: '2026-07-03T00:00:00.000Z', }; vi.mocked(client.getConversation).mockResolvedValue({ conversationId: 'conversation-1', projectId: 'project-1', title: '角色参考', messages: [pendingMessage], createdAt: '2026-07-03T00:00:00.000Z', updatedAt: '2026-07-03T00:00:00.000Z', }); vi.mocked(client.cancelToolCall).mockResolvedValue({ ...pendingMessage, text: 'internal cancelled tool output', toolCall: { ...pendingMessage.toolCall, status: 'cancelled', }, }); const onCanvasRefreshRequested = vi.fn(); const { result } = renderHook(() => useEditorAgentConversation({ projectId: 'project-1', client, onCanvasRefreshRequested, }), ); await waitFor(() => { expect(result.current.messages[0]?.toolCall?.status).toBe( 'pending_confirmation', ); }); await act(async () => { await result.current.cancelToolCall(0); }); expect(client.cancelToolCall).toHaveBeenCalledWith('conversation-1', 0); expect(result.current.messages).toHaveLength(1); expect(result.current.messages[0]?.toolCall?.status).toBe('cancelled'); expect(onCanvasRefreshRequested).not.toHaveBeenCalled(); }); it('handles fetch error as failed stage', async () => { const client = createClient(); vi.mocked(client.sendMessage).mockRejectedValue(new Error('Network error')); const { result } = renderHook(() => useEditorAgentConversation({ projectId: 'project-1', client }), ); await waitFor(() => { expect(result.current.activeConversation?.conversationId).toBe( 'conversation-1', ); }); await act(async () => { await expect(result.current.sendMessage('test')).resolves.toBeUndefined(); }); expect(result.current.errorMessage).toBe('Network error'); }); it('aborts the active request and resets state on stopCurrentTurn', async () => { const client = createClient(); let capturedSignal: AbortSignal | null = null; vi.mocked(client.sendMessage).mockImplementation( (_conversationId, _payload, options) => new Promise((resolve, reject) => { capturedSignal = options.signal ?? null; capturedSignal?.addEventListener('abort', () => { reject(new DOMException('Aborted', 'AbortError')); }); }), ); const { result } = renderHook(() => useEditorAgentConversation({ projectId: 'project-1', client }), ); await waitFor(() => { expect(result.current.activeConversation?.conversationId).toBe( 'conversation-1', ); }); void act(() => { void result.current.sendMessage('请继续'); }); await waitFor(() => { expect(result.current.isWaiting).toBe(true); }); act(() => { result.current.stopCurrentTurn(); }); await waitFor(() => { expect(capturedSignal?.aborted).toBe(true); }); expect(result.current.isWaiting).toBe(false); }); });