From 60e6a8eeb35431b0f1fb569af4d1502683f16317 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Tue, 1 Sep 2026 02:39:16 +0800 Subject: [PATCH] feat(web-search): wire hosted plugin composition --- ...1-web-search-mlw03-composition-d9f5b3e2.md | 64 ++++++++ README.md | 2 + electron/api/coding-composition.ts | 12 +- scripts/lib/pi-product-artifact.mjs | 15 +- src/pages/Chat/CodingConversationTimeline.tsx | 18 +++ tests/unit/coding-capability-registry.test.ts | 142 ++++++++++++++++++ .../coding-conversation-timeline.test.tsx | 54 +++++++ tests/unit/coding-plugin-composition.test.ts | 48 ++++++ tests/unit/pi-product-artifact.test.ts | 12 +- 9 files changed, 361 insertions(+), 6 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260901-web-search-mlw03-composition-d9f5b3e2.md diff --git a/.project-docs/30-worklog/tasks/20260901-web-search-mlw03-composition-d9f5b3e2.md b/.project-docs/30-worklog/tasks/20260901-web-search-mlw03-composition-d9f5b3e2.md new file mode 100644 index 0000000..7bc0264 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260901-web-search-mlw03-composition-d9f5b3e2.md @@ -0,0 +1,64 @@ +# Task: Implement MLW-03 composition and packaged proof + +## Identity + +- Task ID: 20260901-web-search-mlw03-composition-d9f5b3e2 +- Mode: Feature +- Branch: codex/20260901-web-search-mlw03-composition-d9f5b3e2-web-search-mlw03-composition +- Worktree: D:\Datas\OthersProjects\makelore-web-search-mlw03-composition-d9f5b3e2 +- Base commit: 68cb2e73beb17d5041198d020aaa7b2884124950 +- Owner: web-search-mlw03-implementer +- Status: Ready for Integration + +## Scope + +- Wire the code-owned Web Search adapter into `electron/api/coding-composition.ts` so the existing effective-plugin snapshot is the only parent-worker materialization and invocation authority. +- Add the Web Search parent/child/current-authority scenario to `tests/unit/coding-capability-registry.test.ts` and the composition registration proof to `tests/unit/coding-plugin-composition.test.ts`. +- Add only a generic closed billing-state projection to `src/pages/Chat/CodingConversationTimeline.tsx` with focused coverage in `tests/unit/coding-conversation-timeline.test.tsx`; do not parse `web-search.v1` or add Web Search-specific Renderer state. +- Extend the packaged main-reachable proof in `scripts/lib/pi-product-artifact.mjs` and `tests/unit/pi-product-artifact.test.ts` for the fixed hosted Web Search route, receipt-unavailable parser, and absence of provider authority. The verifier script is a necessary adjacent proof file required by the MLW-03 acceptance criteria and has no competing ticket owner. +- Update `README.md` to describe the shipped native hosted Web Search capability and its production activation holds. +- Do not modify MLW-01 admission ownership, MLW-02 client/adapter/envelope ownership, project selection services, Marketplace pages, Provider/Server code, or Release B surfaces. + +## Intent And Constraints + +- Exact source base is `68cb2e73beb17d5041198d020aaa7b2884124950`; server DTO authority is frozen at `a49c696ebc4213e3d62ece780961efbe17576f8e`. +- Concurrent Task Gate and Planning Gate passed after verifying task identity, branch/worktree/base, clean product state, coordinator/MLW-01/MLW-02 peers, canonical implementation spec/design, and project memory. The older broad Web Search client task remains at its original base with no product diff and does not conflict with this exact-base ticket. +- Use test-first natural boundaries. Parent workers may receive one dynamically installed Web Search Skill/tool; child workers receive none. Disabled, uninstalled, stale, account-switched, project-switched, or logged-out state must affect only the next frozen worker snapshot. +- Keep provider key, model choice, provider URL, signed URL, admission authority, and account authority out of Renderer/package artifacts. No static tool allowlist, generic invoke route, `pi-web-search`, or third-party Pi extension. +- Real PostgreSQL, live paid OpenAI, production signing key, production OpenAI key/model/price/privacy copy, push, PR, deployment, and publication remain out of scope or explicitly held. + +## Plan + +1. Add focused failing tests for composition registration, dynamic parent/child/current invocation, generic billing projection, and packaged reachability/provider-authority exclusions. +2. Implement the smallest composition/timeline/artifact/README changes that turn those tests green. +3. Run focused and adjacent regressions, typecheck, lint, Vite/Electron/package proof as applicable, full unit pressure, documentation gates, and produce one clean source commit. + +## Outcome + +- Registered the code-owned Web Search adapter in both production capability execution and project-plugin inspection/deactivation paths. Marketplace-installed schema-v2 definitions remain the only Skill/tool source; no Web Search definition or tool allowlist was added to composition. +- Added an end-to-end registry proof for the frozen Web Search parent snapshot: one parent Skill/tool, no child inheritance, exact Main request/admission/envelope mapping, and refusal of a subsequent old-worker action after the project is disabled. +- Added a generic closed billing-state renderer for all `makelore-capability.v1` tools. `receipt_unavailable` is displayed as `收费状态未同步,请勿重复发起`; the timeline does not inspect `web-search.v1` answer/source payloads or add Web Search-specific state. +- Extended the package-main-reachable artifact proof to require the Web Search plugin ID, fixed Works route, and receipt-unavailable parser while rejecting embedded OpenAI key/origin authority. The built app remains fail-closed for the absent official Marketplace signing key. +- Updated README current-product documentation for native hosted Web Search, dynamic Marketplace Skill materialization, explicit Token Point confirmation, and production activation holds. + +## Verification + +- TDD boundary: after dependency materialization, the first focused run failed exactly four assertions (missing composition registration, generic receipt projection, Web Search artifact result field, and provider-authority rejection). The implementation turned the same boundary green: 4 files / 40 tests. +- Focused plus adjacent Web Search/admission/composition/lifecycle/Pi/timeline/artifact suite: 10 files / 82 tests passed. +- Full unit suite: 214 files / 1,751 tests passed / 2 staged-runtime tests skipped; isolated pressure suite: 1/1 passed. +- `pnpm run typecheck`: passed. Scoped ESLint: passed. Full `pnpm run lint:check`: 0 errors and the unchanged 5 warnings in Home/Makelore. +- `pnpm run build:vite`: passed for Renderer, Main, Preload, and utility worker. Electron Windows Vitest: 2 files / 6 tests passed. Marketplace, Project Plugins, and Skill configuration Playwright targets: 4/4 passed. +- `pnpm run package:win`: passed and produced the unpacked x64 app plus NSIS installer. The first installer was 208,252,283 bytes with SHA-256 `E6ABFA36C219F71FDBCAE811BA01DAEFFFAA3625FCCA17FE40C12CE507D0808E`; a clean-source-HEAD package/verification is required immediately after the sole commit before handoff. +- `pnpm run verify:artifact:pi`: passed against the real built `app.asar`; Marketplace proof includes the Web Search ID, fixed route, receipt parser, absent Web Search provider authority, and official-key-absent fail-closed trust. Existing Pi runtime result remains the inherited `partial-pass` for deferred cross-platform/real-provider evidence. +- `pnpm run verify:publish-runtime`: passed with npm 11.6.2. The pre-commit `verify:artifact:win` correctly refused to claim a clean artifact while the source diff was uncommitted; it is not a product failure and must be rerun on the source commit. +- `git diff --check`: passed. + +## Follow-ups + +- Real PostgreSQL lifecycle remains an external HOLD because no local service, Docker runtime, or `TEST_POSTGRESQL_DATABASE_URL` is available; SQLite is not accepted as a substitute. +- Live OpenAI acceptance remains HOLD because paid calls were not authorized. Production activation also remains HOLD for the official Ed25519 public key, `WEB_SEARCH_OPENAI_API_KEY`, approved model, price, and privacy copy. +- XWS-01 and the fixed-range Standards/Spec review remain coordinator-owned after MLW-03 integration. + +## Promotion Candidates + +- None recorded. diff --git a/README.md b/README.md index c30e2fd..80fbc77 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Makelore 是一个面向软件、视觉创作、互动学习与智能机器人 - AI 学习:主区展示服务端分页项目卡片,详情页用安全 Markdown 渲染 README;原始 HTML 被禁用,Markdown 图片节点直接加载服务端校验后的无凭据 HTTPS URL,包括 SVG 和 Electron 支持的其他图片格式,不经过服务端下载、识别、转码或镜像。下载按钮打开系统保存对话框,Main 不按 `Content-Length`、声明字节数或客户端上限阻断下载,流式校验 SHA-256 与 ZIP 签名后原子保存。客户端不提供课程生成、课程播放器、本地课程库、Agent、ASR 或课堂 runtime。运营管理与接口字段见 [`docs/learning-project-catalog-server-contract.md`](docs/learning-project-catalog-server-contract.md)。 - 插件市场 Release A:插件中心(Marketplace)提供运营精选的免费 `skill_only` 插件;“免费获取”只写入账号 Library,“下载/更新”才写入本机 Package Store,“启用到项目”和“分配给伙伴”仍是 Project Plugins 中彼此独立的动作。我的插件(My Plugins)展示账号获取状态、本机安装/更新/删除设备包、移除后的 tombstone 和 bounded unavailable reason;Project Plugins 继续只修改项目选择,不会因获取或下载自动启用或分配。 - 插件运行架构:Renderer 只调用 Main-owned Marketplace facade;Main 负责账号、请求 deadline、签名/摘要校验、不可变 Release、current selection 与原子回滚。下一代 Pi worker 使用同一个 effective snapshot,将每个有效 Skill 与已验证 Package Store root 成对传给 resource loader、Extension Host 和 CLI;`skill_only` 不依赖运行时 Policy,也不执行分发包中的任意代码。正式激活仍等待官方 Ed25519 公钥(production key activation HOLD);生产私钥只能来自部署 secret,测试使用注入的临时密钥。 +- 原生 Web Search 是 `platform_hosted` 精选插件:用户仍需依次免费获取、下载、启用到项目并分配给伙伴,每次搜索还必须显式确认 Token Point 消耗。Renderer 与插件包不持有 Provider 密钥、模型或 URL;Main 只调用固定 Works Square typed route,并把收费状态作为 closed receipt 展示。正式激活仍等待官方签名公钥、`WEB_SEARCH_OPENAI_API_KEY`、模型、价格与隐私文案;开发和验收不得把 fake Provider 结果当作生产 OpenAI 通过。 - 提示词博物馆:只陈列经过审核的作品预览、Prompt、分类以及作者/来源/许可证信息,支持搜索、使用场景/风格/主体筛选和详情抽屉;“使用此 Prompt”只把原文带回当前 Canvas 对话草稿,不自动发送、不构成社区。列表和详情数据由服务端提供,客户端不打包数据集;服务端字段契约见 [`docs/prompt-museum-server-contract.md`](docs/prompt-museum-server-contract.md)。 - 视觉系统:单一浅色主题,品牌蓝 `#3A5578`、星火橙 `#F26A3D`、白色画布与低饱和蓝灰层级。 - 字体系统:Renderer UI 内嵌 Inter Variable 与经过字符子集化的 Source Han Sans SC WOFF2,按字符范围统一中英文并保留系统中文字体 fallback;代码、路径和日志使用独立等宽字体。 @@ -112,6 +113,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p ### 项目内置编码技能 - 产品内置四个核心编码技能根:`agent-browser`(开发浏览器)、`frontend-slides`(项目演示)、`grilling`(方案质询)和 `planning-with-files`(项目规划),统一从 vendor-neutral 的 `resources/coding-skills/` 打包。`data-service`(开发数据)由固定的 `resources/coding-plugins/data-service/` 插件包持有,不在核心技能根中复制路径或定义。 +- Marketplace 插件 Skill(包括 Web Search)从已签名 Package Store Release 动态物化,不进入核心技能根,也不靠静态工具白名单;只有下一代 parent worker 的冻结 effective snapshot 同时满足安装、账号 Library、项目启用与伙伴分配时才生效,child worker 不继承 hosted tool。 - 项目插件“启用”和把插件技能分配给智能体是两个独立动作:只有项目已启用 Data Service 时,未分配的 `data-service` 才可供新选择;禁用后,已有分配仍会显示并继续保存在智能体的 `skillIds`,但处于不可用且不生效的状态,重新启用后恢复生效。未启用的技能不进入该智能体的有效 Pi 资源集合。 - `data-service` 只在用户显式请求后触发:先检查并说明最小集合,用户确认后配置一次、复制 SDK 资产,再用本地预览执行 put/read-back;它不用于已发布作品。 - 创建项目智能体时,`agent-browser`、`grilling` 与 `planning-with-files` 默认启用;`frontend-slides` 作为专项能力可手动启用。用户可以在创建或编辑智能体时调整选择。最终选择写入项目智能体的 `skillIds`。 diff --git a/electron/api/coding-composition.ts b/electron/api/coding-composition.ts index 542414c..1210947 100644 --- a/electron/api/coding-composition.ts +++ b/electron/api/coding-composition.ts @@ -46,6 +46,8 @@ import { import { createDataServicePluginAdapter } from '../coding-plugins/adapters/data-service'; import { createGameResourcePluginAdapter } from '../coding-plugins/adapters/game-resource'; import { GameResourceClient } from '../services/game-resource-client'; +import { createWebSearchPluginAdapter } from '../coding-plugins/adapters/web-search'; +import { WebSearchClient } from '../services/web-search-client'; import { AccountPluginCache } from '../coding-plugins/account-plugin-cache'; import { createMarketplaceClient, @@ -288,6 +290,12 @@ export function createCodingComposition( packageStore, makeloreVersion: options.clientVersion ?? '2.0.0', }); + const webSearchAdapter = createWebSearchPluginAdapter({ + client: new WebSearchClient(), + marketplace: marketplaceClient, + packageStore, + makeloreVersion: options.clientVersion ?? '2.0.0', + }); const policyClient = options.policyClient ?? new PluginPolicyClient(); const knownPluginIds = new Set(pluginDefinitions.map(({ id }) => id)); // Existing user Releases are discovered from the device index at startup; @@ -323,7 +331,7 @@ export function createCodingComposition( const capabilityRegistry = createCodingCapabilityRegistry({ policyClient, projectPlugins, - adapters: [dataServiceAdapter, gameResourceAdapter], + adapters: [dataServiceAdapter, gameResourceAdapter, webSearchAdapter], definitions: pluginDefinitions, effectiveResolver, getDurableProjectId: async (projectPath, localProjectId) => { @@ -339,7 +347,7 @@ export function createCodingComposition( projects, projectPlugins, policyClient, - adapters: [dataServiceAdapter, gameResourceAdapter], + adapters: [dataServiceAdapter, gameResourceAdapter, webSearchAdapter], definitions: pluginDefinitions, effectiveResolver, getDefinitions: async () => { diff --git a/scripts/lib/pi-product-artifact.mjs b/scripts/lib/pi-product-artifact.mjs index 627494f..d180b71 100644 --- a/scripts/lib/pi-product-artifact.mjs +++ b/scripts/lib/pi-product-artifact.mjs @@ -45,6 +45,12 @@ const MARKETPLACE_ARTIFACT_MARKERS = Object.freeze({ 'makelore.game-resource', '/api/plugins/v1/hosted/game-resource/generations', ]), + webSearchRuntime: Object.freeze([ + 'makelore.web-search', + '/api/plugins/v1/hosted/web-search/searches', + 'plugin_receipt_unavailable', + 'receipt_unavailable', + ]), mainRoutes: Object.freeze([ '/api/coding/plugin-marketplace', 'plugin-marketplace\\/install\\/', @@ -61,12 +67,14 @@ const MARKETPLACE_ARTIFACT_MARKERS = Object.freeze({ '我的插件', ]), }); -const FORBIDDEN_MEOWA_ARTIFACT_MARKERS = Object.freeze([ +const FORBIDDEN_PROVIDER_AUTHORITY_MARKERS = Object.freeze([ 'MEOWA_API_KEY', 'MEOWA_API_URL', 'MEOWA_GAME_ASSETS_SHARED_SECRET', '/api/coding/meowa-game-assets', 'https://api.meowa.ai', + 'WEB_SEARCH_OPENAI_API_KEY', + 'https://api.openai.com/v1/responses', ]); const CODE_OWNED_PLUGIN_SIGNING_KEYS_SOURCE_MARKER = 'makelore.plugin-trust.code-owned.v1'; const PI_AI_PROVIDER_PREFIX = 'pi-runtime/node_modules/@earendil-works/pi-ai/dist/providers/'; @@ -309,11 +317,11 @@ export function verifyMarketplaceClientArtifact(appAsarContents, productionTrust if (missing.length > 0) { throw new Error(`Packaged app.asar does not contain Marketplace contract markers: ${missing.join(', ')}`); } - const forbidden = FORBIDDEN_MEOWA_ARTIFACT_MARKERS.filter((marker) => ( + const forbidden = FORBIDDEN_PROVIDER_AUTHORITY_MARKERS.filter((marker) => ( appAsarContents.includes(Buffer.from(marker)) )); if (forbidden.length > 0) { - throw new Error(`Packaged app.asar still contains legacy Meowa client authority: ${forbidden.join(', ')}`); + throw new Error(`Packaged app.asar still contains provider authority: ${forbidden.join(', ')}`); } const hasEmptyCodeOwnedTrust = /(?:CODE_OWNED_PLUGIN_SIGNING_KEYS\s*=\s*)?Object\.freeze\(\s*\{\}\s*(?:as\s+[^)]*)?\)/u.test(productionTrustSource); if (!hasEmptyCodeOwnedTrust @@ -327,6 +335,7 @@ export function verifyMarketplaceClientArtifact(appAsarContents, productionTrust schema2SkillOnly: true, schema2PlatformHosted: true, legacyMeowaClientAuthorityAbsent: true, + webSearchProviderAuthorityAbsent: true, productionTrust: 'official-key-absent-fail-closed', libraryInstallAndEffectiveRoutes: true, rendererAssets: true, diff --git a/src/pages/Chat/CodingConversationTimeline.tsx b/src/pages/Chat/CodingConversationTimeline.tsx index 2a82f2c..4cd34be 100644 --- a/src/pages/Chat/CodingConversationTimeline.tsx +++ b/src/pages/Chat/CodingConversationTimeline.tsx @@ -87,6 +87,23 @@ const NODE_STATUS_LABELS: Record = { type TextualBlock = Exclude; type ThinkingBlock = Extract; +type CapabilityToolDetails = Extract; + +function capabilityBillingLabel(billing: CapabilityToolDetails['billing']): string { + switch (billing.status) { + case 'not_started': return '尚未开始计费'; + case 'included': return '已包含'; + case 'external': return '由外部账户结算'; + case 'reserved': return `已预留 ${billing.reserved_points} Token Point`; + case 'dispatched': return `处理中 · 已预留 ${billing.reserved_points} Token Point`; + case 'released': return '预留已释放,未收费'; + case 'pending_review': return '账单待审核'; + case 'expired': return '预留已过期'; + case 'settled': return `已结算 ${billing.actual_points} Token Point`; + case 'refunded': return `已退款 ${billing.actual_points} Token Point`; + case 'receipt_unavailable': return '收费状态未同步,请勿重复发起'; + } +} type ProcessItem = | { kind: 'thinking'; id: string; block: ThinkingBlock } @@ -608,6 +625,7 @@ const ToolDetails = memo(function ToolDetails({ details }: { details: KnownToolD

