实现画布 Agent 请求停止交互
为每轮消息请求注入 AbortController 并停止当前 HTTP 请求 停止后在 isAborting 生命周期内静默刷新并阻止重复发送 补充 AbortError、客户端 signal 与面板状态回归测试
This commit is contained in:
+75
-5
@@ -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();
|
||||
|
||||
|
||||
+38
-14
@@ -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>
|
||||
|
||||
+85
-30
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user