fix: 会话快速切换响应乱序
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { EditorAgentMessage } from '@/packages/shared/src/contracts';
|
||||
|
||||
import { MessageBubble } from './MessageBubble.tsx';
|
||||
|
||||
function renderMessage(message: EditorAgentMessage) {
|
||||
return render(
|
||||
<MessageBubble
|
||||
message={message}
|
||||
busyAction={null}
|
||||
onConfirmToolCall={vi.fn()}
|
||||
onCancelToolCall={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('MessageBubble', () => {
|
||||
it('shows prefixed system errors as red Agent errors without the wire prefix', () => {
|
||||
renderMessage({
|
||||
id: 2,
|
||||
role: 'system',
|
||||
text: 'ERROR planning failed',
|
||||
attachments: [],
|
||||
toolCall: null,
|
||||
createdAt: '2026-07-16T00:00:00Z',
|
||||
});
|
||||
|
||||
const error = screen.getByLabelText('Agent错误');
|
||||
expect(error.textContent).toContain('planning failed');
|
||||
expect(error.textContent).not.toContain('ERROR');
|
||||
expect(error.firstElementChild?.classList.contains('bg-red-50')).toBe(true);
|
||||
expect(error.firstElementChild?.classList.contains('text-red-700')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('continues to hide internal system messages without the error prefix', () => {
|
||||
const { container } = renderMessage({
|
||||
id: 3,
|
||||
role: 'system',
|
||||
text: 'internal attachment bookkeeping',
|
||||
attachments: [],
|
||||
toolCall: null,
|
||||
createdAt: '2026-07-16T00:00:00Z',
|
||||
});
|
||||
|
||||
expect(container.childElementCount).toBe(0);
|
||||
});
|
||||
});
|
||||
+88
@@ -4,6 +4,7 @@ import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
EditorAgentConversationDetail,
|
||||
EditorAgentMessage,
|
||||
EditorAgentMessageResponse,
|
||||
} from '../../../../packages/shared/src/contracts/editorAgent.ts';
|
||||
@@ -173,6 +174,93 @@ describe('useEditorAgentConversation', () => {
|
||||
);
|
||||
});
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -170,6 +170,7 @@ export function useEditorAgentConversation({
|
||||
const activeRequestAbortControllerRef = useRef<AbortController | null>(null);
|
||||
const activeConversationIdRef = useRef<string | null>(null);
|
||||
const activeToolCallActionRef = useRef<EditorAgentToolCallActionState>(null);
|
||||
const conversationLoadRequestIdRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
activeConversationIdRef.current = activeConversationId;
|
||||
@@ -205,6 +206,8 @@ export function useEditorAgentConversation({
|
||||
conversationId: string,
|
||||
options: { showLoading?: boolean } = {},
|
||||
) => {
|
||||
const requestId = conversationLoadRequestIdRef.current + 1;
|
||||
conversationLoadRequestIdRef.current = requestId;
|
||||
const showLoading = options.showLoading ?? true;
|
||||
if (showLoading) {
|
||||
setIsLoadingMessages(true);
|
||||
@@ -212,15 +215,19 @@ export function useEditorAgentConversation({
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const detail = await client.getConversation(conversationId);
|
||||
applyConversationDetail(detail);
|
||||
if (conversationLoadRequestIdRef.current === requestId) {
|
||||
applyConversationDetail(detail);
|
||||
}
|
||||
return detail;
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : '读取画布 Agent 会话失败',
|
||||
);
|
||||
if (conversationLoadRequestIdRef.current === requestId) {
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : '读取画布 Agent 会话失败',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
if (conversationLoadRequestIdRef.current === requestId) {
|
||||
setIsLoadingMessages(false);
|
||||
}
|
||||
}
|
||||
@@ -229,6 +236,8 @@ export function useEditorAgentConversation({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
conversationLoadRequestIdRef.current += 1;
|
||||
setIsLoadingMessages(false);
|
||||
if (!normalizedProjectId) {
|
||||
setConversations([]);
|
||||
setActiveConversationId(null);
|
||||
|
||||
Reference in New Issue
Block a user