fix(coding): remediate ML-07 plugin authority
This commit is contained in:
59
tests/unit/agent-creation-dialog.test.tsx
Normal file
59
tests/unit/agent-creation-dialog.test.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AgentCreationDialog } from '@/components/coding/AgentCreationDialog';
|
||||
import type { CodingProjectAgent } from '@/types/coding-project';
|
||||
|
||||
const agent: CodingProjectAgent = {
|
||||
id: 'builder',
|
||||
avatarId: 'avatar-01',
|
||||
roleName: '实现者',
|
||||
name: 'Builder',
|
||||
builtIn: false,
|
||||
enabled: true,
|
||||
model: { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'medium' },
|
||||
modelResolution: 'resolved',
|
||||
skillIds: ['data-service'],
|
||||
responsibility: {
|
||||
mission: 'Implement changes', owns: [], boundaries: [], collaborators: [], principles: [],
|
||||
},
|
||||
prompt: '',
|
||||
archivedAt: null,
|
||||
pinned: false,
|
||||
createdAt: '2026-08-27T00:00:00Z',
|
||||
updatedAt: '2026-08-27T00:00:00Z',
|
||||
};
|
||||
|
||||
describe('AgentCreationDialog plugin Skill availability', () => {
|
||||
it('shows a retained disabled assignment as unavailable and persists it unchanged', async () => {
|
||||
const onUpdate = vi.fn(async () => undefined);
|
||||
render(<AgentCreationDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
agent={agent}
|
||||
modelOptions={[{
|
||||
key: JSON.stringify(['account-a', 'model-a']),
|
||||
accountId: 'account-a',
|
||||
modelId: 'model-a',
|
||||
label: 'Account A / Model A',
|
||||
}]}
|
||||
skills={[{
|
||||
id: 'data-service',
|
||||
name: 'Data Service',
|
||||
description: 'Project data tools',
|
||||
available: false,
|
||||
effective: false,
|
||||
}]}
|
||||
onUpdate={onUpdate}
|
||||
/>);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: '绑定技能:Data Service' });
|
||||
expect(checkbox).toBeChecked();
|
||||
expect(checkbox).toBeDisabled();
|
||||
expect(screen.getByText('插件未启用;现有分配会保留,但当前不可用。')).toBeVisible();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存伙伴' }));
|
||||
await waitFor(() => expect(onUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ skillIds: ['data-service'] }),
|
||||
));
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,20 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { PluginPolicyClientState } from '../../electron/services/plugin-policy-client';
|
||||
import {
|
||||
CodingCapabilityRegistryImpl,
|
||||
} from '../../electron/coding-plugins/registry';
|
||||
import type { CodingPluginAdapter } from '../../electron/coding-plugins/registry';
|
||||
import { createDataServicePluginAdapter } from '../../electron/coding-plugins/adapters/data-service';
|
||||
import {
|
||||
createDataServiceOperations,
|
||||
DataServiceCloudClient,
|
||||
type DataServiceOperations,
|
||||
} from '../../electron/services/data-service-client';
|
||||
import { DATA_SERVICE_PLUGIN_DEFINITION } from '../../shared/coding-plugins';
|
||||
import { productToolDetailsOfResult } from '../../shared/coding-conversation-product-tool-protocol';
|
||||
import type { DataServiceErrorContext, DataServiceHostResult } from '../../shared/data-service';
|
||||
|
||||
const policy: PluginPolicyClientState = {
|
||||
status: 'current',
|
||||
@@ -80,6 +89,7 @@ function registry(overrides: Partial<ConstructorParameters<typeof CodingCapabili
|
||||
policyClient: { getState: () => policy },
|
||||
getEnabledPluginIds: async () => ['makelore.data-service'],
|
||||
adapters: [adapter()],
|
||||
definitions: [DATA_SERVICE_PLUGIN_DEFINITION],
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
@@ -150,4 +160,124 @@ describe('CodingCapabilityRegistry', () => {
|
||||
});
|
||||
expect(JSON.stringify(invalidIdentity)).not.toMatch(/[0-9a-f]{8}-[0-9a-f]{4}/i);
|
||||
});
|
||||
|
||||
it('keeps the replay identity and parses bounded Data Service faults for all ten tools', async () => {
|
||||
const fault = (
|
||||
code: string,
|
||||
context?: DataServiceErrorContext,
|
||||
retryAfter?: number,
|
||||
): DataServiceHostResult<never> => ({
|
||||
success: false,
|
||||
status: code === 'rate_limited' ? 429 : code === 'document_too_large' ? 413 : 409,
|
||||
code,
|
||||
error: `Representative ${code} fault`,
|
||||
retryable: code === 'rate_limited',
|
||||
...(context ? { context } : {}),
|
||||
...(retryAfter === undefined ? {} : { retry_after_seconds: retryAfter }),
|
||||
data: null,
|
||||
});
|
||||
const operations: DataServiceOperations = {
|
||||
configure: vi.fn(async () => fault('quota_exceeded', {
|
||||
resource: 'collections', limit: 20, current: 20, attempted: 21,
|
||||
})),
|
||||
inspect: vi.fn(async () => fault('instance_not_found')),
|
||||
listProjects: vi.fn(async () => fault('quota_exceeded', {
|
||||
resource: 'instances', limit: 20, current: 20, attempted: 21,
|
||||
})),
|
||||
getDocument: vi.fn(async () => fault('document_not_found')),
|
||||
listDocuments: vi.fn(async () => fault('rate_limited', undefined, 17)),
|
||||
putDocument: vi.fn(async () => fault('document_too_large', {
|
||||
resource: 'bytes', actual: 98_305, limit: 98_304,
|
||||
})),
|
||||
deleteDocument: vi.fn(async () => fault('revision_conflict', { current_revision: 7 })),
|
||||
removeCollection: vi.fn(async () => fault('collection_not_found')),
|
||||
reset: vi.fn(async () => fault('quota_exceeded', {
|
||||
resource: 'documents', limit: 5_000, current: 5_000, attempted: 5_001,
|
||||
})),
|
||||
removeProject: vi.fn(async () => fault('instance_not_found')),
|
||||
};
|
||||
const capabilityRegistry = registry({
|
||||
adapters: [createDataServicePluginAdapter(operations)],
|
||||
});
|
||||
const inputs: Readonly<Record<string, unknown>> = {
|
||||
data_service_configure: { collections: ['todos'] },
|
||||
data_service_inspect: {},
|
||||
data_service_list_projects: {},
|
||||
data_service_get_document: { collection: 'todos', document_id: 'one' },
|
||||
data_service_list_documents: { collection: 'todos' },
|
||||
data_service_put_document: { collection: 'todos', document_id: 'one', data: {} },
|
||||
data_service_delete_document: { collection: 'todos', document_id: 'one', confirmed: true },
|
||||
data_service_remove_collection: { collection: 'todos', confirmed: true },
|
||||
data_service_reset: { confirmed: true },
|
||||
data_service_remove_project: { confirmed: true },
|
||||
};
|
||||
|
||||
for (const tool of DATA_SERVICE_PLUGIN_DEFINITION.tools) {
|
||||
const invoke = () => capabilityRegistry.invoke({
|
||||
toolName: tool.name,
|
||||
context,
|
||||
workerRole: 'parent' as const,
|
||||
effectiveSkillIds: context.skillIds,
|
||||
value: inputs[tool.name],
|
||||
});
|
||||
const first = await invoke();
|
||||
const replay = await invoke();
|
||||
expect(first.details.request_id).toBe('pi:run-a:resource-a');
|
||||
expect(replay.details.request_id).toBe(first.details.request_id);
|
||||
expect(productToolDetailsOfResult(first)).toEqual(first.details);
|
||||
}
|
||||
|
||||
expect(productToolDetailsOfResult(await capabilityRegistry.invoke({
|
||||
toolName: 'data_service_put_document', context, workerRole: 'parent',
|
||||
effectiveSkillIds: context.skillIds, value: inputs.data_service_put_document,
|
||||
}))).toMatchObject({ context: { resource: 'bytes', actual: 98_305, limit: 98_304 } });
|
||||
expect(productToolDetailsOfResult(await capabilityRegistry.invoke({
|
||||
toolName: 'data_service_delete_document', context, workerRole: 'parent',
|
||||
effectiveSkillIds: context.skillIds, value: inputs.data_service_delete_document,
|
||||
}))).toMatchObject({ context: { current_revision: 7 } });
|
||||
expect(productToolDetailsOfResult(await capabilityRegistry.invoke({
|
||||
toolName: 'data_service_list_documents', context, workerRole: 'parent',
|
||||
effectiveSkillIds: context.skillIds, value: inputs.data_service_list_documents,
|
||||
}))).toMatchObject({ retry_after_seconds: 17 });
|
||||
});
|
||||
|
||||
it('keeps the Pi identity through the one authoritative 401 refresh', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 401 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
id: 'one',
|
||||
data: { done: false },
|
||||
revision: 7,
|
||||
created_at: '2026-08-27T00:00:00Z',
|
||||
updated_at: '2026-08-27T00:00:00Z',
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
const getAccessToken = vi.fn(async (options?: { forceRefresh?: boolean }) => (
|
||||
options?.forceRefresh ? 'refreshed-token' : 'stale-token'
|
||||
));
|
||||
const client = new DataServiceCloudClient({ fetchImpl, getAccessToken });
|
||||
const operations = createDataServiceOperations({
|
||||
client,
|
||||
projects: {
|
||||
requireActiveRealProjectWithIdentity: vi.fn(async () => ({
|
||||
projectId: '11111111-1111-4111-8111-111111111111',
|
||||
})) as never,
|
||||
},
|
||||
});
|
||||
const result = await registry({
|
||||
adapters: [createDataServicePluginAdapter(operations)],
|
||||
}).invoke({
|
||||
toolName: 'data_service_get_document', context, workerRole: 'parent',
|
||||
effectiveSkillIds: context.skillIds,
|
||||
value: { collection: 'todos', document_id: 'one' },
|
||||
});
|
||||
|
||||
expect(result.details).toMatchObject({
|
||||
success: true,
|
||||
request_id: 'pi:run-a:resource-a',
|
||||
data: { id: 'one', revision: 7 },
|
||||
});
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
expect(getAccessToken).toHaveBeenNthCalledWith(1, { fetchImpl });
|
||||
expect(getAccessToken).toHaveBeenNthCalledWith(2, { fetchImpl, forceRefresh: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,6 +97,13 @@ describe('coding plugin bounded product service', () => {
|
||||
operations: [{
|
||||
id: 'get_document',
|
||||
billing: { mode: 'included', availability: 'available', notice: 'Included quota' },
|
||||
tool: {
|
||||
name: 'data_service_get_document',
|
||||
label: 'Data Service get document',
|
||||
description: 'Read a document from the active project.',
|
||||
mutation: 'read',
|
||||
permissions: ['project.data.read'],
|
||||
},
|
||||
}],
|
||||
}],
|
||||
settingsSurface: 'data-service',
|
||||
@@ -105,6 +112,69 @@ describe('coding plugin bounded product service', () => {
|
||||
expect(JSON.stringify(result)).not.toMatch(/secret upstream body|projectPath|entitlement_scope/u);
|
||||
});
|
||||
|
||||
it('projects the exact three capabilities and fourteen mixed-policy operations', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-policy-join-'));
|
||||
roots.push(root);
|
||||
await createCodingProjectMetadata(root, { now: '2026-08-27T00:00:00.000Z' });
|
||||
const projectPlugins = createProjectPluginService();
|
||||
await projectPlugins.enable(root, DATA_SERVICE_PLUGIN_DEFINITION.id);
|
||||
const capabilities = ['data-service.control', 'data-service.documents', 'data-service.preview'].map((capabilityId) => ({
|
||||
capability_id: capabilityId,
|
||||
operations: DATA_SERVICE_PLUGIN_DEFINITION.operations
|
||||
.filter((operation) => operation.capabilityId === capabilityId)
|
||||
.map(({ operation }, index) => ({
|
||||
operation,
|
||||
billing: capabilityId === 'data-service.documents'
|
||||
? { mode: 'external_account' as const, notice: 'Provider billed' }
|
||||
: index === 0 && capabilityId === 'data-service.control'
|
||||
? {
|
||||
mode: 'platform_metered' as const,
|
||||
status: 'billing_unavailable' as const,
|
||||
entitlement_scope: capabilityId,
|
||||
notice: 'Pricing unavailable',
|
||||
}
|
||||
: { mode: 'included' as const, entitlement_scope: null, notice: 'Included quota' },
|
||||
})),
|
||||
}));
|
||||
const service = createCodingProjectPluginService({
|
||||
projects: { getProject: vi.fn().mockResolvedValue({ id: 'local-a', path: root }) },
|
||||
projectPlugins,
|
||||
policyClient: {
|
||||
refresh: vi.fn(),
|
||||
getState: () => ({
|
||||
status: 'current' as const, revision: 1, lastVerifiedAt: 1,
|
||||
catalog: {
|
||||
schema_version: 1 as const, catalog_version: 'catalog-a', pricing_version: null,
|
||||
plugins: [{
|
||||
plugin_id: DATA_SERVICE_PLUGIN_DEFINITION.id,
|
||||
supported_contract_versions: [1], status: 'active' as const, capabilities,
|
||||
}],
|
||||
},
|
||||
}),
|
||||
},
|
||||
adapters: [{
|
||||
pluginId: DATA_SERVICE_PLUGIN_DEFINITION.id,
|
||||
inspect: vi.fn().mockResolvedValue({ status: 'ready' }), invoke: vi.fn(),
|
||||
}],
|
||||
definitions: [DATA_SERVICE_PLUGIN_DEFINITION],
|
||||
});
|
||||
|
||||
const item = (await service.list('local-a')).items[0];
|
||||
expect(item?.capabilities.map(({ id }) => id)).toEqual([
|
||||
'data-service.control', 'data-service.documents', 'data-service.preview',
|
||||
]);
|
||||
expect(item?.capabilities.flatMap(({ operations }) => operations)).toHaveLength(14);
|
||||
expect(item?.capabilities.find(({ id }) => id === 'data-service.preview')?.operations)
|
||||
.toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'get', tool: null, billing: expect.objectContaining({ mode: 'included' }) }),
|
||||
expect.objectContaining({ id: 'delete', tool: null }),
|
||||
]));
|
||||
expect(item?.capabilities.find(({ id }) => id === 'data-service.documents')?.operations[0]?.billing)
|
||||
.toMatchObject({ mode: 'external_account', availability: 'available' });
|
||||
expect(item?.capabilities.find(({ id }) => id === 'data-service.control')?.operations[0]?.billing)
|
||||
.toMatchObject({ mode: 'platform_metered', availability: 'unavailable' });
|
||||
});
|
||||
|
||||
it('deactivates only the requested adapter and isolates cleanup failures', async () => {
|
||||
const firstDeactivate = vi.fn().mockRejectedValue(new Error('cleanup failed'));
|
||||
const secondDeactivate = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
BUNDLED_CODING_PLUGIN_ROOTS,
|
||||
CodingPluginManifestError,
|
||||
loadBundledCodingPluginDefinitions,
|
||||
loadBundledCodingPluginDefinitionsSync,
|
||||
loadCodingPluginDefinition,
|
||||
parseCodingPluginManifest,
|
||||
parseAgentPluginsRootManifest,
|
||||
@@ -30,6 +31,7 @@ async function packageManifests(): Promise<{ root: Record<string, unknown>; capa
|
||||
describe('bundled coding plugin manifests', () => {
|
||||
it('loads the fixed Data Service package and immutable declarations', async () => {
|
||||
const definitions = await loadBundledCodingPluginDefinitions(path.resolve('resources/coding-plugins'));
|
||||
const startupDefinitions = loadBundledCodingPluginDefinitionsSync(path.resolve('resources/coding-plugins'));
|
||||
expect(BUNDLED_CODING_PLUGIN_ROOTS).toEqual(['data-service']);
|
||||
expect(resolveBundledCodingPluginRootPaths(path.resolve('resources/coding-plugins'))).toEqual([PACKAGE_ROOT]);
|
||||
expect(definitions).toHaveLength(1);
|
||||
@@ -40,6 +42,11 @@ describe('bundled coding plugin manifests', () => {
|
||||
skills: [{ id: 'data-service', entryPath: 'skills/data-service/SKILL.md' }],
|
||||
});
|
||||
expect(definitions[0]?.tools.map(({ name }) => name)).toEqual(DATA_SERVICE_TOOL_NAMES);
|
||||
expect(definitions).toEqual([DATA_SERVICE_PLUGIN_DEFINITION]);
|
||||
expect(startupDefinitions).toEqual(definitions);
|
||||
expect(Object.isFrozen(startupDefinitions)).toBe(true);
|
||||
expect(Object.isFrozen(startupDefinitions[0]?.operations)).toBe(true);
|
||||
expect(definitions[0]?.operations).toHaveLength(14);
|
||||
expect(Object.isFrozen(definitions[0])).toBe(true);
|
||||
expect(Object.isFrozen(definitions[0]?.tools)).toBe(true);
|
||||
expect(DATA_SERVICE_PLUGIN_DEFINITION.tools).toHaveLength(10);
|
||||
|
||||
@@ -16,6 +16,8 @@ function projection() {
|
||||
items: [{
|
||||
id: 'makelore.data-service',
|
||||
version: '1.0.0',
|
||||
contractVersion: 1,
|
||||
requiresBackend: true,
|
||||
displayName: '开发数据服务',
|
||||
description: '项目数据',
|
||||
enabled: false,
|
||||
@@ -27,6 +29,10 @@ function projection() {
|
||||
operations: [{
|
||||
id: 'inspect',
|
||||
billing: { mode: 'included', availability: 'available', notice: 'Fixed quotas apply' },
|
||||
tool: {
|
||||
name: 'data_service_inspect', label: 'Inspect', description: 'Inspect data',
|
||||
mutation: 'read', permissions: ['project.data.read'],
|
||||
},
|
||||
}],
|
||||
}],
|
||||
settingsSurface: 'data-service',
|
||||
|
||||
@@ -55,4 +55,60 @@ describe('coding plugins store', () => {
|
||||
await store.getState().load('other-project');
|
||||
expect(store.getState().dataService).toBeNull();
|
||||
});
|
||||
|
||||
it('commits only the latest project load success or error', async () => {
|
||||
const pending = new Map<string, {
|
||||
resolve: (value: ReturnType<typeof projection>) => void;
|
||||
reject: (reason: Error) => void;
|
||||
}>();
|
||||
const list = vi.fn((projectId: string) => new Promise<ReturnType<typeof projection>>((resolve, reject) => {
|
||||
pending.set(projectId, { resolve, reject });
|
||||
}));
|
||||
const store = createCodingPluginsStore({ list });
|
||||
|
||||
const first = store.getState().load('project-a');
|
||||
const second = store.getState().load('project-b');
|
||||
pending.get('project-b')?.resolve(projection(false, 'project-b'));
|
||||
await second;
|
||||
pending.get('project-a')?.resolve(projection(false, 'project-a'));
|
||||
await first;
|
||||
expect(store.getState()).toMatchObject({
|
||||
projectId: 'project-b', loadState: 'ready', error: null,
|
||||
});
|
||||
|
||||
const third = store.getState().load('project-a');
|
||||
const fourth = store.getState().load('project-b');
|
||||
pending.get('project-b')?.resolve(projection(false, 'project-b'));
|
||||
await fourth;
|
||||
pending.get('project-a')?.reject(new Error('late project-a failure'));
|
||||
await expect(third).rejects.toThrow('late project-a failure');
|
||||
expect(store.getState()).toMatchObject({
|
||||
projectId: 'project-b', loadState: 'ready', error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('retains ready Data Service usage when enabling an existing instance', async () => {
|
||||
const readyProjection = projection(true);
|
||||
readyProjection.items[0] = {
|
||||
...readyProjection.items[0], state: 'ready', backend: { status: 'ready' },
|
||||
};
|
||||
const existing = {
|
||||
instance_id: 'instance-1', project_id: 'cloud-project', collections: ['todos'],
|
||||
usage: { document_count: 3, total_bytes: 128 },
|
||||
limits: { max_collections: 20, max_documents: 1000, max_total_bytes: 20971520, max_document_bytes: 65536, list_default_limit: 50, list_max_limit: 100, list_max_data_bytes: 1048576, mutations_per_minute: 120 },
|
||||
created_at: '2026-08-27T00:00:00Z', updated_at: '2026-08-27T00:00:00Z',
|
||||
};
|
||||
const inspectDataService = vi.fn().mockResolvedValue({
|
||||
success: true, status: 200, code: null, error: null, retryable: false, data: existing,
|
||||
});
|
||||
const store = createCodingPluginsStore({
|
||||
setEnabled: vi.fn().mockResolvedValue(readyProjection), inspectDataService,
|
||||
});
|
||||
|
||||
await store.getState().setEnabled('local-project', 'makelore.data-service', true);
|
||||
|
||||
expect(inspectDataService).toHaveBeenCalledOnce();
|
||||
expect(store.getState().dataService).toEqual(existing);
|
||||
expect(store.getState().projection).toEqual(readyProjection);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ async function temporaryRoot(prefix: string): Promise<string> {
|
||||
return root;
|
||||
}
|
||||
|
||||
async function configuredProject(): Promise<string> {
|
||||
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, {
|
||||
@@ -44,7 +44,7 @@ async function configuredProject(): Promise<string> {
|
||||
name: 'Builder',
|
||||
model: null,
|
||||
modelResolution: 'required',
|
||||
skillIds: ['agent-browser', 'grilling'],
|
||||
skillIds: [...skillIds],
|
||||
responsibility: {
|
||||
mission: 'Implement changes', owns: [], boundaries: [], collaborators: [], principles: [],
|
||||
},
|
||||
@@ -58,11 +58,19 @@ async function configuredProject(): Promise<string> {
|
||||
return root;
|
||||
}
|
||||
|
||||
function productTools(root: string): PiProductTools {
|
||||
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',
|
||||
}],
|
||||
} : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -138,6 +146,39 @@ describe('PI-105 product Host composition', () => {
|
||||
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('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');
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { PiProductTools } from '../../electron/coding-runtime/pi/product-tools';
|
||||
import { productToolDetails } from '../../electron/coding-runtime/product-tool-protocol';
|
||||
import type { CodingCapabilityRegistry } from '../../electron/coding-plugins/registry';
|
||||
import type { DataServiceOperations } from '../../electron/services/data-service-client';
|
||||
import { DATA_SERVICE_PLUGIN_DEFINITION } from '../../shared/coding-plugins';
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const roots: string[] = [];
|
||||
@@ -220,6 +220,57 @@ describe('PI-090 product tools', () => {
|
||||
)).rejects.toThrow('Unknown bundled coding skill');
|
||||
});
|
||||
|
||||
it('hides unassigned disabled plugin Skills and retains assigned ones as ineffective', async () => {
|
||||
const source = [{
|
||||
id: 'data-service',
|
||||
directory: path.resolve('resources/coding-plugins/data-service/skills/data-service'),
|
||||
available: false,
|
||||
}];
|
||||
const unassigned = await listProductCodingSkills(
|
||||
path.resolve('resources/coding-skills'), [], source,
|
||||
);
|
||||
expect(unassigned.some(({ id }) => id === 'data-service')).toBe(false);
|
||||
|
||||
const retained = await listProductCodingSkills(
|
||||
path.resolve('resources/coding-skills'), ['data-service'], source,
|
||||
);
|
||||
expect(retained.find(({ id }) => id === 'data-service')).toMatchObject({
|
||||
selected: true, available: false, effective: false,
|
||||
});
|
||||
|
||||
const reenabled = await listProductCodingSkills(
|
||||
path.resolve('resources/coding-skills'), ['data-service'], [{ ...source[0], available: true }],
|
||||
);
|
||||
expect(reenabled.find(({ id }) => id === 'data-service')).toMatchObject({
|
||||
selected: true, available: true, effective: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('projects effective plugin Skills in the worker runtime context', async () => {
|
||||
const root = await temporaryRoot('makelore-pi-runtime-context-');
|
||||
const tools = new PiProductTools({
|
||||
browser: {} as AgentBrowserModule,
|
||||
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
||||
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
||||
pluginSkillSources: [{
|
||||
id: 'data-service',
|
||||
pluginId: 'makelore.data-service',
|
||||
directory: path.resolve('resources/coding-plugins/data-service/skills/data-service'),
|
||||
}],
|
||||
});
|
||||
const result = await tools.execute('runtime_context', {
|
||||
conversationId: 'conversation-a', runId: 'run-a', resourceId: 'resource-a',
|
||||
projectId: 'project-a', projectPath: root, skillIds: ['data-service'],
|
||||
}, {});
|
||||
|
||||
expect(result.details.schema).toBe('runtime-context.v1');
|
||||
if (result.details.schema !== 'runtime-context.v1') throw new Error('runtime context missing');
|
||||
expect(result.details.skills.find(({ id }) => id === 'data-service')).toMatchObject({
|
||||
id: 'data-service', selected: true, available: true, effective: true,
|
||||
});
|
||||
expect(result.details.commands).toContainEqual(expect.objectContaining({ skillId: 'data-service' }));
|
||||
});
|
||||
|
||||
it('accepts only safe versioned product detail projections', () => {
|
||||
expect(productToolDetails({
|
||||
schema: 'changed-file.v1', paths: ['src/app.ts', '.niancode/project.json'],
|
||||
@@ -351,28 +402,22 @@ describe('PI-090 product tools', () => {
|
||||
expect(JSON.stringify(result)).not.toContain('data:');
|
||||
});
|
||||
|
||||
it('dispatches all Data Service tools through the shared adapter and trusted project path', async () => {
|
||||
it('dispatches all Data Service tools only through the capability registry', async () => {
|
||||
const root = await temporaryRoot('makelore-pi-data-tools-');
|
||||
const response = (data: unknown) => ({
|
||||
success: true, status: 200, code: null, error: null, retryable: false, data,
|
||||
});
|
||||
const dataService = {
|
||||
configure: vi.fn().mockResolvedValue(response({ configured: true })),
|
||||
inspect: vi.fn().mockResolvedValue(response({ instance_id: 'instance-a' })),
|
||||
listProjects: vi.fn().mockResolvedValue(response({ items: [], total: 0, instance_limit: 20 })),
|
||||
getDocument: vi.fn().mockResolvedValue(response({ id: 'one', data: {}, revision: 1 })),
|
||||
listDocuments: vi.fn().mockResolvedValue(response({ items: [], next_cursor: null, limit: 50 })),
|
||||
putDocument: vi.fn().mockResolvedValue(response({ id: 'one', data: {}, revision: 1 })),
|
||||
deleteDocument: vi.fn().mockResolvedValue(response(null)),
|
||||
removeCollection: vi.fn().mockResolvedValue(response({ removed: true, usage: { document_count: 0, total_bytes: 0 } })),
|
||||
reset: vi.fn().mockResolvedValue(response({ instance_id: 'instance-a' })),
|
||||
removeProject: vi.fn().mockResolvedValue(response({ removed: true })),
|
||||
} as unknown as DataServiceOperations;
|
||||
const invoke = vi.fn().mockImplementation(({ toolName, context }) => Promise.resolve({
|
||||
content: [{ type: 'text', text: toolName }],
|
||||
details: {
|
||||
schema: 'makelore-capability.v1', operation: toolName,
|
||||
plugin_id: 'makelore.data-service', request_id: `pi:${context.runId}:${context.resourceId}`,
|
||||
billing: { mode: 'included', status: 'included' }, payload_schema: 'data-service.v1',
|
||||
success: true, status: 200, data: { delegated: true },
|
||||
},
|
||||
}));
|
||||
const tools = new PiProductTools({
|
||||
browser: {} as AgentBrowserModule,
|
||||
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
||||
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
||||
dataService,
|
||||
capabilityRegistry: { invoke } as unknown as CodingCapabilityRegistry,
|
||||
});
|
||||
const context = {
|
||||
conversationId: 'conversation-a', runId: 'run-a', resourceId: 'resource-a',
|
||||
@@ -400,27 +445,15 @@ describe('PI-090 product tools', () => {
|
||||
await tools.execute('data_service_reset', context, { confirmed: true });
|
||||
const removed = await tools.execute('data_service_remove_project', context, { confirmed: true });
|
||||
|
||||
expect(dataService.configure).toHaveBeenCalledWith({ collections: ['todos'] }, root);
|
||||
expect(dataService.inspect).toHaveBeenCalledWith(root);
|
||||
expect(dataService.listProjects).toHaveBeenCalledWith();
|
||||
expect(dataService.getDocument).toHaveBeenCalledWith({ collection: 'todos', document_id: 'one' }, root);
|
||||
expect(dataService.listDocuments).toHaveBeenCalledWith({
|
||||
collection: 'todos', limit: 50, cursor: 'cursor-a',
|
||||
}, root);
|
||||
expect(dataService.putDocument).toHaveBeenCalledWith({
|
||||
collection: 'todos', document_id: 'one', data: { done: false }, if_revision: 1,
|
||||
}, root);
|
||||
expect(dataService.deleteDocument).toHaveBeenCalledWith({
|
||||
collection: 'todos', document_id: 'one', if_revision: 1, confirmed: true,
|
||||
}, root);
|
||||
expect(dataService.removeCollection).toHaveBeenCalledWith({ collection: 'todos', confirmed: true }, root);
|
||||
expect(dataService.reset).toHaveBeenCalledWith({ confirmed: true }, root);
|
||||
expect(dataService.removeProject).toHaveBeenCalledWith({ confirmed: true }, root);
|
||||
expect(invoke).toHaveBeenCalledTimes(DATA_SERVICE_PLUGIN_DEFINITION.tools.length);
|
||||
expect(invoke.mock.calls.map(([input]) => input.toolName)).toEqual(
|
||||
DATA_SERVICE_PLUGIN_DEFINITION.tools.map(({ name }) => name),
|
||||
);
|
||||
expect(removed.details).toMatchObject({
|
||||
schema: 'makelore-capability.v1', operation: 'remove_project',
|
||||
schema: 'makelore-capability.v1', operation: 'data_service_remove_project',
|
||||
plugin_id: 'makelore.data-service', request_id: 'pi:run-a:resource-a',
|
||||
billing: { mode: 'included', status: 'included' }, payload_schema: 'data-service.v1',
|
||||
success: true, status: 200, data: { removed: true },
|
||||
success: true, status: 200, data: { delegated: true },
|
||||
});
|
||||
expect(JSON.stringify(removed)).not.toContain(root);
|
||||
});
|
||||
@@ -453,37 +486,19 @@ describe('PI-090 product tools', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects forbidden tool fields and destructive calls without literal confirmation', async () => {
|
||||
it('does not fabricate Data Service validation or billing without a registry', async () => {
|
||||
const root = await temporaryRoot('makelore-pi-data-input-');
|
||||
const dataService = {
|
||||
inspect: vi.fn().mockResolvedValue({
|
||||
success: true, status: 200, code: null, error: null, retryable: false, data: null,
|
||||
}),
|
||||
removeProject: vi.fn(),
|
||||
} as unknown as DataServiceOperations;
|
||||
const tools = new PiProductTools({
|
||||
browser: {} as AgentBrowserModule,
|
||||
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
||||
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
||||
dataService,
|
||||
});
|
||||
const context = {
|
||||
conversationId: 'conversation-a', runId: 'run-a', resourceId: 'resource-a',
|
||||
projectId: 'local-project-a', projectPath: root, skillIds: [],
|
||||
};
|
||||
|
||||
await expect(tools.execute('data_service_inspect', context, { owner: 'owner-a' })).resolves.toMatchObject({
|
||||
details: { schema: 'makelore-capability.v1', code: 'plugin_input_invalid', status: 422 },
|
||||
});
|
||||
await expect(tools.execute('data_service_put_document', context, {
|
||||
collection: 'todos', document_id: 'one', data: {}, path: root,
|
||||
})).resolves.toMatchObject({
|
||||
details: { schema: 'makelore-capability.v1', code: 'plugin_input_invalid', status: 422 },
|
||||
});
|
||||
await expect(tools.execute('data_service_remove_project', context, { confirmed: false })).resolves.toMatchObject({
|
||||
details: { schema: 'makelore-capability.v1', code: 'plugin_input_invalid', status: 422 },
|
||||
});
|
||||
expect(dataService.inspect).not.toHaveBeenCalled();
|
||||
expect(dataService.removeProject).not.toHaveBeenCalled();
|
||||
await expect(tools.execute('data_service_inspect', context, { owner: 'owner-a' }))
|
||||
.rejects.toThrow('Product tool is unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -182,6 +182,7 @@ describe('Pi managed resource loader', () => {
|
||||
const registry = new CodingCapabilityRegistryImpl({
|
||||
policyClient: { getState: () => policy },
|
||||
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' }; },
|
||||
|
||||
@@ -120,4 +120,29 @@ describe('PluginPolicyClient', () => {
|
||||
errorCode: 'plugin_backend_unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['headers', 'body'] as const)(
|
||||
'bounds a catalog request whose %s never settle',
|
||||
async (phase) => {
|
||||
const fetchImpl = vi.fn((_input: string | URL, init?: RequestInit) => {
|
||||
if (phase === 'headers') {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true });
|
||||
});
|
||||
}
|
||||
return Promise.resolve(new Response(new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
init?.signal?.addEventListener('abort', () => controller.error(init.signal?.reason), { once: true });
|
||||
},
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
});
|
||||
const client = new PluginPolicyClient({ fetchImpl, requestTimeoutMs: 20 });
|
||||
|
||||
await expect(client.refresh()).resolves.toMatchObject({
|
||||
status: 'unavailable', catalog: null, revision: 0,
|
||||
errorCode: 'plugin_backend_unavailable',
|
||||
});
|
||||
expect(fetchImpl).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -81,6 +81,22 @@ describe('ProjectPluginService', () => {
|
||||
await expect(stat(selectionPath(root))).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
it('does not turn a new Skill assignment into legacy plugin enablement', async () => {
|
||||
const root = await project();
|
||||
const service = new ProjectPluginService();
|
||||
await expect(service.readSelection(root)).resolves.toMatchObject({
|
||||
source: 'none', enabledPluginIds: [],
|
||||
});
|
||||
await createCodingProjectAgent(root, {
|
||||
id: 'builder', avatarId: 'avatar-01', roleName: 'Builder', name: 'Builder',
|
||||
model: null, modelResolution: 'required', skillIds: ['data-service'],
|
||||
responsibility: { mission: 'Build', owns: [], boundaries: [], collaborators: [], principles: [] },
|
||||
});
|
||||
await expect(service.readSelection(root)).resolves.toMatchObject({
|
||||
source: 'none', enabledPluginIds: [], legacyProjectedPluginIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('writes deterministic, atomic selection and preserves unknown IDs', async () => {
|
||||
const root = await project();
|
||||
await writeFile(selectionPath(root), JSON.stringify({
|
||||
|
||||
@@ -11,9 +11,32 @@ function projection(): CodingPluginProject {
|
||||
policyStatus: 'current',
|
||||
items: [{
|
||||
id: 'makelore.data-service', version: '1.0.0', displayName: '开发数据服务', description: '为当前项目提供 JSON 数据。',
|
||||
contractVersion: 1, requiresBackend: true,
|
||||
enabled: false, state: 'disabled', backend: { status: 'unconfigured' },
|
||||
skills: [{ id: 'data-service', assignedAgentIds: ['agent-1'] }],
|
||||
capabilities: [{ id: 'data-service.control', operations: [{ id: 'inspect', billing: { mode: 'included', availability: 'available', notice: 'Fixed quotas apply' } }] }],
|
||||
capabilities: [
|
||||
{ id: 'data-service.control', operations: [{
|
||||
id: 'inspect',
|
||||
billing: { mode: 'included', availability: 'available', notice: 'Fixed quotas apply' },
|
||||
tool: {
|
||||
name: 'data_service_inspect', label: 'Inspect', description: 'Inspect data',
|
||||
mutation: 'read', permissions: ['project.data.read'],
|
||||
},
|
||||
}] },
|
||||
{ id: 'data-service.documents', operations: [{
|
||||
id: 'put_document',
|
||||
billing: { mode: 'external_account', availability: 'available', notice: 'Provider billed' },
|
||||
tool: {
|
||||
name: 'data_service_put_document', label: 'Put', description: 'Put data',
|
||||
mutation: 'write', permissions: ['project.data.write'],
|
||||
},
|
||||
}] },
|
||||
{ id: 'data-service.preview', operations: [{
|
||||
id: 'get',
|
||||
billing: { mode: 'platform_metered', availability: 'unavailable', notice: 'Pricing unavailable' },
|
||||
tool: null,
|
||||
}] },
|
||||
],
|
||||
settingsSurface: 'data-service',
|
||||
}],
|
||||
};
|
||||
@@ -29,6 +52,12 @@ describe('Project Plugin Center', () => {
|
||||
expect(screen.getByText('当前包含,不按单次调用扣点')).toBeVisible();
|
||||
expect(screen.getByText('小明')).toBeVisible();
|
||||
expect(screen.getByText('data-service.control')).toBeVisible();
|
||||
expect(screen.getByText('data-service.documents')).toBeVisible();
|
||||
expect(screen.getByText('data-service.preview')).toBeVisible();
|
||||
expect(screen.getByText('data_service_inspect')).toBeVisible();
|
||||
expect(screen.getByText('SDK')).toBeVisible();
|
||||
expect(screen.getByText('由外部服务商计费,不计入 Token Point。')).toBeVisible();
|
||||
expect(screen.getByText('计费策略暂不可用,相关调用已停用。')).toBeVisible();
|
||||
expect(screen.queryByText('local-only-id')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('11111111-1111-4111-8111-111111111111')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '启用开发数据服务' }));
|
||||
@@ -44,6 +73,7 @@ describe('Project Plugin Center', () => {
|
||||
it('explains unknown billing and keeps invocation unavailable', () => {
|
||||
const value = projection();
|
||||
value.items[0].state = 'unavailable';
|
||||
value.items[0].capabilities = [value.items[0].capabilities[0]];
|
||||
value.items[0].capabilities[0].operations[0].billing = { mode: 'platform_metered', availability: 'unavailable', notice: 'pricing unavailable' };
|
||||
render(<MemoryRouter><ProjectPluginsView projectName="Demo" projection={value} dataService={null} pending={{}} agentNames={{}} onRefresh={vi.fn()} onSetEnabled={vi.fn()} onConfigure={vi.fn()} onReset={vi.fn()} onRemoveCollection={vi.fn()} onRemoveProject={vi.fn()} /></MemoryRouter>);
|
||||
expect(screen.getByText('计费策略暂不可用,相关调用已停用。')).toBeVisible();
|
||||
|
||||
Reference in New Issue
Block a user