342 lines
14 KiB
TypeScript
342 lines
14 KiB
TypeScript
import { mkdtemp, mkdir, readFile, 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 YAML from 'yaml';
|
|
import { CodingCapabilityRegistryImpl } from '@electron/coding-plugins/registry';
|
|
import type { PiProviderSelection } from '@electron/coding-runtime/pi/provider-config';
|
|
import {
|
|
buildPiManagedInputArgs,
|
|
getPiManagedPaths,
|
|
MAKELORE_DEFAULT_LANGUAGE_PROMPT,
|
|
materializePiAgentResources,
|
|
resolveBundledCodingSkillsDir,
|
|
resolveExplicitCodingSkillPaths,
|
|
} from '@electron/coding-runtime/pi/resource-loader';
|
|
import type { PluginPolicyClientState } from '@electron/services/plugin-policy-client';
|
|
import { DATA_SERVICE_PLUGIN_DEFINITION } from '../../shared/coding-plugins';
|
|
import type { EffectivePluginSnapshot } from '../../electron/coding-plugins/effective-resolver';
|
|
|
|
const temporaryRoots: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
});
|
|
|
|
async function fixtureRoot(): Promise<{
|
|
root: string;
|
|
userDataDir: string;
|
|
projectDir: string;
|
|
skillsDir: string;
|
|
}> {
|
|
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-resources-'));
|
|
temporaryRoots.push(root);
|
|
const userDataDir = path.join(root, 'user-data');
|
|
const projectDir = path.join(root, 'project');
|
|
const skillsDir = path.join(root, 'bundled-skills');
|
|
await Promise.all([
|
|
mkdir(path.join(projectDir, '.pi', 'skills', 'untrusted-project-skill'), { recursive: true }),
|
|
mkdir(path.join(projectDir, '.agents', 'skills', 'untrusted-agent-skill'), { recursive: true }),
|
|
mkdir(path.join(skillsDir, 'grilling'), { recursive: true }),
|
|
mkdir(path.join(skillsDir, 'agent-browser'), { recursive: true }),
|
|
mkdir(path.join(path.dirname(skillsDir), 'coding-plugins', 'data-service', 'skills', 'data-service'), { recursive: true }),
|
|
]);
|
|
await Promise.all([
|
|
writeFile(path.join(projectDir, '.pi', 'skills', 'untrusted-project-skill', 'SKILL.md'), 'untrusted', 'utf8'),
|
|
writeFile(path.join(projectDir, '.agents', 'skills', 'untrusted-agent-skill', 'SKILL.md'), 'untrusted', 'utf8'),
|
|
writeFile(path.join(skillsDir, 'grilling', 'SKILL.md'), '---\nname: grilling\n---\n', 'utf8'),
|
|
writeFile(path.join(skillsDir, 'agent-browser', 'SKILL.md'), '---\nname: agent-browser\n---\n', 'utf8'),
|
|
writeFile(
|
|
path.join(path.dirname(skillsDir), 'coding-plugins', 'data-service', 'skills', 'data-service', 'SKILL.md'),
|
|
'---\nname: data-service\n---\n',
|
|
'utf8',
|
|
),
|
|
]);
|
|
return { root, userDataDir, projectDir, skillsDir };
|
|
}
|
|
|
|
describe('Pi managed resource loader', () => {
|
|
it('materializes only managed prompt and explicitly selected bundled skills', async () => {
|
|
const fixture = await fixtureRoot();
|
|
const prompt = 'PRIVATE PARTNER PROMPT CONTENT';
|
|
const effectiveSnapshot: EffectivePluginSnapshot = {
|
|
accountSessionId: 'account-a\u00001',
|
|
projectId: 'project-1',
|
|
pluginReleaseIds: ['release-a'],
|
|
effectiveSkillIds: ['grilling'],
|
|
skillEntries: [{ id: 'grilling', entryPath: 'grilling/SKILL.md' }],
|
|
toolDefinitions: [],
|
|
runtimePolicies: [],
|
|
unavailableReasons: [],
|
|
};
|
|
const resources = await materializePiAgentResources({
|
|
userDataDir: fixture.userDataDir,
|
|
projectId: 'project-1',
|
|
agentId: 'agent-1',
|
|
prompt,
|
|
skillEntries: [
|
|
{ id: 'grilling', entryPath: 'grilling/SKILL.md' },
|
|
{ id: 'grilling', entryPath: 'grilling/SKILL.md' },
|
|
],
|
|
catalogRevision: 11,
|
|
bundledSkillsDir: fixture.skillsDir,
|
|
effectiveSnapshot,
|
|
revision: { provider: 3, resources: 7 },
|
|
});
|
|
|
|
expect(await readFile(resources.promptPath, 'utf8')).toBe(prompt);
|
|
expect(await readFile(resources.languagePromptPath, 'utf8')).toBe(MAKELORE_DEFAULT_LANGUAGE_PROMPT);
|
|
expect(MAKELORE_DEFAULT_LANGUAGE_PROMPT).toContain('默认使用简体中文');
|
|
expect(MAKELORE_DEFAULT_LANGUAGE_PROMPT).toContain('可见的思考过程');
|
|
expect(resources.skillIds).toEqual(['grilling']);
|
|
expect(resources.skillEntries).toEqual([
|
|
{ id: 'grilling', entryPath: 'grilling/SKILL.md' },
|
|
]);
|
|
expect(resources.catalogRevision).toBe(11);
|
|
expect(resources.skillPaths).toEqual([path.join(fixture.skillsDir, 'grilling', 'SKILL.md')]);
|
|
expect(JSON.stringify(resources.summary)).not.toContain(prompt);
|
|
const manifest = JSON.parse(await readFile(resources.manifestPath, 'utf8')) as Record<string, unknown>;
|
|
expect(manifest).toMatchObject({
|
|
schemaVersion: 1,
|
|
projectId: 'project-1',
|
|
agentId: 'agent-1',
|
|
promptFile: 'agent-1.md',
|
|
languagePromptFile: 'agent-1.language.md',
|
|
skillIds: ['grilling'],
|
|
skillEntries: [{ id: 'grilling', entryPath: 'grilling/SKILL.md' }],
|
|
catalogRevision: 11,
|
|
effectivePluginSnapshot: effectiveSnapshot,
|
|
revision: { provider: 3, resources: 7 },
|
|
});
|
|
expect(resources.effectivePluginSnapshot).toEqual(effectiveSnapshot);
|
|
expect(resources.summary.effectivePluginSnapshot).toEqual(effectiveSnapshot);
|
|
expect(JSON.stringify(manifest)).not.toContain(prompt);
|
|
await expect(readFile(path.join(fixture.userDataDir, '.pi', 'agents', 'agent-1.md'), 'utf8'))
|
|
.rejects.toMatchObject({ code: 'ENOENT' });
|
|
});
|
|
|
|
it('builds argv from managed paths without prompt content, credentials, or auto-discovery roots', async () => {
|
|
const fixture = await fixtureRoot();
|
|
const prompt = 'PROMPT-MUST-NOT-BE-IN-ARGV';
|
|
const resources = await materializePiAgentResources({
|
|
userDataDir: fixture.userDataDir,
|
|
projectId: 'project-1',
|
|
agentId: 'agent-1',
|
|
prompt,
|
|
skillEntries: [{ id: 'agent-browser', entryPath: 'agent-browser/SKILL.md' }],
|
|
catalogRevision: 12,
|
|
bundledSkillsDir: fixture.skillsDir,
|
|
revision: { provider: 1, resources: 1 },
|
|
});
|
|
const selection: PiProviderSelection = {
|
|
accountId: 'private-account-id',
|
|
runtimeProviderId: 'makelore-account-opaque',
|
|
modelId: 'model-a',
|
|
thinkingLevel: 'medium',
|
|
input: ['text'],
|
|
};
|
|
const args = buildPiManagedInputArgs(selection, resources);
|
|
const serialized = JSON.stringify(args);
|
|
|
|
expect(args).toEqual([
|
|
'--provider', 'makelore-account-opaque',
|
|
'--model', 'model-a',
|
|
'--thinking', 'medium',
|
|
'--system-prompt', resources.promptPath,
|
|
'--append-system-prompt', resources.languagePromptPath,
|
|
'--skill', path.join(fixture.skillsDir, 'agent-browser', 'SKILL.md'),
|
|
]);
|
|
expect(serialized).not.toContain(prompt);
|
|
expect(serialized).not.toContain(MAKELORE_DEFAULT_LANGUAGE_PROMPT);
|
|
expect(serialized).not.toContain('private-account-id');
|
|
expect(serialized).not.toContain(path.join(fixture.projectDir, '.pi'));
|
|
expect(serialized).not.toContain(path.join(fixture.projectDir, '.agents'));
|
|
});
|
|
|
|
it('materializes effective plugin Skill entries from their bundled package roots', async () => {
|
|
const fixture = await fixtureRoot();
|
|
const resources = await materializePiAgentResources({
|
|
userDataDir: fixture.userDataDir,
|
|
projectId: 'project-1',
|
|
agentId: 'agent-1',
|
|
prompt: 'plugin prompt',
|
|
skillEntries: [{ id: 'data-service', entryPath: 'skills/data-service/SKILL.md' }],
|
|
catalogRevision: 13,
|
|
bundledSkillsDir: fixture.skillsDir,
|
|
revision: { provider: 1, resources: 1 },
|
|
});
|
|
|
|
expect(resources.skillIds).toEqual(['data-service']);
|
|
expect(resources.skillPaths).toEqual([
|
|
path.join(path.dirname(fixture.skillsDir), 'coding-plugins', 'data-service', 'skills', 'data-service', 'SKILL.md'),
|
|
]);
|
|
expect(resources.catalogRevision).toBe(13);
|
|
});
|
|
|
|
it('keeps each effective Skill paired with its verified package root when relative paths collide', async () => {
|
|
const fixture = await fixtureRoot();
|
|
const firstRoot = path.join(fixture.root, 'packages', 'first');
|
|
const secondRoot = path.join(fixture.root, 'packages', 'second');
|
|
const relativeEntry = 'skills/shared/SKILL.md';
|
|
await Promise.all([
|
|
mkdir(path.join(firstRoot, 'skills', 'shared'), { recursive: true }),
|
|
mkdir(path.join(secondRoot, 'skills', 'shared'), { recursive: true }),
|
|
]);
|
|
await Promise.all([
|
|
writeFile(path.join(firstRoot, relativeEntry), 'first package', 'utf8'),
|
|
writeFile(path.join(secondRoot, relativeEntry), 'second package', 'utf8'),
|
|
]);
|
|
|
|
const resolved = await resolveExplicitCodingSkillPaths(fixture.skillsDir, [
|
|
{ id: 'first-skill', entryPath: relativeEntry, packageRoot: firstRoot },
|
|
{ id: 'second-skill', entryPath: relativeEntry, packageRoot: secondRoot },
|
|
]);
|
|
|
|
expect(resolved.skillPaths).toEqual([
|
|
path.join(firstRoot, relativeEntry),
|
|
path.join(secondRoot, relativeEntry),
|
|
]);
|
|
await expect(readFile(resolved.skillPaths[0]!, 'utf8')).resolves.toBe('first package');
|
|
await expect(readFile(resolved.skillPaths[1]!, 'utf8')).resolves.toBe('second package');
|
|
});
|
|
|
|
it('filters a known disabled plugin Skill for the next worker and restores it after re-enable', async () => {
|
|
const fixture = await fixtureRoot();
|
|
const assignedSkillIds = ['grilling', 'data-service'];
|
|
let enabled = true;
|
|
const policy: PluginPolicyClientState = {
|
|
status: 'current',
|
|
revision: 17,
|
|
lastVerifiedAt: 1,
|
|
catalog: {
|
|
schema_version: 1,
|
|
catalog_version: 'catalog-17',
|
|
pricing_version: null,
|
|
plugins: [{
|
|
plugin_id: DATA_SERVICE_PLUGIN_DEFINITION.id,
|
|
supported_contract_versions: [DATA_SERVICE_PLUGIN_DEFINITION.contractVersion],
|
|
status: 'active',
|
|
capabilities: DATA_SERVICE_PLUGIN_DEFINITION.skills[0].grants.map((capabilityId) => ({
|
|
capability_id: capabilityId,
|
|
operations: DATA_SERVICE_PLUGIN_DEFINITION.tools
|
|
.filter((tool) => tool.capabilityId === capabilityId)
|
|
.map((tool) => ({
|
|
operation: tool.operation,
|
|
billing: { mode: 'included' as const, entitlement_scope: null, notice: 'Included' },
|
|
})),
|
|
})),
|
|
}],
|
|
},
|
|
};
|
|
const registry = new CodingCapabilityRegistryImpl({
|
|
policyClient: { getState: () => policy, refresh: vi.fn().mockResolvedValue(undefined) },
|
|
getEnabledPluginIds: async () => enabled ? [DATA_SERVICE_PLUGIN_DEFINITION.id] : [],
|
|
definitions: [DATA_SERVICE_PLUGIN_DEFINITION],
|
|
adapters: [{
|
|
pluginId: DATA_SERVICE_PLUGIN_DEFINITION.id,
|
|
async inspect() { return { status: 'ready' }; },
|
|
async invoke() {
|
|
return {
|
|
success: true, status: 200, code: null, error: null, retryable: false,
|
|
payload_schema: 'data-service.v1', data: null,
|
|
};
|
|
},
|
|
}],
|
|
});
|
|
const resolve = async () => await registry.resolveWorkerResources({
|
|
projectPath: fixture.projectDir,
|
|
assignedSkillIds,
|
|
role: 'parent',
|
|
});
|
|
|
|
const first = await resolve();
|
|
expect(first.effectiveSkillIds).toEqual(assignedSkillIds);
|
|
expect(first.tools).toHaveLength(10);
|
|
|
|
enabled = false;
|
|
const disabled = await resolve();
|
|
expect(assignedSkillIds).toEqual(['grilling', 'data-service']);
|
|
expect(disabled.effectiveSkillIds).toEqual(['grilling']);
|
|
expect(disabled.tools).toEqual([]);
|
|
await expect(materializePiAgentResources({
|
|
userDataDir: fixture.userDataDir,
|
|
projectId: 'project-1',
|
|
agentId: 'agent-1',
|
|
prompt: 'disabled plugin prompt',
|
|
skillEntries: disabled.skillEntries,
|
|
catalogRevision: disabled.catalogRevision,
|
|
bundledSkillsDir: fixture.skillsDir,
|
|
revision: { provider: 1, resources: 2 },
|
|
})).resolves.toMatchObject({ skillIds: ['grilling'], catalogRevision: 17 });
|
|
|
|
enabled = true;
|
|
const restored = await resolve();
|
|
expect(restored.effectiveSkillIds).toEqual(assignedSkillIds);
|
|
expect(restored.tools).toHaveLength(10);
|
|
|
|
});
|
|
|
|
it('keeps the platform Chinese-language prompt when the Agent prompt is empty', async () => {
|
|
const fixture = await fixtureRoot();
|
|
const resources = await materializePiAgentResources({
|
|
userDataDir: fixture.userDataDir,
|
|
projectId: 'project-1',
|
|
agentId: 'agent-1',
|
|
prompt: '',
|
|
skillEntries: [],
|
|
catalogRevision: 0,
|
|
bundledSkillsDir: fixture.skillsDir,
|
|
revision: { provider: 1, resources: 1 },
|
|
});
|
|
|
|
expect(await readFile(resources.promptPath, 'utf8')).toBe('');
|
|
expect(await readFile(resources.languagePromptPath, 'utf8')).toBe(MAKELORE_DEFAULT_LANGUAGE_PROMPT);
|
|
});
|
|
|
|
it('rejects unknown skills and unsafe managed path segments', async () => {
|
|
const fixture = await fixtureRoot();
|
|
await expect(resolveExplicitCodingSkillPaths(fixture.skillsDir, [
|
|
{ id: 'not-bundled', entryPath: 'not-bundled/SKILL.md' },
|
|
])).rejects.toThrow('Bundled coding Skill entry is not a file');
|
|
await expect(materializePiAgentResources({
|
|
userDataDir: fixture.userDataDir,
|
|
projectId: '../outside',
|
|
agentId: 'agent-1',
|
|
prompt: '',
|
|
skillEntries: [],
|
|
catalogRevision: 1,
|
|
bundledSkillsDir: fixture.skillsDir,
|
|
revision: { provider: 1, resources: 1 },
|
|
})).rejects.toThrow('Project id');
|
|
});
|
|
|
|
it('uses the same vendor-neutral resource source in development and packaged apps', () => {
|
|
expect(resolveBundledCodingSkillsDir({
|
|
isPackaged: false,
|
|
resourcesPath: 'C:\\Program Files\\Makelore\\resources',
|
|
appPath: 'D:\\source\\makelore',
|
|
})).toBe(path.join('D:\\source\\makelore', 'resources', 'coding-skills'));
|
|
expect(resolveBundledCodingSkillsDir({
|
|
isPackaged: true,
|
|
resourcesPath: 'C:\\Program Files\\Makelore\\resources',
|
|
appPath: 'unused',
|
|
})).toBe(path.join('C:\\Program Files\\Makelore\\resources', 'resources', 'coding-skills'));
|
|
expect(getPiManagedPaths('D:\\user-data').rootDir)
|
|
.toBe(path.join(path.resolve('D:\\user-data'), 'coding-runtime', 'pi'));
|
|
});
|
|
|
|
it('packages coding skills through the vendor-neutral resources bundle only', async () => {
|
|
const builder = YAML.parse(await readFile('electron-builder.yml', 'utf8')) as {
|
|
extraResources: Array<{ from: string; to: string }>;
|
|
};
|
|
expect(builder.extraResources).toContainEqual(expect.objectContaining({
|
|
from: 'resources/',
|
|
to: 'resources/',
|
|
}));
|
|
expect(builder.extraResources).not.toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ from: '.opencode/skills/' }),
|
|
expect.objectContaining({ to: 'course-skills/' }),
|
|
]));
|
|
});
|
|
});
|