Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { screen } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderWithProviders } from "test-utils";
import type { ChatInputModelState } from "#/hooks/use-chat-input-model-state";
import type { ProfileWithPlan } from "#/hooks/use-profile-runtime-plans";

const useProfileRuntimePlansMock = vi.fn();
const switchAcpModelMutate = vi.fn();
const switchAndLog = vi.fn();

vi.mock("#/hooks/use-profile-runtime-plans", () => ({
useProfileRuntimePlans: () => useProfileRuntimePlansMock(),
}));

vi.mock("#/hooks/mutation/use-switch-acp-model", () => ({
useSwitchAcpModel: () => ({ mutate: switchAcpModelMutate }),
}));

vi.mock("#/hooks/mutation/use-switch-llm-profile-and-log", () => ({
useSwitchLlmProfileAndLog: () => ({ switchAndLog, isPending: false }),
}));

import { ChatInputModelMenuContent } from "#/components/features/chat/components/chat-input-model";

const acpModelState: ChatInputModelState = {
isAcpContext: true,
displayModel: "Claude Opus 4.7",
currentModelId: "claude-opus-4-7",
availableAcpModels: [{ id: "claude-opus-4-7", label: "Claude Opus 4.7" }],
showAcpPicker: true,
switchConversationId: "conv-1",
destinationPath: "/settings/agent",
destinationLabel: "Agent",
};

const disabledProfile: ProfileWithPlan = {
profile: {
name: "Cheap GPT daily driver",
model: "openai/gpt-4o",
base_url: null,
api_key_set: true,
},
plan: { action: "disabled", reason: "different-agent-kind" },
};

describe("ChatInputModelMenuContent disabled-profile section", () => {
beforeEach(() => {
useProfileRuntimePlansMock.mockReset();
switchAcpModelMutate.mockReset();
switchAndLog.mockReset();
});

it("shows incompatible profiles visible-but-disabled with a reason in an ACP conversation", () => {
useProfileRuntimePlansMock.mockReturnValue({
profiles: [disabledProfile],
activeProfileName: null,
isAcpContext: true,
});

renderWithProviders(
<ChatInputModelMenuContent model={acpModelState} onClose={() => {}} />,
);

const row = screen.getByTestId(
"chat-input-profile-option-Cheap GPT daily driver",
);
expect(row).toBeDisabled();
expect(row).toHaveTextContent("Cheap GPT daily driver");
// The reason is surfaced inline (not silently swallowed).
expect(
screen.getByTestId(
"chat-input-profile-reason-Cheap GPT daily driver",
),
).toBeInTheDocument();
});

it("clicking a disabled profile never triggers a model switch", () => {
useProfileRuntimePlansMock.mockReturnValue({
profiles: [disabledProfile],
activeProfileName: null,
isAcpContext: true,
});

renderWithProviders(
<ChatInputModelMenuContent model={acpModelState} onClose={() => {}} />,
);

// disabled <button> swallows the click; assert no switch was attempted.
screen
.getByTestId("chat-input-profile-option-Cheap GPT daily driver")
.click();
expect(switchAcpModelMutate).not.toHaveBeenCalled();
});

it("omits the profiles section when there are no incompatible profiles", () => {
useProfileRuntimePlansMock.mockReturnValue({
profiles: [],
activeProfileName: null,
isAcpContext: true,
});

renderWithProviders(
<ChatInputModelMenuContent model={acpModelState} onClose={() => {}} />,
);

expect(
screen.queryByTestId(
"chat-input-profile-option-Cheap GPT daily driver",
),
).not.toBeInTheDocument();
// The ACP model picker is unaffected.
expect(
screen.getByTestId("chat-input-acp-model-option-claude-opus-4-7"),
).toBeInTheDocument();
});

it("switches the ACP model live when a switch-live ACP profile is clicked", () => {
const switchLiveAcpProfile: ProfileWithPlan = {
profile: {
name: "Claude Sonnet daily",
kind: "acp",
model: "claude-sonnet-4-6",
base_url: null,
acp_server: "claude-code",
acp_model: "claude-sonnet-4-6",
api_key_set: true,
},
plan: { action: "switch-live", mutableFields: ["acp_model"] },
};
useProfileRuntimePlansMock.mockReturnValue({
profiles: [switchLiveAcpProfile],
activeProfileName: null,
isAcpContext: true,
});

renderWithProviders(
<ChatInputModelMenuContent model={acpModelState} onClose={() => {}} />,
);

const row = screen.getByTestId(
"chat-input-profile-option-Claude Sonnet daily",
);
expect(row).not.toBeDisabled();
row.click();
expect(switchAcpModelMutate).toHaveBeenCalledWith({
conversationId: "conv-1",
model: "claude-sonnet-4-6",
});
});

it("activates the whole profile (not a model swap) when selected on the home surface", () => {
// Home / new-conversation: no active ACP conversation, so the model state
// carries no switchConversationId. Selecting a profile must activate it
// (kind-aware) rather than only swapping acp_model.
const homeModelState: ChatInputModelState = {
...acpModelState,
switchConversationId: null,
};
const selectableProfile: ProfileWithPlan = {
profile: {
name: "Local Codex",
kind: "acp",
model: "gpt-5-codex",
base_url: null,
acp_server: "codex",
acp_model: "gpt-5-codex",
api_key_set: true,
},
plan: { action: "switch-live", mutableFields: [] },
};
useProfileRuntimePlansMock.mockReturnValue({
profiles: [selectableProfile],
activeProfileName: null,
isAcpContext: true,
inConversation: false,
});

renderWithProviders(
<ChatInputModelMenuContent model={homeModelState} onClose={() => {}} />,
);

screen.getByTestId("chat-input-profile-option-Local Codex").click();
expect(switchAndLog).toHaveBeenCalledWith(null, "Local Codex");
expect(switchAcpModelMutate).not.toHaveBeenCalled();
});
});
26 changes: 13 additions & 13 deletions __tests__/components/features/settings/settings-navigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,14 @@ vi.mock("#/components/shared/buttons/styled-tooltip", () => ({
),
}));

