48c9ee2fae
将 120 秒硬超时改为请求存活时的耐心等待提示。 收口 provider 安全上限、有限重试和明确失败错误。 补齐等待互斥、计时清理、前后端测试及契约文档。
1032 lines
31 KiB
TypeScript
1032 lines
31 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type {
|
|
EditorAgentConversationDetail,
|
|
EditorAgentMessage,
|
|
EditorAgentMessageResponse,
|
|
} from '../../../../packages/shared/src/contracts/editorAgent.ts';
|
|
import {
|
|
EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS,
|
|
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',
|
|
externalJobId: 'task-generated-1',
|
|
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();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
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({
|
|
clientMessageId: expect.stringMatching(/^editor-agent-/u),
|
|
text: '把这个角色改成像素风',
|
|
attachments: [],
|
|
}),
|
|
{},
|
|
);
|
|
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[0]?.clientMessageId).toMatch(
|
|
/^editor-agent-/u,
|
|
);
|
|
expect(result.current.messages[1]?.toolCall).toEqual(
|
|
expect.objectContaining({
|
|
toolName: 'generate_image',
|
|
externalJobId: 'task-generated-1',
|
|
images: [
|
|
expect.objectContaining({
|
|
resourceId: 'resource-result-1',
|
|
thumbnailSrc: '/result-thumb.png',
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('keeps the latest conversation when detail responses arrive out of order', async () => {
|
|
const client = createClient();
|
|
let resolveConversation2!: (detail: EditorAgentConversationDetail) => void;
|
|
let resolveConversation3!: (detail: EditorAgentConversationDetail) => void;
|
|
const conversation2Promise = new Promise<EditorAgentConversationDetail>(
|
|
(resolve) => {
|
|
resolveConversation2 = resolve;
|
|
},
|
|
);
|
|
const conversation3Promise = new Promise<EditorAgentConversationDetail>(
|
|
(resolve) => {
|
|
resolveConversation3 = resolve;
|
|
},
|
|
);
|
|
const { result } = renderHook(() =>
|
|
useEditorAgentConversation({ projectId: 'project-1', client }),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.activeConversationId).toBe('conversation-1');
|
|
});
|
|
vi.mocked(client.getConversation).mockImplementation((conversationId) => {
|
|
if (conversationId === 'conversation-2') {
|
|
return conversation2Promise;
|
|
}
|
|
if (conversationId === 'conversation-3') {
|
|
return conversation3Promise;
|
|
}
|
|
throw new Error(`Unexpected conversation: ${conversationId}`);
|
|
});
|
|
|
|
let conversation2Load!: Promise<EditorAgentConversationDetail | null>;
|
|
let conversation3Load!: Promise<EditorAgentConversationDetail | null>;
|
|
act(() => {
|
|
conversation2Load = result.current.selectConversation('conversation-2');
|
|
conversation3Load = result.current.selectConversation('conversation-3');
|
|
});
|
|
|
|
await act(async () => {
|
|
resolveConversation3({
|
|
conversationId: 'conversation-3',
|
|
projectId: 'project-1',
|
|
title: '第三个会话',
|
|
messages: [
|
|
{
|
|
id: 3,
|
|
role: 'assistant',
|
|
text: '第三个会话消息',
|
|
attachments: [],
|
|
toolCall: null,
|
|
createdAt: '2026-07-03T00:03:00.000Z',
|
|
},
|
|
],
|
|
createdAt: '2026-07-03T00:03:00.000Z',
|
|
updatedAt: '2026-07-03T00:03:00.000Z',
|
|
});
|
|
await conversation3Load;
|
|
});
|
|
expect(result.current.activeConversationId).toBe('conversation-3');
|
|
expect(result.current.messages[0]?.text).toBe('第三个会话消息');
|
|
expect(result.current.isLoadingMessages).toBe(false);
|
|
|
|
await act(async () => {
|
|
resolveConversation2({
|
|
conversationId: 'conversation-2',
|
|
projectId: 'project-1',
|
|
title: '第二个会话',
|
|
messages: [
|
|
{
|
|
id: 2,
|
|
role: 'assistant',
|
|
text: '第二个会话消息',
|
|
attachments: [],
|
|
toolCall: null,
|
|
createdAt: '2026-07-03T00:02:00.000Z',
|
|
},
|
|
],
|
|
createdAt: '2026-07-03T00:02:00.000Z',
|
|
updatedAt: '2026-07-03T00:02:00.000Z',
|
|
});
|
|
await conversation2Load;
|
|
});
|
|
expect(result.current.activeConversationId).toBe('conversation-3');
|
|
expect(result.current.messages[0]?.text).toBe('第三个会话消息');
|
|
expect(result.current.isLoadingMessages).toBe(false);
|
|
});
|
|
|
|
it('appends a lazily reconciled tool message delta', async () => {
|
|
const client = createClient();
|
|
const pendingMessage: EditorAgentMessage = {
|
|
id: 0,
|
|
role: 'system',
|
|
text: 'pending edit',
|
|
attachments: [],
|
|
toolCall: {
|
|
toolName: 'edit-image',
|
|
status: 'not_completed',
|
|
externalJobId: 'task-edit-1',
|
|
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.sendMessage).mockResolvedValue({
|
|
conversation: {
|
|
conversationId: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: '角色参考',
|
|
updatedAt: '2026-07-03T00:00:01.000Z',
|
|
},
|
|
deltaMessages: [
|
|
{
|
|
...pendingMessage,
|
|
text: '[tool_call:edit-image] output: completed',
|
|
toolCall: {
|
|
...pendingMessage.toolCall!,
|
|
status: 'completed',
|
|
images: [{ imageSrc: '/generated/result.png' }],
|
|
},
|
|
},
|
|
],
|
|
errorMessage: null,
|
|
});
|
|
const { result } = renderHook(() =>
|
|
useEditorAgentConversation({ projectId: 'project-1', client }),
|
|
);
|
|
await waitFor(() => {
|
|
expect(result.current.messages).toHaveLength(1);
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.sendMessage('继续');
|
|
});
|
|
|
|
expect(result.current.messages).toHaveLength(3);
|
|
expect(result.current.messages[2]?.toolCall?.images[0]?.imageSrc).toBe(
|
|
'/generated/result.png',
|
|
);
|
|
});
|
|
|
|
it('does not apply a completed message response after switching conversations', async () => {
|
|
const client = createClient();
|
|
const onCanvasRefreshRequested = vi.fn();
|
|
let resolveSend!: (response: EditorAgentMessageResponse) => void;
|
|
vi.mocked(client.sendMessage).mockImplementation(
|
|
() =>
|
|
new Promise<EditorAgentMessageResponse>((resolve) => {
|
|
resolveSend = resolve;
|
|
}),
|
|
);
|
|
const { result } = renderHook(() =>
|
|
useEditorAgentConversation({
|
|
projectId: 'project-1',
|
|
client,
|
|
onCanvasRefreshRequested,
|
|
}),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.activeConversationId).toBe('conversation-1');
|
|
});
|
|
|
|
let sendPromise!: Promise<void>;
|
|
act(() => {
|
|
sendPromise = result.current.sendMessage('在第一个会话生成图片');
|
|
});
|
|
await waitFor(() => {
|
|
expect(result.current.isWaiting).toBe(true);
|
|
});
|
|
|
|
vi.mocked(client.getConversation).mockResolvedValueOnce({
|
|
conversationId: 'conversation-2',
|
|
projectId: 'project-1',
|
|
title: '第二个会话',
|
|
messages: [
|
|
{
|
|
id: 20,
|
|
role: 'assistant',
|
|
text: '第二个会话原有消息',
|
|
attachments: [],
|
|
toolCall: null,
|
|
createdAt: '2026-07-03T00:02:00.000Z',
|
|
},
|
|
],
|
|
createdAt: '2026-07-03T00:02:00.000Z',
|
|
updatedAt: '2026-07-03T00:02:00.000Z',
|
|
});
|
|
await act(async () => {
|
|
await result.current.selectConversation('conversation-2');
|
|
});
|
|
|
|
await act(async () => {
|
|
resolveSend({
|
|
conversation: {
|
|
conversationId: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: '第一个会话已更新',
|
|
updatedAt: '2026-07-03T00:03:00.000Z',
|
|
},
|
|
deltaMessages: [
|
|
{
|
|
id: 21,
|
|
role: 'assistant',
|
|
text: '第一个会话生成完成',
|
|
attachments: [],
|
|
toolCall: {
|
|
toolName: 'generate_image',
|
|
status: 'completed',
|
|
externalJobId: 'task-conversation-1',
|
|
args: {},
|
|
displayArgs: {
|
|
stringArgs: [],
|
|
imageArgs: [],
|
|
extras: { priceMudPoints: 0 },
|
|
},
|
|
images: [{ imageSrc: '/conversation-1-result.png' }],
|
|
error: null,
|
|
},
|
|
createdAt: '2026-07-03T00:03:00.000Z',
|
|
},
|
|
],
|
|
errorMessage: null,
|
|
});
|
|
await sendPromise;
|
|
});
|
|
|
|
expect(result.current.activeConversationId).toBe('conversation-2');
|
|
expect(result.current.messages).toHaveLength(1);
|
|
expect(result.current.messages[0]?.text).toBe('第二个会话原有消息');
|
|
expect(onCanvasRefreshRequested).not.toHaveBeenCalled();
|
|
expect(
|
|
result.current.conversations.find(
|
|
(conversation) => conversation.conversationId === 'conversation-1',
|
|
)?.title,
|
|
).toBe('第一个会话已更新');
|
|
});
|
|
|
|
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('does not send or apply a stale conversation created after switching projects', async () => {
|
|
const client = createClient();
|
|
let resolveCreate!: (detail: EditorAgentConversationDetail) => void;
|
|
vi.mocked(client.listConversations)
|
|
.mockResolvedValueOnce([])
|
|
.mockResolvedValueOnce([
|
|
{
|
|
conversationId: 'conversation-project-2',
|
|
projectId: 'project-2',
|
|
title: '项目二会话',
|
|
updatedAt: '2026-07-03T00:02:00.000Z',
|
|
},
|
|
]);
|
|
vi.mocked(client.createConversation).mockImplementationOnce(
|
|
() =>
|
|
new Promise<EditorAgentConversationDetail>((resolve) => {
|
|
resolveCreate = resolve;
|
|
}),
|
|
);
|
|
vi.mocked(client.getConversation).mockResolvedValueOnce({
|
|
conversationId: 'conversation-project-2',
|
|
projectId: 'project-2',
|
|
title: '项目二会话',
|
|
messages: [
|
|
{
|
|
id: 20,
|
|
role: 'assistant',
|
|
text: '项目二消息',
|
|
attachments: [],
|
|
toolCall: null,
|
|
createdAt: '2026-07-03T00:02:00.000Z',
|
|
},
|
|
],
|
|
createdAt: '2026-07-03T00:02:00.000Z',
|
|
updatedAt: '2026-07-03T00:02:00.000Z',
|
|
});
|
|
const { result, rerender } = renderHook(
|
|
({ projectId }) => useEditorAgentConversation({ projectId, client }),
|
|
{ initialProps: { projectId: 'project-1' } },
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoadingConversations).toBe(false);
|
|
});
|
|
|
|
let sendPromise!: Promise<void>;
|
|
act(() => {
|
|
sendPromise = result.current.sendMessage('旧项目消息');
|
|
});
|
|
await waitFor(() => {
|
|
expect(client.createConversation).toHaveBeenCalledWith('project-1', {});
|
|
});
|
|
|
|
rerender({ projectId: 'project-2' });
|
|
await waitFor(() => {
|
|
expect(result.current.activeConversationId).toBe(
|
|
'conversation-project-2',
|
|
);
|
|
});
|
|
|
|
await act(async () => {
|
|
resolveCreate({
|
|
conversationId: 'conversation-project-1',
|
|
projectId: 'project-1',
|
|
title: '旧项目新会话',
|
|
messages: [],
|
|
createdAt: '2026-07-03T00:01:00.000Z',
|
|
updatedAt: '2026-07-03T00:01:00.000Z',
|
|
});
|
|
await sendPromise;
|
|
});
|
|
|
|
expect(client.sendMessage).not.toHaveBeenCalled();
|
|
expect(result.current.activeConversationId).toBe('conversation-project-2');
|
|
expect(result.current.messages.map((message) => message.text)).toEqual([
|
|
'项目二消息',
|
|
]);
|
|
expect(result.current.isWaiting).toBe(false);
|
|
expect(result.current.isPatienceNoticeVisible).toBe(false);
|
|
});
|
|
|
|
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('applies persisted backend planning errors as system messages', 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: 2,
|
|
role: 'system',
|
|
text: 'ERROR LLM 未配置,无法处理这句话。',
|
|
attachments: [],
|
|
toolCall: null,
|
|
createdAt: '2026-07-16T00:00:01.000Z',
|
|
},
|
|
],
|
|
errorMessage: null,
|
|
} 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).toBeNull();
|
|
expect(result.current.messages).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
role: 'system',
|
|
text: 'ERROR 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: 2,
|
|
role: 'system',
|
|
text: '需要生成一张图',
|
|
attachments: [],
|
|
toolCall: {
|
|
toolName: 'edit-image',
|
|
status: 'not_completed',
|
|
args: {
|
|
object_image_id: 'source-image',
|
|
prompt: '换成像素风',
|
|
},
|
|
displayArgs: createEditImageDisplayArgs('换成像素风'),
|
|
images: [],
|
|
error: null,
|
|
},
|
|
createdAt: '2026-07-03T00:00:00.000Z',
|
|
},
|
|
],
|
|
errorMessage: null,
|
|
} as EditorAgentMessageResponse);
|
|
const confirmedMessage: EditorAgentMessage = {
|
|
id: 2,
|
|
role: 'system',
|
|
text: 'internal confirmed tool output',
|
|
attachments: [],
|
|
toolCall: {
|
|
toolName: 'edit-image',
|
|
status: 'not_completed',
|
|
externalJobId: 'task-edit-1',
|
|
args: {
|
|
object_image_id: 'source-image',
|
|
prompt: '换成像素风',
|
|
},
|
|
displayArgs: createEditImageDisplayArgs('换成像素风'),
|
|
images: [],
|
|
error: null,
|
|
},
|
|
createdAt: '2026-07-03T00:00:00.000Z',
|
|
};
|
|
vi.mocked(client.confirmToolCall).mockResolvedValue(undefined);
|
|
const onCanvasRefreshRequested = vi.fn();
|
|
const onConfirmSent = vi.fn();
|
|
const { result } = renderHook(() =>
|
|
useEditorAgentConversation({
|
|
projectId: 'project-1',
|
|
client,
|
|
onCanvasRefreshRequested,
|
|
onConfirmSent,
|
|
}),
|
|
);
|
|
|
|
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?.id).toBe(2);
|
|
expect(systemMsg?.toolCall?.externalJobId).toBeUndefined();
|
|
vi.mocked(client.getConversation).mockResolvedValue({
|
|
conversationId: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: '角色参考',
|
|
messages: [result.current.messages[0]!, confirmedMessage],
|
|
createdAt: '2026-07-03T00:00:00.000Z',
|
|
updatedAt: '2026-07-03T00:00:00.000Z',
|
|
});
|
|
const getConversationCallsBeforeConfirm = vi.mocked(client.getConversation)
|
|
.mock.calls.length;
|
|
|
|
const messageCount = result.current.messages.length;
|
|
await act(async () => {
|
|
await result.current.confirmToolCall(systemMsg!.id);
|
|
});
|
|
|
|
expect(client.confirmToolCall).toHaveBeenCalledWith('conversation-1', 2);
|
|
expect(client.getConversation).toHaveBeenCalledTimes(
|
|
getConversationCallsBeforeConfirm + 1,
|
|
);
|
|
expect(result.current.messages).toHaveLength(messageCount);
|
|
expect(result.current.messages[1]?.toolCall?.status).toBe('not_completed');
|
|
expect(result.current.messages[1]?.toolCall?.externalJobId).toBe(
|
|
'task-edit-1',
|
|
);
|
|
expect(onCanvasRefreshRequested).not.toHaveBeenCalled();
|
|
expect(onConfirmSent).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('keeps the action busy until confirmation succeeds', async () => {
|
|
const client = createClient();
|
|
const pendingMessage: EditorAgentMessage = {
|
|
id: 7,
|
|
role: 'system',
|
|
text: '需要生成一张图',
|
|
attachments: [],
|
|
toolCall: {
|
|
toolName: 'edit-image',
|
|
status: 'not_completed',
|
|
args: { object_image_id: 'source-image', prompt: '换成像素风' },
|
|
displayArgs: createEditImageDisplayArgs('换成像素风'),
|
|
images: [],
|
|
error: null,
|
|
},
|
|
createdAt: '2026-07-03T00:00:00.000Z',
|
|
};
|
|
const confirmedMessage: EditorAgentMessage = {
|
|
...pendingMessage,
|
|
toolCall: {
|
|
...pendingMessage.toolCall!,
|
|
externalJobId: 'task-edit-2',
|
|
},
|
|
};
|
|
let resolveConfirmation: (() => void) | undefined;
|
|
vi.mocked(client.getConversation)
|
|
.mockResolvedValueOnce({
|
|
conversationId: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: '角色参考',
|
|
messages: [pendingMessage],
|
|
createdAt: '2026-07-03T00:00:00.000Z',
|
|
updatedAt: '2026-07-03T00:00:00.000Z',
|
|
})
|
|
.mockResolvedValue({
|
|
conversationId: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: '角色参考',
|
|
messages: [confirmedMessage],
|
|
createdAt: '2026-07-03T00:00:00.000Z',
|
|
updatedAt: '2026-07-03T00:00:00.000Z',
|
|
});
|
|
vi.mocked(client.confirmToolCall).mockImplementation(
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
resolveConfirmation = resolve;
|
|
}),
|
|
);
|
|
const { result } = renderHook(() =>
|
|
useEditorAgentConversation({ projectId: 'project-1', client }),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.messages[0]?.role).toBe('system');
|
|
expect(
|
|
result.current.messages[0]?.toolCall?.externalJobId,
|
|
).toBeUndefined();
|
|
});
|
|
const getConversationCallsBeforeConfirm = vi.mocked(client.getConversation)
|
|
.mock.calls.length;
|
|
|
|
let confirmationPromise: Promise<void> | undefined;
|
|
act(() => {
|
|
confirmationPromise = result.current.confirmToolCall(7);
|
|
});
|
|
await waitFor(() => expect(resolveConfirmation).toBeTypeOf('function'));
|
|
expect(client.confirmToolCall).toHaveBeenCalledWith('conversation-1', 7);
|
|
|
|
await act(async () => {
|
|
resolveConfirmation?.();
|
|
await confirmationPromise;
|
|
});
|
|
expect(result.current.toolCallAction).toBeNull();
|
|
expect(client.getConversation).toHaveBeenCalledTimes(
|
|
getConversationCallsBeforeConfirm + 1,
|
|
);
|
|
expect(result.current.messages[0]?.toolCall?.externalJobId).toBe(
|
|
'task-edit-2',
|
|
);
|
|
});
|
|
|
|
it('cancels a pending tool call with the refreshed cancelled message', async () => {
|
|
const client = createClient();
|
|
const pendingMessage: EditorAgentMessage = {
|
|
id: 9,
|
|
role: 'system' as const,
|
|
text: 'internal pending tool prompt',
|
|
attachments: [],
|
|
toolCall: {
|
|
toolName: 'edit-image',
|
|
status: 'not_completed',
|
|
args: { object_image_id: 'source-image', prompt: '换成像素风' },
|
|
displayArgs: createEditImageDisplayArgs('换成像素风'),
|
|
images: [],
|
|
error: null,
|
|
},
|
|
createdAt: '2026-07-03T00:00:00.000Z',
|
|
};
|
|
const cancelledMessage: EditorAgentMessage = {
|
|
...pendingMessage,
|
|
text: 'internal cancelled tool output',
|
|
toolCall: {
|
|
...pendingMessage.toolCall!,
|
|
status: 'cancelled',
|
|
},
|
|
};
|
|
vi.mocked(client.cancelToolCall).mockResolvedValue(undefined);
|
|
vi.mocked(client.getConversation)
|
|
.mockResolvedValueOnce({
|
|
conversationId: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: '角色参考',
|
|
messages: [pendingMessage],
|
|
createdAt: '2026-07-03T00:00:00.000Z',
|
|
updatedAt: '2026-07-03T00:00:00.000Z',
|
|
})
|
|
.mockResolvedValue({
|
|
conversationId: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: '角色参考',
|
|
messages: [cancelledMessage],
|
|
createdAt: '2026-07-03T00:00:00.000Z',
|
|
updatedAt: '2026-07-03T00:00:00.000Z',
|
|
});
|
|
const onCanvasRefreshRequested = vi.fn();
|
|
const { result } = renderHook(() =>
|
|
useEditorAgentConversation({
|
|
projectId: 'project-1',
|
|
client,
|
|
onCanvasRefreshRequested,
|
|
}),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.messages[0]?.toolCall?.status).toBe(
|
|
'not_completed',
|
|
);
|
|
});
|
|
const getConversationCallsBeforeCancel = vi.mocked(client.getConversation)
|
|
.mock.calls.length;
|
|
await act(async () => {
|
|
await result.current.cancelToolCall(9);
|
|
});
|
|
|
|
expect(client.cancelToolCall).toHaveBeenCalledWith('conversation-1', 9);
|
|
expect(client.getConversation).toHaveBeenCalledTimes(
|
|
getConversationCallsBeforeCancel + 1,
|
|
);
|
|
expect(result.current.messages).toHaveLength(1);
|
|
expect(result.current.messages[0]?.toolCall?.status).toBe('cancelled');
|
|
expect(onCanvasRefreshRequested).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rethrows fetch errors and rolls back the optimistic message', 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')).rejects.toThrow(
|
|
'Network error',
|
|
);
|
|
});
|
|
|
|
expect(result.current.errorMessage).toBe('Network error');
|
|
expect(result.current.messages).toHaveLength(0);
|
|
});
|
|
|
|
it('replaces the extended patience notice with the actual request failure', async () => {
|
|
const client = createClient();
|
|
let rejectSend!: (error: Error) => void;
|
|
vi.mocked(client.sendMessage).mockImplementation(
|
|
() =>
|
|
new Promise<EditorAgentMessageResponse>((_resolve, reject) => {
|
|
rejectSend = reject;
|
|
}),
|
|
);
|
|
const { result } = renderHook(() =>
|
|
useEditorAgentConversation({ projectId: 'project-1', client }),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.activeConversation?.conversationId).toBe(
|
|
'conversation-1',
|
|
);
|
|
});
|
|
|
|
vi.useFakeTimers();
|
|
let sendPromise!: Promise<void>;
|
|
act(() => {
|
|
sendPromise = result.current.sendMessage('请继续');
|
|
});
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
act(() => {
|
|
vi.advanceTimersByTime(EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS);
|
|
});
|
|
expect(result.current.isPatienceNoticeVisible).toBe(true);
|
|
|
|
await act(async () => {
|
|
rejectSend(new Error('LLM 连接已断开'));
|
|
await sendPromise.catch(() => undefined);
|
|
});
|
|
|
|
expect(result.current.isPatienceNoticeVisible).toBe(false);
|
|
expect(result.current.isWaiting).toBe(false);
|
|
expect(result.current.errorMessage).toBe('LLM 连接已断开');
|
|
expect(result.current.messages).toHaveLength(0);
|
|
});
|
|
|
|
it('cleans the patience timer when the hook unmounts', async () => {
|
|
const client = createClient();
|
|
vi.mocked(client.sendMessage).mockImplementation(
|
|
() => new Promise<EditorAgentMessageResponse>(() => undefined),
|
|
);
|
|
const { result, unmount } = renderHook(() =>
|
|
useEditorAgentConversation({ projectId: 'project-1', client }),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.activeConversation?.conversationId).toBe(
|
|
'conversation-1',
|
|
);
|
|
});
|
|
|
|
vi.useFakeTimers();
|
|
act(() => {
|
|
void result.current.sendMessage('请继续');
|
|
});
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
expect(vi.getTimerCount()).toBe(1);
|
|
|
|
unmount();
|
|
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
|
|
it('keeps the active request pending without exposing a stop action', 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 { result } = renderHook(() =>
|
|
useEditorAgentConversation({ projectId: 'project-1', client }),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.activeConversation?.conversationId).toBe(
|
|
'conversation-1',
|
|
);
|
|
});
|
|
|
|
vi.useFakeTimers();
|
|
let sendPromise!: Promise<void>;
|
|
act(() => {
|
|
sendPromise = result.current.sendMessage('请继续');
|
|
void result.current.sendMessage('不要重复发送');
|
|
});
|
|
await act(async () => {
|
|
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);
|
|
|
|
await act(async () => {
|
|
resolveSend({
|
|
conversation: {
|
|
conversationId: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: '角色参考',
|
|
updatedAt: '2026-07-03T00:00:20.000Z',
|
|
},
|
|
deltaMessages: [],
|
|
errorMessage: null,
|
|
});
|
|
await sendPromise;
|
|
});
|
|
|
|
expect(result.current.isWaiting).toBe(false);
|
|
expect(result.current.isPatienceNoticeVisible).toBe(false);
|
|
});
|
|
});
|