fix: close PI core chat review gaps
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ConversationSnapshot } from '@/types/coding-conversation';
|
||||
import type {
|
||||
CodingConversationMetadata,
|
||||
@@ -58,7 +58,7 @@ const agent: CodingProjectAgent = {
|
||||
createdAt: '2026-08-23T00:00:00.000Z',
|
||||
updatedAt: '2026-08-23T00:00:00.000Z',
|
||||
model: null,
|
||||
modelResolution: 'default',
|
||||
modelResolution: 'required',
|
||||
};
|
||||
const config: CodingProjectConfig = {
|
||||
schemaVersion: 2,
|
||||
@@ -79,8 +79,24 @@ const conversation: CodingConversationMetadata = {
|
||||
createdAt: '2026-08-23T00:00:00.000Z',
|
||||
updatedAt: '2026-08-23T00:00:00.000Z',
|
||||
model: null,
|
||||
modelResolution: 'default',
|
||||
modelResolution: 'required',
|
||||
};
|
||||
const reviewer: CodingProjectAgent = {
|
||||
...agent,
|
||||
id: 'agent-2',
|
||||
name: 'Reviewer',
|
||||
pinned: false,
|
||||
};
|
||||
const reviewerConversation: CodingConversationMetadata = {
|
||||
...conversation,
|
||||
id: 'conversation-2',
|
||||
agentId: reviewer.id,
|
||||
title: 'Reviewer conversation',
|
||||
};
|
||||
|
||||
function configForAgents(agents: CodingProjectAgent[]): CodingProjectConfig {
|
||||
return { ...config, agents };
|
||||
}
|
||||
|
||||
const projectApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
@@ -94,6 +110,7 @@ const conversationApi = vi.hoisted(() => ({
|
||||
submit: vi.fn(),
|
||||
recover: vi.fn(),
|
||||
}));
|
||||
const attachmentApi = vi.hoisted(() => ({ upload: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/coding-projects', () => ({
|
||||
listCodingProjects: projectApi.list,
|
||||
@@ -109,8 +126,28 @@ vi.mock('@/lib/coding-conversations', () => ({
|
||||
recoverCodingConversation: conversationApi.recover,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/coding-attachments', async (importOriginal) => ({
|
||||
...await importOriginal<typeof import('@/lib/coding-attachments')>(),
|
||||
uploadCodingAttachment: attachmentApi.upload,
|
||||
}));
|
||||
|
||||
describe('CodingChatPanel first Conversation', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn((file: File) => `blob:${file.name}`),
|
||||
});
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('makes the first-Conversation textarea editable while runtime metadata is held', async () => {
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
@@ -166,6 +203,168 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('does not let a slow first-Conversation completion steal selection after an Agent switch', async () => {
|
||||
const slowCreate = deferred<CodingConversationMetadata>();
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
projectApi.config.mockResolvedValue({ project, config: configForAgents([agent, reviewer]) });
|
||||
projectApi.conversations.mockResolvedValue([reviewerConversation]);
|
||||
projectApi.create.mockReturnValue(slowCreate.promise);
|
||||
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
|
||||
conversationApi.submit.mockRejectedValue(new Error('Provider is intentionally not used'));
|
||||
conversationApi.recover.mockResolvedValue(undefined);
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
conversationApi.snapshot.mockImplementation(async (conversationId: string) => (
|
||||
createLocalConversationSnapshot(
|
||||
project.id,
|
||||
conversationId === reviewerConversation.id ? reviewerConversation : conversation,
|
||||
)
|
||||
));
|
||||
render(<CodingChatPanel />);
|
||||
|
||||
await screen.findByRole('textbox');
|
||||
await waitFor(() => expect(projectApi.create).toHaveBeenCalledWith({
|
||||
projectId: project.id,
|
||||
agentId: agent.id,
|
||||
title: '新对话',
|
||||
}));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Reviewer/ }));
|
||||
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
|
||||
.toBe(reviewerConversation.id));
|
||||
|
||||
await act(async () => {
|
||||
slowCreate.resolve(conversation);
|
||||
await slowCreate.promise;
|
||||
});
|
||||
expect(codingConversationStore.getState().selectedConversationId).toBe(reviewerConversation.id);
|
||||
});
|
||||
|
||||
it('keeps submission state and rejection scoped to the originating Conversation', async () => {
|
||||
const pendingSubmit = deferred<never>();
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
projectApi.config.mockResolvedValue({ project, config: configForAgents([agent, reviewer]) });
|
||||
projectApi.conversations.mockResolvedValue([conversation, reviewerConversation]);
|
||||
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
|
||||
conversationApi.submit.mockReturnValueOnce(pendingSubmit.promise);
|
||||
conversationApi.recover.mockResolvedValue(undefined);
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
conversationApi.snapshot.mockImplementation(async (conversationId: string) => (
|
||||
createLocalConversationSnapshot(
|
||||
project.id,
|
||||
conversationId === reviewerConversation.id ? reviewerConversation : conversation,
|
||||
)
|
||||
));
|
||||
render(<CodingChatPanel />);
|
||||
|
||||
const textbox = await screen.findByRole('textbox');
|
||||
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
|
||||
.toBe(conversation.id));
|
||||
fireEvent.change(textbox, { target: { value: 'A prompt' } });
|
||||
await waitFor(() => expect(
|
||||
codingConversationStore.getState().draftsByConversationId[conversation.id]?.text,
|
||||
).toBe('A prompt'));
|
||||
expect(codingConversationStore.getState().entriesByConversationId[conversation.id])
|
||||
.toMatchObject({ loadState: 'live', error: null });
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '发送' })).toBeEnabled());
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledOnce());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Reviewer/ }));
|
||||
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue(''));
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'B prompt' } });
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '发送' })).toBeEnabled());
|
||||
|
||||
await act(async () => {
|
||||
pendingSubmit.reject(new Error('A submission failed'));
|
||||
await pendingSubmit.promise.catch(() => undefined);
|
||||
});
|
||||
expect(screen.queryByText('A submission failed')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox')).toHaveValue('B prompt');
|
||||
expect(screen.getByRole('button', { name: '发送' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('caps one message at 16 images and uploads at most four concurrently', async () => {
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
projectApi.config.mockResolvedValue({ project, config });
|
||||
projectApi.conversations.mockResolvedValue([conversation]);
|
||||
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
|
||||
conversationApi.recover.mockResolvedValue(undefined);
|
||||
conversationApi.submit.mockImplementation(async (input: {
|
||||
conversationId: string;
|
||||
clientRequestId: string;
|
||||
mode: 'prompt';
|
||||
}) => ({
|
||||
accepted: true,
|
||||
conversationId: input.conversationId,
|
||||
clientRequestId: input.clientRequestId,
|
||||
runId: 'run-1',
|
||||
mode: input.mode,
|
||||
}));
|
||||
const uploadFlights: Array<ReturnType<typeof deferred<{
|
||||
attachmentId: string;
|
||||
mime: string;
|
||||
byteLength: number;
|
||||
}>>> = [];
|
||||
let activeUploads = 0;
|
||||
let maxActiveUploads = 0;
|
||||
attachmentApi.upload.mockImplementation((file: File) => {
|
||||
const flight = deferred<{ attachmentId: string; mime: string; byteLength: number }>();
|
||||
uploadFlights.push(flight);
|
||||
activeUploads += 1;
|
||||
maxActiveUploads = Math.max(maxActiveUploads, activeUploads);
|
||||
return flight.promise.finally(() => {
|
||||
activeUploads -= 1;
|
||||
}).then(() => ({
|
||||
attachmentId: `attachment-${file.name}`,
|
||||
mime: file.type,
|
||||
byteLength: file.size,
|
||||
}));
|
||||
});
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
|
||||
render(<CodingChatPanel />);
|
||||
await screen.findByRole('textbox');
|
||||
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
|
||||
.toBe(conversation.id));
|
||||
|
||||
const files = Array.from({ length: 17 }, (_, index) => new File(
|
||||
[new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, index])],
|
||||
`image-${index}.png`,
|
||||
{ type: 'image/png' },
|
||||
));
|
||||
fireEvent.change(screen.getByTestId('coding-file-attachment-input'), {
|
||||
target: { files },
|
||||
});
|
||||
expect(await screen.findByText('每条消息最多添加 16 张图片。')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('img')).toHaveLength(16);
|
||||
expect(codingConversationStore.getState().entriesByConversationId[conversation.id])
|
||||
.toMatchObject({ loadState: 'live', error: null });
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '发送' })).toBeEnabled());
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
await waitFor(() => expect(attachmentApi.upload).toHaveBeenCalledTimes(4));
|
||||
|
||||
let resolved = 0;
|
||||
while (resolved < 16) {
|
||||
const available = uploadFlights.slice(resolved);
|
||||
for (const flight of available) flight.resolve({
|
||||
attachmentId: `resolved-${resolved++}`,
|
||||
mime: 'image/png',
|
||||
byteLength: 9,
|
||||
});
|
||||
if (resolved < 16) {
|
||||
await waitFor(() => expect(uploadFlights.length).toBeGreaterThan(resolved));
|
||||
}
|
||||
}
|
||||
await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledOnce());
|
||||
expect(attachmentApi.upload).toHaveBeenCalledTimes(16);
|
||||
expect(maxActiveUploads).toBeLessThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('shows the 202 acceptance and preserves Enter versus Shift+Enter behavior', async () => {
|
||||
const { CodingComposer } = await import('@/pages/Chat/CodingComposer');
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user