Merge branch 'main' into reuse-running-sandboxes

This commit is contained in:
Tim O'Farrell
2025-12-12 10:59:20 -07:00
committed by GitHub
53 changed files with 1226 additions and 905 deletions

View File

@@ -16,7 +16,7 @@ describe("SettingsForm", () => {
Component: () => (
<SettingsForm
settings={DEFAULT_SETTINGS}
models={[DEFAULT_SETTINGS.LLM_MODEL]}
models={[DEFAULT_SETTINGS.llm_model]}
onClose={onCloseMock}
/>
),
@@ -33,7 +33,7 @@ describe("SettingsForm", () => {
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
llm_model: DEFAULT_SETTINGS.llm_model,
}),
);
});

View File

@@ -0,0 +1,53 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav";
import OptionService from "#/api/option-service/option-service.api";
import { useSettingsNavItems } from "#/hooks/use-settings-nav-items";
const queryClient = new QueryClient();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const mockConfig = (appMode: "saas" | "oss", hideLlmSettings = false) => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
APP_MODE: appMode,
FEATURE_FLAGS: { HIDE_LLM_SETTINGS: hideLlmSettings },
} as Awaited<ReturnType<typeof OptionService.getConfig>>);
};
describe("useSettingsNavItems", () => {
beforeEach(() => {
queryClient.clear();
});
it("should return SAAS_NAV_ITEMS when APP_MODE is 'saas'", async () => {
mockConfig("saas");
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
await waitFor(() => {
expect(result.current).toEqual(SAAS_NAV_ITEMS);
});
});
it("should return OSS_NAV_ITEMS when APP_MODE is 'oss'", async () => {
mockConfig("oss");
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
await waitFor(() => {
expect(result.current).toEqual(OSS_NAV_ITEMS);
});
});
it("should filter out '/settings' item when HIDE_LLM_SETTINGS feature flag is enabled", async () => {
mockConfig("saas", true);
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
await waitFor(() => {
expect(
result.current.find((item) => item.to === "/settings"),
).toBeUndefined();
});
});
});

View File

