Files
OpenHands/__tests__/api/cloud/settings-service.test.ts
T
b9d78d3116 Simplify backend registry selection (#1046)
* Add partial stack modes to agent-canvas

Co-authored-by: openhands <openhands@all-hands.dev>

* Simplify backend registry selection

Co-authored-by: openhands <openhands@all-hands.dev>

* Fix backend selection CI regressions

* Seed local proxy backend in cloud tests

* Stabilize onboarding snapshot navigation

* Stabilize ingress tests on Windows

* Remove local backend fallback for cloud calls

* Fix frontend-only backend proxy target

* Test backend-only launch without build

* Add pending workflow and status updates

* Use active LLM profile for setup banner

* Show backend connection errors before saving

* Validate backend keys in health checks

* Update backend selector health test mock

* Fix frontend-only workspace path

* Preserve OpenHands proxy base URL

* Address backend review comments

* Preserve profile config when switching models

* Fail fast on profile export errors

* Throw AgentServerUnavailableError when backend registry is empty

When no backend is configured (empty registry / NO_BACKEND sentinel),
loadAgentServerInfo() was returning null without throwing, causing
OptionService.getConfig() to succeed silently. root.tsx then rendered
the home page instead of the MissingAgentServerScreen with the manage
backends modal.

Now loadAgentServerInfo() checks for the NO_BACKEND sentinel when
getEffectiveLocalBackend() returns null and throws
AgentServerUnavailableError, which root.tsx already handles by showing
the manage backends modal. The cloud-backend path (also null from
getEffectiveLocalBackend) is preserved — it still returns null.

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: Remove PR-only artifacts

---------

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: allhands-bot <allhands-bot@users.noreply.github.com>
2026-06-03 15:26:51 +00:00

208 lines
7.3 KiB
TypeScript

import axios from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import {
fetchCloudSettings,
saveCloudSettings,
} from "#/api/cloud/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
vi.mock("axios");
const cloudBackend: Backend = {
id: "prod",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-token",
kind: "cloud",
};
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
vi.mocked(axios.request).mockReset();
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
describe("cloud settings", () => {
it("fetchCloudSettings preserves provider_tokens_set so the repo chain can fire", async () => {
vi.mocked(axios.request).mockResolvedValue({
data: {
llm_model: "anthropic/claude-3-5-sonnet",
llm_base_url: "https://api.anthropic.com",
llm_api_key_set: true,
agent: "CodeActAgent",
confirmation_mode: true,
security_analyzer: "llm",
max_iterations: 30,
provider_tokens_set: { github: "***" },
},
});
const result = await fetchCloudSettings();
const [config] = vi.mocked(axios.request).mock.calls[0]!;
expect(config).toMatchObject({
url: `${cloudBackend.host}/api/v1/settings`,
method: "GET",
headers: { Authorization: "Bearer bearer-token" },
});
// provider_tokens_set must round-trip — it's what drives
// useUserProviders → useAppInstallations → useGitRepositories.
expect(result.provider_tokens_set).toEqual({ github: "***" });
// Top-level cloud fields are preserved as-is.
expect(result.llm_model).toBe("anthropic/claude-3-5-sonnet");
expect(result.llm_api_key_set).toBe(true);
expect(result.agent).toBe("CodeActAgent");
// Nested shape derived for the local-mode settings page.
expect(result.agent_settings?.agent).toBe("CodeActAgent");
expect(result.agent_settings?.llm).toEqual({
model: "anthropic/claude-3-5-sonnet",
base_url: "https://api.anthropic.com",
});
expect(result.conversation_settings?.confirmation_mode).toBe(true);
expect(result.conversation_settings?.security_analyzer).toBe("llm");
expect(result.conversation_settings?.max_iterations).toBe(30);
});
it("saveCloudSettings forwards diffs verbatim and omits the legacy keys the cloud rejects", async () => {
vi.mocked(axios.request).mockResolvedValue({ data: {} });
const agentDiff = {
llm: { model: "openai/gpt-4o", base_url: "https://api.openai.com" },
agent: "CodeActAgent",
};
const conversationDiff = { max_iterations: 50 };
await saveCloudSettings({
agent_settings_diff: agentDiff,
conversation_settings_diff: conversationDiff,
});
const [config] = vi.mocked(axios.request).mock.calls[0]!;
expect(config).toMatchObject({
url: `${cloudBackend.host}/api/v1/settings`,
method: "POST",
headers: { Authorization: "Bearer bearer-token" },
});
const requestBody = (config as { data: Record<string, unknown> }).data;
expect(requestBody).toEqual({
agent_settings_diff: agentDiff,
conversation_settings_diff: conversationDiff,
});
expect(requestBody).not.toHaveProperty("agent_settings");
expect(requestBody).not.toHaveProperty("conversation_settings");
});
it("SettingsService.saveSettings forwards disabled_skills to cloud when active backend is cloud", async () => {
// Arrange: cloud backend already active via beforeEach; mock cloud response.
vi.mocked(axios.request).mockResolvedValue({ data: {} });
// Act: save a skills-only update — previously this short-circuited and
// sent nothing at all, leaving the toggle un-persisted.
await SettingsService.saveSettings({
disabled_skills: ["SSH Microagent"],
});
// Assert: a single POST /api/v1/settings reached the wire with
// disabled_skills as a top-level field.
expect(vi.mocked(axios.request)).toHaveBeenCalledTimes(1);
const [config] = vi.mocked(axios.request).mock.calls[0]!;
expect(config).toMatchObject({
url: `${cloudBackend.host}/api/v1/settings`,
method: "POST",
headers: { Authorization: "Bearer bearer-token" },
});
expect((config as { data: Record<string, unknown> }).data).toEqual({
disabled_skills: ["SSH Microagent"],
});
});
it("saveCloudSettings omits an empty conversation_settings_diff (LLM-only save)", async () => {
vi.mocked(axios.request).mockResolvedValue({ data: {} });
await saveCloudSettings({
agent_settings_diff: {
llm: { model: "anthropic/claude-sonnet-4-20250514" },
},
conversation_settings_diff: {},
});
const [config] = vi.mocked(axios.request).mock.calls[0]!;
const requestBody = (config as { data: Record<string, unknown> }).data;
expect(requestBody).toEqual({
agent_settings_diff: {
llm: { model: "anthropic/claude-sonnet-4-20250514" },
},
});
});
});
describe("saveCloudSettings drops agent_context: null (agent-canvas#981)", () => {
it("strips a null agent_context while preserving sibling agent settings", async () => {
// Arrange: the cloud rejects agent_context: null against OpenHandsAgentSettings.
vi.mocked(axios.request).mockResolvedValue({ data: {} });
// Act
await saveCloudSettings({
agent_settings_diff: {
llm: { model: "anthropic/claude-sonnet-4-20250514" },
agent_context: null,
},
});
// Assert: agent_context never reaches the wire, but the real llm change does.
const [config] = vi.mocked(axios.request).mock.calls[0]!;
const requestBody = (config as { data: Record<string, unknown> }).data;
expect(requestBody).toEqual({
agent_settings_diff: {
llm: { model: "anthropic/claude-sonnet-4-20250514" },
},
});
});
it("preserves a null mcp_config so clearing MCP config still round-trips", async () => {
// Arrange: mcp_config: null is an intentional "clear" signal, not an error.
vi.mocked(axios.request).mockResolvedValue({ data: {} });
// Act
await saveCloudSettings({
agent_settings_diff: { mcp_config: null },
});
// Assert: the null mcp_config must survive (don't over-strip nulls).
const [config] = vi.mocked(axios.request).mock.calls[0]!;
const requestBody = (config as { data: Record<string, unknown> }).data;
expect(requestBody).toEqual({ agent_settings_diff: { mcp_config: null } });
});
it("omits agent_settings_diff when agent_context: null is its only key", async () => {
// Arrange
vi.mocked(axios.request).mockResolvedValue({ data: {} });
// Act
await saveCloudSettings({
agent_settings_diff: { agent_context: null },
});
// Assert: nothing is left to send, so no agent_settings_diff goes on the wire.
const [config] = vi.mocked(axios.request).mock.calls[0]!;
const requestBody = (config as { data: Record<string, unknown> }).data;
expect(requestBody).toEqual({});
});
});