feat(web-search): wire hosted plugin composition
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
} from '../../electron/coding-plugins/registry';
|
||||
import type { CodingPluginAdapter } from '../../electron/coding-plugins/registry';
|
||||
import { createDataServicePluginAdapter } from '../../electron/coding-plugins/adapters/data-service';
|
||||
import { createWebSearchPluginAdapter } from '../../electron/coding-plugins/adapters/web-search';
|
||||
import {
|
||||
createDataServiceOperations,
|
||||
DataServiceCloudClient,
|
||||
@@ -331,6 +332,147 @@ describe('CodingCapabilityRegistry', () => {
|
||||
expect(invoke).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('materializes Web Search only for the parent snapshot and rejects the old worker after disable', async () => {
|
||||
const definition: CodingPluginDefinition = {
|
||||
id: 'makelore.web-search', version: '1.0.0', contractVersion: 1,
|
||||
displayName: 'Web Search', description: 'Hosted web search',
|
||||
runtimeKind: 'platform_hosted', acquisitionMode: 'user_acquired',
|
||||
releaseId: 'release-web-search-1',
|
||||
provenance: { source: 'marketplace', packageRoot: 'C:/packages/web-search' },
|
||||
scope: 'project', adapterId: 'makelore.web-search', requiresBackend: true,
|
||||
skills: [{
|
||||
id: 'web-search', entryPath: 'skills/web-search/SKILL.md', grants: ['web-search.search'],
|
||||
}],
|
||||
tools: [{
|
||||
name: 'makelore_web_search', label: 'Web Search', description: 'Search the web',
|
||||
capabilityId: 'web-search.search', operation: 'search', roles: ['parent'],
|
||||
mutation: 'read', projectWriteLease: false,
|
||||
permissions: ['hosted.web-search.search'], executionMode: 'synchronous',
|
||||
inputSchema: {
|
||||
type: 'object', additionalProperties: false, required: ['query', 'confirmed'],
|
||||
properties: { query: { type: 'string' }, confirmed: { type: 'boolean' } },
|
||||
},
|
||||
}],
|
||||
operations: [{
|
||||
capabilityId: 'web-search.search', operation: 'search', toolName: 'makelore_web_search',
|
||||
}],
|
||||
surfaces: {},
|
||||
};
|
||||
const billing = {
|
||||
mode: 'platform_metered' as const, entitlement_scope: 'plugin_usage', notice: 'Metered',
|
||||
unit_name: 'search_request', unit_size: 1, rate_points: '1.00',
|
||||
minimum_charge_points: '1.00', rounding_mode: 'ceil' as const,
|
||||
};
|
||||
const webSearchPolicy: PluginPolicyClientState = {
|
||||
status: 'current', revision: 10, lastVerifiedAt: 1,
|
||||
catalog: {
|
||||
schema_version: 1, catalog_version: 'web-search-1', pricing_version: 'pricing-1',
|
||||
plugins: [{
|
||||
plugin_id: definition.id, supported_contract_versions: [1], status: 'active',
|
||||
capabilities: [{
|
||||
capability_id: 'web-search.search', operations: [{ operation: 'search', billing }],
|
||||
}],
|
||||
}],
|
||||
},
|
||||
};
|
||||
const frozen: EffectivePluginSnapshot = {
|
||||
accountSessionId: 'account-a\u00001', projectId: context.projectId,
|
||||
pluginReleaseIds: ['release-web-search-1'], effectiveSkillIds: ['web-search'],
|
||||
skillEntries: [{
|
||||
id: 'web-search', entryPath: 'skills/web-search/SKILL.md', packageRoot: 'C:/packages/web-search',
|
||||
}],
|
||||
toolDefinitions: definition.tools,
|
||||
runtimePolicies: [{
|
||||
pluginId: definition.id, pluginVersion: definition.version,
|
||||
releaseId: definition.releaseId, contractVersion: 1,
|
||||
capabilityId: 'web-search.search', operation: 'search', billing,
|
||||
}],
|
||||
unavailableReasons: [],
|
||||
};
|
||||
const disabled: EffectivePluginSnapshot = {
|
||||
...frozen, pluginReleaseIds: [], effectiveSkillIds: [], skillEntries: [],
|
||||
toolDefinitions: [], runtimePolicies: [],
|
||||
unavailableReasons: [{
|
||||
pluginId: definition.id, code: 'project_disabled', message: 'Plugin is not enabled',
|
||||
}],
|
||||
};
|
||||
let current = frozen;
|
||||
const resolve = vi.fn(async ({ role }: { role: 'parent' | 'child' }) => (
|
||||
role === 'child' ? { ...disabled, unavailableReasons: [] } : current
|
||||
));
|
||||
const effectiveResolver = {
|
||||
resolve,
|
||||
getSkillSources: vi.fn(async () => [{
|
||||
id: 'web-search', pluginId: definition.id, packageRoot: 'C:/packages/web-search',
|
||||
directory: 'C:/packages/web-search/skills/web-search', entryPath: 'SKILL.md',
|
||||
}]),
|
||||
getPolicyState: vi.fn(() => webSearchPolicy),
|
||||
getInstalledDefinition: vi.fn(async () => definition),
|
||||
} as unknown as EffectivePluginResolver;
|
||||
const search = vi.fn().mockResolvedValue({
|
||||
executionId: 'execution-web-1', releaseId: definition.releaseId,
|
||||
logicalOperationId: 'pi:run-a:resource-a', status: 'succeeded',
|
||||
answer: 'A bounded answer', sources: [{ title: 'Source', url: 'https://example.com' }],
|
||||
searchQueries: ['Makelore'], errorCode: null,
|
||||
billing: {
|
||||
mode: 'platform_metered', status: 'settled', reserved_points: '1.00',
|
||||
actual_points: '1.00', usage_amount: 1, unit: 'search_request',
|
||||
},
|
||||
});
|
||||
const adapter = createWebSearchPluginAdapter({
|
||||
client: { search } as never,
|
||||
admissionResolver: {
|
||||
resolve: vi.fn().mockResolvedValue({
|
||||
releaseId: definition.releaseId, releaseAdmissionId: 'admission-web-1',
|
||||
}),
|
||||
} as never,
|
||||
});
|
||||
const capabilityRegistry = registry({
|
||||
definitions: [], effectiveResolver, adapters: [adapter],
|
||||
policyClient: { getState: () => webSearchPolicy, refresh: vi.fn() },
|
||||
getEnabledPluginIds: async () => [definition.id],
|
||||
});
|
||||
|
||||
const parent = await capabilityRegistry.resolveWorkerResources({
|
||||
projectId: context.projectId, projectPath: context.projectPath,
|
||||
assignedSkillIds: ['web-search'], role: 'parent',
|
||||
});
|
||||
const child = await capabilityRegistry.resolveWorkerResources({
|
||||
projectId: context.projectId, projectPath: context.projectPath,
|
||||
assignedSkillIds: ['web-search'], role: 'child',
|
||||
});
|
||||
expect(parent.tools.map(({ name }) => name)).toEqual(['makelore_web_search']);
|
||||
expect(parent.effectiveSkillIds).toEqual(['web-search']);
|
||||
expect(child.tools).toEqual([]);
|
||||
expect(child.effectiveSkillIds).toEqual([]);
|
||||
|
||||
const result = await capabilityRegistry.invoke({
|
||||
toolName: 'makelore_web_search',
|
||||
context: { ...context, skillIds: ['web-search'], effectiveSnapshot: frozen },
|
||||
workerRole: 'parent', effectiveSkillIds: ['web-search'],
|
||||
value: { query: ' Makelore ', confirmed: true },
|
||||
});
|
||||
expect(search).toHaveBeenCalledWith(expect.objectContaining({
|
||||
releaseId: 'release-web-search-1', releaseAdmissionId: 'admission-web-1',
|
||||
logicalOperationId: 'pi:run-a:resource-a', query: 'Makelore', confirmed: true,
|
||||
}));
|
||||
expect(result.details).toMatchObject({
|
||||
schema: 'makelore-capability.v1', plugin_id: 'makelore.web-search',
|
||||
capability_id: 'web-search.search', operation: 'search',
|
||||
payload_schema: 'web-search.v1', billing: { status: 'settled', actual_points: '1.00' },
|
||||
});
|
||||
|
||||
current = disabled;
|
||||
const stale = await capabilityRegistry.invoke({
|
||||
toolName: 'makelore_web_search',
|
||||
context: { ...context, skillIds: ['web-search'], effectiveSnapshot: frozen },
|
||||
workerRole: 'parent', effectiveSkillIds: ['web-search'],
|
||||
value: { query: 'Makelore', confirmed: true },
|
||||
});
|
||||
expect(stale.details).toMatchObject({ success: false, code: 'plugin_not_enabled' });
|
||||
expect(search).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('refuses a new plugin action from an old worker after lifecycle invalidation', async () => {
|
||||
const frozenSnapshot: EffectivePluginSnapshot = {
|
||||
accountSessionId: 'account-a\u00001',
|
||||
|
||||
@@ -880,4 +880,58 @@ describe('CodingConversationTimeline', () => {
|
||||
expect(document.querySelector('[data-node-id="tool-changed-file-feature"]')).toBeNull();
|
||||
expect(screen.queryByText(/回滚|revert/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders receipt-unavailable as a generic billing state without parsing Web Search payloads', async () => {
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
const { CodingConversationTimeline } = await import(
|
||||
'@/pages/Chat/CodingConversationTimeline'
|
||||
);
|
||||
const base = createProductSnapshot('conversation-web-search-billing', 1);
|
||||
const snapshot = {
|
||||
...base,
|
||||
nodes: [{
|
||||
kind: 'tool' as const,
|
||||
id: 'tool-web-search-billing',
|
||||
toolCallId: 'call-web-search-billing',
|
||||
toolName: 'makelore_web_search',
|
||||
title: '搜索网络',
|
||||
inputText: '{"query":"Makelore"}',
|
||||
status: 'error' as const,
|
||||
output: [],
|
||||
details: {
|
||||
schema: 'makelore-capability.v1' as const,
|
||||
plugin_id: 'makelore.web-search',
|
||||
plugin_version: '1.0.0',
|
||||
capability_id: 'web-search.search',
|
||||
operation: 'search',
|
||||
request_id: 'pi:run-a:resource-a',
|
||||
success: false,
|
||||
status: 503,
|
||||
code: 'plugin_receipt_unavailable',
|
||||
error: 'Web Search billing status could not be synchronized',
|
||||
retryable: false,
|
||||
billing: { mode: 'platform_metered' as const, status: 'receipt_unavailable' as const },
|
||||
payload_schema: 'web-search.v1',
|
||||
data: null,
|
||||
},
|
||||
}],
|
||||
};
|
||||
codingConversationStore.getState().applySnapshotEvent({
|
||||
type: 'snapshot',
|
||||
conversationId: 'conversation-web-search-billing',
|
||||
workerGeneration: 1,
|
||||
seq: snapshot.cursor.seq,
|
||||
snapshot,
|
||||
});
|
||||
|
||||
render(<CodingConversationTimeline conversationId="conversation-web-search-billing" />);
|
||||
|
||||
const process = screen.getByTestId('coding-process-group');
|
||||
fireEvent.click(process.querySelector('summary')!);
|
||||
const tool = document.querySelector('[data-node-id="tool-web-search-billing"]')!;
|
||||
fireEvent.click(tool.querySelector('summary')!);
|
||||
const details = within(tool as HTMLElement).getByTestId('tool-details');
|
||||
expect(within(details).getByText('收费状态未同步,请勿重复发起')).toBeVisible();
|
||||
expect(within(details).queryByText(/来源|搜索结果|Makelore\.com/u)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,21 +4,69 @@ import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AgentBrowserModule } from '../../electron/agent-browser';
|
||||
import { createCodingComposition } from '../../electron/api/coding-composition';
|
||||
import {
|
||||
createCodingProjectPluginService,
|
||||
} from '../../electron/api/coding-product-services';
|
||||
import { createCodingProjectMetadata, createCodingProjectAgent } from '../../electron/coding-projects/project-config';
|
||||
import type { CodingPluginAdapter } from '../../electron/coding-plugins/registry';
|
||||
import { createProjectPluginService } from '../../electron/coding-plugins/project-service';
|
||||
import { createMemoryCodingProjectStorage } from '../../electron/coding-projects/project-store';
|
||||
import { DATA_SERVICE_PLUGIN_DEFINITION } from '../../shared/coding-plugins';
|
||||
|
||||
const webSearchAdapterMock = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
deactivate: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../electron/coding-plugins/adapters/web-search', () => ({
|
||||
createWebSearchPluginAdapter: webSearchAdapterMock.create.mockImplementation(() => ({
|
||||
pluginId: 'makelore.web-search',
|
||||
inspect: vi.fn().mockResolvedValue({ status: 'ready' }),
|
||||
invoke: vi.fn(),
|
||||
deactivate: webSearchAdapterMock.deactivate,
|
||||
})),
|
||||
}));
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
webSearchAdapterMock.create.mockClear();
|
||||
webSearchAdapterMock.deactivate.mockClear();
|
||||
});
|
||||
|
||||
describe('coding plugin bounded product service', () => {
|
||||
it('registers the code-owned Web Search adapter in the production composition', async () => {
|
||||
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-web-search-composition-project-'));
|
||||
const userDataDir = await mkdtemp(path.join(tmpdir(), 'makelore-web-search-composition-user-'));
|
||||
roots.push(projectPath, userDataDir);
|
||||
const composition = createCodingComposition({
|
||||
storage: createMemoryCodingProjectStorage(),
|
||||
browser: { close: vi.fn().mockResolvedValue(undefined) } as unknown as AgentBrowserModule,
|
||||
paths: {
|
||||
executablePath: process.execPath,
|
||||
cliPath: path.join(projectPath, 'unused-cli.js'),
|
||||
serverPath: path.join(projectPath, 'unused-server.mjs'),
|
||||
userDataDir,
|
||||
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
||||
},
|
||||
});
|
||||
|
||||
await composition.plugins.deactivate(projectPath, 'makelore.web-search');
|
||||
|
||||
expect(webSearchAdapterMock.create).toHaveBeenCalledOnce();
|
||||
expect(webSearchAdapterMock.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
client: expect.any(Object),
|
||||
marketplace: expect.any(Object),
|
||||
packageStore: expect.any(Object),
|
||||
makeloreVersion: '2.0.0',
|
||||
}));
|
||||
expect(webSearchAdapterMock.deactivate).toHaveBeenCalledWith(projectPath);
|
||||
await composition.shutdown();
|
||||
});
|
||||
|
||||
it('joins package and policy exactly while isolating adapter inspection failure', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-product-'));
|
||||
roots.push(root);
|
||||
|
||||
@@ -29,6 +29,8 @@ const MARKETPLACE_ARTIFACT_TEXT = [
|
||||
'makelore-plugin-release.v1', 'skill_only', 'platform_hosted',
|
||||
'plugin_signature_invalid', 'signing key is not trusted',
|
||||
'makelore.game-resource', '/api/plugins/v1/hosted/game-resource/generations',
|
||||
'makelore.web-search', '/api/plugins/v1/hosted/web-search/searches',
|
||||
'plugin_receipt_unavailable', 'receipt_unavailable',
|
||||
'/api/coding/plugin-marketplace',
|
||||
'plugin-marketplace\\/install\\/', 'plugin-marketplace\\/update\\/',
|
||||
'effectiveSkillIds', 'pluginReleaseIds',
|
||||
@@ -215,6 +217,7 @@ describe('final Pi product artifact verification', () => {
|
||||
schema2SkillOnly: true,
|
||||
schema2PlatformHosted: true,
|
||||
legacyMeowaClientAuthorityAbsent: true,
|
||||
webSearchProviderAuthorityAbsent: true,
|
||||
productionTrust: 'official-key-absent-fail-closed',
|
||||
libraryInstallAndEffectiveRoutes: true,
|
||||
rendererAssets: true,
|
||||
@@ -238,7 +241,12 @@ describe('final Pi product artifact verification', () => {
|
||||
expect(() => verifyMarketplaceClientArtifact(
|
||||
Buffer.from(`${MARKETPLACE_ARTIFACT_TEXT}\nMEOWA_API_KEY`),
|
||||
`${emptyTrust}\nmakelore.plugin-trust.code-owned.v1`,
|
||||
)).toThrow('legacy Meowa client authority');
|
||||
)).toThrow('provider authority');
|
||||
|
||||
expect(() => verifyMarketplaceClientArtifact(
|
||||
Buffer.from(`${MARKETPLACE_ARTIFACT_TEXT}\nWEB_SEARCH_OPENAI_API_KEY`),
|
||||
`${emptyTrust}\nmakelore.plugin-trust.code-owned.v1`,
|
||||
)).toThrow('provider authority');
|
||||
});
|
||||
|
||||
it('proves Marketplace trust from the packaged app.asar rather than checkout source', async () => {
|
||||
@@ -320,6 +328,8 @@ describe('final Pi product artifact verification', () => {
|
||||
await writeFile(path.join(source, 'dist', 'assets', 'plugin-marketplace.js'), [
|
||||
'makelore-plugin-release.v1 skill_only platform_hosted plugin_signature_invalid signing key is not trusted',
|
||||
'makelore.game-resource /api/plugins/v1/hosted/game-resource/generations',
|
||||
'makelore.web-search /api/plugins/v1/hosted/web-search/searches',
|
||||
'plugin_receipt_unavailable receipt_unavailable',
|
||||
'/api/coding/plugin-marketplace plugin-marketplace\\/install\\/ plugin-marketplace\\/update\\/',
|
||||
'effectiveSkillIds pluginReleaseIds',
|
||||
'/api/coding/plugin-marketplace/catalog /api/coding/plugin-marketplace/library 免费获取 我的插件',
|
||||
|
||||
Reference in New Issue
Block a user