// @vitest-environment node import { spawn } from 'node:child_process'; import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { createCodingConversationStore, validateSessionKey, } from '../../electron/coding-projects/conversation-store'; import { atomicWriteJson } from '../../electron/coding-projects/atomic-json'; import { createCodingProjectAgent, createCodingProjectConfigV2, normalizeProductModelRef, readCodingProjectConfigV2, } from '../../electron/coding-projects/project-config'; import { createCodingProjectStore, createLocalCodingProject, createMemoryCodingProjectStorage, } from '../../electron/coding-projects/project-store'; vi.mock('node:child_process', () => ({ spawn: vi.fn() })); const scratchRoots: string[] = []; const NOW = '2026-08-22T08:00:00.000Z'; const NEXT = '2026-08-22T08:00:01.000Z'; const MODEL = { accountId: 'account-local', modelId: 'provider/model-a', thinkingLevel: 'medium' as const, }; const RESPONSIBILITY = { mission: 'Implement the assigned work', owns: ['electron/coding-projects'], boundaries: [], collaborators: [], principles: ['Keep metadata local'], }; async function makeProjectPath(): Promise { const root = await mkdtemp(path.join(tmpdir(), 'makelore-coding-v2-')); scratchRoots.push(root); return root; } afterEach(async () => { vi.clearAllMocks(); await Promise.all(scratchRoots.splice(0).map((root) => rm(root, { recursive: true, force: true, }))); }); describe('coding project schema v2', () => { it('accepts the native max thinking level in a project model reference', () => { expect(normalizeProductModelRef({ accountId: 'account-local', modelId: 'deepseek-v4-pro', thinkingLevel: 'max', })).toEqual({ accountId: 'account-local', modelId: 'deepseek-v4-pro', thinkingLevel: 'max', }); }); it('creates project, Agent, and empty Conversation metadata without a runtime child', async () => { const projectPath = await makeProjectPath(); const storage = createMemoryCodingProjectStorage(); const store = createCodingProjectStore(storage, { createId: () => 'project-stable-id', now: () => NOW, }); const projectStartedAt = performance.now(); const { project, config } = await createLocalCodingProject({ projectPath, now: NOW }, store); const projectDurationMs = performance.now() - projectStartedAt; const agentStartedAt = performance.now(); const agent = await createCodingProjectAgent(projectPath, { id: 'implementer', avatarId: 'avatar-01', roleName: 'Implementer', name: 'Implementation Agent', model: MODEL, modelResolution: 'resolved', responsibility: RESPONSIBILITY, prompt: '\nPreserve this prompt.\n', skillIds: ['tdd'], }, { now: NEXT }); const agentDurationMs = performance.now() - agentStartedAt; const conversations = createCodingConversationStore(projectPath, { createId: () => 'f47ac10b-58cc-4372-a567-0e02b2c3d479', now: () => NEXT, }); const conversationStartedAt = performance.now(); const conversation = await conversations.create({ agentId: agent.id, title: 'Local draft', model: MODEL, modelResolution: 'resolved', }); const conversationDurationMs = performance.now() - conversationStartedAt; expect(project.id).toBe('project-stable-id'); expect(config).toMatchObject({ schemaVersion: 2, agents: [] }); expect(agent).toMatchObject({ id: 'implementer', name: 'Implementation Agent', prompt: '\nPreserve this prompt.\n', skillIds: ['tdd'], archivedAt: null, model: MODEL, modelResolution: 'resolved', }); expect(conversation).toMatchObject({ id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', agentId: 'implementer', model: MODEL, modelResolution: 'resolved', }); expect(conversation).not.toHaveProperty('piSessionId'); expect(conversation).not.toHaveProperty('sessionKey'); expect(vi.mocked(spawn)).not.toHaveBeenCalled(); expect(projectDurationMs).toBeLessThan(1_000); expect(agentDurationMs).toBeLessThan(500); expect(conversationDurationMs).toBeLessThan(500); }); it('uses .makelore as the only project metadata namespace', async () => { const projectPath = await makeProjectPath(); const store = createCodingProjectStore(createMemoryCodingProjectStorage(), { createId: () => 'project-makelore-only', now: () => NOW, }); await createLocalCodingProject({ projectPath, now: NOW }, store); expect(JSON.parse(await readFile( path.join(projectPath, '.makelore', 'project.json'), 'utf8', ))).toMatchObject({ schemaVersion: 2 }); await expect(readFile( path.join(projectPath, '.niancode', 'project.json'), 'utf8', )).rejects.toMatchObject({ code: 'ENOENT' }); const oldNamespaceOnly = await makeProjectPath(); await mkdir(path.join(oldNamespaceOnly, '.niancode'), { recursive: true }); await writeFile( path.join(oldNamespaceOnly, '.niancode', 'project.json'), JSON.stringify(createCodingProjectConfigV2(NOW)), 'utf8', ); await expect(readCodingProjectConfigV2(oldNamespaceOnly)).resolves.toEqual({ status: 'missing' }); }); it('requires an explicit model selection when resolution is required', async () => { const projectPath = await makeProjectPath(); const store = createCodingProjectStore(createMemoryCodingProjectStorage(), { createId: () => 'project-id', now: () => NOW, }); await createLocalCodingProject({ projectPath, now: NOW }, store); const agent = await createCodingProjectAgent(projectPath, { id: 'unresolved', avatarId: 'avatar-02', roleName: 'Unresolved', name: 'Unresolved Agent', model: null, modelResolution: 'required', responsibility: RESPONSIBILITY, }, { now: NEXT }); expect(agent).toMatchObject({ model: null, modelResolution: 'required' }); const read = await readCodingProjectConfigV2(projectPath); expect(read.status).toBe('valid'); if (read.status === 'valid') { expect(read.config.agents[0]).toMatchObject({ model: null, modelResolution: 'required' }); } }); it('preserves one project id across concurrent first opens of the same folder', async () => { const projectPath = await makeProjectPath(); let createCount = 0; const store = createCodingProjectStore(createMemoryCodingProjectStorage(), { createId: () => `project-${++createCount}`, now: () => NOW, }); const [first, second] = await Promise.all([ store.openFolder(projectPath), store.openFolder(projectPath), ]); expect(first.id).toBe('project-1'); expect(second.id).toBe('project-1'); expect(createCount).toBe(1); expect(await store.listProjects()).toHaveLength(1); }); }); describe('coding Conversation schema v2', () => { it.each([ '../session', '..\\session', '/absolute/session', 'C:\\absolute\\session', 'nested/session', 'nested\\session', ' session-opaque ', ])('rejects a non-opaque session key: %s', (sessionKey) => { expect(() => validateSessionKey(sessionKey)).toThrow('opaque relative key'); }); it('allows only one Pi session creation for concurrent first prompts', async () => { const projectPath = await makeProjectPath(); const store = createCodingConversationStore(projectPath, { createId: () => '2c1f4e52-4af8-4fce-a82e-18972ed71b47', now: () => NOW, }); const conversation = await store.create({ agentId: 'implementer', title: 'First prompt', model: MODEL, modelResolution: 'resolved', }); let release!: () => void; const gate = new Promise((resolve) => { release = resolve; }); const createBinding = vi.fn(async () => { await gate; return { piSessionId: 'pi-session-1', sessionKey: 'session-opaque-1' }; }); const first = store.ensureSessionBinding(conversation.id, createBinding); const second = store.ensureSessionBinding(conversation.id, createBinding); await vi.waitFor(() => expect(createBinding).toHaveBeenCalledTimes(1)); release(); await expect(Promise.all([first, second])).resolves.toEqual([ expect.objectContaining({ piSessionId: 'pi-session-1', sessionKey: 'session-opaque-1' }), expect.objectContaining({ piSessionId: 'pi-session-1', sessionKey: 'session-opaque-1' }), ]); expect(createBinding).toHaveBeenCalledTimes(1); expect(JSON.parse(await readFile(path.join(projectPath, '.makelore', 'conversations.json'), 'utf8'))) .toMatchObject({ schemaVersion: 2, conversations: [{ piSessionId: 'pi-session-1', sessionKey: 'session-opaque-1' }], }); }); it('atomically replaces JSON and cleans its temporary file after a failed replace', async () => { const projectPath = await makeProjectPath(); const metadataDirectory = path.join(projectPath, '.makelore'); const filePath = path.join(metadataDirectory, 'atomic.json'); await atomicWriteJson(filePath, { version: 1 }); await atomicWriteJson(filePath, { version: 2 }); expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ version: 2 }); const directoryTarget = path.join(metadataDirectory, 'cannot-replace-directory'); await mkdir(directoryTarget); await expect(atomicWriteJson(directoryTarget, { version: 3 })).rejects.toBeDefined(); expect((await readdir(metadataDirectory)).filter((name) => name.endsWith('.tmp'))).toEqual([]); }); });