Files
Genarrative/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx
T
k88936 386534a415
Project CI / Frontend tests (push) Failing after 22s
Project CI / Repository checks (push) Successful in 1m10s
Project CI / Backend tests (push) Successful in 3m40s
Project CI / Native shell tests (push) Successful in 10m56s
Feat/artagent 点击/右键功能 聚焦到画布上的素材 (#123)
- 为避免prop drilling把原有的刷新画布集中到一个useContext中,
   在此基础上实现画布聚焦, 并在画布agent中使用

![shotmd-1785482938-compressed.webp](/attachments/59d71873-79fc-40e8-a9dc-7830a5ad7f3c)

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/123
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-07-31 17:25:35 +08:00

1311 lines
40 KiB
TypeScript

/* @vitest-environment jsdom */
import {
act,
renderHook as testingLibraryRenderHook,
type RenderHookOptions,
type RenderHookResult,
waitFor,
} from '@testing-library/react';
import type { ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
createEditorAgentAttachmentRef,
type EditorAgentConversationDetail,
type EditorAgentMessage,
type EditorAgentMessageResponse,
} from '../../../../packages/shared/src/contracts/editorAgent.ts';
import { ImageCanvasActionsProvider } from '../ImageCanvasActionsProvider.tsx';
import {
EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS,
type EditorAgentConversationClient,
useEditorAgentConversation,
} from './useEditorAgentConversation.ts';
const focusResourceMock = vi.fn();
const refreshCanvasMock = vi.fn();
function ImageCanvasActionsTestWrapper({
children,
}: {
children: ReactNode;
}) {
return (
<ImageCanvasActionsProvider
focusResource={focusResourceMock}
refreshCanvas={refreshCanvasMock}
>
{children}
</ImageCanvasActionsProvider>
);
}
function renderHook<Result, Props>(
callback: (initialProps: Props) => Result,
options?: RenderHookOptions<Props>,
): RenderHookResult<Result, Props> {
return testingLibraryRenderHook(callback, {
...options,
wrapper: ImageCanvasActionsTestWrapper,
});
}
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 { 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(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(refreshCanvasMock).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('does not let a refresh supersede an in-flight conversation activation', async () => {
const client = createClient();
let resolveActivation!: (detail: EditorAgentConversationDetail) => void;
let resolveRefresh!: (detail: EditorAgentConversationDetail) => void;
const activationPromise = new Promise<EditorAgentConversationDetail>(
(resolve) => {
resolveActivation = resolve;
},
);
const refreshPromise = new Promise<EditorAgentConversationDetail>(
(resolve) => {
resolveRefresh = 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 activationPromise;
}
if (conversationId === 'conversation-1') {
return refreshPromise;
}
throw new Error(`Unexpected conversation: ${conversationId}`);
});
let pendingActivation!: Promise<EditorAgentConversationDetail | null>;
let pendingRefresh!: Promise<EditorAgentConversationDetail | null>;
act(() => {
pendingActivation = result.current.selectConversation('conversation-2');
pendingRefresh = result.current.refreshActiveConversation();
});
await act(async () => {
resolveRefresh({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '旧会话刷新结果',
messages: [
{
id: 10,
role: 'assistant',
text: '旧会话刷新消息',
attachments: [],
toolCall: null,
createdAt: '2026-07-03T00:02:00.000Z',
},
],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:02:00.000Z',
});
await pendingRefresh;
});
expect(result.current.activeConversationId).toBe('conversation-1');
expect(result.current.messages).toEqual([]);
await act(async () => {
resolveActivation({
conversationId: 'conversation-2',
projectId: 'project-1',
title: '第二个会话',
messages: [
{
id: 20,
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 pendingActivation;
});
expect(result.current.activeConversationId).toBe('conversation-2');
expect(result.current.messages[0]?.text).toBe('第二个会话消息');
expect(result.current.isLoadingMessages).toBe(false);
});
it('keeps the latest activation loading while an older suppressed refresh finishes', async () => {
const client = createClient();
let resolveConversation2!: (detail: EditorAgentConversationDetail) => void;
let resolveRefresh!: (detail: EditorAgentConversationDetail) => void;
let resolveConversation3!: (detail: EditorAgentConversationDetail) => void;
const conversation2Promise = new Promise<EditorAgentConversationDetail>(
(resolve) => {
resolveConversation2 = resolve;
},
);
const refreshPromise = new Promise<EditorAgentConversationDetail>(
(resolve) => {
resolveRefresh = 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-1') {
return refreshPromise;
}
if (conversationId === 'conversation-3') {
return conversation3Promise;
}
throw new Error(`Unexpected conversation: ${conversationId}`);
});
let conversation2Load!: Promise<EditorAgentConversationDetail | null>;
let pendingRefresh!: Promise<EditorAgentConversationDetail | null>;
let conversation3Load!: Promise<EditorAgentConversationDetail | null>;
act(() => {
conversation2Load = result.current.selectConversation('conversation-2');
pendingRefresh = result.current.refreshActiveConversation();
conversation3Load = result.current.selectConversation('conversation-3');
});
expect(result.current.isLoadingMessages).toBe(true);
await act(async () => {
resolveRefresh({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '旧会话刷新结果',
messages: [],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:02:00.000Z',
});
await pendingRefresh;
});
expect(result.current.activeConversationId).toBe('conversation-1');
expect(result.current.isLoadingMessages).toBe(true);
await act(async () => {
await result.current.sendMessage('切换期间不应发送');
});
expect(client.sendMessage).not.toHaveBeenCalled();
await act(async () => {
resolveConversation3({
conversationId: 'conversation-3',
projectId: 'project-1',
title: '第三个会话',
messages: [
{
id: 30,
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.isLoadingMessages).toBe(false);
await act(async () => {
resolveConversation2({
conversationId: 'conversation-2',
projectId: 'project-1',
title: '第二个会话',
messages: [],
createdAt: '2026-07-03T00:02:00.000Z',
updatedAt: '2026-07-03T00:02:00.000Z',
});
await conversation2Load;
});
expect(result.current.activeConversationId).toBe('conversation-3');
});
it('does not let a silent refresh replace a newly created conversation', async () => {
const client = createClient();
let resolveRefresh!: (detail: EditorAgentConversationDetail) => void;
let resolveCreate!: (detail: EditorAgentConversationDetail) => void;
const refreshPromise = new Promise<EditorAgentConversationDetail>(
(resolve) => {
resolveRefresh = resolve;
},
);
const createPromise = new Promise<EditorAgentConversationDetail>(
(resolve) => {
resolveCreate = resolve;
},
);
const { result } = renderHook(() =>
useEditorAgentConversation({ projectId: 'project-1', client }),
);
await waitFor(() => {
expect(result.current.activeConversationId).toBe('conversation-1');
});
vi.mocked(client.getConversation).mockReturnValueOnce(refreshPromise);
vi.mocked(client.createConversation).mockReturnValueOnce(createPromise);
let pendingRefresh!: Promise<EditorAgentConversationDetail | null>;
let pendingCreate!: Promise<EditorAgentConversationDetail>;
act(() => {
pendingRefresh = result.current.refreshActiveConversation();
pendingCreate = result.current.createConversation();
});
await act(async () => {
resolveCreate({
conversationId: 'conversation-2',
projectId: 'project-1',
title: '新对话',
messages: [],
createdAt: '2026-07-03T00:01:00.000Z',
updatedAt: '2026-07-03T00:01:00.000Z',
});
await pendingCreate;
});
expect(result.current.activeConversationId).toBe('conversation-2');
await act(async () => {
resolveRefresh({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '旧对话刷新结果',
messages: [
{
id: 10,
role: 'assistant',
text: '旧会话消息',
attachments: [],
toolCall: null,
createdAt: '2026-07-03T00:02:00.000Z',
},
],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:02:00.000Z',
});
await pendingRefresh;
});
expect(result.current.activeConversationId).toBe('conversation-2');
expect(result.current.messages).toEqual([]);
});
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();
let resolveSend!: (response: EditorAgentMessageResponse) => void;
vi.mocked(client.sendMessage).mockImplementation(
() =>
new Promise<EditorAgentMessageResponse>((resolve) => {
resolveSend = resolve;
}),
);
const { result } = renderHook(() =>
useEditorAgentConversation({
projectId: 'project-1',
client,
}),
);
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(refreshCanvasMock).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('rejects an attachment-only message before calling the client', 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('', [
createEditorAgentAttachmentRef({
source: 'canvas_resource',
referenceId: 'resource-1',
objectKey: 'generated-editor-assets/resource-1.png',
imageSrc: '/resource-1.png',
label: '一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯辰巳',
}),
]);
});
expect(client.sendMessage).not.toHaveBeenCalled();
});
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 onConfirmSent = vi.fn();
const { result } = renderHook(() =>
useEditorAgentConversation({
projectId: 'project-1',
client,
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(refreshCanvasMock).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 { result } = renderHook(() =>
useEditorAgentConversation({
projectId: 'project-1',
client,
}),
);
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(refreshCanvasMock).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);
});
});