diff --git a/src/components/image-editor/EditorAgentConversation/ToolCallView.test.tsx b/src/components/image-editor/EditorAgentConversation/ToolCallView.test.tsx new file mode 100644 index 000000000..8ec8c461f --- /dev/null +++ b/src/components/image-editor/EditorAgentConversation/ToolCallView.test.tsx @@ -0,0 +1,154 @@ +/* @vitest-environment jsdom */ + +import { act, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { EditorAgentToolCall } from '@/packages/shared/src/contracts'; + +import ToolCallView from './ToolCallView.tsx'; + +const getExternalGenerationJobStatusMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/src/services/external-generation', () => ({ + getExternalGenerationJobStatus: getExternalGenerationJobStatusMock, +})); + +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +function createToolCall( + overrides: Partial = {}, +): EditorAgentToolCall { + return { + toolName: 'generate-image', + status: 'not_completed', + args: {}, + displayArgs: { + stringArgs: [], + imageArgs: [], + extras: { priceMudPoints: 1 }, + }, + externalJobId: 'job-a', + images: [], + error: null, + ...overrides, + }; +} + +function createCompletedJobResponse(jobId: string) { + return { + job: { + operationId: jobId, + status: 'completed' as const, + phaseLabel: '已完成', + phaseDetail: '生成完成', + progress: 100, + error: null, + updatedAtMicros: 1, + }, + }; +} + +describe('ToolCallView', () => { + beforeEach(() => { + getExternalGenerationJobStatusMock.mockReset(); + }); + + it.each([ + ['failed', '失败', '生成失败'], + ['cancelled', '已取消', null], + ] as const)( + 'keeps the server %s state when the pending response arrives late', + async (status, statusLabel, error) => { + const pendingResponse = createDeferred< + ReturnType + >(); + getExternalGenerationJobStatusMock.mockReturnValueOnce( + pendingResponse.promise, + ); + const onJobCompleted = vi.fn(); + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(getExternalGenerationJobStatusMock).toHaveBeenCalledWith( + 'job-a', + ); + }); + + rerender( + , + ); + expect(await screen.findByText(statusLabel)).toBeTruthy(); + + await act(async () => { + pendingResponse.resolve(createCompletedJobResponse('job-a')); + await pendingResponse.promise; + }); + + expect(screen.getByText(statusLabel)).toBeTruthy(); + expect(screen.queryByText('已完成')).toBeNull(); + expect(onJobCompleted).not.toHaveBeenCalled(); + }, + ); + + it('ignores job A after switching to job B and completes job B once', async () => { + const jobAResponse = createDeferred< + ReturnType + >(); + const jobBResponse = createDeferred< + ReturnType + >(); + getExternalGenerationJobStatusMock.mockImplementation((jobId: string) => + jobId === 'job-a' ? jobAResponse.promise : jobBResponse.promise, + ); + const onJobCompleted = vi.fn(); + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(getExternalGenerationJobStatusMock).toHaveBeenCalledWith('job-a'); + }); + + rerender( + , + ); + await waitFor(() => { + expect(getExternalGenerationJobStatusMock).toHaveBeenCalledWith('job-b'); + }); + + await act(async () => { + jobAResponse.resolve(createCompletedJobResponse('job-a')); + await jobAResponse.promise; + }); + expect(screen.getByText('执行中')).toBeTruthy(); + expect(onJobCompleted).not.toHaveBeenCalled(); + + await act(async () => { + jobBResponse.resolve(createCompletedJobResponse('job-b')); + await jobBResponse.promise; + }); + + expect(await screen.findByText('已完成')).toBeTruthy(); + expect(onJobCompleted).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx b/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx index dd58bf376..0c39c9749 100644 --- a/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx +++ b/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx @@ -18,7 +18,6 @@ function ToolCallView({ const videos = toolCall.videos ?? []; const audios = toolCall.audios ?? []; const jobId = toolCall.externalJobId?.trim() || null; - const displaySourceKey = jobId ?? toolCall.status; const initialDisplayStatus = toolCall.status === 'completed' ? 'completed' @@ -27,24 +26,21 @@ function ToolCallView({ : toolCall.status === 'cancelled' ? 'cancelled' : 'pending'; + const initialDisplayError = toolCall.error ?? null; + const shouldPoll = toolCall.status === 'not_completed' && Boolean(jobId); const [displayStatus, setDisplayStatus] = useState(initialDisplayStatus); - const [displayError, setDisplayError] = useState( - toolCall.error ?? null, - ); + const [displayError, setDisplayError] = + useState(initialDisplayError); const terminalNotifiedRef = useRef(false); const onJobCompletedRef = useRef(onJobCompleted); useEffect(() => { onJobCompletedRef.current = onJobCompleted; }, [onJobCompleted]); useEffect(() => { - if (toolCall.status !== 'not_completed' || !jobId) { - terminalNotifiedRef.current = false; - return; - } - // Only a different job may reinitialize the local display state. setDisplayStatus(initialDisplayStatus); - setDisplayError(toolCall.error ?? null); + setDisplayError(initialDisplayError); terminalNotifiedRef.current = false; + if (!shouldPoll || !jobId) return; let disposed = false; let timeoutId: ReturnType | undefined; const poll = async () => { @@ -73,7 +69,7 @@ function ToolCallView({ disposed = true; if (timeoutId) clearTimeout(timeoutId); }; - }, [displaySourceKey]); + }, [initialDisplayError, initialDisplayStatus, jobId, shouldPoll]); const isCancelled = displayStatus === 'cancelled'; const isCompleted = displayStatus === 'completed'; const isFailed = displayStatus === 'failed';