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 new file mode 100644 index 00000000..27f5db23 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260928-default-child-replies-0108dd01.md @@ -0,0 +1,43 @@ +# Task: Install the server child-friendly replies plugin by default + +## Identity + +- Task ID: 20260928-default-child-replies-0108dd01 +- Mode: Feature +- Branch: codex/20260928-default-child-replies-0108dd01-default-child-replies +- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260928-default-child-replies-0108dd01 +- Base commit: efff238a75c1eaba20dba8ae505f47dbea8ba131 +- Owner: codex +- Status: Ready for Integration + +## 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. + +## 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. +- 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. + +## 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. + +## 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. + +## 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. diff --git a/README.md b/README.md index 817f7e94..5a1ffc02 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Makelore 2.0 +“对孩子说清楚”(`makelore.child-friendly-replies`)在登录及插件列表刷新时默认从服务端领取、验证签名并安装。原始 Skill 仍由服务端下载包提供;没有可用签名、断网或下载失败时保留真实安装状态,下次刷新可重试。手动从插件库移除或卸载会保留账号的移除记录,不会再次自动装回;默认安装不替代项目启用和 Agent 分配。 + **一念成光,万物可创。** 平台托管模型的图片输入与思考选项由 Works Square 的 `model_capabilities_v2` 下发。未知能力保持未知;会话保存“模型默认 / 关闭 / 开启及原生强度”,Pi 请求前按冻结的选择写入供应商参数。配置刷新后失效的选择会在发送前提示重新选择;BYOK 模型沿用自身配置。 diff --git a/electron/api/coding-composition.ts b/electron/api/coding-composition.ts index 02869e98..f2a65621 100644 --- a/electron/api/coding-composition.ts +++ b/electron/api/coding-composition.ts @@ -545,8 +545,10 @@ export function createCodingComposition( }); void gameResourceDelivery.resumePending().catch(() => undefined); void gameAudioDelivery.resumePending().catch(() => undefined); + void pluginMarketplace.readLibrary().catch(() => undefined); const unsubscribeMarketplaceSession = subscribeWorksSquareSession(() => { void invalidateManagedResources(); + void pluginMarketplace.readLibrary().catch(() => undefined); void gameResourceDelivery.resumePending().catch(() => undefined); void gameAudioDelivery.resumePending().catch(() => undefined); }); diff --git a/electron/api/coding-product-services.ts b/electron/api/coding-product-services.ts index 0ee1d6a7..29bb8c4f 100644 --- a/electron/api/coding-product-services.ts +++ b/electron/api/coding-product-services.ts @@ -28,6 +28,8 @@ import type { PluginBackendProjection, } from '../coding-plugins/registry'; import type { ProjectPluginService } from '../coding-plugins/project-service'; +import { DEFAULT_CHILD_REPLIES_PLUGIN_ID, installDefaultChildReplies } from '../coding-plugins/default-installation'; +import { MarketplaceClientError } from '../coding-plugins/marketplace-client'; import type { PluginBillingPolicy, PluginCatalogOperation, @@ -170,6 +172,25 @@ async function publicLibrary( export function createCodingPluginMarketplaceService( options: CreateCodingPluginMarketplaceServiceOptions, ): CodingPluginMarketplaceService { + // Order automatic installation and explicit removal so the user's later action wins. + let pending: Promise = Promise.resolve(); + const forAccount = (action: () => Promise): Promise => { + const binding = options.marketplace.getCurrentAccountBinding(); + const run = pending.then(async () => { + const assertAccount = () => { + const current = options.marketplace.getCurrentAccountBinding(); + if (current?.accountKey !== binding?.accountKey || current?.epoch !== binding?.epoch) { + throw new MarketplaceClientError('marketplace_account_changed', 409, 'Marketplace account changed'); + } + }; + assertAccount(); + const result = await action(); + assertAccount(); + return result; + }); + pending = run.catch(() => undefined); + return run; + }; const install = async (pluginId: string, kind: 'install' | 'update'): Promise => { const snapshot = await options.packageStore.resolveAndInstall({ pluginId, @@ -190,6 +211,7 @@ export function createCodingPluginMarketplaceService( return publicInstallation(snapshot); }; const uninstall = async (pluginId: string): Promise => { + if (pluginId === DEFAULT_CHILD_REPLIES_PLUGIN_ID) await options.marketplace.remove(pluginId); const snapshot = await options.packageStore.uninstall(pluginId); await options.onChanged?.({ pluginId, kind: 'uninstall' }); return publicInstallation(snapshot); @@ -197,21 +219,28 @@ export function createCodingPluginMarketplaceService( return { readCatalog: (input = {}) => options.marketplace.readCatalog(input), readDetail: (pluginId) => options.marketplace.readDetail(pluginId), - readLibrary: async () => publicLibrary(await options.marketplace.readLibrary(), options.packageStore), - acquire: async (pluginId) => { + readLibrary: () => forAccount(async () => { + const binding = options.marketplace.getCurrentAccountBinding(); + const library = await installDefaultChildReplies({ + ...options, binding, library: await options.marketplace.readLibrary(), + onInstalled: async () => { await options.onChanged?.({ pluginId: DEFAULT_CHILD_REPLIES_PLUGIN_ID, kind: 'install' }); }, + }); + return publicLibrary(library, options.packageStore); + }), + acquire: (pluginId) => forAccount(async () => { const snapshot = await options.marketplace.acquire(pluginId); await options.onChanged?.({ pluginId, kind: 'acquire' }); return publicLibrary(snapshot, options.packageStore); - }, - remove: async (pluginId) => { + }), + remove: (pluginId) => forAccount(async () => { const snapshot = await options.marketplace.remove(pluginId); await options.onChanged?.({ pluginId, kind: 'remove' }); return publicLibrary(snapshot, options.packageStore); - }, - install: (pluginId) => install(pluginId, 'install'), - installBeta, - update: (pluginId) => install(pluginId, 'update'), - uninstall, + }), + install: (pluginId) => forAccount(() => install(pluginId, 'install')), + installBeta: (pluginId) => forAccount(() => installBeta(pluginId)), + update: (pluginId) => forAccount(() => install(pluginId, 'update')), + uninstall: (pluginId) => forAccount(() => uninstall(pluginId)), }; } diff --git a/electron/coding-plugins/default-installation.ts b/electron/coding-plugins/default-installation.ts new file mode 100644 index 00000000..e0b181c9 --- /dev/null +++ b/electron/coding-plugins/default-installation.ts @@ -0,0 +1,54 @@ +import type { AccountBinding, MarketplaceLibrarySnapshot } from './account-plugin-cache'; +import { MarketplaceClientError, type MarketplaceClient } from './marketplace-client'; +import type { PluginPackageStore } from './package-store'; +import { isMakeLoreVersionCompatible } from './release-descriptor'; + +export const DEFAULT_CHILD_REPLIES_PLUGIN_ID = 'makelore.child-friendly-replies'; + +/** Defaults use the normal signed Artifact flow and the account's removal receipt. */ +export async function installDefaultChildReplies(options: { + marketplace: MarketplaceClient; + packageStore: PluginPackageStore; + clientVersion: string; + binding: AccountBinding | null; + library: MarketplaceLibrarySnapshot; + onInstalled(): Promise; +}): Promise { + let library = options.library; + const binding = options.binding; + if (!binding || library.stale) return library; + const assertAccount = () => { + const current = options.marketplace.getCurrentAccountBinding(); + if (current?.accountKey !== binding.accountKey || current?.epoch !== binding.epoch) { + throw new MarketplaceClientError('marketplace_account_changed', 409, 'Marketplace account changed'); + } + }; + 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; + 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 + || !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', + }); + assertAccount(); + await options.onInstalled(); + } catch (error) { + assertAccount(); + if (error instanceof MarketplaceClientError && error.code === 'marketplace_account_changed') throw error; + // An unavailable default must not hide the rest of the Library. A later + // session/list refresh retries; no local success or removal receipt is invented. + } + return library; +} diff --git a/tests/e2e/plugin-marketplace.spec.ts b/tests/e2e/plugin-marketplace.spec.ts index 7d014aa1..6e7d8c9e 100644 --- a/tests/e2e/plugin-marketplace.spec.ts +++ b/tests/e2e/plugin-marketplace.spec.ts @@ -18,16 +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 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, + }); const library = () => ({ library: { - items: acquired ? [{ - pluginId: item.pluginId, title: item.title, summary: item.summary, category: item.category, - acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'enabled', - acquiredAt: '2026-09-03T00:00:00Z', removedAt: null, stableVersion: '1.0.0', betaVersion: null, - }] : [], - total: acquired ? 1 : 0, stale: false, fetchedAt: 1, + items: [entry(defaultItem), ...(acquired ? [entry(item)] : [])], + total: acquired ? 2 : 1, stale: false, fetchedAt: 1, }, - installations: [], + installations: [{ pluginId: defaultItem.pluginId, status: 'installed', releaseId: 'child-1', version: '1.0.0', channel: 'stable' }], }); (globalThis as typeof globalThis & { __pluginsE2E?: { requests: string[] } }).__pluginsE2E = { requests }; ipcMain.removeHandler('hostapi:fetch'); @@ -59,7 +61,7 @@ test.describe('Unified plugin workspace', () => { }, }); if (requestPath.startsWith('/api/coding/plugin-marketplace/catalog')) return result({ - items: [item], nextCursor: null, total: 1, catalogGeneration: 1, + items: [item, defaultItem], nextCursor: null, total: 2, catalogGeneration: 1, etag: 'plugins-1', pricingVersionId: null, stale: false, fetchedAt: 1, }); if (requestPath === '/api/coding/plugin-marketplace/plugins/makelore.notes') return result({ @@ -93,10 +95,14 @@ test.describe('Unified plugin workspace', () => { await expect(page.getByTestId('project-plugins-sheet')).toBeVisible(); await expect(page.getByTestId('plugins-page')).toBeVisible(); await expect(page).toHaveURL(/\/project-config\/plugins\?scope=all/); - await expect(page.getByText('为你的智能体添加插件,拓展更多能力。')).toBeVisible(); + await expect(page.getByTestId('plugin-tabs')).toBeVisible(); await expect(page.getByRole('button', { name: /刷新/ })).toHaveCount(0); await expect(page.getByRole('searchbox')).toHaveCount(0); await expect(page.getByRole('combobox')).toHaveCount(0); + await page.getByTestId('plugin-tab-installed').click(); + await expect(page.getByRole('heading', { name: '对孩子说清楚' })).toBeVisible(); + await expect(page.getByRole('button', { name: '安装对孩子说清楚', exact: true })).toHaveCount(0); + await page.getByTestId('plugin-tab-available').click(); await expect(page.getByRole('button', { name: '添加灵感笔记' })).toBeVisible(); await page.getByRole('button', { name: '查看灵感笔记详情' }).click(); await expect(page.getByRole('dialog', { name: '灵感笔记' })).toContainText('详细介绍'); @@ -111,6 +117,7 @@ test.describe('Unified plugin workspace', () => { (globalThis as typeof globalThis & { __pluginsE2E?: { requests: string[] } }).__pluginsE2E?.requests ?? [] )); expect(requests).toContain('PUT /api/coding/plugin-marketplace/library/makelore.notes'); + expect(requests.some((request) => request.includes('/installations/makelore.child-friendly-replies'))).toBe(false); expect(requests.some((request) => request.startsWith('PUT /api/coding/plugins/'))).toBe(false); } finally { await closeElectronApp(app); diff --git a/tests/unit/coding-product-services.test.ts b/tests/unit/coding-product-services.test.ts index 6d0ab046..ade81164 100644 --- a/tests/unit/coding-product-services.test.ts +++ b/tests/unit/coding-product-services.test.ts @@ -375,6 +375,7 @@ describe('Marketplace public Library projection', () => { it('rebuilds fresh-process installation state from the Package Store current selection', async () => { const marketplace = { + getCurrentAccountBinding: () => null, readCatalog: vi.fn(), readDetail: vi.fn(), readLibrary: vi.fn().mockResolvedValue(snapshot), acquire: vi.fn(), remove: vi.fn(), }; @@ -400,6 +401,7 @@ describe('Marketplace public Library projection', () => { it('projects an installed but client-incompatible package without discarding its cached release', async () => { const marketplace = { + getCurrentAccountBinding: () => null, readCatalog: vi.fn(), readDetail: vi.fn(), readLibrary: vi.fn().mockResolvedValue(snapshot), acquire: vi.fn(), remove: vi.fn(), }; @@ -425,6 +427,7 @@ describe('Marketplace public Library projection', () => { it.each(['acquire', 'remove'] as const)('returns an authoritative joined snapshot after %s', async (action) => { const marketplace = { + getCurrentAccountBinding: () => null, readCatalog: vi.fn(), readDetail: vi.fn(), readLibrary: vi.fn(), acquire: vi.fn().mockResolvedValue(snapshot), remove: vi.fn().mockResolvedValue(snapshot), }; diff --git a/tests/unit/default-plugin-installation.test.ts b/tests/unit/default-plugin-installation.test.ts new file mode 100644 index 00000000..7585b348 --- /dev/null +++ b/tests/unit/default-plugin-installation.test.ts @@ -0,0 +1,118 @@ +// @vitest-environment node +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'; + +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, + }; + 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 }, + })), + acquire: vi.fn(async () => (library = { ...library, items: [entry], total: 1 })), + remove: vi.fn(async () => (library = { + ...library, items: [{ ...entry, removedAt: '2026-09-28T01:00:00Z' }], total: 1, + })), + }; + 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' }; }), + }; + const onChanged = vi.fn(); + const service = createCodingPluginMarketplaceService({ + marketplace: marketplace as never, packageStore: packageStore as never, + clientVersion: '2.0.0', 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 () => { + 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); + }); + + it('respects account removal and default-plugin device 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(f.marketplace.acquire).toHaveBeenCalledTimes(1); + }); + + 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(); + }); + 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 () => { + 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 refreshing = f.service.readLibrary(); + await vi.waitFor(() => expect(f.packageStore.resolveAndInstall).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); + }); +});