Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/plugins-marketplace-async-versions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Show the /plugins marketplace catalog as soon as it loads, with latest-version lookups running in the background.
4 changes: 4 additions & 0 deletions apps/kimi-code/src/constant/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ export { KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV } from '@moonshot-ai/agent-core-v2
export function kimiCodePluginMarketplaceUrl(): string {
return `${kimiCodeCdnBase()}/plugins/marketplace.json`;
}
// Bound on each background "latest release" lookup when the TUI fills in
// marketplace versions. Without it a stalled connection to github.com hangs
// the version phase for undici's default header timeout (300s).
export const MARKETPLACE_VERSION_LOOKUP_TIMEOUT_MS = 5000;
// Official plugins whose usage bills against the user's plan quota. Installing
// one of these shows a quota note after the install result.
export const QUOTA_CONSUMING_PLUGIN_IDS: readonly string[] = ['kimi-datasource'];
Expand Down
34 changes: 28 additions & 6 deletions apps/kimi-code/src/tui/commands/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ import {
isOfficialPluginSource,
} from '../utils/plugin-source-label';
import { KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV, QUOTA_CONSUMING_PLUGIN_IDS } from '#/constant/app';
import { loadPluginMarketplace, type PluginMarketplaceEntry } from '#/utils/plugin-marketplace';
import {
loadPluginMarketplace,
withMarketplaceLatestVersions,
type PluginMarketplace,
type PluginMarketplaceEntry,
} from '#/utils/plugin-marketplace';
import { openUrl } from '#/utils/open-url';
import type { SlashCommandHost } from './dispatch';

