Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
664 changes: 662 additions & 2 deletions apps/server/src/provider/providerExtensions.test.ts

Large diffs are not rendered by default.

744 changes: 703 additions & 41 deletions apps/server/src/provider/providerExtensions.ts

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner
import {
addProviderExtensionMarketplace,
callProviderExtensionMcpTool,
createProviderExtensionSkill,
deleteProviderExtensionSkill,
getProviderExtensionOperationStatus,
installProviderExtensionPlugin,
readProviderInstructionFiles,
Expand Down Expand Up @@ -1368,6 +1370,24 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
}),
{ "rpc.aggregate": "server" },
),
[WS_METHODS.serverCreateProviderExtensionSkill]: (input) =>
observeRpcEffect(
WS_METHODS.serverCreateProviderExtensionSkill,
Effect.gen(function* () {
const settings = yield* loadProviderExtensionSettings;
return yield* createProviderExtensionSkill({ request: input, settings });
}),
{ "rpc.aggregate": "server" },
),
[WS_METHODS.serverDeleteProviderExtensionSkill]: (input) =>
observeRpcEffect(
WS_METHODS.serverDeleteProviderExtensionSkill,
Effect.gen(function* () {
const settings = yield* loadProviderExtensionSettings;
return yield* deleteProviderExtensionSkill({ request: input, settings });
}),
{ "rpc.aggregate": "server" },
),
[WS_METHODS.serverReadProviderExtensionPlugin]: (input) =>
observeRpcEffect(
WS_METHODS.serverReadProviderExtensionPlugin,
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
ArrowLeftIcon,
ArrowUpIcon,
CornerLeftUpIcon,
FileTextIcon,
FolderIcon,
FolderPlusIcon,
GaugeIcon,
Expand Down Expand Up @@ -850,14 +851,14 @@
buildProjectActionItems({
projects,
valuePrefix: "project",
icon: (project) => (
<ProjectFavicon
environmentId={project.environmentId}
cwd={project.cwd}
name={project.name}
className={ITEM_ICON_CLASS}
/>
),

Check warning on line 861 in apps/web/src/components/CommandPalette.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

react(no-unstable-nested-components)

Do not define components during render.
runProject: openProjectFromSearch,
}),
[openProjectFromSearch, projects],
Expand All @@ -868,14 +869,14 @@
buildProjectActionItems({
projects,
valuePrefix: "new-thread-in",
icon: (project) => (
<ProjectFavicon
environmentId={project.environmentId}
cwd={project.cwd}
name={project.name}
className={ITEM_ICON_CLASS}
/>
),

Check warning on line 879 in apps/web/src/components/CommandPalette.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

react(no-unstable-nested-components)

Do not define components during render.
runProject: async (project) => {
await startNewThreadInProjectFromContext(
{
Expand Down Expand Up @@ -906,25 +907,25 @@
...(activeThreadId ? { activeThreadId } : {}),
projectTitleById,
sortOrder: settings.sidebarThreadSortOrder,
icon: (thread) => {
const project = projectByScopedKey.get(
scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)),
);
if (!project) {
return <MessageSquareIcon className={ITEM_ICON_CLASS} />;
}
if (project.kind === "general-chat") {
return <MessagesSquareIcon className={ITEM_ICON_CLASS} />;
}
return (
<ProjectFavicon
environmentId={project.environmentId}
cwd={project.cwd}
name={project.name}
className={ITEM_ICON_CLASS}
/>
);
},

Check warning on line 928 in apps/web/src/components/CommandPalette.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

react(no-unstable-nested-components)

Do not define components during render.
renderLeadingContent: (thread) => <ThreadRowLeadingStatus thread={thread} />,
renderTrailingContent: (thread) => <ThreadRowTrailingStatus thread={thread} />,
runThread: async (thread) => {
Expand Down Expand Up @@ -1641,6 +1642,17 @@
},
});

actionItems.push({
kind: "action",
value: "action:skills",
searchTerms: ["skills", "plugins", "extensions", "agents", "capabilities"],
title: "Open skills",
icon: <FileTextIcon className={ITEM_ICON_CLASS} />,
run: async () => {
await navigate({ to: "/settings/plugins", search: { tab: "skills" } });
},
});

const rootGroups = buildRootGroups({ actionItems, recentThreadItems });
const activeGroups = currentView
? currentView.groups.map((group) => ({
Expand Down
173 changes: 173 additions & 0 deletions apps/web/src/components/settings/ExtensionsSettings.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,21 @@ import {
extensionMcpNeedsAuthStatus,
extensionMcpOAuthActionIntent,
extensionMcpOAuthActionLabel,
extensionBrowserStatusLine,
extensionTextMatchesFilter,
extensionProviderDriverSortRank,
formatExtensionGroupLabel,
isLikelyLocalPath,
isProviderCoverageMissing,
makeExtensionInventoryCacheKey,
formatSkillDisplayName,
formatTokenCount,
groupExtensionSkills,
bucketSkillsByPlugin,
dedupeShadowedSkills,
groupPluginComponents,
parseExtensionsSettingsTab,
resolveExtensionsSettingsTab,
makeExtensionJsonSchemaFormDefaults,
resolvePluginComponentTarget,
selectCuratedPlugins,
Expand All @@ -31,6 +38,172 @@ import {
} from "./ExtensionsSettings.logic";

describe("ExtensionsSettings logic", () => {
it("takes the tab from the URL and falls back to the remembered one", () => {
expect(parseExtensionsSettingsTab("skills")).toBe("skills");
expect(parseExtensionsSettingsTab("nonsense")).toBeUndefined();
expect(parseExtensionsSettingsTab(undefined)).toBeUndefined();

// An explicit link wins over whatever the last visit left behind.
expect(resolveExtensionsSettingsTab("plugins", "skills")).toBe("plugins");
expect(resolveExtensionsSettingsTab(undefined, "skills")).toBe("skills");
expect(resolveExtensionsSettingsTab(undefined, undefined)).toBe("plugins");
});

it("groups skills by origin and sorts each group by name", () => {
const skills = [
{ name: "zebra", scope: "user" },
{ name: "shipped", scope: "system" },
{ name: "alpha", scope: "user" },
{ name: "repo-check", scope: "project" },
{ name: "bundled", scope: "user", bundleId: "helper@market" },
];

const groups = groupExtensionSkills(skills, (skill) => ({
scope: skill.scope,
bundleId: skill.bundleId,
sortKey: skill.name,
}));

expect(groups.map((group) => group.label)).toEqual([
"Project skills",
"Personal skills",
"From plugins",
"Built in",
]);
expect(groups[1]?.items.map((skill) => skill.name)).toEqual(["alpha", "zebra"]);
// A bundled skill belongs to its plugin no matter which root it was found in.
expect(groups[2]?.items.map((skill) => skill.name)).toEqual(["bundled"]);
});

it("hides a Codex built-in skill that the user has their own copy of", () => {
const skills = [
{ id: "user-imagegen", providerId: "codex", name: "imagegen", scope: "user" },
{ id: "system-imagegen", providerId: "codex", name: "imagegen", scope: "system" },
{ id: "system-openai-docs", providerId: "codex", name: "openai-docs", scope: "system" },
// Same name, different provider: not a collision, both stay.
{ id: "claude-imagegen", providerId: "claudeAgent", name: "imagegen", scope: "user" },
];

const visible = dedupeShadowedSkills(skills, (skill) => ({
providerId: skill.providerId,
name: skill.name,
scope: skill.scope,
}));

expect(visible.map((skill) => skill.id)).toEqual([
"user-imagegen",
"system-openai-docs",
"claude-imagegen",
]);
});

it("buckets bundled skills per plugin and opens only the narrowed ones", () => {
const skills = [
{ id: "a", bundle: "posthog@official", label: "PostHog", name: "alpha", matches: true },
{ id: "b", bundle: "posthog@official", label: "PostHog", name: "beta", matches: false },
{ id: "c", bundle: "figma@official", label: "Figma", name: "gamma", matches: true },
{ id: "d", bundle: "figma@official", label: "Figma", name: "delta", matches: true },
{ id: "e", bundle: "stripe@official", label: "Stripe", name: "eps", matches: false },
];

const buckets = bucketSkillsByPlugin(skills, (skill) => ({
bundleId: skill.bundle,
label: skill.label,
sortKey: skill.name,
matches: skill.matches,
}));

// Sorted by plugin name; the plugin with nothing matching drops out.
expect(buckets.map((bucket) => bucket.label)).toEqual(["Figma", "PostHog"]);
// Every Figma skill matched, so the query was about the plugin, not a skill inside it.
expect(buckets[0]?.autoExpand).toBe(false);
expect(buckets[0]?.matching.map((skill) => skill.name)).toEqual(["delta", "gamma"]);
// PostHog matched one of two, so the match is the point and the group opens.
expect(buckets[1]?.autoExpand).toBe(true);
expect(buckets[1]?.total).toBe(2);
expect(buckets[1]?.matching.map((skill) => skill.id)).toEqual(["a"]);
});

it("knows when the loaded inventory cannot answer for the selected provider", () => {
const base = {
providerInstanceId: "codex",
hasInventory: true,
hasError: false,
};

// The bug: a Claude-scoped fetch leaves an inventory with no Codex entry, so selecting the
// Codex chip filtered to nothing and every section claimed to be empty.
expect(
isProviderCoverageMissing({ ...base, inventoryProviderInstanceIds: ["claudeAgent"] }),
).toBe(true);

expect(
isProviderCoverageMissing({
...base,
inventoryProviderInstanceIds: ["codex", "claudeAgent"],
}),
).toBe(false);
// No filter means the inventory covers whatever it returned.
expect(
isProviderCoverageMissing({
...base,
providerInstanceId: "",
inventoryProviderInstanceIds: ["claudeAgent"],
}),
).toBe(false);
// Nothing loaded yet is the ordinary initial load, not a coverage gap.
expect(
isProviderCoverageMissing({ ...base, hasInventory: false, inventoryProviderInstanceIds: [] }),
).toBe(false);
// A failed fetch keeps its own error presentation rather than pretending to load.
expect(
isProviderCoverageMissing({ ...base, hasError: true, inventoryProviderInstanceIds: [] }),
).toBe(false);
});

it("says the browse catalog is still loading even when rows are already showing", () => {
const base = {
providerLabel: "Codex",
sectionTitle: "Apps",
visibleCount: 9,
totalCount: 9,
isCurated: false,
};

// The bug: 9 connected apps render immediately, so a plain count read as finished for the
// ~12s the directory took to arrive.
const loading = extensionBrowserStatusLine({ ...base, isLoading: true, hasError: false });
expect(loading.state).toBe("loading");
expect(loading.text).toBe("Codex - 9 shown, loading all apps");

const failed = extensionBrowserStatusLine({ ...base, isLoading: false, hasError: true });
expect(failed.state).toBe("error");
expect(failed.text).toBe("Codex - 9 shown, could not load all apps");

const ready = extensionBrowserStatusLine({
...base,
totalCount: 109,
isLoading: false,
hasError: false,
});
expect(ready.state).toBe("ready");
expect(ready.text).toBe("Codex - 9 visible from 109 total");

// Loading wins over an error left from a previous attempt.
expect(extensionBrowserStatusLine({ ...base, isLoading: true, hasError: true }).state).toBe(
"loading",
);
});

it("drops skill groups that have no members", () => {
const groups = groupExtensionSkills([{ name: "solo", scope: "user" }], (skill) => ({
scope: skill.scope,
sortKey: skill.name,
}));

expect(groups.map((group) => group.key)).toEqual(["personal"]);
});

it("matches extension records case-insensitively across provided fields", () => {
expect(extensionTextMatchesFilter(["Browser", "Control the in-app browser"], "BROW")).toBe(
true,
Expand Down
Loading
Loading