fix(coding): scope metadata updates by project
This commit is contained in:
@@ -296,6 +296,91 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
expect(codingConversationStore.getState().selectedConversationId).toBe(reviewerConversation.id);
|
||||
});
|
||||
|
||||
it('does not write a pending fork into the Renderer store after switching projects', async () => {
|
||||
const forkFlight = deferred<CodingConversationMetadata>();
|
||||
const secondProject = { ...project, id: 'project-2', name: 'Second project' };
|
||||
const secondAgent = { ...agent, id: 'agent-project-2', name: 'Second builder' };
|
||||
const secondConversation = {
|
||||
...conversation,
|
||||
id: 'conversation-project-2',
|
||||
agentId: secondAgent.id,
|
||||
title: 'Second project conversation',
|
||||
};
|
||||
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.fork.mockReturnValue(forkFlight.promise);
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const { codingWorkspaceStore } = await import('@/stores/coding-workspace');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
conversationApi.snapshot.mockImplementation(async (conversationId: string) => (
|
||||
createLocalConversationSnapshot(
|
||||
conversationId === secondConversation.id ? secondProject.id : project.id,
|
||||
conversationId === secondConversation.id ? secondConversation : conversation,
|
||||
)
|
||||
));
|
||||
render(<CodingChatPanel />);
|
||||
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
|
||||
.toBe(conversation.id));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建分支' }));
|
||||
await waitFor(() => expect(conversationApi.fork).toHaveBeenCalledWith(conversation.id, undefined));
|
||||
act(() => {
|
||||
codingWorkspaceStore.setState({
|
||||
activeProjectId: secondProject.id,
|
||||
activeProject: secondProject,
|
||||
config: configForAgents([secondAgent]),
|
||||
conversations: [secondConversation],
|
||||
selectedAgentId: secondAgent.id,
|
||||
});
|
||||
codingConversationStore.setState({ selectedConversationId: secondConversation.id });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
forkFlight.resolve({ ...conversation, id: 'conversation-forked', title: 'Old project fork' });
|
||||
await forkFlight.promise;
|
||||
});
|
||||
expect(codingWorkspaceStore.getState().conversations).toEqual([secondConversation]);
|
||||
expect(codingConversationStore.getState().selectedConversationId).toBe(secondConversation.id);
|
||||
});
|
||||
|
||||
it('only shows a metadata error on its originating Conversation', async () => {
|
||||
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.recover.mockResolvedValue(undefined);
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const { codingWorkspaceStore } = await import('@/stores/coding-workspace');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
conversationApi.snapshot.mockImplementation(async (conversationId: string) => (
|
||||
createLocalConversationSnapshot(
|
||||
project.id,
|
||||
conversationId === reviewerConversation.id ? reviewerConversation : conversation,
|
||||
)
|
||||
));
|
||||
render(<CodingChatPanel />);
|
||||
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
|
||||
.toBe(conversation.id));
|
||||
act(() => {
|
||||
codingWorkspaceStore.setState({
|
||||
conversationErrorsByProjectId: {
|
||||
[project.id]: { [conversation.id]: 'metadata rejected' },
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(await screen.findByText('metadata rejected')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Reviewer/ }));
|
||||
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
|
||||
.toBe(reviewerConversation.id));
|
||||
expect(screen.queryByText('metadata rejected')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not clear a newly selected Conversation when an old archive request finishes', async () => {
|
||||
const archiveFlight = deferred<CodingConversationMetadata>();
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
|
||||
@@ -69,6 +69,16 @@ function conversation(id: string, agentId: string): CodingConversationMetadata {
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('coding workspace store', () => {
|
||||
it('loads local project metadata and selects the pinned Agent without touching runtime APIs', async () => {
|
||||
const listProjects = vi.fn(async () => ({ projects: [project], activeProjectId: project.id }));
|
||||
@@ -156,4 +166,78 @@ describe('coding workspace store', () => {
|
||||
second,
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not write an old project metadata result into the newly active project', async () => {
|
||||
const secondProject = { ...project, id: 'project-2', name: 'Second project' };
|
||||
const firstConversation = conversation('conversation-a', 'agent-a');
|
||||
const secondConversation = conversation('conversation-b', 'agent-b');
|
||||
const patchFlight = deferred<CodingConversationMetadata>();
|
||||
let activeProject = project;
|
||||
const store = createCodingWorkspaceStore({
|
||||
listProjects: vi.fn(async () => ({
|
||||
projects: [project, secondProject],
|
||||
activeProjectId: activeProject.id,
|
||||
})),
|
||||
getConfig: vi.fn(async (projectId: string) => (
|
||||
projectId === project.id
|
||||
? { project, config: config([agent('agent-a')]) }
|
||||
: { project: secondProject, config: config([agent('agent-b')]) }
|
||||
)),
|
||||
listConversations: vi.fn(async (projectId: string) => (
|
||||
projectId === project.id ? [firstConversation] : [secondConversation]
|
||||
)),
|
||||
createConversation: vi.fn(),
|
||||
patchConversation: vi.fn(() => patchFlight.promise),
|
||||
});
|
||||
await store.getState().load();
|
||||
|
||||
const pendingPatch = store.getState().patchConversation(firstConversation.id, { title: 'Old project title' });
|
||||
activeProject = secondProject;
|
||||
await store.getState().load();
|
||||
patchFlight.resolve({ ...firstConversation, title: 'Old project title' });
|
||||
await pendingPatch;
|
||||
|
||||
expect(store.getState()).toMatchObject({
|
||||
activeProjectId: secondProject.id,
|
||||
conversations: [secondConversation],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a metadata rejection on its source project and Conversation', async () => {
|
||||
const secondProject = { ...project, id: 'project-2', name: 'Second project' };
|
||||
const firstConversation = conversation('conversation-a', 'agent-a');
|
||||
const secondConversation = conversation('conversation-b', 'agent-b');
|
||||
const patchFlight = deferred<CodingConversationMetadata>();
|
||||
let activeProject = project;
|
||||
const store = createCodingWorkspaceStore({
|
||||
listProjects: vi.fn(async () => ({
|
||||
projects: [project, secondProject],
|
||||
activeProjectId: activeProject.id,
|
||||
})),
|
||||
getConfig: vi.fn(async (projectId: string) => (
|
||||
projectId === project.id
|
||||
? { project, config: config([agent('agent-a')]) }
|
||||
: { project: secondProject, config: config([agent('agent-b')]) }
|
||||
)),
|
||||
listConversations: vi.fn(async (projectId: string) => (
|
||||
projectId === project.id ? [firstConversation] : [secondConversation]
|
||||
)),
|
||||
createConversation: vi.fn(),
|
||||
patchConversation: vi.fn(() => patchFlight.promise),
|
||||
});
|
||||
await store.getState().load();
|
||||
|
||||
const pendingPatch = store.getState().patchConversation(firstConversation.id, { title: 'Rejected title' });
|
||||
const rejection = expect(pendingPatch).rejects.toThrow('metadata rejected');
|
||||
activeProject = secondProject;
|
||||
await store.getState().load();
|
||||
patchFlight.reject(new Error('metadata rejected'));
|
||||
await rejection;
|
||||
|
||||
expect(store.getState().error).toBeNull();
|
||||
expect(store.getState().conversationErrorsByProjectId).toEqual({
|
||||
[project.id]: { [firstConversation.id]: 'metadata rejected' },
|
||||
});
|
||||
expect(store.getState().conversationErrorsByProjectId[secondProject.id]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user