Compare commits

...
2 Commits
Author SHA1 Message Date
brother7 5d45cd7f33 Record default child replies client integration
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
2026-09-28 23:09:05 +08:00
brother7 e70b6eab91 Install child-friendly replies by default through signed Marketplace delivery 2026-09-28 23:00:54 +08:00
10 changed files with 320 additions and 18 deletions

No files matched your search

@@ -4,6 +4,8 @@ This file is the integrated default-branch snapshot. Feature tasks record progre
## Integrated Through
- 2026-09-28:经用户确认主目录交接,将源 `e70b6eab91b71c6149a96fa9cd5974d8706417ba` 从 `efff238a` 快进合入本地 `main`。“对孩子说清楚”在会话激活和插件列表刷新时通过既有签名市场流程自动领取并安装,已装复用、失败后续重试、账号隔离;明确移除或卸载后保留退出意愿。安装完成显示在已安装列表,项目启用与 Agent 绑定仍由原控制管理。配套服务端源 `c9ff8a4f` 修复线上已上架却未签名的 Release,并禁止未就绪包上架。产品/测试与已验证源一致,沿用 70 项相关单测、标准类型检查、lint、Vite 构建和 1 项 Electron 场景。三份外来未跟踪文档原样保留;未推送、打包、部署或更新安装版,需要同时发布客户端与服务端才能生效。见[源记录](tasks/20260928-default-child-replies-0108dd01.md)和[集成记录](tasks/20260928-merge-default-child-b4d62e81.md)。
- 2026-09-28:续接获授权的组件移除集成任务,合并远程 `b645cfa312ca23e9a44758211a43f4934a44a70f` 与本地 `d37bc13`。保留账号/项目/智能体独立聊天,接入 reply-v1、0–3 条新快捷回复、完整正文/历史恢复、显式重试、欢迎语消费及旧组件接口退役;项目边界适配到路由、存储与测试。补齐首次导入全局历史时的内存回复恢复,原始存档保留。472 项相关单测、28 项浏览器布局、6 项 Electron 场景、标准类型检查、变更文件 lint 和最终生产构建通过。旧组件仅留原档案,不进入新上下文;本条取代下方历史记录中组件协议与跨项目假设。三份未跟踪文档及六份远程来源记录原样保留,未推送、打包、部署或更新安装版;独立主动观察仍未合入。见[集成记录](tasks/20260928-merge-teacher-cards-a09c07f3.md#remote-main-merge-resume)。
- 2026-09-28:用户确认主目录交接后,将组件移除源 317def79129503062d15076585881ceae753055c 从 592920d 无冲突快进合入本地 main。老师对话栏移除想法板、结构图、流程图、对照及其专用交互,新问题不再请求组件输出;普通正文、快捷求助、历史数据及未确认旧请求的原身份恢复保留。产品/测试与源一致,沿用47项单测、12项布局、1项Electron以及类型/lint/构建验证。三份外来文档原样保留且未提交;未推送、打包或更新安装版。见[源记录](tasks/20260928-remove-teacher-cards-ef1cf59c.md)和[集成记录](tasks/20260928-merge-teacher-cards-a09c07f3.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.
@@ -0,0 +1,42 @@
# Task: Integrate default child-friendly replies installation
## Identity
- Task ID: 20260928-merge-default-child-b4d62e81
- Mode: Integration
- Branch: main
- Worktree: D:\Datas\OthersProjects\makelore
- Base commit: efff238a75c1eaba20dba8ae505f47dbea8ba131
- Owner: codex
- Status: Ready for Integration
## Scope
- Integrate verified default-install source `e70b6eab91b71c6149a96fa9cd5974d8706417ba` into the primary MakeLore checkout on local `main`, and promote its accepted current-state facts.
## Intent And Constraints
- User explicitly approved handing over `D:\Datas\OthersProjects\makelore` and merging the client source, preserving three existing untracked task records. Bundled `release --force` released completed owner `20260928-merge-teacher-cards-a09c07f3`; bundled `start --mode integration --adopt-existing` and exact status verified this task, mode, main branch, primary path, recorded base, exclusive integration lock and adopted foreign states.
- Project Context Loaded: entry, own/source records and current-state refreshed; reuse previously loaded memory index, positioning, decision, architecture, domain, success/evidence context and 135 peer scopes at unchanged `efff238a` base. Historical template sections remain unknown coordination state; old Marketplace scopes preserve the same contracts and no semantic conflict blocks this merge. Planning and Integration Gates passed before editing.
- Preserve source records and all three foreign files read-only. No subagents. Default installation preserves existing signature trust, account isolation, explicit opt-out and separate project/Agent controls. Companion server task `20260928-default-child-replies-06693309` owns Release readiness repair.
## Outcome
- Fast-forwarded local main from `efff238a75c1eaba20dba8ae505f47dbea8ba131` to `e70b6eab91b71c6149a96fa9cd5974d8706417ba` without conflicts.
- Session activation and Library refresh acquire/install the signed server child-friendly Skill by default; existing installed packages are reused, unavailable downloads retry later, and explicit removal/uninstall prevents automatic reinstallation. Queued account operations retain account isolation and honor removal during installation.
- Promoted accepted source behavior and cross-repository rollout requirements to current-state. Source README already describes the product behavior. No architectural conflict or new product decision is introduced.
## Verification
- Source: 70 relevant unit tests passed; six default-install cases cover reuse, opt-out, unsigned deferral, failure retry, account switch and pending-install removal. Additional composition checks passed (partially overlapping coverage).
- Standard typecheck, changed-file ESLint and Vite production build passed. Electron Installed-tab/default-plugin plus optional acquisition scenario passed; its Main API fixture validates display rather than a live production download.
- Primary-checkout product/tests and imported source record match the tested source. Only integration-owned documentation is added, so no redundant test rerun is needed. Three adopted foreign records remain unchanged and uncommitted; task-aware drift check completes the documentation gate.
## Follow-ups
- Ship the new client alongside Works Square startup-signing fix `c9ff8a4f5b0be50858842d9627608987a8a883d0`. The live descriptor was unsigned at diagnosis; no live production signing, push, packaging, deployment or installed desktop update occurred here.
- Retain the source worktree pending explicit post-merge cleanup authorization; preserve its branch and commit.
## Promotion Candidates
- Accepted: signed Marketplace default installation with persistent user opt-out, independent project/Agent controls and coordinated rollout. Recorded in current-state Integrated Through with source provenance; source task records remain read-only.
+2
View File
@@ -1,5 +1,7 @@
# Makelore 2.0
“对孩子说清楚”(`makelore.child-friendly-replies`)在登录及插件列表刷新时默认从服务端领取、验证签名并安装。原始 Skill 仍由服务端下载包提供;没有可用签名、断网或下载失败时保留真实安装状态,下次刷新可重试。手动从插件库移除或卸载会保留账号的移除记录,不会再次自动装回;默认安装不替代项目启用和 Agent 分配。
**一念成光,万物可创。**
平台托管模型的图片输入与思考选项由 Works Square 的 `model_capabilities_v2` 下发。未知能力保持未知;会话保存“模型默认 / 关闭 / 开启及原生强度”,Pi 请求前按冻结的选择写入供应商参数。配置刷新后失效的选择会在发送前提示重新选择;BYOK 模型沿用自身配置。
+2
View File
@@ -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);
});
+38 -9
View File
@@ -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<unknown> = Promise.resolve();
const forAccount = <T>(action: () => Promise<T>): Promise<T> => {
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<PublicPluginInstallation> => {
const snapshot = await options.packageStore.resolveAndInstall({
pluginId,
@@ -190,6 +211,7 @@ export function createCodingPluginMarketplaceService(
return publicInstallation(snapshot);
};
const uninstall = async (pluginId: string): Promise<PublicPluginInstallation> => {
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)),
};
}
@@ -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<void>;
}): Promise<MarketplaceLibrarySnapshot> {
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;
}
+16 -9
View File
@@ -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);
@@ -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),
};
@@ -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<void>((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);
});
});