From cf13aa7ebac65fb8e32b0848593bd17d4c0137c4 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Thu, 27 Aug 2026 20:40:58 +0800 Subject: [PATCH] fix(coding): remediate ML-07 plugin authority --- ...260827-plugin-ml07-remediation-5d8c2a71.md | 171 ++++++++++++++++++ README.md | 5 +- electron/api/coding-composition.ts | 23 +++ electron/api/coding-product-services.ts | 57 ++++-- electron/coding-plugins/manifest.ts | 99 ++++++++-- electron/coding-plugins/project-service.ts | 11 +- electron/coding-plugins/registry.ts | 5 +- electron/coding-projects/skill-registry.ts | 35 ++-- electron/coding-runtime/pi/product-tools.ts | 73 ++------ electron/services/plugin-policy-client.ts | 36 ++-- shared/coding-plugins.ts | 20 ++ shared/coding-product-tools.ts | 8 +- src/components/coding/AgentCreationDialog.tsx | 4 +- src/lib/coding-plugins.ts | 43 ++++- src/pages/ProjectConfiguration/index.tsx | 13 +- src/pages/ProjectPlugins/index.tsx | 13 +- src/stores/coding-plugins.ts | 26 ++- tests/unit/agent-creation-dialog.test.tsx | 59 ++++++ tests/unit/coding-capability-registry.test.ts | 132 +++++++++++++- tests/unit/coding-plugin-composition.test.ts | 70 +++++++ tests/unit/coding-plugin-manifest.test.ts | 7 + tests/unit/coding-plugins-client.test.ts | 6 + tests/unit/coding-plugins-store.test.ts | 56 ++++++ tests/unit/coding-product-services.test.ts | 47 ++++- tests/unit/pi-product-tools.test.ts | 129 +++++++------ tests/unit/pi-resource-loader.test.ts | 1 + tests/unit/plugin-policy-client.test.ts | 25 +++ tests/unit/project-plugin-service.test.ts | 16 ++ tests/unit/project-plugins-page.test.tsx | 32 +++- 29 files changed, 1008 insertions(+), 214 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260827-plugin-ml07-remediation-5d8c2a71.md create mode 100644 tests/unit/agent-creation-dialog.test.tsx diff --git a/.project-docs/30-worklog/tasks/20260827-plugin-ml07-remediation-5d8c2a71.md b/.project-docs/30-worklog/tasks/20260827-plugin-ml07-remediation-5d8c2a71.md new file mode 100644 index 0000000..fa68712 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260827-plugin-ml07-remediation-5d8c2a71.md @@ -0,0 +1,171 @@ +# Task: Remediate accepted MakeLore ML-07 review findings + +## Identity + +- Task ID: 20260827-plugin-ml07-remediation-5d8c2a71 +- Mode: Feature +- Branch: codex/20260827-plugin-ml07-remediation-5d8c2a71-plugin-ml07-remediation +- Worktree: D:\Datas\OthersProjects\makelore-plugin-ml07-remediation-5d8c2a71 +- Base commit: 9407c67df21c2f0f50bb0362c826fcff643d9d5f +- Owner: ml07-remediator +- Status: Ready for integration + +## Scope + +- Remediate the ten accepted ML-07 fixed-range findings on exact reviewed head + `9407c67df21c2f0f50bb0362c826fcff643d9d5f`. +- Own only the product files and directly focused tests explicitly delegated by the + coordinator: bundled plugin/Skill definitions, project/capability/Host + projections, policy client, Pi product-tool seam, Renderer Plugin Center store + and views, README, and focused package/runtime/conversation tests. +- Deliver one final remediation commit whose sole parent is the reviewed head. + +## Intent And Constraints + +- Make the parsed fixed bundled package set the single production authority; + preserve enablement, Agent assignment, backend configuration, and billing as + independent states; join all three Data Service capabilities and fourteen + operations exactly against policy. +- Remove the forbidden direct Data Service path from `PiProductTools`; keep the + capability registry as the sole non-core invocation path and retain bounded + stable request/fault semantics. +- Bound policy fetch plus body consumption, preserve preview/settings data on + enable, prevent stale project-load commits, use the stable Coding Skill type, + and update current-state README wording. +- Test first in focused behavior groups. Do not add generic plugin/P1 framework, + compatibility layers, checksums, static fallback lists, or touch files outside + the coordinator ownership list without stopping for an explicit transfer. +- Do not modify the user root worktree, push, open a PR, or spawn subagents. + +## Project Context Loaded + +Task context: +- Task ID: `20260827-plugin-ml07-remediation-5d8c2a71` +- Mode: Feature +- Branch: `codex/20260827-plugin-ml07-remediation-5d8c2a71-plugin-ml07-remediation` +- Worktree: `D:\Datas\OthersProjects\makelore-plugin-ml07-remediation-5d8c2a71` +- Base commit: `9407c67df21c2f0f50bb0362c826fcff643d9d5f` +- Other active local tasks: the ML-07 Standards and Spec reviewers are read-only + peers on the same head; ML-01 through ML-06 source tasks are ready for + integration and the client coordinator owns only its integration branch/record. +- Overlap or semantic-conflict assessment: no unresolved conflict. This task is the + coordinator-designated sole remediation owner; reviewers do not write product + files and earlier ticket owners have completed their scopes. + +Read: +- `AGENTS.md`; mandatory `implement-spec` and `maintain-project-docs` skills. +- `.project-docs/05-agent-entry/{memory-index,read-before-planning,planning-gate}.md` + and the required project positioning/current state/decision/architecture/domain/ + evidence/reflection/commitment/stale records. +- ADR-006, the active/coordinator/ML-01 through ML-07 peer task records, the plugin + implementation spec §§3, 6-10.2, 12, ticket ML-07, and detailed design §§2, + 5-9, 14, 17. + +Relevant understanding: +- Project goal: preserve Pi as the sole Main-owned Coding runtime while adding one + fixed first-party bundled plugin package with strict project and policy authority. +- Current integrated focus: canonical memory predates this unintegrated plugin + branch; the frozen specification and exact reviewed head control this task. +- Active task scope: the ten accepted findings only, within the delegated file list. +- Active constraints: no generic invoke/config/ledger/marketplace/arbitrary code, + no duplicated Skill/tool authority, no root-worktree writes. +- Decisions affecting this task: ADR-006 and the plugin spec require Main authority, + dynamic worker resources, accepted/uncertain non-replay, and bounded product DTOs. +- Evidence, reflections, or commitments affecting this task: Windows package proof + exists on the reviewed head; staged runtime skips and the historical unrelated E2E + timeout must not be overstated as passes. +- Files or modules likely involved: the exact delegated shared definitions, + manifest/registry/project service, policy client, composition/product services, + Pi product-tools, Renderer plugin store/page/configuration selector, README, and + directly corresponding unit/E2E tests. +- Unknowns, stale docs, or conflicts: the generic positioning template is stale; + current-state memory does not include this branch. Neither changes the frozen + contract. No semantic peer conflict is known. + +Gate result: +- Passed on 2026-08-27. + +## Plan + +1. Add red tests for startup parsed-definition authority, normalized equality, + disabled retained Skill availability, exact policy join, and removal of the + direct Data Service product-tool path; implement the smallest shared/Main fix. +2. Add red tests for stable request identity/fault propagation and bounded policy + fetch+body deadline; change production only where failures expose defects. +3. Add red tests for ready-instance retention on enable, stable Skill typing, and + out-of-order Renderer loads; implement the bounded lifecycle/UI fixes. +4. Update README inventory/enablement wording, run affected focused regressions, + typecheck, lint, build, package proof, and broader relevant suites. +5. Record exact results, run documentation drift/completion gates, create one clean + commit directly on the reviewed base, and return the test ledger/deviations. + +## Outcome + +- Electron Main composition now synchronously loads and validates the fixed bundled + package roots, freezes the parsed outer definition set, and injects that same set + into project, capability, and package-Skill projections. Production no longer + falls back to the shared duplicated definition catalog or a hard-coded package + Skill path. +- Package operation ownership now includes the code-owned `data-service.preview` + get/list/put/delete operations. The policy projection joins exactly three + capabilities and fourteen operations; preview operations remain non-tool SDK + operations, and Renderer displays each operation's joined billing/tool metadata. +- Plugin Skill availability is explicit across Product contracts, Host commands, + worker runtime context, and the Agent selector. Disabled unassigned Skills are + hidden, retained assignments remain selected/persisted but ineffective, and + re-enable restores availability without a new assignment implicitly enabling the + plugin. The genuine first-read legacy assignment migration remains intact. +- `PiProductTools` has no Data Service option, adapter, configure method, ten-name + fallback, or fabricated billing envelope. All non-core tools use the capability + registry. `ProductCodingSkill` and commands use `CodingSkillId` without the old + unsafe cast. +- Policy refresh now places fetch, headers, and bounded body consumption under the + existing deadline/abort seam and retains unknown/stale fallback semantics. + Plugin Center loads use latest-generation commit guards, and enabling an already + ready Data Service inspects before one atomic state commit while retaining its + actual instance usage/settings. +- README now describes four core Skill roots plus the package-owned Data Service + Skill and distinguishes project enablement from Agent assignment. + +## Verification + +- Test-first red evidence initially exposed five failures: missing fourteen-operation + normalization, stale A/B store success, missing ready-enable inspection, and + unbounded never-settling policy headers/body. Later focused tests also exposed + Host plugin-ID/Skill-ID mapping, command/runtime availability, and the unfrozen + parsed outer definition set; all were fixed at their existing seams. +- Focused final suite: 10 files, 60 tests passed. It covers normalized sync/async + package equality and freezing; exact three-capability/fourteen-operation mixed + policy projection; Host/Renderer disabled-retained/re-enable behavior; ready + instance retention; policy headers/body deadlines; A/B load ordering; and legacy + assignment versus new assignment behavior. +- Request/fault evidence: a repeated registry invocation with the same run/resource + (representing reconnect/event replay) preserves `pi:run-a:resource-a`; a real + `DataServiceCloudClient` authoritative 401, one forced token refresh, operations, + adapter, and registry invocation preserves that same ID. All ten Data Service + tools route representative `DataServiceOperations` faults through the real adapter + and registry into `productToolDetailsOfResult`, including quota context, + `actual`/`limit`, `current_revision`, and `retry_after_seconds`. +- `corepack pnpm test`: 201 files / 1,679 passed / 2 skipped, followed by the pressure + file 1 file / 1 passed (202 files / 1,680 passed / 2 skipped total). +- `corepack pnpm run typecheck`: passed. +- `corepack pnpm exec eslint .`: passed with zero errors and five pre-existing + warnings in non-owned `src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`. +- `corepack pnpm run build`: passed through Renderer/Main/Preload/utility builds, + Pi runtime bundle, Windows unpacked artifact, and NSIS installer. +- `node scripts/verify-pi-product-artifact.mjs --app-exe + release/win-unpacked/Makelore.exe --samples 1 --timeout-ms 10000`: result `pass`; + package proof reports four core Skills and package-owned Data Service with ten + tools and five package files. An earlier invocation used an unsupported + `--project-root` argument and failed before verification; rerunning with the + documented interface passed without a source/script change. + +## Follow-ups + +- Coordinator will rerun the required integration ledger on the integrated commit. +- No full external-provider verification is claimed; the artifact verifier reports + its existing Phase-0 platform/provider waivers separately from this remediation. + +## Promotion Candidates + +- None recorded. diff --git a/README.md b/README.md index 61e1f11..8045cfb 100644 --- a/README.md +++ b/README.md @@ -114,9 +114,10 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p ### 项目内置编码 Skills -- 项目随产品提供 `agent-browser`(开发浏览器)、`frontend-slides`(项目演示)、`grilling`(方案质询)、`planning-with-files`(项目规划)和 `data-service`(开发数据)五个编码 Skill。它们从 vendor-neutral 的 `resources/coding-skills/` 打包,由 Electron Main 按 Agent 选择直接加载;未选择的 Skill 不进入该 Agent 的 Pi 资源集合。 +- 产品内置四个核心编码 Skill 根:`agent-browser`(开发浏览器)、`frontend-slides`(项目演示)、`grilling`(方案质询)和 `planning-with-files`(项目规划),统一从 vendor-neutral 的 `resources/coding-skills/` 打包。`data-service`(开发数据)则由固定的 `resources/coding-plugins/data-service/` 插件包持有,不在核心 Skill 根中复制路径或定义。 +- 项目插件“启用”和把插件 Skill 分配给伙伴是两个独立动作:只有项目已启用 Data Service 时,未分配的 `data-service` 才可供新选择;禁用后,已有分配仍会显示并继续保存在 Agent 的 `skillIds`,但处于不可用且不生效的状态,重新启用后恢复生效。未选择的 Skill 不进入该 Agent 的有效 Pi 资源集合。 - `data-service` 只在用户显式请求后触发:先检查并说明最小集合,用户确认后配置一次、复制 SDK 资产,再用本地预览执行 put/read-back;它不用于已发布作品。 -- 创建项目伙伴时,`agent-browser`、`grilling` 与 `planning-with-files` 默认勾选;`frontend-slides` 作为专项能力可手动选择。用户可以在创建或维护伙伴时调整选择。最终选择写入项目 Agent 的 `skillIds`,未选择的 Skill 保持拒绝权限。 +- 创建项目伙伴时,`agent-browser`、`grilling` 与 `planning-with-files` 默认勾选;`frontend-slides` 作为专项能力可手动选择。用户可以在创建或维护伙伴时调整选择。最终选择写入项目 Agent 的 `skillIds`。 - `grilling` 会在复杂实现前逐项确认高影响决策,用户确认前不执行变更。`planning-with-files` 只在复杂、可分阶段或需要跨会话恢复的任务中使用,并把 `task_plan.md`、`findings.md` 和 `progress.md` 直接保存到当前项目根目录,不写入 Skill 安装目录、用户目录或 `.niancode/agent-planning/`。 - `frontend-slides` 只在用户准备项目展示、汇报或结题时自动调用,生成项目目录中的固定 16:9 HTML 演示和相对路径素材;它不生成 `.pptx`,不访问云部署服务。 diff --git a/electron/api/coding-composition.ts b/electron/api/coding-composition.ts index fec1102..f163f3a 100644 --- a/electron/api/coding-composition.ts +++ b/electron/api/coding-composition.ts @@ -42,6 +42,10 @@ import { } from '../coding-plugins/registry'; import { createDataServicePluginAdapter } from '../coding-plugins/adapters/data-service'; import { PluginPolicyClient } from '../services/plugin-policy-client'; +import { + loadBundledCodingPluginDefinitionsSync, + resolveBundledCodingPluginRootPaths, +} from '../coding-plugins/manifest'; import { createCodingProjectPluginService, type CodingProjectPluginService, @@ -88,6 +92,20 @@ export function createCodingComposition( options: CreateCodingCompositionOptions, ): CodingProductComposition { const getLocalProxyCredential = options.getLocalProxyCredential; + const bundledPluginsDir = path.join( + path.dirname(path.resolve(options.paths.bundledSkillsDir)), + 'coding-plugins', + ); + const pluginDefinitions = loadBundledCodingPluginDefinitionsSync(bundledPluginsDir); + const pluginRoots = resolveBundledCodingPluginRootPaths(bundledPluginsDir); + const pluginSkillSources = pluginDefinitions.flatMap((definition, index) => ( + definition.skills.map((skill) => ({ + id: skill.id, + pluginId: definition.id, + directory: path.join(pluginRoots[index] as string, path.dirname(skill.entryPath)), + entryPath: path.basename(skill.entryPath), + })) + )); const projectStore = options.projectStore ?? createCodingProjectStore(options.storage); const attachments = new CodingAttachmentStore( path.join(options.paths.userDataDir, 'coding-runtime', 'attachments'), @@ -96,6 +114,7 @@ export function createCodingComposition( browser: options.browser, attachments, bundledSkillsDir: options.paths.bundledSkillsDir, + pluginSkillSources, }); const extensionHost = new PiManagedExtensionHost(); extensionHost.configureProductTools(productTools); @@ -156,6 +175,7 @@ export function createCodingComposition( const dataServiceAdapter = createDataServicePluginAdapter(dataService); const policyClient = new PluginPolicyClient(); const projectPlugins = createProjectPluginService({ + knownPluginIds: pluginDefinitions.map(({ id }) => id), onManagedInputsChanged: async ({ projectPath }) => { runtime?.markResourcesStale(); const conversations = await conversationStoreForProject(projectPath).read() @@ -172,6 +192,7 @@ export function createCodingComposition( policyClient, projectPlugins, adapters: [dataServiceAdapter], + definitions: pluginDefinitions, getDurableProjectId: async (projectPath, localProjectId) => { const active = await projects.requireActiveRealProjectWithIdentity(projectPath); if (active.project.id !== localProjectId) { @@ -195,6 +216,7 @@ export function createCodingComposition( projectPlugins, policyClient, adapters: [dataServiceAdapter], + definitions: pluginDefinitions, }); const workerPool = new PiWorkerPool({ processBudget, @@ -269,6 +291,7 @@ export function createCodingComposition( const host = createCodingProductHost({ projects, productTools, + getEnabledPluginIds: (projectPath) => projectPlugins.getEnabledPluginIds(projectPath), listPiCommands: (conversationId) => conversations.listLiveCommands(conversationId), }); previewDataSession = createPreviewDataSessionManager({ projects }); diff --git a/electron/api/coding-product-services.ts b/electron/api/coding-product-services.ts index 7e29944..06dd864 100644 --- a/electron/api/coding-product-services.ts +++ b/electron/api/coding-product-services.ts @@ -20,7 +20,6 @@ import type { PiProductTools } from '../coding-runtime/pi/product-tools'; import type { DataServiceOperations } from '../services/data-service-client'; import type { PreviewDataSessionManager } from '../services/preview-data-session'; import { - BUNDLED_CODING_PLUGIN_DEFINITIONS, type CodingPluginDefinition, type PluginBillingMode, } from '../../shared/coding-plugins'; @@ -97,6 +96,8 @@ export interface CodingPluginProjectProjection { items: Array<{ id: string; version: string; + contractVersion: number; + requiresBackend: boolean; displayName: string; description: string; enabled: boolean; @@ -105,7 +106,17 @@ export interface CodingPluginProjectProjection { skills: Array<{ id: string; assignedAgentIds: string[] }>; capabilities: Array<{ id: string; - operations: Array<{ id: string; billing: PublicPluginBilling }>; + operations: Array<{ + id: string; + billing: PublicPluginBilling; + tool: { + name: string; + label: string; + description: string; + mutation: 'read' | 'write' | 'destructive'; + permissions: string[]; + } | null; + }>; }>; settingsSurface: string | null; }>; @@ -137,7 +148,7 @@ export interface CreateCodingProjectPluginServiceOptions { projectPlugins: Pick; policyClient: Pick; adapters: readonly CodingPluginAdapter[]; - definitions?: readonly CodingPluginDefinition[]; + definitions: readonly CodingPluginDefinition[]; } function policyOperation( @@ -245,7 +256,7 @@ function effectiveState(input: { export function createCodingProjectPluginService( options: CreateCodingProjectPluginServiceOptions, ): CodingProjectPluginService { - const definitions = options.definitions ?? BUNDLED_CODING_PLUGIN_DEFINITIONS; + const definitions = options.definitions; const adapters = new Map(options.adapters.map((adapter) => [adapter.pluginId, adapter])); async function project(localProjectId: string) { @@ -278,13 +289,27 @@ export function createCodingProjectPluginService( const policyAvailable = Boolean( pluginPolicy?.supported_contract_versions.includes(definition.contractVersion), ); - const capabilityIds = [...new Set(definition.tools.map(({ capabilityId }) => capabilityId))]; + const capabilityIds = [...new Set(definition.operations.map(({ capabilityId }) => capabilityId))]; const capabilities = capabilityIds.flatMap((capabilityId) => { - const operations = definition.tools - .filter((tool) => tool.capabilityId === capabilityId) - .flatMap((tool) => { - const joined = policyOperation(policy, definition, capabilityId, tool.operation); - return joined ? [{ id: tool.operation, billing: publicBilling(joined.billing, policy) }] : []; + const operations = definition.operations + .filter((candidate) => candidate.capabilityId === capabilityId) + .flatMap((candidate) => { + const joined = policyOperation(policy, definition, capabilityId, candidate.operation); + if (!joined) return []; + const tool = candidate.toolName + ? definition.tools.find(({ name }) => name === candidate.toolName) + : undefined; + return [{ + id: candidate.operation, + billing: publicBilling(joined.billing, policy), + tool: tool ? { + name: tool.name, + label: tool.label, + description: tool.description, + mutation: tool.mutation, + permissions: [...tool.permissions], + } : null, + }]; }); return operations.length > 0 ? [{ id: capabilityId, operations }] : []; }); @@ -292,6 +317,8 @@ export function createCodingProjectPluginService( return { id: definition.id, version: definition.version, + contractVersion: definition.contractVersion, + requiresBackend: definition.requiresBackend, displayName: definition.displayName, description: definition.description, enabled, @@ -349,6 +376,7 @@ export interface CodingProductHostOptions { projects: Pick; productTools: PiProductTools; files?: CodingProjectFileService; + getEnabledPluginIds?(projectPath: string): Promise; listPiCommands?(conversationId: string): Promise; } @@ -455,16 +483,23 @@ export function createCodingProductHost(options: CodingProductHostOptions): Codi }, async listSkills(agentId) { const project = await activeProject(); + const enabledPluginIds = options.getEnabledPluginIds + ? await options.getEnabledPluginIds(project.path) + : []; return await options.productTools.listSkills( await selectedSkillIds(project.path, agentId), + enabledPluginIds, ); }, async listCommands(conversationId) { const context = await conversationContext(conversationId); + const enabledPluginIds = options.getEnabledPluginIds + ? await options.getEnabledPluginIds(context.project.path) + : []; const piCommands = options.listPiCommands ? normalizePiCommands(await options.listPiCommands(conversationId)) : []; - return await options.productTools.listCommands(context.skillIds, piCommands); + return await options.productTools.listCommands(context.skillIds, piCommands, enabledPluginIds); }, async getChanges(conversationId) { await conversationContext(conversationId); diff --git a/electron/coding-plugins/manifest.ts b/electron/coding-plugins/manifest.ts index 7adfbe4..e82090c 100644 --- a/electron/coding-plugins/manifest.ts +++ b/electron/coding-plugins/manifest.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { @@ -7,6 +8,7 @@ import { BUNDLED_CODING_PLUGIN_SETTINGS_SURFACES, CODE_OWNED_PLUGIN_PERMISSION_IDS, DATA_SERVICE_CAPABILITY_IDS, + DATA_SERVICE_OPERATION_DEFINITIONS, DATA_SERVICE_PLUGIN_ID, DATA_SERVICE_TOOL_NAMES, type AgentPluginsRootManifest, @@ -563,6 +565,9 @@ export function parseCodingPluginManifest( requiresBackend, skills, tools, + operations: pluginId === DATA_SERVICE_PLUGIN_ID + ? DATA_SERVICE_OPERATION_DEFINITIONS + : tools.map(({ capabilityId, operation, name }) => ({ capabilityId, operation, toolName: name })), surfaces: normalizedSurfaces, }); validateDataServiceDefinition(definition, capabilityManifestPath); @@ -613,6 +618,42 @@ export async function loadCodingPluginDefinition(packageRoot: string): Promise path.join(root, relativeRoot)); @@ -620,22 +661,7 @@ export function resolveBundledCodingPluginRootPaths(resourcesRoot: string): stri export const resolveBundledPluginRoots = resolveBundledCodingPluginRootPaths; -export async function loadBundledCodingPluginDefinitions( - resourcesRoot: string, -): Promise { - const roots = resolveBundledCodingPluginRootPaths(resourcesRoot); - const definitions = await Promise.all(roots.map(async (root, index) => { - const definition = await loadCodingPluginDefinition(root); - const expectedRoot = BUNDLED_CODING_PLUGIN_ROOTS[index]; - if (definition.id !== `makelore.${expectedRoot}`) { - throw new CodingPluginManifestError( - path.join(root, PACKAGE_MANIFEST_FILE), - 'name', - `fixed bundle root ${expectedRoot} contains ${definition.id}`, - ); - } - return definition; - })); +function validateBundledDefinitions(definitions: readonly CodingPluginDefinition[]): void { const pluginIds = new Set(); const capabilityIds = new Set(); const skillIds = new Set(); @@ -657,7 +683,46 @@ export async function loadBundledCodingPluginDefinitions( capabilityIds.add(tool.capabilityId); } } - return definitions; +} + +export async function loadBundledCodingPluginDefinitions( + resourcesRoot: string, +): Promise { + const roots = resolveBundledCodingPluginRootPaths(resourcesRoot); + const definitions = await Promise.all(roots.map(async (root, index) => { + const definition = await loadCodingPluginDefinition(root); + const expectedRoot = BUNDLED_CODING_PLUGIN_ROOTS[index]; + if (definition.id !== `makelore.${expectedRoot}`) { + throw new CodingPluginManifestError( + path.join(root, PACKAGE_MANIFEST_FILE), + 'name', + `fixed bundle root ${expectedRoot} contains ${definition.id}`, + ); + } + return definition; + })); + validateBundledDefinitions(definitions); + return Object.freeze(definitions); +} + +export function loadBundledCodingPluginDefinitionsSync( + resourcesRoot: string, +): readonly CodingPluginDefinition[] { + const roots = resolveBundledCodingPluginRootPaths(resourcesRoot); + const definitions = roots.map((root, index) => { + const definition = loadCodingPluginDefinitionSync(root); + const expectedRoot = BUNDLED_CODING_PLUGIN_ROOTS[index]; + if (definition.id !== `makelore.${expectedRoot}`) { + throw new CodingPluginManifestError( + path.join(root, PACKAGE_MANIFEST_FILE), + 'name', + `fixed bundle root ${expectedRoot} contains ${definition.id}`, + ); + } + return definition; + }); + validateBundledDefinitions(definitions); + return Object.freeze(definitions); } export const parseBundledCodingPlugins = loadBundledCodingPluginDefinitions; diff --git a/electron/coding-plugins/project-service.ts b/electron/coding-plugins/project-service.ts index b263f52..6d68979 100644 --- a/electron/coding-plugins/project-service.ts +++ b/electron/coding-plugins/project-service.ts @@ -2,7 +2,6 @@ import path from 'node:path'; import { atomicWriteJson, readJsonFile, type JsonFileWriter } from '../coding-projects/atomic-json'; import { readCodingProjectConfigV2 } from '../coding-projects/project-config'; import { - BUNDLED_CODING_PLUGIN_DEFINITIONS, DATA_SERVICE_PLUGIN_ID, } from '../../shared/coding-plugins'; @@ -98,7 +97,7 @@ function knownPluginIdSet( ): Set { const source = typeof value === 'function' ? value() - : value ?? BUNDLED_CODING_PLUGIN_DEFINITIONS.map(({ id }) => id); + : value ?? [DATA_SERVICE_PLUGIN_ID]; return new Set(source.map((id) => id.trim()).filter(Boolean)); } @@ -193,6 +192,7 @@ interface ReadSelectionResult { export class ProjectPluginService { private readonly mutationTails = new Map>(); private readonly managedInputRevisions = new Map(); + private readonly legacySelections = new Map(); constructor(private readonly options: ProjectPluginServiceOptions = {}) {} @@ -211,6 +211,7 @@ export class ProjectPluginService { const result = await this.readFile(filePath); const knownIds = knownPluginIdSet(this.options.knownPluginIds); if (result.file) { + this.legacySelections.delete(project); const enabledPluginIds = result.file.enabledPluginIds; return { projectPath: project, @@ -225,7 +226,11 @@ export class ProjectPluginService { }; } - const legacyProjectedPluginIds = await this.legacyProjectedPluginIds(project); + let legacyProjectedPluginIds = this.legacySelections.get(project); + if (!legacyProjectedPluginIds) { + legacyProjectedPluginIds = Object.freeze(await this.legacyProjectedPluginIds(project)); + this.legacySelections.set(project, legacyProjectedPluginIds); + } const enabledPluginIds = [...legacyProjectedPluginIds]; return { projectPath: project, diff --git a/electron/coding-plugins/registry.ts b/electron/coding-plugins/registry.ts index a5fbd3e..915ffd0 100644 --- a/electron/coding-plugins/registry.ts +++ b/electron/coding-plugins/registry.ts @@ -1,5 +1,4 @@ import { - BUNDLED_CODING_PLUGIN_DEFINITIONS, type CodingPluginDefinition, type CodingPluginToolDefinition, type PluginBillingMode, @@ -120,7 +119,7 @@ export interface CodingCapabilityRegistryOptions { getEnabledPluginIds(projectPath: string): Promise; }; adapters: readonly CodingPluginAdapter[]; - definitions?: readonly CodingPluginDefinition[]; + definitions: readonly CodingPluginDefinition[]; getDurableProjectId?: (projectPath: string, localProjectId: string) => Promise | string; } @@ -340,7 +339,7 @@ export class CodingCapabilityRegistryImpl implements CodingCapabilityRegistryPor private readonly adaptersByPluginId: ReadonlyMap; constructor(private readonly options: CodingCapabilityRegistryOptions) { - const definitions = (options.definitions ?? BUNDLED_CODING_PLUGIN_DEFINITIONS).filter(definitionValid); + const definitions = options.definitions.filter(definitionValid); this.definitions = Object.freeze([...definitions]); const tools = new Map(); for (const definition of this.definitions) { diff --git a/electron/coding-projects/skill-registry.ts b/electron/coding-projects/skill-registry.ts index 26446b7..1d76e53 100644 --- a/electron/coding-projects/skill-registry.ts +++ b/electron/coding-projects/skill-registry.ts @@ -1,9 +1,8 @@ import { readFile, readdir } from 'node:fs/promises'; import path from 'node:path'; -import { DATA_SERVICE_PLUGIN_DEFINITION } from '../../shared/coding-plugins'; import { BUNDLED_CODING_SKILL_IDS, - type BundledCodingSkillId, + type CodingSkillId, } from '../../shared/coding-skills'; import type { ProductCodingCommand, @@ -29,7 +28,9 @@ const COMMAND_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/; export interface ProductCodingPluginSkillSource { id: string; + pluginId?: string; directory: string; + available?: boolean; /** Package-relative Skill entry; defaults to `SKILL.md`. */ entryPath?: string; } @@ -47,20 +48,6 @@ function frontmatterScalar(content: string, key: string): string | undefined { return value || undefined; } -function defaultPluginSkillSources(bundledSkillsDir: string): ProductCodingPluginSkillSource[] { - const skillId = DATA_SERVICE_PLUGIN_DEFINITION.skills[0]?.id ?? 'data-service'; - return [{ - id: skillId, - directory: path.join( - path.dirname(path.resolve(bundledSkillsDir)), - 'coding-plugins', - 'data-service', - 'skills', - 'data-service', - ), - }]; -} - function selectedSkillIds( value: readonly string[], allIds: readonly string[], @@ -75,11 +62,8 @@ function selectedSkillIds( return selected; } -function productSkillId(id: string): BundledCodingSkillId { - // ProductCodingSkill predates package-owned Skill ids. The registry is - // the trusted projection boundary, so the runtime value may contain a - // package Skill while the shared contract is migrated by the caller. - return id as BundledCodingSkillId; +function productSkillId(id: string): CodingSkillId { + return id; } async function listSkillEntries( @@ -103,7 +87,7 @@ async function listSkillEntries( export async function listProductCodingSkills( bundledSkillsDir: string, selectedIds: readonly string[] = [], - pluginSkillSources: readonly ProductCodingPluginSkillSource[] = defaultPluginSkillSources(bundledSkillsDir), + pluginSkillSources: readonly ProductCodingPluginSkillSource[] = [], ): Promise { const pluginIds = pluginSkillSources.map(({ id }) => id); const allIds = [...BUNDLED_CODING_SKILL_IDS, ...pluginIds]; @@ -118,7 +102,8 @@ export async function listProductCodingSkills( })), ...pluginSkillSources, ]; - return await Promise.all(sources.map(async ({ id, directory, entryPath }) => { + const visibleSources = sources.filter(({ id, available }) => available !== false || selected.has(id)); + return await Promise.all(visibleSources.map(async ({ id, directory, entryPath, available = true }) => { const location = path.resolve(directory); const content = await readFile(path.join(location, entryPath ?? 'SKILL.md'), 'utf8'); return { @@ -126,6 +111,8 @@ export async function listProductCodingSkills( name: frontmatterScalar(content, 'name') ?? id, description: frontmatterScalar(content, 'description') ?? '', selected: selected.has(id), + available, + effective: available && selected.has(id), location, content, entries: await listSkillEntries(location), @@ -153,7 +140,7 @@ export function buildProductCodingCommandCatalog( } for (const skill of skills) { const key = skill.id.toLocaleLowerCase(); - if (!skill.selected || used.has(key)) continue; + if (!skill.effective || used.has(key)) continue; used.add(key); commands.push({ name: skill.id, diff --git a/electron/coding-runtime/pi/product-tools.ts b/electron/coding-runtime/pi/product-tools.ts index a768f10..dd14779 100644 --- a/electron/coding-runtime/pi/product-tools.ts +++ b/electron/coding-runtime/pi/product-tools.ts @@ -14,20 +14,11 @@ import type { ProductPiCommandInput, } from '../../../shared/coding-product-tools'; import { - DATA_SERVICE_PI_TOOL_NAMES, - type DataServicePiToolName, -} from '../../../shared/data-service'; + type ProductCodingPluginSkillSource, +} from '../../coding-projects/skill-registry'; import { - DATA_SERVICE_PLUGIN_DEFINITION, -} from '../../../shared/coding-plugins'; -import { createDataServicePluginAdapter } from '../../coding-plugins/adapters/data-service'; -import { - buildCapabilityToolResult, - type AdapterInvocationResult, type CodingCapabilityRegistry, - type TrustedCodingCapabilityContext, } from '../../coding-plugins/registry'; -import type { DataServiceOperations } from '../../services/data-service-client'; import type { KnownToolDetails, RuntimeContextDetailsV1 } from '../contracts'; import { PiAgentBrowserTool } from './extensions/agent-browser'; import { reportChangedFiles } from './extensions/changed-file'; @@ -40,10 +31,7 @@ export type PiProductToolName = | 'game_asset_review' | 'task_state' | 'changed_file' - | 'runtime_context' - | DataServicePiToolName; - -export { DATA_SERVICE_PI_TOOL_NAMES }; + | 'runtime_context'; const PI_PRODUCT_TOOL_NAMES = new Set([ 'agent_browser', @@ -52,7 +40,6 @@ const PI_PRODUCT_TOOL_NAMES = new Set([ 'task_state', 'changed_file', 'runtime_context', - ...DATA_SERVICE_PI_TOOL_NAMES, ]); export function isPiProductToolName(value: unknown): value is PiProductToolName { @@ -78,7 +65,7 @@ export interface PiProductToolsOptions { attachments: CodingAttachmentStore; bundledSkillsDir: string; changeTracker?: ConversationChangeTracker; - dataService?: DataServiceOperations; + pluginSkillSources?: readonly ProductCodingPluginSkillSource[]; capabilityRegistry?: CodingCapabilityRegistry; } @@ -86,20 +73,12 @@ export class PiProductTools { readonly changeTracker: ConversationChangeTracker; private readonly browser: PiAgentBrowserTool; private readonly gameAssets = new PiGameAssetTools(); - private dataServiceAdapter: ReturnType | undefined; private capabilityRegistry: CodingCapabilityRegistry | undefined; constructor(private readonly options: PiProductToolsOptions) { this.changeTracker = options.changeTracker ?? new ConversationChangeTracker(); this.browser = new PiAgentBrowserTool(options.browser, options.attachments); this.capabilityRegistry = options.capabilityRegistry; - this.dataServiceAdapter = options.dataService - ? createDataServicePluginAdapter(options.dataService) - : undefined; - } - - configureDataService(dataService: DataServiceOperations): void { - this.dataServiceAdapter = createDataServicePluginAdapter(dataService); } configureCapabilityRegistry(registry: CodingCapabilityRegistry): void { @@ -118,15 +97,27 @@ export class PiProductTools { return this.changeTracker.getSnapshot(conversationId); } - listSkills(skillIds: readonly string[]): Promise { - return listProductCodingSkills(this.options.bundledSkillsDir, skillIds); + listSkills( + skillIds: readonly string[], + availablePluginSkillIds: readonly string[] = [], + ): Promise { + const available = new Set(availablePluginSkillIds); + const sources = (this.options.pluginSkillSources ?? []).map((source) => ({ + ...source, + available: available.has(source.pluginId ?? source.id) || available.has(source.id), + })); + return listProductCodingSkills(this.options.bundledSkillsDir, skillIds, sources); } async listCommands( skillIds: readonly string[], piCommands: readonly ProductPiCommandInput[] = [], + availablePluginSkillIds: readonly string[] = [], ): Promise { - return buildProductCodingCommandCatalog(await this.listSkills(skillIds), piCommands); + return buildProductCodingCommandCatalog( + await this.listSkills(skillIds, availablePluginSkillIds), + piCommands, + ); } async markBash(conversationId: string, runId: string): Promise { @@ -164,32 +155,8 @@ export class PiProductTools { value: input, }); } - if (DATA_SERVICE_PI_TOOL_NAMES.includes(toolName as DataServicePiToolName)) { - const adapter = this.dataServiceAdapter; - const definition = DATA_SERVICE_PLUGIN_DEFINITION.tools.find(({ name }) => name === toolName); - if (!adapter || !definition) throw new Error('Data Service tools are unavailable'); - const trustedContext: TrustedCodingCapabilityContext = { - conversationId: context.conversationId, - runId: context.runId, - resourceId: context.resourceId, - requestId: `pi:${context.runId}:${context.resourceId}`, - localProjectId: context.projectId, - projectPath: context.projectPath, - durableProjectId: context.projectId, - workerRole: 'parent', - effectiveSkillIds: [...context.skillIds], - }; - const result: AdapterInvocationResult = await adapter.invoke(trustedContext, definition, input); - return buildCapabilityToolResult( - DATA_SERVICE_PLUGIN_DEFINITION, - definition, - context, - result, - { mode: 'included', status: 'included' }, - ); - } if (toolName !== 'runtime_context') throw new Error('Product tool is unavailable'); - const skills = await this.listSkills(context.skillIds); + const skills = await this.listSkills(context.skillIds, context.skillIds); const details: RuntimeContextDetailsV1 = { schema: 'runtime-context.v1', skills, diff --git a/electron/services/plugin-policy-client.ts b/electron/services/plugin-policy-client.ts index 4386788..a94d7bd 100644 --- a/electron/services/plugin-policy-client.ts +++ b/electron/services/plugin-policy-client.ts @@ -1,5 +1,5 @@ import { WORKS_SQUARE_CONFIG } from '../api/works-config'; -import { proxyAwareFetch } from '../utils/proxy-fetch'; +import { proxyAwareFetch, runWithDeadline } from '../utils/proxy-fetch'; const MAX_CATALOG_BYTES = 1_310_720; const MAX_CATALOG_VERSION = 64; @@ -7,6 +7,7 @@ const MAX_PLUGIN_ID = 48; const MAX_CAPABILITY_ID = 64; const MAX_OPERATION_ID = 64; const MAX_NOTICE = 160; +const DEFAULT_POLICY_REQUEST_TIMEOUT_MS = 10_000; const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9.-]{0,47}$/u; const CAPABILITY_ID_PATTERN = /^[a-z][a-z0-9.-]{0,63}$/u; const OPERATION_ID_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/u; @@ -86,6 +87,7 @@ export interface PluginPolicyClientOptions { fetchImpl?: FetchImplementation; apiBaseUrl?: string; now?: () => number; + requestTimeoutMs?: number; } export class PluginPolicyCatalogError extends Error { @@ -384,6 +386,7 @@ export class PluginPolicyClient { private readonly fetchImpl: FetchImplementation; private readonly apiBaseUrl: string; private readonly now: () => number; + private readonly requestTimeoutMs: number; private state: PluginPolicyClientState = { status: 'unavailable', catalog: null, @@ -396,6 +399,7 @@ export class PluginPolicyClient { this.fetchImpl = options.fetchImpl ?? proxyAwareFetch; this.apiBaseUrl = (options.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/u, ''); this.now = options.now ?? (() => Date.now()); + this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_POLICY_REQUEST_TIMEOUT_MS; } getState(): PluginPolicyClientState { @@ -442,24 +446,26 @@ export class PluginPolicyClient { } private async fetchCatalog(): Promise { - let response: Response; try { - response = await this.fetchImpl( - `${this.apiBaseUrl}/api/plugins/v1/catalog`, - { - method: 'GET', - headers: { Accept: 'application/json' }, - redirect: 'manual', - }, - ); + return await runWithDeadline(async (signal) => { + const response = await this.fetchImpl( + `${this.apiBaseUrl}/api/plugins/v1/catalog`, + { + method: 'GET', + headers: { Accept: 'application/json' }, + redirect: 'manual', + signal, + }, + ); + if (!response.ok) { + await response.body?.cancel().catch(() => undefined); + throw new PluginPolicyCatalogError('catalog request failed'); + } + return await readCatalog(response); + }, this.requestTimeoutMs); } catch { throw new PluginPolicyCatalogError('catalog request failed'); } - if (!response.ok) { - await response.body?.cancel().catch(() => undefined); - throw new PluginPolicyCatalogError('catalog request failed'); - } - return await readCatalog(response); } } diff --git a/shared/coding-plugins.ts b/shared/coding-plugins.ts index 7dbea3c..cf98da7 100644 --- a/shared/coding-plugins.ts +++ b/shared/coding-plugins.ts @@ -31,6 +31,12 @@ export interface CodingPluginToolDefinition { inputSchema: Readonly>; } +export interface CodingPluginOperationDefinition { + capabilityId: string; + operation: string; + toolName?: string; +} + export interface CodingPluginDefinition { id: string; version: string; @@ -42,6 +48,7 @@ export interface CodingPluginDefinition { requiresBackend: boolean; skills: readonly CodingPluginSkillDefinition[]; tools: readonly CodingPluginToolDefinition[]; + operations: readonly CodingPluginOperationDefinition[]; surfaces: Readonly<{ projectSettings?: string; previewRuntime?: string; @@ -379,6 +386,18 @@ export const DATA_SERVICE_TOOL_DEFINITIONS = freezeDeep([ ), ] as const satisfies readonly CodingPluginToolDefinition[]); +export const DATA_SERVICE_OPERATION_DEFINITIONS = freezeDeep([ + ...DATA_SERVICE_TOOL_DEFINITIONS.map(({ capabilityId, operation, name }) => ({ + capabilityId, + operation, + toolName: name, + })), + ...['get', 'list', 'put', 'delete'].map((operation) => ({ + capabilityId: 'data-service.preview', + operation, + })), +] as const satisfies readonly CodingPluginOperationDefinition[]); + export const DATA_SERVICE_PLUGIN_DEFINITION = freezeDeep({ id: DATA_SERVICE_PLUGIN_ID, version: '1.0.0', @@ -396,6 +415,7 @@ export const DATA_SERVICE_PLUGIN_DEFINITION = freezeDeep({ }, ], tools: DATA_SERVICE_TOOL_DEFINITIONS, + operations: DATA_SERVICE_OPERATION_DEFINITIONS, surfaces: { projectSettings: DATA_SERVICE_PROJECT_SETTINGS_SURFACE, previewRuntime: DATA_SERVICE_PREVIEW_RUNTIME_SURFACE, diff --git a/shared/coding-product-tools.ts b/shared/coding-product-tools.ts index c36f717..3f18cce 100644 --- a/shared/coding-product-tools.ts +++ b/shared/coding-product-tools.ts @@ -1,4 +1,4 @@ -import type { BundledCodingSkillId } from './coding-skills'; +import type { CodingSkillId } from './coding-skills'; export type CodingProjectFileStatus = | 'added' @@ -53,10 +53,12 @@ export interface ConversationChangesSnapshot { } export interface ProductCodingSkill { - id: BundledCodingSkillId; + id: CodingSkillId; name: string; description: string; selected: boolean; + available: boolean; + effective: boolean; location: string; content: string; entries: Array<{ path: string; type: 'file' | 'directory' }>; @@ -67,7 +69,7 @@ export interface ProductCodingCommand { title: string; description: string; source: 'makelore' | 'pi' | 'skill'; - skillId?: BundledCodingSkillId; + skillId?: CodingSkillId; } export interface ProductPiCommandInput { diff --git a/src/components/coding/AgentCreationDialog.tsx b/src/components/coding/AgentCreationDialog.tsx index 97566f1..a8547ad 100644 --- a/src/components/coding/AgentCreationDialog.tsx +++ b/src/components/coding/AgentCreationDialog.tsx @@ -17,6 +17,8 @@ export type AgentCreationSkillOption = { id: string; name: string; description: string; + available?: boolean; + effective?: boolean; }; export type AgentCreationInput = { @@ -194,7 +196,7 @@ export function AgentCreationDialog({

