448 lines
18 KiB
TypeScript
448 lines
18 KiB
TypeScript
// @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<string> {
|
|
const root = await mkdtemp(path.join(tmpdir(), prefix));
|
|
roots.push(root);
|
|
return root;
|
|
}
|
|
|
|
async function configuredProject(skillIds: readonly string[] = ['agent-browser', 'grilling']): Promise<string> {
|
|
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<void> {
|
|
await exec('git', ['-C', root, ...args], { windowsHide: true });
|
|
}
|
|
|
|
async function gitOutput(root: string, ...args: string[]): Promise<string> {
|
|
return (await exec('git', ['-C', root, ...args], { windowsHide: true })).stdout.trim();
|
|
}
|
|
|
|
describe('PI-105 product Host composition', () => {
|
|
it('ignores legacy standalone Skill assignments and projects a stable command catalog', 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([]);
|
|
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).not.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<CodingProductHostError>({
|
|
status: 409, code: 'CODING_ACTIVE_PROJECT_REQUIRED',
|
|
});
|
|
|
|
const host = createCodingProductHost({
|
|
projects: projectService(root),
|
|
productTools: tools,
|
|
});
|
|
await expect(host.listSkills('missing')).rejects.toMatchObject<CodingProductHostError>({
|
|
status: 404, code: 'CODING_AGENT_NOT_FOUND',
|
|
});
|
|
await expect(host.listCommands('22222222-2222-4222-8222-222222222222'))
|
|
.rejects.toMatchObject<CodingProductHostError>({
|
|
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();
|
|
});
|
|
});
|