// @vitest-environment node import { execFile } from 'node:child_process'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { promisify } from 'node:util'; import { afterEach, describe, expect, it } from 'vitest'; import type { AgentBrowserModule } from '../../electron/agent-browser'; import { CodingProductHostError, createCodingPluginMarketplaceService, createCodingProductHost, type CodingProductHostOptions, } from '../../electron/api/coding-product-services'; import { CodingAttachmentStore } from '../../electron/coding-projects/attachment-store'; import { createCodingConversationStore } from '../../electron/coding-projects/conversation-store'; import { createCodingProjectAgent, createCodingProjectMetadata, } from '../../electron/coding-projects/project-config'; import { PiProductTools } from '../../electron/coding-runtime/pi/product-tools'; import { DATA_SERVICE_PLUGIN_DEFINITION } from '../../shared/coding-plugins'; const roots: string[] = []; const conversationId = '11111111-1111-4111-8111-111111111111'; const exec = promisify(execFile); afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); async function temporaryRoot(prefix: string): Promise { const root = await mkdtemp(path.join(tmpdir(), prefix)); roots.push(root); return root; } async function configuredProject(skillIds: readonly string[] = ['agent-browser', 'grilling']): Promise { const root = await temporaryRoot('makelore-pi-products-'); await createCodingProjectMetadata(root, { now: '2026-08-23T00:00:00.000Z' }); await createCodingProjectAgent(root, { id: 'builder', avatarId: 'avatar-01', roleName: '实现者', name: 'Builder', model: null, modelResolution: 'required', skillIds: [...skillIds], responsibility: { mission: 'Implement changes', owns: [], boundaries: [], collaborators: [], principles: [], }, }, { now: '2026-08-23T00:00:00.000Z' }); await createCodingConversationStore(root, { createId: () => conversationId, now: () => '2026-08-23T00:00:00.000Z', }).create({ agentId: 'builder', title: 'PI-105', model: null, modelResolution: 'required', }); return root; } function productTools(root: string, withDataService = false): PiProductTools { return new PiProductTools({ browser: {} as AgentBrowserModule, attachments: new CodingAttachmentStore(path.join(root, 'attachments')), bundledSkillsDir: path.resolve('resources/coding-skills'), ...(withDataService ? { pluginSkillSources: [{ id: 'data-service', pluginId: 'makelore.data-service', directory: path.resolve('resources/coding-plugins/data-service'), entryPath: 'skills/data-service/SKILL.md', }], } : {}), }); } function projectService( root: string, active = true, ): CodingProductHostOptions['projects'] { const project = { id: 'project-a', path: root, name: 'project-a', createdAt: '2026-08-23T00:00:00.000Z', updatedAt: '2026-08-23T00:00:00.000Z', lastOpenedAt: '2026-08-23T00:00:00.000Z', }; return { getActiveProject: async () => active ? project : null, findActiveConversation: async (id) => { if (!active) throw new Error('No active project'); const conversation = await createCodingConversationStore(root).get(id); if (!conversation) { throw new CodingProductHostError( 404, 'CODING_CONVERSATION_NOT_FOUND', 'Coding Conversation does not exist', ); } return { project, conversation }; }, }; } async function git(root: string, ...args: string[]): Promise { await exec('git', ['-C', root, ...args], { windowsHide: true }); } async function gitOutput(root: string, ...args: string[]): Promise { return (await exec('git', ['-C', root, ...args], { windowsHide: true })).stdout.trim(); } describe('PI-105 product Host composition', () => { it('projects managed skills and a stable command catalog without raw Pi fields', async () => { const root = await configuredProject(); const tools = productTools(root); const host = createCodingProductHost({ projects: projectService(root), productTools: tools, listPiCommands: async () => ({ commands: [ { name: 'custom', description: 'Custom Pi command', token: 'pi-secret' }, { name: 'compact', description: 'Must not shadow Makelore' }, { name: '../unsafe', description: 'Invalid command' }, ], apiKey: 'raw-provider-secret', }), }); const skills = await host.listSkills('builder'); expect(skills.filter(({ selected }) => selected).map(({ id }) => id)).toEqual([ 'agent-browser', 'grilling', ]); const commands = await host.listCommands(conversationId); expect(commands.slice(0, 5).map(({ source }) => source)).toEqual([ 'makelore', 'makelore', 'makelore', 'makelore', 'makelore', ]); expect(commands).toContainEqual(expect.objectContaining({ name: 'custom', source: 'pi' })); expect(commands).toContainEqual(expect.objectContaining({ name: 'agent-browser', source: 'skill' })); expect(commands.filter(({ name }) => name === 'compact')).toHaveLength(1); const serialized = JSON.stringify({ skills, commands }); expect(serialized).not.toContain(root); expect(serialized).not.toContain('pi-secret'); expect(serialized).not.toContain('raw-provider-secret'); expect(serialized).not.toMatch(/"(todo|share|revert|unrevert)"/); }); it('retains a disabled assigned plugin Skill but only makes it effective after enable', async () => { const root = await configuredProject(['data-service']); const tools = productTools(root, true); let enabled = false; const host = createCodingProductHost({ projects: projectService(root), productTools: tools, getEnabledPluginIds: async () => enabled ? ['makelore.data-service'] : [], }); await expect(host.listSkills()).resolves.not.toContainEqual( expect.objectContaining({ id: 'data-service' }), ); await expect(host.listSkills('builder')).resolves.toContainEqual( expect.objectContaining({ id: 'data-service', selected: true, available: false, effective: false, }), ); await expect(host.listCommands(conversationId)).resolves.not.toContainEqual( expect.objectContaining({ skillId: 'data-service' }), ); enabled = true; await expect(host.listSkills('builder')).resolves.toContainEqual( expect.objectContaining({ id: 'data-service', selected: true, available: true, effective: true, }), ); await expect(host.listCommands(conversationId)).resolves.toContainEqual( expect.objectContaining({ skillId: 'data-service' }), ); }); it('keeps a resolver-disabled assignment visible without passing it to worker resources', async () => { const root = await configuredProject(['data-service']); const tools = productTools(root, true); const effectiveResolver = { resolve: vi.fn(async () => ({ accountSessionId: 'account-a\u00001', projectId: 'project-a', pluginReleaseIds: [], effectiveSkillIds: [], skillEntries: [], toolDefinitions: [], runtimePolicies: [], unavailableReasons: [{ pluginId: DATA_SERVICE_PLUGIN_DEFINITION.id, code: 'project_disabled' as const, message: 'Plugin is not enabled for this project', }], })), getSkillSources: vi.fn(async () => []), getPolicyState: vi.fn(() => ({ status: 'current' as const, catalog: null, revision: 1, lastVerifiedAt: 1, })), }; const host = createCodingProductHost({ projects: projectService(root), productTools: tools, effectiveResolver, }); await expect(host.listSkills('builder')).resolves.toContainEqual(expect.objectContaining({ id: 'data-service', selected: true, available: false, effective: false, })); expect(effectiveResolver.resolve).toHaveBeenCalledWith(expect.objectContaining({ assignedSkillIds: ['data-service'], role: 'parent', })); }); it('offers an enabled Game Resource Skill before its first Agent assignment', async () => { const root = await configuredProject(['agent-browser']); const tools = new PiProductTools({ browser: {} as AgentBrowserModule, attachments: new CodingAttachmentStore(path.join(root, 'attachments')), bundledSkillsDir: path.resolve('resources/coding-skills'), pluginSkillSources: [{ id: 'game-resource', pluginId: 'makelore.game-resource', directory: path.resolve( 'resources/coding-plugins/game-resource/skills/game-resource', ), }], }); const effectiveResolver = { resolve: vi.fn(async () => ({ accountSessionId: 'account-a\u00001', projectId: 'project-a', pluginReleaseIds: [], effectiveSkillIds: [], skillEntries: [], toolDefinitions: [], runtimePolicies: [], unavailableReasons: [{ pluginId: 'makelore.game-resource', code: 'skill_unassigned' as const, message: 'Plugin Skill is not assigned', }], })), getSkillSources: vi.fn(async () => []), getPolicyState: vi.fn(() => ({ status: 'current' as const, catalog: null, revision: 1, lastVerifiedAt: 1, })), }; const host = createCodingProductHost({ projects: projectService(root), productTools: tools, effectiveResolver, getEnabledPluginIds: async () => ['makelore.game-resource'], }); await expect(host.listSkills()).resolves.toContainEqual(expect.objectContaining({ id: 'game-resource', selected: false, available: true, effective: false, })); expect(effectiveResolver.resolve).not.toHaveBeenCalled(); await expect(host.listSkills('builder')).resolves.not.toContainEqual( expect.objectContaining({ id: 'game-resource', available: true }), ); }); it('reads exact-run changes from the same PiProductTools tracker instance', async () => { const root = await configuredProject(); await writeFile(path.join(root, 'notes.txt'), 'baseline\n', 'utf8'); const tools = productTools(root); const host = createCodingProductHost({ projects: projectService(root), productTools: tools, }); await tools.beginRun({ conversationId, runId: 'run-a', projectPath: root }); await writeFile(path.join(root, 'notes.txt'), 'changed\n', 'utf8'); await tools.recordTouchedPaths(conversationId, 'run-a', ['notes.txt']); expect(await host.getChanges(conversationId)).toMatchObject({ conversationId, runId: 'run-a', git: false, files: [{ path: 'notes.txt', status: 'modified', preview: 'changed\n' }], }); expect(JSON.stringify(await host.getChanges(conversationId))).not.toContain(root); }); it('keeps a merge conflict created during the target run in its changes snapshot', async () => { const root = await configuredProject(); await git(root, 'init'); await git(root, 'config', 'user.email', 'pi-products@example.invalid'); await git(root, 'config', 'user.name', 'PI Products'); await writeFile(path.join(root, 'conflict.txt'), 'baseline\n', 'utf8'); await git(root, 'add', '.'); await git(root, 'commit', '-m', 'baseline'); const baseBranch = await gitOutput(root, 'branch', '--show-current'); await git(root, 'switch', '-c', 'conflicting-change'); await writeFile(path.join(root, 'conflict.txt'), 'branch change\n', 'utf8'); await git(root, 'commit', '-am', 'branch change'); await git(root, 'switch', baseBranch); await writeFile(path.join(root, 'conflict.txt'), 'base change\n', 'utf8'); await git(root, 'commit', '-am', 'base change'); const tools = productTools(root); const host = createCodingProductHost({ projects: projectService(root), productTools: tools, }); await tools.beginRun({ conversationId, runId: 'run-conflict', projectPath: root }); await expect(exec('git', ['-C', root, 'merge', 'conflicting-change'], { windowsHide: true })) .rejects.toThrow(); await tools.markBash(conversationId, 'run-conflict'); await tools.settleRun(conversationId, 'run-conflict'); expect(await host.getChanges(conversationId)).toMatchObject({ conversationId, runId: 'run-conflict', files: [expect.objectContaining({ path: 'conflict.txt', status: 'conflicted' })], }); }); it('returns typed project, Agent, and Conversation errors', async () => { const root = await configuredProject(); const tools = productTools(root); const unavailable = createCodingProductHost({ projects: projectService(root, false), productTools: tools, }); await expect(unavailable.fileStatus()).rejects.toMatchObject({ status: 409, code: 'CODING_ACTIVE_PROJECT_REQUIRED', }); const host = createCodingProductHost({ projects: projectService(root), productTools: tools, }); await expect(host.listSkills('missing')).rejects.toMatchObject({ status: 404, code: 'CODING_AGENT_NOT_FOUND', }); await expect(host.listCommands('22222222-2222-4222-8222-222222222222')) .rejects.toMatchObject({ status: 404, code: 'CODING_CONVERSATION_NOT_FOUND', }); }); }); describe('Marketplace public Library projection', () => { const snapshot = { items: [{ pluginId: 'makelore.notes', title: 'Notes', summary: 'Notes', category: 'tools', acquisition: 'free' as const, acquisitionMode: 'user_acquired' as const, catalogStatus: 'active' as const, runtimeStatus: 'enabled' as const, acquiredAt: '2026-08-28T00:00:00Z', removedAt: null, stableVersion: '2.0.0', betaVersion: null, }], total: 1, stale: false, fetchedAt: 1, }; const records = [ { pluginId: 'makelore.notes', releaseId: 'release-old', version: '1.0.0', packageSchemaVersion: 2, contractVersion: 1, runtimeKind: 'skill_only' as const, sha256: 'a'.repeat(64), sizeBytes: 10, installedAt: '2026-08-27T00:00:00Z', }, { pluginId: 'makelore.notes', releaseId: 'release-new', version: '1.5.0', packageSchemaVersion: 2, contractVersion: 1, runtimeKind: 'skill_only' as const, sha256: 'b'.repeat(64), sizeBytes: 11, installedAt: '2026-08-28T00:00:00Z', }, { pluginId: 'makelore.other-account', releaseId: 'foreign-release', version: '9.0.0', packageSchemaVersion: 2, contractVersion: 1, runtimeKind: 'skill_only' as const, sha256: 'c'.repeat(64), sizeBytes: 12, installedAt: '2026-08-28T01:00:00Z', }, ]; it('rebuilds fresh-process installation state from the Package Store current selection', async () => { const marketplace = { readCatalog: vi.fn(), readDetail: vi.fn(), readLibrary: vi.fn().mockResolvedValue(snapshot), acquire: vi.fn(), remove: vi.fn(), }; const packageStore = { readInstalledIndex: vi.fn().mockResolvedValue(records), getInstalled: vi.fn().mockResolvedValue({ ...records[0], packageRoot: 'ignored', definition: {} }), resolveAndInstall: vi.fn(), removeUnused: vi.fn(), }; const service = createCodingPluginMarketplaceService({ marketplace: marketplace as never, packageStore: packageStore as never, clientVersion: '2.0.0', }); const result = await service.readLibrary(); expect(result).toEqual({ library: snapshot, installations: [{ status: 'installed', pluginId: 'makelore.notes', releaseId: 'release-old', version: '1.0.0', }], }); expect(JSON.stringify(result)).not.toMatch(/sha256|sizeBytes|installedAt|packageRoot|definition|account|admission|token/i); }); it('projects an installed but client-incompatible package without discarding its cached release', async () => { const marketplace = { readCatalog: vi.fn(), readDetail: vi.fn(), readLibrary: vi.fn().mockResolvedValue(snapshot), acquire: vi.fn(), remove: vi.fn(), }; const packageStore = { readInstalledIndex: vi.fn().mockResolvedValue(records), getInstalled: vi.fn().mockResolvedValue({ ...records[0], channel: 'beta', packageRoot: 'ignored', definition: {}, unavailableReason: 'plugin_incompatible_client' as const, }), resolveAndInstall: vi.fn(), removeUnused: vi.fn(), }; const service = createCodingPluginMarketplaceService({ marketplace: marketplace as never, packageStore: packageStore as never, clientVersion: '3.0.0', }); await expect(service.readLibrary()).resolves.toMatchObject({ installations: [{ status: 'unavailable', pluginId: 'makelore.notes', releaseId: 'release-old', version: '1.0.0', channel: 'beta', reason: 'plugin_incompatible_client', }], }); }); it.each(['acquire', 'remove'] as const)('returns an authoritative joined snapshot after %s', async (action) => { const marketplace = { readCatalog: vi.fn(), readDetail: vi.fn(), readLibrary: vi.fn(), acquire: vi.fn().mockResolvedValue(snapshot), remove: vi.fn().mockResolvedValue(snapshot), }; const packageStore = { readInstalledIndex: vi.fn().mockResolvedValue(records.slice(1, 2)), getInstalled: vi.fn().mockResolvedValue({ ...records[1], packageRoot: 'ignored', definition: {} }), resolveAndInstall: vi.fn(), removeUnused: vi.fn(), }; const service = createCodingPluginMarketplaceService({ marketplace: marketplace as never, packageStore: packageStore as never, clientVersion: '2.0.0', }); await expect(service[action]('makelore.notes')).resolves.toEqual({ library: snapshot, installations: [{ status: 'installed', pluginId: 'makelore.notes', releaseId: 'release-new', version: '1.5.0' }], }); expect(marketplace[action]).toHaveBeenCalledWith('makelore.notes'); expect(packageStore.getInstalled).toHaveBeenCalledOnce(); }); });