绑定技能

已选 {input.skillIds.length}
{skills.length > 0 ? <>
setSkillQuery(event.target.value)} placeholder="搜索技能" className="bg-background pl-9" />
-
{filteredSkills.map((skill) => { const checked = input.skillIds.includes(skill.id); return ; })}{filteredSkills.length === 0 ?

没有匹配的技能

: null}
+
{filteredSkills.map((skill) => { const checked = input.skillIds.includes(skill.id); const unavailable = skill.available === false; return ; })}{filteredSkills.length === 0 ?

没有匹配的技能

: null}
:

当前暂无可绑定技能。

} diff --git a/src/lib/coding-plugins.ts b/src/lib/coding-plugins.ts index c1973bf..a8d4e93 100644 --- a/src/lib/coding-plugins.ts +++ b/src/lib/coding-plugins.ts @@ -31,13 +31,25 @@ export type PluginBilling = { export type CodingPluginItem = { id: string; version: string; + contractVersion: number; + requiresBackend: boolean; displayName: string; description: string; enabled: boolean; state: CodingPluginEffectiveState; backend: PluginBackend; skills: Array<{ id: string; assignedAgentIds: string[] }>; - capabilities: Array<{ id: string; operations: Array<{ id: string; billing: PluginBilling }> }>; + capabilities: Array<{ id: string; operations: Array<{ + id: string; + billing: PluginBilling; + tool: { + name: string; + label: string; + description: string; + mutation: 'read' | 'write' | 'destructive'; + permissions: string[]; + } | null; + }> }>; settingsSurface: string | null; }; @@ -120,13 +132,18 @@ function billing(value: unknown): PluginBilling { function pluginItem(value: unknown): CodingPluginItem { const source = record(value, 'plugin'); - exact(source, ['id', 'version', 'displayName', 'description', 'enabled', 'state', 'backend', 'skills', 'capabilities', 'settingsSurface'], 'plugin'); - if (typeof source.enabled !== 'boolean') throw new Error('plugin.enabled is invalid'); + exact(source, ['id', 'version', 'contractVersion', 'requiresBackend', 'displayName', 'description', 'enabled', 'state', 'backend', 'skills', 'capabilities', 'settingsSurface'], 'plugin'); + if (typeof source.enabled !== 'boolean' || typeof source.requiresBackend !== 'boolean' + || !Number.isSafeInteger(source.contractVersion) || (source.contractVersion as number) < 1) { + throw new Error('plugin metadata is invalid'); + } const state = string(source.state, 'plugin.state', 32) as CodingPluginEffectiveState; if (!['unavailable', 'disabled', 'identity_required', 'authentication_required', 'configuration_required', 'ready', 'degraded'].includes(state)) throw new Error('plugin.state is invalid'); if (!Array.isArray(source.skills) || !Array.isArray(source.capabilities)) throw new Error('plugin arrays are invalid'); return { id: string(source.id, 'plugin.id', 128), version: string(source.version, 'plugin.version', 64), + contractVersion: source.contractVersion as number, + requiresBackend: source.requiresBackend, displayName: string(source.displayName, 'plugin.displayName'), description: string(source.description, 'plugin.description', 512), enabled: source.enabled, state, backend: backend(source.backend), skills: source.skills.map((value) => { @@ -138,8 +155,24 @@ function pluginItem(value: unknown): CodingPluginItem { const capability = record(value, 'capability'); exact(capability, ['id', 'operations'], 'capability'); if (!Array.isArray(capability.operations)) throw new Error('capability.operations is invalid'); return { id: string(capability.id, 'capability.id', 128), operations: capability.operations.map((value) => { - const operation = record(value, 'operation'); exact(operation, ['id', 'billing'], 'operation'); - return { id: string(operation.id, 'operation.id', 128), billing: billing(operation.billing) }; + const operation = record(value, 'operation'); exact(operation, ['id', 'billing', 'tool'], 'operation'); + let tool: CodingPluginItem['capabilities'][number]['operations'][number]['tool'] = null; + if (operation.tool !== null) { + const sourceTool = record(operation.tool, 'operation.tool'); + exact(sourceTool, ['name', 'label', 'description', 'mutation', 'permissions'], 'operation.tool'); + const mutation = string(sourceTool.mutation, 'operation.tool.mutation', 16); + if (!['read', 'write', 'destructive'].includes(mutation) || !Array.isArray(sourceTool.permissions)) { + throw new Error('operation.tool is invalid'); + } + tool = { + name: string(sourceTool.name, 'operation.tool.name', 64), + label: string(sourceTool.label, 'operation.tool.label', 128), + description: string(sourceTool.description, 'operation.tool.description', 512), + mutation: mutation as 'read' | 'write' | 'destructive', + permissions: sourceTool.permissions.map((permission) => string(permission, 'operation.tool.permission', 64)), + }; + } + return { id: string(operation.id, 'operation.id', 128), billing: billing(operation.billing), tool }; }) }; }), settingsSurface: source.settingsSurface === null ? null : string(source.settingsSurface, 'plugin.settingsSurface', 64), diff --git a/src/pages/ProjectConfiguration/index.tsx b/src/pages/ProjectConfiguration/index.tsx index a4a8c40..8b34379 100644 --- a/src/pages/ProjectConfiguration/index.tsx +++ b/src/pages/ProjectConfiguration/index.tsx @@ -26,7 +26,16 @@ const EMPTY_FILES: string[] = []; type DrawerMode = 'models' | 'knowledge' | 'skills' | null; type ProjectIdentityKind = ProjectIdentityChoice['kind']; type SkillStructureEntry = { path: string; type: 'file' | 'directory' }; -type SkillInfo = { id: string; name: string; description: string; location: string; content: string; entries: SkillStructureEntry[] }; +type SkillInfo = { + id: string; + name: string; + description: string; + available: boolean; + effective: boolean; + location: string; + content: string; + entries: SkillStructureEntry[]; +}; function createCustomAgent(index: number, modelKey: string): CodingProjectAgent { const now = new Date().toISOString(); @@ -154,6 +163,8 @@ export function ProjectConfiguration() { .then((result) => setSkills(result.skills.map((skill) => ({ id: skill.id, ...getSkillDisplayInfo(skill.id), + available: skill.available, + effective: skill.effective, location: skill.location ?? '', content: skill.content ?? '', entries: skill.entries ?? [{ path: 'SKILL.md', type: 'file' }], diff --git a/src/pages/ProjectPlugins/index.tsx b/src/pages/ProjectPlugins/index.tsx index 91bd260..cdad1e3 100644 --- a/src/pages/ProjectPlugins/index.tsx +++ b/src/pages/ProjectPlugins/index.tsx @@ -11,10 +11,8 @@ import { cn } from '@/lib/utils'; import { codingPluginsStore, useCodingPluginsStore } from '@/stores/coding-plugins'; import { useCodingWorkspaceStore } from '@/stores/coding-workspace'; import type { DataServiceInstanceState } from '../../../shared/data-service'; -import { BUNDLED_CODING_PLUGIN_DEFINITIONS, type CodingPluginDefinition } from '../../../shared/coding-plugins'; const PLUGIN_SETTINGS_SURFACES = { 'data-service': DataServicePluginSettings } as const; -const definitions = new Map(BUNDLED_CODING_PLUGIN_DEFINITIONS.map((definition) => [definition.id, definition])); const STATE_TEXT: Record = { unavailable: '暂不可用', disabled: '未启用', identity_required: '需要项目 ID', @@ -60,8 +58,6 @@ export function ProjectPluginsView(props: ViewProps) { const [selectedId, setSelectedId] = useState(props.projection.items[0]?.id ?? null); const [disableCandidate, setDisableCandidate] = useState(null); const selected = props.projection.items.find(({ id }) => id === selectedId) ?? props.projection.items[0] ?? null; - const definition = selected ? definitions.get(selected.id) : undefined; - const billing = selected?.capabilities.flatMap(({ operations }) => operations.map(({ billing }) => billing))[0] ?? null; const SettingsSurface = selected?.settingsSurface && selected.settingsSurface in PLUGIN_SETTINGS_SURFACES ? PLUGIN_SETTINGS_SURFACES[selected.settingsSurface as keyof typeof PLUGIN_SETTINGS_SURFACES] : null; @@ -92,19 +88,18 @@ export function ProjectPluginsView(props: ViewProps) {

发布者

MakeLore

包版本

{selected.version}

-

合同版本

v{definition?.contractVersion ?? '—'}

-

后端

{definition?.requiresBackend ? '需要' : '不需要'}

+

合同版本

v{selected.contractVersion}

+

后端

{selected.requiresBackend ? '需要' : '不需要'}

{selected.state === 'identity_required' ? : null} {selected.state === 'authentication_required' ? : null}

能力与权限

-
{selected.capabilities.map((capability) => {capability.id})}
-
{definition?.tools.map((tool) =>
{tool.name}{MUTATION_TEXT[tool.mutation]}

{tool.description}

{tool.permissions.map((permission) => PERMISSION_TEXT[permission] ?? permission).join('、')}

)}
+
{selected.capabilities.map((capability) =>

{capability.id}

{capability.operations.map((operation) =>
{operation.id}{operation.tool ? <>{MUTATION_TEXT[operation.tool.mutation]}{operation.tool.name} : SDK}
{operation.tool ? <>

{operation.tool.description}

{operation.tool.permissions.map((permission) => PERMISSION_TEXT[permission] ?? permission).join('、')}

:

由生成的 Data Service SDK 使用,不作为 Pi 工具暴露。

}

{billingText(operation.billing)}

)}
)}

伙伴分配

{selected.skills.flatMap(({ assignedAgentIds }) => assignedAgentIds).length ? selected.skills.flatMap(({ assignedAgentIds }) => assignedAgentIds).map((id) => {props.agentNames[id] ?? '未知伙伴'}) :

尚未分配给任何伙伴。

}
-

用量与计费

{billing ? <>

{billingText(billing)}

{billing.mode === 'platform_metered' ?

钱包流水由权益所有者查看,当前页面不扩大财务权限。

: null} :

计费策略暂不可用,相关调用已停用。

}
+

用量与计费

各操作的计费模式已在上方能力列表中逐项显示;平台计量操作的流水仍由权益所有者查看。

{SettingsSurface && selected.enabled ?

配置

: null} ) :

当前没有可用插件。

} diff --git a/src/stores/coding-plugins.ts b/src/stores/coding-plugins.ts index 114683d..4474261 100644 --- a/src/stores/coding-plugins.ts +++ b/src/stores/coding-plugins.ts @@ -44,6 +44,7 @@ export function createCodingPluginsStore(overrides: Partial = {}): removeProject: removeDataServiceProject, ...overrides, }; const flights = new Map>(); + let loadGeneration = 0; const operation = (key: string, run: () => Promise): Promise => { const existing = flights.get(key); if (existing) return existing; let flight: Promise; @@ -57,7 +58,11 @@ export function createCodingPluginsStore(overrides: Partial = {}): const store = createStore((set, get) => ({ projectId: null, projection: null, dataService: null, loadState: 'idle', error: null, pending: {}, load(projectId) { - return operation(`load:${projectId}`, async () => { + const key = `load:${projectId}`; + const existing = flights.get(key); + if (existing) return existing; + const generation = ++loadGeneration; + return operation(key, async () => { set({ loadState: 'loading', error: null }); try { const projection = await deps.list(projectId); @@ -67,9 +72,13 @@ export function createCodingPluginsStore(overrides: Partial = {}): const result = await deps.inspectDataService(); if (result.success && result.data) dataService = result.data; } - set({ projectId, projection, dataService, loadState: 'ready' }); + if (generation === loadGeneration) { + set({ projectId, projection, dataService, loadState: 'ready', error: null }); + } } catch (error) { - set({ loadState: 'error', error: error instanceof Error ? error.message : String(error) }); + if (generation === loadGeneration) { + set({ loadState: 'error', error: error instanceof Error ? error.message : String(error) }); + } throw error; } }); @@ -77,9 +86,16 @@ export function createCodingPluginsStore(overrides: Partial = {}): setEnabled(projectId, pluginId, enabled) { return operation(`enabled:${projectId}:${pluginId}`, async () => { const projection = await deps.setEnabled(projectId, pluginId, enabled); + let dataService = get().projectId === projectId ? get().dataService : null; + const item = projection.items.find(({ id }) => id === pluginId); + if (!enabled) { + dataService = null; + } else if (item?.settingsSurface === 'data-service' && item.backend.status === 'ready') { + const inspected = await deps.inspectDataService(); + if (inspected.success && inspected.data) dataService = inspected.data; + } set({ - projectId, projection, error: null, - ...(!enabled || get().projectId !== projectId ? { dataService: null } : {}), + projectId, projection, dataService, error: null, }); }); }, diff --git a/tests/unit/agent-creation-dialog.test.tsx b/tests/unit/agent-creation-dialog.test.tsx new file mode 100644 index 0000000..c5df121 --- /dev/null +++ b/tests/unit/agent-creation-dialog.test.tsx @@ -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(); + + 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'] }), + )); + }); +}); diff --git a/tests/unit/coding-capability-registry.test.ts b/tests/unit/coding-capability-registry.test.ts index 49ba957..ec17101 100644 --- a/tests/unit/coding-capability-registry.test.ts +++ b/tests/unit/coding-capability-registry.test.ts @@ -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 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 => ({ + 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> = { + 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() + .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 }); + }); }); diff --git a/tests/unit/coding-plugin-composition.test.ts b/tests/unit/coding-plugin-composition.test.ts index ad50742..a22f7a4 100644 --- a/tests/unit/coding-plugin-composition.test.ts +++ b/tests/unit/coding-plugin-composition.test.ts @@ -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); diff --git a/tests/unit/coding-plugin-manifest.test.ts b/tests/unit/coding-plugin-manifest.test.ts index 2041325..9b9d372 100644 --- a/tests/unit/coding-plugin-manifest.test.ts +++ b/tests/unit/coding-plugin-manifest.test.ts @@ -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; 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); diff --git a/tests/unit/coding-plugins-client.test.ts b/tests/unit/coding-plugins-client.test.ts index cabe9c3..931c825 100644 --- a/tests/unit/coding-plugins-client.test.ts +++ b/tests/unit/coding-plugins-client.test.ts @@ -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', diff --git a/tests/unit/coding-plugins-store.test.ts b/tests/unit/coding-plugins-store.test.ts index 96e934b..4133e6d 100644 --- a/tests/unit/coding-plugins-store.test.ts +++ b/tests/unit/coding-plugins-store.test.ts @@ -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) => void; + reject: (reason: Error) => void; + }>(); + const list = vi.fn((projectId: string) => new Promise>((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); + }); }); diff --git a/tests/unit/coding-product-services.test.ts b/tests/unit/coding-product-services.test.ts index 5e0ab06..3741d99 100644 --- a/tests/unit/coding-product-services.test.ts +++ b/tests/unit/coding-product-services.test.ts @@ -34,7 +34,7 @@ async function temporaryRoot(prefix: string): Promise { return root; } -async function configuredProject(): Promise { +async function configuredProject(skillIds: readonly string[] = ['agent-browser', 'grilling']): Promise { 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 { 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 { 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'); diff --git a/tests/unit/pi-product-tools.test.ts b/tests/unit/pi-product-tools.test.ts index df2b88b..3764a8a 100644 --- a/tests/unit/pi-product-tools.test.ts +++ b/tests/unit/pi-product-tools.test.ts @@ -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'); }); }); diff --git a/tests/unit/pi-resource-loader.test.ts b/tests/unit/pi-resource-loader.test.ts index 2176c4e..9116f49 100644 --- a/tests/unit/pi-resource-loader.test.ts +++ b/tests/unit/pi-resource-loader.test.ts @@ -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' }; }, diff --git a/tests/unit/plugin-policy-client.test.ts b/tests/unit/plugin-policy-client.test.ts index 74f7490..2d73d8c 100644 --- a/tests/unit/plugin-policy-client.test.ts +++ b/tests/unit/plugin-policy-client.test.ts @@ -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((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true }); + }); + } + return Promise.resolve(new Response(new ReadableStream({ + 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(); + }, + ); }); diff --git a/tests/unit/project-plugin-service.test.ts b/tests/unit/project-plugin-service.test.ts index f2c2f45..6143266 100644 --- a/tests/unit/project-plugin-service.test.ts +++ b/tests/unit/project-plugin-service.test.ts @@ -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({ diff --git a/tests/unit/project-plugins-page.test.tsx b/tests/unit/project-plugins-page.test.tsx index 82db5ca..2d5acb9 100644 --- a/tests/unit/project-plugins-page.test.tsx +++ b/tests/unit/project-plugins-page.test.tsx @@ -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(); expect(screen.getByText('计费策略暂不可用,相关调用已停用。')).toBeVisible();