Compare commits

..

12 Commits

Author SHA1 Message Date
openhands 4095770d1e Enable JavaScript source maps and make them available in GitHub workflows
- Enable source maps in Vite configuration (frontend/vite.config.ts)
- Add source map artifact upload to existing fe-unit-tests workflow
- Create dedicated frontend-source-maps workflow for production builds
- Source maps will be available as downloadable artifacts from GitHub Actions

Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-12 03:31:36 +00:00
Hiep Le 74753036bb refactor(frontend): move user APIs to a dedicated service handler (#10943) 2025-09-12 09:08:15 +07:00
Hiep Le 95d7c10608 refactor(frontend): move option APIs to a dedicated service handler (#10933) 2025-09-12 00:43:15 +07:00
Hiep Le c142cc27ff refactor(frontend): home header component (#10930) 2025-09-12 00:10:58 +07:00
Hiep Le 0e20fc206b refactor(frontend): move settings APIs to a dedicated service handler (#10941) 2025-09-11 23:39:23 +07:00
Hiep Le e21475a88e feat(frontend): persist drawer open/close state on page refresh (#10935)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-09-11 15:58:00 +00:00
Hiep Le 921fec0019 refactor(frontend): expand repository pill to full available width (#10936) 2025-09-11 22:37:44 +07:00
Hiep Le 049f839a62 refactor(frontend): move auth APIs to a dedicated service handler (#10932) 2025-09-11 22:31:41 +07:00
Hiep Le 0dde758e13 refactor(frontend): move microagent management API to a dedicated service handler (#10934) 2025-09-11 22:27:56 +07:00
Tim O'Farrell 8257ae70cc Additional logs to debug container working directories (#10902)
Co-authored-by: Chuck Butkus <chuck@all-hands.dev>
2025-09-11 11:06:19 -04:00
Ray Myers 4513bcc622 chore - MyPy check Enterprise with OpenHands (#10858)
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2025-09-11 11:05:50 -04:00
Hiep Le b5b9a3f40b refactor(frontend): create waiting for runtime component (#10931) 2025-09-11 21:30:05 +07:00
89 changed files with 975 additions and 714 deletions
@@ -46,7 +46,8 @@ repos:
- types-toml
- types-redis
- lxml
# TODO: Add OpenHands in parent
# OpenHands package in repo root
- ./
- stripe==11.5.0
- pygithub==2.6.1
# To see gaps add `--html-report mypy-report/`
+2 -6
View File
@@ -7,15 +7,11 @@ warn_unreachable = True
warn_redundant_casts = True
no_implicit_optional = True
strict_optional = True
exclude = (^enterprise/migrations/.*|^openhands/.*)
disable_error_code = type-abstract
exclude = (^enterprise/migrations/.*)
[mypy-enterprise.tests.unit.test_auth_routes.*]
disable_error_code = union-attr
[mypy-enterprise.sync.install_gitlab_webhooks.*]
disable_error_code = redundant-cast
# Let the other config check base openhands packages
[mypy-openhands.*]
follow_imports = skip
ignore_missing_imports = True
+1 -1
View File
@@ -55,7 +55,7 @@ class SaaSExperimentManager(ExperimentManager):
@staticmethod
def run_config_variant_test(
user_id: str, conversation_id: str, config: OpenHandsConfig
user_id: str | None, conversation_id: str, config: OpenHandsConfig
) -> OpenHandsConfig:
"""
Run agent config variant test and potentially modify the OpenHands config
@@ -62,7 +62,13 @@ class GitlabManager(Manager):
logger.warning(f'Got invalid keyloak user id for GitLab User {user_id}')
return False
gitlab_service = GitLabServiceImpl(external_auth_id=keycloak_user_id)
# Importing here prevents circular import
from integrations.gitlab.gitlab_service import SaaSGitLabService
gitlab_service: SaaSGitLabService = GitLabServiceImpl(
external_auth_id=keycloak_user_id
)
return await gitlab_service.user_has_write_access(project_id)
async def receive_message(self, message: Message):
@@ -119,7 +125,13 @@ class GitlabManager(Manager):
gitlab_view: The GitLab view object containing issue/PR/comment info
"""
keycloak_user_id = gitlab_view.user_info.keycloak_user_id
gitlab_service = GitLabServiceImpl(external_auth_id=keycloak_user_id)
# Importing here prevents circular import
from integrations.gitlab.gitlab_service import SaaSGitLabService
gitlab_service: SaaSGitLabService = GitLabServiceImpl(
external_auth_id=keycloak_user_id
)
outgoing_message = message.message
@@ -47,14 +47,14 @@ class GitlabIssue(ResolverViewInterface):
)
self.previous_comments = await gitlab_service.get_issue_or_mr_comments(
self.project_id, self.issue_number, is_mr=self.is_mr
str(self.project_id), self.issue_number, is_mr=self.is_mr
)
(
self.title,
self.description,
) = await gitlab_service.get_issue_or_mr_title_and_body(
self.project_id, self.issue_number, is_mr=self.is_mr
str(self.project_id), self.issue_number, is_mr=self.is_mr
)
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
@@ -199,11 +199,11 @@ class GitlabInlineMRComment(GitlabMRComment):
self.title,
self.description,
) = await gitlab_service.get_issue_or_mr_title_and_body(
self.project_id, self.issue_number, is_mr=self.is_mr
str(self.project_id), self.issue_number, is_mr=self.is_mr
)
self.previous_comments = await gitlab_service.get_review_thread_comments(
self.project_id, self.issue_number, self.discussion_id
str(self.project_id), self.issue_number, self.discussion_id
)
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
+1 -1
View File
@@ -234,7 +234,7 @@ def _get_user_id(conversation_id: str) -> str:
return conversation_metadata.user_id
async def _get_session_api_key(user_id: str, conversation_id: str) -> str:
async def _get_session_api_key(user_id: str, conversation_id: str) -> str | None:
agent_loop_info = await conversation_manager.get_agent_loop_info(
user_id, filter_to_sids={conversation_id}
)
@@ -178,10 +178,8 @@ class SaasNestedConversationManager(ConversationManager):
redis = self._get_redis_client()
key = self._get_redis_conversation_key(user_id, sid)
starting = await redis.get(key)
logger.debug(f'maybe_start_agent_loop starting from redis: {starting}')
runtime = await self._get_runtime(sid)
logger.debug(f'maybe_start_agent_loop runtime: {runtime}')
nested_url = None
session_api_key = None
@@ -189,20 +187,15 @@ class SaasNestedConversationManager(ConversationManager):
event_store = EventStore(sid, self.file_store, user_id)
if runtime:
nested_url = self._get_nested_url_for_runtime(runtime['runtime_id'], sid)
logger.debug(f'maybe_start_agent_loop nested_url: {nested_url}')
session_api_key = runtime.get('session_api_key')
logger.debug(f'maybe_start_agent_loop session_api_key: {session_api_key}')
status_str = (runtime.get('status') or 'stopped').upper()
logger.debug(f'maybe_start_agent_loop status_str: {status_str}')
if status_str in ConversationStatus:
status = ConversationStatus[status_str]
if status is ConversationStatus.STOPPED and starting:
logger.debug('maybe_start_agent_loop setting status to starting...')
status = ConversationStatus.STARTING
if status is ConversationStatus.STOPPED:
# Mark the agentloop as starting in redis
logger.debug('maybe_start_agent_loop starting agent...')
await redis.set(key, 1, ex=_REDIS_ENTRY_TIMEOUT_SECONDS)
# Start the agent loop in the background
+3 -3
View File
@@ -276,12 +276,12 @@ class VerifyWebhookStatus:
webhook
)
gitlab_service = GitLabServiceImpl(external_auth_id=user_id)
gitlab_service_impl = GitLabServiceImpl(external_auth_id=user_id)
if not isinstance(gitlab_service, SaaSGitLabService):
if not isinstance(gitlab_service_impl, SaaSGitLabService):
raise Exception('Only SaaSGitLabService is supported')
# Cast needed when mypy can see OpenHands
gitlab_service = cast(type[SaaSGitLabService], gitlab_service)
gitlab_service = cast(type[SaaSGitLabService], gitlab_service_impl)
await self.verify_conditions_are_met(
gitlab_service=gitlab_service,
@@ -3,7 +3,7 @@ import { screen } from "@testing-library/react";
import { renderWithProviders } from "test-utils";
import { createRoutesStub } from "react-router";
import { ExpandableMessage } from "#/components/features/chat/expandable-message";
import OpenHands from "#/api/open-hands";
import OptionService from "#/api/option-service/option-service.api";
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
@@ -113,7 +113,7 @@ describe("ExpandableMessage", () => {
});
it("should render the out of credits message when the user is out of credits", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - We only care about the APP_MODE and FEATURE_FLAGS fields
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
@@ -3,13 +3,13 @@ import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
import OpenHands from "#/api/open-hands";
import SettingsService from "#/settings-service/settings-service.api";
describe("AnalyticsConsentFormModal", () => {
it("should call saveUserSettings with consent", async () => {
const user = userEvent.setup();
const onCloseMock = vi.fn();
const saveUserSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const saveUserSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
render(<AnalyticsConsentFormModal onClose={onCloseMock} />, {
wrapper: ({ children }) => (
@@ -3,7 +3,7 @@ import { render, screen } from "@testing-library/react";
import { Provider } from "react-redux";
import { setupStore } from "test-utils";
import { describe, expect, it, vi } from "vitest";
import { HomeHeader } from "#/components/features/home/home-header";
import { HomeHeader } from "#/components/features/home/home-header/home-header";
// Mock the translation function
vi.mock("react-i18next", async () => {
@@ -5,7 +5,10 @@ import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { setupStore } from "test-utils";
import { Provider } from "react-redux";
import { createRoutesStub, Outlet } from "react-router";
import SettingsService from "#/settings-service/settings-service.api";
import OpenHands from "#/api/open-hands";
import GitService from "#/api/git-service/git-service.api";
import OptionService from "#/api/option-service/option-service.api";
import { GitRepository } from "#/types/git";
import { RepoConnector } from "#/components/features/home/repo-connector";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
@@ -66,7 +69,7 @@ const MOCK_RESPOSITORIES: GitRepository[] = [
];
beforeEach(() => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
@@ -84,7 +87,7 @@ describe("RepoConnector", () => {
it("should render the available repositories in the dropdown", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
@@ -93,7 +96,7 @@ describe("RepoConnector", () => {
});
// Mock the search function that's used by the dropdown
vi.spyOn(OpenHands, "searchGitRepositories").mockResolvedValue(
vi.spyOn(GitService, "searchGitRepositories").mockResolvedValue(
MOCK_RESPOSITORIES,
);
@@ -121,7 +124,7 @@ describe("RepoConnector", () => {
it("should only enable the launch button if a repo is selected", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
@@ -135,10 +138,16 @@ describe("RepoConnector", () => {
expect(launchButton).toBeDisabled();
// Mock the repository branches API call
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
branches: [
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
],
has_next_page: false,
current_page: 1,
per_page: 30,
total_count: 2,
});
// First select the provider
const providerDropdown = await waitFor(() =>
@@ -170,14 +179,14 @@ describe("RepoConnector", () => {
});
it("should render the 'add github repos' link in dropdown if saas mode and github provider is set", async () => {
const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
const getConfiSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return the APP_MODE and APP_SLUG
getConfiSpy.mockResolvedValue({
APP_MODE: "saas",
APP_SLUG: "openhands",
});
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
@@ -187,7 +196,7 @@ describe("RepoConnector", () => {
});
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
@@ -217,14 +226,14 @@ describe("RepoConnector", () => {
});
it("should not render the 'add github repos' link if github provider is not set", async () => {
const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
const getConfiSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return the APP_MODE and APP_SLUG
getConfiSpy.mockResolvedValue({
APP_MODE: "saas",
APP_SLUG: "openhands",
});
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
@@ -234,7 +243,7 @@ describe("RepoConnector", () => {
});
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
@@ -262,13 +271,13 @@ describe("RepoConnector", () => {
});
it("should not render the 'add github repos' link in dropdown if oss mode", async () => {
const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
const getConfiSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return the APP_MODE
getConfiSpy.mockResolvedValue({
APP_MODE: "oss",
});
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
@@ -278,7 +287,7 @@ describe("RepoConnector", () => {
});
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
@@ -321,7 +330,7 @@ describe("RepoConnector", () => {
session_api_key: null,
});
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
@@ -340,10 +349,16 @@ describe("RepoConnector", () => {
expect(createConversationSpy).not.toHaveBeenCalled();
// Mock the repository branches API call
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
branches: [
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
],
has_next_page: false,
current_page: 1,
per_page: 30,
total_count: 2,
});
// First select the provider
const providerDropdown = await waitFor(() =>
@@ -388,7 +403,7 @@ describe("RepoConnector", () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
createConversationSpy.mockImplementation(() => new Promise(() => {})); // Never resolves to keep loading state
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
@@ -397,10 +412,16 @@ describe("RepoConnector", () => {
});
// Mock the repository branches API call
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
branches: [
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
],
has_next_page: false,
current_page: 1,
per_page: 30,
total_count: 2,
});
renderRepoConnector();
@@ -448,7 +469,7 @@ describe("RepoConnector", () => {
});
it("should display a button to settings if the user needs to sign in with their git provider", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {},
@@ -3,6 +3,8 @@ import { describe, expect, vi, beforeEach, it } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RepositorySelectionForm } from "../../../../src/components/features/home/repo-selection-form";
import OpenHands from "#/api/open-hands";
import UserService from "#/api/user-service/user-service.api";
import GitService from "#/api/git-service/git-service.api";
import { GitRepository } from "#/types/git";
// Create mock functions
@@ -204,7 +206,7 @@ describe("RepositorySelectionForm", () => {
];
// Create a spy on the API call
const searchGitReposSpy = vi.spyOn(OpenHands, "searchGitRepositories");
const searchGitReposSpy = vi.spyOn(GitService, "searchGitRepositories");
searchGitReposSpy.mockResolvedValue(MOCK_SEARCH_REPOS);
mockUseGitRepositories.mockReturnValue({
@@ -6,6 +6,8 @@ import { Provider } from "react-redux";
import { createRoutesStub } from "react-router";
import { setupStore } from "test-utils";
import OpenHands from "#/api/open-hands";
import UserService from "#/api/user-service/user-service.api";
import GitService from "#/api/git-service/git-service.api";
import { TaskCard } from "#/components/features/home/tasks/task-card";
import { GitRepository } from "#/types/git";
import { SuggestedTask } from "#/utils/types";
@@ -70,7 +72,7 @@ describe("TaskCard", () => {
describe("creating suggested task conversation", () => {
beforeEach(() => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
@@ -8,6 +8,7 @@ import { renderWithProviders } from "test-utils";
import MicroagentManagement from "#/routes/microagent-management";
import { MicroagentManagementMain } from "#/components/features/microagent-management/microagent-management-main";
import OpenHands from "#/api/open-hands";
import GitService from "#/api/git-service/git-service.api";
import { GitRepository } from "#/types/git";
import { RepositoryMicroagent } from "#/types/microagent-management";
import { Conversation } from "#/api/open-hands.types";
@@ -231,12 +232,12 @@ describe("MicroagentManagement", () => {
});
// Setup default mock for retrieveUserGitRepositories
vi.spyOn(OpenHands, "retrieveUserGitRepositories").mockResolvedValue({
vi.spyOn(GitService, "retrieveUserGitRepositories").mockResolvedValue({
data: [...mockRepositories],
nextPage: null,
});
// Setup default mock for getRepositoryMicroagents
vi.spyOn(OpenHands, "getRepositoryMicroagents").mockResolvedValue([
vi.spyOn(GitService, "getRepositoryMicroagents").mockResolvedValue([
...mockMicroagents,
]);
// Setup default mock for searchConversations
@@ -244,7 +245,7 @@ describe("MicroagentManagement", () => {
...mockConversations,
]);
// Setup default mock for getRepositoryMicroagentContent
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
vi.spyOn(GitService, "getRepositoryMicroagentContent").mockResolvedValue({
content: "Original microagent content for testing updates",
path: ".openhands/microagents/update-test-microagent",
git_provider: "github",
@@ -1290,7 +1291,7 @@ describe("MicroagentManagement", () => {
// Add microagent integration tests
describe("Add microagent functionality", () => {
beforeEach(() => {
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
branches: [{ name: "main", commit_sha: "abc123", protected: false }],
has_next_page: false,
current_page: 1,
@@ -1983,7 +1984,7 @@ describe("MicroagentManagement", () => {
};
beforeEach(() => {
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
branches: [{ name: "main", commit_sha: "abc123", protected: false }],
has_next_page: false,
current_page: 1,
@@ -2314,7 +2315,7 @@ describe("MicroagentManagement", () => {
const user = userEvent.setup();
// Mock the content API to return empty content for this test
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
vi.spyOn(GitService, "getRepositoryMicroagentContent").mockResolvedValue({
content: "",
path: ".openhands/microagents/update-test-microagent",
git_provider: "github",
@@ -2363,7 +2364,7 @@ describe("MicroagentManagement", () => {
const user = userEvent.setup();
// Mock the content API to return content without triggers for this test
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
vi.spyOn(GitService, "getRepositoryMicroagentContent").mockResolvedValue({
content: "Original microagent content for testing updates",
path: ".openhands/microagents/update-test-microagent",
git_provider: "github",
@@ -2647,7 +2648,7 @@ describe("MicroagentManagement", () => {
const user = userEvent.setup();
// Mock the content API to return the expected content for this test
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
vi.spyOn(GitService, "getRepositoryMicroagentContent").mockResolvedValue({
content: "Test microagent content for learn functionality",
path: ".openhands/microagents/learn-test-microagent",
git_provider: "github",
@@ -2707,7 +2708,7 @@ describe("MicroagentManagement", () => {
const user = userEvent.setup();
// Mock the content API to return empty content for this test
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
vi.spyOn(GitService, "getRepositoryMicroagentContent").mockResolvedValue({
content: "",
path: ".openhands/microagents/learn-test-microagent",
git_provider: "github",
@@ -2765,7 +2766,7 @@ describe("MicroagentManagement", () => {
const user = userEvent.setup();
// Mock the content API to return content without triggers for this test
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
vi.spyOn(GitService, "getRepositoryMicroagentContent").mockResolvedValue({
content: "Test microagent content for learn functionality",
path: ".openhands/microagents/learn-test-microagent",
git_provider: "github",
@@ -3,12 +3,13 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
import OpenHands from "#/api/open-hands";
import OptionService from "#/api/option-service/option-service.api";
import { PaymentForm } from "#/components/features/payment/payment-form";
describe("PaymentForm", () => {
const getBalanceSpy = vi.spyOn(OpenHands, "getBalance");
const createCheckoutSessionSpy = vi.spyOn(OpenHands, "createCheckoutSession");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const renderPaymentForm = () =>
render(<PaymentForm />, {
@@ -3,7 +3,7 @@ import { renderWithProviders } from "test-utils";
import { createRoutesStub } from "react-router";
import { waitFor } from "@testing-library/react";
import { Sidebar } from "#/components/features/sidebar/sidebar";
import OpenHands from "#/api/open-hands";
import SettingsService from "#/settings-service/settings-service.api";
// These tests will now fail because the conversation panel is rendered through a portal
// and technically not a child of the Sidebar component.
@@ -19,7 +19,7 @@ const renderSidebar = () =>
renderWithProviders(<RouterStub initialEntries={["/conversation/123"]} />);
describe("Sidebar", () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
afterEach(() => {
vi.clearAllMocks();
@@ -3,13 +3,13 @@ import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { createRoutesStub } from "react-router";
import { screen } from "@testing-library/react";
import OpenHands from "#/api/open-hands";
import SettingsService from "#/settings-service/settings-service.api";
import { SettingsForm } from "#/components/shared/modals/settings/settings-form";
import { DEFAULT_SETTINGS } from "#/services/settings";
describe("SettingsForm", () => {
const onCloseMock = vi.fn();
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const RouteStub = createRoutesStub([
{
@@ -1,12 +1,12 @@
import { renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import SettingsService from "#/settings-service/settings-service.api";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
describe("useSaveSettings", () => {
it("should send an empty string for llm_api_key if an empty string is passed, otherwise undefined", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const { result } = renderHook(() => useSaveSettings(), {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
+7 -5
View File
@@ -8,7 +8,9 @@ import {
import userEvent from "@testing-library/user-event";
import MainApp from "#/routes/root-layout";
import i18n from "#/i18n";
import OptionService from "#/api/option-service/option-service.api";
import * as CaptureConsent from "#/utils/handle-capture-consent";
import SettingsService from "#/settings-service/settings-service.api";
import OpenHands from "#/api/open-hands";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
@@ -62,8 +64,8 @@ describe("frontend/routes/_oh", () => {
// FIXME: This test fails when it shouldn't be, please investigate
it.skip("should render and capture the user's consent if oss mode", async () => {
const user = userEvent.setup();
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const handleCaptureConsentSpy = vi.spyOn(
CaptureConsent,
"handleCaptureConsent",
@@ -106,7 +108,7 @@ describe("frontend/routes/_oh", () => {
});
it("should not render the user consent form if saas mode", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test-id",
@@ -184,8 +186,8 @@ describe("frontend/routes/_oh", () => {
});
it("should render a you're in toast if it is a new user and in saas mode", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const displaySuccessToastSpy = vi.spyOn(
ToastHandlers,
"displaySuccessToast",
+13 -13
View File
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import AppSettingsScreen from "#/routes/app-settings";
import OpenHands from "#/api/open-hands";
import SettingsService from "#/settings-service/settings-service.api";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { AvailableLanguages } from "#/i18n";
import * as CaptureConsent from "#/utils/handle-capture-consent";
@@ -25,7 +25,7 @@ describe("Content", () => {
});
it("should render the correct default values", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
language: "no",
@@ -65,8 +65,8 @@ describe("Form submission", () => {
});
it("should submit the form with the correct values", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
renderAppSettingsScreen();
@@ -106,7 +106,7 @@ describe("Form submission", () => {
});
it("should only enable the submit button when there are changes", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
renderAppSettingsScreen();
@@ -146,7 +146,7 @@ describe("Form submission", () => {
});
it("should call handleCaptureConsents with true when the analytics switch is toggled", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const handleCaptureConsentsSpy = vi.spyOn(
@@ -168,7 +168,7 @@ describe("Form submission", () => {
});
it("should call handleCaptureConsents with false when the analytics switch is toggled", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
user_consents_to_analytics: true,
@@ -215,8 +215,8 @@ describe("Form submission", () => {
});
it("should disable the button after submitting changes", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
renderAppSettingsScreen();
@@ -240,8 +240,8 @@ describe("Form submission", () => {
describe("Status toasts", () => {
it("should call displaySuccessToast when the settings are saved", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const displaySuccessToastSpy = vi.spyOn(
@@ -265,8 +265,8 @@ describe("Status toasts", () => {
});
it("should call displayErrorToast when the settings fail to save", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
+21 -18
View File
@@ -6,9 +6,12 @@ import userEvent from "@testing-library/user-event";
import i18next from "i18next";
import { I18nextProvider } from "react-i18next";
import GitSettingsScreen from "#/routes/git-settings";
import SettingsService from "#/settings-service/settings-service.api";
import OpenHands from "#/api/open-hands";
import OptionService from "#/api/option-service/option-service.api";
import AuthService from "#/api/auth-service/auth-service.api";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { GetConfigResponse } from "#/api/open-hands.types";
import { GetConfigResponse } from "#/api/option-service/option.types";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
import { SecretsService } from "#/api/secrets-service";
@@ -108,7 +111,7 @@ describe("Content", () => {
});
it("should render the inputs if OSS mode", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
const { rerender } = renderGitSettingsScreen();
@@ -151,8 +154,8 @@ describe("Content", () => {
});
it("should set '<hidden>' placeholder and indicator if the GitHub token is set", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
getSettingsSpy.mockResolvedValue({
@@ -226,7 +229,7 @@ describe("Content", () => {
});
it("should render the 'Configure GitHub Repositories' button if SaaS mode and app slug exists", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
const { rerender } = renderGitSettingsScreen();
@@ -270,7 +273,7 @@ describe("Form submission", () => {
it("should save the GitHub token", async () => {
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
saveProvidersSpy.mockImplementation(() => Promise.resolve(true));
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
@@ -291,7 +294,7 @@ describe("Form submission", () => {
it("should save GitLab tokens", async () => {
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
saveProvidersSpy.mockImplementation(() => Promise.resolve(true));
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
@@ -312,7 +315,7 @@ describe("Form submission", () => {
it("should save the Bitbucket token", async () => {
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
saveProvidersSpy.mockImplementation(() => Promise.resolve(true));
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
@@ -331,7 +334,7 @@ describe("Form submission", () => {
});
it("should disable the button if there is no input", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
@@ -357,8 +360,8 @@ describe("Form submission", () => {
});
it("should enable a disconnect tokens button if there is at least one token set", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
getSettingsSpy.mockResolvedValue({
@@ -391,9 +394,9 @@ describe("Form submission", () => {
});
it("should call logout when pressing the disconnect tokens button", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const logoutSpy = vi.spyOn(OpenHands, "logout");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const logoutSpy = vi.spyOn(AuthService, "logout");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
getSettingsSpy.mockResolvedValue({
@@ -418,7 +421,7 @@ describe("Form submission", () => {
// flaky test
it.skip("should disable the button when submitting changes", async () => {
const saveSettingsSpy = vi.spyOn(SecretsService, "addGitProvider");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
@@ -442,7 +445,7 @@ describe("Form submission", () => {
it("should disable the button after submitting changes", async () => {
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
@@ -476,7 +479,7 @@ describe("Form submission", () => {
describe("Status toasts", () => {
it("should call displaySuccessToast when the settings are saved", async () => {
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const displaySuccessToastSpy = vi.spyOn(
@@ -499,7 +502,7 @@ describe("Status toasts", () => {
it("should call displayErrorToast when the settings fail to save", async () => {
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
+14 -12
View File
@@ -7,7 +7,9 @@ import { Provider } from "react-redux";
import { createAxiosNotFoundErrorObject, setupStore } from "test-utils";
import HomeScreen from "#/routes/home";
import { GitRepository } from "#/types/git";
import OpenHands from "#/api/open-hands";
import SettingsService from "#/settings-service/settings-service.api";
import GitService from "#/api/git-service/git-service.api";
import OptionService from "#/api/option-service/option-service.api";
import MainApp from "#/routes/root-layout";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
@@ -91,7 +93,7 @@ const MOCK_RESPOSITORIES: GitRepository[] = [
describe("HomeScreen", () => {
beforeEach(() => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
@@ -139,7 +141,7 @@ describe("HomeScreen", () => {
it("should filter the suggested tasks based on the selected repository", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
@@ -148,7 +150,7 @@ describe("HomeScreen", () => {
});
// Mock the repository branches API call
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
branches: [
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
@@ -183,7 +185,7 @@ describe("HomeScreen", () => {
it("should filter tasks when different repositories are selected", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
@@ -192,7 +194,7 @@ describe("HomeScreen", () => {
});
// Mock the repository branches API call
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
branches: [
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
@@ -246,7 +248,7 @@ describe("HomeScreen", () => {
await screen.findAllByTestId("task-launch-button");
// Mock the repository branches API call
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
branches: [
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
@@ -282,7 +284,7 @@ describe("HomeScreen", () => {
beforeEach(() => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
@@ -358,8 +360,8 @@ describe("Settings 404", () => {
vi.resetAllMocks();
});
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
it("should open the settings modal if GET /settings fails with a 404", async () => {
const error = createAxiosNotFoundErrorObject();
@@ -417,8 +419,8 @@ describe("Settings 404", () => {
});
describe("Setup Payment modal", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
it("should only render if SaaS mode and is new user", async () => {
// @ts-expect-error - we only need the APP_MODE for this test
+42 -24
View File
@@ -3,7 +3,9 @@ import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import LlmSettingsScreen from "#/routes/llm-settings";
import SettingsService from "#/settings-service/settings-service.api";
import OpenHands from "#/api/open-hands";
import OptionService from "#/api/option-service/option-service.api";
import {
MOCK_DEFAULT_USER_SETTINGS,
resetTestHandlersMockSettings,
@@ -56,7 +58,7 @@ describe("Content", () => {
});
it("should render the existing settings values", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "openai/gpt-4o",
@@ -84,7 +86,9 @@ describe("Content", () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const confirmation = screen.getByTestId("enable-confirmation-mode-switch");
const confirmation = screen.getByTestId(
"enable-confirmation-mode-switch",
);
// Initially confirmation mode is false, so security analyzer should not be visible
expect(confirmation).not.toBeChecked();
@@ -185,7 +189,7 @@ describe("Content", () => {
});
it("should render existing advanced settings correctly", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "openai/gpt-4o",
@@ -230,7 +234,7 @@ describe("Content", () => {
describe("Form submission", () => {
it("should submit the basic form with the correct values", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
@@ -266,7 +270,7 @@ describe("Form submission", () => {
});
it("should submit the advanced form with the correct values", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
@@ -310,7 +314,9 @@ describe("Form submission", () => {
// select security analyzer
const securityAnalyzer = screen.getByTestId("security-analyzer-input");
await userEvent.click(securityAnalyzer);
const securityAnalyzerOption = screen.getByText("SETTINGS$SECURITY_ANALYZER_NONE");
const securityAnalyzerOption = screen.getByText(
"SETTINGS$SECURITY_ANALYZER_NONE",
);
await userEvent.click(securityAnalyzerOption);
const submitButton = screen.getByTestId("submit-button");
@@ -329,7 +335,7 @@ describe("Form submission", () => {
});
it("should disable the button if there are no changes in the basic form", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "openai/gpt-4o",
@@ -372,7 +378,7 @@ describe("Form submission", () => {
});
it("should disable the button if there are no changes in the advanced form", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "openai/gpt-4o",
@@ -392,10 +398,14 @@ describe("Form submission", () => {
const baseUrl = await screen.findByTestId("base-url-input");
const apiKey = await screen.findByTestId("llm-api-key-input");
const agent = await screen.findByTestId("agent-input");
const condensor = await screen.findByTestId("enable-memory-condenser-switch");
const condensor = await screen.findByTestId(
"enable-memory-condenser-switch",
);
// Confirmation mode switch is now in basic settings, always visible
const confirmation = await screen.findByTestId("enable-confirmation-mode-switch");
const confirmation = await screen.findByTestId(
"enable-confirmation-mode-switch",
);
// enter custom model
await userEvent.type(model, "-mini");
@@ -468,9 +478,13 @@ describe("Form submission", () => {
expect(submitButton).toBeDisabled();
// select security analyzer
const securityAnalyzer = await screen.findByTestId("security-analyzer-input");
const securityAnalyzer = await screen.findByTestId(
"security-analyzer-input",
);
await userEvent.click(securityAnalyzer);
const securityAnalyzerOption = screen.getByText("SETTINGS$SECURITY_ANALYZER_NONE");
const securityAnalyzerOption = screen.getByText(
"SETTINGS$SECURITY_ANALYZER_NONE",
);
await userEvent.click(securityAnalyzerOption);
expect(securityAnalyzer).toHaveValue("SETTINGS$SECURITY_ANALYZER_NONE");
@@ -478,9 +492,13 @@ describe("Form submission", () => {
// revert back to original value
await userEvent.click(securityAnalyzer);
const originalSecurityAnalyzerOption = screen.getByText("SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT");
const originalSecurityAnalyzerOption = screen.getByText(
"SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT",
);
await userEvent.click(originalSecurityAnalyzerOption);
expect(securityAnalyzer).toHaveValue("SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT");
expect(securityAnalyzer).toHaveValue(
"SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT",
);
expect(submitButton).toBeDisabled();
});
@@ -512,7 +530,7 @@ describe("Form submission", () => {
// flaky test
it.skip("should disable the button when submitting changes", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
@@ -539,7 +557,7 @@ describe("Form submission", () => {
});
it("should clear advanced settings when saving basic settings", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "openai/gpt-4o",
@@ -547,7 +565,7 @@ describe("Form submission", () => {
llm_api_key_set: true,
confirmation_mode: true,
});
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
@@ -583,7 +601,7 @@ describe("Form submission", () => {
describe("Status toasts", () => {
describe("Basic form", () => {
it("should call displaySuccessToast when the settings are saved", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const displaySuccessToastSpy = vi.spyOn(
ToastHandlers,
@@ -604,7 +622,7 @@ describe("Status toasts", () => {
});
it("should call displayErrorToast when the settings fail to save", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
@@ -626,7 +644,7 @@ describe("Status toasts", () => {
describe("Advanced form", () => {
it("should call displaySuccessToast when the settings are saved", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const displaySuccessToastSpy = vi.spyOn(
ToastHandlers,
@@ -652,7 +670,7 @@ describe("Status toasts", () => {
});
it("should call displayErrorToast when the settings fail to save", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
@@ -680,7 +698,7 @@ describe("Status toasts", () => {
describe("SaaS mode", () => {
it("should not render the runtime settings input in oss mode", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return mode
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
@@ -698,7 +716,7 @@ describe("SaaS mode", () => {
});
it("should render the runtime settings input in saas mode", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return mode
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
@@ -716,7 +734,7 @@ describe("SaaS mode", () => {
});
it("should always render the runtime settings input as disabled", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return mode
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
@@ -6,7 +6,9 @@ import { createRoutesStub, Outlet } from "react-router";
import SecretsSettingsScreen from "#/routes/secrets-settings";
import { SecretsService } from "#/api/secrets-service";
import { GetSecretsResponse } from "#/api/secrets-service.types";
import SettingsService from "#/settings-service/settings-service.api";
import OpenHands from "#/api/open-hands";
import OptionService from "#/api/option-service/option-service.api";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
const MOCK_GET_SECRETS_RESPONSE: GetSecretsResponse["custom_secrets"] = [
@@ -53,7 +55,7 @@ const renderSecretsSettings = () =>
});
beforeEach(() => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return the config we need
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
@@ -67,8 +69,8 @@ describe("Content", () => {
});
it("should NOT render a button to connect with git if they havent already in oss", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
// @ts-expect-error - only return the config we need
getConfigSpy.mockResolvedValue({
@@ -87,7 +89,7 @@ describe("Content", () => {
});
it("should render add secret button in saas mode", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
// @ts-expect-error - only return the config we need
getConfigSpy.mockResolvedValue({
@@ -476,7 +478,9 @@ describe("Secret actions", () => {
// make POST request
expect(createSecretSpy).not.toHaveBeenCalled();
expect(screen.queryByText("SECRETS$SECRET_ALREADY_EXISTS")).toBeInTheDocument();
expect(
screen.queryByText("SECRETS$SECRET_ALREADY_EXISTS"),
).toBeInTheDocument();
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "My_Custom_Secret");
@@ -560,7 +564,9 @@ describe("Secret actions", () => {
// make POST request
expect(createSecretSpy).not.toHaveBeenCalled();
expect(screen.queryByText("SECRETS$SECRET_ALREADY_EXISTS")).toBeInTheDocument();
expect(
screen.queryByText("SECRETS$SECRET_ALREADY_EXISTS"),
).toBeInTheDocument();
expect(nameInput).toHaveValue(MOCK_GET_SECRETS_RESPONSE[0].name);
expect(valueInput).toHaveValue("my-custom-secret-value");
+3 -2
View File
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest";
import { QueryClientProvider } from "@tanstack/react-query";
import SettingsScreen, { clientLoader } from "#/routes/settings";
import OpenHands from "#/api/open-hands";
import OptionService from "#/api/option-service/option-service.api";
// Mock the i18next hook
vi.mock("react-i18next", async () => {
@@ -93,7 +94,7 @@ describe("Settings Screen", () => {
it("should render the navbar", async () => {
const sectionsToInclude = ["llm", "integrations", "application", "secrets"];
const sectionsToExclude = ["api keys", "credits", "billing"];
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return app mode
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
@@ -156,7 +157,7 @@ describe("Settings Screen", () => {
});
it("should not be able to access saas-only routes in oss mode", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return app mode
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
@@ -0,0 +1,52 @@
import { openHands } from "../open-hands-axios";
import { AuthenticateResponse, GitHubAccessTokenResponse } from "./auth.types";
import { GetConfigResponse } from "../option-service/option.types";
/**
* Authentication service for handling all authentication-related API calls
*/
class AuthService {
/**
* Authenticate with GitHub token
* @param appMode The application mode (saas or oss)
* @returns Response with authentication status and user info if successful
*/
static async authenticate(
appMode: GetConfigResponse["APP_MODE"],
): Promise<boolean> {
if (appMode === "oss") return true;
// Just make the request, if it succeeds (no exception thrown), return true
await openHands.post<AuthenticateResponse>("/api/authenticate");
return true;
}
/**
* Get GitHub access token from Keycloak callback
* @param code Code provided by GitHub
* @returns GitHub access token
*/
static async getGitHubAccessToken(
code: string,
): Promise<GitHubAccessTokenResponse> {
const { data } = await openHands.post<GitHubAccessTokenResponse>(
"/api/keycloak/callback",
{
code,
},
);
return data;
}
/**
* Logout user from the application
* @param appMode The application mode (saas or oss)
*/
static async logout(appMode: GetConfigResponse["APP_MODE"]): Promise<void> {
const endpoint =
appMode === "saas" ? "/api/logout" : "/api/unset-provider-tokens";
await openHands.post(endpoint);
}
}
export default AuthService;
@@ -0,0 +1,8 @@
export interface AuthenticateResponse {
message?: string;
error?: string;
}
export interface GitHubAccessTokenResponse {
access_token: string;
}
@@ -0,0 +1,215 @@
import { openHands } from "../open-hands-axios";
import { Provider } from "#/types/settings";
import { GitRepository, PaginatedBranchesResponse, Branch } from "#/types/git";
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
import { RepositoryMicroagent } from "#/types/microagent-management";
import { MicroagentContentResponse } from "../open-hands.types";
/**
* Git Service API - Handles all Git-related API endpoints
*/
class GitService {
/**
* Search for Git repositories
* @param query Search query
* @param per_page Number of results per page
* @param selected_provider Git provider to search in
* @returns List of matching repositories
*/
static async searchGitRepositories(
query: string,
per_page = 5,
selected_provider?: Provider,
): Promise<GitRepository[]> {
const response = await openHands.get<GitRepository[]>(
"/api/user/search/repositories",
{
params: {
query,
per_page,
selected_provider,
},
},
);
return response.data;
}
/**
* Retrieve user's Git repositories
* @param selected_provider Git provider
* @param page Page number
* @param per_page Number of results per page
* @returns User's repositories with pagination info
*/
static async retrieveUserGitRepositories(
selected_provider: Provider,
page = 1,
per_page = 30,
) {
const { data } = await openHands.get<GitRepository[]>(
"/api/user/repositories",
{
params: {
selected_provider,
sort: "pushed",
page,
per_page,
},
},
);
const link =
data.length > 0 && data[0].link_header ? data[0].link_header : "";
const nextPage = extractNextPageFromLink(link);
return { data, nextPage };
}
/**
* Retrieve repositories from a specific installation
* @param selected_provider Git provider
* @param installationIndex Current installation index
* @param installations List of installation IDs
* @param page Page number
* @param per_page Number of results per page
* @returns Installation repositories with pagination info
*/
static async retrieveInstallationRepositories(
selected_provider: Provider,
installationIndex: number,
installations: string[],
page = 1,
per_page = 30,
) {
const installationId = installations[installationIndex];
const response = await openHands.get<GitRepository[]>(
"/api/user/repositories",
{
params: {
selected_provider,
sort: "pushed",
page,
per_page,
installation_id: installationId,
},
},
);
const link =
response.data.length > 0 && response.data[0].link_header
? response.data[0].link_header
: "";
const nextPage = extractNextPageFromLink(link);
let nextInstallation: number | null;
if (nextPage) {
nextInstallation = installationIndex;
} else if (installationIndex + 1 < installations.length) {
nextInstallation = installationIndex + 1;
} else {
nextInstallation = null;
}
return {
data: response.data,
nextPage,
installationIndex: nextInstallation,
};
}
/**
* Get repository branches
* @param repository Repository name
* @param page Page number
* @param perPage Number of results per page
* @returns Paginated branches response
*/
static async getRepositoryBranches(
repository: string,
page: number = 1,
perPage: number = 30,
): Promise<PaginatedBranchesResponse> {
const { data } = await openHands.get<PaginatedBranchesResponse>(
`/api/user/repository/branches?repository=${encodeURIComponent(repository)}&page=${page}&per_page=${perPage}`,
);
return data;
}
/**
* Search repository branches
* @param repository Repository name
* @param query Search query
* @param perPage Number of results per page
* @param selectedProvider Git provider
* @returns List of matching branches
*/
static async searchRepositoryBranches(
repository: string,
query: string,
perPage: number = 30,
selectedProvider?: Provider,
): Promise<Branch[]> {
const { data } = await openHands.get<Branch[]>(
`/api/user/search/branches`,
{
params: {
repository,
query,
per_page: perPage,
selected_provider: selectedProvider,
},
},
);
return data;
}
/**
* Get the available microagents for a repository
* @param owner The repository owner
* @param repo The repository name
* @returns The available microagents for the repository
*/
static async getRepositoryMicroagents(
owner: string,
repo: string,
): Promise<RepositoryMicroagent[]> {
const { data } = await openHands.get<RepositoryMicroagent[]>(
`/api/user/repository/${owner}/${repo}/microagents`,
);
return data;
}
/**
* Get the content of a specific microagent from a repository
* @param owner The repository owner
* @param repo The repository name
* @param filePath The path to the microagent file within the repository
* @returns The microagent content and metadata
*/
static async getRepositoryMicroagentContent(
owner: string,
repo: string,
filePath: string,
): Promise<MicroagentContentResponse> {
const { data } = await openHands.get<MicroagentContentResponse>(
`/api/user/repository/${owner}/${repo}/microagents/content`,
{
params: { file_path: filePath },
},
);
return data;
}
/**
* Get the user installation IDs
* @param provider The provider to get installation IDs for (github, bitbucket, etc.)
* @returns List of installation IDs
*/
static async getUserInstallationIds(provider: Provider): Promise<string[]> {
const { data } = await openHands.get<string[]>(
`/api/user/installations?provider=${provider}`,
);
return data;
}
}
export default GitService;
+1 -294
View File
@@ -2,10 +2,7 @@ import { AxiosHeaders } from "axios";
import {
Feedback,
FeedbackResponse,
GitHubAccessTokenResponse,
GetConfigResponse,
GetVSCodeUrlResponse,
AuthenticateResponse,
Conversation,
ResultSet,
GetTrajectoryResponse,
@@ -14,22 +11,13 @@ import {
GetMicroagentsResponse,
GetMicroagentPromptResponse,
CreateMicroagent,
MicroagentContentResponse,
FileUploadSuccessResponse,
GetFilesResponse,
GetFileResponse,
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
import { Provider } from "#/types/settings";
import { SuggestedTask } from "#/utils/types";
import {
GitUser,
GitRepository,
PaginatedBranchesResponse,
Branch,
} from "#/types/git";
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
import { RepositoryMicroagent } from "#/types/microagent-management";
import { BatchFeedbackData } from "#/hooks/query/use-batch-feedback";
import { SubscriptionAccess } from "#/types/billing";
@@ -66,42 +54,6 @@ class OpenHands {
return `/api/conversations/${conversationId}`;
}
/**
* Retrieve the list of models available
* @returns List of models available
*/
static async getModels(): Promise<string[]> {
const { data } = await openHands.get<string[]>("/api/options/models");
return data;
}
/**
* Retrieve the list of agents available
* @returns List of agents available
*/
static async getAgents(): Promise<string[]> {
const { data } = await openHands.get<string[]>("/api/options/agents");
return data;
}
/**
* Retrieve the list of security analyzers available
* @returns List of security analyzers available
*/
static async getSecurityAnalyzers(): Promise<string[]> {
const { data } = await openHands.get<string[]>(
"/api/options/security-analyzers",
);
return data;
}
static async getConfig(): Promise<GetConfigResponse> {
const { data } = await openHands.get<GetConfigResponse>(
"/api/options/config",
);
return data;
}
static getConversationHeaders(): AxiosHeaders {
const headers = new AxiosHeaders();
const sessionApiKey = this.currentConversation?.session_api_key;
@@ -210,20 +162,6 @@ class OpenHands {
return data;
}
/**
* Authenticate with GitHub token
* @returns Response with authentication status and user info if successful
*/
static async authenticate(
appMode: GetConfigResponse["APP_MODE"],
): Promise<boolean> {
if (appMode === "oss") return true;
// Just make the request, if it succeeds (no exception thrown), return true
await openHands.post<AuthenticateResponse>("/api/authenticate");
return true;
}
/**
* Get the blob of the workspace zip
* @returns Blob of the workspace zip
@@ -249,22 +187,6 @@ class OpenHands {
return Object.keys(response.data.hosts);
}
/**
* @param code Code provided by GitHub
* @returns GitHub access token
*/
static async getGitHubAccessToken(
code: string,
): Promise<GitHubAccessTokenResponse> {
const { data } = await openHands.post<GitHubAccessTokenResponse>(
"/api/keycloak/callback",
{
code,
},
);
return data;
}
/**
* Get the VSCode URL
* @returns VSCode URL
@@ -373,15 +295,11 @@ class OpenHands {
conversationId: string,
providers?: Provider[],
): Promise<Conversation | null> {
const err = new Error("Call stack:");
console.log("startConversation...");
console.log(err.stack);
const { data } = await openHands.post<Conversation | null>(
`/api/conversations/${conversationId}/start`,
providers ? { providers_set: providers } : {},
);
console.log(data);
return data;
}
@@ -395,25 +313,6 @@ class OpenHands {
return data;
}
/**
* 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");
return data;
}
/**
* 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> {
const data = await openHands.post("/api/settings", settings);
return data.status === 200;
}
static async createCheckoutSession(amount: number): Promise<string> {
const { data } = await openHands.post(
"/api/billing/create-checkout-session",
@@ -445,42 +344,6 @@ class OpenHands {
return data;
}
static async getGitUser(): Promise<GitUser> {
const response = await openHands.get<GitUser>("/api/user/info");
const { data } = response;
const user: GitUser = {
id: data.id,
login: data.login,
avatar_url: data.avatar_url,
company: data.company,
name: data.name,
email: data.email,
};
return user;
}
static async searchGitRepositories(
query: string,
per_page = 5,
selected_provider?: Provider,
): Promise<GitRepository[]> {
const response = await openHands.get<GitRepository[]>(
"/api/user/search/repositories",
{
params: {
query,
per_page,
selected_provider,
},
},
);
return response.data;
}
static async getTrajectory(
conversationId: string,
): Promise<GetTrajectoryResponse> {
@@ -491,12 +354,6 @@ class OpenHands {
return data;
}
static async logout(appMode: GetConfigResponse["APP_MODE"]): Promise<void> {
const endpoint =
appMode === "saas" ? "/api/logout" : "/api/unset-provider-tokens";
await openHands.post(endpoint);
}
static async getGitChanges(conversationId: string): Promise<GitChange[]> {
const url = `${this.getConversationUrl(conversationId)}/git/changes`;
const { data } = await openHands.get<GitChange[]>(url, {
@@ -520,101 +377,6 @@ class OpenHands {
/**
* @returns A list of repositories
*/
static async retrieveUserGitRepositories(
selected_provider: Provider,
page = 1,
per_page = 30,
) {
const { data } = await openHands.get<GitRepository[]>(
"/api/user/repositories",
{
params: {
selected_provider,
sort: "pushed",
page,
per_page,
},
},
);
const link =
data.length > 0 && data[0].link_header ? data[0].link_header : "";
const nextPage = extractNextPageFromLink(link);
return { data, nextPage };
}
static async retrieveInstallationRepositories(
selected_provider: Provider,
installationIndex: number,
installations: string[],
page = 1,
per_page = 30,
) {
const installationId = installations[installationIndex];
const response = await openHands.get<GitRepository[]>(
"/api/user/repositories",
{
params: {
selected_provider,
sort: "pushed",
page,
per_page,
installation_id: installationId,
},
},
);
const link =
response.data.length > 0 && response.data[0].link_header
? response.data[0].link_header
: "";
const nextPage = extractNextPageFromLink(link);
let nextInstallation: number | null;
if (nextPage) {
nextInstallation = installationIndex;
} else if (installationIndex + 1 < installations.length) {
nextInstallation = installationIndex + 1;
} else {
nextInstallation = null;
}
return {
data: response.data,
nextPage,
installationIndex: nextInstallation,
};
}
static async getRepositoryBranches(
repository: string,
page: number = 1,
perPage: number = 30,
): Promise<PaginatedBranchesResponse> {
const { data } = await openHands.get<PaginatedBranchesResponse>(
`/api/user/repository/branches?repository=${encodeURIComponent(repository)}&page=${page}&per_page=${perPage}`,
);
return data;
}
static async searchRepositoryBranches(
repository: string,
query: string,
perPage: number = 30,
selectedProvider?: Provider,
): Promise<Branch[]> {
const { data } = await openHands.get<Branch[]>(
`/api/user/search/branches`,
{
params: {
repository,
query,
per_page: perPage,
selected_provider: selectedProvider,
},
},
);
return data;
}
/**
* Get the available microagents associated with a conversation
@@ -637,15 +399,6 @@ class OpenHands {
* @param repo The repository name
* @returns The available microagents for the repository
*/
static async getRepositoryMicroagents(
owner: string,
repo: string,
): Promise<RepositoryMicroagent[]> {
const { data } = await openHands.get<RepositoryMicroagent[]>(
`/api/user/repository/${owner}/${repo}/microagents`,
);
return data;
}
/**
* Get the content of a specific microagent from a repository
@@ -654,19 +407,6 @@ class OpenHands {
* @param filePath The path to the microagent file within the repository
* @returns The microagent content and metadata
*/
static async getRepositoryMicroagentContent(
owner: string,
repo: string,
filePath: string,
): Promise<MicroagentContentResponse> {
const { data } = await openHands.get<MicroagentContentResponse>(
`/api/user/repository/${owner}/${repo}/microagents/content`,
{
params: { file_path: filePath },
},
);
return data;
}
static async getMicroagentPrompt(
conversationId: string,
@@ -755,39 +495,6 @@ class OpenHands {
);
return response.data;
}
/**
* Get the user installation IDs
* @param provider The provider to get installation IDs for (github, bitbucket, etc.)
* @returns List of installation IDs
*/
static async getUserInstallationIds(provider: Provider): Promise<string[]> {
const { data } = await openHands.get<string[]>(
`/api/user/installations?provider=${provider}`,
);
return data;
}
static async getMicroagentManagementConversations(
selectedRepository: string,
pageId?: string,
limit: number = 100,
): Promise<Conversation[]> {
const params: Record<string, string | number> = {
limit,
selected_repository: selectedRepository,
};
if (pageId) {
params.page_id = pageId;
}
const { data } = await openHands.get<ResultSet<Conversation>>(
"/api/microagent-management/conversations",
{ params },
);
return data.results;
}
}
export default OpenHands;
-28
View File
@@ -26,10 +26,6 @@ export interface FeedbackResponse {
body: FeedbackBodyResponse;
}
export interface GitHubAccessTokenResponse {
access_token: string;
}
export interface AuthenticationResponse {
message: string;
login?: string; // Only present when allow list is enabled
@@ -44,25 +40,6 @@ export interface Feedback {
trajectory: unknown[];
}
export interface GetConfigResponse {
APP_MODE: "saas" | "oss";
APP_SLUG?: string;
GITHUB_CLIENT_ID: string;
POSTHOG_CLIENT_KEY: string;
PROVIDERS_CONFIGURED?: Provider[];
AUTH_URL?: string;
FEATURE_FLAGS: {
ENABLE_BILLING: boolean;
HIDE_LLM_SETTINGS: boolean;
ENABLE_JIRA: boolean;
ENABLE_JIRA_DC: boolean;
ENABLE_LINEAR: boolean;
};
MAINTENANCE?: {
startTime: string;
};
}
export interface GetVSCodeUrlResponse {
vscode_url: string | null;
error?: string;
@@ -73,11 +50,6 @@ export interface GetTrajectoryResponse {
error?: string;
}
export interface AuthenticateResponse {
message?: string;
error?: string;
}
export interface RepositorySelection {
selected_repository: string | null;
selected_branch: string | null;
@@ -0,0 +1,49 @@
import { openHands } from "../open-hands-axios";
import { GetConfigResponse } from "./option.types";
/**
* Service for handling API options endpoints
*/
class OptionService {
/**
* Retrieve the list of models available
* @returns List of models available
*/
static async getModels(): Promise<string[]> {
const { data } = await openHands.get<string[]>("/api/options/models");
return data;
}
/**
* Retrieve the list of agents available
* @returns List of agents available
*/
static async getAgents(): Promise<string[]> {
const { data } = await openHands.get<string[]>("/api/options/agents");
return data;
}
/**
* Retrieve the list of security analyzers available
* @returns List of security analyzers available
*/
static async getSecurityAnalyzers(): Promise<string[]> {
const { data } = await openHands.get<string[]>(
"/api/options/security-analyzers",
);
return data;
}
/**
* Get the configuration from the server
* @returns Configuration response
*/
static async getConfig(): Promise<GetConfigResponse> {
const { data } = await openHands.get<GetConfigResponse>(
"/api/options/config",
);
return data;
}
}
export default OptionService;
@@ -0,0 +1,20 @@
import { Provider } from "#/types/settings";
export interface GetConfigResponse {
APP_MODE: "saas" | "oss";
APP_SLUG?: string;
GITHUB_CLIENT_ID: string;
POSTHOG_CLIENT_KEY: string;
PROVIDERS_CONFIGURED?: Provider[];
AUTH_URL?: string;
FEATURE_FLAGS: {
ENABLE_BILLING: boolean;
HIDE_LLM_SETTINGS: boolean;
ENABLE_JIRA: boolean;
ENABLE_JIRA_DC: boolean;
ENABLE_LINEAR: boolean;
};
MAINTENANCE?: {
startTime: string;
};
}
@@ -0,0 +1,30 @@
import { openHands } from "../open-hands-axios";
import { GitUser } from "#/types/git";
/**
* User Service API - Handles all user-related API endpoints
*/
class UserService {
/**
* Get the current user's Git information
* @returns Git user information
*/
static async getUser(): Promise<GitUser> {
const response = await openHands.get<GitUser>("/api/user/info");
const { data } = response;
const user: GitUser = {
id: data.id,
login: data.login,
avatar_url: data.avatar_url,
company: data.company,
name: data.name,
email: data.email,
};
return user;
}
}
export default UserService;
@@ -31,27 +31,21 @@ export function GitControlBarBranchButton({
target="_blank"
rel="noopener noreferrer"
className={cn(
"group flex flex-row items-center justify-between gap-2 pl-2.5 pr-2.5 py-1 rounded-[100px] w-fit max-w-none flex-shrink-0 max-w-[108px] truncate relative",
"group flex flex-row items-center justify-between gap-2 pl-2.5 pr-2.5 py-1 rounded-[100px] w-fit flex-shrink-0 max-w-[200px] truncate relative",
hasBranch
? "border border-[#525252] bg-transparent hover:border-[#454545] cursor-pointer"
: "border border-[rgba(71,74,84,0.50)] bg-transparent cursor-not-allowed min-w-[108px]",
)}
>
<div className="flex flex-row gap-2 items-center justify-start">
<div className="w-3 h-3 flex items-center justify-center">
<BranchIcon width={12} height={12} color="white" />
</div>
<div
className={cn(
"font-normal text-white text-sm leading-5 truncate",
hasBranch && "max-w-[70px]",
)}
title={buttonText}
>
{buttonText}
</div>
<div className="w-3 h-3 flex items-center justify-center flex-shrink-0">
<BranchIcon width={12} height={12} color="white" />
</div>
<div
className="font-normal text-white text-sm leading-5 truncate"
title={buttonText}
>
{buttonText}
</div>
{hasBranch && <GitExternalLinkIcon />}
</a>
);
@@ -33,32 +33,27 @@ export function GitControlBarRepoButton({
target="_blank"
rel="noopener noreferrer"
className={cn(
"group flex flex-row items-center justify-between gap-2 pl-2.5 pr-2.5 py-1 rounded-[100px] w-fit flex-shrink-0 max-w-[170px] truncate relative",
"group flex flex-row items-center justify-between gap-2 pl-2.5 pr-2.5 py-1 rounded-[100px] flex-1 truncate relative",
hasRepository
? "border border-[#525252] bg-transparent hover:border-[#454545] cursor-pointer"
: "border border-[rgba(71,74,84,0.50)] bg-transparent cursor-not-allowed min-w-[170px]",
)}
>
<div className="flex flex-row gap-2 items-center justify-start">
<div className="w-3 h-3 flex items-center justify-center">
{hasRepository ? (
<GitProviderIcon
gitProvider={gitProvider as Provider}
className="w-3 h-3 inline-flex"
/>
) : (
<RepoForkedIcon width={12} height={12} color="white" />
)}
</div>
<div
className={cn(
"font-normal text-white text-sm leading-5 truncate",
hasRepository && "max-w-[100px]",
)}
title={buttonText}
>
{buttonText}
</div>
<div className="w-3 h-3 flex items-center justify-center flex-shrink-0">
{hasRepository ? (
<GitProviderIcon
gitProvider={gitProvider as Provider}
className="w-3 h-3 inline-flex"
/>
) : (
<RepoForkedIcon width={12} height={12} color="white" />
)}
</div>
<div
className="font-normal text-white text-sm leading-5 truncate flex-1 min-w-0"
title={buttonText}
>
{buttonText}
</div>
{hasRepository && <GitExternalLinkIcon />}
</a>
@@ -0,0 +1,26 @@
import { useTranslation } from "react-i18next";
import { cn } from "#/utils/utils";
interface WaitingForRuntimeMessageProps {
className?: string;
testId?: string;
}
export function WaitingForRuntimeMessage({
className,
testId,
}: WaitingForRuntimeMessageProps) {
const { t } = useTranslation();
return (
<div
data-testid={testId}
className={cn(
"w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light",
className,
)}
>
{t("DIFF_VIEWER$WAITING_FOR_RUNTIME")}
</div>
);
}
@@ -57,18 +57,13 @@ export function ConversationMain() {
<PanelGroup
direction="horizontal"
className="grow h-full min-h-0 min-w-0"
autoSaveId="react-resizable-panels:layout"
>
<Panel
defaultSize={50}
minSize={30}
maxSize={80}
className="overflow-hidden bg-base"
>
<Panel minSize={30} maxSize={80} className="overflow-hidden bg-base">
<ChatInterfaceWrapper isRightPanelShown={isRightPanelShown} />
</Panel>
<PanelResizeHandle className="cursor-ew-resize" />
<Panel
defaultSize={50}
minSize={20}
maxSize={70}
className="flex flex-col overflow-hidden"
@@ -1,6 +1,7 @@
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { useLocalStorage } from "@uidotdev/usehooks";
import JupyterIcon from "#/icons/jupyter.svg?react";
import TerminalIcon from "#/icons/terminal.svg?react";
import GlobeIcon from "#/icons/globe.svg?react";
@@ -15,6 +16,7 @@ import { VSCodeTooltipContent } from "./vscode-tooltip-content";
import {
setHasRightPanelToggled,
setSelectedTab,
setIsRightPanelShown,
type ConversationTab,
} from "#/state/conversation-slice";
import { RootState } from "#/store";
@@ -28,10 +30,30 @@ export function ConversationTabs() {
(state: RootState) => state.conversation,
);
// Persist selectedTab and isRightPanelShown in localStorage
const [persistedSelectedTab, setPersistedSelectedTab] =
useLocalStorage<ConversationTab | null>(
"conversation-selected-tab",
"editor",
);
const [persistedIsRightPanelShown, setPersistedIsRightPanelShown] =
useLocalStorage<boolean>("conversation-right-panel-shown", true);
const onTabChange = (value: ConversationTab | null) => {
dispatch(setSelectedTab(value));
// Persist the selected tab to localStorage
setPersistedSelectedTab(value);
};
// Initialize Redux state from localStorage on component mount
useEffect(() => {
// Initialize selectedTab from localStorage if available
dispatch(setSelectedTab(persistedSelectedTab));
dispatch(setIsRightPanelShown(persistedIsRightPanelShown));
dispatch(setHasRightPanelToggled(persistedIsRightPanelShown));
}, []);
useEffect(() => {
const handlePanelVisibilityChange = () => {
if (isRightPanelShown) {
@@ -51,11 +73,13 @@ export function ConversationTabs() {
if (selectedTab === tab && isRightPanelShown) {
// If clicking the same active tab, close the drawer
dispatch(setHasRightPanelToggled(false));
setPersistedIsRightPanelShown(false);
} else {
// If clicking a different tab or drawer is closed, open drawer and select tab
onTabChange(tab);
if (!isRightPanelShown) {
dispatch(setHasRightPanelToggled(true));
setPersistedIsRightPanelShown(true);
}
}
};
@@ -1,7 +1,7 @@
import { useState, useEffect } from "react";
import { Provider } from "#/types/settings";
import { GitRepository } from "#/types/git";
import OpenHands from "#/api/open-hands";
import GitService from "#/api/git-service/git-service.api";
export function useUrlSearch(inputValue: string, provider: Provider) {
const [urlSearchResults, setUrlSearchResults] = useState<GitRepository[]>([]);
@@ -16,7 +16,7 @@ export function useUrlSearch(inputValue: string, provider: Provider) {
setIsUrlSearchLoading(true);
try {
const repositories = await OpenHands.searchGitRepositories(
const repositories = await GitService.searchGitRepositories(
repoName,
3,
provider,
@@ -1,22 +0,0 @@
import { useTranslation } from "react-i18next";
export function GuideMessage() {
const { t } = useTranslation();
return (
<div className="px-4 md:px-0 w-full flex items-center justify-center">
<div className="w-fit flex items-center justify-center gap-1 px-[15px] rounded-[12px] bg-[#454545] leading-5 text-white text-[15px] font-normal md:h-9.5 m-1">
<div className="pb-1 md:py-0">
<span className="">{t("HOME$GUIDE_MESSAGE_TITLE")} </span>
<a
href="https://docs.all-hands.dev/usage/getting-started"
target="_blank"
rel="noopener noreferrer"
>
<span className="underline">{t("COMMON$CLICK_HERE")}</span>
</a>
</div>
</div>
</div>
);
}
@@ -1,19 +0,0 @@
import { useTranslation } from "react-i18next";
import { GuideMessage } from "./guide-message";
export function HomeHeader() {
const { t } = useTranslation();
return (
<header className="flex flex-col items-center">
<GuideMessage />
<div className="mt-12 flex flex-col gap-4 items-center">
<div className="h-[80px] flex items-center">
<span className="text-[32px] text-white font-bold leading-5">
{t("HOME$LETS_START_BUILDING")}
</span>
</div>
</div>
</header>
);
}
@@ -0,0 +1,18 @@
import { useTranslation } from "react-i18next";
export function GuideMessage() {
const { t } = useTranslation();
return (
<div className="w-fit flex flex-col md:flex-row items-start md:items-center justify-center gap-1 rounded-[12px] bg-[#454545] leading-5 text-white text-[15px] font-normal m-1 md:h-9.5 px-4 pb-1 md:px-[15px] md:py-0">
<span className="">{t("HOME$GUIDE_MESSAGE_TITLE")} </span>
<a
href="https://docs.all-hands.dev/usage/getting-started"
target="_blank"
rel="noopener noreferrer"
>
<span className="underline">{t("COMMON$CLICK_HERE")}</span>
</a>
</div>
);
}
@@ -0,0 +1,12 @@
import { useTranslation } from "react-i18next";
import { Typography } from "#/ui/typography";
export function HomeHeaderTitle() {
const { t } = useTranslation();
return (
<div className="h-[80px] flex items-center">
<Typography.H1>{t("HOME$LETS_START_BUILDING")}</Typography.H1>
</div>
);
}
@@ -0,0 +1,11 @@
import { GuideMessage } from "./guide-message";
import { HomeHeaderTitle } from "./home-header-title";
export function HomeHeader() {
return (
<header className="flex flex-col items-center gap-12">
<GuideMessage />
<HomeHeaderTitle />
</header>
);
}
@@ -8,6 +8,7 @@ import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bott
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { I18nKey } from "#/i18n/declaration";
import JupyterLargeIcon from "#/icons/jupyter-large.svg?react";
import { WaitingForRuntimeMessage } from "../chat/waiting-for-runtime-message";
interface JupyterEditorProps {
maxWidth: number;
@@ -28,11 +29,7 @@ export function JupyterEditor({ maxWidth }: JupyterEditorProps) {
return (
<>
{isRuntimeInactive && (
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
{t("DIFF_VIEWER$WAITING_FOR_RUNTIME")}
</div>
)}
{isRuntimeInactive && <WaitingForRuntimeMessage />}
{!isRuntimeInactive && cells.length > 0 && (
<div className="flex-1 h-full flex flex-col" style={{ maxWidth }}>
<div
@@ -1,10 +1,10 @@
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { RootState } from "#/store";
import { useTerminal } from "#/hooks/use-terminal";
import "@xterm/xterm/css/xterm.css";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { cn } from "#/utils/utils";
import { WaitingForRuntimeMessage } from "../chat/waiting-for-runtime-message";
function Terminal() {
const { commands } = useSelector((state: RootState) => state.cmd);
@@ -12,19 +12,13 @@ function Terminal() {
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
const { t } = useTranslation();
const ref = useTerminal({
commands,
});
return (
<div className="h-full flex flex-col rounded-xl">
{isRuntimeInactive && (
<div className="w-full flex items-center text-center justify-center text-2xl text-tertiary-light pt-16">
{t("DIFF_VIEWER$WAITING_FOR_RUNTIME")}
</div>
)}
{isRuntimeInactive && <WaitingForRuntimeMessage className="pt-16" />}
<div className="flex-1 min-h-0 p-4">
<div
@@ -9,7 +9,7 @@ import GitHubLogo from "#/assets/branding/github-logo.svg?react";
import GitLabLogo from "#/assets/branding/gitlab-logo.svg?react";
import BitbucketLogo from "#/assets/branding/bitbucket-logo.svg?react";
import { useAuthUrl } from "#/hooks/use-auth-url";
import { GetConfigResponse } from "#/api/open-hands.types";
import { GetConfigResponse } from "#/api/option-service/option.types";
import { Provider } from "#/types/settings";
interface AuthModalProps {
+2 -2
View File
@@ -13,7 +13,7 @@ import posthog from "posthog-js";
import "./i18n";
import { QueryClientProvider } from "@tanstack/react-query";
import store from "./store";
import OpenHands from "./api/open-hands";
import OptionService from "./api/option-service/option-service.api";
import { displayErrorToast } from "./utils/custom-toast-handlers";
import { queryClient } from "./query-client-config";
@@ -25,7 +25,7 @@ function PosthogInit() {
React.useEffect(() => {
(async () => {
try {
const config = await OpenHands.getConfig();
const config = await OptionService.getConfig();
setPosthogClientKey(config.POSTHOG_CLIENT_KEY);
} catch (error) {
displayErrorToast("Error fetching PostHog client key");
@@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import OpenHands from "#/api/open-hands";
import SettingsService from "#/settings-service/settings-service.api";
import { MCPSSEServer, MCPStdioServer, MCPSHTTPServer } from "#/types/settings";
type MCPServerType = "sse" | "stdio" | "shttp";
@@ -57,7 +57,7 @@ export function useAddMcpServer() {
mcp_config: newConfig,
};
await OpenHands.saveSettings(apiSettings);
await SettingsService.saveSettings(apiSettings);
},
onSuccess: () => {
// Invalidate the settings query to trigger a refetch
@@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import OpenHands from "#/api/open-hands";
import SettingsService from "#/settings-service/settings-service.api";
import { MCPConfig } from "#/types/settings";
export function useDeleteMcpServer() {
@@ -27,7 +27,7 @@ export function useDeleteMcpServer() {
mcp_config: newConfig,
};
await OpenHands.saveSettings(apiSettings);
await SettingsService.saveSettings(apiSettings);
},
onSuccess: () => {
// Invalidate the settings query to trigger a refetch
+2 -2
View File
@@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import posthog from "posthog-js";
import OpenHands from "#/api/open-hands";
import AuthService from "#/api/auth-service/auth-service.api";
import { useConfig } from "../query/use-config";
import { clearLoginData } from "#/utils/local-storage";
@@ -9,7 +9,7 @@ export const useLogout = () => {
const { data: config } = useConfig();
return useMutation({
mutationFn: () => OpenHands.logout(config?.APP_MODE ?? "oss"),
mutationFn: () => AuthService.logout(config?.APP_MODE ?? "oss"),
onSuccess: async () => {
queryClient.removeQueries({ queryKey: ["tasks"] });
queryClient.removeQueries({ queryKey: ["settings"] });
@@ -1,8 +1,9 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import posthog from "posthog-js";
import { DEFAULT_SETTINGS } from "#/services/settings";
import OpenHands from "#/api/open-hands";
import { PostSettings, PostApiSettings } from "#/types/settings";
import SettingsService from "#/settings-service/settings-service.api";
import { PostSettings } from "#/types/settings";
import { PostApiSettings } from "#/settings-service/settings.types";
import { useSettings } from "../query/use-settings";
const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
@@ -36,7 +37,7 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
settings.GIT_USER_EMAIL?.trim() || DEFAULT_SETTINGS.GIT_USER_EMAIL,
};
await OpenHands.saveSettings(apiSettings);
await SettingsService.saveSettings(apiSettings);
};
export const useSaveSettings = () => {
@@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import OpenHands from "#/api/open-hands";
import SettingsService from "#/settings-service/settings-service.api";
import { MCPSSEServer, MCPStdioServer, MCPSHTTPServer } from "#/types/settings";
type MCPServerType = "sse" | "stdio" | "shttp";
@@ -59,7 +59,7 @@ export function useUpdateMcpServer() {
mcp_config: newConfig,
};
await OpenHands.saveSettings(apiSettings);
await SettingsService.saveSettings(apiSettings);
},
onSuccess: () => {
// Invalidate the settings query to trigger a refetch
@@ -1,10 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import OptionService from "#/api/option-service/option-service.api";
const fetchAiConfigOptions = async () => ({
models: await OpenHands.getModels(),
agents: await OpenHands.getAgents(),
securityAnalyzers: await OpenHands.getSecurityAnalyzers(),
models: await OptionService.getModels(),
agents: await OptionService.getAgents(),
securityAnalyzers: await OptionService.getSecurityAnalyzers(),
});
export const useAIConfigOptions = () =>
@@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import { useConfig } from "./use-config";
import { useIsAuthed } from "./use-is-authed";
import OpenHands from "#/api/open-hands";
import GitService from "#/api/git-service/git-service.api";
import { useUserProviders } from "../use-user-providers";
import { Provider } from "#/types/settings";
import { shouldUseInstallationRepos } from "#/utils/utils";
@@ -13,7 +13,7 @@ export const useAppInstallations = (selectedProvider: Provider | null) => {
return useQuery({
queryKey: ["installations", providers || [], selectedProvider],
queryFn: () => OpenHands.getUserInstallationIds(selectedProvider!),
queryFn: () => GitService.getUserInstallationIds(selectedProvider!),
enabled:
userIsAuthenticated &&
!!selectedProvider &&
+2 -2
View File
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import OptionService from "#/api/option-service/option-service.api";
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
export const useConfig = () => {
@@ -7,7 +7,7 @@ export const useConfig = () => {
return useQuery({
queryKey: ["config"],
queryFn: OpenHands.getConfig,
queryFn: OptionService.getConfig,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes,
enabled: !isOnTosPage,
@@ -4,7 +4,7 @@ import { useUserProviders } from "../use-user-providers";
import { useAppInstallations } from "./use-app-installations";
import { GitRepository } from "../../types/git";
import { Provider } from "../../types/settings";
import OpenHands from "#/api/open-hands";
import GitService from "#/api/git-service/git-service.api";
import { shouldUseInstallationRepos } from "#/utils/utils";
interface UseGitRepositoriesOptions {
@@ -60,7 +60,7 @@ export function useGitRepositories(options: UseGitRepositoriesOptions) {
throw new Error("Missing installation list");
}
return OpenHands.retrieveInstallationRepositories(
return GitService.retrieveInstallationRepositories(
provider,
installationIndex || 0,
installations,
@@ -69,7 +69,7 @@ export function useGitRepositories(options: UseGitRepositoriesOptions) {
);
}
return OpenHands.retrieveUserGitRepositories(
return GitService.retrieveUserGitRepositories(
provider,
pageParam as number,
pageSize,
+2 -2
View File
@@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import React from "react";
import posthog from "posthog-js";
import { useConfig } from "./use-config";
import OpenHands from "#/api/open-hands";
import UserService from "#/api/user-service/user-service.api";
import { useShouldShowUserFeatures } from "#/hooks/use-should-show-user-features";
export const useGitUser = () => {
@@ -13,7 +13,7 @@ export const useGitUser = () => {
const user = useQuery({
queryKey: ["user"],
queryFn: OpenHands.getGitUser,
queryFn: UserService.getUser,
enabled: shouldFetchUser,
retry: false,
staleTime: 1000 * 60 * 5, // 5 minutes
@@ -3,7 +3,7 @@ import { useAppInstallations } from "./use-app-installations";
import { useConfig } from "./use-config";
import { useUserProviders } from "../use-user-providers";
import { Provider } from "#/types/settings";
import OpenHands from "#/api/open-hands";
import GitService from "#/api/git-service/git-service.api";
import { shouldUseInstallationRepos } from "#/utils/utils";
export const useInstallationRepositories = (
@@ -31,7 +31,7 @@ export const useInstallationRepositories = (
throw new Error("Missing installation list");
}
return OpenHands.retrieveInstallationRepositories(
return GitService.retrieveInstallationRepositories(
selectedProvider!,
installationIndex || 0,
installations,
+2 -2
View File
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import axios, { AxiosError } from "axios";
import OpenHands from "#/api/open-hands";
import AuthService from "#/api/auth-service/auth-service.api";
import { useConfig } from "./use-config";
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
@@ -15,7 +15,7 @@ export const useIsAuthed = () => {
queryFn: async () => {
try {
// If in OSS mode or authentication succeeds, return true
await OpenHands.authenticate(appMode!);
await AuthService.authenticate(appMode!);
return true;
} catch (error) {
// If it's a 401 error, return false (not authenticated)
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import MicroagentManagementService from "#/ui/microagent-management-service/microagent-management-service.api";
export const useMicroagentManagementConversations = (
selectedRepository: string,
@@ -16,7 +16,7 @@ export const useMicroagentManagementConversations = (
selectedRepository,
],
queryFn: () =>
OpenHands.getMicroagentManagementConversations(
MicroagentManagementService.getMicroagentManagementConversations(
selectedRepository,
pageId,
limit,
@@ -1,5 +1,5 @@
import { useQuery, useInfiniteQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import GitService from "#/api/git-service/git-service.api";
import { Branch, PaginatedBranchesResponse } from "#/types/git";
export const useRepositoryBranches = (repository: string | null) =>
@@ -7,7 +7,7 @@ export const useRepositoryBranches = (repository: string | null) =>
queryKey: ["repository", repository, "branches"],
queryFn: async () => {
if (!repository) return [];
const response = await OpenHands.getRepositoryBranches(repository);
const response = await GitService.getRepositoryBranches(repository);
// Ensure we return an array even if the response is malformed
return Array.isArray(response.branches) ? response.branches : [];
},
@@ -31,7 +31,7 @@ export const useRepositoryBranchesPaginated = (
total_count: 0,
};
}
return OpenHands.getRepositoryBranches(
return GitService.getRepositoryBranches(
repository,
pageParam as number,
perPage,
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import GitService from "#/api/git-service/git-service.api";
export const useRepositoryMicroagentContent = (
owner: string,
@@ -10,7 +10,7 @@ export const useRepositoryMicroagentContent = (
useQuery({
queryKey: ["repository", "microagent", "content", owner, repo, filePath],
queryFn: () =>
OpenHands.getRepositoryMicroagentContent(owner, repo, filePath),
GitService.getRepositoryMicroagentContent(owner, repo, filePath),
enabled: !!owner && !!repo && !!filePath,
staleTime: cacheDisabled ? 0 : 1000 * 60 * 5, // 5 minutes
gcTime: cacheDisabled ? 0 : 1000 * 60 * 15, // 15 minutes
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import GitService from "#/api/git-service/git-service.api";
export const useRepositoryMicroagents = (
owner: string,
@@ -8,7 +8,7 @@ export const useRepositoryMicroagents = (
) =>
useQuery({
queryKey: ["repository", "microagents", owner, repo],
queryFn: () => OpenHands.getRepositoryMicroagents(owner, repo),
queryFn: () => GitService.getRepositoryMicroagents(owner, repo),
enabled: !!owner && !!repo,
staleTime: cacheDisabled ? 0 : 1000 * 60 * 5, // 5 minutes
gcTime: cacheDisabled ? 0 : 1000 * 60 * 15, // 15 minutes
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import GitService from "#/api/git-service/git-service.api";
import { Branch } from "#/types/git";
import { Provider } from "#/types/settings";
@@ -21,7 +21,7 @@ export function useSearchBranches(
],
queryFn: async () => {
if (!repository || !query) return [];
return OpenHands.searchRepositoryBranches(
return GitService.searchRepositoryBranches(
repository,
query,
perPage,
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import GitService from "#/api/git-service/git-service.api";
import { Provider } from "#/types/settings";
export function useSearchRepositories(
@@ -11,7 +11,7 @@ export function useSearchRepositories(
return useQuery({
queryKey: ["repositories", "search", query, selectedProvider, pageSize],
queryFn: () =>
OpenHands.searchGitRepositories(
GitService.searchGitRepositories(
query,
pageSize,
selectedProvider || undefined,
+2 -2
View File
@@ -1,14 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import React from "react";
import posthog from "posthog-js";
import OpenHands from "#/api/open-hands";
import SettingsService from "#/settings-service/settings-service.api";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
import { Settings } from "#/types/settings";
import { useIsAuthed } from "./use-is-authed";
const getSettingsQueryFn = async (): Promise<Settings> => {
const apiSettings = await OpenHands.getSettings();
const apiSettings = await SettingsService.getSettings();
return {
LLM_MODEL: apiSettings.llm_model,
@@ -2,7 +2,7 @@ import { useInfiniteQuery } from "@tanstack/react-query";
import { useConfig } from "./use-config";
import { useUserProviders } from "../use-user-providers";
import { Provider } from "#/types/settings";
import OpenHands from "#/api/open-hands";
import GitService from "#/api/git-service/git-service.api";
import { shouldUseInstallationRepos } from "#/utils/utils";
export const useUserRepositories = (selectedProvider: Provider | null) => {
@@ -12,7 +12,7 @@ export const useUserRepositories = (selectedProvider: Provider | null) => {
const repos = useInfiniteQuery({
queryKey: ["repositories", providers || [], selectedProvider],
queryFn: async ({ pageParam }) =>
OpenHands.retrieveUserGitRepositories(selectedProvider!, pageParam, 30),
GitService.retrieveUserGitRepositories(selectedProvider!, pageParam, 30),
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage,
enabled:
+1 -1
View File
@@ -1,5 +1,5 @@
import { generateAuthUrl } from "#/utils/generate-auth-url";
import { GetConfigResponse } from "#/api/open-hands.types";
import { GetConfigResponse } from "#/api/option-service/option.types";
interface UseAuthUrlConfig {
appMode: GetConfigResponse["APP_MODE"] | null;
+1 -1
View File
@@ -1,5 +1,5 @@
import { useAuthUrl } from "./use-auth-url";
import { GetConfigResponse } from "#/api/open-hands.types";
import { GetConfigResponse } from "#/api/option-service/option.types";
interface UseGitHubAuthUrlConfig {
appMode: GetConfigResponse["APP_MODE"] | null;
+7 -6
View File
@@ -1,12 +1,13 @@
import { delay, http, HttpResponse } from "msw";
import {
GetConfigResponse,
Conversation,
ResultSet,
} from "#/api/open-hands.types";
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 { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
import { Provider } from "#/types/settings";
import {
ApiSettings,
PostApiSettings,
} from "#/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";
-5
View File
@@ -46,11 +46,6 @@ function AppContent() {
useDocumentTitleFromState();
React.useEffect(() => {
console.log(
`isFetched: ${isFetched}, conversation: ${conversation}, isAuthed: ${isAuthed}`,
);
console.log("conversation: ", conversation);
console.log(`conversation status: " ${conversation?.status}`);
if (isFetched && !conversation && isAuthed) {
displayErrorToast(
"This conversation does not exist, or you do not have permission to access it.",
+1 -1
View File
@@ -1,6 +1,6 @@
import React from "react";
import { PrefetchPageLinks } from "react-router";
import { HomeHeader } from "#/components/features/home/home-header";
import { HomeHeader } from "#/components/features/home/home-header/home-header";
import { RepoConnector } from "#/components/features/home/repo-connector";
import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestions";
import { GitRepository } from "#/types/git";
@@ -1,6 +1,6 @@
import { queryClient } from "#/query-client-config";
import { GetConfigResponse } from "#/api/open-hands.types";
import OpenHands from "#/api/open-hands";
import { GetConfigResponse } from "#/api/option-service/option.types";
import OptionService from "#/api/option-service/option-service.api";
import { MicroagentManagementContent } from "#/components/features/microagent-management/microagent-management-content";
import { ConversationSubscriptionsProvider } from "#/context/conversation-subscriptions-provider";
import { EventHandler } from "#/wrapper/event-handler";
@@ -8,7 +8,7 @@ import { EventHandler } from "#/wrapper/event-handler";
export const clientLoader = async () => {
let config = queryClient.getQueryData<GetConfigResponse>(["config"]);
if (!config) {
config = await OpenHands.getConfig();
config = await OptionService.getConfig();
queryClient.setQueryData<GetConfigResponse>(["config"], config);
}
+3 -3
View File
@@ -6,9 +6,9 @@ import { cn } from "#/utils/utils";
import { useConfig } from "#/hooks/query/use-config";
import { I18nKey } from "#/i18n/declaration";
import { Route } from "./+types/settings";
import OpenHands from "#/api/open-hands";
import OptionService from "#/api/option-service/option-service.api";
import { queryClient } from "#/query-client-config";
import { GetConfigResponse } from "#/api/open-hands.types";
import { GetConfigResponse } from "#/api/option-service/option.types";
import { useSubscriptionAccess } from "#/hooks/query/use-subscription-access";
const SAAS_ONLY_PATHS = [
@@ -42,7 +42,7 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
let config = queryClient.getQueryData<GetConfigResponse>(["config"]);
if (!config) {
config = await OpenHands.getConfig();
config = await OptionService.getConfig();
queryClient.setQueryData<GetConfigResponse>(["config"], config);
}
+3 -14
View File
@@ -6,6 +6,7 @@ import { RootState } from "#/store";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { useVSCodeUrl } from "#/hooks/query/use-vscode-url";
import { VSCODE_IN_NEW_TAB } from "#/utils/feature-flags";
import { WaitingForRuntimeMessage } from "#/components/features/chat/waiting-for-runtime-message";
function VSCodeTab() {
const { t } = useTranslation();
@@ -39,20 +40,8 @@ function VSCodeTab() {
}
};
if (isRuntimeInactive) {
return (
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
{t("DIFF_VIEWER$WAITING_FOR_RUNTIME")}
</div>
);
}
if (isLoading) {
return (
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
{t("DIFF_VIEWER$WAITING_FOR_RUNTIME")}
</div>
);
if (isRuntimeInactive || isLoading) {
return <WaitingForRuntimeMessage />;
}
if (error || (data && data.error) || !data?.url || iframeError) {
@@ -0,0 +1,28 @@
import { openHands } from "../api/open-hands-axios";
import { ApiSettings, PostApiSettings } from "./settings.types";
/**
* Settings service for managing application settings
*/
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");
return data;
}
/**
* 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> {
const data = await openHands.post("/api/settings", settings);
return data.status === 200;
}
}
export default SettingsService;
@@ -0,0 +1,53 @@
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;
};
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 })[];
};
};
@@ -117,8 +117,6 @@ export const conversationSlice = createSlice({
},
// Reset conversation state (useful for cleanup)
resetConversationState: (state) => {
state.selectedTab = "editor";
state.isRightPanelShown = true;
state.shouldHideSuggestions = false;
},
setHasRightPanelToggled: (state, action) => {
-38
View File
@@ -63,47 +63,9 @@ export type Settings = {
GIT_USER_EMAIL?: string;
};
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 | MCPSSEServer)[];
stdio_servers: MCPStdioServer[];
shttp_servers: (string | MCPSHTTPServer)[];
};
email?: string;
email_verified?: boolean;
git_user_name?: string;
git_user_email?: string;
};
export type PostSettings = Settings & {
user_consents_to_analytics: boolean | null;
llm_api_key?: string | null;
search_api_key?: string;
mcp_config?: MCPConfig;
};
export type PostApiSettings = ApiSettings & {
user_consents_to_analytics: boolean | null;
search_api_key?: string;
mcp_config?: MCPConfig;
};
@@ -0,0 +1,34 @@
import { openHands } from "#/api/open-hands-axios";
import { Conversation, ResultSet } from "#/api/open-hands.types";
class MicroagentManagementService {
/**
* Get conversations for microagent management
* @param selectedRepository The selected repository
* @param pageId Optional page ID for pagination
* @param limit Maximum number of conversations to return
* @returns List of conversations
*/
static async getMicroagentManagementConversations(
selectedRepository: string,
pageId?: string,
limit: number = 100,
): Promise<Conversation[]> {
const params: Record<string, string | number> = {
limit,
selected_repository: selectedRepository,
};
if (pageId) {
params.page_id = pageId;
}
const { data } = await openHands.get<ResultSet<Conversation>>(
"/api/microagent-management/conversations",
{ params },
);
return data.results;
}
}
export default MicroagentManagementService;
+53
View File
@@ -0,0 +1,53 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "#/utils/utils";
const typographyVariants = cva("", {
variants: {
variant: {
h1: "text-[32px] text-white font-bold leading-5",
},
},
defaultVariants: {
variant: "h1",
},
});
interface TypographyProps extends VariantProps<typeof typographyVariants> {
className?: string;
testId?: string;
children: React.ReactNode;
}
export function Typography({
variant,
className,
testId,
children,
}: TypographyProps) {
const Tag = variant as keyof React.JSX.IntrinsicElements;
return (
<Tag
data-testid={testId}
className={cn(typographyVariants({ variant }), className)}
>
{children}
</Tag>
);
}
// Export individual heading components for convenience
export function H1({
className,
testId,
children,
}: Omit<TypographyProps, "variant">) {
return (
<Typography variant="h1" className={className} testId={testId}>
{children}
</Typography>
);
}
// Attach H1 to Typography for the expected API
Typography.H1 = H1;
@@ -120,7 +120,7 @@ class GitHubResolverMixin(GitHubMixinBase):
'first': 50,
}
if after_cursor:
threads_variables['after'] = after_cursor
threads_variables['after'] = after_cursor # type: ignore[unreachable]
threads_data = await self.execute_graphql_query(
get_review_threads_graphql_query, threads_variables
@@ -167,7 +167,7 @@ class GitHubResolverMixin(GitHubMixinBase):
comments_variables['threadId'] = thread_id
comments_variables['page'] = 50
if after_cursor:
comments_variables['after'] = after_cursor
comments_variables['after'] = after_cursor # type: ignore[unreachable]
thread_comments_data = await self.execute_graphql_query(
get_thread_comments_graphql_query, comments_variables
@@ -358,6 +358,11 @@ class ActionExecutor:
INIT_COMMANDS.append(no_pager_cmd)
# Hack: for some reason when you set the openhands user to anything but root, tmux changes out
# of the mount directory on the first invocation.
if self.user_id != 0:
INIT_COMMANDS.append(f'cd {self._initial_cwd}')
logger.info(f'Initializing by running {len(INIT_COMMANDS)} bash commands...')
for command in INIT_COMMANDS:
action = CmdRunAction(command=command)
+12 -3
View File
@@ -206,7 +206,9 @@ class BashSession:
# else:
window_command = _shell_command
logger.debug(f'Initializing bash session with command: {window_command}')
logger.debug(
f'Initializing bash session in {self.work_dir} with command: {window_command}'
)
session_name = f'openhands-{self.username}-{uuid.uuid4()}'
self.session = self.server.new_session(
session_name=session_name,
@@ -331,6 +333,9 @@ class BashSession:
# Update the current working directory if it has changed
if metadata.working_dir != self._cwd and metadata.working_dir:
logger.debug(
f'directory_changed: {self._cwd}; {metadata.working_dir}; {command}'
)
self._cwd = metadata.working_dir
logger.debug(f'COMMAND OUTPUT: {pane_content}')
@@ -598,8 +603,12 @@ class BashSession:
logger.debug(
f'PANE CONTENT GOT after {time.time() - _start_time:.2f} seconds'
)
logger.debug(f'BEGIN OF PANE CONTENT: {cur_pane_output.split("\n")[:10]}')
logger.debug(f'END OF PANE CONTENT: {cur_pane_output.split("\n")[-10:]}')
cur_pane_lines = cur_pane_output.split('\n')
if len(cur_pane_lines) <= 20:
logger.debug('PANE_CONTENT: {cur_pane_output}')
else:
logger.debug(f'BEGIN OF PANE CONTENT: {cur_pane_lines[:10]}')
logger.debug(f'END OF PANE CONTENT: {cur_pane_lines[-10:]}')
ps1_matches = CmdOutputMetadata.matches_ps1_metadata(cur_pane_output)
current_ps1_count = len(ps1_matches)
@@ -567,7 +567,8 @@ class DockerNestedConversationManager(ConversationManager):
env_vars['SERVE_FRONTEND'] = '0'
env_vars['RUNTIME'] = 'local'
# TODO: In the long term we may come up with a more secure strategy for user management within the nested runtime.
env_vars['USER'] = 'root'
env_vars['USER'] = 'openhands' if config.run_as_openhands else 'root'
env_vars['SANDBOX_USER_ID'] = str(config.sandbox.user_id)
env_vars['SESSION_API_KEY'] = self._get_session_api_key_for_conversation(sid)
# We need to be able to specify the nested conversation id within the nested runtime
env_vars['ALLOW_SET_CONVERSATION_ID'] = '1'
-7
View File
@@ -103,16 +103,12 @@ async def connect(connection_id: str, environ: dict) -> None:
):
continue
elif isinstance(event, AgentStateChangedObservation):
logger.debug(
f'oh_event: AgentStateChangedObservation {event.agent_state}'
)
agent_state_changed = event
else:
await sio.emit('oh_event', event_to_dict(event), to=connection_id)
# Send the agent state changed event last if we have one
if agent_state_changed:
logger.debug(f'sending AgentStateChangedObservation {event.agent_state}')
await sio.emit(
'oh_event', event_to_dict(agent_state_changed), to=connection_id
)
@@ -125,9 +121,6 @@ async def connect(connection_id: str, environ: dict) -> None:
user_id, conversation_id, providers_set
)
logger.debug(
f'conversation manager type {conversation_manager.__class__.__name__}'
)
agent_loop_info = await conversation_manager.join_conversation(
conversation_id,
connection_id,
@@ -570,7 +570,7 @@ async def stop_conversation(
status='ok',
conversation_id=conversation_id,
message='Conversation stopped successfully',
conversation_status=ConversationStatus.STOPPED,
conversation_status=conversation_status,
)
except Exception as e:
logger.error(