fix(coding): scope metadata updates by project
This commit is contained in:
1 parent
da92eb1957
commit
fcd03f9f6d
5 files changed
+228
-12
No files matched your search
@@ -97,15 +97,18 @@ Gate result:
|
|||||||
- Windowed streaming thinking DOM output to the latest 16 KB while streaming, restoring the complete text when settled; this keeps the existing REN-008 100 KB pressure budget passing without changing the protocol or test threshold.
|
- Windowed streaming thinking DOM output to the latest 16 KB while streaming, restoring the complete text when settled; this keeps the existing REN-008 100 KB pressure budget passing without changing the protocol or test threshold.
|
||||||
- Isolated Conversation-local async UI state by remounting Header, pending interactions, and the tools inspector on Conversation changes. A pending fork only selects its result while the source project, Agent, and Conversation remain selected; a completed archive no longer clears a newer selection.
|
- Isolated Conversation-local async UI state by remounting Header, pending interactions, and the tools inspector on Conversation changes. A pending fork only selects its result while the source project, Agent, and Conversation remain selected; a completed archive no longer clears a newer selection.
|
||||||
- Added regression coverage for pending fork, archive, interaction response, and tools-load completion across Conversation switches.
|
- Added regression coverage for pending fork, archive, interaction response, and tools-load completion across Conversation switches.
|
||||||
|
- Bound Conversation metadata writeback to its source project: store upserts now require an explicit project id, fork results are ignored by the current Renderer store after a project switch, and metadata failures are retained under their source project and Conversation instead of the global workspace error.
|
||||||
|
- Added project-switch success/failure tests plus Renderer tests proving that old-project forks and metadata errors cannot leak into the newly selected project or Conversation.
|
||||||
- Updated `README.md` to describe the now-shipped Coding UI state and retained the explicit unverified shared Provider/runtime concurrency boundary.
|
- Updated `README.md` to describe the now-shipped Coding UI state and retained the explicit unverified shared Provider/runtime concurrency boundary.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
- `pnpm run typecheck` — Pass.
|
- `pnpm run typecheck` — Pass.
|
||||||
- `pnpm run lint:check` — Pass with 0 errors and 6 pre-existing warnings outside PI-130-owned files.
|
- `pnpm run lint:check` — Pass with 0 errors and 6 pre-existing warnings outside PI-130-owned files.
|
||||||
- Focused PI-130 Vitest coverage — Pass: 17/17 across the two changed UI suites, including facade routes, queue modes/positions, interactions, Conversation controls, metadata and async-switch isolation, nested subagents, compaction retry, inline tool output, fork wording, and removed UI entries.
|
- Focused PI-130 Vitest coverage — Pass: 24/24 across the three changed store/UI suites, including facade routes, queue modes/positions, interactions, Conversation controls, metadata and async-switch isolation, project-switch writeback/error isolation, nested subagents, compaction retry, inline tool output, fork wording, and removed UI entries.
|
||||||
- Isolated REN-008 100 KB pressure test — Pass: 20 patch batches, 20 React commits, 34.8 ms Main-to-React p95 against the 50 ms budget. An earlier run was intentionally discarded after it shared CPU with full-repository lint and measured 112.9 ms; the serial full-suite rerun below passed.
|
- Isolated REN-008 100 KB pressure test — Pass: 20 patch batches, 20 React commits, 34.7 ms Main-to-React p95 against the 50 ms budget.
|
||||||
- `pnpm test` — Pass: 219 files; 2332 tests passed; 2 skipped.
|
- Functional full suite without the pressure file, capped at four workers — Pass: 218 files; 2335 tests passed; 2 skipped. Together with the isolated pressure test this covers all 219 files and 2336 passing tests.
|
||||||
|
- Default `pnpm test` final reruns — Not recorded as Pass: one 24-worker run lost a Vitest child process without a test stack; the next completed all files but the pressure test measured 57.4 ms while competing with the other workers. Neither run reported a functional assertion failure outside the load-sensitive pressure threshold; the controlled full-suite and isolated-pressure runs above are the acceptance evidence.
|
||||||
- `pnpm run build:vite` — Pass as part of the scoped Electron E2E command; only existing Vite chunk/dynamic-import warnings were reported.
|
- `pnpm run build:vite` — Pass as part of the scoped Electron E2E command; only existing Vite chunk/dynamic-import warnings were reported.
|
||||||
- `pnpm run test:e2e -- tests/e2e/pi-coding-first-chat.spec.ts` — Pass: 2/2, covering first-Conversation editability, two Conversation isolation, queue, interaction, model, abort, subagent, files/changes/commands tools, and removed share/revert/todo/global-runtime entries.
|
- `pnpm run test:e2e -- tests/e2e/pi-coding-first-chat.spec.ts` — Pass: 2/2, covering first-Conversation editability, two Conversation isolation, queue, interaction, model, abort, subagent, files/changes/commands tools, and removed share/revert/todo/global-runtime entries.
|
||||||
- `git diff --check` — Pass.
|
- `git diff --check` — Pass.
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ export function CodingChatPanel({
|
|||||||
const selectedAgentId = useCodingWorkspaceStore((state) => state.selectedAgentId);
|
const selectedAgentId = useCodingWorkspaceStore((state) => state.selectedAgentId);
|
||||||
const workspaceLoadState = useCodingWorkspaceStore((state) => state.loadState);
|
const workspaceLoadState = useCodingWorkspaceStore((state) => state.loadState);
|
||||||
const workspaceError = useCodingWorkspaceStore((state) => state.error);
|
const workspaceError = useCodingWorkspaceStore((state) => state.error);
|
||||||
|
const conversationErrorsByProjectId = useCodingWorkspaceStore(
|
||||||
|
(state) => state.conversationErrorsByProjectId,
|
||||||
|
);
|
||||||
const creatingAgentIds = useCodingWorkspaceStore((state) => state.creatingAgentIds);
|
const creatingAgentIds = useCodingWorkspaceStore((state) => state.creatingAgentIds);
|
||||||
const loadWorkspace = useCodingWorkspaceStore((state) => state.load);
|
const loadWorkspace = useCodingWorkspaceStore((state) => state.load);
|
||||||
const selectAgent = useCodingWorkspaceStore((state) => state.selectAgent);
|
const selectAgent = useCodingWorkspaceStore((state) => state.selectAgent);
|
||||||
@@ -149,6 +152,9 @@ export function CodingChatPanel({
|
|||||||
&& !conversation.archivedAt
|
&& !conversation.archivedAt
|
||||||
)) ?? null;
|
)) ?? null;
|
||||||
const targetConversationId = selectedConversation?.id ?? null;
|
const targetConversationId = selectedConversation?.id ?? null;
|
||||||
|
const conversationMetadataError = activeProject && targetConversationId
|
||||||
|
? conversationErrorsByProjectId[activeProject.id]?.[targetConversationId] ?? null
|
||||||
|
: null;
|
||||||
const provisionalDraftKey = activeProject && selectedAgent
|
const provisionalDraftKey = activeProject && selectedAgent
|
||||||
? `new:${activeProject.id}:${selectedAgent.id}`
|
? `new:${activeProject.id}:${selectedAgent.id}`
|
||||||
: null;
|
: null;
|
||||||
@@ -340,9 +346,10 @@ export function CodingChatPanel({
|
|||||||
const sourceAgentId = selectedAgent.id;
|
const sourceAgentId = selectedAgent.id;
|
||||||
const sourceConversationId = targetConversationId;
|
const sourceConversationId = targetConversationId;
|
||||||
const forked = await forkCodingConversation(sourceConversationId, sourceEntryId);
|
const forked = await forkCodingConversation(sourceConversationId, sourceEntryId);
|
||||||
upsertConversation(forked);
|
|
||||||
primeConversation(createLocalConversationSnapshot(sourceProjectId, forked));
|
|
||||||
const workspace = codingWorkspaceStore.getState();
|
const workspace = codingWorkspaceStore.getState();
|
||||||
|
if (workspace.activeProjectId !== sourceProjectId) return;
|
||||||
|
upsertConversation(sourceProjectId, forked);
|
||||||
|
primeConversation(createLocalConversationSnapshot(sourceProjectId, forked));
|
||||||
const selectedId = codingConversationStore.getState().selectedConversationId;
|
const selectedId = codingConversationStore.getState().selectedConversationId;
|
||||||
if (workspace.activeProjectId === sourceProjectId
|
if (workspace.activeProjectId === sourceProjectId
|
||||||
&& workspace.selectedAgentId === sourceAgentId
|
&& workspace.selectedAgentId === sourceAgentId
|
||||||
@@ -656,10 +663,12 @@ export function CodingChatPanel({
|
|||||||
onOpenSettings={onOpenProjectSettings}
|
onOpenSettings={onOpenProjectSettings}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(workspaceError || connectionError) && (
|
{(workspaceError || conversationMetadataError || connectionError) && (
|
||||||
<div className="mx-4 mt-3 flex min-h-10 items-center gap-2 rounded-xl bg-destructive/5 px-3 py-2 text-xs text-destructive sm:mx-5">
|
<div className="mx-4 mt-3 flex min-h-10 items-center gap-2 rounded-xl bg-destructive/5 px-3 py-2 text-xs text-destructive sm:mx-5">
|
||||||
<CircleAlert className="h-4 w-4 shrink-0" aria-hidden="true" />
|
<CircleAlert className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||||
<p className="min-w-0 flex-1 text-pretty">{workspaceError ?? connectionError}</p>
|
<p className="min-w-0 flex-1 text-pretty">
|
||||||
|
{workspaceError ?? conversationMetadataError ?? connectionError}
|
||||||
|
</p>
|
||||||
{workspaceError && (
|
{workspaceError && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface CodingWorkspaceState {
|
|||||||
selectedAgentId: string | null;
|
selectedAgentId: string | null;
|
||||||
loadState: 'idle' | 'loading' | 'ready' | 'error';
|
loadState: 'idle' | 'loading' | 'ready' | 'error';
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
conversationErrorsByProjectId: Record<string, Record<string, string>>;
|
||||||
creatingAgentIds: Record<string, true>;
|
creatingAgentIds: Record<string, true>;
|
||||||
load(): Promise<void>;
|
load(): Promise<void>;
|
||||||
selectAgent(agentId: string): void;
|
selectAgent(agentId: string): void;
|
||||||
@@ -51,7 +52,7 @@ export interface CodingWorkspaceState {
|
|||||||
conversationId: string,
|
conversationId: string,
|
||||||
patch: { title?: string; archived?: boolean; unread?: boolean },
|
patch: { title?: string; archived?: boolean; unread?: boolean },
|
||||||
): Promise<CodingConversationMetadata>;
|
): Promise<CodingConversationMetadata>;
|
||||||
upsertConversation(conversation: CodingConversationMetadata): void;
|
upsertConversation(projectId: string, conversation: CodingConversationMetadata): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function enabledAgent(config: CodingProjectConfig | null, agentId: string | null): CodingProjectAgent | null {
|
function enabledAgent(config: CodingProjectConfig | null, agentId: string | null): CodingProjectAgent | null {
|
||||||
@@ -76,6 +77,21 @@ function newestConversation(
|
|||||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ?? null;
|
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withConversationError(
|
||||||
|
errorsByProjectId: Record<string, Record<string, string>>,
|
||||||
|
projectId: string,
|
||||||
|
conversationId: string,
|
||||||
|
message: string | null,
|
||||||
|
): Record<string, Record<string, string>> {
|
||||||
|
const next = { ...errorsByProjectId };
|
||||||
|
const projectErrors = { ...(next[projectId] ?? {}) };
|
||||||
|
if (message) projectErrors[conversationId] = message;
|
||||||
|
else delete projectErrors[conversationId];
|
||||||
|
if (Object.keys(projectErrors).length > 0) next[projectId] = projectErrors;
|
||||||
|
else delete next[projectId];
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
function defaultDependencies(): CodingWorkspaceDependencies {
|
function defaultDependencies(): CodingWorkspaceDependencies {
|
||||||
return {
|
return {
|
||||||
listProjects: listCodingProjects,
|
listProjects: listCodingProjects,
|
||||||
@@ -103,6 +119,7 @@ export function createCodingWorkspaceStore(
|
|||||||
selectedAgentId: null,
|
selectedAgentId: null,
|
||||||
loadState: 'idle',
|
loadState: 'idle',
|
||||||
error: null,
|
error: null,
|
||||||
|
conversationErrorsByProjectId: {},
|
||||||
creatingAgentIds: {},
|
creatingAgentIds: {},
|
||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
@@ -213,18 +230,36 @@ export function createCodingWorkspaceStore(
|
|||||||
},
|
},
|
||||||
|
|
||||||
async patchConversation(conversationId, patch) {
|
async patchConversation(conversationId, patch) {
|
||||||
set({ error: null });
|
const sourceProjectId = get().activeProjectId;
|
||||||
|
if (!sourceProjectId) throw new Error('当前没有可用的项目。');
|
||||||
|
set((current) => ({
|
||||||
|
conversationErrorsByProjectId: withConversationError(
|
||||||
|
current.conversationErrorsByProjectId,
|
||||||
|
sourceProjectId,
|
||||||
|
conversationId,
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
}));
|
||||||
try {
|
try {
|
||||||
const conversation = await deps.patchConversation(conversationId, patch);
|
const conversation = await deps.patchConversation(conversationId, patch);
|
||||||
get().upsertConversation(conversation);
|
get().upsertConversation(sourceProjectId, conversation);
|
||||||
return conversation;
|
return conversation;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({ error: error instanceof Error ? error.message : String(error) });
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
set((current) => ({
|
||||||
|
conversationErrorsByProjectId: withConversationError(
|
||||||
|
current.conversationErrorsByProjectId,
|
||||||
|
sourceProjectId,
|
||||||
|
conversationId,
|
||||||
|
message,
|
||||||
|
),
|
||||||
|
}));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
upsertConversation(conversation) {
|
upsertConversation(projectId, conversation) {
|
||||||
|
if (get().activeProjectId !== projectId) return;
|
||||||
set((current) => ({
|
set((current) => ({
|
||||||
conversations: [
|
conversations: [
|
||||||
conversation,
|
conversation,
|
||||||
|
|||||||
@@ -296,6 +296,91 @@ describe('CodingChatPanel first Conversation', () => {
|
|||||||
expect(codingConversationStore.getState().selectedConversationId).toBe(reviewerConversation.id);
|
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 () => {
|
it('does not clear a newly selected Conversation when an old archive request finishes', async () => {
|
||||||
const archiveFlight = deferred<CodingConversationMetadata>();
|
const archiveFlight = deferred<CodingConversationMetadata>();
|
||||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
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', () => {
|
describe('coding workspace store', () => {
|
||||||
it('loads local project metadata and selects the pinned Agent without touching runtime APIs', async () => {
|
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 }));
|
const listProjects = vi.fn(async () => ({ projects: [project], activeProjectId: project.id }));
|
||||||
@@ -156,4 +166,78 @@ describe('coding workspace store', () => {
|
|||||||
second,
|
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