{details.success ? '成功' : details.error ?? '请求失败'} · HTTP {details.status}

+

{capabilityBillingLabel(details.billing)}

); } diff --git a/tests/unit/coding-capability-registry.test.ts b/tests/unit/coding-capability-registry.test.ts index 9b2274e..194a45a 100644 --- a/tests/unit/coding-capability-registry.test.ts +++ b/tests/unit/coding-capability-registry.test.ts @@ -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', diff --git a/tests/unit/coding-conversation-timeline.test.tsx b/tests/unit/coding-conversation-timeline.test.tsx index 6bef893..8fc4302 100644 --- a/tests/unit/coding-conversation-timeline.test.tsx +++ b/tests/unit/coding-conversation-timeline.test.tsx @@ -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(); + + 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(); + }); }); diff --git a/tests/unit/coding-plugin-composition.test.ts b/tests/unit/coding-plugin-composition.test.ts index e68a3dc..3a04de8 100644 --- a/tests/unit/coding-plugin-composition.test.ts +++ b/tests/unit/coding-plugin-composition.test.ts @@ -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); diff --git a/tests/unit/pi-product-artifact.test.ts b/tests/unit/pi-product-artifact.test.ts index c1a16ed..621c03e 100644 --- a/tests/unit/pi-product-artifact.test.ts +++ b/tests/unit/pi-product-artifact.test.ts @@ -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 免费获取 我的插件',