fix(cli): show OpenCode Free in Hermes Agent picker (#3240)

Show OpenCode Free in the Hermes Agent model picker via alwaysIncludeProviders.

Integrated into release/v3.8.11. Thanks @wilsonicdev.
This commit is contained in:
Wilson
2026-06-05 13:12:52 -03:00
committed by GitHub
parent 5ec8fa222a
commit c222143071
4 changed files with 187 additions and 2 deletions

View File

@@ -23,6 +23,8 @@ const HERMES_ROLES: Role[] = [
{ id: "approval", label: "Approval", description: "Safety and approval decisions" },
];
const HERMES_AGENT_ZERO_CONFIG_PROVIDERS = ["opencode"];
export default function HermesAgentToolCard({
tool,
isExpanded = false,
@@ -517,6 +519,7 @@ export default function HermesAgentToolCard({
}}
showCombos={true}
activeProviders={activeProviders}
alwaysIncludeProviders={HERMES_AGENT_ZERO_CONFIG_PROVIDERS}
/>
</Card>
);

View File

@@ -37,6 +37,7 @@ type ModelSelectModalProps = {
addedModelValues?: string[];
multiSelect?: boolean;
showCombos?: boolean;
alwaysIncludeProviders?: string[] | null;
};
export default function ModelSelectModal({
@@ -51,6 +52,7 @@ export default function ModelSelectModal({
addedModelValues = [],
multiSelect = false,
showCombos = true,
alwaysIncludeProviders = [],
}: ModelSelectModalProps) {
const t = useTranslations("common");
const resolvedTitle = title ?? t("selectModel");
@@ -111,6 +113,11 @@ export default function ModelSelectModal({
() => ({ ...OAUTH_PROVIDERS, ...NOAUTH_PROVIDERS, ...APIKEY_PROVIDERS }),
[]
);
const alwaysIncludeProvidersKey = Array.isArray(alwaysIncludeProviders)
? alwaysIncludeProviders
.filter((providerId) => typeof providerId === "string" && providerId)
.join("\0")
: "";
// Group models by provider with priority order
const groupedModels = useMemo(() => {
@@ -118,10 +125,14 @@ export default function ModelSelectModal({
// Get all active provider IDs from connections
const activeConnectionIds = activeProviders.map((p) => p.provider);
const explicitProviderIds = alwaysIncludeProvidersKey
? alwaysIncludeProvidersKey.split("\0")
: [];
// Only show connected providers (including both standard and custom)
const providerIdsToShow = new Set([
...activeConnectionIds, // Only connected providers
...activeConnectionIds, // Connected providers
...explicitProviderIds, // Zero-config providers required by specific clients
]);
// Sort by PROVIDER_ORDER
@@ -262,7 +273,14 @@ export default function ModelSelectModal({
});
return groups;
}, [activeProviders, modelAliases, allProviders, providerNodes, customModels]);
}, [
activeProviders,
alwaysIncludeProvidersKey,
modelAliases,
allProviders,
providerNodes,
customModels,
]);
// Filter combos by search query
const filteredCombos = useMemo(() => {

View File

@@ -0,0 +1,76 @@
// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let lastModelSelectProps: any = null;
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock("@/shared/components", () => ({
Card: ({ children }: any) => <div>{children}</div>,
Button: ({ children, onClick, disabled, ...props }: any) => (
<button type="button" onClick={onClick} disabled={disabled} {...props}>
{children}
</button>
),
ModelSelectModal: (props: any) => {
lastModelSelectProps = props;
return <div data-testid="ModelSelectModal" />;
},
}));
const { default: HermesAgentToolCard } =
await import("@/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard");
const containers: HTMLElement[] = [];
function renderCard() {
const container = document.createElement("div");
document.body.appendChild(container);
containers.push(container);
const root = createRoot(container);
act(() => {
root.render(
<HermesAgentToolCard
tool={{ name: "Hermes Agent", description: "Hermes Agent" }}
isExpanded={false}
baseUrl="http://localhost:3000"
apiKeys={[{ id: "key-1" }]}
activeProviders={[]}
batchStatus={null}
/>
);
});
return container;
}
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
lastModelSelectProps = null;
});
afterEach(() => {
while (containers.length > 0) {
containers.pop()?.remove();
}
document.body.innerHTML = "";
});
describe("HermesAgentToolCard", () => {
it("keeps OpenCode Free available in the model picker even with no active connections", async () => {
renderCard();
await act(async () => {});
expect(lastModelSelectProps).toBeTruthy();
expect(lastModelSelectProps.activeProviders).toEqual([]);
expect(lastModelSelectProps.alwaysIncludeProviders).toContain("opencode");
});
});

View File

@@ -0,0 +1,88 @@
// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
const { default: ModelSelectModal } = await import("@/shared/components/ModelSelectModal");
const containers: HTMLElement[] = [];
async function renderModal(props: Partial<React.ComponentProps<typeof ModelSelectModal>> = {}) {
const container = document.createElement("div");
document.body.appendChild(container);
containers.push(container);
const root = createRoot(container);
await act(async () => {
root.render(
<ModelSelectModal
isOpen={true}
onClose={() => {}}
onSelect={() => {}}
showCombos={false}
activeProviders={[]}
{...props}
/>
);
});
await act(async () => {});
return container;
}
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
if (url === "/api/provider-nodes") {
return { ok: true, json: async () => ({ nodes: [] }) };
}
if (url === "/api/provider-models") {
return { ok: true, json: async () => ({ models: {} }) };
}
if (url === "/api/combos") {
return { ok: true, json: async () => ({ combos: [] }) };
}
return { ok: true, json: async () => ({}) };
})
);
});
afterEach(() => {
while (containers.length > 0) {
containers.pop()?.remove();
}
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
describe("ModelSelectModal zero-config providers", () => {
it("shows OpenCode Free models when explicitly included without an active connection", async () => {
const container = await renderModal({ alwaysIncludeProviders: ["opencode"] });
expect(container.textContent).toContain("OpenCode Free");
expect(container.textContent).toContain("Big Pickle");
});
it("does not show OpenCode Free by default without an active connection", async () => {
const container = await renderModal();
expect(container.textContent).not.toContain("OpenCode Free");
expect(container.textContent).not.toContain("Big Pickle");
});
it("treats null explicit provider lists as empty", async () => {
const container = await renderModal({ alwaysIncludeProviders: null });
expect(container.textContent).not.toContain("OpenCode Free");
expect(container.textContent).not.toContain("Big Pickle");
});
});