feat(plugins): merge Skill management into plugins

This commit is contained in:
inman
2026-09-07 18:36:33 +08:00
parent bb8a19eecf
commit ab9f889233
49 changed files with 461 additions and 1218 deletions

View File

@@ -4,8 +4,8 @@ import path from 'node:path';
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
import { createCodingProjectConfigV2 } from '../../electron/coding-projects/project-config';
test.describe('Project configuration skills', () => {
test('does not expose the removed Superpowers feature and supports Skill details', async ({ launchElectronApp }) => {
test.describe('Project configuration plugin capabilities', () => {
test('merges Skill management into the plugin tabs and removes standalone local Skills', async ({ launchElectronApp }) => {
const parentPath = await mkdtemp(path.join(tmpdir(), 'makelore-project-configuration-e2e-'));
const app = await launchElectronApp({ skipSetup: true });
@@ -52,7 +52,7 @@ test.describe('Project configuration skills', () => {
const rect = card.getBoundingClientRect();
return { left: rect.left, top: rect.top, width: rect.width };
}));
expect(resourceCardGeometry).toHaveLength(4);
expect(resourceCardGeometry).toHaveLength(3);
expect(Math.max(...resourceCardGeometry.map(({ top }) => top)) - Math.min(...resourceCardGeometry.map(({ top }) => top))).toBeLessThanOrEqual(1);
expect(resourceCardGeometry.every(({ width }) => width > 0)).toBe(true);
expect(resourceCardGeometry.map(({ left }) => left)).toEqual([...resourceCardGeometry.map(({ left }) => left)].sort((a, b) => a - b));
@@ -60,7 +60,6 @@ test.describe('Project configuration skills', () => {
const drawerWidths: number[] = [];
for (const [resourceId, title] of [
['resource-card-models', '可用模型'],
['resource-card-skills', '可用技能'],
['resource-card-knowledge', '知识文件'],
['resource-card-plugins', '插件'],
] as const) {
@@ -77,7 +76,9 @@ test.describe('Project configuration skills', () => {
if (resourceId === 'resource-card-plugins') {
await expect(page.getByTestId('project-plugins-sheet')).toBeVisible();
await expect(drawer.getByTestId('plugins-page')).toBeVisible();
await expect(drawer.getByText('为你的智能体添加插件,拓展更多能力。')).toBeVisible();
await expect(drawer.getByText('统一管理云端下发的插件与 Skill,以及本机安装的能力。')).toBeVisible();
await expect(drawer.getByRole('tab', { name: /可选/ })).toBeVisible();
await expect(drawer.getByRole('tab', { name: /已安装/ })).toBeVisible();
await expect(drawer.getByRole('button', { name: /刷新/ })).toHaveCount(0);
await expect(drawer.getByRole('searchbox')).toHaveCount(0);
await expect(drawer.getByRole('combobox')).toHaveCount(0);
@@ -120,7 +121,7 @@ test.describe('Project configuration skills', () => {
enabled: true,
model: { accountId: 'moonshot', modelId: 'kimi-k2.6', thinkingLevel: 'off' },
modelResolution: 'resolved',
skillIds: [],
skillIds: ['agent-browser', 'grilling'],
responsibility: { mission: '验证智能体编辑弹窗。', owns: [], boundaries: [], collaborators: [], principles: [] },
prompt: '',
archivedAt: null,
@@ -135,18 +136,12 @@ test.describe('Project configuration skills', () => {
await expect(page.getByTestId('legacy-project-identity-card')).toHaveCount(0);
await expect(page.getByText(/项目 ID|项目身份|独立副本/u)).toHaveCount(0);
await page.getByTestId('resource-card-skills').click();
await expect(page.getByTestId('resource-card-skills')).toHaveCount(0);
await expect(page.getByTestId('project-agent-e2e-agent')).toContainText('0 项插件能力');
await expect(page.getByTestId('superpowers-card')).toHaveCount(0);
await expect(page.getByRole('switch', { name: '启用 Superpowers' })).toHaveCount(0);
await expect(page.getByTestId('skill-card-agent-browser')).toBeVisible();
await page.getByTestId('skill-card-agent-browser').click();
await expect(page.getByTestId('skill-detail-view')).toBeVisible();
await expect(page.getByTestId('skill-structure')).toContainText('SKILL.md');
await expect(page.getByTestId('skill-main-file')).toContainText('# Makelore 开发浏览器');
await page.getByRole('button', { name: '返回技能列表' }).locator('svg').click();
await expect(page.getByTestId('skill-card-agent-browser')).toBeVisible();
await expect(page.getByTestId('skill-card-agent-browser')).toHaveCount(0);
await expect(page.getByTestId('skill-card-game-engine')).toHaveCount(0);
await page.getByRole('button', { name: '关闭' }).click();
await expect(page.getByTestId('resource-card-publish')).toHaveCount(0);
await expect(page.getByRole('button', { name: '一键提交审核' })).toBeVisible();

View File

@@ -47,7 +47,7 @@ describe('AgentCreationDialog plugin Skill availability', () => {
onUpdate={onUpdate}
/>);
const checkbox = screen.getByRole('checkbox', { name: '绑定技能:Data Service' });
const checkbox = screen.getByRole('checkbox', { name: '绑定插件能力:Data Service' });
expect(checkbox).toBeChecked();
expect(checkbox).toBeDisabled();
expect(screen.getByText('插件未启用;现有分配会保留,但当前不可用。')).toBeVisible();

View File

@@ -134,7 +134,7 @@ describe('CodingCapabilityRegistry', () => {
expect(unavailable.tools).toEqual([]);
});
it('does not wait for policy refresh when child or parent core-only resources cannot consume plugins', async () => {
it('does not wait for policy refresh when child or legacy standalone assignments cannot consume plugins', async () => {
const refresh = vi.fn(() => new Promise<void>(() => {}));
const policyClient = { getState: () => policy, refresh };
const child = await registry({ policyClient }).resolveWorkerResources({
@@ -149,7 +149,7 @@ describe('CodingCapabilityRegistry', () => {
});
expect(child.tools).toEqual([]);
expect(coreOnly.effectiveSkillIds).toEqual(['grilling']);
expect(coreOnly.effectiveSkillIds).toEqual([]);
expect(refresh).not.toHaveBeenCalled();
});

View File

@@ -160,7 +160,7 @@ async function resolve(effective: EffectivePluginResolver, assignedSkillIds = ['
}
describe('effective plugin resolver', () => {
it('rejects Marketplace Skill ID collisions with core and other packages without changing project assignment', async () => {
it('accepts former local Skill IDs and rejects collisions between Marketplace packages', async () => {
const coreCollision: CodingPluginDefinition = {
...skillOnlyDefinition,
id: 'makelore.core-collision',
@@ -219,14 +219,20 @@ describe('effective plugin resolver', () => {
expect(result.effectiveSkillIds).toEqual(['agent-browser', 'shared-skill']);
expect(result.skillEntries).toEqual([
{ id: 'agent-browser', entryPath: 'agent-browser/SKILL.md' },
{
id: 'agent-browser',
entryPath: 'skills/agent-browser/SKILL.md',
packageRoot: 'C:/packages/core-collision-1',
},
{ id: 'shared-skill', entryPath: 'skills/shared/SKILL.md', packageRoot: 'C:/packages/collision-a-1' },
]);
expect(result.pluginReleaseIds).toEqual(['collision-a-1']);
expect(result.pluginReleaseIds).toEqual(['core-collision-1', 'collision-a-1']);
expect(result.unavailableReasons).toEqual(expect.arrayContaining([
expect.objectContaining({ pluginId: coreCollision.id, code: 'skill_owner_conflict' }),
expect.objectContaining({ pluginId: packageB.id, code: 'skill_owner_conflict' }),
]));
expect(result.unavailableReasons).not.toEqual(expect.arrayContaining([
expect.objectContaining({ pluginId: coreCollision.id, code: 'skill_owner_conflict' }),
]));
});
it('reserves every bundled Skill owner, including Data Service, before accepting Marketplace packages', async () => {

View File

@@ -114,7 +114,7 @@ async function gitOutput(root: string, ...args: string[]): Promise<string> {
}
describe('PI-105 product Host composition', () => {
it('projects managed skills and a stable command catalog without raw Pi fields', async () => {
it('ignores legacy standalone Skill assignments and projects a stable command catalog', async () => {
const root = await configuredProject();
const tools = productTools(root);
const host = createCodingProductHost({
@@ -131,15 +131,13 @@ describe('PI-105 product Host composition', () => {
});
const skills = await host.listSkills('builder');
expect(skills.filter(({ selected }) => selected).map(({ id }) => id)).toEqual([
'agent-browser', 'grilling',
]);
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).toContainEqual(expect.objectContaining({ name: 'agent-browser', source: 'skill' }));
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);

View File

@@ -71,43 +71,11 @@ describe('Makelore character scene partner profiles', () => {
expect(hostApiFetchMock.mock.calls.some(([path]) => String(path).includes('/api/coding/agents'))).toBe(false);
});
it('opens an installed Skill detail with its structure and main file', async () => {
const project = {
id: 'project-1',
path: 'D:/projects/project-1',
name: '详情测试项目',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
lastOpenedAt: '2026-01-01T00:00:00.000Z',
};
codingWorkspaceStore.setState({ activeProject: project, projects: [project] });
hostApiFetchMock.mockResolvedValue({
skills: [{
id: 'agent-browser',
name: 'agent-browser',
description: 'E2E browser debugging skill.',
location: 'D:/managed/skills/agent-browser/SKILL.md',
content: '# Browser debugging',
entries: [
{ path: 'references', type: 'directory' },
{ path: 'references/guide.md', type: 'file' },
{ path: 'SKILL.md', type: 'file' },
],
}],
});
it('routes Skill management through the merged plugin module', () => {
render(<MemoryRouter><CharacterScene /></MemoryRouter>);
fireEvent.click(screen.getByTestId('resource-card-skills'));
await screen.findByText('本地已安装');
fireEvent.click(screen.getByTestId('skill-library-card-agent-browser'));
expect(await screen.findByTestId('skill-detail-view')).toBeInTheDocument();
expect(screen.getByTestId('skill-structure')).toHaveTextContent('references/guide.md');
expect(screen.getByTestId('skill-main-file')).toHaveTextContent('# Browser debugging');
fireEvent.click(screen.getByRole('button', { name: '返回技能列表' }));
expect(await screen.findByTestId('skill-library-card-agent-browser')).toBeInTheDocument();
expect(screen.getByTestId('resource-card-plugins')).toHaveTextContent('可选与已安装');
expect(screen.queryByTestId('resource-card-skills')).not.toBeInTheDocument();
});
it('defers the automatic model refresh and applies it only after an explicit click', async () => {

View File

@@ -464,7 +464,7 @@ describe('Makelore Pi extension bundle', () => {
const worker = await host.registerWorker({
conversationId: 'conversation-tools', generation: 1, projectId: 'project-a',
projectPath: root,
skillEntries: [{ id: 'agent-browser', entryPath: 'agent-browser/SKILL.md' }],
skillEntries: [],
catalogRevision: 3,
extensionsDir: root,
});
@@ -507,7 +507,7 @@ describe('Makelore Pi extension bundle', () => {
)).resolves.toMatchObject({
details: {
schema: 'runtime-context.v1',
skills: expect.arrayContaining([expect.objectContaining({ id: 'agent-browser', selected: true })]),
skills: [],
},
});
const browserResult = await tools.get('agent_browser')?.execute?.(

View File

@@ -183,8 +183,8 @@ describe('managed Pi worker opener', () => {
accountSessionId: 'account-a\u00001',
projectId: 'project-a',
pluginReleaseIds: Object.freeze(['plugin-release-a']),
effectiveSkillIds: Object.freeze(['grilling']),
skillEntries: Object.freeze([{ id: 'grilling', entryPath: 'grilling/SKILL.md' }]),
effectiveSkillIds: Object.freeze([]),
skillEntries: Object.freeze([]),
toolDefinitions: Object.freeze(DATA_SERVICE_PLUGIN_DEFINITION.tools),
runtimePolicies: Object.freeze([]),
unavailableReasons: Object.freeze([]),
@@ -193,8 +193,8 @@ describe('managed Pi worker opener', () => {
resolveWorkerResources: vi.fn(async () => ({
catalogRevision: 1,
pluginIds: [],
effectiveSkillIds: ['grilling'],
skillEntries: [{ id: 'grilling', entryPath: 'grilling/SKILL.md' }],
effectiveSkillIds: [],
skillEntries: [],
tools: DATA_SERVICE_PLUGIN_DEFINITION.tools,
effectiveSnapshot,
})),
@@ -286,7 +286,7 @@ describe('managed Pi worker opener', () => {
for (const options of processOptions) {
const argv = JSON.stringify(options.additionalArgs);
expect(argv).toContain('--system-prompt');
expect(argv).toContain('grilling');
expect(argv).not.toContain('grilling');
expect(argv).toContain('--session-id');
expect(argv).toContain('--extension');
expect(argv).toContain('makelore-runtime-v6.mjs');

View File

@@ -93,7 +93,6 @@ async function bundledResourceFixture({ schemaVersion = 1, includeSdkAssets = tr
};
await mkdir(path.join(pluginRoot, 'com.makelore'), { recursive: true });
await mkdir(path.join(pluginRoot, 'skills', 'example'), { recursive: true });
await mkdir(path.join(sourceResources, 'coding-skills', 'agent-browser'), { recursive: true });
await writeFile(path.join(pluginRoot, 'plugin.json'), JSON.stringify({
name: 'example.plugin',
version: '1.0.0',
@@ -106,7 +105,6 @@ async function bundledResourceFixture({ schemaVersion = 1, includeSdkAssets = tr
await writeFile(path.join(pluginRoot, 'skills', 'example', 'assets', 'sdk.ts'), 'export {};\n');
await writeFile(path.join(pluginRoot, 'skills', 'example', 'assets', 'sdk.js'), 'export {};\n');
}
await writeFile(path.join(sourceResources, 'coding-skills', 'agent-browser', 'SKILL.md'), '# Browser\n');
const resourcesDirectory = path.join(root, 'packaged');
await cp(sourceResources, path.join(resourcesDirectory, 'resources'), { recursive: true });
return {
@@ -192,7 +190,7 @@ describe('final Pi product artifact verification', () => {
});
});
it('proves bundled plugin manifests, Skills, SDK assets, catalog, and core resources', async () => {
it('proves bundled plugin manifests, Skills, SDK assets, and the absence of standalone Skill resources', async () => {
const fixture = await bundledResourceFixture();
await expect(verifyBundledCodingPluginResources(fixture)).resolves.toMatchObject({
root: 'resources/coding-plugins',
@@ -209,11 +207,27 @@ describe('final Pi product artifact verification', () => {
tools: ['example_read'],
}],
catalog: { adapters: ['example-adapter'], tools: ['example_read'], packagedInAsar: true },
coreResources: { root: 'resources/coding-skills', skills: ['agent-browser'] },
coreResources: { root: null, files: [], skills: [] },
result: 'pass',
});
});
it('rejects any source or packaged standalone Skill directory', async () => {
const sourceFixture = await bundledResourceFixture();
await mkdir(path.join(sourceFixture.projectRoot, 'resources', 'coding-skills', 'legacy'), {
recursive: true,
});
await expect(verifyBundledCodingPluginResources(sourceFixture))
.rejects.toThrow('Standalone coding Skill resources must not be bundled');
const packagedFixture = await bundledResourceFixture();
await mkdir(path.join(packagedFixture.resourcesDirectory, 'resources', 'coding-skills'), {
recursive: true,
});
await expect(verifyBundledCodingPluginResources(packagedFixture))
.rejects.toThrow('Standalone coding Skill resources must not be bundled');
});
it('accepts schema-2 hosted plugins whose implementation has no client SDK assets', async () => {
const fixture = await bundledResourceFixture({ schemaVersion: 2, includeSdkAssets: false });
await expect(verifyBundledCodingPluginResources(fixture)).resolves.toMatchObject({

View File

@@ -201,27 +201,32 @@ describe('PI-090 product tools', () => {
)).rejects.toThrow('project-relative');
});
it('projects only bundled selected skills and safe command metadata', async () => {
it('projects only Plugin-delivered selected Skills and safe command metadata', async () => {
const pluginRoot = path.resolve('resources/coding-plugins/data-service');
const skills = await listProductCodingSkills(
path.resolve('resources/coding-skills'),
['agent-browser', 'grilling'],
['data-service'],
[{
id: 'data-service',
pluginId: 'makelore.data-service',
directory: pluginRoot,
entryPath: 'skills/data-service/SKILL.md',
}],
);
expect(skills.filter(({ selected }) => selected).map(({ id }) => id)).toEqual([
'agent-browser', 'grilling',
]);
expect(skills[0]?.location).toBe('resources/coding-skills/agent-browser');
expect(JSON.stringify(skills)).not.toContain(path.resolve('resources/coding-skills'));
expect(skills.filter(({ selected }) => selected).map(({ id }) => id)).toEqual(['data-service']);
expect(skills[0]?.location).toBe(pluginRoot);
expect(JSON.stringify(skills)).not.toContain('resources/coding-skills');
const commands = buildProductCodingCommandCatalog(skills, [
{ name: 'custom', description: 'Custom Pi command' },
{ name: 'compact', description: 'Must not shadow Makelore' },
]);
expect(commands).toContainEqual(expect.objectContaining({ name: 'compact', source: 'makelore' }));
expect(commands).toContainEqual(expect.objectContaining({ name: 'custom', source: 'pi' }));
expect(commands).toContainEqual(expect.objectContaining({ name: 'agent-browser', source: 'skill' }));
expect(commands).toContainEqual(expect.objectContaining({ name: 'data-service', source: 'skill' }));
expect(commands.some(({ name }) => name === 'planning-with-files')).toBe(false);
await expect(listProductCodingSkills(
path.resolve('resources/coding-skills'), ['not-installed'],
)).rejects.toThrow('Unknown bundled coding skill');
)).resolves.toEqual([]);
});
it('hides unassigned disabled plugin Skills and retains assigned ones as ineffective', async () => {

View File

@@ -62,7 +62,7 @@ async function fixtureRoot(): Promise<{
}
describe('Pi managed resource loader', () => {
it('materializes only managed prompt and explicitly selected bundled skills', async () => {
it('materializes only managed prompt and explicitly paired package Skills', async () => {
const fixture = await fixtureRoot();
const prompt = 'PRIVATE PARTNER PROMPT CONTENT';
const effectiveSnapshot: EffectivePluginSnapshot = {
@@ -70,7 +70,9 @@ describe('Pi managed resource loader', () => {
projectId: 'project-1',
pluginReleaseIds: ['release-a'],
effectiveSkillIds: ['grilling'],
skillEntries: [{ id: 'grilling', entryPath: 'grilling/SKILL.md' }],
skillEntries: [{
id: 'grilling', entryPath: 'grilling/SKILL.md', packageRoot: fixture.skillsDir,
}],
toolDefinitions: [],
runtimePolicies: [],
unavailableReasons: [],
@@ -81,8 +83,8 @@ describe('Pi managed resource loader', () => {
agentId: 'agent-1',
prompt,
skillEntries: [
{ id: 'grilling', entryPath: 'grilling/SKILL.md' },
{ id: 'grilling', entryPath: 'grilling/SKILL.md' },
{ id: 'grilling', entryPath: 'grilling/SKILL.md', packageRoot: fixture.skillsDir },
{ id: 'grilling', entryPath: 'grilling/SKILL.md', packageRoot: fixture.skillsDir },
],
catalogRevision: 11,
bundledSkillsDir: fixture.skillsDir,
@@ -96,7 +98,7 @@ describe('Pi managed resource loader', () => {
expect(MAKELORE_DEFAULT_LANGUAGE_PROMPT).toContain('可见的思考过程');
expect(resources.skillIds).toEqual(['grilling']);
expect(resources.skillEntries).toEqual([
{ id: 'grilling', entryPath: 'grilling/SKILL.md' },
{ id: 'grilling', entryPath: 'grilling/SKILL.md', packageRoot: fixture.skillsDir },
]);
expect(resources.catalogRevision).toBe(11);
expect(resources.skillPaths).toEqual([path.join(fixture.skillsDir, 'grilling', 'SKILL.md')]);
@@ -109,7 +111,9 @@ describe('Pi managed resource loader', () => {
promptFile: 'agent-1.md',
languagePromptFile: 'agent-1.language.md',
skillIds: ['grilling'],
skillEntries: [{ id: 'grilling', entryPath: 'grilling/SKILL.md' }],
skillEntries: [{
id: 'grilling', entryPath: 'grilling/SKILL.md', packageRoot: fixture.skillsDir,
}],
catalogRevision: 11,
effectivePluginSnapshot: effectiveSnapshot,
revision: { provider: 3, resources: 7 },
@@ -129,7 +133,9 @@ describe('Pi managed resource loader', () => {
projectId: 'project-1',
agentId: 'agent-1',
prompt,
skillEntries: [{ id: 'agent-browser', entryPath: 'agent-browser/SKILL.md' }],
skillEntries: [{
id: 'agent-browser', entryPath: 'agent-browser/SKILL.md', packageRoot: fixture.skillsDir,
}],
catalogRevision: 12,
bundledSkillsDir: fixture.skillsDir,
revision: { provider: 1, resources: 1 },
@@ -183,6 +189,14 @@ describe('Pi managed resource loader', () => {
expect(resources.catalogRevision).toBe(13);
});
it('never resolves a Skill from the retired standalone resource root', async () => {
const fixture = await fixtureRoot();
await expect(resolveExplicitCodingSkillPaths(fixture.skillsDir, [
{ id: 'grilling', entryPath: 'grilling/SKILL.md' },
])).rejects.toThrow('Plugin Skill entry is not a file');
});
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');
@@ -210,7 +224,7 @@ describe('Pi managed resource loader', () => {
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 () => {
it('ignores a legacy standalone assignment and restores a disabled plugin Skill after re-enable', async () => {
const fixture = await fixtureRoot();
const assignedSkillIds = ['grilling', 'data-service'];
let enabled = true;
@@ -260,13 +274,13 @@ describe('Pi managed resource loader', () => {
});
const first = await resolve();
expect(first.effectiveSkillIds).toEqual(assignedSkillIds);
expect(first.effectiveSkillIds).toEqual(['data-service']);
expect(first.tools).toHaveLength(10);
enabled = false;
const disabled = await resolve();
expect(assignedSkillIds).toEqual(['grilling', 'data-service']);
expect(disabled.effectiveSkillIds).toEqual(['grilling']);
expect(disabled.effectiveSkillIds).toEqual([]);
expect(disabled.tools).toEqual([]);
await expect(materializePiAgentResources({
userDataDir: fixture.userDataDir,
@@ -277,11 +291,11 @@ describe('Pi managed resource loader', () => {
catalogRevision: disabled.catalogRevision,
bundledSkillsDir: fixture.skillsDir,
revision: { provider: 1, resources: 2 },
})).resolves.toMatchObject({ skillIds: ['grilling'], catalogRevision: 17 });
})).resolves.toMatchObject({ skillIds: [], catalogRevision: 17 });
enabled = true;
const restored = await resolve();
expect(restored.effectiveSkillIds).toEqual(assignedSkillIds);
expect(restored.effectiveSkillIds).toEqual(['data-service']);
expect(restored.tools).toHaveLength(10);
});
@@ -307,7 +321,7 @@ describe('Pi managed resource loader', () => {
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');
])).rejects.toThrow('Plugin Skill entry is not a file');
await expect(materializePiAgentResources({
userDataDir: fixture.userDataDir,
projectId: '../outside',

View File

@@ -137,7 +137,7 @@ describe('managed Pi subagent child opener', () => {
for (const options of processOptions) {
expect(options.additionalArgs).toContain('--no-session');
expect(options.additionalArgs).not.toContain('--session-id');
expect(options.additionalArgs?.some((argument) => argument.includes('grilling'))).toBe(true);
expect(options.additionalArgs?.some((argument) => argument.includes('grilling'))).toBe(false);
expect(JSON.stringify(options.additionalArgs)).not.toContain('PRIVATE CHILD PROMPT');
expect(JSON.stringify(options.additionalArgs)).not.toContain('provider-secret');
expect(Object.values(options.env ?? {})).toContain('provider-secret');

View File

@@ -1,17 +0,0 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
describe('planning-with-files Skill', () => {
it('requires planning files directly in the current project root', async () => {
const skill = await readFile(
path.join(process.cwd(), 'resources', 'coding-skills', 'planning-with-files', 'SKILL.md'),
'utf8',
);
expect(skill).toContain('<current-project-root>/task_plan.md');
expect(skill).toContain('不是本 Skill 的安装目录、进程工作目录、用户主目录');
expect(skill).toContain('直接在当前项目根目录创建三个文件');
expect(skill).toContain('只继续维护根目录文件');
});
});

View File

@@ -21,9 +21,9 @@ function LocationProbe() {
describe('plugin navigation cutover', () => {
it.each([
['/plugin-marketplace', `${PROJECT_SETTINGS_PLUGINS_PATH}?scope=all&source=official`],
['/plugin-marketplace', `${PROJECT_SETTINGS_PLUGINS_PATH}?scope=all&source=all&state=available`],
['/my-plugins', `${PROJECT_SETTINGS_PLUGINS_PATH}?scope=all&state=mine`],
['/project-plugins', `${PROJECT_SETTINGS_PLUGINS_PATH}?scope=project`],
['/project-plugins', `${PROJECT_SETTINGS_PLUGINS_PATH}?scope=project&state=mine`],
] as const)('replace-redirects %s to %s', async (legacy, canonical) => {
render(
<MemoryRouter initialEntries={['/previous', legacy]} initialIndex={1}>

View File

@@ -129,7 +129,7 @@ function devicePackage(packageId: string, displayName: string, enabled: boolean)
resolvedVersion: '1.0.0',
source: { kind: 'npm', requested: packageId, resolved: `${packageId}@1.0.0` },
kind: 'skill-only',
skillEntries: [],
skillEntries: [{ id: `${packageId}.skill`, entryPath: 'SKILL.md' }],
extensionEntries: [],
enabled,
confirmedExecutableCode: false,
@@ -138,6 +138,45 @@ function devicePackage(packageId: string, displayName: string, enabled: boolean)
}
describe('buildPluginWorkspaceProjection', () => {
it('places a user-installed loose Skill only in the installed tab', () => {
const looseSkill = {
...devicePackage('local-writing', 'Local Writing', true),
source: {
kind: 'file' as const,
requested: '/Users/example/.codex/skills/local-writing/SKILL.md',
resolved: '/Users/example/.codex/skills/local-writing/SKILL.md',
},
};
const base = {
catalog: null,
library: null,
marketplaceInstallations: {},
project: null,
activeProject: null,
devicePackages: { schemaVersion: 1 as const, generation: 2, packages: [looseSkill] },
};
const installed = buildPluginWorkspaceProjection(input({
...base,
filters: { scope: 'all', source: 'all', state: 'mine', search: '' },
}));
expect(installed.items).toEqual([
expect.objectContaining({
key: 'local:local-writing',
source: 'local',
delivery: 'local_installed',
local: expect.objectContaining({ kind: 'skill-only' }),
}),
]);
expect(installed.counts).toMatchObject({ available: 0, mine: 1 });
const available = buildPluginWorkspaceProjection(input({
...base,
filters: { scope: 'all', source: 'all', state: 'available', search: '' },
}));
expect(available.items).toEqual([]);
});
it('merges every official source into one item with only its legal commands', () => {
const result = buildPluginWorkspaceProjection(input());
@@ -562,11 +601,11 @@ describe('buildPluginWorkspaceProjection', () => {
}));
const all = projectAll({ scope: 'all', source: 'all', state: 'all', search: '' });
expect(all.counts).toEqual({ all: 6, available: 1, mine: 3, enabled: 2, update: 1, unavailable: 2 });
expect(all.counts).toEqual({ all: 6, available: 2, mine: 4, enabled: 2, update: 1, unavailable: 2 });
const expectedByState = {
available: ['official:makelore.available'],
mine: ['official:makelore.notes', 'local:alpha.package', 'local:zeta.package'],
available: ['official:makelore.available', 'official:makelore.suspended'],
mine: ['official:makelore.notes', 'local:alpha.package', 'local:zeta.package', 'retained:makelore.ghost'],
enabled: ['official:makelore.notes', 'local:alpha.package'],
update: ['official:makelore.notes'],
unavailable: ['official:makelore.suspended', 'retained:makelore.ghost'],

View File

@@ -6,19 +6,19 @@ import {
} from '@/pages/Plugins/plugin-workspace-query';
describe('plugin workspace URL state', () => {
it('uses the hydrated project presence for a bare route default', () => {
it('opens the cloud-backed available tab for a bare route', () => {
expect(parsePluginWorkspaceSearch(new URLSearchParams(), true)).toEqual({
scope: 'project', source: 'all', state: 'all', search: '', selectedKey: null,
scope: 'all', source: 'all', state: 'available', search: '', selectedKey: null,
});
expect(parsePluginWorkspaceSearch(new URLSearchParams(), false)).toEqual({
scope: 'all', source: 'all', state: 'all', search: '', selectedKey: null,
scope: 'all', source: 'all', state: 'available', search: '', selectedKey: null,
});
});
it('normalizes unknown values and a project scope without a project', () => {
const params = new URLSearchParams('scope=unknown&source=remote&state=broken&q=%20notes%20&plugin=notes');
expect(parsePluginWorkspaceSearch(params, true)).toEqual({
scope: 'project', source: 'all', state: 'all', search: 'notes', selectedKey: null,
scope: 'all', source: 'all', state: 'available', search: 'notes', selectedKey: null,
});
expect(parsePluginWorkspaceSearch(new URLSearchParams('scope=project'), false).scope).toBe('all');
});
@@ -31,39 +31,44 @@ describe('plugin workspace URL state', () => {
expect(fallback).toEqual({
filters: {
scope: 'all', source: 'all', state: 'all', search: '', selectedKey: null,
scope: 'all', source: 'all', state: 'available', search: '', selectedKey: null,
},
canonicalSearch: 'scope=all&source=all&state=all',
canonicalSearch: 'scope=all&source=all&state=available',
scopeFallback: true,
});
const afterProjectAppears = resolvePluginWorkspaceSearch(new URLSearchParams(), true);
expect(afterProjectAppears).toEqual({
filters: {
scope: 'project', source: 'all', state: 'all', search: '', selectedKey: null,
scope: 'all', source: 'all', state: 'available', search: '', selectedKey: null,
},
canonicalSearch: 'scope=project&source=all&state=all',
canonicalSearch: 'scope=all&source=all&state=available',
scopeFallback: false,
});
});
it('round-trips canonical filters and source-qualified selection', () => {
it('round-trips the installed tab and source-qualified selection', () => {
const serialized = serializePluginWorkspaceSearch({
scope: 'all',
source: 'local',
state: 'enabled',
state: 'mine',
search: ' web search ',
selectedKey: 'local:makelore_web_search',
});
expect(serialized.toString()).toBe(
'scope=all&source=local&state=enabled&q=web+search&plugin=local%3Amakelore_web_search',
'scope=all&source=local&state=mine&q=web+search&plugin=local%3Amakelore_web_search',
);
expect(parsePluginWorkspaceSearch(serialized, true)).toEqual({
scope: 'all',
source: 'local',
state: 'enabled',
state: 'mine',
search: 'web search',
selectedKey: 'local:makelore_web_search',
});
});
it('maps historical hidden filters onto one of the two visible tabs', () => {
expect(parsePluginWorkspaceSearch(new URLSearchParams('state=enabled'), true).state).toBe('mine');
expect(parsePluginWorkspaceSearch(new URLSearchParams('state=all'), true).state).toBe('available');
});
});

View File

@@ -182,6 +182,8 @@ function props(selected: PluginWorkspaceItem | null = null) {
sourceLoading: [],
dataService: null,
dataServicePending: {},
activeTab: 'mine' as const,
onTabChange: vi.fn(),
onSelect: vi.fn(),
onCommand: vi.fn(),
isCommandPending: vi.fn().mockReturnValue(false),
@@ -241,10 +243,15 @@ function preparePluginsRouteState() {
describe('PluginsView', () => {
it('renders a direct Skill-like list in the project drawer without filters or refresh', () => {
render(<MemoryRouter><PluginsView {...props()} embedded /></MemoryRouter>);
const view = props();
render(<MemoryRouter><PluginsView {...view} embedded /></MemoryRouter>);
expect(screen.getByTestId('plugins-page')).toBeVisible();
expect(screen.queryByRole('heading', { name: '插件', level: 1 })).not.toBeInTheDocument();
expect(screen.getByRole('tab', { name: /可选/ })).toHaveAttribute('aria-selected', 'false');
expect(screen.getByRole('tab', { name: /已安装/ })).toHaveAttribute('aria-selected', 'true');
fireEvent.mouseDown(screen.getByRole('tab', { name: /可选/ }), { button: 0, ctrlKey: false });
expect(view.onTabChange).toHaveBeenCalledWith('available');
expect(screen.getByTestId('plugin-list')).toHaveClass('space-y-3');
expect(screen.queryByRole('button', { name: /刷新/ })).not.toBeInTheDocument();
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument();
@@ -252,6 +259,7 @@ describe('PluginsView', () => {
expect(screen.getByRole('button', { name: '查看开发数据服务详情' })).toBeVisible();
expect(screen.getByRole('button', { name: '开发数据服务:已添加到项目' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Pi Web Search:已安装' })).toBeDisabled();
expect(screen.getByText('Skill + 插件')).toBeVisible();
});
it('keeps the production URL and fallback notice aligned without exposing query controls', async () => {
@@ -263,7 +271,7 @@ describe('PluginsView', () => {
);
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
'/project-config/plugins?scope=all&source=all&state=all',
'/project-config/plugins?scope=all&source=all&state=available',
));
expect(screen.queryByLabelText('范围')).not.toBeInTheDocument();
expect(screen.getByText('当前没有打开的项目,已显示全部插件。')).toBeVisible();
@@ -278,25 +286,25 @@ describe('PluginsView', () => {
act(() => codingWorkspaceStore.setState({ activeProjectId: activeProject.id, activeProject }));
fireEvent.click(screen.getByRole('button', { name: '打开裸插件地址' }));
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
'/project-config/plugins?scope=project&source=all&state=all',
'/project-config/plugins?scope=all&source=all&state=available',
));
expect(screen.queryByLabelText('范围')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '打开非法插件地址' }));
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
'/project-config/plugins?scope=project&source=all&state=all',
'/project-config/plugins?scope=all&source=all&state=available',
));
expect(screen.queryByLabelText('范围')).not.toBeInTheDocument();
act(() => codingWorkspaceStore.setState({ activeProjectId: null, activeProject: null }));
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
'/project-config/plugins?scope=all&source=all&state=all',
'/project-config/plugins?scope=all&source=all&state=available',
));
expect(screen.queryByLabelText('范围')).not.toBeInTheDocument();
expect(screen.getByText('当前没有打开的项目,已显示全部插件。')).toBeVisible();
expect(screen.queryByText('当前没有打开的项目,已显示全部插件。')).not.toBeInTheDocument();
});
it('keeps available rows visible when individual sources fail to load', async () => {
it('keeps installed rows visible when individual sources fail to load', async () => {
const activeProject = {
id: 'project-a',
name: 'Project A',
@@ -344,7 +352,7 @@ describe('PluginsView', () => {
vi.spyOn(devicePackageStore.getState(), 'load').mockResolvedValue(undefined);
vi.spyOn(codingPluginsStore.getState(), 'load').mockResolvedValue(undefined);
render(<MemoryRouter initialEntries={['/project-config/plugins?scope=project&source=all&state=all']}><Plugins /></MemoryRouter>);
render(<MemoryRouter initialEntries={['/project-config/plugins?scope=project&source=all&state=mine']}><Plugins /></MemoryRouter>);
for (const name of ['开发数据服务', 'Pi Web Search', 'makelore.retained']) {
expect(await screen.findByRole('heading', { name })).toBeVisible();
@@ -353,6 +361,73 @@ describe('PluginsView', () => {
expect(screen.queryByText('缓存')).not.toBeInTheDocument();
});
it('clears legacy source filters when switching to installed so local Skills remain visible', async () => {
preparePluginsRouteState();
pluginMarketplaceStore.setState({
catalog: {
items: [{
pluginId: 'makelore.notes',
title: 'Notes',
summary: 'Write project notes',
category: 'productivity',
tags: ['notes'],
providerDisplayName: 'MakeLore',
runtimeKind: 'skill_only',
runtimeStatus: 'enabled',
acquisition: 'free',
usageBilling: 'included',
includedOperationCount: 1,
meteredOperationCount: 0,
stableVersion: '1.0.0',
betaVersion: null,
}],
nextCursor: null,
total: 1,
catalogGeneration: 1,
etag: 'catalog-1',
pricingVersionId: null,
stale: false,
fetchedAt: 1,
},
});
devicePackageStore.setState({
index: {
schemaVersion: 1,
generation: 2,
packages: [{
...localItem.local!,
packageId: 'local-writing',
displayName: 'Local Writing',
source: {
kind: 'file',
requested: '/Users/example/.codex/skills/local-writing/SKILL.md',
resolved: '/Users/example/.codex/skills/local-writing/SKILL.md',
},
kind: 'skill-only',
skillEntries: [{ id: 'local-writing', entryPath: 'SKILL.md' }],
extensionEntries: [],
confirmedExecutableCode: false,
}],
},
});
render(
<MemoryRouter initialEntries={[
'/project-config/plugins?scope=all&source=official&state=available',
]}>
<PluginsRouteHarness />
</MemoryRouter>,
);
expect(await screen.findByRole('heading', { name: 'Notes' })).toBeVisible();
fireEvent.mouseDown(screen.getByRole('tab', { name: /已安装/ }), { button: 0, ctrlKey: false });
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
'/project-config/plugins?scope=all&source=all&state=mine',
));
expect(await screen.findByRole('heading', { name: 'Local Writing' })).toBeVisible();
expect(screen.getByText('Skill')).toBeVisible();
});
it('attempts a rejected official detail load once per open and retries only after close and reopen', async () => {
preparePluginsRouteState();
pluginMarketplaceStore.setState({
@@ -393,7 +468,7 @@ describe('PluginsView', () => {
render(
<MemoryRouter initialEntries={[
'/project-config/plugins?scope=all&source=all&state=all&plugin=official%3Amakelore.notes',
'/project-config/plugins?scope=all&source=all&state=available&plugin=official%3Amakelore.notes',
]}>
<Plugins />
</MemoryRouter>,
@@ -431,7 +506,7 @@ describe('PluginsView', () => {
render(<MemoryRouter><PluginsView {...view} /></MemoryRouter>);
expect(screen.getByRole('heading', { name: '插件' })).toBeVisible();
expect(screen.getByText('为你的智能体添加插件,拓展更多能力。')).toBeVisible();
expect(screen.getByText('插件与 Skill 统一管理;云端能力在这里下发,本机安装的 Skill 也会集中显示。')).toBeVisible();
expect(screen.getByText('开发数据服务')).toBeVisible();
expect(screen.getByText('Pi Web Search')).toBeVisible();
expect(screen.getByRole('button', { name: '查看开发数据服务详情' })).toBeVisible();
@@ -471,7 +546,7 @@ describe('PluginsView', () => {
view.projection = {
items: [scaffoldItem],
selected: scaffoldItem,
counts: { all: 1, available: 0, mine: 0, enabled: 0, update: 0, unavailable: 0 },
counts: { all: 1, available: 1, mine: 0, enabled: 0, update: 0, unavailable: 0 },
notices: [],
};
@@ -531,7 +606,7 @@ describe('PluginsView', () => {
render(<MemoryRouter><PluginsView {...props(scriptSkill)} /></MemoryRouter>);
const dialog = screen.getByRole('dialog', { name: 'MakeLore 项目初始化' });
expect(dialog).toHaveTextContent('为智能体提供可在本机使用的扩展能力。');
expect(dialog).toHaveTextContent('这是用户自行安装在本机的 Skill,已统一收纳在插件模块中。');
expect(dialog).not.toHaveTextContent('Skill 脚本');
expect(dialog).not.toHaveTextContent('Pi extension');
expect(dialog).not.toHaveTextContent('完整桌面权限');

View File

@@ -1,36 +0,0 @@
import { describe, expect, it } from 'vitest';
import { getSkillDisplayInfo } from '@/lib/skill-display';
describe('skill display metadata', () => {
it('keeps runtime ids internal and returns Chinese-only learner-facing copy', () => {
expect(getSkillDisplayInfo('agent-browser')).toEqual({
name: '开发浏览器',
description: '打开、查看或调试本地及公网网页,读取 Console、Network、DOM 和样式信息。',
});
expect(getSkillDisplayInfo('data-service')).toEqual({
name: '开发数据服务',
description: '为本地项目配置受控的开发数据,并在预览中验证真实读写。',
});
expect(getSkillDisplayInfo('frontend-slides')).toEqual({
name: '项目演示',
description: '把项目内容整理成可播放的 16:9 HTML 幻灯片,不生成 PPTX 或云端发布。',
});
expect(getSkillDisplayInfo('grilling')).toEqual({
name: '方案质询',
description: '在重要工作开始前逐项澄清目标、范围和关键取舍,确认后再执行。',
});
expect(getSkillDisplayInfo('planning-with-files')).toEqual({
name: '项目规划',
description: '为复杂任务维护可恢复的计划、发现和进展,直接记录在当前项目根目录。',
});
});
it('does not expose an unknown external runtime id', () => {
const display = getSkillDisplayInfo('repo-audit');
expect(display).toEqual({
name: '自定义技能',
description: '用于扩展智能体工作能力的自定义技能。',
});
expect(`${display.name}${display.description}`).not.toContain('repo-audit');
});
});