chore: bump @openhands/extensions to 0.18.0 (#16754)

This commit is contained in:
Vasco Schiavo
2026-08-21 11:24:32 +02:00
committed by GitHub
parent 4a8cabc5fd
commit 4e7d1ec475
14 changed files with 252 additions and 53 deletions
@@ -65,8 +65,10 @@ describe("RecommendedAutomationsRail", () => {
);
expect(cardIds).toEqual([
"github-repo-monitor",
"github-issue-to-pr",
"slack-channel-monitor",
"github-agents-md-maintainer",
"news-digest",
"slack-standup-digest",
"linear-triage-assistant",
"jira-issue-to-pr",
@@ -115,8 +117,10 @@ describe("RecommendedAutomationsRail", () => {
<RecommendedAutomationsRail
installedAutomations={[
{ name: "GitHub Code Review Agent" },
{ name: "GitHub repository monitor" },
{ name: "GitHub Issue to PR Agent" },
{ name: "Slack channel monitor" },
{ name: "AGENTS.md Maintainer" },
{ name: "Daily news digest" },
{ name: "Slack standup digest" },
{ name: "Linear issue triage assistant" },
{ name: "Jira issue to GitHub PR" },
@@ -206,8 +206,11 @@ describe("recommended automations", () => {
expect(cardIds).toEqual([
"github-pr-reviewer",
"github-repo-monitor",
"github-issue-to-pr",
"slack-channel-monitor",
"github-agents-md-maintainer",
"news-digest",
"github-repo-monitor",
"slack-standup-digest",
"linear-triage-assistant",
"jira-issue-to-pr",
@@ -229,7 +232,7 @@ describe("recommended automations", () => {
const provenHeading = screen.getByText(
I18nKey.RECOMMENDED_AUTOMATIONS$SECTION_TITLE,
).parentElement!;
expect(within(provenHeading).getByText("3")).toBeInTheDocument();
expect(within(provenHeading).getByText("5")).toBeInTheDocument();
const betaHeading = screen.getByTestId(
"recommended-automations-beta-heading",
@@ -237,7 +240,7 @@ describe("recommended automations", () => {
expect(betaHeading).toHaveTextContent(
I18nKey.RECOMMENDED_AUTOMATIONS$BETA_LABEL,
);
expect(within(betaHeading).getByText("6")).toBeInTheDocument();
expect(within(betaHeading).getByText("7")).toBeInTheDocument();
const betaSection = screen.getByTestId(
"recommended-automations-beta-section",
@@ -316,6 +319,24 @@ describe("recommended automations", () => {
).toHaveAttribute("data-layout", "quadrants");
});
it("shows the declared glyph instead of a logo stack when an entry names one", () => {
render(
<RecommendedAutomationsSection
backendKind="local"
installedServers={[]}
onSelect={vi.fn()}
/>,
);
// `news-digest` connects to nothing, so there are no logos to stack; it
// names its own glyph, and the badge must render that rather than the
// generic placeholder a bare empty stack would give.
const badge = screen.getByTestId("recommended-automation-icon-news-digest");
expect(badge).not.toHaveAttribute("data-layout");
expect(badge.querySelector("svg")).toBeInTheDocument();
expect(badge.querySelector("img")).not.toBeInTheDocument();
});
it("renders missing MCP connect copy as a pill on the same row", () => {
const offsetWidthDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
@@ -72,9 +72,17 @@ describe("OpenHands extensions catalogs", () => {
const knownMcpIds = new Set(INTEGRATION_CATALOG.map((entry) => entry.id));
for (const automation of AUTOMATION_CATALOG) {
const integrationIds = getIntegrationIds(automation);
expect(integrationIds.length).toBeGreaterThan(0);
expect(integrationIds.every((id) => knownMcpIds.has(id))).toBe(true);
}
// Declaring none is legitimate — `news-digest` connects to nothing — so the
// resolution above is only worth asserting while some entry still declares
// one. Without this the loop above would pass over an empty catalog.
expect(
AUTOMATION_CATALOG.some(
(automation) => getIntegrationIds(automation).length > 0,
),
).toBe(true);
});
it("admits every setup experience the automation catalog ships", () => {
+32 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { AUTOMATION_CATALOG } from "@openhands/extensions/automations";
import { getAutomationLaunchPrompt } from "#/utils/automation-catalog";
import { Activity, Bot } from "lucide-react";
import {
getAutomationIcon,
getAutomationLaunchPrompt,
} from "#/utils/automation-catalog";
const automationById = (id: string) =>
AUTOMATION_CATALOG.find((automation) => automation.id === id)!;
@@ -27,3 +31,30 @@ describe("getAutomationLaunchPrompt", () => {
);
});
});
describe("getAutomationIcon", () => {
it("resolves the glyph an entry declares", () => {
// Arrange / Act / Assert — the two entries that name one, each a
// different slug, so the lookup is not passing by returning a constant.
expect(getAutomationIcon(automationById("news-digest"))).toBe(Activity);
expect(
getAutomationIcon(automationById("github-agents-md-maintainer")),
).toBe(Bot);
});
it("returns null for an entry that declares none", () => {
// Its integrations' logos identify it instead.
expect(getAutomationIcon(automationById("github-pr-reviewer"))).toBeNull();
});
it("returns null for a slug this host has no artwork for", () => {
// The catalog is published elsewhere, so an unknown slug must fall back to
// the logos rather than render nothing.
expect(
getAutomationIcon({
...automationById("news-digest"),
icon: "not-a-real-slug",
} as unknown as Parameters<typeof getAutomationIcon>[0]),
).toBeNull();
});
});
@@ -49,8 +49,10 @@ describe("recommended automation rail", () => {
]);
expect(groups.proven.map((entry) => entry.id)).toEqual([
"github-repo-monitor",
"github-issue-to-pr",
"slack-channel-monitor",
"github-agents-md-maintainer",
"news-digest",
]);
});
@@ -60,8 +62,10 @@ describe("recommended automation rail", () => {
expect(groups.proven.map((entry) => entry.id)).toEqual([
"github-pr-reviewer",
"github-repo-monitor",
"github-issue-to-pr",
"slack-channel-monitor",
"github-agents-md-maintainer",
"news-digest",
]);
expect(conversationIds).toEqual([
"slack-standup-digest",
@@ -71,6 +75,9 @@ describe("recommended automation rail", () => {
]);
expect(conversationIds).not.toContain("upstream-fork-sync");
expect(conversationIds).not.toContain("incident-retrospective-drafter");
// Beta as of extensions 0.18.0, and set up by a host form rather than a
// conversation, so it is in neither group.
expect(conversationIds).not.toContain("github-repo-monitor");
expect(isConversationLaunchAutomation(slackStandup)).toBe(true);
expect(isConversationLaunchAutomation(upstreamFork)).toBe(false);
expect(SETUP_REGISTRY.findById(upstreamFork.id)).not.toBeNull();
@@ -79,8 +86,10 @@ describe("recommended automation rail", () => {
it("returns an empty rail when every recommended automation has been added", () => {
const groups = getRecommendedRailGroups([
{ name: "GitHub Code Review Agent" },
{ name: "GitHub repository monitor" },
{ name: "GitHub Issue to PR Agent" },
{ name: "Slack channel monitor" },
{ name: "AGENTS.md Maintainer" },
{ name: "Daily news digest" },
{ name: "Slack standup digest" },
{ name: "Linear issue triage assistant" },
{ name: "Jira issue to GitHub PR" },
+4 -4
View File
@@ -13,7 +13,7 @@
"@heroui/react": "2.8.10",
"@microlink/react-json-view": "1.31.25",
"@monaco-editor/react": "4.7.0",
"@openhands/extensions": "0.17.0",
"@openhands/extensions": "0.18.0",
"@openhands/typescript-client": "1.38.0",
"@react-router/node": "7.18.2",
"@react-router/serve": "7.18.2",
@@ -4164,9 +4164,9 @@
"license": "MIT"
},
"node_modules/@openhands/extensions": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/@openhands/extensions/-/extensions-0.17.0.tgz",
"integrity": "sha512-y3+OSHsMWN1sZVT1NERVdCnZVBgHg3F5E4tlYea6bb/nLzX750SuvJpfUJNitJqxN7ls+jMYVgBOgee6KoeLqA==",
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/@openhands/extensions/-/extensions-0.18.0.tgz",
"integrity": "sha512-0cbVueh8wk5OLq+kbEgI5k1sFUaNgahmxXwvOR5+4fpxwc2YnVivdyDzxjZ+SiagUmYi/EgADFVa/5X38e9BzQ==",
"license": "MIT",
"engines": {
"node": ">=18.20.0"
+1 -1
View File
@@ -23,7 +23,7 @@
"@heroui/react": "2.8.10",
"@microlink/react-json-view": "1.31.25",
"@monaco-editor/react": "4.7.0",
"@openhands/extensions": "0.17.0",
"@openhands/extensions": "0.18.0",
"@openhands/typescript-client": "1.38.0",
"@react-router/node": "7.18.2",
"@react-router/serve": "7.18.2",
@@ -1,4 +1,10 @@
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import {
createElement,
useCallback,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import type { RecommendedAutomation } from "@openhands/extensions/automations";
import {
@@ -8,7 +14,10 @@ import {
import { I18nKey } from "#/i18n/declaration";
import { McpLogoBadge } from "#/components/features/mcp-logo-badge";
import { getMarketplaceEntryById } from "#/utils/mcp-marketplace-utils";
import { getIntegrationIds } from "#/utils/automation-catalog";
import {
getAutomationIcon,
getIntegrationIds,
} from "#/utils/automation-catalog";
import {
flattenRecommendedRailGroups,
getRecommendedRailGroups,
@@ -42,23 +51,40 @@ function integrationEntries(
}
function RailIntegrationIcons({
automation,
entries,
testId,
}: {
automation: RecommendedAutomation;
entries: MarketplaceEntry[];
testId: string;
}) {
const visibleEntries = entries.slice(0, 4);
const isOverlap = visibleEntries.length > 1;
// A declared glyph replaces the logos rather than joining them: it is the
// whole identity of an entry that names one.
const Icon = getAutomationIcon(automation);
return (
<span
aria-hidden="true"
data-testid={testId}
data-layout={isOverlap ? "overlap" : undefined}
className={cn(RAIL_ICON_ROW_CLASS_NAME, isOverlap && "-space-x-2")}
data-layout={isOverlap && !Icon ? "overlap" : undefined}
className={cn(
RAIL_ICON_ROW_CLASS_NAME,
isOverlap && !Icon && "-space-x-2",
)}
>
{visibleEntries.length === 0 ? (
{Icon ? (
<McpLogoBadge
entry={null}
size="base"
fallback={createElement(Icon, {
className: "h-5 w-5",
strokeWidth: 2.25,
})}
/>
) : visibleEntries.length === 0 ? (
<McpLogoBadge entry={null} size="base" />
) : (
visibleEntries.map((entry) => (
@@ -157,6 +183,7 @@ export function RecommendedAutomationsRail({
)}
>
<RailIntegrationIcons
automation={automation}
entries={integrationEntries(automation)}
testId={`recommended-automation-rail-icon-${automation.id}`}
/>
@@ -1,3 +1,4 @@
import { createElement } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { I18nKey } from "#/i18n/declaration";
@@ -24,6 +25,7 @@ import {
} from "#/utils/mcp-marketplace-utils";
import { getFeaturedAutomationIds } from "#/manifests/automation-interface";
import {
getAutomationIcon,
getAutomationLaunchPrompt,
getIntegrationIds,
} from "#/utils/automation-catalog";
@@ -109,15 +111,6 @@ function automationMatchesQuery(
return haystack.includes(query);
}
/**
* Keep any automation with declared integrations visible, including when a
* catalog entry is missing. This makes catalog drift actionable instead of
* silently hiding the automation.
*/
function isAutomationAvailable(automation: RecommendedAutomation) {
return getIntegrationIds(automation).length > 0;
}
function buildRecommendedAutomationPills(
integrations: AutomationIntegration[],
installedServers: MCPServerConfig[],
@@ -177,6 +170,44 @@ function buildRecommendedAutomationPills(
return pills;
}
/**
* A card's badge: the declared glyph when the entry names one, and its
* integration logos otherwise. Both render into the same slot at the same
* size, so a card is laid out the same either way.
*/
function AutomationCardIcon({
automation,
integrations,
size,
testId,
}: {
automation: RecommendedAutomation;
integrations: AutomationIntegration[];
size: "base" | "md";
testId: string;
}) {
const Icon = getAutomationIcon(automation);
if (Icon) {
return (
<McpLogoBadge
entry={null}
size={size}
testId={testId}
fallback={createElement(Icon, {
className: "h-5 w-5",
strokeWidth: 2.25,
})}
/>
);
}
return (
<McpLogoStackBadge
entries={integrations.flatMap(({ entry }) => (entry ? [entry] : []))}
testId={testId}
/>
);
}
interface AutomationCardGridProps {
automations: RecommendedAutomation[];
installedServers: MCPServerConfig[];
@@ -217,10 +248,10 @@ function AutomationCardGrid({
)}
>
<div className="flex min-w-0 flex-1 items-start gap-3">
<McpLogoStackBadge
entries={integrations.flatMap(({ entry }) =>
entry ? [entry] : [],
)}
<AutomationCardIcon
automation={automation}
integrations={integrations}
size="md"
testId={`recommended-automation-icon-${automation.id}`}
/>
<div className="flex min-w-0 flex-1 flex-col gap-3">
@@ -268,13 +299,17 @@ export function RecommendedAutomationsSection({
}: RecommendedAutomationsSectionProps) {
const { t } = useTranslation("openhands");
const visibleAutomations = RECOMMENDED_AUTOMATIONS.filter((automation) => {
const integrationEntries = getIntegrationEntries(automation);
return (
isAutomationAvailable(automation) &&
automationMatchesQuery(automation, integrationEntries, query)
);
});
// Only the query narrows the grid. An automation that declares no
// integration needs nothing connected, and one naming an integration this
// host cannot resolve is shown with that gap on its pill, so neither is a
// reason to hide a card the catalog ships.
const visibleAutomations = RECOMMENDED_AUTOMATIONS.filter((automation) =>
automationMatchesQuery(
automation,
getIntegrationEntries(automation),
query,
),
);
if (visibleAutomations.length === 0) return null;
+24
View File
@@ -218,9 +218,33 @@ export function buildCreatePayload(
const trigger = buildTrigger(entry, values);
if (trigger) payload.trigger = trigger;
const template = buildTemplate(entry, values);
if (template) payload.template = template;
return payload;
}
/**
* The `template` provenance a prompt entry sends, or undefined for one that
* publishes no version.
*
* It is what lets the service recognise a second setup of the same entry as
* the automation it already holds. The bundle path derives the same block from
* the config the bundle declares; a prompt entry declares none, so the answer
* to "what was this created from" is the form as submitted.
*/
function buildTemplate(
entry: SetupEntry,
values: SetupFormValues,
): SetupRequestBody | undefined {
if (!entry.version) return undefined;
return {
id: entry.id,
version: entry.version,
config: { ...values },
};
}
/**
* The `trigger` object, read off the key and fields under `form.triggers`.
*
+19 -5
View File
@@ -26,8 +26,11 @@ const ENTRY_ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
const FIELD_NAME_PATTERN = /^[a-z][A-Za-z0-9]*$/;
/** Copy must never be able to inject markup into the host. */
const MARKUP_PATTERN = /<[A-Za-z/!]/;
/** Every `{{` must open a known namespace and close immediately. */
const BUNDLE_VERSION_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+$/;
/**
* A template version, declared as `version` by a prompt entry and as
* `setup.bundle.version` by a bundle one.
*/
const TEMPLATE_VERSION_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+$/;
// The characters a command of plain words and paths is made of. No shell
// metacharacters, which the service rejects anyway and a bundle has no reason
// to need; where those words may point is `isPlainCommand`'s to say.
@@ -35,6 +38,7 @@ const BUNDLE_COMMAND_PATTERN = /^[A-Za-z0-9 ._/-]+$/;
const BUNDLE_PATH_PATTERN = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
const BUNDLE_SOURCE_PATTERN =
/^(skills|automations)\/[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
/** Every `{{` must open a known namespace and close immediately. */
const UNKNOWN_PLACEHOLDER_PATTERN = new RegExp(
`\\{\\{(?!(?:${SETUP_PLACEHOLDER_NAMESPACES.join("|")})\\.[A-Za-z0-9_.]+\\}\\})`,
);
@@ -175,8 +179,11 @@ function checkRequires(check: SetupChecker, requires: unknown): void {
}
const { integrations, features } = requires;
if (!isRecord(integrations) || Object.keys(integrations).length === 0) {
check.fail("requires.integrations", "must be a non-empty object");
// Empty is meaningful rather than malformed: an automation that needs no
// credential connects to nothing, and the key stays required so that saying
// so is a deliberate statement instead of an omission.
if (!isRecord(integrations)) {
check.fail("requires.integrations", "must be an object");
} else {
Object.entries(integrations).forEach(([id, requirement]) => {
const path = `requires.integrations.${id}`;
@@ -400,7 +407,7 @@ function checkBundle(check: SetupChecker, bundle: unknown): void {
if (
typeof bundle.version !== "string" ||
!BUNDLE_VERSION_PATTERN.test(bundle.version)
!TEMPLATE_VERSION_PATTERN.test(bundle.version)
) {
check.fail("setup.bundle.version", "must be a semantic version");
}
@@ -587,6 +594,13 @@ export function validateSetupEntry(candidate: unknown): SetupValidationResult {
}
check.copy(candidate.name, "name");
check.copy(candidate.description, "description");
if (
candidate.version !== undefined &&
(typeof candidate.version !== "string" ||
!TEMPLATE_VERSION_PATTERN.test(candidate.version))
) {
check.fail("version", "must be a semantic version");
}
if (
candidate.skill !== undefined &&
(typeof candidate.skill !== "string" ||
+7
View File
@@ -152,6 +152,13 @@ export interface SetupEntry {
id: string;
name: string;
description: string;
/**
* Version of the template this entry publishes, sent with the create request
* as provenance. A bundle declares its own at `setup.bundle.version`; a
* prompt entry declares it here. Absent means the entry sends none, so the
* service records no template for what it creates.
*/
version?: string;
requires: SetupPrerequisites;
/** The skill that owns the launch command. Defaults to `id`. */
skill?: string;
+23
View File
@@ -3,6 +3,8 @@ import {
SKILLS_CATALOG,
type SkillCatalogEntry,
} from "@openhands/extensions/skills";
import type { LucideIcon } from "lucide-react";
import { MANIFEST_ICON_BY_SLUG } from "#/components/features/manifest/manifest-icons";
/**
* Reading a published automation catalog entry.
@@ -18,6 +20,27 @@ const SKILL_BY_NAME = new Map<string, SkillCatalogEntry>(
SKILLS_CATALOG.map((skill) => [skill.name, skill]),
);
/**
* The glyph a card shows in place of its integration logos, or null when it
* has none to show and the logos speak for it.
*
* An entry naming the service it talks to is recognised by that service's
* logo; one whose identity is the work itself declares a slug instead. The
* lookup is a guard as much as a mapping — the catalog is published elsewhere,
* so a slug this host has no artwork for falls back rather than rendering
* nothing.
*/
export function getAutomationIcon(
automation: RecommendedAutomation,
): LucideIcon | null {
const slug = automation.icon;
if (!slug) return null;
return (
(MANIFEST_ICON_BY_SLUG as Record<string, LucideIcon | undefined>)[slug] ??
null
);
}
/** Every integration the entry declares, in declaration order. */
export function getIntegrationIds(automation: RecommendedAutomation): string[] {
return Object.keys(automation.requires.integrations);
+4 -8
View File
@@ -5,7 +5,6 @@ import {
import { getFeaturedAutomationIds } from "#/manifests/automation-interface";
import { SETUP_REGISTRY } from "#/manifests/manifest-sources";
import type { Automation } from "#/types/automation";
import { getIntegrationIds } from "#/utils/automation-catalog";
export function getAutomationsByPopularity(
catalog: RecommendedAutomation[],
@@ -49,10 +48,6 @@ export function isCatalogAutomationAdded(
return catalogMatchKeys(entry).some((key) => installedKeys.has(key));
}
function isAvailableCatalogEntry(entry: RecommendedAutomation): boolean {
return getIntegrationIds(entry).length > 0;
}
function isProvenAutomation(entry: RecommendedAutomation): boolean {
return getFeaturedAutomationIds().includes(entry.id);
}
@@ -77,10 +72,11 @@ export interface RecommendedRailGroups {
export function getRecommendedRailGroups(
installed: readonly Pick<Automation, "name">[],
): RecommendedRailGroups {
// Every catalog entry is offerable: one that declares no integration needs
// nothing connected, and one that names an integration this host cannot
// resolve stays visible so the drift is actionable rather than hidden.
const available = getAutomationsByPopularity(AUTOMATION_CATALOG).filter(
(entry) =>
isAvailableCatalogEntry(entry) &&
!isCatalogAutomationAdded(entry, installed),
(entry) => !isCatalogAutomationAdded(entry, installed),
);
return {