@@ -12,20 +12,20 @@ describe("hasAdvancedSettingsSet", () => {
});
describe("should be true if", () => {
test("LLM_BASE_URL is set", () => {
test("llm_base_url is set", () => {
expect(
hasAdvancedSettingsSet({
...DEFAULT_SETTINGS,
LLM_BASE_URL: "test",
llm_base_url: "test",
}),
).toBe(true);
});
test("AGENT is not default value", () => {
test("agent is not default value", () => {
expect(
hasAdvancedSettingsSet({
...DEFAULT_SETTINGS,
AGENT: "test",
agent: "test",
}),
).toBe(true);
});

View File

@@ -13,7 +13,7 @@ describe("Model name case preservation", () => {
const settings = extractSettings(formData);
// Test that model names maintain their original casing
expect(settings.LLM_MODEL).toBe("SambaNova/Meta-Llama-3.1-8B-Instruct");
expect(settings.llm_model).toBe("SambaNova/Meta-Llama-3.1-8B-Instruct");
});
it("should preserve openai model case", () => {
@@ -24,7 +24,7 @@ describe("Model name case preservation", () => {
formData.set("language", "en");
const settings = extractSettings(formData);
expect(settings.LLM_MODEL).toBe("openai/gpt-4o");
expect(settings.llm_model).toBe("openai/gpt-4o");
});
it("should preserve anthropic model case", () => {
@@ -35,7 +35,7 @@ describe("Model name case preservation", () => {
formData.set("language", "en");
const settings = extractSettings(formData);
expect(settings.LLM_MODEL).toBe("anthropic/claude-sonnet-4-20250514");
expect(settings.llm_model).toBe("anthropic/claude-sonnet-4-20250514");
});
it("should not automatically lowercase model names", () => {
@@ -48,7 +48,7 @@ describe("Model name case preservation", () => {
const settings = extractSettings(formData);
// Test that camelCase and PascalCase are preserved
expect(settings.LLM_MODEL).not.toBe("sambanova/meta-llama-3.1-8b-instruct");
expect(settings.LLM_MODEL).toBe("SambaNova/Meta-Llama-3.1-8B-Instruct");
expect(settings.llm_model).not.toBe("sambanova/meta-llama-3.1-8b-instruct");
expect(settings.llm_model).toBe("SambaNova/Meta-Llama-3.1-8B-Instruct");
});
});

View File

@@ -13,7 +13,7 @@
"@monaco-editor/react": "^4.7.0-rc.0",
"@react-router/node": "^7.10.1",
"@react-router/serve": "^7.10.1",
"@tailwindcss/vite": "^4.1.17",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.12",
"@uidotdev/usehooks": "^2.4.1",
"@xterm/addon-fit": "^0.10.0",
@@ -28,13 +28,13 @@
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.32",
"lucide-react": "^0.560.0",
"lucide-react": "^0.561.0",
"monaco-editor": "^0.55.1",
"posthog-js": "^1.302.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"posthog-js": "^1.306.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-hot-toast": "^2.6.0",
"react-i18next": "^16.4.0",
"react-i18next": "^16.5.0",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-router": "^7.10.1",
@@ -58,7 +58,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.0.0",
"@types/node": "^25.0.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
@@ -5431,10 +5431,9 @@
}
},
"node_modules/@tailwindcss/node": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz",
"integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==",
"license": "MIT",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
"integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"enhanced-resolve": "^5.18.3",
@@ -5442,40 +5441,38 @@
"lightningcss": "1.30.2",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.1.17"
"tailwindcss": "4.1.18"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz",
"integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==",
"license": "MIT",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
"integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.1.17",
"@tailwindcss/oxide-darwin-arm64": "4.1.17",
"@tailwindcss/oxide-darwin-x64": "4.1.17",
"@tailwindcss/oxide-freebsd-x64": "4.1.17",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.17",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.17",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.17",
"@tailwindcss/oxide-linux-x64-musl": "4.1.17",
"@tailwindcss/oxide-wasm32-wasi": "4.1.17",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.17",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.17"
"@tailwindcss/oxide-android-arm64": "4.1.18",
"@tailwindcss/oxide-darwin-arm64": "4.1.18",
"@tailwindcss/oxide-darwin-x64": "4.1.18",
"@tailwindcss/oxide-freebsd-x64": "4.1.18",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
"@tailwindcss/oxide-linux-x64-musl": "4.1.18",
"@tailwindcss/oxide-wasm32-wasi": "4.1.18",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz",
"integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
"integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
@@ -5485,13 +5482,12 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz",
"integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
"integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -5501,13 +5497,12 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz",
"integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
"integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -5517,13 +5512,12 @@
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz",
"integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
"integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
@@ -5533,13 +5527,12 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz",
"integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
"integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -5549,13 +5542,12 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz",
"integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
"integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -5565,13 +5557,12 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz",
"integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
"integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -5581,13 +5572,12 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz",
"integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
"integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -5597,13 +5587,12 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz",
"integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
"integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -5613,9 +5602,9 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz",
"integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
"integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@@ -5627,13 +5616,12 @@
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.6.0",
"@emnapi/runtime": "^1.6.0",
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@emnapi/wasi-threads": "^1.1.0",
"@napi-rs/wasm-runtime": "^1.0.7",
"@napi-rs/wasm-runtime": "^1.1.0",
"@tybys/wasm-util": "^0.10.1",
"tslib": "^2.4.0"
},
@@ -5642,7 +5630,7 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.6.0",
"version": "1.7.1",
"inBundle": true,
"license": "MIT",
"optional": true,
@@ -5652,7 +5640,7 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.6.0",
"version": "1.7.1",
"inBundle": true,
"license": "MIT",
"optional": true,
@@ -5670,13 +5658,13 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.0.7",
"version": "1.1.0",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.5.0",
"@emnapi/runtime": "^1.5.0",
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
}
},
@@ -5696,13 +5684,12 @@
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz",
"integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
"integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -5712,13 +5699,12 @@
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz",
"integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
"integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -5741,14 +5727,13 @@
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.17.tgz",
"integrity": "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==",
"license": "MIT",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz",
"integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==",
"dependencies": {
"@tailwindcss/node": "4.1.17",
"@tailwindcss/oxide": "4.1.17",
"tailwindcss": "4.1.17"
"@tailwindcss/node": "4.1.18",
"@tailwindcss/oxide": "4.1.18",
"tailwindcss": "4.1.18"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6 || ^7"
@@ -6006,9 +5991,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.0.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.0.tgz",
"integrity": "sha512-rl78HwuZlaDIUSeUKkmogkhebA+8K1Hy7tddZuJ3D0xV8pZSfsYGTsliGUol1JPzu9EKnTxPC4L1fiWouStRew==",
"version": "25.0.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.1.tgz",
"integrity": "sha512-czWPzKIAXucn9PtsttxmumiQ9N0ok9FrBwgRWrwmVLlp86BrMExzvXRLFYRJ+Ex3g6yqj+KuaxfX1JTgV2lpfg==",
"devOptional": true,
"dependencies": {
"undici-types": "~7.16.0"
@@ -8333,10 +8318,9 @@
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
"integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
"license": "MIT",
"version": "5.18.4",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
"integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.2.0"
@@ -10020,8 +10004,7 @@
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
},
"node_modules/graphemer": {
"version": "1.4.0",
@@ -11733,9 +11716,9 @@
}
},
"node_modules/lucide-react": {
"version": "0.560.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.560.0.tgz",
"integrity": "sha512-NwKoUA/aBShsdL8WE5lukV2F/tjHzQRlonQs7fkNGI1sCT0Ay4a9Ap3ST2clUUkcY+9eQ0pBe2hybTQd2fmyDA==",
"version": "0.561.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.561.0.tgz",
"integrity": "sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
@@ -13676,10 +13659,9 @@
}
},
"node_modules/posthog-js": {
"version": "1.304.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.304.0.tgz",
"integrity": "sha512-revqoppmJ5y1Oa9iRUb3P8w1htfxZdrSAe+elSNMxvl7wxY62qWN7Q0kE5Sk81o1qLHa6drPhVKa/dppWOfSUw==",
"license": "SEE LICENSE IN LICENSE",
"version": "1.306.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.306.0.tgz",
"integrity": "sha512-sjsy0El4HL6PgbyWiUF0CaKb2d1Q8okbSeT4eajan3QSvkWus6ygHQuW2l4lfvp6NLRQrIZKH/0sUanhASptUQ==",
"dependencies": {
"@posthog/core": "1.7.1",
"core-js": "^3.38.1",
@@ -13911,10 +13893,9 @@
}
},
"node_modules/react": {
"version": "19.2.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
"license": "MIT",
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"engines": {
"node": ">=0.10.0"
}
@@ -13960,15 +13941,14 @@
"license": "MIT"
},
"node_modules/react-dom": {
"version": "19.2.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
"license": "MIT",
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.1"
"react": "^19.2.3"
}
},
"node_modules/react-hot-toast": {
@@ -13989,10 +13969,9 @@
}
},
"node_modules/react-i18next": {
"version": "16.4.1",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.4.1.tgz",
"integrity": "sha512-GzsYomxb1/uE7nlJm0e1qQ8f+W9I3Xirh9VoycZIahk6C8Pmv/9Fd0ek6zjf1FSgtGLElDGqwi/4FOHEGUbsEQ==",
"license": "MIT",
"version": "16.5.0",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.0.tgz",
"integrity": "sha512-IMpPTyCTKxEj8klCrLKUTIUa8uYTd851+jcu2fJuUB9Agkk9Qq8asw4omyeHVnOXHrLgQJGTm5zTvn8HpaPiqw==",
"dependencies": {
"@babel/runtime": "^7.27.6",
"html-parse-stringify": "^3.0.1",
@@ -15578,16 +15557,14 @@
}
},
"node_modules/tailwindcss": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz",
"integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==",
"license": "MIT"
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="
},
"node_modules/tapable": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
"license": "MIT",
"engines": {
"node": ">=6"
},

View File

@@ -12,7 +12,7 @@
"@monaco-editor/react": "^4.7.0-rc.0",
"@react-router/node": "^7.10.1",
"@react-router/serve": "^7.10.1",
"@tailwindcss/vite": "^4.1.17",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.12",
"@uidotdev/usehooks": "^2.4.1",
"@xterm/addon-fit": "^0.10.0",
@@ -27,13 +27,13 @@
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.32",
"lucide-react": "^0.560.0",
"lucide-react": "^0.561.0",
"monaco-editor": "^0.55.1",
"posthog-js": "^1.302.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"posthog-js": "^1.306.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-hot-toast": "^2.6.0",
"react-i18next": "^16.4.0",
"react-i18next": "^16.5.0",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-router": "^7.10.1",
@@ -89,7 +89,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.0.0",
"@types/node": "^25.0.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",

View File

@@ -5,7 +5,6 @@ import type {
ConfirmationResponseRequest,
ConfirmationResponseResponse,
} from "./event-service.types";
import { openHands } from "../open-hands-axios";
class EventService {
/**
@@ -38,11 +37,27 @@ class EventService {
return data;
}
static async getEventCount(conversationId: string): Promise<number> {
const params = new URLSearchParams();
params.append("conversation_id__eq", conversationId);
const { data } = await openHands.get<number>(
`/api/v1/events/count?${params.toString()}`,
/**
* Get event count for a V1 conversation
* @param conversationId The conversation ID
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
* @param sessionApiKey Session API key for authentication (required for V1)
* @returns The event count
*/
static async getEventCount(
conversationId: string,
conversationUrl: string,
sessionApiKey?: string | null,
): Promise<number> {
// Build the runtime URL using the conversation URL
const runtimeUrl = buildHttpBaseUrl(conversationUrl);
// Build session headers for authentication
const headers = buildSessionHeaders(sessionApiKey);
const { data } = await axios.get<number>(
`${runtimeUrl}/api/conversations/${conversationId}/events/count`,
{ headers },
);
return data;
}

View File

@@ -1,5 +1,5 @@
import { openHands } from "../open-hands-axios";
import { ApiSettings, PostApiSettings } from "./settings.types";
import { Settings } from "#/types/settings";
/**
* Settings service for managing application settings
@@ -8,8 +8,8 @@ class SettingsService {
/**
* Get the settings from the server or use the default settings if not found
*/
static async getSettings(): Promise<ApiSettings> {
const { data } = await openHands.get<ApiSettings>("/api/settings");
static async getSettings(): Promise<Settings> {
const { data } = await openHands.get<Settings>("/api/settings");
return data;
}
@@ -17,9 +17,7 @@ class SettingsService {
* Save the settings to the server. Only valid settings are saved.
* @param settings - the settings to save
*/
static async saveSettings(
settings: Partial<PostApiSettings>,
): Promise<boolean> {
static async saveSettings(settings: Partial<Settings>): Promise<boolean> {
const data = await openHands.post("/api/settings", settings);
return data.status === 200;
}

View File

@@ -1,54 +0,0 @@
import { Provider } from "#/types/settings";
export type ApiSettings = {
llm_model: string;
llm_base_url: string;
agent: string;
language: string;
llm_api_key: string | null;
llm_api_key_set: boolean;
search_api_key_set: boolean;
confirmation_mode: boolean;
security_analyzer: string | null;
remote_runtime_resource_factor: number | null;
enable_default_condenser: boolean;
// Max size for condenser in backend settings
condenser_max_size: number | null;
enable_sound_notifications: boolean;
enable_proactive_conversation_starters: boolean;
enable_solvability_analysis: boolean;
user_consents_to_analytics: boolean | null;
search_api_key?: string;
provider_tokens_set: Partial<Record<Provider, string | null>>;
max_budget_per_task: number | null;
mcp_config?: {
sse_servers: (string | { url: string; api_key?: string })[];
stdio_servers: {
name: string;
command: string;
args?: string[];
env?: Record<string, string>;
}[];
shttp_servers: (string | { url: string; api_key?: string })[];
};
email?: string;
email_verified?: boolean;
git_user_name?: string;
git_user_email?: string;
v1_enabled?: boolean;
};
export type PostApiSettings = ApiSettings & {
user_consents_to_analytics: boolean | null;
search_api_key?: string;
mcp_config?: {
sse_servers: (string | { url: string; api_key?: string })[];
stdio_servers: {
name: string;
command: string;
args?: string[];
env?: Record<string, string>;
}[];
shttp_servers: (string | { url: string; api_key?: string })[];
};
};

View File

@@ -9,7 +9,7 @@ function ConfirmationModeEnabled() {
const { data: settings } = useSettings();
if (!settings?.CONFIRMATION_MODE) {
if (!settings?.confirmation_mode) {
return null;
}

View File

@@ -24,7 +24,7 @@ export function TaskItem({ task }: TaskItemProps) {
case "todo":
return <CircleIcon className="w-4 h-4 text-[#ffffff]" />;
case "in_progress":
return <LoadingIcon className="w-4 h-4 text-[#ffffff]" />;
return <LoadingIcon className="w-4 h-4 text-[#ffffff] animate-spin" />;
case "done":
return <CheckCircleIcon className="w-4 h-4 text-[#A3A3A3]" />;
default:

View File

@@ -5,11 +5,10 @@ import { ContextMenu } from "#/ui/context-menu";
import { ContextMenuListItem } from "./context-menu-list-item";
import { Divider } from "#/ui/divider";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { useConfig } from "#/hooks/query/use-config";
import { I18nKey } from "#/i18n/declaration";
import LogOutIcon from "#/icons/log-out.svg?react";
import DocumentIcon from "#/icons/document.svg?react";
import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav";
import { useSettingsNavItems } from "#/hooks/use-settings-nav-items";
interface AccountSettingsContextMenuProps {
onLogout: () => void;
@@ -22,15 +21,8 @@ export function AccountSettingsContextMenu({
}: AccountSettingsContextMenuProps) {
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
const { t } = useTranslation();
const { data: config } = useConfig();
const isSaas = config?.APP_MODE === "saas";
// Get navigation items and filter out LLM settings if the feature flag is enabled
let items = isSaas ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS;
if (config?.FEATURE_FLAGS?.HIDE_LLM_SETTINGS) {
items = items.filter((item) => item.to !== "/settings");
}
const items = useSettingsNavItems();
const navItems = items.map((item) => ({
...item,
@@ -39,11 +31,7 @@ export function AccountSettingsContextMenu({
height: 16,
} as React.SVGProps<SVGSVGElement>),
}));
const handleNavigationClick = () => {
onClose();
// The Link component will handle the actual navigation
};
const handleNavigationClick = () => onClose();
return (
<ContextMenu
@@ -55,7 +43,7 @@ export function AccountSettingsContextMenu({
{navItems.map(({ to, text, icon }) => (
<Link key={to} to={to} className="text-decoration-none">
<ContextMenuListItem
onClick={() => handleNavigationClick()}
onClick={handleNavigationClick}
className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded h-[30px]"
>
{icon}

View File

@@ -20,13 +20,13 @@ export function EmailVerificationGuard({
if (isLoading) return;
// If EMAIL_VERIFIED is explicitly false (not undefined or null)
if (settings?.EMAIL_VERIFIED === false) {
if (settings?.email_verified === false) {
// Allow access to /settings/user but redirect from any other page
if (pathname !== "/settings/user") {
navigate("/settings/user", { replace: true });
}
}
}, [settings?.EMAIL_VERIFIED, pathname, navigate, isLoading]);
}, [settings?.email_verified, pathname, navigate, isLoading]);
return children;
}

View File

@@ -1,16 +1,11 @@
import { useState } from "react";
import { MobileHeader } from "./mobile-header";
import { SettingsNavigation } from "./settings-navigation";
interface NavigationItem {
to: string;
icon: React.ReactNode;
text: string;
}
import { SettingsNavItem } from "#/constants/settings-nav";
interface SettingsLayoutProps {
children: React.ReactNode;
navigationItems: NavigationItem[];
navigationItems: SettingsNavItem[];
}
export function SettingsLayout({
@@ -19,13 +14,8 @@ export function SettingsLayout({
}: SettingsLayoutProps) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const toggleMobileMenu = () => {
setIsMobileMenuOpen(!isMobileMenuOpen);
};
const closeMobileMenu = () => {
setIsMobileMenuOpen(false);
};
const toggleMobileMenu = () => setIsMobileMenuOpen(!isMobileMenuOpen);
const closeMobileMenu = () => setIsMobileMenuOpen(false);
return (
<div className="flex flex-col h-full px-[14px] pt-8">
@@ -34,7 +24,6 @@ export function SettingsLayout({
isMobileMenuOpen={isMobileMenuOpen}
onToggleMenu={toggleMobileMenu}
/>
{/* Desktop layout with navigation and main content */}
<div className="flex flex-1 overflow-hidden gap-10">
{/* Navigation */}
@@ -43,7 +32,6 @@ export function SettingsLayout({
onCloseMobileMenu={closeMobileMenu}
navigationItems={navigationItems}
/>
{/* Main content */}
<main className="flex-1 overflow-auto custom-scrollbar-always">
{children}

View File

@@ -5,17 +5,12 @@ import { Typography } from "#/ui/typography";
import { I18nKey } from "#/i18n/declaration";
import SettingsIcon from "#/icons/settings-gear.svg?react";
import CloseIcon from "#/icons/close.svg?react";
interface NavigationItem {
to: string;
icon: React.ReactNode;
text: string;
}
import { SettingsNavItem } from "#/constants/settings-nav";
interface SettingsNavigationProps {
isMobileMenuOpen: boolean;
onCloseMobileMenu: () => void;
navigationItems: NavigationItem[];
navigationItems: SettingsNavItem[];
}
export function SettingsNavigation({
@@ -34,7 +29,6 @@ export function SettingsNavigation({
onClick={onCloseMobileMenu}
/>
)}
{/* Navigation sidebar */}
<nav
data-testid="settings-navbar"

View File

@@ -71,19 +71,19 @@ export function Sidebar() {
<OpenHandsLogoButton />
</div>
<div>
<NewProjectButton disabled={settings?.EMAIL_VERIFIED === false} />
<NewProjectButton disabled={settings?.email_verified === false} />
</div>
<ConversationPanelButton
isOpen={conversationPanelIsOpen}
onClick={() =>
settings?.EMAIL_VERIFIED === false
settings?.email_verified === false
? null
: setConversationPanelIsOpen((prev) => !prev)
}
disabled={settings?.EMAIL_VERIFIED === false}
disabled={settings?.email_verified === false}
/>
<MicroagentManagementButton
disabled={settings?.EMAIL_VERIFIED === false}
disabled={settings?.email_verified === false}
/>
</div>

View File

@@ -41,11 +41,11 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
onClose();
posthog.capture("settings_saved", {
LLM_MODEL: newSettings.LLM_MODEL,
LLM_API_KEY_SET: newSettings.LLM_API_KEY_SET ? "SET" : "UNSET",
SEARCH_API_KEY_SET: newSettings.SEARCH_API_KEY ? "SET" : "UNSET",
LLM_MODEL: newSettings.llm_model,
LLM_API_KEY_SET: newSettings.llm_api_key_set ? "SET" : "UNSET",
SEARCH_API_KEY_SET: newSettings.search_api_key ? "SET" : "UNSET",
REMOTE_RUNTIME_RESOURCE_FACTOR:
newSettings.REMOTE_RUNTIME_RESOURCE_FACTOR,
newSettings.remote_runtime_resource_factor,
});
},
});
@@ -67,7 +67,7 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
}
};
const isLLMKeySet = settings.LLM_API_KEY_SET;
const isLLMKeySet = settings.llm_api_key_set;
return (
<div>
@@ -80,7 +80,7 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
<div className="flex flex-col gap-[17px]">
<ModelSelector
models={organizeModelsAndProviders(models)}
currentModel={settings.LLM_MODEL}
currentModel={settings.llm_model}
wrapperClassName="!flex-col !gap-[17px]"
labelClassName={SETTINGS_FORM.LABEL_CLASSNAME}
/>

View File

@@ -20,9 +20,7 @@ export function TaskItem({ task }: TaskItemProps) {
case "todo":
return <CircleIcon className="w-4 h-4 text-[#ffffff]" />;
case "in_progress":
return (
<LoadingIcon className="w-4 h-4 text-[#ffffff]" strokeWidth={0.5} />
);
return <LoadingIcon className="w-4 h-4 text-[#ffffff] animate-spin" />;
case "done":
return <CheckCircleIcon className="w-4 h-4 text-[#A3A3A3]" />;
default:

View File

@@ -578,9 +578,13 @@ export function ConversationWebSocketProvider({
removeErrorMessage(); // Clear any previous error messages on successful connection
// Fetch expected event count for history loading detection
if (conversationId) {
if (conversationId && conversationUrl) {
try {
const count = await EventService.getEventCount(conversationId);
const count = await EventService.getEventCount(
conversationId,
conversationUrl,
sessionApiKey,
);
setExpectedEventCountMain(count);
// If no events expected, mark as loaded immediately
@@ -618,6 +622,7 @@ export function ConversationWebSocketProvider({
removeErrorMessage,
sessionApiKey,
conversationId,
conversationUrl,
]);
// Separate WebSocket options for planning agent connection
@@ -642,10 +647,15 @@ export function ConversationWebSocketProvider({
removeErrorMessage(); // Clear any previous error messages on successful connection
// Fetch expected event count for history loading detection
if (planningAgentConversation?.id) {
if (
planningAgentConversation?.id &&
planningAgentConversation.conversation_url
) {
try {
const count = await EventService.getEventCount(
planningAgentConversation.id,
planningAgentConversation.conversation_url,
planningAgentConversation.session_api_key,
);
setExpectedEventCountPlanning(count);

View File

@@ -24,7 +24,7 @@ export function useAddMcpServer() {
mutationFn: async (server: MCPServerConfig): Promise<void> => {
if (!settings) return;
const currentConfig = settings.MCP_CONFIG || {
const currentConfig = settings.mcp_config || {
sse_servers: [],
stdio_servers: [],
shttp_servers: [],
@@ -57,7 +57,7 @@ export function useAddMcpServer() {
const apiSettings = {
mcp_config: newConfig,
v1_enabled: settings.V1_ENABLED,
v1_enabled: settings.v1_enabled,
};
await SettingsService.saveSettings(apiSettings);

View File

@@ -51,7 +51,7 @@ export const useCreateConversation = () => {
agentType,
} = variables;
const useV1 = !!settings?.V1_ENABLED && !createMicroagent;
const useV1 = !!settings?.v1_enabled && !createMicroagent;
if (useV1) {
// Use V1 API - creates a conversation start task

View File

@@ -9,9 +9,9 @@ export function useDeleteMcpServer() {
return useMutation({
mutationFn: async (serverId: string): Promise<void> => {
if (!settings?.MCP_CONFIG) return;
if (!settings?.mcp_config) return;
const newConfig: MCPConfig = { ...settings.MCP_CONFIG };
const newConfig: MCPConfig = { ...settings.mcp_config };
const [serverType, indexStr] = serverId.split("-");
const index = parseInt(indexStr, 10);
@@ -25,7 +25,7 @@ export function useDeleteMcpServer() {
const apiSettings = {
mcp_config: newConfig,
v1_enabled: settings.V1_ENABLED,
v1_enabled: settings.v1_enabled,
};
await SettingsService.saveSettings(apiSettings);

View File

@@ -2,43 +2,28 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { usePostHog } from "posthog-js/react";
import { DEFAULT_SETTINGS } from "#/services/settings";
import SettingsService from "#/api/settings-service/settings-service.api";
import { PostSettings } from "#/types/settings";
import { PostApiSettings } from "#/api/settings-service/settings.types";
import { Settings } from "#/types/settings";
import { useSettings } from "../query/use-settings";
const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
const apiSettings: Partial<PostApiSettings> = {
llm_model: settings.LLM_MODEL,
llm_base_url: settings.LLM_BASE_URL,
agent: settings.AGENT || DEFAULT_SETTINGS.AGENT,
language: settings.LANGUAGE || DEFAULT_SETTINGS.LANGUAGE,
confirmation_mode: settings.CONFIRMATION_MODE,
security_analyzer: settings.SECURITY_ANALYZER,
const saveSettingsMutationFn = async (settings: Partial<Settings>) => {
const settingsToSave: Partial<Settings> = {
...settings,
agent: settings.agent || DEFAULT_SETTINGS.agent,
language: settings.language || DEFAULT_SETTINGS.language,
llm_api_key:
settings.llm_api_key === ""
? ""
: settings.llm_api_key?.trim() || undefined,
remote_runtime_resource_factor: settings.REMOTE_RUNTIME_RESOURCE_FACTOR,
enable_default_condenser: settings.ENABLE_DEFAULT_CONDENSER,
condenser_max_size:
settings.CONDENSER_MAX_SIZE ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE,
enable_sound_notifications: settings.ENABLE_SOUND_NOTIFICATIONS,
user_consents_to_analytics: settings.user_consents_to_analytics,
provider_tokens_set: settings.PROVIDER_TOKENS_SET,
mcp_config: settings.MCP_CONFIG,
enable_proactive_conversation_starters:
settings.ENABLE_PROACTIVE_CONVERSATION_STARTERS,
enable_solvability_analysis: settings.ENABLE_SOLVABILITY_ANALYSIS,
search_api_key: settings.SEARCH_API_KEY?.trim() || "",
max_budget_per_task: settings.MAX_BUDGET_PER_TASK,
settings.condenser_max_size ?? DEFAULT_SETTINGS.condenser_max_size,
search_api_key: settings.search_api_key?.trim() || "",
git_user_name:
settings.GIT_USER_NAME?.trim() || DEFAULT_SETTINGS.GIT_USER_NAME,
settings.git_user_name?.trim() || DEFAULT_SETTINGS.git_user_name,
git_user_email:
settings.GIT_USER_EMAIL?.trim() || DEFAULT_SETTINGS.GIT_USER_EMAIL,
v1_enabled: settings.V1_ENABLED,
settings.git_user_email?.trim() || DEFAULT_SETTINGS.git_user_email,
};
await SettingsService.saveSettings(apiSettings);
await SettingsService.saveSettings(settingsToSave);
};
export const useSaveSettings = () => {
@@ -47,18 +32,18 @@ export const useSaveSettings = () => {
const { data: currentSettings } = useSettings();
return useMutation({
mutationFn: async (settings: Partial<PostSettings>) => {
mutationFn: async (settings: Partial<Settings>) => {
const newSettings = { ...currentSettings, ...settings };
// Track MCP configuration changes
if (
settings.MCP_CONFIG &&
currentSettings?.MCP_CONFIG !== settings.MCP_CONFIG
settings.mcp_config &&
currentSettings?.mcp_config !== settings.mcp_config
) {
const hasMcpConfig = !!settings.MCP_CONFIG;
const sseServersCount = settings.MCP_CONFIG?.sse_servers?.length || 0;
const hasMcpConfig = !!settings.mcp_config;
const sseServersCount = settings.mcp_config?.sse_servers?.length || 0;
const stdioServersCount =
settings.MCP_CONFIG?.stdio_servers?.length || 0;
settings.mcp_config?.stdio_servers?.length || 0;
// Track MCP configuration usage
posthog.capture("mcp_config_updated", {

View File

@@ -28,9 +28,9 @@ export function useUpdateMcpServer() {
serverId: string;
server: MCPServerConfig;
}): Promise<void> => {
if (!settings?.MCP_CONFIG) return;
if (!settings?.mcp_config) return;
const newConfig = { ...settings.MCP_CONFIG };
const newConfig = { ...settings.mcp_config };
const [serverType, indexStr] = serverId.split("-");
const index = parseInt(indexStr, 10);
@@ -59,7 +59,7 @@ export function useUpdateMcpServer() {
const apiSettings = {
mcp_config: newConfig,
v1_enabled: settings.V1_ENABLED,
v1_enabled: settings.v1_enabled,
};
await SettingsService.saveSettings(apiSettings);

View File

@@ -6,37 +6,18 @@ import { Settings } from "#/types/settings";
import { useIsAuthed } from "./use-is-authed";
const getSettingsQueryFn = async (): Promise<Settings> => {
const apiSettings = await SettingsService.getSettings();
const settings = await SettingsService.getSettings();
return {
LLM_MODEL: apiSettings.llm_model,
LLM_BASE_URL: apiSettings.llm_base_url,
AGENT: apiSettings.agent,
LANGUAGE: apiSettings.language,
CONFIRMATION_MODE: apiSettings.confirmation_mode,
SECURITY_ANALYZER: apiSettings.security_analyzer,
LLM_API_KEY_SET: apiSettings.llm_api_key_set,
SEARCH_API_KEY_SET: apiSettings.search_api_key_set,
REMOTE_RUNTIME_RESOURCE_FACTOR: apiSettings.remote_runtime_resource_factor,
PROVIDER_TOKENS_SET: apiSettings.provider_tokens_set,
ENABLE_DEFAULT_CONDENSER: apiSettings.enable_default_condenser,
CONDENSER_MAX_SIZE:
apiSettings.condenser_max_size ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE,
ENABLE_SOUND_NOTIFICATIONS: apiSettings.enable_sound_notifications,
ENABLE_PROACTIVE_CONVERSATION_STARTERS:
apiSettings.enable_proactive_conversation_starters,
ENABLE_SOLVABILITY_ANALYSIS: apiSettings.enable_solvability_analysis,
USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics,
SEARCH_API_KEY: apiSettings.search_api_key || "",
MAX_BUDGET_PER_TASK: apiSettings.max_budget_per_task,
EMAIL: apiSettings.email || "",
EMAIL_VERIFIED: apiSettings.email_verified,
MCP_CONFIG: apiSettings.mcp_config,
GIT_USER_NAME: apiSettings.git_user_name || DEFAULT_SETTINGS.GIT_USER_NAME,
GIT_USER_EMAIL:
apiSettings.git_user_email || DEFAULT_SETTINGS.GIT_USER_EMAIL,
IS_NEW_USER: false,
V1_ENABLED: apiSettings.v1_enabled ?? DEFAULT_SETTINGS.V1_ENABLED,
...settings,
condenser_max_size:
settings.condenser_max_size ?? DEFAULT_SETTINGS.condenser_max_size,
search_api_key: settings.search_api_key || "",
email: settings.email || "",
git_user_name: settings.git_user_name || DEFAULT_SETTINGS.git_user_name,
git_user_email: settings.git_user_email || DEFAULT_SETTINGS.git_user_email,
is_new_user: false,
v1_enabled: settings.v1_enabled ?? DEFAULT_SETTINGS.v1_enabled,
};
};

View File

@@ -15,7 +15,7 @@ import { useSettings } from "#/hooks/query/use-settings";
*/
export const useStartTasks = (limit = 10) => {
const { data: settings } = useSettings();
const isV1Enabled = settings?.V1_ENABLED;
const isV1Enabled = settings?.v1_enabled;
return useQuery({
queryKey: ["start-tasks", "search", limit],

View File

@@ -0,0 +1,15 @@
import { useConfig } from "#/hooks/query/use-config";
import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav";
export function useSettingsNavItems() {
const { data: config } = useConfig();
const shouldHideLlmSettings = !!config?.FEATURE_FLAGS?.HIDE_LLM_SETTINGS;
const isSaasMode = config?.APP_MODE === "saas";
const items = isSaasMode ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS;
return shouldHideLlmSettings
? items.filter((item) => item.to !== "/settings")
: items;
}

View File

@@ -19,7 +19,7 @@ export const useSyncPostHogConsent = () => {
return;
}
const backendConsent = settings.USER_CONSENTS_TO_ANALYTICS;
const backendConsent = settings.user_consents_to_analytics;
// Only sync if there's a backend preference set
if (backendConsent !== null) {

View File

@@ -17,7 +17,7 @@ export const useTracking = () => {
app_surface: config?.APP_MODE || "unknown",
plan_tier: null,
current_url: window.location.href,
user_email: settings?.EMAIL || settings?.GIT_USER_EMAIL || null,
user_email: settings?.email || settings?.git_user_email || null,
};
const trackLoginButtonClick = ({ provider }: { provider: Provider }) => {

View File

@@ -6,8 +6,8 @@ export const useUserProviders = () => {
const { data: settings, isLoading: isLoadingSettings } = useSettings();
const providers = React.useMemo(
() => convertRawProvidersToList(settings?.PROVIDER_TOKENS_SET),
[settings?.PROVIDER_TOKENS_SET],
() => convertRawProvidersToList(settings?.provider_tokens_set),
[settings?.provider_tokens_set],
);
return {

View File

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="7" viewBox="0 0 16 7" fill="none">
<path d="M7.50684 0.25C9.24918 0.25 10.9332 0.87774 12.251 2.01758C13.5688 3.15746 14.4327 4.73379 14.6836 6.45801L14.7256 6.74316H13.2129L13.1777 6.53516C12.9499 5.19635 12.2554 3.98161 11.2178 3.10547C10.1799 2.22925 8.86511 1.74805 7.50684 1.74805C6.14866 1.74811 4.83466 2.22931 3.79688 3.10547C2.75913 3.98161 2.06476 5.19628 1.83691 6.53516L1.80078 6.74316H0.289063L0.331055 6.45801C0.581982 4.73389 1.44504 3.15745 2.7627 2.01758C4.08041 0.877757 5.76455 0.250069 7.50684 0.25Z" fill="currentColor" stroke="currentColor" stroke-width="0.5"/>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8 0.5C9.74234 0.5 11.4264 1.12774 12.7442 2.26758C14.062 3.40746 14.9259 4.98379 15.1768 6.70801L15.2188 6.99316H13.7061L13.6709 6.78516C13.4431 5.44635 12.7486 4.23161 11.711 3.35547C10.6731 2.47925 9.35827 1.99805 8 1.99805C6.64182 1.99811 5.32782 2.47931 4.29004 3.35547C3.25229 4.23161 2.55792 5.44628 2.33007 6.78516L2.29394 6.99316H0.782227L0.824219 6.70801C1.07515 4.98389 1.9382 3.40745 3.25586 2.26758C4.57357 1.12776 6.25771 0.500069 8 0.5Z" fill="currentColor" stroke="currentColor" stroke-width="0.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 652 B

After

Width:  |  Height:  |  Size: 630 B

View File

@@ -0,0 +1,7 @@
import { http, HttpResponse } from "msw";
export const ANALYTICS_HANDLERS = [
http.post("https://us.i.posthog.com/e", async () =>
HttpResponse.json(null, { status: 200 }),
),
];

View File

@@ -0,0 +1,23 @@
import { http, HttpResponse } from "msw";
import { GitUser } from "#/types/git";
export const AUTH_HANDLERS = [
http.get("/api/user/info", () => {
const user: GitUser = {
id: "1",
login: "octocat",
avatar_url: "https://avatars.githubusercontent.com/u/583231?v=4",
company: "GitHub",
email: "placeholder@placeholder.placeholder",
name: "monalisa octocat",
};
return HttpResponse.json(user);
}),
http.post("/api/authenticate", async () =>
HttpResponse.json({ message: "Authenticated" }),
),
http.post("/api/logout", () => HttpResponse.json(null, { status: 200 })),
];

View File

@@ -0,0 +1,118 @@
import { http, delay, HttpResponse } from "msw";
import { Conversation, ResultSet } from "#/api/open-hands.types";
const conversations: Conversation[] = [
{
conversation_id: "1",
title: "My New Project",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
status: "RUNNING",
runtime_status: "STATUS$READY",
url: null,
session_api_key: null,
},
{
conversation_id: "2",
title: "Repo Testing",
selected_repository: "octocat/hello-world",
git_provider: "github",
selected_branch: null,
last_updated_at: new Date(
Date.now() - 2 * 24 * 60 * 60 * 1000,
).toISOString(),
created_at: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
status: "STOPPED",
runtime_status: null,
url: null,
session_api_key: null,
},
{
conversation_id: "3",
title: "Another Project",
selected_repository: "octocat/earth",
git_provider: null,
selected_branch: "main",
last_updated_at: new Date(
Date.now() - 5 * 24 * 60 * 60 * 1000,
).toISOString(),
created_at: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
status: "STOPPED",
runtime_status: null,
url: null,
session_api_key: null,
},
];
const CONVERSATIONS = new Map<string, Conversation>(
conversations.map((c) => [c.conversation_id, c]),
);
export const CONVERSATION_HANDLERS = [
http.get("/api/conversations", async () => {
const values = Array.from(CONVERSATIONS.values());
const results: ResultSet<Conversation> = {
results: values,
next_page_id: null,
};
return HttpResponse.json(results);
}),
http.get("/api/conversations/:conversationId", async ({ params }) => {
const conversationId = params.conversationId as string;
const project = CONVERSATIONS.get(conversationId);
if (project) return HttpResponse.json(project);
return HttpResponse.json(null, { status: 404 });
}),
http.post("/api/conversations", async () => {
await delay();
const conversation: Conversation = {
conversation_id: (Math.random() * 100).toString(),
title: "New Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
status: "RUNNING",
runtime_status: "STATUS$READY",
url: null,
session_api_key: null,
};
CONVERSATIONS.set(conversation.conversation_id, conversation);
return HttpResponse.json(conversation, { status: 201 });
}),
http.patch(
"/api/conversations/:conversationId",
async ({ params, request }) => {
const conversationId = params.conversationId as string;
const conversation = CONVERSATIONS.get(conversationId);
if (conversation) {
const body = await request.json();
if (typeof body === "object" && body?.title) {
CONVERSATIONS.set(conversationId, {
...conversation,
title: body.title,
});
return HttpResponse.json(null, { status: 200 });
}
}
return HttpResponse.json(null, { status: 404 });
},
),
http.delete("/api/conversations/:conversationId", async ({ params }) => {
const conversationId = params.conversationId as string;
if (CONVERSATIONS.has(conversationId)) {
CONVERSATIONS.delete(conversationId);
return HttpResponse.json(null, { status: 200 });
}
return HttpResponse.json(null, { status: 404 });
}),
];

View File

@@ -0,0 +1,15 @@
import { http, delay, HttpResponse } from "msw";
export const FEEDBACK_HANDLERS = [
http.post("/api/submit-feedback", async () => {
await delay(1200);
return HttpResponse.json({
statusCode: 200,
body: { message: "Success", link: "fake-url.com", password: "abc123" },
});
}),
http.post("/api/submit-feedback", async () =>
HttpResponse.json({ statusCode: 200 }, { status: 200 }),
),
];

View File

@@ -1,146 +1,17 @@
import { delay, http, HttpResponse } from "msw";
import { GetConfigResponse } from "#/api/option-service/option.types";
import { Conversation, ResultSet } from "#/api/open-hands.types";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { STRIPE_BILLING_HANDLERS } from "./billing-handlers";
import { Provider } from "#/types/settings";
import {
ApiSettings,
PostApiSettings,
} from "#/api/settings-service/settings.types";
import { FILE_SERVICE_HANDLERS } from "./file-service-handlers";
import { GitUser } from "#/types/git";
import { TASK_SUGGESTIONS_HANDLERS } from "./task-suggestions-handlers";
import { SECRETS_HANDLERS } from "./secrets-handlers";
import { GIT_REPOSITORY_HANDLERS } from "./git-repository-handlers";
export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
llm_base_url: DEFAULT_SETTINGS.LLM_BASE_URL,
llm_api_key: null,
llm_api_key_set: DEFAULT_SETTINGS.LLM_API_KEY_SET,
search_api_key_set: DEFAULT_SETTINGS.SEARCH_API_KEY_SET,
agent: DEFAULT_SETTINGS.AGENT,
language: DEFAULT_SETTINGS.LANGUAGE,
confirmation_mode: DEFAULT_SETTINGS.CONFIRMATION_MODE,
security_analyzer: DEFAULT_SETTINGS.SECURITY_ANALYZER,
remote_runtime_resource_factor:
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
provider_tokens_set: {},
enable_default_condenser: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER,
condenser_max_size: DEFAULT_SETTINGS.CONDENSER_MAX_SIZE,
enable_sound_notifications: DEFAULT_SETTINGS.ENABLE_SOUND_NOTIFICATIONS,
enable_proactive_conversation_starters:
DEFAULT_SETTINGS.ENABLE_PROACTIVE_CONVERSATION_STARTERS,
enable_solvability_analysis: DEFAULT_SETTINGS.ENABLE_SOLVABILITY_ANALYSIS,
user_consents_to_analytics: DEFAULT_SETTINGS.USER_CONSENTS_TO_ANALYTICS,
max_budget_per_task: DEFAULT_SETTINGS.MAX_BUDGET_PER_TASK,
};
const MOCK_USER_PREFERENCES: {
settings: ApiSettings | PostApiSettings | null;
} = {
settings: null,
};
/**
* Set the user settings to the default settings
*
* Useful for resetting the settings in tests
*/
export const resetTestHandlersMockSettings = () => {
MOCK_USER_PREFERENCES.settings = MOCK_DEFAULT_USER_SETTINGS;
};
const conversations: Conversation[] = [
{
conversation_id: "1",
title: "My New Project",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
status: "RUNNING",
runtime_status: "STATUS$READY",
url: null,
session_api_key: null,
},
{
conversation_id: "2",
title: "Repo Testing",
selected_repository: "octocat/hello-world",
git_provider: "github",
selected_branch: null,
// 2 days ago
last_updated_at: new Date(
Date.now() - 2 * 24 * 60 * 60 * 1000,
).toISOString(),
created_at: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
status: "STOPPED",
runtime_status: null,
url: null,
session_api_key: null,
},
{
conversation_id: "3",
title: "Another Project",
selected_repository: "octocat/earth",
git_provider: null,
selected_branch: "main",
// 5 days ago
last_updated_at: new Date(
Date.now() - 5 * 24 * 60 * 60 * 1000,
).toISOString(),
created_at: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
status: "STOPPED",
runtime_status: null,
url: null,
session_api_key: null,
},
];
const CONVERSATIONS = new Map<string, Conversation>(
conversations.map((conversation) => [
conversation.conversation_id,
conversation,
]),
);
const openHandsHandlers = [
http.get("/api/options/models", async () =>
HttpResponse.json([
"gpt-3.5-turbo",
"gpt-4o",
"gpt-4o-mini",
"anthropic/claude-3.5",
"anthropic/claude-sonnet-4-20250514",
"anthropic/claude-sonnet-4-5-20250929",
"anthropic/claude-haiku-4-5-20251001",
"openhands/claude-sonnet-4-20250514",
"openhands/claude-sonnet-4-5-20250929",
"openhands/claude-haiku-4-5-20251001",
"sambanova/Meta-Llama-3.1-8B-Instruct",
]),
),
http.get("/api/options/agents", async () =>
HttpResponse.json(["CodeActAgent", "CoActAgent"]),
),
http.get("/api/options/security-analyzers", async () =>
HttpResponse.json(["llm", "none"]),
),
http.post("http://localhost:3001/api/submit-feedback", async () => {
await delay(1200);
return HttpResponse.json({
statusCode: 200,
body: { message: "Success", link: "fake-url.com", password: "abc123" },
});
}),
];
import {
SETTINGS_HANDLERS,
MOCK_DEFAULT_USER_SETTINGS,
resetTestHandlersMockSettings,
} from "./settings-handlers";
import { CONVERSATION_HANDLERS } from "./conversation-handlers";
import { AUTH_HANDLERS } from "./auth-handlers";
import { FEEDBACK_HANDLERS } from "./feedback-handlers";
import { ANALYTICS_HANDLERS } from "./analytics-handlers";
export const handlers = [
...STRIPE_BILLING_HANDLERS,
@@ -148,192 +19,11 @@ export const handlers = [
...TASK_SUGGESTIONS_HANDLERS,
...SECRETS_HANDLERS,
...GIT_REPOSITORY_HANDLERS,
...openHandsHandlers,
http.get("/api/user/info", () => {
const user: GitUser = {
id: "1",
login: "octocat",
avatar_url: "https://avatars.githubusercontent.com/u/583231?v=4",
company: "GitHub",
email: "placeholder@placeholder.placeholder",
name: "monalisa octocat",
};
return HttpResponse.json(user);
}),
http.post("http://localhost:3001/api/submit-feedback", async () =>
HttpResponse.json({ statusCode: 200 }, { status: 200 }),
),
http.post("https://us.i.posthog.com/e", async () =>
HttpResponse.json(null, { status: 200 }),
),
http.get("/api/options/config", () => {
const mockSaas = import.meta.env.VITE_MOCK_SAAS === "true";
const config: GetConfigResponse = {
APP_MODE: mockSaas ? "saas" : "oss",
GITHUB_CLIENT_ID: "fake-github-client-id",
POSTHOG_CLIENT_KEY: "fake-posthog-client-key",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: mockSaas,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
// Uncomment the following to test the maintenance banner
// MAINTENANCE: {
// startTime: "2024-01-15T10:00:00-05:00", // EST timestamp
// },
};
return HttpResponse.json(config);
}),
http.get("/api/settings", async () => {
await delay();
const { settings } = MOCK_USER_PREFERENCES;
if (!settings) return HttpResponse.json(null, { status: 404 });
return HttpResponse.json(settings);
}),
http.post("/api/settings", async ({ request }) => {
await delay();
const body = await request.json();
if (body) {
const current = MOCK_USER_PREFERENCES.settings || {
...MOCK_DEFAULT_USER_SETTINGS,
};
// Persist new values over current/mock defaults
MOCK_USER_PREFERENCES.settings = {
...current,
...(body as Partial<ApiSettings>),
};
return HttpResponse.json(null, { status: 200 });
}
return HttpResponse.json(null, { status: 400 });
}),
http.post("/api/authenticate", async () =>
HttpResponse.json({ message: "Authenticated" }),
),
http.get("/api/conversations", async () => {
const values = Array.from(CONVERSATIONS.values());
const results: ResultSet<Conversation> = {
results: values,
next_page_id: null,
};
return HttpResponse.json(results, { status: 200 });
}),
http.delete("/api/conversations/:conversationId", async ({ params }) => {
const { conversationId } = params;
if (typeof conversationId === "string") {
CONVERSATIONS.delete(conversationId);
return HttpResponse.json(null, { status: 200 });
}
return HttpResponse.json(null, { status: 404 });
}),
http.patch(
"/api/conversations/:conversationId",
async ({ params, request }) => {
const { conversationId } = params;
if (typeof conversationId === "string") {
const conversation = CONVERSATIONS.get(conversationId);
if (conversation) {
const body = await request.json();
if (typeof body === "object" && body?.title) {
CONVERSATIONS.set(conversationId, {
...conversation,
title: body.title,
});
return HttpResponse.json(null, { status: 200 });
}
}
}
return HttpResponse.json(null, { status: 404 });
},
),
http.post("/api/conversations", async () => {
await delay();
const conversation: Conversation = {
conversation_id: (Math.random() * 100).toString(),
title: "New Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
status: "RUNNING",
runtime_status: "STATUS$READY",
url: null,
session_api_key: null,
};
CONVERSATIONS.set(conversation.conversation_id, conversation);
return HttpResponse.json(conversation, { status: 201 });
}),
http.get("/api/conversations/:conversationId", async ({ params }) => {
const { conversationId } = params;
if (typeof conversationId === "string") {
const project = CONVERSATIONS.get(conversationId);
if (project) {
return HttpResponse.json(project, { status: 200 });
}
}
return HttpResponse.json(null, { status: 404 });
}),
http.post("/api/logout", () => HttpResponse.json(null, { status: 200 })),
http.post("/api/reset-settings", async () => {
await delay();
MOCK_USER_PREFERENCES.settings = { ...MOCK_DEFAULT_USER_SETTINGS };
return HttpResponse.json(null, { status: 200 });
}),
http.post("/api/add-git-providers", async ({ request }) => {
const body = await request.json();
if (typeof body === "object" && body?.provider_tokens) {
const rawTokens = body.provider_tokens as Record<
string,
{ token?: string }
>;
const providerTokensSet: Partial<Record<Provider, string | null>> =
Object.fromEntries(
Object.entries(rawTokens)
.filter(([, val]) => val && val.token)
.map(([provider]) => [provider as Provider, ""]),
);
const newSettings = {
...(MOCK_USER_PREFERENCES.settings ?? MOCK_DEFAULT_USER_SETTINGS),
provider_tokens_set: providerTokensSet,
};
MOCK_USER_PREFERENCES.settings = newSettings;
return HttpResponse.json(true, { status: 200 });
}
return HttpResponse.json(null, { status: 400 });
}),
...SETTINGS_HANDLERS,
...CONVERSATION_HANDLERS,
...AUTH_HANDLERS,
...FEEDBACK_HANDLERS,
...ANALYTICS_HANDLERS,
];
export { MOCK_DEFAULT_USER_SETTINGS, resetTestHandlersMockSettings };

View File

@@ -0,0 +1,151 @@
import { http, delay, HttpResponse } from "msw";
import { GetConfigResponse } from "#/api/option-service/option.types";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { Provider, Settings } from "#/types/settings";
export const MOCK_DEFAULT_USER_SETTINGS: Settings = {
llm_model: DEFAULT_SETTINGS.llm_model,
llm_base_url: DEFAULT_SETTINGS.llm_base_url,
llm_api_key: null,
llm_api_key_set: DEFAULT_SETTINGS.llm_api_key_set,
search_api_key_set: DEFAULT_SETTINGS.search_api_key_set,
agent: DEFAULT_SETTINGS.agent,
language: DEFAULT_SETTINGS.language,
confirmation_mode: DEFAULT_SETTINGS.confirmation_mode,
security_analyzer: DEFAULT_SETTINGS.security_analyzer,
remote_runtime_resource_factor:
DEFAULT_SETTINGS.remote_runtime_resource_factor,
provider_tokens_set: {},
enable_default_condenser: DEFAULT_SETTINGS.enable_default_condenser,
condenser_max_size: DEFAULT_SETTINGS.condenser_max_size,
enable_sound_notifications: DEFAULT_SETTINGS.enable_sound_notifications,
enable_proactive_conversation_starters:
DEFAULT_SETTINGS.enable_proactive_conversation_starters,
enable_solvability_analysis: DEFAULT_SETTINGS.enable_solvability_analysis,
user_consents_to_analytics: DEFAULT_SETTINGS.user_consents_to_analytics,
max_budget_per_task: DEFAULT_SETTINGS.max_budget_per_task,
};
const MOCK_USER_PREFERENCES: {
settings: Settings | null;
} = {
settings: null,
};
// Reset mock
export const resetTestHandlersMockSettings = () => {
MOCK_USER_PREFERENCES.settings = MOCK_DEFAULT_USER_SETTINGS;
};
// --- Handlers for options/config/settings ---
export const SETTINGS_HANDLERS = [
http.get("/api/options/models", async () =>
HttpResponse.json([
"gpt-3.5-turbo",
"gpt-4o",
"gpt-4o-mini",
"anthropic/claude-3.5",
"anthropic/claude-sonnet-4-20250514",
"anthropic/claude-sonnet-4-5-20250929",
"anthropic/claude-haiku-4-5-20251001",
"openhands/claude-sonnet-4-20250514",
"openhands/claude-sonnet-4-5-20250929",
"openhands/claude-haiku-4-5-20251001",
"sambanova/Meta-Llama-3.1-8B-Instruct",
]),
),
http.get("/api/options/agents", async () =>
HttpResponse.json(["CodeActAgent", "CoActAgent"]),
),
http.get("/api/options/security-analyzers", async () =>
HttpResponse.json(["llm", "none"]),
),
http.get("/api/options/config", () => {
const mockSaas = import.meta.env.VITE_MOCK_SAAS === "true";
const config: GetConfigResponse = {
APP_MODE: mockSaas ? "saas" : "oss",
GITHUB_CLIENT_ID: "fake-github-client-id",
POSTHOG_CLIENT_KEY: "fake-posthog-client-key",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: mockSaas,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
// Uncomment the following to test the maintenance banner
// MAINTENANCE: {
// startTime: "2024-01-15T10:00:00-05:00", // EST timestamp
// },
};
return HttpResponse.json(config);
}),
http.get("/api/settings", async () => {
await delay();
const { settings } = MOCK_USER_PREFERENCES;
if (!settings) return HttpResponse.json(null, { status: 404 });
return HttpResponse.json(settings);
}),
http.post("/api/settings", async ({ request }) => {
await delay();
const body = await request.json();
if (body) {
const current = MOCK_USER_PREFERENCES.settings || {
...MOCK_DEFAULT_USER_SETTINGS,
};
MOCK_USER_PREFERENCES.settings = {
...current,
...(body as Partial<Settings>),
};
return HttpResponse.json(null, { status: 200 });
}
return HttpResponse.json(null, { status: 400 });
}),
http.post("/api/reset-settings", async () => {
await delay();
MOCK_USER_PREFERENCES.settings = { ...MOCK_DEFAULT_USER_SETTINGS };
return HttpResponse.json(null, { status: 200 });
}),
http.post("/api/add-git-providers", async ({ request }) => {
const body = await request.json();
if (typeof body === "object" && body?.provider_tokens) {
const rawTokens = body.provider_tokens as Record<
string,
{ token?: string }
>;
const providerTokensSet: Partial<Record<Provider, string | null>> =
Object.fromEntries(
Object.entries(rawTokens)
.filter(([, val]) => val && val.token)
.map(([provider]) => [provider as Provider, ""]),
);
MOCK_USER_PREFERENCES.settings = {
...(MOCK_USER_PREFERENCES.settings || MOCK_DEFAULT_USER_SETTINGS),
provider_tokens_set: providerTokensSet,
};
return HttpResponse.json(true, { status: 200 });
}
return HttpResponse.json(null, { status: 400 });
}),
];

View File

@@ -56,7 +56,7 @@ function AppSettingsScreen() {
const languageValue = AvailableLanguages.find(
({ label }) => label === languageLabel,
)?.value;
const language = languageValue || DEFAULT_SETTINGS.LANGUAGE;
const language = languageValue || DEFAULT_SETTINGS.language;
const enableAnalytics =
formData.get("enable-analytics-switch")?.toString() === "on";
@@ -77,21 +77,21 @@ function AppSettingsScreen() {
const gitUserName =
formData.get("git-user-name-input")?.toString() ||
DEFAULT_SETTINGS.GIT_USER_NAME;
DEFAULT_SETTINGS.git_user_name;
const gitUserEmail =
formData.get("git-user-email-input")?.toString() ||
DEFAULT_SETTINGS.GIT_USER_EMAIL;
DEFAULT_SETTINGS.git_user_email;
saveSettings(
{
LANGUAGE: language,
language,
user_consents_to_analytics: enableAnalytics,
ENABLE_SOUND_NOTIFICATIONS: enableSoundNotifications,
ENABLE_PROACTIVE_CONVERSATION_STARTERS: enableProactiveConversations,
ENABLE_SOLVABILITY_ANALYSIS: enableSolvabilityAnalysis,
MAX_BUDGET_PER_TASK: maxBudgetPerTask,
GIT_USER_NAME: gitUserName,
GIT_USER_EMAIL: gitUserEmail,
enable_sound_notifications: enableSoundNotifications,
enable_proactive_conversation_starters: enableProactiveConversations,
enable_solvability_analysis: enableSolvabilityAnalysis,
max_budget_per_task: maxBudgetPerTask,
git_user_name: gitUserName,
git_user_email: gitUserEmail,
},
{
onSuccess: () => {
@@ -120,7 +120,7 @@ function AppSettingsScreen() {
({ label: langValue }) => langValue === value,
)?.label;
const currentLanguage = AvailableLanguages.find(
({ value: langValue }) => langValue === settings?.LANGUAGE,
({ value: langValue }) => langValue === settings?.language,
)?.label;
setLanguageInputHasChanged(selectedLanguage !== currentLanguage);
@@ -128,12 +128,12 @@ function AppSettingsScreen() {
const checkIfAnalyticsSwitchHasChanged = (checked: boolean) => {
// Treat null as true since analytics is opt-in by default
const currentAnalytics = settings?.USER_CONSENTS_TO_ANALYTICS ?? true;
const currentAnalytics = settings?.user_consents_to_analytics ?? true;
setAnalyticsSwitchHasChanged(checked !== currentAnalytics);
};
const checkIfSoundNotificationsSwitchHasChanged = (checked: boolean) => {
const currentSoundNotifications = !!settings?.ENABLE_SOUND_NOTIFICATIONS;
const currentSoundNotifications = !!settings?.enable_sound_notifications;
setSoundNotificationsSwitchHasChanged(
checked !== currentSoundNotifications,
);
@@ -141,14 +141,14 @@ function AppSettingsScreen() {
const checkIfProactiveConversationsSwitchHasChanged = (checked: boolean) => {
const currentProactiveConversations =
!!settings?.ENABLE_PROACTIVE_CONVERSATION_STARTERS;
!!settings?.enable_proactive_conversation_starters;
setProactiveConversationsSwitchHasChanged(
checked !== currentProactiveConversations,
);
};
const checkIfSolvabilityAnalysisSwitchHasChanged = (checked: boolean) => {
const currentSolvabilityAnalysis = !!settings?.ENABLE_SOLVABILITY_ANALYSIS;
const currentSolvabilityAnalysis = !!settings?.enable_solvability_analysis;
setSolvabilityAnalysisSwitchHasChanged(
checked !== currentSolvabilityAnalysis,
);
@@ -156,17 +156,17 @@ function AppSettingsScreen() {
const checkIfMaxBudgetPerTaskHasChanged = (value: string) => {
const newValue = parseMaxBudgetPerTask(value);
const currentValue = settings?.MAX_BUDGET_PER_TASK;
const currentValue = settings?.max_budget_per_task;
setMaxBudgetPerTaskHasChanged(newValue !== currentValue);
};
const checkIfGitUserNameHasChanged = (value: string) => {
const currentValue = settings?.GIT_USER_NAME;
const currentValue = settings?.git_user_name;
setGitUserNameHasChanged(value !== currentValue);
};
const checkIfGitUserEmailHasChanged = (value: string) => {
const currentValue = settings?.GIT_USER_EMAIL;
const currentValue = settings?.git_user_email;
setGitUserEmailHasChanged(value !== currentValue);
};
@@ -193,14 +193,14 @@ function AppSettingsScreen() {
<div className="flex flex-col gap-6">
<LanguageInput
name="language-input"
defaultKey={settings.LANGUAGE}
defaultKey={settings.language}
onChange={checkIfLanguageInputHasChanged}
/>
<SettingsSwitch
testId="enable-analytics-switch"
name="enable-analytics-switch"
defaultIsToggled={settings.USER_CONSENTS_TO_ANALYTICS ?? true}
defaultIsToggled={settings.user_consents_to_analytics ?? true}
onToggle={checkIfAnalyticsSwitchHasChanged}
>
{t(I18nKey.ANALYTICS$SEND_ANONYMOUS_DATA)}
@@ -209,7 +209,7 @@ function AppSettingsScreen() {
<SettingsSwitch
testId="enable-sound-notifications-switch"
name="enable-sound-notifications-switch"
defaultIsToggled={!!settings.ENABLE_SOUND_NOTIFICATIONS}
defaultIsToggled={!!settings.enable_sound_notifications}
onToggle={checkIfSoundNotificationsSwitchHasChanged}
>
{t(I18nKey.SETTINGS$SOUND_NOTIFICATIONS)}
@@ -220,7 +220,7 @@ function AppSettingsScreen() {
testId="enable-proactive-conversations-switch"
name="enable-proactive-conversations-switch"
defaultIsToggled={
!!settings.ENABLE_PROACTIVE_CONVERSATION_STARTERS
!!settings.enable_proactive_conversation_starters
}
onToggle={checkIfProactiveConversationsSwitchHasChanged}
>
@@ -232,20 +232,20 @@ function AppSettingsScreen() {
<SettingsSwitch
testId="enable-solvability-analysis-switch"
name="enable-solvability-analysis-switch"
defaultIsToggled={!!settings.ENABLE_SOLVABILITY_ANALYSIS}
defaultIsToggled={!!settings.enable_solvability_analysis}
onToggle={checkIfSolvabilityAnalysisSwitchHasChanged}
>
{t(I18nKey.SETTINGS$SOLVABILITY_ANALYSIS)}
</SettingsSwitch>
)}
{!settings?.V1_ENABLED && (
{!settings?.v1_enabled && (
<SettingsInput
testId="max-budget-per-task-input"
name="max-budget-per-task-input"
type="number"
label={t(I18nKey.SETTINGS$MAX_BUDGET_PER_CONVERSATION)}
defaultValue={settings.MAX_BUDGET_PER_TASK?.toString() || ""}
defaultValue={settings.max_budget_per_task?.toString() || ""}
onChange={checkIfMaxBudgetPerTaskHasChanged}
placeholder={t(I18nKey.SETTINGS$MAXIMUM_BUDGET_USD)}
min={1}
@@ -267,7 +267,7 @@ function AppSettingsScreen() {
name="git-user-name-input"
type="text"
label={t(I18nKey.SETTINGS$GIT_USERNAME)}
defaultValue={settings.GIT_USER_NAME || ""}
defaultValue={settings.git_user_name || ""}
onChange={checkIfGitUserNameHasChanged}
placeholder="Username for git commits"
className="w-full max-w-[680px]"
@@ -277,7 +277,7 @@ function AppSettingsScreen() {
name="git-user-email-input"
type="email"
label={t(I18nKey.SETTINGS$GIT_EMAIL)}
defaultValue={settings.GIT_USER_EMAIL || ""}
defaultValue={settings.git_user_email || ""}
onChange={checkIfGitUserEmailHasChanged}
placeholder="Email for git commits"
className="w-full max-w-[680px]"

View File

@@ -50,10 +50,10 @@ function GitSettingsScreen() {
const [azureDevOpsHostInputHasValue, setAzureDevOpsHostInputHasValue] =
React.useState(false);
const existingGithubHost = settings?.PROVIDER_TOKENS_SET.github;
const existingGitlabHost = settings?.PROVIDER_TOKENS_SET.gitlab;
const existingBitbucketHost = settings?.PROVIDER_TOKENS_SET.bitbucket;
const existingAzureDevOpsHost = settings?.PROVIDER_TOKENS_SET.azure_devops;
const existingGithubHost = settings?.provider_tokens_set.github;
const existingGitlabHost = settings?.provider_tokens_set.gitlab;
const existingBitbucketHost = settings?.provider_tokens_set.bitbucket;
const existingAzureDevOpsHost = settings?.provider_tokens_set.azure_devops;
const isSaas = config?.APP_MODE === "saas";
const isGitHubTokenSet = providers.includes("github");

View File

@@ -91,15 +91,15 @@ function LlmSettingsScreen() {
// Track confirmation mode state to control security analyzer visibility
const [confirmationModeEnabled, setConfirmationModeEnabled] = React.useState(
settings?.CONFIRMATION_MODE ?? DEFAULT_SETTINGS.CONFIRMATION_MODE,
settings?.confirmation_mode ?? DEFAULT_SETTINGS.confirmation_mode,
);
// Track selected security analyzer for form submission
const [selectedSecurityAnalyzer, setSelectedSecurityAnalyzer] =
React.useState(
settings?.SECURITY_ANALYZER === null
settings?.security_analyzer === null
? "none"
: (settings?.SECURITY_ANALYZER ?? DEFAULT_SETTINGS.SECURITY_ANALYZER),
: (settings?.security_analyzer ?? DEFAULT_SETTINGS.security_analyzer),
);
const [selectedProvider, setSelectedProvider] = React.useState<string | null>(
@@ -111,7 +111,7 @@ function LlmSettingsScreen() {
);
// Determine if we should hide the API key input and use OpenHands-managed key (when using OpenHands provider in SaaS mode)
const currentModel = currentSelectedModel || settings?.LLM_MODEL;
const currentModel = currentSelectedModel || settings?.llm_model;
const isSaasMode = config?.APP_MODE === "saas";
@@ -124,7 +124,7 @@ function LlmSettingsScreen() {
if (dirtyInputs.model) {
return currentModel?.startsWith("openhands/");
}
return settings?.LLM_MODEL?.startsWith("openhands/");
return settings?.llm_model?.startsWith("openhands/");
}
return false;
@@ -133,13 +133,13 @@ function LlmSettingsScreen() {
const shouldUseOpenHandsKey = isOpenHandsProvider() && isSaasMode;
// Determine if we should hide the agent dropdown when V1 conversation API is enabled
const isV1Enabled = settings?.V1_ENABLED;
const isV1Enabled = settings?.v1_enabled;
React.useEffect(() => {
const determineWhetherToToggleAdvancedSettings = () => {
if (resources && settings) {
return (
isCustomModel(resources.models, settings.LLM_MODEL) ||
isCustomModel(resources.models, settings.llm_model) ||
hasAdvancedSettingsSet({
...settings,
})
@@ -157,24 +157,24 @@ function LlmSettingsScreen() {
// Initialize currentSelectedModel with the current settings
React.useEffect(() => {
if (settings?.LLM_MODEL) {
setCurrentSelectedModel(settings.LLM_MODEL);
if (settings?.llm_model) {
setCurrentSelectedModel(settings.llm_model);
}
}, [settings?.LLM_MODEL]);
}, [settings?.llm_model]);
// Update confirmation mode state when settings change
React.useEffect(() => {
if (settings?.CONFIRMATION_MODE !== undefined) {
setConfirmationModeEnabled(settings.CONFIRMATION_MODE);
if (settings?.confirmation_mode !== undefined) {
setConfirmationModeEnabled(settings.confirmation_mode);
}
}, [settings?.CONFIRMATION_MODE]);
}, [settings?.confirmation_mode]);
// Update selected security analyzer state when settings change
React.useEffect(() => {
if (settings?.SECURITY_ANALYZER !== undefined) {
setSelectedSecurityAnalyzer(settings.SECURITY_ANALYZER || "none");
if (settings?.security_analyzer !== undefined) {
setSelectedSecurityAnalyzer(settings.security_analyzer || "none");
}
}, [settings?.SECURITY_ANALYZER]);
}, [settings?.security_analyzer]);
// Handle URL parameters for SaaS subscription redirects
React.useEffect(() => {
@@ -230,19 +230,19 @@ function LlmSettingsScreen() {
saveSettings(
{
LLM_MODEL: fullLlmModel,
llm_model: fullLlmModel,
llm_api_key: finalApiKey || null,
SEARCH_API_KEY: searchApiKey || "",
CONFIRMATION_MODE: confirmationMode,
SECURITY_ANALYZER:
search_api_key: searchApiKey || "",
confirmation_mode: confirmationMode,
security_analyzer:
securityAnalyzer === "none"
? null
: securityAnalyzer || DEFAULT_SETTINGS.SECURITY_ANALYZER,
: securityAnalyzer || DEFAULT_SETTINGS.security_analyzer,
// reset advanced settings
LLM_BASE_URL: DEFAULT_SETTINGS.LLM_BASE_URL,
AGENT: DEFAULT_SETTINGS.AGENT,
ENABLE_DEFAULT_CONDENSER: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER,
llm_base_url: DEFAULT_SETTINGS.llm_base_url,
agent: DEFAULT_SETTINGS.agent,
enable_default_condenser: DEFAULT_SETTINGS.enable_default_condenser,
},
{
onSuccess: handleSuccessfulMutation,
@@ -281,19 +281,19 @@ function LlmSettingsScreen() {
saveSettings(
{
LLM_MODEL: model,
LLM_BASE_URL: baseUrl,
llm_model: model,
llm_base_url: baseUrl,
llm_api_key: finalApiKey || null,
SEARCH_API_KEY: searchApiKey || "",
AGENT: agent,
CONFIRMATION_MODE: confirmationMode,
ENABLE_DEFAULT_CONDENSER: enableDefaultCondenser,
CONDENSER_MAX_SIZE:
condenserMaxSize ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE,
SECURITY_ANALYZER:
search_api_key: searchApiKey || "",
agent,
confirmation_mode: confirmationMode,
enable_default_condenser: enableDefaultCondenser,
condenser_max_size:
condenserMaxSize ?? DEFAULT_SETTINGS.condenser_max_size,
security_analyzer:
securityAnalyzer === "none"
? null
: securityAnalyzer || DEFAULT_SETTINGS.SECURITY_ANALYZER,
: securityAnalyzer || DEFAULT_SETTINGS.security_analyzer,
},
{
onSuccess: handleSuccessfulMutation,
@@ -323,7 +323,7 @@ function LlmSettingsScreen() {
) => {
// openai providers are special case; see ModelSelector
// component for details
const modelIsDirty = model !== settings?.LLM_MODEL.replace("openai/", "");
const modelIsDirty = model !== settings?.llm_model.replace("openai/", "");
setDirtyInputs((prev) => ({
...prev,
model: modelIsDirty,
@@ -351,7 +351,7 @@ function LlmSettingsScreen() {
};
const handleSearchApiKeyIsDirty = (searchApiKey: string) => {
const searchApiKeyIsDirty = searchApiKey !== settings?.SEARCH_API_KEY;
const searchApiKeyIsDirty = searchApiKey !== settings?.search_api_key;
setDirtyInputs((prev) => ({
...prev,
searchApiKey: searchApiKeyIsDirty,
@@ -359,7 +359,7 @@ function LlmSettingsScreen() {
};
const handleCustomModelIsDirty = (model: string) => {
const modelIsDirty = model !== settings?.LLM_MODEL && model !== "";
const modelIsDirty = model !== settings?.llm_model && model !== "";
setDirtyInputs((prev) => ({
...prev,
model: modelIsDirty,
@@ -370,7 +370,7 @@ function LlmSettingsScreen() {
};
const handleBaseUrlIsDirty = (baseUrl: string) => {
const baseUrlIsDirty = baseUrl !== settings?.LLM_BASE_URL;
const baseUrlIsDirty = baseUrl !== settings?.llm_base_url;
setDirtyInputs((prev) => ({
...prev,
baseUrl: baseUrlIsDirty,
@@ -378,7 +378,7 @@ function LlmSettingsScreen() {
};
const handleAgentIsDirty = (agent: string) => {
const agentIsDirty = agent !== settings?.AGENT && agent !== "";
const agentIsDirty = agent !== settings?.agent && agent !== "";
setDirtyInputs((prev) => ({
...prev,
agent: agentIsDirty,
@@ -386,7 +386,7 @@ function LlmSettingsScreen() {
};
const handleConfirmationModeIsDirty = (isToggled: boolean) => {
const confirmationModeIsDirty = isToggled !== settings?.CONFIRMATION_MODE;
const confirmationModeIsDirty = isToggled !== settings?.confirmation_mode;
setDirtyInputs((prev) => ({
...prev,
confirmationMode: confirmationModeIsDirty,
@@ -395,7 +395,7 @@ function LlmSettingsScreen() {
// When confirmation mode is enabled, set default security analyzer to "llm" if not already set
if (isToggled && !selectedSecurityAnalyzer) {
setSelectedSecurityAnalyzer(DEFAULT_SETTINGS.SECURITY_ANALYZER);
setSelectedSecurityAnalyzer(DEFAULT_SETTINGS.security_analyzer);
setDirtyInputs((prev) => ({
...prev,
securityAnalyzer: true,
@@ -405,7 +405,7 @@ function LlmSettingsScreen() {
const handleEnableDefaultCondenserIsDirty = (isToggled: boolean) => {
const enableDefaultCondenserIsDirty =
isToggled !== settings?.ENABLE_DEFAULT_CONDENSER;
isToggled !== settings?.enable_default_condenser;
setDirtyInputs((prev) => ({
...prev,
enableDefaultCondenser: enableDefaultCondenserIsDirty,
@@ -416,8 +416,8 @@ function LlmSettingsScreen() {
const parsed = value ? Number.parseInt(value, 10) : undefined;
const bounded = parsed !== undefined ? Math.max(20, parsed) : undefined;
const condenserMaxSizeIsDirty =
(bounded ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE) !==
(settings?.CONDENSER_MAX_SIZE ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE);
(bounded ?? DEFAULT_SETTINGS.condenser_max_size) !==
(settings?.condenser_max_size ?? DEFAULT_SETTINGS.condenser_max_size);
setDirtyInputs((prev) => ({
...prev,
condenserMaxSize: condenserMaxSizeIsDirty,
@@ -426,7 +426,7 @@ function LlmSettingsScreen() {
const handleSecurityAnalyzerIsDirty = (securityAnalyzer: string) => {
const securityAnalyzerIsDirty =
securityAnalyzer !== settings?.SECURITY_ANALYZER;
securityAnalyzer !== settings?.security_analyzer;
setDirtyInputs((prev) => ({
...prev,
securityAnalyzer: securityAnalyzerIsDirty,
@@ -512,12 +512,12 @@ function LlmSettingsScreen() {
<>
<ModelSelector
models={modelsAndProviders}
currentModel={settings.LLM_MODEL || DEFAULT_OPENHANDS_MODEL}
currentModel={settings.llm_model || DEFAULT_OPENHANDS_MODEL}
onChange={handleModelIsDirty}
onDefaultValuesChanged={onDefaultValuesChanged}
wrapperClassName="!flex-col !gap-6"
/>
{(settings.LLM_MODEL?.startsWith("openhands/") ||
{(settings.llm_model?.startsWith("openhands/") ||
currentSelectedModel?.startsWith("openhands/")) && (
<OpenHandsApiKeyHelp testId="openhands-api-key-help" />
)}
@@ -532,11 +532,11 @@ function LlmSettingsScreen() {
label={t(I18nKey.SETTINGS_FORM$API_KEY)}
type="password"
className="w-full max-w-[680px]"
placeholder={settings.LLM_API_KEY_SET ? "<hidden>" : ""}
placeholder={settings.llm_api_key_set ? "<hidden>" : ""}
onChange={handleApiKeyIsDirty}
startContent={
settings.LLM_API_KEY_SET && (
<KeyStatusIcon isSet={settings.LLM_API_KEY_SET} />
settings.llm_api_key_set && (
<KeyStatusIcon isSet={settings.llm_api_key_set} />
)
}
/>
@@ -561,13 +561,13 @@ function LlmSettingsScreen() {
testId="llm-custom-model-input"
name="llm-custom-model-input"
label={t(I18nKey.SETTINGS$CUSTOM_MODEL)}
defaultValue={settings.LLM_MODEL || DEFAULT_OPENHANDS_MODEL}
defaultValue={settings.llm_model || DEFAULT_OPENHANDS_MODEL}
placeholder={DEFAULT_OPENHANDS_MODEL}
type="text"
className="w-full max-w-[680px]"
onChange={handleCustomModelIsDirty}
/>
{(settings.LLM_MODEL?.startsWith("openhands/") ||
{(settings.llm_model?.startsWith("openhands/") ||
currentSelectedModel?.startsWith("openhands/")) && (
<OpenHandsApiKeyHelp testId="openhands-api-key-help-2" />
)}
@@ -576,7 +576,7 @@ function LlmSettingsScreen() {
testId="base-url-input"
name="base-url-input"
label={t(I18nKey.SETTINGS$BASE_URL)}
defaultValue={settings.LLM_BASE_URL}
defaultValue={settings.llm_base_url}
placeholder="https://api.openai.com"
type="text"
className="w-full max-w-[680px]"
@@ -591,11 +591,11 @@ function LlmSettingsScreen() {
label={t(I18nKey.SETTINGS_FORM$API_KEY)}
type="password"
className="w-full max-w-[680px]"
placeholder={settings.LLM_API_KEY_SET ? "<hidden>" : ""}
placeholder={settings.llm_api_key_set ? "<hidden>" : ""}
onChange={handleApiKeyIsDirty}
startContent={
settings.LLM_API_KEY_SET && (
<KeyStatusIcon isSet={settings.LLM_API_KEY_SET} />
settings.llm_api_key_set && (
<KeyStatusIcon isSet={settings.llm_api_key_set} />
)
}
/>
@@ -616,12 +616,12 @@ function LlmSettingsScreen() {
label={t(I18nKey.SETTINGS$SEARCH_API_KEY)}
type="password"
className="w-full max-w-[680px]"
defaultValue={settings.SEARCH_API_KEY || ""}
defaultValue={settings.search_api_key || ""}
onChange={handleSearchApiKeyIsDirty}
placeholder={t(I18nKey.API$TVLY_KEY_EXAMPLE)}
startContent={
settings.SEARCH_API_KEY_SET && (
<KeyStatusIcon isSet={settings.SEARCH_API_KEY_SET} />
settings.search_api_key_set && (
<KeyStatusIcon isSet={settings.search_api_key_set} />
)
}
/>
@@ -644,7 +644,7 @@ function LlmSettingsScreen() {
label: agent, // TODO: Add i18n support for agent names
})) || []
}
defaultSelectedKey={settings.AGENT}
defaultSelectedKey={settings.agent}
isClearable={false}
onInputChange={handleAgentIsDirty}
wrapperClassName="w-full max-w-[680px]"
@@ -662,11 +662,11 @@ function LlmSettingsScreen() {
step={1}
label={t(I18nKey.SETTINGS$CONDENSER_MAX_SIZE)}
defaultValue={(
settings.CONDENSER_MAX_SIZE ??
DEFAULT_SETTINGS.CONDENSER_MAX_SIZE
settings.condenser_max_size ??
DEFAULT_SETTINGS.condenser_max_size
)?.toString()}
onChange={(value) => handleCondenserMaxSizeIsDirty(value)}
isDisabled={!settings.ENABLE_DEFAULT_CONDENSER}
isDisabled={!settings.enable_default_condenser}
/>
<p className="text-xs text-tertiary-alt mt-1">
{t(I18nKey.SETTINGS$CONDENSER_MAX_SIZE_TOOLTIP)}
@@ -676,7 +676,7 @@ function LlmSettingsScreen() {
<SettingsSwitch
testId="enable-memory-condenser-switch"
name="enable-memory-condenser-switch"
defaultIsToggled={settings.ENABLE_DEFAULT_CONDENSER}
defaultIsToggled={settings.enable_default_condenser}
onToggle={handleEnableDefaultCondenserIsDirty}
>
{t(I18nKey.SETTINGS$ENABLE_MEMORY_CONDENSATION)}
@@ -688,7 +688,7 @@ function LlmSettingsScreen() {
testId="enable-confirmation-mode-switch"
name="enable-confirmation-mode-switch"
onToggle={handleConfirmationModeIsDirty}
defaultIsToggled={settings.CONFIRMATION_MODE}
defaultIsToggled={settings.confirmation_mode}
isBeta
>
{t(I18nKey.SETTINGS$CONFIRMATION_MODE)}

View File

@@ -41,7 +41,7 @@ function MCPSettingsScreen() {
useState(false);
const [serverToDelete, setServerToDelete] = useState<string | null>(null);
const mcpConfig: MCPConfig = settings?.MCP_CONFIG || {
const mcpConfig: MCPConfig = settings?.mcp_config || {
sse_servers: [],
stdio_servers: [],
shttp_servers: [],

View File

@@ -106,16 +106,16 @@ export default function MainApp() {
React.useEffect(() => {
// Don't change language when on TOS page
if (!isOnTosPage && settings?.LANGUAGE) {
i18n.changeLanguage(settings.LANGUAGE);
if (!isOnTosPage && settings?.language) {
i18n.changeLanguage(settings.language);
}
}, [settings?.LANGUAGE, isOnTosPage]);
}, [settings?.language, isOnTosPage]);
React.useEffect(() => {
// Don't show consent form when on TOS page
if (!isOnTosPage) {
const consentFormModalIsOpen =
settings?.USER_CONSENTS_TO_ANALYTICS === null;
settings?.user_consents_to_analytics === null;
setConsentFormIsOpen(consentFormModalIsOpen);
}
@@ -134,10 +134,10 @@ export default function MainApp() {
}, [isOnTosPage]);
React.useEffect(() => {
if (settings?.IS_NEW_USER && config.data?.APP_MODE === "saas") {
if (settings?.is_new_user && config.data?.APP_MODE === "saas") {
displaySuccessToast(t(I18nKey.BILLING$YOURE_IN));
}
}, [settings?.IS_NEW_USER, config.data?.APP_MODE]);
}, [settings?.is_new_user, config.data?.APP_MODE]);
React.useEffect(() => {
// Don't do any redirects when on TOS page
@@ -249,7 +249,7 @@ export default function MainApp() {
{config.data?.FEATURE_FLAGS.ENABLE_BILLING &&
config.data?.APP_MODE === "saas" &&
settings?.IS_NEW_USER && <SetupPaymentModal />}
settings?.is_new_user && <SetupPaymentModal />}
</div>
);
}

View File

@@ -1,14 +1,13 @@
import { useMemo } from "react";
import { Outlet, redirect, useLocation } from "react-router";
import { useTranslation } from "react-i18next";
import { useConfig } from "#/hooks/query/use-config";
import { Route } from "./+types/settings";
import OptionService from "#/api/option-service/option-service.api";
import { queryClient } from "#/query-client-config";
import { GetConfigResponse } from "#/api/option-service/option.types";
import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav";
import { Typography } from "#/ui/typography";
import { SettingsLayout } from "#/components/features/settings/settings-layout";
import { Typography } from "#/ui/typography";
import { useSettingsNavItems } from "#/hooks/use-settings-nav-items";
const SAAS_ONLY_PATHS = [
"/settings/user",
@@ -33,14 +32,10 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
// if in OSS mode, do not allow access to saas-only paths
return redirect("/settings");
}
// If LLM settings are hidden and user tries to access the LLM settings page
if (config?.FEATURE_FLAGS?.HIDE_LLM_SETTINGS && pathname === "/settings") {
// Redirect to the first available settings page
if (isSaas) {
return redirect("/settings/user");
}
return redirect("/settings/mcp");
return isSaas ? redirect("/settings/user") : redirect("/settings/mcp");
}
return null;
@@ -48,37 +43,15 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
function SettingsScreen() {
const { t } = useTranslation();
const { data: config } = useConfig();
const location = useLocation();
const isSaas = config?.APP_MODE === "saas";
// Navigation items configuration
const navItems = useMemo(() => {
const items = [];
if (isSaas) {
items.push(...SAAS_NAV_ITEMS);
} else {
items.push(...OSS_NAV_ITEMS);
}
// Filter out LLM settings if the feature flag is enabled
if (config?.FEATURE_FLAGS?.HIDE_LLM_SETTINGS) {
return items.filter((item) => item.to !== "/settings");
}
return items;
}, [isSaas, config?.FEATURE_FLAGS?.HIDE_LLM_SETTINGS]);
const navItems = useSettingsNavItems();
// Current section title for the main content area
const currentSectionTitle = useMemo(() => {
const currentItem = navItems.find((item) => item.to === location.pathname);
if (currentItem) {
return currentItem.text;
}
// Default to the first available navigation item if current page is not found
return navItems.length > 0 ? navItems[0].text : "SETTINGS$TITLE";
return currentItem
? currentItem.text
: (navItems[0]?.text ?? "SETTINGS$TITLE");
}, [navItems, location.pathname]);
return (

View File

@@ -122,12 +122,12 @@ function UserSettingsScreen() {
const prevVerificationStatusRef = useRef<boolean | undefined>(undefined);
useEffect(() => {
if (settings?.EMAIL) {
setEmail(settings.EMAIL);
setOriginalEmail(settings.EMAIL);
setIsEmailValid(EMAIL_REGEX.test(settings.EMAIL));
if (settings?.email) {
setEmail(settings.email);
setOriginalEmail(settings.email);
setIsEmailValid(EMAIL_REGEX.test(settings.email));
}
}, [settings?.EMAIL]);
}, [settings?.email]);
useEffect(() => {
if (pollingIntervalRef.current) {
@@ -137,7 +137,7 @@ function UserSettingsScreen() {
if (
prevVerificationStatusRef.current === false &&
settings?.EMAIL_VERIFIED === true
settings?.email_verified === true
) {
// Display toast notification instead of setting state
displaySuccessToast(t("SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY"));
@@ -146,9 +146,9 @@ function UserSettingsScreen() {
}, 2000);
}
prevVerificationStatusRef.current = settings?.EMAIL_VERIFIED;
prevVerificationStatusRef.current = settings?.email_verified;
if (settings?.EMAIL_VERIFIED === false) {
if (settings?.email_verified === false) {
pollingIntervalRef.current = window.setInterval(() => {
refetch();
}, 5000);
@@ -160,7 +160,7 @@ function UserSettingsScreen() {
pollingIntervalRef.current = null;
}
};
}, [settings?.EMAIL_VERIFIED, refetch, queryClient, t]);
}, [settings?.email_verified, refetch, queryClient, t]);
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newEmail = e.target.value;
@@ -215,10 +215,10 @@ function UserSettingsScreen() {
isSaving={isSaving}
isResendingVerification={isResendingVerification}
isEmailChanged={isEmailChanged}
emailVerified={settings?.EMAIL_VERIFIED}
emailVerified={settings?.email_verified}
isEmailValid={isEmailValid}
>
{settings?.EMAIL_VERIFIED === false && <VerificationAlert />}
{settings?.email_verified === false && <VerificationAlert />}
</EmailInputSection>
)}
</div>

View File

@@ -3,35 +3,36 @@ import { Settings } from "#/types/settings";
export const LATEST_SETTINGS_VERSION = 5;
export const DEFAULT_SETTINGS: Settings = {
LLM_MODEL: "openhands/claude-sonnet-4-20250514",
LLM_BASE_URL: "",
AGENT: "CodeActAgent",
LANGUAGE: "en",
LLM_API_KEY_SET: false,
SEARCH_API_KEY_SET: false,
CONFIRMATION_MODE: false,
SECURITY_ANALYZER: "llm",
REMOTE_RUNTIME_RESOURCE_FACTOR: 1,
PROVIDER_TOKENS_SET: {},
ENABLE_DEFAULT_CONDENSER: true,
CONDENSER_MAX_SIZE: 120,
ENABLE_SOUND_NOTIFICATIONS: false,
USER_CONSENTS_TO_ANALYTICS: false,
ENABLE_PROACTIVE_CONVERSATION_STARTERS: false,
ENABLE_SOLVABILITY_ANALYSIS: false,
SEARCH_API_KEY: "",
IS_NEW_USER: true,
MAX_BUDGET_PER_TASK: null,
EMAIL: "",
EMAIL_VERIFIED: true, // Default to true to avoid restricting access unnecessarily
MCP_CONFIG: {
llm_model: "openhands/claude-sonnet-4-20250514",
llm_base_url: "",
agent: "CodeActAgent",
language: "en",
llm_api_key: null,
llm_api_key_set: false,
search_api_key_set: false,
confirmation_mode: false,
security_analyzer: "llm",
remote_runtime_resource_factor: 1,
provider_tokens_set: {},
enable_default_condenser: true,
condenser_max_size: 120,
enable_sound_notifications: false,
user_consents_to_analytics: false,
enable_proactive_conversation_starters: false,
enable_solvability_analysis: false,
search_api_key: "",
is_new_user: true,
max_budget_per_task: null,
email: "",
email_verified: true, // Default to true to avoid restricting access unnecessarily
mcp_config: {
sse_servers: [],
stdio_servers: [],
shttp_servers: [],
},
GIT_USER_NAME: "openhands",
GIT_USER_EMAIL: "openhands@all-hands.dev",
V1_ENABLED: false,
git_user_name: "openhands",
git_user_email: "openhands@all-hands.dev",
v1_enabled: false,
};
/**

View File

@@ -38,37 +38,31 @@ export type MCPConfig = {
};
export type Settings = {
LLM_MODEL: string;
LLM_BASE_URL: string;
AGENT: string;
LANGUAGE: string;
LLM_API_KEY_SET: boolean;
SEARCH_API_KEY_SET: boolean;
CONFIRMATION_MODE: boolean;
SECURITY_ANALYZER: string | null;
REMOTE_RUNTIME_RESOURCE_FACTOR: number | null;
PROVIDER_TOKENS_SET: Partial<Record<Provider, string | null>>;
ENABLE_DEFAULT_CONDENSER: boolean;
llm_model: string;
llm_base_url: string;
agent: string;
language: string;
llm_api_key: string | null;
llm_api_key_set: boolean;
search_api_key_set: boolean;
confirmation_mode: boolean;
security_analyzer: string | null;
remote_runtime_resource_factor: number | null;
provider_tokens_set: Partial<Record<Provider, string | null>>;
enable_default_condenser: boolean;
// Maximum number of events before the condenser runs
CONDENSER_MAX_SIZE: number | null;
ENABLE_SOUND_NOTIFICATIONS: boolean;
ENABLE_PROACTIVE_CONVERSATION_STARTERS: boolean;
ENABLE_SOLVABILITY_ANALYSIS: boolean;
USER_CONSENTS_TO_ANALYTICS: boolean | null;
SEARCH_API_KEY?: string;
IS_NEW_USER?: boolean;
MCP_CONFIG?: MCPConfig;
MAX_BUDGET_PER_TASK: number | null;
EMAIL?: string;
EMAIL_VERIFIED?: boolean;
GIT_USER_NAME?: string;
GIT_USER_EMAIL?: string;
V1_ENABLED?: boolean;
};
export type PostSettings = Settings & {
condenser_max_size: number | null;
enable_sound_notifications: boolean;
enable_proactive_conversation_starters: boolean;
enable_solvability_analysis: boolean;
user_consents_to_analytics: boolean | null;
llm_api_key?: string | null;
search_api_key?: string;
is_new_user?: boolean;
mcp_config?: MCPConfig;
max_budget_per_task: number | null;
email?: string;
email_verified?: boolean;
git_user_name?: string;
git_user_email?: string;
v1_enabled?: boolean;
};

View File

@@ -67,10 +67,10 @@ describe("extractSettings", () => {
// Verify that the model name case is preserved
const expectedModel = `${provider}/${model}`;
expect(settings.LLM_MODEL).toBe(expectedModel);
expect(settings.llm_model).toBe(expectedModel);
// Only test that it's not lowercased if the original has uppercase letters
if (expectedModel !== expectedModel.toLowerCase()) {
expect(settings.LLM_MODEL).not.toBe(expectedModel.toLowerCase());
expect(settings.llm_model).not.toBe(expectedModel.toLowerCase());
}
});
});
@@ -85,7 +85,7 @@ describe("extractSettings", () => {
const settings = extractSettings(formData);
// Custom model should take precedence and preserve case
expect(settings.LLM_MODEL).toBe("Custom-Model-Name");
expect(settings.LLM_MODEL).not.toBe("custom-model-name");
expect(settings.llm_model).toBe("Custom-Model-Name");
expect(settings.llm_model).not.toBe("custom-model-name");
});
});

View File

@@ -3,4 +3,4 @@ import { Settings } from "#/types/settings";
export const hasAdvancedSettingsSet = (settings: Partial<Settings>): boolean =>
Object.keys(settings).length > 0 &&
(!!settings.LLM_BASE_URL || settings.AGENT !== DEFAULT_SETTINGS.AGENT);
(!!settings.llm_base_url || settings.agent !== DEFAULT_SETTINGS.agent);

View File

@@ -67,9 +67,7 @@ export const parseMaxBudgetPerTask = (value: string): number | null => {
: null;
};
export const extractSettings = (
formData: FormData,
): Partial<Settings> & { llm_api_key?: string | null } => {
export const extractSettings = (formData: FormData): Partial<Settings> => {
const { LLM_MODEL, LLM_API_KEY, AGENT, LANGUAGE } =
extractBasicFormData(formData);
@@ -82,14 +80,14 @@ export const extractSettings = (
} = extractAdvancedFormData(formData);
return {
LLM_MODEL: CUSTOM_LLM_MODEL || LLM_MODEL,
LLM_API_KEY_SET: !!LLM_API_KEY,
AGENT,
LANGUAGE,
LLM_BASE_URL,
CONFIRMATION_MODE,
SECURITY_ANALYZER,
ENABLE_DEFAULT_CONDENSER,
llm_model: CUSTOM_LLM_MODEL || LLM_MODEL,
llm_api_key_set: !!LLM_API_KEY,
agent: AGENT,
language: LANGUAGE,
llm_base_url: LLM_BASE_URL,
confirmation_mode: CONFIRMATION_MODE,
security_analyzer: SECURITY_ANALYZER,
enable_default_condenser: ENABLE_DEFAULT_CONDENSER,
llm_api_key: LLM_API_KEY,
};
};

View File

@@ -4,7 +4,11 @@ import tempfile
from abc import ABC
from dataclasses import dataclass
from pathlib import Path
from typing import AsyncGenerator
from typing import TYPE_CHECKING, AsyncGenerator
from uuid import UUID
if TYPE_CHECKING:
import httpx
import base62
@@ -29,6 +33,14 @@ from openhands.sdk.context.agent_context import AgentContext
from openhands.sdk.context.condenser import LLMSummarizingCondenser
from openhands.sdk.context.skills import load_user_skills
from openhands.sdk.llm import LLM
from openhands.sdk.security.analyzer import SecurityAnalyzerBase
from openhands.sdk.security.confirmation_policy import (
AlwaysConfirm,
ConfirmationPolicyBase,
ConfirmRisky,
NeverConfirm,
)
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
_logger = logging.getLogger(__name__)
@@ -379,3 +391,95 @@ class AppConversationServiceBase(AppConversationService, ABC):
condenser = LLMSummarizingCondenser(**condenser_kwargs)
return condenser
def _create_security_analyzer_from_string(
self, security_analyzer_str: str | None
) -> SecurityAnalyzerBase | None:
"""Convert security analyzer string from settings to SecurityAnalyzerBase instance.
Args:
security_analyzer_str: String value from settings. Valid values:
- "llm" -> LLMSecurityAnalyzer
- "none" or None -> None
- Other values -> None (unsupported analyzers are ignored)
Returns:
SecurityAnalyzerBase instance or None
"""
if not security_analyzer_str or security_analyzer_str.lower() == 'none':
return None
if security_analyzer_str.lower() == 'llm':
return LLMSecurityAnalyzer()
# For unknown values, log a warning and return None
_logger.warning(
f'Unknown security analyzer value: {security_analyzer_str}. '
'Supported values: "llm", "none". Defaulting to None.'
)
return None
def _select_confirmation_policy(
self, confirmation_mode: bool, security_analyzer: str | None
) -> ConfirmationPolicyBase:
"""Choose confirmation policy using only mode flag and analyzer string."""
if not confirmation_mode:
return NeverConfirm()
analyzer_kind = (security_analyzer or '').lower()
if analyzer_kind == 'llm':
return ConfirmRisky()
return AlwaysConfirm()
async def _set_security_analyzer_from_settings(
self,
agent_server_url: str,
session_api_key: str | None,
conversation_id: UUID,
security_analyzer_str: str | None,
httpx_client: 'httpx.AsyncClient',
) -> None:
"""Set security analyzer on conversation using only the analyzer string.
Args:
agent_server_url: URL of the agent server
session_api_key: Session API key for authentication
conversation_id: ID of the conversation to update
security_analyzer_str: String value from settings
httpx_client: HTTP client for making API requests
"""
if session_api_key is None:
return
security_analyzer = self._create_security_analyzer_from_string(
security_analyzer_str
)
# Only make API call if we have a security analyzer to set
# (None is the default, so we can skip the call if it's None)
if security_analyzer is None:
return
try:
# Prepare the request payload
payload = {'security_analyzer': security_analyzer.model_dump()}
# Call agent server API to set security analyzer
response = await httpx_client.post(
f'{agent_server_url}/api/conversations/{conversation_id}/security_analyzer',
json=payload,
headers={'X-Session-API-Key': session_api_key},
timeout=30.0,
)
response.raise_for_status()
_logger.info(
f'Successfully set security analyzer for conversation {conversation_id}'
)
except Exception as e:
# Log error but don't fail conversation creation
_logger.warning(
f'Failed to set security analyzer for conversation {conversation_id}: {e}',
exc_info=True,
)

View File

@@ -13,7 +13,6 @@ from pydantic import Field, SecretStr, TypeAdapter
from openhands.agent_server.models import (
ConversationInfo,
NeverConfirm,
SendMessageRequest,
StartConversationRequest,
)
@@ -73,7 +72,6 @@ from openhands.integrations.provider import ProviderType
from openhands.sdk import Agent, AgentContext, LocalWorkspace
from openhands.sdk.llm import LLM
from openhands.sdk.secret import LookupSecret, StaticSecret
from openhands.sdk.security.confirmation_policy import AlwaysConfirm
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
from openhands.server.types import AppMode
from openhands.tools.preset.default import (
@@ -320,6 +318,16 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
)
)
# Set security analyzer from settings
user = await self.user_context.get_user_info()
await self._set_security_analyzer_from_settings(
agent_server_url,
sandbox.session_api_key,
info.id,
user.security_analyzer,
self.httpx_client,
)
# Update the start task
task.status = AppConversationStartTaskStatus.READY
task.app_conversation_id = info.id
@@ -884,8 +892,8 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
conversation_id=conversation_id,
agent=agent,
workspace=workspace,
confirmation_policy=(
AlwaysConfirm() if user.confirmation_mode else NeverConfirm()
confirmation_policy=self._select_confirmation_policy(
bool(user.confirmation_mode), user.security_analyzer
),
initial_message=initial_message,
secrets=secrets,

View File

@@ -1,11 +1,13 @@
"""Unit tests for git functionality in AppConversationServiceBase.
"""Unit tests for git and security functionality in AppConversationServiceBase.
This module tests the git-related functionality, specifically the clone_or_init_git_repo method
and the recent bug fixes for git checkout operations.
"""
import subprocess
from types import MethodType
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from uuid import uuid4
import pytest
@@ -434,13 +436,298 @@ def test_create_condenser_plan_agent_with_custom_max_size(mock_condenser_class):
mock_llm.model_copy.assert_called_once()
# =============================================================================
# Tests for security analyzer helpers
# =============================================================================
@pytest.mark.parametrize('value', [None, '', 'none', 'NoNe'])
def test_create_security_analyzer_returns_none_for_empty_values(value):
"""_create_security_analyzer_from_string returns None for empty/none values."""
# Arrange
service, _ = _create_service_with_mock_user_context(
MockUserInfo(), bind_methods=('_create_security_analyzer_from_string',)
)
# Act
result = service._create_security_analyzer_from_string(value)
# Assert
assert result is None
def test_create_security_analyzer_returns_llm_analyzer():
"""_create_security_analyzer_from_string returns LLMSecurityAnalyzer for llm string."""
# Arrange
security_analyzer_str = 'llm'
service, _ = _create_service_with_mock_user_context(
MockUserInfo(), bind_methods=('_create_security_analyzer_from_string',)
)
# Act
result = service._create_security_analyzer_from_string(security_analyzer_str)
# Assert
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
assert isinstance(result, LLMSecurityAnalyzer)
def test_create_security_analyzer_logs_warning_for_unknown_value():
"""_create_security_analyzer_from_string logs warning and returns None for unknown."""
# Arrange
unknown_value = 'custom'
service, _ = _create_service_with_mock_user_context(
MockUserInfo(), bind_methods=('_create_security_analyzer_from_string',)
)
# Act
with patch(
'openhands.app_server.app_conversation.app_conversation_service_base._logger'
) as mock_logger:
result = service._create_security_analyzer_from_string(unknown_value)
# Assert
assert result is None
mock_logger.warning.assert_called_once()
def test_select_confirmation_policy_when_disabled_returns_never_confirm():
"""_select_confirmation_policy returns NeverConfirm when confirmation_mode is False."""
# Arrange
confirmation_mode = False
security_analyzer = 'llm'
service, _ = _create_service_with_mock_user_context(
MockUserInfo(), bind_methods=('_select_confirmation_policy',)
)
# Act
policy = service._select_confirmation_policy(confirmation_mode, security_analyzer)
# Assert
from openhands.sdk.security.confirmation_policy import NeverConfirm
assert isinstance(policy, NeverConfirm)
def test_select_confirmation_policy_llm_returns_confirm_risky():
"""_select_confirmation_policy uses ConfirmRisky when analyzer is llm."""
# Arrange
confirmation_mode = True
security_analyzer = 'llm'
service, _ = _create_service_with_mock_user_context(
MockUserInfo(), bind_methods=('_select_confirmation_policy',)
)
# Act
policy = service._select_confirmation_policy(confirmation_mode, security_analyzer)
# Assert
from openhands.sdk.security.confirmation_policy import ConfirmRisky
assert isinstance(policy, ConfirmRisky)
@pytest.mark.parametrize('security_analyzer', [None, '', 'none', 'custom'])
def test_select_confirmation_policy_non_llm_returns_always_confirm(
security_analyzer,
):
"""_select_confirmation_policy falls back to AlwaysConfirm for non-llm values."""
# Arrange
confirmation_mode = True
service, _ = _create_service_with_mock_user_context(
MockUserInfo(), bind_methods=('_select_confirmation_policy',)
)
# Act
policy = service._select_confirmation_policy(confirmation_mode, security_analyzer)
# Assert
from openhands.sdk.security.confirmation_policy import AlwaysConfirm
assert isinstance(policy, AlwaysConfirm)
@pytest.mark.asyncio
async def test_set_security_analyzer_skips_when_no_session_key():
"""_set_security_analyzer_from_settings exits early without session_api_key."""
# Arrange
agent_server_url = 'https://agent.example.com'
conversation_id = uuid4()
httpx_client = AsyncMock()
service, _ = _create_service_with_mock_user_context(
MockUserInfo(),
bind_methods=(
'_create_security_analyzer_from_string',
'_set_security_analyzer_from_settings',
),
)
with patch.object(service, '_create_security_analyzer_from_string') as mock_create:
# Act
await service._set_security_analyzer_from_settings(
agent_server_url=agent_server_url,
session_api_key=None,
conversation_id=conversation_id,
security_analyzer_str='llm',
httpx_client=httpx_client,
)
# Assert
mock_create.assert_not_called()
httpx_client.post.assert_not_called()
@pytest.mark.asyncio
async def test_set_security_analyzer_skips_when_analyzer_none():
"""_set_security_analyzer_from_settings skips API call when analyzer resolves to None."""
# Arrange
agent_server_url = 'https://agent.example.com'
session_api_key = 'session-key'
conversation_id = uuid4()
httpx_client = AsyncMock()
service, _ = _create_service_with_mock_user_context(
MockUserInfo(),
bind_methods=(
'_create_security_analyzer_from_string',
'_set_security_analyzer_from_settings',
),
)
with patch.object(
service, '_create_security_analyzer_from_string', return_value=None
) as mock_create:
# Act
await service._set_security_analyzer_from_settings(
agent_server_url=agent_server_url,
session_api_key=session_api_key,
conversation_id=conversation_id,
security_analyzer_str='none',
httpx_client=httpx_client,
)
# Assert
mock_create.assert_called_once_with('none')
httpx_client.post.assert_not_called()
class DummyAnalyzer:
"""Simple analyzer stub for testing model_dump contract."""
def __init__(self, payload: dict):
self._payload = payload
def model_dump(self) -> dict:
return self._payload
@pytest.mark.asyncio
async def test_set_security_analyzer_successfully_calls_agent_server():
"""_set_security_analyzer_from_settings posts analyzer payload when available."""
# Arrange
agent_server_url = 'https://agent.example.com'
session_api_key = 'session-key'
conversation_id = uuid4()
analyzer_payload = {'type': 'llm'}
httpx_client = AsyncMock()
http_response = MagicMock()
http_response.raise_for_status = MagicMock()
httpx_client.post.return_value = http_response
service, _ = _create_service_with_mock_user_context(
MockUserInfo(),
bind_methods=(
'_create_security_analyzer_from_string',
'_set_security_analyzer_from_settings',
),
)
analyzer = DummyAnalyzer(analyzer_payload)
with (
patch.object(
service,
'_create_security_analyzer_from_string',
return_value=analyzer,
) as mock_create,
patch(
'openhands.app_server.app_conversation.app_conversation_service_base._logger'
) as mock_logger,
):
# Act
await service._set_security_analyzer_from_settings(
agent_server_url=agent_server_url,
session_api_key=session_api_key,
conversation_id=conversation_id,
security_analyzer_str='llm',
httpx_client=httpx_client,
)
# Assert
mock_create.assert_called_once_with('llm')
httpx_client.post.assert_awaited_once_with(
f'{agent_server_url}/api/conversations/{conversation_id}/security_analyzer',
json={'security_analyzer': analyzer_payload},
headers={'X-Session-API-Key': session_api_key},
timeout=30.0,
)
http_response.raise_for_status.assert_called_once()
mock_logger.info.assert_called()
@pytest.mark.asyncio
async def test_set_security_analyzer_logs_warning_on_failure():
"""_set_security_analyzer_from_settings warns but does not raise on errors."""
# Arrange
agent_server_url = 'https://agent.example.com'
session_api_key = 'session-key'
conversation_id = uuid4()
analyzer_payload = {'type': 'llm'}
httpx_client = AsyncMock()
httpx_client.post.side_effect = RuntimeError('network down')
service, _ = _create_service_with_mock_user_context(
MockUserInfo(),
bind_methods=(
'_create_security_analyzer_from_string',
'_set_security_analyzer_from_settings',
),
)
analyzer = DummyAnalyzer(analyzer_payload)
with (
patch.object(
service,
'_create_security_analyzer_from_string',
return_value=analyzer,
) as mock_create,
patch(
'openhands.app_server.app_conversation.app_conversation_service_base._logger'
) as mock_logger,
):
# Act
await service._set_security_analyzer_from_settings(
agent_server_url=agent_server_url,
session_api_key=session_api_key,
conversation_id=conversation_id,
security_analyzer_str='llm',
httpx_client=httpx_client,
)
# Assert
mock_create.assert_called_once_with('llm')
httpx_client.post.assert_awaited_once()
mock_logger.warning.assert_called()
# =============================================================================
# Tests for _configure_git_user_settings
# =============================================================================
def _create_service_with_mock_user_context(user_info: MockUserInfo) -> tuple:
"""Create a mock service with the actual _configure_git_user_settings method.
def _create_service_with_mock_user_context(
user_info: MockUserInfo, bind_methods: tuple[str, ...] | None = None
) -> tuple:
"""Create a mock service with selected real methods bound for testing.
Uses MagicMock for the service but binds the real method for testing.
@@ -452,13 +739,16 @@ def _create_service_with_mock_user_context(user_info: MockUserInfo) -> tuple:
# Create a simple mock service and set required attribute
service = MagicMock()
service.user_context = mock_user_context
methods_to_bind = ['_configure_git_user_settings']
if bind_methods:
methods_to_bind.extend(bind_methods)
# Remove potential duplicates while keeping order
methods_to_bind = list(dict.fromkeys(methods_to_bind))
# Bind the actual method from the real class to test real implementation
service._configure_git_user_settings = (
lambda workspace: AppConversationServiceBase._configure_git_user_settings(
service, workspace
)
)
# Bind actual methods from the real class to test implementations directly
for method_name in methods_to_bind:
real_method = getattr(AppConversationServiceBase, method_name)
setattr(service, method_name, MethodType(real_method, service))
return service, mock_user_context

View File

@@ -153,6 +153,7 @@ class TestExperimentManagerIntegration:
llm_api_key=None,
confirmation_mode=False,
condenser_max_size=None,
security_analyzer=None,
)
async def get_secrets(self):