feat(runner): filter marketplace recommendations by explicit usage

This commit is contained in:
RockChinQ
2026-09-12 12:51:16 +08:00
parent fa15f48fd7
commit 1277c6da07
28 changed files with 272 additions and 23 deletions
+1 -1
View File
@@ -232,4 +232,4 @@ line-ending = "auto"
[tool.uv.sources]
# Development contract: update to the matching SDK release before publishing.
langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "92a9e03fa9c791f4ed30cc3f5f0602c13b800d28" }
langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "c67e6c85a0cde8ae2b20cbd89e33805a68382563" }
@@ -9,6 +9,7 @@ metadata:
en_US: Deterministic runner fixture that returns stable QA sentinel output.
zh_Hans: 返回稳定 QA 哨兵输出的确定性 runner 夹具。
spec:
usages: [agent]
capabilities:
streaming: true
tool_calling: false
+1 -1
View File
@@ -55,7 +55,7 @@ class RunnerDescriptor(pydantic.BaseModel):
"""Original manifest for reference"""
component_kind: typing.Literal['Runner'] = 'Runner'
usages: list[typing.Literal['agent', 'event']] = pydantic.Field(default_factory=lambda: ['agent'])
usages: list[typing.Literal['agent', 'event']] = pydantic.Field(min_length=1)
supported_event_patterns: list[str] = pydantic.Field(default_factory=lambda: ['*'])
model_config = pydantic.ConfigDict(
@@ -32,6 +32,7 @@ def make_descriptor(
permissions: dict | None = None,
) -> RunnerDescriptor:
return RunnerDescriptor(
usages=['agent'],
id='plugin:test/runner/default',
source='plugin',
label={'en_US': 'Test Runner'},
@@ -91,6 +91,7 @@ class TestContextValidation:
def _make_descriptor(self):
"""Create a mock runner descriptor."""
return RunnerDescriptor(
usages=['agent'],
id='plugin:test/plugin/runner',
source='plugin',
label={'en_US': 'Test Runner'},
@@ -125,6 +125,7 @@ class MockApplication:
class FakeRunnerRegistry:
async def get(self, context, runner_id, bound_plugins=None):
return RunnerDescriptor(
usages=['agent'],
id=runner_id,
source='plugin',
label={'en_US': 'Test Runner'},
@@ -23,6 +23,7 @@ SOURCE = {'source': 'native', 'source_id': None}
async def _resources(tool_mgr, rag_mgr, binding):
descriptor = RunnerDescriptor(
usages=['agent'],
id=LOCAL_RUNNER_ID,
source='plugin',
label={'en_US': 'Local Agent'},
@@ -164,6 +164,7 @@ class FakeConversation:
def make_descriptor() -> RunnerDescriptor:
return RunnerDescriptor(
usages=['agent'],
id=RUNNER_ID,
source='plugin',
label={'en_US': 'Local Agent'},
+19
View File
@@ -55,6 +55,7 @@ class FakeApplication:
'id': 'plugin:langbot-team/LocalAgent/default',
'name': 'default',
'label': {'en_US': 'Local Agent'},
'usages': ['agent'],
'capabilities': {'streaming': True},
'permissions': {},
'config_schema': [],
@@ -68,6 +69,7 @@ class FakeApplication:
'id': 'plugin:alice/my-agent/custom',
'name': 'custom',
'label': {'en_US': 'Custom Agent'},
'usages': ['agent'],
'capabilities': {},
'permissions': {},
'config_schema': [{'name': 'param1', 'type': 'string'}],
@@ -308,6 +310,7 @@ class TestDescriptorValidation:
def test_validate_runner_descriptor(self):
"""Validate correctly built descriptor."""
descriptor = RunnerDescriptor(
usages=['agent'],
id='plugin:test/my-runner/default',
source='plugin',
label={'en_US': 'Test Runner'},
@@ -323,6 +326,7 @@ class TestDescriptorValidation:
def test_descriptor_capabilities(self):
"""Descriptor capability helper methods."""
descriptor = RunnerDescriptor(
usages=['agent'],
id='plugin:test/my-runner/default',
source='plugin',
label={'en_US': 'Test Runner'},
@@ -365,3 +369,18 @@ async def test_registry_filters_usages_without_splitting_component_identity():
assert [item.runner_name for item in processors] == ['events', 'both']
assert agents[-1].id == processors[-1].id
assert (await registry.get(TEST_CONTEXT, processors[0].id)).usages == ['event']
@pytest.mark.asyncio
async def test_discovery_rejects_runner_without_usage_declaration():
ap = FakeApplication()
original = ap.plugin_connector.list_runners
async def missing_usage(bound_plugins=None):
runners = await original(bound_plugins)
runners[0]['manifest'].pop('usages')
return runners
ap.plugin_connector.list_runners = missing_usage
runners = await RunnerRegistry(ap).list_runners(TEST_CONTEXT, use_cache=False)
assert [runner.id for runner in runners] == ['plugin:alice/my-agent/custom']
@@ -39,6 +39,7 @@ def make_descriptor(
permissions: dict | None = None,
) -> RunnerDescriptor:
return RunnerDescriptor(
usages=['agent'],
id=RUNNER_ID,
source='plugin',
label={'en_US': 'Test Runner'},
@@ -37,6 +37,7 @@ class FakeApplication:
def make_descriptor():
"""Create a test descriptor."""
return RunnerDescriptor(
usages=['agent'],
id='plugin:langbot-team/LocalAgent/default',
source='plugin',
label={'en_US': 'Local Agent', 'zh_Hans': '内置 Agent'},
@@ -25,6 +25,7 @@ from langbot.pkg.agent.runner.state_scope import (
def make_descriptor(runner_id: str = 'plugin:test/my-runner/default') -> RunnerDescriptor:
"""Create a test descriptor."""
return RunnerDescriptor(
usages=['agent'],
id=runner_id,
source='plugin',
label={'en_US': 'Test Runner'},
@@ -26,6 +26,7 @@ class FakeRegistry:
def make_runner(runner_id: str, config_schema: list[dict]):
parts = runner_id.removeprefix('plugin:').split('/')
return RunnerDescriptor(
usages=['agent'],
id=runner_id,
source='plugin',
label={'en_US': runner_id},
@@ -32,6 +32,7 @@ RUNNER_ID = 'plugin:langbot-team/LocalAgent/default'
def attach_runner_descriptor(app):
descriptor = RunnerDescriptor(
usages=['agent'],
id=RUNNER_ID,
source='plugin',
label={'en_US': 'Local Agent'},
@@ -17,6 +17,7 @@ def _attach_runner_descriptor(app):
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
descriptor = RunnerDescriptor(
usages=['agent'],
id=RUNNER_ID,
source='plugin',
label={'en_US': 'Local Agent'},
+1
View File
@@ -80,6 +80,7 @@ def _make_app(*, skill_service) -> SimpleNamespace:
model = SimpleNamespace(model_entity=SimpleNamespace(uuid='model-1', abilities={'func_call'}))
tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock(return_value=[]))
descriptor = RunnerDescriptor(
usages=['agent'],
id=_RUNNER_ID,
source='plugin',
label={'en_US': 'Local Agent'},
Generated
+2 -2
View File
@@ -2119,7 +2119,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=92a9e03fa9c791f4ed30cc3f5f0602c13b800d28" },
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=c67e6c85a0cde8ae2b20cbd89e33805a68382563" },
{ name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2186,7 +2186,7 @@ dev = [
[[package]]
name = "langbot-plugin"
version = "0.5.5"
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=92a9e03fa9c791f4ed30cc3f5f0602c13b800d28#92a9e03fa9c791f4ed30cc3f5f0602c13b800d28" }
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=c67e6c85a0cde8ae2b20cbd89e33805a68382563#c67e6c85a0cde8ae2b20cbd89e33805a68382563" }
dependencies = [
{ name = "aiofiles" },
{ name = "aiohttp" },
@@ -115,7 +115,7 @@ export default function PluginProcessorSettings({
<div className="p-2 text-sm text-muted-foreground">
{t('agents.eventProcessor.noComponents')}
<Button asChild variant="link" className="h-auto px-0">
<Link to="/home/plugins">
<Link to="/home/add-extension?type=plugin&component=Runner&runner_usage=event">
{t('agents.eventProcessor.installPlugin')}
</Link>
</Button>
@@ -227,7 +227,7 @@ export default function RunnerSelect({
setCatalogLoading(true);
setCatalogError(false);
try {
const catalog = await loadRunnerCatalog();
const catalog = await loadRunnerCatalog('agent');
setMarketplaceRunners(catalog.marketplaceRunners);
setInstalledPluginIds(catalog.installedPluginIds);
setInstalledPluginDescriptions(catalog.installedPluginDescriptions);
@@ -392,7 +392,7 @@ export default function RunnerSelect({
{t('agents.marketplaceRunners')}
</span>
<a
href="https://space.langbot.app/market?type=plugin&component=Runner"
href="https://space.langbot.app/market?type=plugin&component=Runner&runner_usage=agent"
target="_blank"
rel="noreferrer"
className="inline-flex shrink-0 items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-medium text-foreground hover:bg-accent"
+11 -3
View File
@@ -3,7 +3,11 @@ import { getCloudServiceClient } from '@/app/infra/http';
import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext';
import type { IDynamicFormItemOption } from '@/app/infra/entities/form/dynamic';
import type { PipelineConfigTab } from '@/app/infra/entities/pipeline';
import type { PluginV4 } from '@/app/infra/entities/plugin';
import {
supportsRunnerUsage,
type PluginV4,
type RunnerUsage,
} from '@/app/infra/entities/plugin';
import type { I18nObject } from '@/app/infra/entities/common';
export const RUNNER_COMPONENT_FILTER = 'Runner';
@@ -140,7 +144,9 @@ export function subscribePendingRunnerInstall(
window.removeEventListener(RUNNER_INSTALL_INTENT_EVENT, handleChange);
}
export async function loadRunnerCatalog(): Promise<RunnerCatalog> {
export async function loadRunnerCatalog(
usage: RunnerUsage,
): Promise<RunnerCatalog> {
const cloudClient = await getCloudServiceClient();
const [firstSearchResult, recommendationResult, installedResult] =
await Promise.all([
@@ -150,6 +156,7 @@ export async function loadRunnerCatalog(): Promise<RunnerCatalog> {
page_size: RUNNER_CATALOG_PAGE_SIZE,
type_filter: 'plugin',
component_filter: RUNNER_COMPONENT_FILTER,
runner_usage: usage,
}),
cloudClient.getRecommendationLists().catch(() => ({ lists: [] })),
httpClient.getPlugins().catch(() => ({ plugins: [] })),
@@ -167,6 +174,7 @@ export async function loadRunnerCatalog(): Promise<RunnerCatalog> {
page_size: RUNNER_CATALOG_PAGE_SIZE,
type_filter: 'plugin',
component_filter: RUNNER_COMPONENT_FILTER,
runner_usage: usage,
}),
),
);
@@ -189,7 +197,7 @@ export async function loadRunnerCatalog(): Promise<RunnerCatalog> {
}
const marketplaceRunners = catalogPlugins
.filter((plugin) => plugin.components?.[RUNNER_COMPONENT_FILTER])
.filter((plugin) => supportsRunnerUsage(plugin, usage))
.sort((left, right) => {
const leftOrder = recommendationOrder.get(marketplacePluginId(left));
const rightOrder = recommendationOrder.get(marketplacePluginId(right));
@@ -126,6 +126,14 @@ function MarketPageContent({
loadMarketFilters().componentFilter ??
'all',
);
const [runnerUsage, setRunnerUsage] = useState(() => {
const value = searchParams.get('runner_usage');
return value === 'agent' || value === 'event' ? value : 'all';
});
const activeRunnerUsage =
componentFilter === 'Runner' && runnerUsage !== 'all'
? (runnerUsage as 'agent' | 'event')
: undefined;
const [typeFilter, setTypeFilter] = useState<string>(() => {
if (getComponentFilterFromQuery(searchParams)) {
return 'plugin';
@@ -138,7 +146,9 @@ function MarketPageContent({
return saved && MARKET_TYPE_VALUES.includes(saved) ? saved : 'all';
});
const activeAdvancedFilters =
(typeFilter === 'all' ? 0 : 1) + (componentFilter === 'all' ? 0 : 1);
(typeFilter === 'all' ? 0 : 1) +
(componentFilter === 'all' ? 0 : 1) +
(activeRunnerUsage ? 1 : 0);
const [selectedTags, setSelectedTags] = useState<string[]>(
() => loadMarketFilters().selectedTags ?? [],
);
@@ -313,6 +323,7 @@ function MarketPageContent({
type_filter: typeFilter === 'all' ? undefined : typeFilter,
component_filter:
componentFilter === 'all' ? undefined : componentFilter,
runner_usage: activeRunnerUsage,
tags_filter: selectedTags.length > 0 ? selectedTags : undefined,
});
@@ -344,6 +355,7 @@ function MarketPageContent({
[
searchQuery,
componentFilter,
activeRunnerUsage,
selectedTags,
pageSize,
transformToVO,
@@ -502,6 +514,10 @@ function MarketPageContent({
setPlugins([]);
const params = new URLSearchParams(searchParams);
if (value !== 'Runner') {
setRunnerUsage('all');
params.delete('runner_usage');
}
if (value === 'all') {
params.delete('component');
} else {
@@ -517,7 +533,7 @@ function MarketPageContent({
// 当排序选项或组件筛选或类型筛选变化时重新加载数据
useEffect(() => {
fetchPlugins(1, !!searchQuery.trim(), true);
}, [sortOption, componentFilter, typeFilter]);
}, [sortOption, componentFilter, typeFilter, activeRunnerUsage]);
// Tags 筛选变化时重新搜索
useEffect(() => {
@@ -826,6 +842,38 @@ function MarketPageContent({
})}
</ToggleGroup>
</div>
{componentFilter === 'Runner' && (
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">
{t('market.runnerUsage')}
</div>
<ToggleGroup
type="single"
size="sm"
value={runnerUsage}
onValueChange={(value) => {
if (!value) return;
setRunnerUsage(value);
setCurrentPage(1);
setPlugins([]);
const params = new URLSearchParams(searchParams);
if (value === 'all') params.delete('runner_usage');
else params.set('runner_usage', value);
setSearchParams(params, { replace: true });
}}
>
<ToggleGroupItem value="all">
{t('market.runnerUsageAll')}
</ToggleGroupItem>
<ToggleGroupItem value="agent">
{t('market.runnerUsageAgent')}
</ToggleGroupItem>
<ToggleGroupItem value="event">
{t('market.runnerUsageEvent')}
</ToggleGroupItem>
</ToggleGroup>
</div>
)}
</PopoverContent>
</Popover>
@@ -52,8 +52,23 @@ export interface PluginV4 {
hot_score?: number;
latest_version: string;
components: Record<string, number>;
runner_usages?: RunnerUsage[];
status: PluginV4Status;
type?: 'plugin' | 'mcp' | 'skill';
created_at: string;
updated_at: string;
}
export type RunnerUsage = 'agent' | 'event';
/** Unknown usage metadata must not become an install recommendation. */
export function supportsRunnerUsage(
plugin: PluginV4,
usage: RunnerUsage,
): boolean {
return Boolean(
plugin.components?.Runner &&
Array.isArray(plugin.runner_usages) &&
plugin.runner_usages.includes(usage),
);
}
+19 -2
View File
@@ -3,7 +3,11 @@ import {
ApiRespMarketplacePluginDetail,
ApiRespMarketplacePlugins,
} from '@/app/infra/entities/api';
import { PluginV4 } from '@/app/infra/entities/plugin';
import {
PluginV4,
RunnerUsage,
supportsRunnerUsage,
} from '@/app/infra/entities/plugin';
import { I18nObject } from '@/app/infra/entities/common';
/**
@@ -39,6 +43,7 @@ export class CloudServiceClient extends BaseHttpClient {
component_filter?: string,
tags_filter?: string[],
type_filter?: string,
runner_usage?: RunnerUsage,
): Promise<ApiRespMarketplacePlugins> {
// Use different endpoints based on type_filter
if (type_filter === 'mcp') {
@@ -90,6 +95,7 @@ export class CloudServiceClient extends BaseHttpClient {
sort_by,
sort_order,
component_filter,
runner_usage,
tags_filter,
type_filter,
},
@@ -104,6 +110,7 @@ export class CloudServiceClient extends BaseHttpClient {
sort_order?: string;
type_filter?: string;
component_filter?: string;
runner_usage?: RunnerUsage;
tags_filter?: string[];
}): Promise<ApiRespMarketplacePlugins> {
return this.post<{ extensions: PluginV4[]; total: number }>(
@@ -125,7 +132,15 @@ export class CloudServiceClient extends BaseHttpClient {
total: resp?.total || 0,
};
})
.catch(() => this.searchMarketplaceExtensionsLegacy(data));
.catch(() => this.searchMarketplaceExtensionsLegacy(data))
.then((result) => ({
...result,
plugins: data.runner_usage
? result.plugins.filter((plugin) =>
supportsRunnerUsage(plugin, data.runner_usage!),
)
: result.plugins,
}));
}
public getMarketplaceLikedExtensions(
@@ -162,6 +177,7 @@ export class CloudServiceClient extends BaseHttpClient {
sort_order?: string;
type_filter?: string;
component_filter?: string;
runner_usage?: RunnerUsage;
tags_filter?: string[];
}): Promise<ApiRespMarketplacePlugins> {
const query = data.query || '';
@@ -183,6 +199,7 @@ export class CloudServiceClient extends BaseHttpClient {
data.component_filter,
data.tags_filter,
data.component_filter ? 'plugin' : data.type_filter,
data.runner_usage,
).catch((error) => {
if (data.type_filter === 'mcp' || data.type_filter === 'skill') {
return { plugins: [], total: 0 };
+2 -2
View File
@@ -225,7 +225,7 @@ export default function WizardPage() {
setIsRunnerCatalogLoading(true);
setRunnerCatalogError(false);
try {
const catalog = await fetchRunnerCatalog();
const catalog = await fetchRunnerCatalog('agent');
setMarketplaceRunners(catalog.marketplaceRunners);
setInstalledPluginIds(catalog.installedPluginIds);
} catch (error) {
@@ -2087,7 +2087,7 @@ function StepAIEngine({
<div className="flex justify-center">
<Button variant="outline" size="sm" asChild>
<Link to="/home/extensions?type=plugin&component=Runner">
<Link to="/home/extensions?type=plugin&component=Runner&runner_usage=agent">
{t('wizard.aiEngine.browseRunners')}
<ExternalLink className="size-4" />
</Link>
+7 -2
View File
@@ -1229,6 +1229,11 @@ const enUS = {
},
},
market: {
runnerUsage: 'Runner usage',
runnerUsageAll: 'All',
runnerUsageAgent: 'Agent / Pipeline',
runnerUsageEvent: 'Plugin processor',
searchPlaceholder: 'Search plugins...',
searchPlaceholderCount:
'Search {{count}} extensions, capabilities, or use cases...',
@@ -2543,9 +2548,9 @@ const enUS = {
catalogUnavailable: 'Runner catalog is unavailable',
catalogUnavailableDescription:
'Installed runners are still available. Retry the catalog or browse Extensions.',
noMarketplaceRunners: 'No Runner extensions are published yet',
noMarketplaceRunners: 'No Runner plugins match this usage',
noMarketplaceRunnersDescription:
'Retry after runner extensions are published to the configured Marketplace.',
'Use an installed Runner or try again later.',
browseRunners: 'Browse Runner Extensions',
installAndContinue: 'Install & Continue',
installing: 'Installing...',
+7 -2
View File
@@ -1149,6 +1149,11 @@ const jaJP = {
uploadPluginOnly: '.lbpkg プラグインパッケージのみ対応しています',
},
market: {
runnerUsage: 'ランナーの用途',
runnerUsageAll: 'すべて',
runnerUsageAgent: 'Agent / パイプライン',
runnerUsageEvent: 'プラグインプロセッサー',
searchPlaceholder: 'プラグインを検索...',
searchPlaceholderCount:
'{{count}} 個の拡張機能・機能・ユースケースを検索...',
@@ -2323,9 +2328,9 @@ const jaJP = {
catalogUnavailable: 'Runner カタログを読み込めません',
catalogUnavailableDescription:
'インストール済みの Runner は引き続き使用できます。再試行するか、拡張機能を確認してください。',
noMarketplaceRunners: 'Runner 拡張機能はまだ公開されていません',
noMarketplaceRunners: 'この用途に対応するランナープラグインはありません',
noMarketplaceRunnersDescription:
'設定済みのマーケットプレイスに Runner 拡張機能が公開された後、再試行してください。',
'インストール済みのランナーを使うか、後でもう一度お試しください。',
browseRunners: 'Runner 拡張機能を見る',
installAndContinue: 'インストールして続行',
installing: 'インストール中...',
+7 -3
View File
@@ -1166,6 +1166,11 @@ const zhHans = {
},
},
market: {
runnerUsage: '运行器用途',
runnerUsageAll: '全部',
runnerUsageAgent: 'Agent / 流水线',
runnerUsageEvent: '插件处理器',
searchPlaceholder: '搜索插件...',
searchPlaceholderCount: '搜索 {{count}} 个扩展、能力或场景...',
searchResults: '搜索到 {{count}} 个扩展',
@@ -2404,9 +2409,8 @@ const zhHans = {
catalogUnavailable: '无法加载运行器目录',
catalogUnavailableDescription:
'已安装的运行器仍可使用。你可以重试,或前往扩展页面查看。',
noMarketplaceRunners: '市场暂未发布运行器扩展',
noMarketplaceRunnersDescription:
'请在运行器扩展发布到当前配置的市场后重试。',
noMarketplaceRunners: '暂无适用于此用途的运行器插件',
noMarketplaceRunnersDescription: '可以使用已安装的运行器,或稍后重试。',
browseRunners: '浏览运行器扩展',
installAndContinue: '安装并继续',
installing: '正在安装...',
@@ -0,0 +1,115 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
function load(path, imports = {}) {
const exports = {};
const source = fs.readFileSync(new URL(path, import.meta.url), 'utf8');
const js = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
}).outputText;
new Function('exports', 'require', js)(exports, (name) => {
if (!(name in imports)) throw new Error(`Unexpected import: ${name}`);
return imports[name];
});
return exports;
}
const entities = load('../../src/app/infra/entities/plugin/index.ts');
const plugin = (name, runner_usages) => ({
name,
author: 'test',
components: { Runner: 1 },
runner_usages,
install_count: 0,
latest_version: '1',
});
const plugins = [
plugin('agent', ['agent']),
plugin('event', ['event']),
plugin('both', ['agent', 'event']),
plugin('unknown', undefined),
];
test('recommendations require explicit usage and Runner component', () => {
assert.deepEqual(
plugins
.filter((p) => entities.supportsRunnerUsage(p, 'agent'))
.map((p) => p.name),
['agent', 'both'],
);
assert.deepEqual(
plugins
.filter((p) => entities.supportsRunnerUsage(p, 'event'))
.map((p) => p.name),
['event', 'both'],
);
assert.equal(
entities.supportsRunnerUsage({ ...plugins[0], components: {} }, 'agent'),
false,
);
});
test('catalog filters every page and recommendations cannot reintroduce incompatible plugins', async () => {
const requests = [];
const catalog = load('../../src/app/home/agents/runner-marketplace.ts', {
'@/app/infra/entities/plugin': entities,
'@/app/infra/http/HttpClient': {
httpClient: { getPlugins: async () => ({ plugins: [] }) },
},
'@/app/infra/http': {
getCloudServiceClient: async () => ({
searchMarketplaceExtensions: async (request) => {
requests.push(request);
return {
total: 101,
plugins:
request.page === 1 ? plugins.slice(0, 2) : plugins.slice(2),
};
},
getRecommendationLists: async () => ({
lists: [{ plugins: [plugins[1], plugins[2]] }],
}),
}),
},
'@/app/infra/http/workspaceContext': {},
});
const result = await catalog.loadRunnerCatalog('agent');
assert.deepEqual(
result.marketplaceRunners.map((p) => p.name),
['both', 'agent'],
);
assert.deepEqual(
requests.map((r) => r.runner_usage),
['agent', 'agent'],
);
});
test('legacy API fallback preserves usage and rejects missing usage metadata', async () => {
const requests = [];
class BaseHttpClient {
async post(url, data) {
requests.push({ url, data });
if (url.endsWith('/extensions/search')) throw new Error('old endpoint');
return { plugins, total: plugins.length };
}
}
const { CloudServiceClient } = load(
'../../src/app/infra/http/CloudServiceClient.ts',
{
'./BaseHttpClient': { BaseHttpClient },
'@/app/infra/entities/plugin': entities,
},
);
const result = await new CloudServiceClient().searchMarketplaceExtensions({
page: 1,
page_size: 100,
type_filter: 'plugin',
component_filter: 'Runner',
runner_usage: 'event',
});
assert.deepEqual(
result.plugins.map((p) => p.name),
['event', 'both'],
);
assert.equal(requests[1].data.runner_usage, 'event');
});