修复画布Agent工具调用轮询状态同步

补齐工具调用轮询依赖并同步服务端终态
清理任务切换后的旧轮询,避免迟到响应覆盖
新增失败、取消及任务切换竞态测试
This commit is contained in:
2026-07-18 12:44:40 +08:00
parent 5e512a6b23
commit a5ae283561
2 changed files with 161 additions and 11 deletions
@@ -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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
function createToolCall(
overrides: Partial<EditorAgentToolCall> = {},
): 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<typeof createCompletedJobResponse>
>();
getExternalGenerationJobStatusMock.mockReturnValueOnce(
pendingResponse.promise,
);
const onJobCompleted = vi.fn();
const { rerender } = render(
<ToolCallView
toolCall={createToolCall()}
onJobCompleted={onJobCompleted}
/>,
);
await waitFor(() => {
expect(getExternalGenerationJobStatusMock).toHaveBeenCalledWith(
'job-a',
);
});
rerender(
<ToolCallView
toolCall={createToolCall({ status, error })}
onJobCompleted={onJobCompleted}
/>,
);
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<typeof createCompletedJobResponse>
>();
const jobBResponse = createDeferred<
ReturnType<typeof createCompletedJobResponse>
>();
getExternalGenerationJobStatusMock.mockImplementation((jobId: string) =>
jobId === 'job-a' ? jobAResponse.promise : jobBResponse.promise,
);
const onJobCompleted = vi.fn();
const { rerender } = render(
<ToolCallView
toolCall={createToolCall()}
onJobCompleted={onJobCompleted}
/>,
);
await waitFor(() => {
expect(getExternalGenerationJobStatusMock).toHaveBeenCalledWith('job-a');
});
rerender(
<ToolCallView
toolCall={createToolCall({ externalJobId: 'job-b' })}
onJobCompleted={onJobCompleted}
/>,
);
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);
});
});
@@ -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<string | null>(
toolCall.error ?? null,
);
const [displayError, setDisplayError] =
useState<string | null>(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<typeof setTimeout> | 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';