From 1781895546ca6913e2fcf283238925e2628fc84d Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 30 Jul 2026 19:44:24 +0800 Subject: [PATCH] feat(agent-core-v2): surface a machine-key note from capability installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CapabilityEntry.install now resolves an optional note exposed through CapabilityInstallProgress.note (wire-visible). The webbridge entry returns 'user-skill-migrated' when it replaces a pre-existing user-source skill (from the official installer) with the plugin-managed copy — clients can localize the migration instead of the skill silently disappearing from the user's directory. --- .../src/app/capability/capabilityService.ts | 7 ++-- .../src/app/capability/entries/kimiCu.ts | 3 +- .../app/capability/entries/kimiWebbridge.ts | 8 +++-- .../agent-core-v2/src/app/capability/types.ts | 13 +++++++- .../app/capability/capabilityService.test.ts | 32 +++++++++++++++---- .../test/app/capability/kimiWebbridge.test.ts | 9 ++++-- .../src/protocol/rest-capability.ts | 1 + 7 files changed, 58 insertions(+), 15 deletions(-) diff --git a/packages/agent-core-v2/src/app/capability/capabilityService.ts b/packages/agent-core-v2/src/app/capability/capabilityService.ts index 29cd850b2..006091bab 100644 --- a/packages/agent-core-v2/src/app/capability/capabilityService.ts +++ b/packages/agent-core-v2/src/app/capability/capabilityService.ts @@ -126,13 +126,16 @@ export class CapabilityService extends Disposable implements ICapabilityService // Fire-and-forget: progress and errors are surfaced through polling. void (async () => { try { - await entry.install((step, percent) => { + const note = await entry.install((step, percent) => { this.installProgress.set( entry.id, percent === undefined ? { running: true, step } : { running: true, step, percent }, ); }); - this.installProgress.set(entry.id, { running: false }); + this.installProgress.set( + entry.id, + note === undefined ? { running: false } : { running: false, note }, + ); } catch (error) { this.installProgress.set(entry.id, { running: false, diff --git a/packages/agent-core-v2/src/app/capability/entries/kimiCu.ts b/packages/agent-core-v2/src/app/capability/entries/kimiCu.ts index 206978b47..268618c39 100644 --- a/packages/agent-core-v2/src/app/capability/entries/kimiCu.ts +++ b/packages/agent-core-v2/src/app/capability/entries/kimiCu.ts @@ -183,7 +183,7 @@ export function createKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry } } - async function install(report: CapabilityInstallReporter): Promise { + async function install(report: CapabilityInstallReporter): Promise { if (!supported) { throw new Error(`kimi-cu is only supported on macOS (current: ${ctx.platform})`); } @@ -240,6 +240,7 @@ export function createKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry await runCommand(ctx.hostProcess, appBin, ['request-permissions', '--ax', '--screen'], { timeout: PERMISSIONS_TIMEOUT_MS, }); + return undefined; } finally { await rm(workDir, { recursive: true, force: true }).catch(() => undefined); } diff --git a/packages/agent-core-v2/src/app/capability/entries/kimiWebbridge.ts b/packages/agent-core-v2/src/app/capability/entries/kimiWebbridge.ts index 696b5e1a1..a33fed977 100644 --- a/packages/agent-core-v2/src/app/capability/entries/kimiWebbridge.ts +++ b/packages/agent-core-v2/src/app/capability/entries/kimiWebbridge.ts @@ -141,7 +141,7 @@ export function createKimiWebbridgeEntry(ctx: CapabilityEntryContext): Capabilit throw new Error(`WebBridge daemon did not come up on ${baseUrl} — check ~/.kimi-webbridge/logs`); } - async function install(report: CapabilityInstallReporter): Promise { + async function install(report: CapabilityInstallReporter): Promise { const asset = binaryAssetName(ctx.platform, ctx.arch); if (asset === undefined) { throw new Error(`kimi-webbridge is not supported on ${ctx.platform}/${ctx.arch}`); @@ -188,8 +188,12 @@ export function createKimiWebbridgeEntry(ctx: CapabilityEntryContext): Capabilit report('skill'); await ctx.plugins.installPlugin({ source: PLUGIN_ZIP_URL }); // Un-shadow the plugin copy: the user-source skill (priority 20) wins - // over the plugin source (priority 5) on name collisions. + // over the plugin source (priority 5) on name collisions. Surface the + // migration so clients can tell the user their manually-installed skill + // is now managed as a plugin. + const hadUserSourceSkill = await exists(userSourceSkillDir); await rm(userSourceSkillDir, { recursive: true, force: true }); + return hadUserSourceSkill ? 'user-skill-migrated' : undefined; } return { diff --git a/packages/agent-core-v2/src/app/capability/types.ts b/packages/agent-core-v2/src/app/capability/types.ts index 73462bc29..95fc12231 100644 --- a/packages/agent-core-v2/src/app/capability/types.ts +++ b/packages/agent-core-v2/src/app/capability/types.ts @@ -40,6 +40,13 @@ export interface CapabilityInstallProgress { readonly percent?: number; /** Set when the last install attempt failed; cleared on the next attempt. */ readonly error?: string; + /** + * Machine-key note from the last completed install (e.g. + * 'user-skill-migrated' — a pre-existing user-source skill was replaced by + * the plugin-managed copy). Clients localize it; cleared on the next + * attempt. + */ + readonly note?: string; } export interface CapabilityDetectResult { @@ -79,5 +86,9 @@ export interface CapabilityEntry { */ readonly wiringStepId: string; detect(): Promise; - install(report: CapabilityInstallReporter): Promise; + /** + * Resolves with an optional machine-key note surfaced through + * `CapabilityInstallProgress.note` (e.g. 'user-skill-migrated'). + */ + install(report: CapabilityInstallReporter): Promise; } diff --git a/packages/agent-core-v2/test/app/capability/capabilityService.test.ts b/packages/agent-core-v2/test/app/capability/capabilityService.test.ts index 8dea89fbf..733fd5fd4 100644 --- a/packages/agent-core-v2/test/app/capability/capabilityService.test.ts +++ b/packages/agent-core-v2/test/app/capability/capabilityService.test.ts @@ -21,7 +21,7 @@ function fakeEntry(overrides: { supported?: boolean; wiringStepId?: string; detect?: CapabilityDetectResult; - install?: (report: CapabilityInstallReporter) => Promise; + install?: (report: CapabilityInstallReporter) => Promise; }): CapabilityEntry { return { id: overrides.id, @@ -33,7 +33,7 @@ function fakeEntry(overrides: { Promise.resolve( overrides.detect ?? { steps: [{ id: 'plugin', state: 'ok' }] }, ), - install: overrides.install ?? (() => Promise.resolve()), + install: overrides.install ?? (() => Promise.resolve(undefined)), }; } @@ -164,8 +164,8 @@ describe('CapabilityService', () => { id: 'kimi-cu', install: (report) => { report('download', 42); - return new Promise((resolve) => { - release = resolve; + return new Promise((resolve) => { + release = () => resolve(undefined); }); }, }), @@ -199,6 +199,22 @@ describe('CapabilityService', () => { expect.unreachable('install never settled'); }); + it('surfaces an install note from the entry through progress', async () => { + const service = fakeService([ + fakeEntry({ + id: 'kimi-cu', + install: () => Promise.resolve('user-skill-migrated'), + }), + ]); + await service.installCapability('kimi-cu'); + for (let i = 0; i < 50; i += 1) { + const status = await service.getCapability('kimi-cu'); + if (!status.install.running) break; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect((await service.getCapability('kimi-cu')).install.note).toBe('user-skill-migrated'); + }); + it('surfaces install errors through progress until the next attempt', async () => { let attempts = 0; const service = fakeService([ @@ -206,7 +222,9 @@ describe('CapabilityService', () => { id: 'kimi-cu', install: () => { attempts += 1; - return attempts === 1 ? Promise.reject(new Error('boom')) : Promise.resolve(); + return attempts === 1 + ? Promise.reject(new Error('boom')) + : Promise.resolve(undefined); }, }), ]); @@ -237,7 +255,7 @@ describe('CapabilityService shelf-install hook', () => { id: 'kimi-cu' | 'kimi-webbridge'; wiringStepId?: string; steps: Array<{ id: string; state: 'ok' | 'missing'; optional?: boolean }>; - install?: () => Promise; + install?: () => Promise; }) { const state = { steps: opts.steps }; let installs = 0; @@ -247,7 +265,7 @@ describe('CapabilityService shelf-install hook', () => { detect: { steps: state.steps }, install: () => { installs += 1; - return (opts.install ?? (() => Promise.resolve()))(); + return (opts.install ?? (() => Promise.resolve(undefined)))(); }, }); // Re-read the mutable step list on every detect. diff --git a/packages/agent-core-v2/test/app/capability/kimiWebbridge.test.ts b/packages/agent-core-v2/test/app/capability/kimiWebbridge.test.ts index 50725fe5c..2706a6b65 100644 --- a/packages/agent-core-v2/test/app/capability/kimiWebbridge.test.ts +++ b/packages/agent-core-v2/test/app/capability/kimiWebbridge.test.ts @@ -215,7 +215,10 @@ describe('kimi-webbridge entry', () => { makeCtx({ plugins: plugins.service, hostProcess: host.service, fetchImpl }), ); - await entry.install((step, percent) => reports.push([step, percent])); + const note = await entry.install((step, percent) => reports.push([step, percent])); + + // Migration note surfaced (the pre-existing user skill was replaced). + expect(note).toBe('user-skill-migrated'); // Binary downloaded into place and made executable. const binPath = path.join(root, 'user-home', '.kimi-webbridge', 'bin', 'kimi-webbridge'); @@ -244,8 +247,10 @@ describe('kimi-webbridge entry', () => { makeCtx({ plugins: plugins.service, hostProcess: host.service, fetchImpl }), ); - await entry.install(() => {}); + const note = await entry.install(() => {}); expect(host.calls).toEqual([]); + // No pre-existing user skill → no migration note. + expect(note).toBeUndefined(); }); it('rejects install on unsupported platforms before any side effect', async () => { diff --git a/packages/kap-server/src/protocol/rest-capability.ts b/packages/kap-server/src/protocol/rest-capability.ts index ffe9419dd..0e217329b 100644 --- a/packages/kap-server/src/protocol/rest-capability.ts +++ b/packages/kap-server/src/protocol/rest-capability.ts @@ -19,6 +19,7 @@ export const capabilityInstallProgressSchema = z.object({ step: z.string().optional(), percent: z.number().min(0).max(100).optional(), error: z.string().optional(), + note: z.string().optional(), }); export type CapabilityInstallProgressWire = z.infer;