From 97c3e4590e0b49302fbd8a360cd72bf25ff56c42 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Tue, 29 Sep 2026 08:57:31 +0800 Subject: [PATCH] Bundle child-friendly replies with MakeLore and acquire it by default --- ...20260928-default-child-replies-0108dd01.md | 28 ++-- README.md | 2 +- .../coding-plugins/default-installation.ts | 20 +-- electron/coding-plugins/manifest.ts | 10 ++ package.json | 2 +- .../child-friendly-replies/.gitattributes | 1 + .../com.makelore/capability.json | 17 +++ .../child-friendly-replies/plugin.json | 14 ++ .../makelore-child-friendly-replies/SKILL.md | 26 ++++ .../agents/openai.yaml | 4 + shared/coding-plugins.ts | 10 +- tests/e2e/plugin-marketplace.spec.ts | 6 +- tests/unit/coding-plugin-manifest.test.ts | 12 +- .../unit/default-plugin-installation.test.ts | 125 ++++++++++-------- tests/unit/plugin-workspace-model.test.ts | 1 + 15 files changed, 190 insertions(+), 88 deletions(-) create mode 100644 resources/coding-plugins/child-friendly-replies/.gitattributes create mode 100644 resources/coding-plugins/child-friendly-replies/com.makelore/capability.json create mode 100644 resources/coding-plugins/child-friendly-replies/plugin.json create mode 100644 resources/coding-plugins/child-friendly-replies/skills/makelore-child-friendly-replies/SKILL.md create mode 100644 resources/coding-plugins/child-friendly-replies/skills/makelore-child-friendly-replies/agents/openai.yaml diff --git a/.project-docs/30-worklog/tasks/20260928-default-child-replies-0108dd01.md b/.project-docs/30-worklog/tasks/20260928-default-child-replies-0108dd01.md index 27f5db23..017893e7 100644 --- a/.project-docs/30-worklog/tasks/20260928-default-child-replies-0108dd01.md +++ b/.project-docs/30-worklog/tasks/20260928-default-child-replies-0108dd01.md @@ -12,32 +12,36 @@ ## Scope -- Automatically acquire and install the signed server plugin `makelore.child-friendly-replies` on session activation and Library refresh. Update the Main facade/composition, focused tests, Electron projection acceptance and README. +- Ship the original child-friendly Skill inside MakeLore 2.0.2 as code-owned bundled plugin version 1.0.1, with default account acquisition and no separate package download or signature requirement. ## Intent And Constraints -- User requests default installation. Preserve account isolation, server-signed immutable artifacts, existing client trusted-key verification and device package lifecycle. Do not turn install into project enablement or Agent assignment. Account removal and default-plugin uninstall are explicit opt-outs. +- On 2026-09-29 the user explicitly chose “改成客户端内置,默认安装”, superseding the earlier server-only package requirement. Follow the project-scaffold bundled path; keep project enablement separate, preserve account opt-out, and leave other downloaded-package signature verification unchanged. - Official check/start/status passed for the isolated feature Identity. Planning Gate passed after own task, entry/memory, positioning/current-state/decisions/architecture/domain/success/evidence and 135 peer coordination records. Positioning still includes template text; AGENTS/README and current source establish product meaning. Old Marketplace tasks overlap paths but preserve the same contracts; no semantic conflict found. Primary main remains owned by integration task `20260928-merge-teacher-cards-a09c07f3` and is untouched. - User-provided no-subagent constraint honored. Feature code and mandatory task record only; canonical docs remain unchanged. WS companion task `20260928-default-child-replies-06693309` owns readiness repair. ## Outcome -- Main acquires/downloads only this default plugin through existing Marketplace and Package Store APIs, after confirming a fresh signed compatible Artifact release. Existing installed packages are reused; removed/suspended/stale entries remain unchanged, and failed downloads retry on a later session/list refresh. -- Facade serializes account operations so an explicit removal during default installation wins, and verifies account binding around queued work. Default-plugin uninstall writes the existing account removal receipt, preventing reinstallation on restart; other plugin/device semantics remain unchanged. -- Composition starts the normal Library flow when initialized and on session changes. Successful package installation invalidates managed resources through the existing callback. Renderer still receives only safe installation projections; original Skill bytes remain server-delivered. -- Updated Electron fixture verifies the default plugin appears in the Installed tab without an install click, alongside the existing optional acquisition flow. Updated a stale header-copy assertion to the actual tab landmark for the current embedded drawer. +- Original Skill and openai.yaml are preserved byte-for-byte under resources/coding-plugins/child-friendly-replies; package-local LF attributes retain those bytes. Registered the fixed root, code-owned release ...0605 and project-wide Skill semantics alongside project-scaffold. Existing packaging includes the entire resources tree. +- Default Library refresh now acquires the exact bundled release; it never calls Package Store download/resolve or requires a plugin signature. Account operation serialization, binding checks, failure retry and persistent removal remain. Already acquired entries are reused. Existing installed Artifact files cannot shadow code-owned definitions. +- New bundled Skill is activated by the existing project enablement control without requiring per-Agent assignment; disabled projects and removed accounts do not receive it. Renderer uses the same bundled Installed-tab projection as other official plugins, without a fake download receipt. +- Client version is 2.0.2 to match the new server compatibility floor; README and Electron fixture describe the current bundled behavior. No installer packaging, update-feed publication or installed app mutation occurred. ## Verification -- Red: five default-install facade tests failed before implementation (no install, no opt-out, no retry, no account-switch rejection). Final focused run: 70 unit tests passed across default installation, facade, Marketplace client, routes and plugin composition; separate composition image/background-sleep run: 9 passed (overlap in plugin composition). -- Six default-install scenarios cover first install, reuse, explicit uninstall/removal, unsigned package deferral, download retry, account change and removal while installation is pending. -- Pinned pnpm 10.33.4 frozen offline install, standard typecheck, changed-file ESLint and production `build:vite` passed. Electron default Installed-tab/optional acquisition scenario: 1 passed. E2E uses a Main API fixture for display; signed network/package validation remains covered by the existing client tests and companion server acceptance, not a real production install. -- `git diff --check` passed; final task-aware drift check required before completion. No packaging, installed-app update, push, production mutation or paid call occurred. +- Red: five default bundled-acquisition tests failed before replacing the signed-download implementation. Final focused run: 113 unit tests passed across default acquisition, manifest parsing, effective Skill materialization, workspace command boundaries, composition, facade, routes and Marketplace client. +- The real packaged Skill parser/materializer test proves project enablement yields the original Skill without assignment and account removal stops it. Cases also cover unsigned/no-signature bundled metadata, old Artifact deferral, acquisition retry, account switch and removal during acquisition. +- Standard typecheck, changed-file ESLint and production Vite build passed. Electron default Installed-tab/optional acquisition scenario passed with no Package Store installation row and no child-plugin download/install request; the fixture covers UI, not a live deployment. +- Direct byte comparison proves original Skill and openai.yaml unchanged. Companion server run: 24 passed, including old-client rejection, signature-free bundled Resolve and retained legacy release/account history. Task-aware drift check completes documentation before commit. ## Follow-ups -- Merge verified source after the primary main ownership handoff is authorized. Ship updated client and companion WS startup-signing repair; deploying only the server cannot make an old client automatically install packages. +- Integrate both verified sources, migrate Works Square to 20260929_0098 and release MakeLore 2.0.2. Local main merge permission and ownership handoff were already granted; no push, deployment or installed-client update is claimed. Retain source worktrees pending explicit cleanup consent. ## Promotion Candidates -- Target current-state and product README: the specific server Skill is installed by default through the signed Marketplace path on session/list refresh, with removal retained and project/Agent controls unchanged. Evidence: focused red/green tests, production build and Electron Installed-tab projection. User requested behavior; no architecture reversal or additional product decision needed. +- Target current-state: replace prior server-download/default-signing guidance with code-owned bundled 1.0.1 in client 2.0.2, default acquisition, persistent opt-out and normal project enablement. User explicitly confirmed the delivery change. Evidence: parser/materializer tests, 113 related unit cases, build and Electron UI. Source task records remain read-only during integration. + +## Resume Context + +- Bundled check/start/status verified the same feature identity and worktree. Reused previously loaded entry, memory index, positioning, decisions, architecture/domain/evidence and 135 peer scopes at unchanged source baseline; own task refreshed. Both feature planning gates passed. Existing primary integration owner is now 20260928-merge-default-child-b4d62e81 under prior user authorization. No subagents or foreign-document edits. diff --git a/README.md b/README.md index 5a1ffc02..45fc0edb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Makelore 2.0 -“对孩子说清楚”(`makelore.child-friendly-replies`)在登录及插件列表刷新时默认从服务端领取、验证签名并安装。原始 Skill 仍由服务端下载包提供;没有可用签名、断网或下载失败时保留真实安装状态,下次刷新可重试。手动从插件库移除或卸载会保留账号的移除记录,不会再次自动装回;默认安装不替代项目启用和 Agent 分配。 +“对孩子说清楚”(`makelore.child-friendly-replies`)的 `1.0.1` 版本随 MakeLore `2.0.2` 起内置,登录及插件列表刷新时默认领取并显示在“已安装”中,不下载插件 ZIP,也不需要单独的插件签名。服务端需升级至迁移 `20260929_0098`;断网或领取失败可在下次刷新重试。手动移除后保留账号的移除记录,不会自动装回。启用项目后,其 Skill 对该项目的操作 Agent 生效,沿用其他内置插件的项目控制。 **一念成光,万物可创。** diff --git a/electron/coding-plugins/default-installation.ts b/electron/coding-plugins/default-installation.ts index e0b181c9..4571a923 100644 --- a/electron/coding-plugins/default-installation.ts +++ b/electron/coding-plugins/default-installation.ts @@ -1,14 +1,13 @@ import type { AccountBinding, MarketplaceLibrarySnapshot } from './account-plugin-cache'; import { MarketplaceClientError, type MarketplaceClient } from './marketplace-client'; -import type { PluginPackageStore } from './package-store'; +import { CHILD_FRIENDLY_REPLIES_PLUGIN_ID, CHILD_FRIENDLY_REPLIES_BUNDLED_RELEASE_ID } from '../../shared/coding-plugins'; import { isMakeLoreVersionCompatible } from './release-descriptor'; -export const DEFAULT_CHILD_REPLIES_PLUGIN_ID = 'makelore.child-friendly-replies'; +export const DEFAULT_CHILD_REPLIES_PLUGIN_ID = CHILD_FRIENDLY_REPLIES_PLUGIN_ID; -/** Defaults use the normal signed Artifact flow and the account's removal receipt. */ +/** This code-owned Skill ships with the client; only its account acquisition is remote. */ export async function installDefaultChildReplies(options: { marketplace: MarketplaceClient; - packageStore: PluginPackageStore; clientVersion: string; binding: AccountBinding | null; library: MarketplaceLibrarySnapshot; @@ -25,23 +24,16 @@ export async function installDefaultChildReplies(options: { }; const pluginId = DEFAULT_CHILD_REPLIES_PLUGIN_ID; const entry = library.items.find((item) => item.pluginId === pluginId); - if (entry?.removedAt || entry?.runtimeStatus === 'suspended' || entry?.catalogStatus === 'retired') return library; + if (entry) return library; // Includes the account's durable removal receipt. try { assertAccount(); - if (entry && await options.packageStore.getInstalled(pluginId)) return library; const detail = await options.marketplace.readDetail(pluginId); assertAccount(); const release = detail.stableRelease; if (detail.stale || detail.runtimeStatus !== 'enabled' || detail.acquisition !== 'free' - || release?.deliveryKind !== 'artifact' || !release.signingKeyId || !release.descriptorSignature + || release?.deliveryKind !== 'bundled' || release.releaseId !== CHILD_FRIENDLY_REPLIES_BUNDLED_RELEASE_ID || !isMakeLoreVersionCompatible(options.clientVersion, release.minMakeloreVersion, release.maxMakeloreVersion)) return library; - if (!entry) { - library = await options.marketplace.acquire(pluginId); - assertAccount(); - } - await options.packageStore.resolveAndInstall({ - pluginId, makeloreVersion: options.clientVersion, channel: 'stable', - }); + library = await options.marketplace.acquire(pluginId); assertAccount(); await options.onInstalled(); } catch (error) { diff --git a/electron/coding-plugins/manifest.ts b/electron/coding-plugins/manifest.ts index 1bfce8a1..3729af05 100644 --- a/electron/coding-plugins/manifest.ts +++ b/electron/coding-plugins/manifest.ts @@ -16,6 +16,8 @@ import { GAME_AUDIO_BUNDLED_RELEASE_ID, PROJECT_SCAFFOLD_BUNDLED_RELEASE_ID, PROJECT_SCAFFOLD_PLUGIN_ID, + CHILD_FRIENDLY_REPLIES_BUNDLED_RELEASE_ID, + CHILD_FRIENDLY_REPLIES_PLUGIN_ID, DATA_SERVICE_CAPABILITY_IDS, DATA_SERVICE_OPERATION_DEFINITIONS, DATA_SERVICE_PLUGIN_ID, @@ -41,9 +43,17 @@ export const BUNDLED_CODING_PLUGIN_ROOTS = Object.freeze([ 'game-resource', 'project-scaffold', 'game-audio', + 'child-friendly-replies', ] as const); const BUNDLED_CODING_PLUGIN_METADATA = Object.freeze({ + 'child-friendly-replies': Object.freeze({ + pluginId: CHILD_FRIENDLY_REPLIES_PLUGIN_ID, + runtimeKind: 'skill_only' as const, + acquisitionMode: 'user_acquired' as const, + releaseId: CHILD_FRIENDLY_REPLIES_BUNDLED_RELEASE_ID, + bundledV2: true, + }), 'game-audio': Object.freeze({ pluginId: GAME_AUDIO_PLUGIN_ID, runtimeKind: 'platform_hosted' as const, diff --git a/package.json b/package.json index 673da61e..ad370a43 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "niancode", - "version": "2.0.1", + "version": "2.0.2", "pnpm": { "onlyBuiltDependencies": [ "@discordjs/opus", diff --git a/resources/coding-plugins/child-friendly-replies/.gitattributes b/resources/coding-plugins/child-friendly-replies/.gitattributes new file mode 100644 index 00000000..fcadb2cf --- /dev/null +++ b/resources/coding-plugins/child-friendly-replies/.gitattributes @@ -0,0 +1 @@ +* text eol=lf diff --git a/resources/coding-plugins/child-friendly-replies/com.makelore/capability.json b/resources/coding-plugins/child-friendly-replies/com.makelore/capability.json new file mode 100644 index 00000000..0a60e564 --- /dev/null +++ b/resources/coding-plugins/child-friendly-replies/com.makelore/capability.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 2, + "pluginId": "makelore.child-friendly-replies", + "contractVersion": 1, + "scope": "project", + "runtime": { + "kind": "skill_only" + }, + "skills": [ + { + "id": "makelore-child-friendly-replies", + "entry": "../skills/makelore-child-friendly-replies/SKILL.md", + "grants": [] + } + ], + "tools": [] +} diff --git a/resources/coding-plugins/child-friendly-replies/plugin.json b/resources/coding-plugins/child-friendly-replies/plugin.json new file mode 100644 index 00000000..d733d119 --- /dev/null +++ b/resources/coding-plugins/child-friendly-replies/plugin.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "makelore.child-friendly-replies", + "version": "1.0.1", + "description": "让麦洛操作 Agent 用孩子能懂的语言说明作品变化、进展和问题。", + "author": { + "name": "MakeLore" + }, + "extensions": { + "com.makelore": { + "capabilityManifest": "./com.makelore/capability.json" + } + } +} diff --git a/resources/coding-plugins/child-friendly-replies/skills/makelore-child-friendly-replies/SKILL.md b/resources/coding-plugins/child-friendly-replies/skills/makelore-child-friendly-replies/SKILL.md new file mode 100644 index 00000000..f9a7995d --- /dev/null +++ b/resources/coding-plugins/child-friendly-replies/skills/makelore-child-friendly-replies/SKILL.md @@ -0,0 +1,26 @@ +--- +name: makelore-child-friendly-replies +description: "麦洛操作 Agent 面向 6–14 岁孩子回复时使用,让回答、进度说明、追问和问题说明简短易懂。只调整表达,不增加教学、项目阶段判断或制作流程。" +--- + +# 对孩子说清楚 + +只改变操作 Agent 对孩子说话的方式。保持当前任务、事实和执行权限,不扮演老师,不额外安排教学或强行追问。 + +- 先回应孩子正在问的事。说明作品有什么变化、现在能做什么或哪里还没做好;需要孩子行动或决定时,说清一个能接住的下一步。不例行汇报整套开发过程。 +- 用日常词、短句和具体行为说明,尽量让孩子能想象“谁做什么,会发生什么”。不只是把技术词换成可爱的比喻。尊重孩子,不用宝宝腔、固定夸奖或夸张承诺;可以少量用贴合内容的符号,不必每次使用。 +- 先判断信息对孩子有没有用,再决定怎么说。默认省略与使用无关的文件名、路径、代码行数、包体积、测试数量、命令和日志,不把整张技术统计表逐项翻译成白话。保留玩法中的数字,以及影响孩子选择、费用、数据或实际使用的信息。孩子明确问技术细节时,给准确内容并用一句日常话解释,不回避问题。 +- 需要孩子操作时,使用界面上实际可见的名称,直接说“点哪里、会发生什么”。只有确实需要孩子帮忙时才提出操作,不把内部排查步骤当作孩子的任务,也不在每次回复末尾追加问题。 +- 区分已经修改、自动检查通过、实际试玩和发布。只说证据支持的结果,不把“构建通过”说成“游戏完全没问题”,也不因为简化而藏起失败或未完成的部分。不知道影响范围时照实说明。 +- 简单问题用一两句答清,通常用 2–4 个短句就够;多个变化可以分成少量短条。按问题需要展开,不为了短而漏答,也不把技术统计表或代码块当作日常回复。 + +这些要求只用于给孩子看的自然语言;代码、工具参数和接口要求的字段保持准确,不把原始日志或协议数据混进正文。 + +## 表达示例 + +仅在事实符合时参考,不照搬内容或固定格式: + +- “已更新 state.js 中的饱腹值逻辑” → “喂食后,宠物会更饱。” +- “3349 行源码,66 KB JS,105 项测试通过,构建通过;未试玩” → “作品已经生成了,电脑自动检查的部分也通过了。我还没打开试玩。” +- “点击 Preview 验证交互”(界面按钮确实叫“预览”,且预览已可用) → “点‘预览’,就能打开作品试一试。” +- “localStorage 持久化失败,刷新后进度丢失” → “游戏进度还没存好,刷新后可能会回到刚开始。” diff --git a/resources/coding-plugins/child-friendly-replies/skills/makelore-child-friendly-replies/agents/openai.yaml b/resources/coding-plugins/child-friendly-replies/skills/makelore-child-friendly-replies/agents/openai.yaml new file mode 100644 index 00000000..d27cd213 --- /dev/null +++ b/resources/coding-plugins/child-friendly-replies/skills/makelore-child-friendly-replies/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "对孩子说清楚" + short_description: "让麦洛操作 Agent 用孩子能懂的语言说明作品变化、进展和问题" + default_prompt: "请用 $makelore-child-friendly-replies,以孩子能懂的语言回答,保留真实进展和必要限制。" diff --git a/shared/coding-plugins.ts b/shared/coding-plugins.ts index 6f6e33c0..b7f1013a 100644 --- a/shared/coding-plugins.ts +++ b/shared/coding-plugins.ts @@ -107,6 +107,8 @@ export const GAME_AUDIO_PLUGIN_ID = 'makelore.game-audio' as const; export const GAME_AUDIO_BUNDLED_RELEASE_ID = '00000000-0000-4000-8000-000000000505' as const; export const PROJECT_SCAFFOLD_PLUGIN_ID = 'makelore.project-scaffold' as const; export const PROJECT_SCAFFOLD_BUNDLED_RELEASE_ID = '00000000-0000-4000-8000-000000000303' as const; +export const CHILD_FRIENDLY_REPLIES_PLUGIN_ID = 'makelore.child-friendly-replies' as const; +export const CHILD_FRIENDLY_REPLIES_BUNDLED_RELEASE_ID = '00000000-0000-4000-8000-000000000605' as const; /** * Project-wide Plugins materialize all of their Skills for every parent Agent @@ -117,10 +119,16 @@ export function isProjectWideCodingPluginId(pluginId: string): boolean { return pluginId === DATA_SERVICE_PLUGIN_ID || pluginId === GAME_AUDIO_PLUGIN_ID || pluginId === GAME_RESOURCE_PLUGIN_ID - || pluginId === PROJECT_SCAFFOLD_PLUGIN_ID; + || pluginId === PROJECT_SCAFFOLD_PLUGIN_ID + || pluginId === CHILD_FRIENDLY_REPLIES_PLUGIN_ID; } export const CODE_OWNED_OPTIONAL_BUNDLED_RELEASES = Object.freeze({ + [CHILD_FRIENDLY_REPLIES_PLUGIN_ID]: Object.freeze({ + releaseId: CHILD_FRIENDLY_REPLIES_BUNDLED_RELEASE_ID, + version: '1.0.1', + channel: 'stable' as const, + }), [GAME_AUDIO_PLUGIN_ID]: Object.freeze({ releaseId: GAME_AUDIO_BUNDLED_RELEASE_ID, version: '1.0.0', diff --git a/tests/e2e/plugin-marketplace.spec.ts b/tests/e2e/plugin-marketplace.spec.ts index 6e7d8c9e..5c96e79f 100644 --- a/tests/e2e/plugin-marketplace.spec.ts +++ b/tests/e2e/plugin-marketplace.spec.ts @@ -18,18 +18,18 @@ test.describe('Unified plugin workspace', () => { stableVersion: '1.0.0', betaVersion: null, }; const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } }); - const defaultItem = { ...item, pluginId: 'makelore.child-friendly-replies', title: '对孩子说清楚' }; + const defaultItem = { ...item, pluginId: 'makelore.child-friendly-replies', title: '对孩子说清楚', stableVersion: '1.0.1' }; const entry = (value: typeof item) => ({ pluginId: value.pluginId, title: value.title, summary: value.summary, category: value.category, acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'enabled', - acquiredAt: '2026-09-28T00:00:00Z', removedAt: null, stableVersion: '1.0.0', betaVersion: null, + acquiredAt: '2026-09-28T00:00:00Z', removedAt: null, stableVersion: value.stableVersion, betaVersion: null, }); const library = () => ({ library: { items: [entry(defaultItem), ...(acquired ? [entry(item)] : [])], total: acquired ? 2 : 1, stale: false, fetchedAt: 1, }, - installations: [{ pluginId: defaultItem.pluginId, status: 'installed', releaseId: 'child-1', version: '1.0.0', channel: 'stable' }], + installations: [], // Bundled Skills need no downloaded Package Store entry. }); (globalThis as typeof globalThis & { __pluginsE2E?: { requests: string[] } }).__pluginsE2E = { requests }; ipcMain.removeHandler('hostapi:fetch'); diff --git a/tests/unit/coding-plugin-manifest.test.ts b/tests/unit/coding-plugin-manifest.test.ts index 4b050101..cc4dfcce 100644 --- a/tests/unit/coding-plugin-manifest.test.ts +++ b/tests/unit/coding-plugin-manifest.test.ts @@ -39,14 +39,23 @@ describe('bundled coding plugin manifests', () => { 'game-resource', 'project-scaffold', 'game-audio', + 'child-friendly-replies', ]); expect(resolveBundledCodingPluginRootPaths(path.resolve('resources/coding-plugins'))).toEqual([ PACKAGE_ROOT, GAME_RESOURCE_ROOT, PROJECT_SCAFFOLD_ROOT, path.resolve('resources/coding-plugins/game-audio'), + path.resolve('resources/coding-plugins/child-friendly-replies'), ]); - expect(definitions).toHaveLength(4); + expect(definitions).toHaveLength(5); + expect(definitions[4]).toMatchObject({ + id: 'makelore.child-friendly-replies', version: '1.0.1', runtimeKind: 'skill_only', + acquisitionMode: 'user_acquired', releaseId: '00000000-0000-4000-8000-000000000605', + provenance: { source: 'bundled', packageRoot: 'child-friendly-replies' }, + skills: [{ id: 'makelore-child-friendly-replies', entryPath: 'skills/makelore-child-friendly-replies/SKILL.md', grants: [] }], + tools: [], + }); expect(definitions[0]).toMatchObject({ id: 'makelore.data-service', adapterId: 'data-service', @@ -163,6 +172,7 @@ describe('bundled coding plugin manifests', () => { path.resolve('tmp/game-resource'), path.resolve('tmp/project-scaffold'), path.resolve('tmp/game-audio'), + path.resolve('tmp/child-friendly-replies'), ]); }); diff --git a/tests/unit/default-plugin-installation.test.ts b/tests/unit/default-plugin-installation.test.ts index 7585b348..8a0e2671 100644 --- a/tests/unit/default-plugin-installation.test.ts +++ b/tests/unit/default-plugin-installation.test.ts @@ -2,25 +2,28 @@ import { describe, expect, it, vi } from 'vitest'; import { createCodingPluginMarketplaceService } from '../../electron/api/coding-product-services'; import type { MarketplaceLibrarySnapshot } from '../../electron/coding-plugins/account-plugin-cache'; +import path from 'node:path'; +import { loadBundledCodingPluginDefinitions } from '../../electron/coding-plugins/manifest'; +import { createEffectivePluginResolver } from '../../electron/coding-plugins/effective-resolver'; const pluginId = 'makelore.child-friendly-replies'; function fixture() { let binding = { accountKey: 'a'.repeat(64), epoch: 1 }; let library: MarketplaceLibrarySnapshot = { items: [], total: 0, stale: false, fetchedAt: 1 }; - let installed: object | null = null; const entry = { pluginId, title: '对孩子说清楚', summary: '简明表达', category: 'communication', acquisition: 'free' as const, acquisitionMode: 'user_acquired' as const, catalogStatus: 'active' as const, runtimeStatus: 'enabled' as const, - acquiredAt: '2026-09-28T00:00:00Z', removedAt: null, stableVersion: '1.0.0', betaVersion: null, + acquiredAt: '2026-09-28T00:00:00Z', removedAt: null, stableVersion: '1.0.1', betaVersion: null, }; const marketplace = { getCurrentAccountBinding: () => binding, readLibrary: vi.fn(async () => library), readDetail: vi.fn(async () => ({ pluginId, acquisition: 'free', runtimeStatus: 'enabled', stale: false, - stableRelease: { deliveryKind: 'artifact', signingKeyId: 'platform', descriptorSignature: 'signed', - minMakeloreVersion: '2.0.0', maxMakeloreVersion: null }, + stableRelease: { deliveryKind: 'bundled', releaseId: '00000000-0000-4000-8000-000000000605', + signingKeyId: null, descriptorSignature: null, + minMakeloreVersion: '2.0.2', maxMakeloreVersion: null }, })), acquire: vi.fn(async () => (library = { ...library, items: [entry], total: 1 })), remove: vi.fn(async () => (library = { @@ -28,91 +31,103 @@ function fixture() { })), }; const packageStore = { - getInstalled: vi.fn(async () => installed), - resolveAndInstall: vi.fn(async () => { - installed = { pluginId, releaseId: 'child-1', version: '1.0.0', channel: 'stable' }; - return { ...installed, status: 'installed' }; - }), - uninstall: vi.fn(async () => { installed = null; return { pluginId, status: 'removed' }; }), + getInstalled: vi.fn(async () => null), + resolveAndInstall: vi.fn(), + uninstall: vi.fn(async () => ({ pluginId, status: 'removed' })), }; const onChanged = vi.fn(); const service = createCodingPluginMarketplaceService({ marketplace: marketplace as never, packageStore: packageStore as never, - clientVersion: '2.0.0', onChanged, + clientVersion: '2.0.2', onChanged, }); return { service, marketplace, packageStore, onChanged, switchAccount() { binding = { accountKey: 'b'.repeat(64), epoch: 2 }; }, }; } -describe('default child-friendly replies installation', () => { - it('acquires and installs without a manual action, then leaves the installed package alone', async () => { +describe('default bundled child-friendly replies', () => { + it('uses the packaged Skill after project enablement, and stops using it after removal', async () => { const f = fixture(); - const result = await f.service.readLibrary(); - expect(result.installations).toEqual([{ pluginId, status: 'installed', releaseId: 'child-1', version: '1.0.0', channel: 'stable' }]); - expect(f.marketplace.acquire).toHaveBeenCalledWith(pluginId); - expect(f.packageStore.resolveAndInstall).toHaveBeenCalledWith({ pluginId, makeloreVersion: '2.0.0', channel: 'stable' }); - expect(f.onChanged).toHaveBeenCalledWith({ pluginId, kind: 'install' }); await f.service.readLibrary(); - expect(f.packageStore.resolveAndInstall).toHaveBeenCalledTimes(1); + const definitions = (await loadBundledCodingPluginDefinitions(path.resolve('resources/coding-plugins'))) + .filter(({ id }) => id === pluginId); + let enabled: string[] = []; + const resolver = createEffectivePluginResolver({ + definitions, getAccountBinding: f.marketplace.getCurrentAccountBinding, + getLibrary: f.marketplace.readLibrary, getEnabledPluginIds: async () => enabled, + }); + const input = { projectId: 'project', projectPath: 'C:/project', assignedSkillIds: [], role: 'parent' as const }; + expect((await resolver.resolve(input)).effectiveSkillIds).toEqual([]); + enabled = [pluginId]; + expect(await resolver.resolve(input)).toMatchObject({ + pluginReleaseIds: ['00000000-0000-4000-8000-000000000605'], + effectiveSkillIds: ['makelore-child-friendly-replies'], unavailableReasons: [], + }); + await f.service.remove(pluginId); + expect((await resolver.resolve(input)).effectiveSkillIds).toEqual([]); + expect(f.packageStore.resolveAndInstall).not.toHaveBeenCalled(); }); - it('respects account removal and default-plugin device uninstall on later refreshes', async () => { + it('acquires the built-in Skill without signatures or any package download', async () => { + const f = fixture(); + const result = await f.service.readLibrary(); + expect(result.library.items).toEqual([expect.objectContaining({ pluginId, removedAt: null })]); + expect(f.packageStore.resolveAndInstall).not.toHaveBeenCalled(); + expect(f.onChanged).toHaveBeenCalledWith({ pluginId, kind: 'install' }); + await f.service.readLibrary(); + expect(f.marketplace.acquire).toHaveBeenCalledTimes(1); + }); + + it('respects account removal and uninstall on later refreshes', async () => { const f = fixture(); await f.service.readLibrary(); await f.service.uninstall(pluginId); - expect(f.marketplace.remove).toHaveBeenCalledWith(pluginId); - expect((await f.service.readLibrary()).installations).toEqual([]); - expect(f.packageStore.resolveAndInstall).toHaveBeenCalledTimes(1); - }); - - it('does not acquire an unsigned release or mistake it for installed', async () => { - const f = fixture(); - f.marketplace.readDetail.mockImplementationOnce(async () => ({ - pluginId, acquisition: 'free', runtimeStatus: 'enabled', stale: false, - stableRelease: { deliveryKind: 'artifact', signingKeyId: '', descriptorSignature: '', - minMakeloreVersion: '2.0.0', maxMakeloreVersion: null }, - })); - expect((await f.service.readLibrary()).installations).toEqual([]); - expect(f.marketplace.acquire).not.toHaveBeenCalled(); - expect(f.packageStore.resolveAndInstall).not.toHaveBeenCalled(); - expect((await f.service.readLibrary()).installations).toHaveLength(1); - }); - - it('retries a failed download on refresh without duplicating acquisition', async () => { - const f = fixture(); - f.packageStore.resolveAndInstall.mockRejectedValueOnce(new Error('download unavailable')); - expect((await f.service.readLibrary()).installations).toEqual([]); - expect((await f.service.readLibrary()).installations).toHaveLength(1); + expect((await f.service.readLibrary()).library.items[0].removedAt).toBeTruthy(); expect(f.marketplace.acquire).toHaveBeenCalledTimes(1); + expect(f.packageStore.resolveAndInstall).not.toHaveBeenCalled(); + }); + + it('waits for the matching bundled server release instead of downloading the old unsigned release', async () => { + const f = fixture(); + const detail = await f.marketplace.readDetail(); + f.marketplace.readDetail.mockResolvedValueOnce({ ...detail, + stableRelease: { ...detail.stableRelease, deliveryKind: 'artifact', releaseId: 'old-release' }, + }); + expect((await f.service.readLibrary()).library.items).toEqual([]); + expect(f.marketplace.acquire).not.toHaveBeenCalled(); + expect((await f.service.readLibrary()).library.items).toHaveLength(1); + expect(f.packageStore.resolveAndInstall).not.toHaveBeenCalled(); + }); + + it('retries failed acquisition on refresh', async () => { + const f = fixture(); + f.marketplace.acquire.mockRejectedValueOnce(new Error('backend unavailable')); + expect((await f.service.readLibrary()).library.items).toEqual([]); + expect((await f.service.readLibrary()).library.items).toHaveLength(1); + expect(f.marketplace.acquire).toHaveBeenCalledTimes(2); }); it('does not acquire for a different account when login changes during the detail request', async () => { const f = fixture(); const original = f.marketplace.readDetail.getMockImplementation()!; - f.marketplace.readDetail.mockImplementationOnce(async () => { - f.switchAccount(); - return original(); - }); + f.marketplace.readDetail.mockImplementationOnce(async () => { f.switchAccount(); return original(); }); await expect(f.service.readLibrary()).rejects.toMatchObject({ code: 'marketplace_account_changed' }); expect(f.marketplace.acquire).not.toHaveBeenCalled(); - expect(f.packageStore.resolveAndInstall).not.toHaveBeenCalled(); }); - it('keeps an explicit removal made during automatic installation as the final choice', async () => { + it('keeps explicit removal during automatic acquisition as the final choice', async () => { const f = fixture(); let finish!: () => void; const paused = new Promise((resolve) => { finish = resolve; }); - const install = f.packageStore.resolveAndInstall.getMockImplementation()!; - f.packageStore.resolveAndInstall.mockImplementationOnce(async () => { await paused; return install(); }); + const acquire = f.marketplace.acquire.getMockImplementation()!; + f.marketplace.acquire.mockImplementationOnce(async () => { await paused; return acquire(); }); const refreshing = f.service.readLibrary(); - await vi.waitFor(() => expect(f.packageStore.resolveAndInstall).toHaveBeenCalled()); + await vi.waitFor(() => expect(f.marketplace.acquire).toHaveBeenCalled()); const removing = f.service.remove(pluginId); finish(); await refreshing; await removing; - const result = await f.service.readLibrary(); - expect(result.library.items[0].removedAt).toBeTruthy(); - expect(f.packageStore.resolveAndInstall).toHaveBeenCalledTimes(1); + expect((await f.service.readLibrary()).library.items[0].removedAt).toBeTruthy(); + expect(f.marketplace.acquire).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/unit/plugin-workspace-model.test.ts b/tests/unit/plugin-workspace-model.test.ts index b815db19..61bd1027 100644 --- a/tests/unit/plugin-workspace-model.test.ts +++ b/tests/unit/plugin-workspace-model.test.ts @@ -303,6 +303,7 @@ describe('buildPluginWorkspaceProjection', () => { for (const [pluginId, title, runtimeKind] of [ ['makelore.game-resource', 'Game Resource', 'platform_hosted'], ['makelore.project-scaffold', 'Project Scaffold', 'skill_only'], + ['makelore.child-friendly-replies', '对孩子说清楚', 'skill_only'], ] as const) { const bundledCatalog = { ...catalog.items[0]!,