实现画布 Agent 请求停止交互

为每轮消息请求注入 AbortController 并停止当前 HTTP 请求

停止后在 isAborting 生命周期内静默刷新并阻止重复发送

补充 AbortError、客户端 signal 与面板状态回归测试
This commit is contained in:
2026-07-22 17:37:54 +08:00
parent ae9502d9ed
commit 98daad90c0
7 changed files with 337 additions and 64 deletions
@@ -11,6 +11,7 @@ import {
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type {
EditorAgentConversationDetail,
EditorAgentMessage,
EditorAgentMessageResponse,
} from '@/packages/shared/src/contracts';
@@ -315,7 +316,7 @@ describe('EditorAgentConversationPanelView', () => {
);
});
it('disables sending while a message request is pending without showing stop', async () => {
it('replaces send with interrupt while a message request is pending', async () => {
const client = createClient();
let resolveSend!: (response: EditorAgentMessageResponse) => void;
vi.mocked(client.sendMessage).mockImplementation(
@@ -346,10 +347,8 @@ describe('EditorAgentConversationPanelView', () => {
await act(async () => {
await Promise.resolve();
});
expect(
screen.getByRole('button', { name: '发送' }).hasAttribute('disabled'),
).toBe(true);
expect(screen.queryByRole('button', { name: '停止' })).toBeNull();
expect(screen.queryByRole('button', { name: '发送' })).toBeNull();
expect(screen.getByRole('button', { name: '停止' })).toBeTruthy();
expect(screen.queryByText('仍在处理中,请耐心等待')).toBeNull();
act(() => {
@@ -373,6 +372,77 @@ describe('EditorAgentConversationPanelView', () => {
expect(screen.queryByText('仍在处理中,请耐心等待')).toBeNull();
});
it('keeps post-abort refresh inside the stopping state', async () => {
const client = createClient();
let rejectSend!: (error: unknown) => void;
const capturedRequest: { signal: AbortSignal | null } = { signal: null };
vi.mocked(client.sendMessage).mockImplementationOnce(
(_conversationId, _payload, options) =>
new Promise<EditorAgentMessageResponse>((_resolve, reject) => {
rejectSend = reject;
capturedRequest.signal = options.signal ?? null;
}),
);
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
/>,
);
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
let resolveRefresh!: (detail: EditorAgentConversationDetail) => void;
vi.mocked(client.getConversation).mockImplementationOnce(
() =>
new Promise<EditorAgentConversationDetail>((resolve) => {
resolveRefresh = resolve;
}),
);
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: '请中断这一轮' },
});
fireEvent.click(screen.getByRole('button', { name: '发送' }));
const interruptButton = await screen.findByRole('button', {
name: '停止',
});
fireEvent.click(interruptButton);
expect(capturedRequest.signal?.aborted).toBe(true);
expect(screen.getByRole('button', { name: '停止中' })).toBeTruthy();
await act(async () => {
rejectSend(
capturedRequest.signal?.reason ??
new DOMException('aborted', 'AbortError'),
);
await Promise.resolve();
});
expect(screen.getByRole('button', { name: '停止中' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '发送' })).toBeNull();
expect(screen.queryByText('刷新中')).toBeNull();
expect(screen.queryByText('加载中')).toBeNull();
expect(screen.getByText('请中断这一轮')).toBeTruthy();
await act(async () => {
resolveRefresh({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:20.000Z',
});
await Promise.resolve();
});
expect(await screen.findByRole('button', { name: '发送' })).toBeTruthy();
expect(screen.queryByText('刷新中')).toBeNull();
});
it('uploads pasted images as canvas attachments before sending', async () => {
const client = createClient();
@@ -5,6 +5,7 @@ import {
Paperclip,
Plus,
Send,
Square,
Trash2,
X,
} from 'lucide-react';
@@ -258,6 +259,7 @@ export function EditorAgentConversationPanelView({
isCreatingConversation,
isDeletingConversation,
isWaiting,
isAborting,
isPatienceNoticeVisible,
toolCallAction,
isToolCallActionPending,
@@ -266,6 +268,7 @@ export function EditorAgentConversationPanelView({
selectConversation,
refreshActiveConversation,
sendMessage,
stopCurrentTurn,
confirmToolCall,
cancelToolCall,
deleteActiveConversation,
@@ -582,7 +585,9 @@ export function EditorAgentConversationPanelView({
? '执行中'
: toolCallAction?.action === 'cancel'
? '取消中'
: '思考中'}
: isAborting
? '停止中'
: '思考中'}
</span>
</div>
) : null}
@@ -673,19 +678,38 @@ export function EditorAgentConversationPanelView({
}
}}
/>
<button
type="submit"
className="inline-flex h-10 min-w-16 shrink-0 items-center justify-center gap-1.5 rounded-full bg-slate-900 px-3 text-sm font-semibold text-white disabled:opacity-45"
disabled={
isWaiting ||
isToolCallActionPending ||
(!draftText.trim() && !attachments.length) ||
!hasProject
}
>
<Send className="h-3.5 w-3.5" aria-hidden="true" />
</button>
{isWaiting ? (
<button
type="button"
className="inline-flex h-10 min-w-20 shrink-0 items-center justify-center gap-1.5 rounded-full bg-slate-900 px-3 text-sm font-semibold text-white disabled:opacity-45"
disabled={isAborting}
onClick={stopCurrentTurn}
>
{isAborting ? (
<Loader2
className="h-3.5 w-3.5 animate-spin"
aria-hidden="true"
/>
) : (
<Square className="h-3.5 w-3.5" aria-hidden="true" />
)}
{isAborting ? '停止中' : '停止'}
</button>
) : (
<button
type="submit"
className="inline-flex h-10 min-w-16 shrink-0 items-center justify-center gap-1.5 rounded-full bg-slate-900 px-3 text-sm font-semibold text-white disabled:opacity-45"
disabled={
isToolCallActionPending ||
isLoadingMessages ||
(!draftText.trim() && !attachments.length) ||
!hasProject
}
>
<Send className="h-3.5 w-3.5" aria-hidden="true" />
</button>
)}
</div>
</form>
</aside>
@@ -149,7 +149,7 @@ describe('useEditorAgentConversation', () => {
text: '把这个角色改成像素风',
attachments: [],
}),
{},
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(result.current.isWaiting).toBe(false);
expect(result.current.activeConversation?.title).toBe(
@@ -969,15 +969,17 @@ describe('useEditorAgentConversation', () => {
expect(vi.getTimerCount()).toBe(0);
});
it('keeps the active request pending without exposing a stop action', async () => {
it('aborts the active request, keeps the user message, and allows a new prompt', async () => {
const client = createClient();
let capturedSignal: AbortSignal | null = null;
let resolveSend!: (response: EditorAgentMessageResponse) => void;
vi.mocked(client.sendMessage).mockImplementation(
(_conversationId, _payload, options) =>
new Promise<EditorAgentMessageResponse>((resolve) => {
resolveSend = resolve;
capturedSignal = options.signal ?? null;
const capturedRequest: { signal: AbortSignal | null } = { signal: null };
let capturedClientMessageId = '';
let rejectSend!: (error: unknown) => void;
vi.mocked(client.sendMessage).mockImplementationOnce(
(_conversationId, payload, options) =>
new Promise<EditorAgentMessageResponse>((_resolve, reject) => {
capturedClientMessageId = payload.clientMessageId;
rejectSend = reject;
capturedRequest.signal = options.signal ?? null;
}),
);
const { result } = renderHook(() =>
@@ -990,42 +992,95 @@ describe('useEditorAgentConversation', () => {
);
});
vi.useFakeTimers();
let sendPromise!: Promise<void>;
act(() => {
sendPromise = result.current.sendMessage('请继续');
void result.current.sendMessage('不要重复发送');
});
await waitFor(() => {
expect(capturedRequest.signal).not.toBeNull();
});
expect(result.current.isWaiting).toBe(true);
expect(client.sendMessage).toHaveBeenCalledTimes(1);
expect(capturedRequest.signal?.aborted).toBe(false);
expect(typeof result.current.stopCurrentTurn).toBe('function');
act(() => {
result.current.stopCurrentTurn();
});
expect(capturedRequest.signal?.aborted).toBe(true);
expect(result.current.isAborting).toBe(true);
let resolveRefresh!: (detail: EditorAgentConversationDetail) => void;
vi.mocked(client.getConversation).mockImplementationOnce(
() =>
new Promise<EditorAgentConversationDetail>((resolve) => {
resolveRefresh = resolve;
}),
);
await act(async () => {
rejectSend(
capturedRequest.signal?.reason ??
new DOMException('aborted', 'AbortError'),
);
await Promise.resolve();
});
expect(result.current.isWaiting).toBe(true);
expect(result.current.isPatienceNoticeVisible).toBe(false);
expect(client.sendMessage).toHaveBeenCalledTimes(1);
expect(capturedSignal).toBeNull();
expect('stopCurrentTurn' in result.current).toBe(false);
act(() => {
vi.advanceTimersByTime(EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS);
});
expect(result.current.isPatienceNoticeVisible).toBe(true);
expect(result.current.isAborting).toBe(true);
expect(result.current.isLoadingMessages).toBe(false);
expect(result.current.errorMessage).toBeNull();
expect(result.current.messages).toEqual([
expect.objectContaining({
clientMessageId: capturedClientMessageId,
role: 'user',
text: '请继续',
}),
]);
await act(async () => {
resolveSend({
conversation: {
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
updatedAt: '2026-07-03T00:00:20.000Z',
},
deltaMessages: [],
errorMessage: null,
await result.current.sendMessage('刷新完成前不应发送');
});
expect(client.sendMessage).toHaveBeenCalledTimes(1);
await act(async () => {
resolveRefresh({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [
{
id: 1,
clientMessageId: capturedClientMessageId,
role: 'user',
text: '请继续',
attachments: [],
toolCall: null,
createdAt: '2026-07-03T00:00:20.000Z',
},
],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:20.000Z',
});
await sendPromise;
});
expect(result.current.isLoadingMessages).toBe(false);
expect(result.current.isWaiting).toBe(false);
expect(result.current.isPatienceNoticeVisible).toBe(false);
expect(result.current.isAborting).toBe(false);
await act(async () => {
await result.current.sendMessage('新的请求');
});
expect(client.sendMessage).toHaveBeenCalledTimes(2);
expect(client.sendMessage).toHaveBeenLastCalledWith(
'conversation-1',
expect.objectContaining({
clientMessageId: expect.not.stringMatching(capturedClientMessageId),
text: '新的请求',
}),
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
});
@@ -20,6 +20,7 @@ import {
sendEditorAgentMessage,
type SendEditorAgentMessageOptions,
} from '../../../services/image-editor/editorAgentClient.ts';
import { isAbortError } from '../../../services/apiClient.ts';
export type EditorAgentConversationClient = {
listConversations: (
@@ -58,6 +59,13 @@ export type EditorAgentToolCallActionState = {
action: EditorAgentToolCallAction;
} | null;
type ActiveEditorAgentSend = {
requestId: number;
conversationId: string;
clientMessageId: string;
controller: AbortController;
};
const defaultEditorAgentConversationClient: EditorAgentConversationClient = {
listConversations: listEditorAgentConversations,
createConversation: createEditorAgentConversation,
@@ -151,6 +159,7 @@ export function useEditorAgentConversation({
const [isCreatingConversation, setIsCreatingConversation] = useState(false);
const [isDeletingConversation, setIsDeletingConversation] = useState(false);
const [isWaiting, setIsWaiting] = useState(false);
const [isAborting, setIsAborting] = useState(false);
const [patienceNoticeConversationId, setPatienceNoticeConversationId] =
useState<string | null>(null);
const [toolCallAction, setToolCallAction] =
@@ -163,6 +172,8 @@ export function useEditorAgentConversation({
const createConversationRequestIdRef = useRef(0);
const isWaitingRef = useRef(false);
const pendingSendRequestIdRef = useRef(0);
const activeSendRef = useRef<ActiveEditorAgentSend | null>(null);
const stoppedSendRequestIdRef = useRef<number | null>(null);
const patienceNoticeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
@@ -177,6 +188,8 @@ export function useEditorAgentConversation({
pendingSendRequestIdRef.current += 1;
createConversationRequestIdRef.current += 1;
isWaitingRef.current = false;
activeSendRef.current = null;
stoppedSendRequestIdRef.current = null;
if (patienceNoticeTimerRef.current !== null) {
clearTimeout(patienceNoticeTimerRef.current);
patienceNoticeTimerRef.current = null;
@@ -188,11 +201,14 @@ export function useEditorAgentConversation({
pendingSendRequestIdRef.current += 1;
createConversationRequestIdRef.current += 1;
isWaitingRef.current = false;
activeSendRef.current = null;
stoppedSendRequestIdRef.current = null;
if (patienceNoticeTimerRef.current !== null) {
clearTimeout(patienceNoticeTimerRef.current);
patienceNoticeTimerRef.current = null;
}
setIsWaiting(false);
setIsAborting(false);
setIsCreatingConversation(false);
setPatienceNoticeConversationId(null);
}, [normalizedProjectId]);
@@ -224,14 +240,20 @@ export function useEditorAgentConversation({
);
const loadConversation = useCallback(
async (conversationId: string, options: { showLoading?: boolean } = {}) => {
async (
conversationId: string,
options: { showLoading?: boolean; reportError?: boolean } = {},
) => {
const requestId = conversationLoadRequestIdRef.current + 1;
conversationLoadRequestIdRef.current = requestId;
const showLoading = options.showLoading ?? true;
const reportError = options.reportError ?? true;
if (showLoading) {
setIsLoadingMessages(true);
}
setErrorMessage(null);
if (reportError) {
setErrorMessage(null);
}
try {
const detail = await client.getConversation(conversationId);
if (conversationLoadRequestIdRef.current === requestId) {
@@ -239,14 +261,20 @@ export function useEditorAgentConversation({
}
return detail;
} catch (error) {
if (conversationLoadRequestIdRef.current === requestId) {
if (
reportError &&
conversationLoadRequestIdRef.current === requestId
) {
setErrorMessage(
error instanceof Error ? error.message : '读取画布 Agent 会话失败',
);
}
throw error;
} finally {
if (conversationLoadRequestIdRef.current === requestId) {
if (
showLoading &&
conversationLoadRequestIdRef.current === requestId
) {
setIsLoadingMessages(false);
}
}
@@ -435,6 +463,7 @@ export function useEditorAgentConversation({
return;
}
const clientMessageId = createEditorAgentClientMessageId();
const controller = new AbortController();
const nextOptimisticMessage = createLocalUserMessage({
id: -1,
clientMessageId,
@@ -442,6 +471,12 @@ export function useEditorAgentConversation({
attachments,
});
optimisticMessage = nextOptimisticMessage;
activeSendRef.current = {
requestId,
conversationId,
clientMessageId,
controller,
};
setMessages((currentMessages) => [
...currentMessages,
nextOptimisticMessage,
@@ -459,7 +494,7 @@ export function useEditorAgentConversation({
text,
attachments,
},
{},
{ signal: controller.signal },
);
if (pendingSendRequestIdRef.current !== requestId) {
@@ -480,6 +515,20 @@ export function useEditorAgentConversation({
applyDeltaMessages(response.deltaMessages);
}
} catch (error) {
const wasStoppedByUser =
stoppedSendRequestIdRef.current === requestId && isAbortError(error);
if (wasStoppedByUser) {
if (
conversationId &&
activeConversationIdRef.current === conversationId
) {
await loadConversation(conversationId, {
showLoading: false,
reportError: false,
}).catch(() => undefined);
}
return;
}
const message =
error instanceof Error ? error.message : '发送画布 Agent 消息失败';
const shouldReportError =
@@ -504,7 +553,14 @@ export function useEditorAgentConversation({
patienceNoticeTimerRef.current = null;
}
isWaitingRef.current = false;
if (activeSendRef.current?.requestId === requestId) {
activeSendRef.current = null;
}
if (stoppedSendRequestIdRef.current === requestId) {
stoppedSendRequestIdRef.current = null;
}
setIsWaiting(false);
setIsAborting(false);
setPatienceNoticeConversationId(null);
}
}
@@ -515,10 +571,21 @@ export function useEditorAgentConversation({
applyDeltaMessages,
isLoadingConversations,
isLoadingMessages,
loadConversation,
normalizedProjectId,
],
);
const stopCurrentTurn = useCallback(() => {
const activeSend = activeSendRef.current;
if (!activeSend || activeSend.controller.signal.aborted) {
return;
}
stoppedSendRequestIdRef.current = activeSend.requestId;
setIsAborting(true);
activeSend.controller.abort();
}, []);
const resolveToolCall = useCallback(
async (messageId: number, action: EditorAgentToolCallAction) => {
const conversationId = activeConversationIdRef.current;
@@ -622,6 +689,7 @@ export function useEditorAgentConversation({
isCreatingConversation,
isDeletingConversation,
isWaiting,
isAborting,
isPatienceNoticeVisible:
isWaiting && patienceNoticeConversationId === activeConversationId,
toolCallAction,
@@ -631,6 +699,7 @@ export function useEditorAgentConversation({
selectConversation,
refreshActiveConversation,
sendMessage,
stopCurrentTurn,
confirmToolCall,
cancelToolCall,
deleteActiveConversation,
+50
View File
@@ -6,6 +6,7 @@ import {
clearStoredAccessToken,
fetchWithApiAuth,
getStoredAccessToken,
isAbortError,
isTimeoutError,
refreshStoredAccessToken,
requestJson,
@@ -652,6 +653,55 @@ describe('apiClient', () => {
);
});
it('does not retry a caller-aborted unsafe request', async () => {
setStoredAccessToken('editor-agent-token', { emit: false });
const controller = new AbortController();
fetchMock.mockImplementation(
async (_input: string, init?: RequestInit) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener(
'abort',
() => reject(init.signal?.reason),
{ once: true },
);
}),
);
const request = requestJson(
'/api/editor/agent-conversations/conversation-1/messages',
{
method: 'POST',
signal: controller.signal,
body: JSON.stringify({
clientMessageId: 'client-message-aborted',
text: '中断这一轮',
}),
},
'发送画布 Agent 消息失败',
{
authImpact: 'local',
retry: {
maxRetries: 1,
baseDelayMs: 1,
maxDelayMs: 1,
retryUnsafeMethods: true,
},
},
);
await Promise.resolve();
controller.abort();
let capturedError: unknown;
try {
await request;
} catch (error) {
capturedError = error;
}
expect(isAbortError(capturedError)).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('aborts requests when timeoutMs is reached', async () => {
setStoredAccessToken('timeout-token', { emit: false });
fetchMock.mockImplementation(
+4 -5
View File
@@ -405,11 +405,10 @@ function shouldRetryResponse(
export function isAbortError(error: unknown) {
return (
error instanceof Error &&
(error.name === 'AbortError' ||
(typeof DOMException !== 'undefined' &&
error instanceof DOMException &&
error.name === 'AbortError'))
typeof error === 'object' &&
error !== null &&
'name' in error &&
error.name === 'AbortError'
);
}
@@ -121,12 +121,17 @@ describe('editorAgentClient', () => {
errorMessage: null,
};
requestJsonMock.mockResolvedValueOnce(responseBody);
const controller = new AbortController();
const result = await sendEditorAgentMessage('conversation-1', {
clientMessageId: 'client-message-1',
text: '帮我把角色改成像素风',
attachments: [],
});
const result = await sendEditorAgentMessage(
'conversation-1',
{
clientMessageId: 'client-message-1',
text: '帮我把角色改成像素风',
attachments: [],
},
{ signal: controller.signal },
);
expect(result).toEqual(responseBody);
expect(requestJsonMock).toHaveBeenCalledWith(
@@ -139,6 +144,7 @@ describe('editorAgentClient', () => {
text: '帮我把角色改成像素风',
attachments: [],
}),
signal: controller.signal,
}),
'发送画布 Agent 消息失败',
expect.objectContaining({