const llmItem = OSS_NAV_ITEMS.find((item) => item.to === "/settings/llm")!;
const verificationItem = OSS_NAV_ITEMS.find((item) => item.to === "/settings/verification")!;
const condenserItem = OSS_NAV_ITEMS.find(
(item) => item.to === "/settings/condenser",
)!;

const baseItems: SettingsNavRenderedItem[] = [
{ type: "header", text: "SETTINGS$TITLE" as never },
{ type: "item", item: llmItem },
{ type: "item", item: verificationItem },
{ type: "divider" },
{ type: "item", item: condenserItem },
];
Expand Down Expand Up @@ -67,7 +67,7 @@ describe("SettingsNavigation", () => {

expect(screen.getByTestId("settings-navbar")).toBeInTheDocument();
expect(screen.getAllByText("SETTINGS$TITLE").length).toBeGreaterThan(0);
expect(screen.getAllByText("SETTINGS$NAV_LLM").length).toBeGreaterThan(0);
expect(screen.getAllByText("SETTINGS$NAV_VERIFICATION").length).toBeGreaterThan(0);
expect(
screen.getAllByText("SETTINGS$NAV_CONDENSER").length,
).toBeGreaterThan(0);
Expand Down Expand Up @@ -101,13 +101,13 @@ describe("SettingsNavigation", () => {
);

const mobileNav = screen.getByTestId("settings-navbar");
await userEvent.click(within(mobileNav).getByText("SETTINGS$NAV_LLM"));
await userEvent.click(within(mobileNav).getByText("SETTINGS$NAV_VERIFICATION"));

expect(onCloseMobileMenu).toHaveBeenCalledTimes(1);
});

it("renders disabled-by-ACP items as disabled in the desktop sidebar", () => {
// Regression guard: when ACP is active, the LLM and Condenser items
// Regression guard: when ACP is active, the Verification and Condenser items
// come through with ``disabled: true`` from ``useSettingsNavItems``;
// both the mobile drawer (via SettingsNavLink) and the desktop
// sidebar (via SidebarNavLink) must propagate that. Earlier the
Expand All @@ -119,7 +119,7 @@ describe("SettingsNavigation", () => {
navigationItems={[
{
type: "item",
item: llmItem,
item: verificationItem,
disabled: true,
disabledAgentName: "Claude Code",
},
Expand All @@ -137,13 +137,13 @@ describe("SettingsNavigation", () => {

// SidebarNavLink renders disabled items as a non-link span with
// ``aria-disabled="true"`` and ``opacity-50`` styling.
const llmLink = within(desktopNav).getByTestId(
"sidebar-settings-/settings/llm",
const verificationLink = within(desktopNav).getByTestId(
"sidebar-settings-/settings/verification",
);
const condenserLink = within(desktopNav).getByTestId(
"sidebar-settings-/settings/condenser",
);
expect(llmLink).toHaveAttribute("aria-disabled", "true");
expect(verificationLink).toHaveAttribute("aria-disabled", "true");
expect(condenserLink).toHaveAttribute("aria-disabled", "true");
});

Expand All @@ -152,14 +152,14 @@ describe("SettingsNavigation", () => {
<SettingsNavigation
isMobileMenuOpen={false}
onCloseMobileMenu={vi.fn()}
navigationItems={[{ type: "item", item: llmItem }]}
navigationItems={[{ type: "item", item: verificationItem }]}
/>,
);
const desktopNav = screen.getByTestId("settings-navbar-desktop");
const llmLink = within(desktopNav).getByTestId(
"sidebar-settings-/settings/llm",
const verificationLink = within(desktopNav).getByTestId(
"sidebar-settings-/settings/verification",
);
expect(llmLink).not.toHaveAttribute("aria-disabled", "true");
expect(verificationLink).not.toHaveAttribute("aria-disabled", "true");
});

it("wraps disabled-by-ACP desktop items in the explanatory tooltip", () => {
Expand Down
30 changes: 15 additions & 15 deletions __tests__/hooks/use-settings-nav-items.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,20 +60,20 @@ describe("useSettingsNavItems", () => {
});
});

it("returns the LLM settings item unchanged on local backends", () => {
it("returns a non-special settings item unchanged on local backends", () => {
useConfigMock.mockReturnValue({ data: createConfig() });

const { result } = renderHook(() => useSettingsNavItems());
const llmItem = result.current.find(
(item) => item.type === "item" && item.item.to === "/settings/llm",
const condenserItem = result.current.find(
(item) => item.type === "item" && item.item.to === "/settings/condenser",
);

const baseLlm = OSS_NAV_ITEMS.find(
(item) => item.to === "/settings/llm",
const baseCondenser = OSS_NAV_ITEMS.find(
(item) => item.to === "/settings/condenser",
)!;
expect(llmItem).toEqual({
expect(condenserItem).toEqual({
type: "item",
item: baseLlm,
item: baseCondenser,
});
});

Expand Down Expand Up @@ -120,7 +120,7 @@ describe("useSettingsNavItems", () => {
expect(paths).not.toContain("/settings/mcp");
});

it("disables LLM + Condenser when the active agent_kind is acp", () => {
it("disables Condenser + Verification when the active agent_kind is acp", () => {
useConfigMock.mockReturnValue({ data: createConfig() });
useSettingsMock.mockReturnValue({ data: acpClaudeCodeSettings });

Expand All @@ -134,17 +134,17 @@ describe("useSettingsNavItems", () => {
),
);

const llm = byPath.get("/settings/llm");
expect(llm?.type).toBe("item");
if (llm?.type === "item") {
expect(llm.disabled).toBe(true);
expect(llm.disabledAgentName).toBe("Claude Code");
}

const condenser = byPath.get("/settings/condenser");
expect(condenser?.type).toBe("item");
if (condenser?.type === "item") {
expect(condenser.disabled).toBe(true);
expect(condenser.disabledAgentName).toBe("Claude Code");
}

const verification = byPath.get("/settings/verification");
expect(verification?.type).toBe("item");
if (verification?.type === "item") {
expect(verification.disabled).toBe(true);
}

// Items without `disabledByAcp` stay enabled.
Expand Down
9 changes: 8 additions & 1 deletion __tests__/package-library.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,16 @@ describe("package library metadata", () => {
// runs the prepare script without devDependencies. All packages should be
// referenced from a registry; only @openhands/extensions is allowed as a git
// dep until it is published to npm.
//
// @openhands/typescript-client is a TEMPORARY git pin to the AgentProfile
// client commit (typescript-client#195) so CI builds against the new profile
// types before that PR publishes. Revert to a semver range once #195 ships.
it("does not use git dependencies (except @openhands/extensions)", () => {
const GIT_DEP_PATTERN = /^(git[+:]|github:|bitbucket:|gitlab:|[a-zA-Z0-9_-]+\/)/;
const ALLOWED_GIT_DEPS = new Set(["@openhands/extensions"]);
const ALLOWED_GIT_DEPS = new Set([
"@openhands/extensions",
"@openhands/typescript-client",
]);

const allDeps = {
...packageJson.dependencies,
Expand Down
Loading