fix(coding): scope metadata updates by project

This commit is contained in:
2026-08-24 09:43:45 +08:00
parent da92eb1957
commit fcd03f9f6d
5 changed files with 228 additions and 12 deletions

View File

@@ -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 });

View File

@@ -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();
});
});