diff --git a/src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx index dcdee025c2..f72b609b69 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx @@ -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} /> ); diff --git a/src/shared/components/ModelSelectModal.tsx b/src/shared/components/ModelSelectModal.tsx index 8c30a2dfa4..9736ed3866 100644 --- a/src/shared/components/ModelSelectModal.tsx +++ b/src/shared/components/ModelSelectModal.tsx @@ -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(() => { diff --git a/tests/unit/ui/hermes-agent-opencode-free.test.tsx b/tests/unit/ui/hermes-agent-opencode-free.test.tsx new file mode 100644 index 0000000000..57988fda70 --- /dev/null +++ b/tests/unit/ui/hermes-agent-opencode-free.test.tsx @@ -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) =>
{children}
, + Button: ({ children, onClick, disabled, ...props }: any) => ( + + ), + ModelSelectModal: (props: any) => { + lastModelSelectProps = props; + return
; + }, +})); + +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( + + ); + }); + + 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"); + }); +}); diff --git a/tests/unit/ui/model-select-modal-zero-config.test.tsx b/tests/unit/ui/model-select-modal-zero-config.test.tsx new file mode 100644 index 0000000000..422207a34a --- /dev/null +++ b/tests/unit/ui/model-select-modal-zero-config.test.tsx @@ -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> = {}) { + const container = document.createElement("div"); + document.body.appendChild(container); + containers.push(container); + + const root = createRoot(container); + await act(async () => { + root.render( + {}} + 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"); + }); +});