Expand Down Expand Up @@ -345,18 +350,35 @@ async function loadMarketplaceCatalog(
source: string | undefined,
capabilities: readonly CapabilityStatus[],
): Promise<void> {
const builtInEntries =
host.engineV2 && isDefaultMarketplaceCatalog(source)
? capabilities.map(capabilityMarketplaceEntry)
: undefined;
let marketplace: PluginMarketplace;
try {
const marketplace = await loadPluginMarketplace({
// Phase 1: render the catalog as soon as it arrives. Version lookups
// (GitHub releases/latest round trips) must not gate the first paint.
marketplace = await loadPluginMarketplace({
workDir: host.state.appState.workDir,
source,
builtInEntries:
host.engineV2 && isDefaultMarketplaceCatalog(source)
? capabilities.map(capabilityMarketplaceEntry)
: undefined,
skipLatestVersions: true,
builtInEntries,
});
panel.setMarketplace(marketplace.plugins, marketplace.source);
host.state.ui.requestRender();
} catch (error) {
panel.setMarketplaceError(formatErrorMessage(error));
host.state.ui.requestRender();
return;
}
try {
// Phase 2: resolve latest versions in the background, then refresh so
// update badges appear. Failures degrade to badge-less rows and never
// clobber the already-rendered list.
const enriched = await withMarketplaceLatestVersions(marketplace);
Comment thread
kimi-agent-bot marked this conversation as resolved.
Outdated
panel.setMarketplace(enriched.plugins, enriched.source);
} catch (error) {
log.warn('marketplace version lookup failed', { error });
}
host.state.ui.requestRender();
}
Expand Down
33 changes: 29 additions & 4 deletions apps/kimi-code/src/utils/plugin-marketplace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import {
KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV,
kimiCodePluginMarketplaceUrl,
MARKETPLACE_VERSION_LOOKUP_TIMEOUT_MS,
} from '#/constant/app';

export {
Expand All @@ -45,6 +46,31 @@ export interface LoadPluginMarketplaceOptions {
* Undefined means no injection.
*/
readonly builtInEntries?: readonly PluginMarketplaceEntry[];
/**
* Skip the per-entry "latest GitHub release" lookups so the catalog can be
* rendered as soon as it is parsed; the caller resolves versions in the
* background via {@link withMarketplaceLatestVersions} and re-renders.
*/
readonly skipLatestVersions?: boolean;
}

/**
* Second phase of the marketplace load: fill in `version` for entries that
Comment thread
kimi-agent-bot marked this conversation as resolved.
* need a GitHub `releases/latest` lookup. Every lookup gets a hard timeout
* (MARKETPLACE_VERSION_LOOKUP_TIMEOUT_MS) and per-entry failures degrade to
* a missing version (badge-less row), so this never throws for network
* reasons and never blocks the first paint.
*/
export async function withMarketplaceLatestVersions(
marketplace: PluginMarketplace,
fetchImpl: typeof fetch = fetch,
): Promise<PluginMarketplace> {
const timedFetch: typeof fetch = (input, init) =>
fetchImpl(input, {
...init,
signal: AbortSignal.timeout(MARKETPLACE_VERSION_LOOKUP_TIMEOUT_MS),
});
return withLatestVersions(marketplace, timedFetch);
}

export async function loadPluginMarketplace(
Expand All @@ -70,10 +96,9 @@ export async function loadPluginMarketplace(
}
throw error;
}
const marketplace = await withLatestVersions(
parsePluginMarketplace(read.raw, read.location),
fetchImpl,
);
const marketplace = options.skipLatestVersions === true
? parsePluginMarketplace(read.raw, read.location)
: await withLatestVersions(parsePluginMarketplace(read.raw, read.location), fetchImpl);
return options.builtInEntries !== undefined
? withBuiltInEntries(marketplace, options.builtInEntries)
: marketplace;
Expand Down
88 changes: 87 additions & 1 deletion apps/kimi-code/test/utils/plugin-marketplace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import {
KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV,
kimiCodePluginMarketplaceUrl,
} from '#/constant/app';
import { computeUpdateStatus, loadPluginMarketplace } from '#/utils/plugin-marketplace';
import {
computeUpdateStatus,
loadPluginMarketplace,
withMarketplaceLatestVersions,
} from '#/utils/plugin-marketplace';

const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../../../..');

Expand Down Expand Up @@ -588,4 +592,86 @@ describe('loadPluginMarketplace', () => {
);
});

describe('two-phase version lookup', () => {
async function writeCatalog(dir: string) {
const file = join(dir, 'marketplace.json');
await writeFile(
file,
JSON.stringify({
plugins: [
{ id: 'demo', displayName: 'Demo', source: 'https://github.com/owner/repo' },
],
}),
'utf8',
);
return file;
}

it('skipLatestVersions returns the catalog without querying GitHub', async () => {
const fetchImpl = vi.fn(async () => {
throw new Error('should not be called');
}) as unknown as typeof fetch;
const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-'));
const file = await writeCatalog(dir);

const marketplace = await loadPluginMarketplace({
workDir: dir,
source: file,
fetchImpl,
skipLatestVersions: true,
});

expect(marketplace.plugins[0]?.version).toBeUndefined();
expect(fetchImpl).not.toHaveBeenCalled();
});

it('withMarketplaceLatestVersions fills versions from the latest release redirect', async () => {
const fetchImpl = vi.fn(async (input: unknown) => ({
ok: false,
status: 302,
headers: new Headers({
location: 'https://github.com/owner/repo/releases/tag/v1.2.3',
}),
text: async () => '',
})) as unknown as typeof fetch;
const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-'));
const file = await writeCatalog(dir);
const marketplace = await loadPluginMarketplace({
workDir: dir,
source: file,
skipLatestVersions: true,
});

const enriched = await withMarketplaceLatestVersions(marketplace, fetchImpl);

expect(fetchImpl).toHaveBeenCalledWith(
'https://github.com/owner/repo/releases/latest',
expect.objectContaining({ redirect: 'manual', signal: expect.any(AbortSignal) }),
);
expect(enriched.plugins[0]?.version).toBe('1.2.3');
});

it('withMarketplaceLatestVersions degrades to a missing version when the lookup aborts', async () => {
const fetchImpl = vi.fn(async (_input: unknown, init?: { signal?: AbortSignal }) => {
// Simulate the lookup hitting the timeout: undici rejects with the
// signal's reason once the AbortSignal fires.
throw init?.signal?.aborted === true
? init.signal.reason
: new DOMException('This operation was aborted', 'AbortError');
}) as unknown as typeof fetch;
const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-'));
const file = await writeCatalog(dir);
const marketplace = await loadPluginMarketplace({
workDir: dir,
source: file,
skipLatestVersions: true,
});

const enriched = await withMarketplaceLatestVersions(marketplace, fetchImpl);

expect(enriched.plugins[0]?.version).toBeUndefined();
expect(enriched.plugins[0]?.id).toBe('demo');
});
});

});
Loading