import { describe, expect, it, vi } from 'vitest'; import { createCodingWorkspaceStore } from '@/stores/coding-workspace'; import type { CodingConversationMetadata, CodingProjectAgent, CodingProjectConfig, CodingProjectSummary, } from '@/types/coding-project'; const project: CodingProjectSummary = { id: 'project-1', name: 'Local project', createdAt: '2026-08-23T00:00:00.000Z', updatedAt: '2026-08-23T00:00:00.000Z', lastOpenedAt: '2026-08-23T00:00:00.000Z', }; function agent(id: string, overrides: Partial = {}): CodingProjectAgent { return { id, avatarId: 'default', roleName: '伙伴', name: id, builtIn: false, enabled: true, skillIds: [], responsibility: { mission: '协助当前项目', owns: [], boundaries: [], collaborators: [], principles: [], }, prompt: '', archivedAt: null, pinned: false, createdAt: '2026-08-23T00:00:00.000Z', updatedAt: '2026-08-23T00:00:00.000Z', model: null, modelResolution: 'default', ...overrides, }; } function config(agents: CodingProjectAgent[]): CodingProjectConfig { return { schemaVersion: 2, projectType: 'custom', initialized: true, agents, knowledgeDirectory: 'knowledge', createdAt: '2026-08-23T00:00:00.000Z', updatedAt: '2026-08-23T00:00:00.000Z', }; } function conversation(id: string, agentId: string): CodingConversationMetadata { return { id, agentId, title: '新对话', archivedAt: null, unread: false, createdAt: '2026-08-23T00:00:00.000Z', updatedAt: '2026-08-23T00:00:00.000Z', model: null, modelResolution: 'default', }; } function deferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((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 })); const getConfig = vi.fn(async () => ({ project, config: config([agent('agent-a'), agent('agent-b', { pinned: true })]), })); const listConversations = vi.fn(async () => [conversation('conversation-b', 'agent-b')]); const createConversation = vi.fn(); const store = createCodingWorkspaceStore({ listProjects, getConfig, listConversations, createConversation, }); await store.getState().load(); expect(store.getState()).toMatchObject({ activeProjectId: 'project-1', selectedAgentId: 'agent-b', loadState: 'ready', conversations: [{ id: 'conversation-b' }], }); expect(createConversation).not.toHaveBeenCalled(); }); it('passes the explicit identity choice through project creation', async () => { const createProject = vi.fn(async () => ({ project, config: config([agent('agent-a')]), knowledgeFiles: [], })); const store = createCodingWorkspaceStore({ listProjects: vi.fn(async () => ({ projects: [project], activeProjectId: project.id })), getConfig: vi.fn(async () => ({ project, config: config([agent('agent-a')]) })), listConversations: vi.fn(async () => []), createProject, }); await expect(store.getState().createProject({ projectPath: 'C:/projects/new', projectType: 'custom', identity: { kind: 'bind', projectId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, })).resolves.toEqual(project); expect(createProject).toHaveBeenCalledWith({ projectPath: 'C:/projects/new', projectType: 'custom', identity: { kind: 'bind', projectId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, }); }); it('creates a missing first Conversation once for concurrent callers', async () => { let resolveCreate!: (value: CodingConversationMetadata) => void; const createFlight = new Promise((resolve) => { resolveCreate = resolve; }); const createConversation = vi.fn(() => createFlight); const store = createCodingWorkspaceStore({ listProjects: vi.fn(async () => ({ projects: [project], activeProjectId: project.id })), getConfig: vi.fn(async () => ({ project, config: config([agent('agent-a')]) })), listConversations: vi.fn(async () => []), createConversation, }); await store.getState().load(); const first = store.getState().ensureConversation('agent-a'); const second = store.getState().ensureConversation('agent-a'); expect(createConversation).toHaveBeenCalledTimes(1); expect(store.getState().creatingAgentIds).toEqual({ 'agent-a': true }); const created = conversation('conversation-a', 'agent-a'); resolveCreate(created); await expect(Promise.all([first, second])).resolves.toEqual([created, created]); expect(store.getState().conversations).toEqual([created]); expect(store.getState().creatingAgentIds).toEqual({}); }); it('keeps title, archive, and unread metadata scoped to the patched Conversation', async () => { const first = conversation('conversation-a', 'agent-a'); const second = conversation('conversation-b', 'agent-a'); const patchConversation = vi.fn(async (_id: string, patch: { title?: string; archived?: boolean; unread?: boolean }) => ({ ...first, ...(patch.title ? { title: patch.title } : {}), ...(patch.archived ? { archivedAt: '2026-08-24T01:00:00.000Z' } : {}), ...(patch.unread !== undefined ? { unread: patch.unread } : {}), })); const store = createCodingWorkspaceStore({ listProjects: vi.fn(async () => ({ projects: [project], activeProjectId: project.id })), getConfig: vi.fn(async () => ({ project, config: config([agent('agent-a')]) })), listConversations: vi.fn(async () => [first, second]), createConversation: vi.fn(), patchConversation, }); await store.getState().load(); await store.getState().patchConversation(first.id, { title: 'Feature UI', archived: true, unread: true, }); expect(patchConversation).toHaveBeenCalledWith(first.id, { title: 'Feature UI', archived: true, unread: true, }); expect(store.getState().conversations).toEqual([ expect.objectContaining({ id: first.id, title: 'Feature UI', unread: true, archivedAt: expect.any(String) }), 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(); 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(); 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(); }); });