import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router";
import userEvent from "@testing-library/user-event";
import AgentSettingsScreen from "#/routes/agent-settings";
import SettingsService from "#/api/settings-service/settings-service.api";
import OptionService from "#/api/option-service/option-service.api";
import type { ACPProviderConfig } from "#/api/option-service/option.types";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
});
afterEach(() => {
vi.restoreAllMocks();
});
const renderAgentSettings = () =>
render(
,
{
wrapper: ({ children }) => (
{children}
),
},
);
// Mirrors the SDK registry-backed web-client config. Frontend tests cannot
// import the Python SDK source directly, but keeping this as a typed fixture
// catches frontend drift in the API shape.
const ACP_PROVIDERS_FIXTURE: ACPProviderConfig[] = [
{
key: "claude-code",
display_name: "Claude Code",
default_command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"],
},
{
key: "codex",
display_name: "Codex",
default_command: ["npx", "-y", "@zed-industries/codex-acp"],
},
{
key: "gemini-cli",
display_name: "Gemini CLI",
default_command: ["npx", "-y", "@google/gemini-cli", "--acp"],
},
];
const baseConfig = {
app_mode: "oss" as const,
posthog_client_key: null,
feature_flags: {
enable_billing: false,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
hide_users_page: false,
hide_billing_page: false,
hide_integrations_page: false,
enable_acp: true,
enable_onboarding: false,
},
providers_configured: [],
maintenance_start_time: null,
auth_url: null,
recaptcha_site_key: null,
faulty_models: [],
error_message: null,
updated_at: "2026-01-01T00:00:00Z",
github_app_slug: null,
acp_providers: ACP_PROVIDERS_FIXTURE,
};
describe("AgentSettingsScreen — minimal generic ACP UX", () => {
it("hydrates the form from saved ACP settings", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue(baseConfig);
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
agent_settings: {
agent_kind: "acp",
acp_server: "custom",
acp_command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"],
acp_args: [],
acp_model: "claude-opus-4",
},
});
renderAgentSettings();
await waitFor(() => {
expect(
(screen.getByTestId("agent-command-input") as HTMLTextAreaElement)
.value,
).toBe("npx -y @agentclientprotocol/claude-agent-acp");
});
expect(
(screen.getByTestId("agent-model-input") as HTMLInputElement).value,
).toBe("claude-opus-4");
});
it("clears acp_* fields when switching back to OpenHands", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue(baseConfig);
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
agent_settings: {
agent_kind: "acp",
acp_server: "custom",
acp_command: ["claude-agent-acp"],
acp_args: [],
acp_model: "claude-opus-4",
},
});
const saveSpy = vi
.spyOn(SettingsService, "saveSettings")
.mockResolvedValue(true);
renderAgentSettings();
await waitFor(() => {
expect(
(screen.getByTestId("agent-command-input") as HTMLTextAreaElement)
.value,
).toBe("claude-agent-acp");
});
const dropdown = screen.getByTestId("agent-type-selector");
await userEvent.click(dropdown);
const ohOption = await screen.findByRole("option", {
name: "SETTINGS$AGENT_TYPE_WREN",
});
await userEvent.click(ohOption);
await userEvent.click(screen.getByTestId("agent-save-button"));
await waitFor(() => {
expect(saveSpy).toHaveBeenCalledTimes(1);
});
// OH-switch payload sends only ``agent_kind`` — the backend
// ``Settings.update()`` starts a fresh base when the kind flips and
// discards any incoming ``acp_*`` fields anyway.
expect(saveSpy.mock.calls[0][0]).toEqual({
agent_settings_diff: { agent_kind: "wren" },
});
});
it("saves built-in presets via acp_server and lets the SDK resolve defaults", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue(baseConfig);
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
agent_settings: {
agent_kind: "acp",
acp_server: "custom",
acp_command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"],
acp_args: [],
acp_model: null,
},
});
const saveSpy = vi
.spyOn(SettingsService, "saveSettings")
.mockResolvedValue(true);
renderAgentSettings();
await waitFor(() => {
expect(
(screen.getByTestId("agent-command-input") as HTMLTextAreaElement)
.value,
).toBe("npx -y @agentclientprotocol/claude-agent-acp");
});
const commandInput = screen.getByTestId("agent-command-input");
await userEvent.clear(commandInput);
await userEvent.type(
commandInput,
"npx -y{enter}@agentclientprotocol/claude-agent-acp",
);
await userEvent.type(screen.getByTestId("agent-model-input"), "opus");
await userEvent.click(screen.getByTestId("agent-save-button"));
await waitFor(() => {
expect(saveSpy).toHaveBeenCalledTimes(1);
});
expect(saveSpy.mock.calls[0][0]).toMatchObject({
agent_settings_diff: {
agent_kind: "acp",
acp_server: "claude-code",
acp_command: [],
acp_model: "opus",
},
});
});
it("saves as custom when acp_server is built-in but command differs from default", async () => {
// If the persisted command doesn't match the provider default, the UI must
// treat it as "custom" and save it that way — even if acp_server says
// "claude-code". This prevents silent round-trip corruption where the UI
// shows a built-in preset but saving overwrites the custom command with [].
vi.spyOn(OptionService, "getConfig").mockResolvedValue(baseConfig);
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
agent_settings: {
agent_kind: "acp",
acp_server: "claude-code",
acp_command: ["npx", "-y", "@custom/my-agent"],
acp_args: [],
acp_model: null,
},
});
const saveSpy = vi
.spyOn(SettingsService, "saveSettings")
.mockResolvedValue(true);
renderAgentSettings();
await waitFor(() => {
expect(
(screen.getByTestId("agent-command-input") as HTMLTextAreaElement)
.value,
).toBe("npx -y @custom/my-agent");
});
// Touch the model field to dirty the form (so save is enabled).
await userEvent.type(screen.getByTestId("agent-model-input"), "x");
await userEvent.clear(screen.getByTestId("agent-model-input"));
await userEvent.click(screen.getByTestId("agent-save-button"));
await waitFor(() => {
expect(saveSpy).toHaveBeenCalledTimes(1);
});
// Since the command doesn't match the claude-code default, it must save as
// custom with the full command — NOT as acp_server="claude-code" + acp_command=[].
expect(saveSpy.mock.calls[0][0]).toMatchObject({
agent_settings_diff: {
acp_server: "custom",
acp_command: ["npx", "-y", "@custom/my-agent"],
},
});
});
it("keeps a useful command placeholder if no provider metadata is available", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
...baseConfig,
acp_providers: [],
});
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
agent_settings: {
agent_kind: "acp",
acp_server: "custom",
acp_command: [],
acp_args: [],
acp_model: null,
},
});
renderAgentSettings();
expect(await screen.findByTestId("agent-command-input")).toHaveAttribute(
"placeholder",
"npx -y ",
);
});
const openHandsSettingsWithConcurrency = (toolConcurrencyLimit: number) => ({
...MOCK_DEFAULT_USER_SETTINGS,
agent_settings: {
...MOCK_DEFAULT_USER_SETTINGS.agent_settings,
agent_kind: "wren",
enable_sub_agents: false,
tool_concurrency_limit: toolConcurrencyLimit,
},
});
it("saves a coerced tool_concurrency_limit on the OpenHands path", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue(baseConfig);
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
openHandsSettingsWithConcurrency(1),
);
const saveSpy = vi
.spyOn(SettingsService, "saveSettings")
.mockResolvedValue(true);
renderAgentSettings();
const input = await screen.findByTestId(
"sdk-settings-tool_concurrency_limit",
);
await userEvent.clear(input);
await userEvent.type(input, "4");
await userEvent.click(screen.getByTestId("agent-save-button"));
await waitFor(() => {
expect(saveSpy).toHaveBeenCalledTimes(1);
});
const { agent_settings_diff: diff } = saveSpy.mock.calls[0][0] as {
agent_settings_diff: Record;
};
// Coerced to a number (not the raw input string) via the shared coercion.
expect(diff.tool_concurrency_limit).toBe(4);
});
it("rejects a tool_concurrency_limit above the cap of 10", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue(baseConfig);
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
openHandsSettingsWithConcurrency(1),
);
const saveSpy = vi
.spyOn(SettingsService, "saveSettings")
.mockResolvedValue(true);
renderAgentSettings();
const input = await screen.findByTestId(
"sdk-settings-tool_concurrency_limit",
);
await userEvent.clear(input);
await userEvent.type(input, "15");
await userEvent.click(screen.getByTestId("agent-save-button"));
// coerceFieldValue throws ("must be at most 10") → error toast, no save.
expect(saveSpy).not.toHaveBeenCalled();
});
});