Compare commits

...

15 Commits

Author SHA1 Message Date
openhands e4a91fee52 Fix: Update last_active_ts in dispatch method to prevent inactive sessions from persisting 2025-04-29 13:26:34 +00:00
Xingyao Wang 5fa01ed278 Fix mobile layout for repo picker and suggested tasks (#8137)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-28 21:39:09 -04:00
Xingyao Wang 1f747232cf feat: add agent memory microagents for experiment (#8122) 2025-04-28 19:08:44 -04:00
Engel Nyst 4b1ed30e97 Fix truncation, ensure first user message and log (#8103)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-28 22:43:41 +02:00
Panduka Muditha 998de564cd feat: Add CLI support for agent pause and resume (#8129)
Co-authored-by: Bashwara Undupitiya <bashwarau@verdentra.com>
2025-04-28 16:26:18 -04:00
Engel Nyst 06ce12eff4 Update resolver model (#8124) 2025-04-28 21:07:40 +02:00
sp.wack 88fc26d9b0 frontend: Better diff contrast (#8128) 2025-04-28 22:53:37 +04:00
dependabot[bot] 99233ec153 chore(deps): bump the version-all group with 6 updates (#8125)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-28 20:50:39 +02:00
sp.wack ae9573a503 chore(frontend): Breakdown settings screen (#7929)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-28 22:32:28 +04:00
dependabot[bot] f2725eeb3d chore(deps): bump the version-all group across 1 directory with 8 updates (#8123)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-04-28 17:18:21 +00:00
Engel Nyst 1b63633030 Simplify microagents (#8114)
Co-authored-by: Robert Brennan <accounts@rbren.io>
2025-04-28 15:00:06 +00:00
Ryan H. Tran 107789b5a8 Add missing last ps1 match output into combined output (#8097) 2025-04-28 21:06:26 +08:00
Panduka Muditha 04bdea5faf feat: Add CLI support for /new, /status and /settings (#8017)
Co-authored-by: Bashwara Undupitiya <bashwarau@verdentra.com>
2025-04-28 08:52:33 -04:00
Engel Nyst 2bad4ea3d2 Litellm emergency fix (#8116) 2025-04-28 08:36:45 +02:00
Rohit Malhotra 1c4c477b3f [Refactor]: Abstract resolver into classes (#7999)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-27 18:52:52 -04:00
117 changed files with 10086 additions and 4852 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ on:
LLM_MODEL:
required: false
type: string
default: "anthropic/claude-3-5-sonnet-20241022"
default: "anthropic/claude-3-7-sonnet-20250219"
LLM_API_VERSION:
required: false
type: string
@@ -4,7 +4,7 @@
Microagentes públicos são diretrizes especializadas acionadas por palavras-chave para todos os usuários do OpenHands.
Eles são definidos em arquivos markdown no diretório
[`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge).
[`microagents/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge).
Microagentes públicos:
- Monitoram comandos recebidos em busca de suas palavras-chave de acionamento.
@@ -15,7 +15,7 @@ Microagentes públicos:
## Microagentes Públicos Atuais
Para mais informações sobre microagentes específicos, consulte seus arquivos de documentação individuais no
diretório [`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge/).
diretório [`microagents/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/).
### Agente GitHub
**Arquivo**: `github.md`
@@ -59,7 +59,7 @@ yes | npm install package-name
## Contribuindo com um Microagente Público
Você pode criar seus próprios microagentes públicos adicionando novos arquivos markdown ao
diretório [`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge/).
diretório [`microagents/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/).
### Melhores Práticas para Microagentes Públicos
@@ -81,7 +81,7 @@ Antes de criar um microagente público, considere:
#### 2. Crie o Arquivo
Crie um novo arquivo markdown em [`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge/)
Crie um novo arquivo markdown em [`microagents/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/)
com um nome descritivo (por exemplo, `docker.md` para um agente focado em Docker).
Atualize o arquivo com o frontmatter necessário [de acordo com o formato exigido](./microagents-overview#microagent-format)
@@ -32,7 +32,7 @@ Before creating a global microagent, consider:
#### 2. Create File
Create a new Markdown file with a descriptive name in the appropriate directory:
[`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge)
[`microagents/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents)
#### 3. Testing the Global Microagent
@@ -0,0 +1,291 @@
import { render, screen, waitFor } from "@testing-library/react";
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 { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { AuthProvider } from "#/context/auth-context";
import { AvailableLanguages } from "#/i18n";
import * as CaptureConsent from "#/utils/handle-capture-consent";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
const renderAppSettingsScreen = () =>
render(<AppSettingsScreen />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
),
});
describe("Content", () => {
it("should render the screen", () => {
renderAppSettingsScreen();
screen.getByTestId("app-settings-screen");
});
it("should render the correct default values", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
language: "no",
user_consents_to_analytics: true,
enable_sound_notifications: true,
});
renderAppSettingsScreen();
await waitFor(() => {
const language = screen.getByTestId("language-input");
const analytics = screen.getByTestId("enable-analytics-switch");
const sound = screen.getByTestId("enable-sound-notifications-switch");
expect(language).toHaveValue("Norsk");
expect(analytics).toBeChecked();
expect(sound).toBeChecked();
});
});
it("should render the language options", async () => {
renderAppSettingsScreen();
const language = await screen.findByTestId("language-input");
await userEvent.click(language);
AvailableLanguages.forEach((lang) => {
const option = screen.getByText(lang.label);
expect(option).toBeInTheDocument();
});
});
});
describe("Form submission", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("should submit the form with the correct values", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
renderAppSettingsScreen();
const language = await screen.findByTestId("language-input");
const analytics = await screen.findByTestId("enable-analytics-switch");
const sound = await screen.findByTestId(
"enable-sound-notifications-switch",
);
expect(language).toHaveValue("English");
expect(analytics).not.toBeChecked();
expect(sound).not.toBeChecked();
// change language
await userEvent.click(language);
const norsk = screen.getByText("Norsk");
await userEvent.click(norsk);
expect(language).toHaveValue("Norsk");
// toggle options
await userEvent.click(analytics);
expect(analytics).toBeChecked();
await userEvent.click(sound);
expect(sound).toBeChecked();
// submit the form
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
language: "no",
user_consents_to_analytics: true,
enable_sound_notifications: true,
}),
);
});
it("should only enable the submit button when there are changes", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
renderAppSettingsScreen();
const submit = await screen.findByTestId("submit-button");
expect(submit).toBeDisabled();
// Language check
const language = await screen.findByTestId("language-input");
await userEvent.click(language);
const norsk = screen.getByText("Norsk");
await userEvent.click(norsk);
expect(submit).not.toBeDisabled();
await userEvent.click(language);
const english = screen.getByText("English");
await userEvent.click(english);
expect(submit).toBeDisabled();
// Analytics check
const analytics = await screen.findByTestId("enable-analytics-switch");
await userEvent.click(analytics);
expect(submit).not.toBeDisabled();
await userEvent.click(analytics);
expect(submit).toBeDisabled();
// Sound check
const sound = await screen.findByTestId(
"enable-sound-notifications-switch",
);
await userEvent.click(sound);
expect(submit).not.toBeDisabled();
await userEvent.click(sound);
expect(submit).toBeDisabled();
});
it("should call handleCaptureConsents with true when the analytics switch is toggled", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const handleCaptureConsentsSpy = vi.spyOn(
CaptureConsent,
"handleCaptureConsent",
);
renderAppSettingsScreen();
const analytics = await screen.findByTestId("enable-analytics-switch");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(analytics);
await userEvent.click(submit);
await waitFor(() =>
expect(handleCaptureConsentsSpy).toHaveBeenCalledWith(true),
);
});
it("should call handleCaptureConsents with false when the analytics switch is toggled", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
user_consents_to_analytics: true,
});
const handleCaptureConsentsSpy = vi.spyOn(
CaptureConsent,
"handleCaptureConsent",
);
renderAppSettingsScreen();
const analytics = await screen.findByTestId("enable-analytics-switch");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(analytics);
await userEvent.click(submit);
await waitFor(() =>
expect(handleCaptureConsentsSpy).toHaveBeenCalledWith(false),
);
});
// flaky test
it.skip("should disable the button when submitting changes", async () => {
renderAppSettingsScreen();
const submit = await screen.findByTestId("submit-button");
expect(submit).toBeDisabled();
const sound = await screen.findByTestId(
"enable-sound-notifications-switch",
);
await userEvent.click(sound);
expect(submit).not.toBeDisabled();
// submit the form
await userEvent.click(submit);
expect(submit).toHaveTextContent("Saving...");
expect(submit).toBeDisabled();
await waitFor(() => expect(submit).toHaveTextContent("Save"));
});
it("should disable the button after submitting changes", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
renderAppSettingsScreen();
const submit = await screen.findByTestId("submit-button");
expect(submit).toBeDisabled();
const sound = await screen.findByTestId(
"enable-sound-notifications-switch",
);
await userEvent.click(sound);
expect(submit).not.toBeDisabled();
// submit the form
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
await waitFor(() => expect(submit).toBeDisabled());
});
});
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");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const displaySuccessToastSpy = vi.spyOn(
ToastHandlers,
"displaySuccessToast",
);
renderAppSettingsScreen();
// Toggle setting to change
const sound = await screen.findByTestId(
"enable-sound-notifications-switch",
);
await userEvent.click(sound);
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
await waitFor(() => expect(displaySuccessToastSpy).toHaveBeenCalled());
});
it("should call displayErrorToast when the settings fail to save", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings"));
renderAppSettingsScreen();
// Toggle setting to change
const sound = await screen.findByTestId(
"enable-sound-notifications-switch",
);
await userEvent.click(sound);
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(displayErrorToastSpy).toHaveBeenCalled();
});
});
@@ -0,0 +1,461 @@
import { render, screen, waitFor } from "@testing-library/react";
import { createRoutesStub } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import GitSettingsScreen from "#/routes/git-settings";
import OpenHands from "#/api/open-hands";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { AuthProvider } from "#/context/auth-context";
import { GetConfigResponse } from "#/api/open-hands.types";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
const VALID_OSS_CONFIG: GetConfigResponse = {
APP_MODE: "oss",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
};
const VALID_SAAS_CONFIG: GetConfigResponse = {
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
};
const queryClient = new QueryClient();
const GitSettingsRouterStub = createRoutesStub([
{
Component: GitSettingsScreen,
path: "/settings/github",
},
]);
const renderGitSettingsScreen = () => {
const { rerender, ...rest } = render(
<GitSettingsRouterStub initialEntries={["/settings/github"]} />,
{
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
),
},
);
const rerenderGitSettingsScreen = () =>
rerender(
<QueryClientProvider client={queryClient}>
<AuthProvider>
<GitSettingsRouterStub initialEntries={["/settings/github"]} />
</AuthProvider>
</QueryClientProvider>,
);
return {
...rest,
rerender: rerenderGitSettingsScreen,
};
};
beforeEach(() => {
// Since we don't recreate the query client on every test, we need to
// reset the query client before each test to avoid state leaks
// between tests.
queryClient.invalidateQueries();
});
describe("Content", () => {
it("should render", async () => {
renderGitSettingsScreen();
await screen.findByTestId("git-settings-screen");
});
it("should render the inputs if OSS mode", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
const { rerender } = renderGitSettingsScreen();
await screen.findByTestId("github-token-input");
await screen.findByTestId("github-token-help-anchor");
await screen.findByTestId("gitlab-token-input");
await screen.findByTestId("gitlab-token-help-anchor");
getConfigSpy.mockResolvedValue(VALID_SAAS_CONFIG);
queryClient.invalidateQueries();
rerender();
await waitFor(() => {
expect(
screen.queryByTestId("github-token-input"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("github-token-help-anchor"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("gitlab-token-input"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("gitlab-token-help-anchor"),
).not.toBeInTheDocument();
});
});
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");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: false,
gitlab: false,
},
});
const { rerender } = renderGitSettingsScreen();
await waitFor(() => {
const githubInput = screen.getByTestId("github-token-input");
expect(githubInput).toHaveProperty("placeholder", "");
expect(
screen.queryByTestId("gh-set-token-indicator"),
).not.toBeInTheDocument();
const gitlabInput = screen.getByTestId("gitlab-token-input");
expect(gitlabInput).toHaveProperty("placeholder", "");
expect(
screen.queryByTestId("gl-set-token-indicator"),
).not.toBeInTheDocument();
});
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: true,
gitlab: true,
},
});
queryClient.invalidateQueries();
rerender();
await waitFor(() => {
const githubInput = screen.getByTestId("github-token-input");
expect(githubInput).toHaveProperty("placeholder", "<hidden>");
expect(
screen.queryByTestId("gh-set-token-indicator"),
).toBeInTheDocument();
const gitlabInput = screen.getByTestId("gitlab-token-input");
expect(gitlabInput).toHaveProperty("placeholder", "<hidden>");
expect(
screen.queryByTestId("gl-set-token-indicator"),
).toBeInTheDocument();
});
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: false,
gitlab: true,
},
});
queryClient.invalidateQueries();
rerender();
await waitFor(() => {
const githubInput = screen.getByTestId("github-token-input");
expect(githubInput).toHaveProperty("placeholder", "");
expect(
screen.queryByTestId("gh-set-token-indicator"),
).not.toBeInTheDocument();
const gitlabInput = screen.getByTestId("gitlab-token-input");
expect(gitlabInput).toHaveProperty("placeholder", "<hidden>");
expect(
screen.queryByTestId("gl-set-token-indicator"),
).toBeInTheDocument();
});
});
it("should render the 'Configure GitHub Repositories' button if SaaS mode and app slug exists", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
const { rerender } = renderGitSettingsScreen();
let button = screen.queryByTestId("configure-github-repositories-button");
expect(button).not.toBeInTheDocument();
expect(screen.getByTestId("submit-button")).toBeInTheDocument();
expect(screen.getByTestId("disconnect-tokens-button")).toBeInTheDocument();
getConfigSpy.mockResolvedValue(VALID_SAAS_CONFIG);
queryClient.invalidateQueries();
rerender();
await waitFor(() => {
// wait until queries are resolved
expect(queryClient.isFetching()).toBe(0);
button = screen.queryByTestId("configure-github-repositories-button");
expect(button).not.toBeInTheDocument();
});
getConfigSpy.mockResolvedValue({
...VALID_SAAS_CONFIG,
APP_SLUG: "test-slug",
});
queryClient.invalidateQueries();
rerender();
await waitFor(() => {
button = screen.getByTestId("configure-github-repositories-button");
expect(button).toBeInTheDocument();
expect(screen.queryByTestId("submit-button")).not.toBeInTheDocument();
expect(
screen.queryByTestId("disconnect-tokens-button"),
).not.toBeInTheDocument();
});
});
});
describe("Form submission", () => {
it("should save the GitHub token", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
const githubInput = await screen.findByTestId("github-token-input");
const submit = await screen.findByTestId("submit-button");
await userEvent.type(githubInput, "test-token");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
provider_tokens: {
github: "test-token",
gitlab: "",
},
}),
);
const gitlabInput = await screen.findByTestId("gitlab-token-input");
await userEvent.type(gitlabInput, "test-token");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
provider_tokens: {
github: "",
gitlab: "test-token",
},
}),
);
});
it("should disable the button if there is no input", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
const submit = await screen.findByTestId("submit-button");
expect(submit).toBeDisabled();
const githubInput = await screen.findByTestId("github-token-input");
await userEvent.type(githubInput, "test-token");
expect(submit).not.toBeDisabled();
await userEvent.clear(githubInput);
expect(submit).toBeDisabled();
const gitlabInput = await screen.findByTestId("gitlab-token-input");
await userEvent.type(gitlabInput, "test-token");
expect(submit).not.toBeDisabled();
await userEvent.clear(gitlabInput);
expect(submit).toBeDisabled();
});
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");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: true,
gitlab: false,
},
});
renderGitSettingsScreen();
await screen.findByTestId("git-settings-screen");
let disconnectButton = await screen.findByTestId(
"disconnect-tokens-button",
);
await waitFor(() => expect(disconnectButton).not.toBeDisabled());
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: false,
gitlab: false,
},
});
queryClient.invalidateQueries();
disconnectButton = await screen.findByTestId("disconnect-tokens-button");
await waitFor(() => expect(disconnectButton).toBeDisabled());
});
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");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: true,
gitlab: false,
},
});
renderGitSettingsScreen();
const disconnectButton = await screen.findByTestId(
"disconnect-tokens-button",
);
await waitFor(() => expect(disconnectButton).not.toBeDisabled());
await userEvent.click(disconnectButton);
expect(logoutSpy).toHaveBeenCalled();
});
// flaky test
it.skip("should disable the button when submitting changes", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
const submit = await screen.findByTestId("submit-button");
expect(submit).toBeDisabled();
const githubInput = await screen.findByTestId("github-token-input");
await userEvent.type(githubInput, "test-token");
expect(submit).not.toBeDisabled();
// submit the form
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(submit).toHaveTextContent("Saving...");
expect(submit).toBeDisabled();
await waitFor(() => expect(submit).toHaveTextContent("Save"));
});
it("should disable the button after submitting changes", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
await screen.findByTestId("git-settings-screen");
const submit = await screen.findByTestId("submit-button");
expect(submit).toBeDisabled();
const githubInput = await screen.findByTestId("github-token-input");
await userEvent.type(githubInput, "test-token");
expect(submit).not.toBeDisabled();
// submit the form
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(submit).toBeDisabled();
const gitlabInput = await screen.findByTestId("gitlab-token-input");
await userEvent.type(gitlabInput, "test-token");
expect(gitlabInput).toHaveValue("test-token");
expect(submit).not.toBeDisabled();
// submit the form
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
await waitFor(() => expect(submit).toBeDisabled());
});
});
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");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const displaySuccessToastSpy = vi.spyOn(
ToastHandlers,
"displaySuccessToast",
);
renderGitSettingsScreen();
// Toggle setting to change
const githubInput = await screen.findByTestId("github-token-input");
await userEvent.type(githubInput, "test-token");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
await waitFor(() => expect(displaySuccessToastSpy).toHaveBeenCalled());
});
it("should call displayErrorToast when the settings fail to save", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings"));
renderGitSettingsScreen();
// Toggle setting to change
const gitlabInput = await screen.findByTestId("gitlab-token-input");
await userEvent.type(gitlabInput, "test-token");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(displayErrorToastSpy).toHaveBeenCalled();
});
});
@@ -91,6 +91,13 @@ describe("HomeScreen", () => {
screen.getByTestId("task-suggestions");
});
it("should have responsive layout for mobile and desktop screens", async () => {
renderHomeScreen();
const mainContainer = screen.getByTestId("home-screen").querySelector("main");
expect(mainContainer).toHaveClass("flex", "flex-col", "md:flex-row");
});
it("should filter the suggested tasks based on the selected repository", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
@@ -0,0 +1,674 @@
import { render, screen, waitFor, within } from "@testing-library/react";
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 OpenHands from "#/api/open-hands";
import {
MOCK_DEFAULT_USER_SETTINGS,
resetTestHandlersMockSettings,
} from "#/mocks/handlers";
import { AuthProvider } from "#/context/auth-context";
import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
const renderLlmSettingsScreen = () =>
render(<LlmSettingsScreen />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
),
});
beforeEach(() => {
vi.resetAllMocks();
resetTestHandlersMockSettings();
});
describe("Content", () => {
describe("Basic form", () => {
it("should render the basic form by default", async () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const basicFom = screen.getByTestId("llm-settings-form-basic");
within(basicFom).getByTestId("llm-provider-input");
within(basicFom).getByTestId("llm-model-input");
within(basicFom).getByTestId("llm-api-key-input");
within(basicFom).getByTestId("llm-api-key-help-anchor");
});
it("should render the default values if non exist", async () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const provider = screen.getByTestId("llm-provider-input");
const model = screen.getByTestId("llm-model-input");
const apiKey = screen.getByTestId("llm-api-key-input");
await waitFor(() => {
expect(provider).toHaveValue("Anthropic");
expect(model).toHaveValue("claude-3-5-sonnet-20241022");
expect(apiKey).toHaveValue("");
expect(apiKey).toHaveProperty("placeholder", "");
});
});
it("should render the existing settings values", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "openai/gpt-4o",
llm_api_key_set: true,
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const provider = screen.getByTestId("llm-provider-input");
const model = screen.getByTestId("llm-model-input");
const apiKey = screen.getByTestId("llm-api-key-input");
await waitFor(() => {
expect(provider).toHaveValue("OpenAI");
expect(model).toHaveValue("gpt-4o");
expect(apiKey).toHaveValue("");
expect(apiKey).toHaveProperty("placeholder", "<hidden>");
expect(screen.getByTestId("set-indicator")).toBeInTheDocument();
});
});
});
describe("Advanced form", () => {
it("should render the advanced form if the switch is toggled", async () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
const basicForm = screen.getByTestId("llm-settings-form-basic");
expect(
screen.queryByTestId("llm-settings-form-advanced"),
).not.toBeInTheDocument();
expect(basicForm).toBeInTheDocument();
await userEvent.click(advancedSwitch);
expect(
screen.queryByTestId("llm-settings-form-advanced"),
).toBeInTheDocument();
expect(basicForm).not.toBeInTheDocument();
const advancedForm = screen.getByTestId("llm-settings-form-advanced");
within(advancedForm).getByTestId("llm-custom-model-input");
within(advancedForm).getByTestId("base-url-input");
within(advancedForm).getByTestId("llm-api-key-input");
within(advancedForm).getByTestId("llm-api-key-help-anchor");
within(advancedForm).getByTestId("agent-input");
within(advancedForm).getByTestId("enable-confirmation-mode-switch");
within(advancedForm).getByTestId("enable-memory-condenser-switch");
await userEvent.click(advancedSwitch);
expect(
screen.queryByTestId("llm-settings-form-advanced"),
).not.toBeInTheDocument();
expect(screen.getByTestId("llm-settings-form-basic")).toBeInTheDocument();
});
it("should render the default advanced settings", async () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
expect(advancedSwitch).not.toBeChecked();
await userEvent.click(advancedSwitch);
const model = screen.getByTestId("llm-custom-model-input");
const baseUrl = screen.getByTestId("base-url-input");
const apiKey = screen.getByTestId("llm-api-key-input");
const agent = screen.getByTestId("agent-input");
const confirmation = screen.getByTestId(
"enable-confirmation-mode-switch",
);
const condensor = screen.getByTestId("enable-memory-condenser-switch");
expect(model).toHaveValue("anthropic/claude-3-5-sonnet-20241022");
expect(baseUrl).toHaveValue("");
expect(apiKey).toHaveValue("");
expect(apiKey).toHaveProperty("placeholder", "");
expect(agent).toHaveValue("CodeActAgent");
expect(confirmation).not.toBeChecked();
expect(condensor).toBeChecked();
// check that security analyzer is present
expect(
screen.queryByTestId("security-analyzer-input"),
).not.toBeInTheDocument();
await userEvent.click(confirmation);
screen.getByTestId("security-analyzer-input");
});
it("should render the advanced form if existings settings are advanced", async () => {
const hasAdvancedSettingsSetSpy = vi.spyOn(
AdvancedSettingsUtlls,
"hasAdvancedSettingsSet",
);
hasAdvancedSettingsSetSpy.mockReturnValue(true);
renderLlmSettingsScreen();
await waitFor(() => {
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
expect(advancedSwitch).toBeChecked();
screen.getByTestId("llm-settings-form-advanced");
});
});
it("should render existing advanced settings correctly", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "openai/gpt-4o",
llm_base_url: "https://api.openai.com/v1/chat/completions",
llm_api_key_set: true,
agent: "CoActAgent",
confirmation_mode: true,
enable_default_condenser: false,
security_analyzer: "mock-invariant",
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const model = screen.getByTestId("llm-custom-model-input");
const baseUrl = screen.getByTestId("base-url-input");
const apiKey = screen.getByTestId("llm-api-key-input");
const agent = screen.getByTestId("agent-input");
const confirmation = screen.getByTestId(
"enable-confirmation-mode-switch",
);
const condensor = screen.getByTestId("enable-memory-condenser-switch");
const securityAnalyzer = screen.getByTestId("security-analyzer-input");
await waitFor(() => {
expect(model).toHaveValue("openai/gpt-4o");
expect(baseUrl).toHaveValue(
"https://api.openai.com/v1/chat/completions",
);
expect(apiKey).toHaveValue("");
expect(apiKey).toHaveProperty("placeholder", "<hidden>");
expect(agent).toHaveValue("CoActAgent");
expect(confirmation).toBeChecked();
expect(condensor).not.toBeChecked();
expect(securityAnalyzer).toHaveValue("mock-invariant");
});
});
});
it.todo("should render an indicator if the llm api key is set");
});
describe("Form submission", () => {
it("should submit the basic form with the correct values", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const provider = screen.getByTestId("llm-provider-input");
const model = screen.getByTestId("llm-model-input");
const apiKey = screen.getByTestId("llm-api-key-input");
// select provider
await userEvent.click(provider);
const providerOption = screen.getByText("OpenAI");
await userEvent.click(providerOption);
expect(provider).toHaveValue("OpenAI");
// enter api key
await userEvent.type(apiKey, "test-api-key");
// select model
await userEvent.click(model);
const modelOption = screen.getByText("gpt-4o");
await userEvent.click(modelOption);
expect(model).toHaveValue("gpt-4o");
const submitButton = screen.getByTestId("submit-button");
await userEvent.click(submitButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_model: "openai/gpt-4o",
llm_api_key: "test-api-key",
}),
);
});
it("should submit the advanced form with the correct values", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
const model = screen.getByTestId("llm-custom-model-input");
const baseUrl = screen.getByTestId("base-url-input");
const apiKey = screen.getByTestId("llm-api-key-input");
const agent = screen.getByTestId("agent-input");
const confirmation = screen.getByTestId("enable-confirmation-mode-switch");
const condensor = screen.getByTestId("enable-memory-condenser-switch");
// enter custom model
await userEvent.clear(model);
await userEvent.type(model, "openai/gpt-4o");
expect(model).toHaveValue("openai/gpt-4o");
// enter base url
await userEvent.type(baseUrl, "https://api.openai.com/v1/chat/completions");
expect(baseUrl).toHaveValue("https://api.openai.com/v1/chat/completions");
// enter api key
await userEvent.type(apiKey, "test-api-key");
// toggle confirmation mode
await userEvent.click(confirmation);
expect(confirmation).toBeChecked();
// toggle memory condensor
await userEvent.click(condensor);
expect(condensor).not.toBeChecked();
// select agent
await userEvent.click(agent);
const agentOption = screen.getByText("CoActAgent");
await userEvent.click(agentOption);
expect(agent).toHaveValue("CoActAgent");
// select security analyzer
const securityAnalyzer = screen.getByTestId("security-analyzer-input");
await userEvent.click(securityAnalyzer);
const securityAnalyzerOption = screen.getByText("mock-invariant");
await userEvent.click(securityAnalyzerOption);
const submitButton = screen.getByTestId("submit-button");
await userEvent.click(submitButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_model: "openai/gpt-4o",
llm_base_url: "https://api.openai.com/v1/chat/completions",
agent: "CoActAgent",
confirmation_mode: true,
enable_default_condenser: false,
security_analyzer: "mock-invariant",
}),
);
});
it("should disable the button if there are no changes in the basic form", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "openai/gpt-4o",
llm_api_key_set: true,
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
screen.getByTestId("llm-settings-form-basic");
const submitButton = screen.getByTestId("submit-button");
expect(submitButton).toBeDisabled();
const model = screen.getByTestId("llm-model-input");
const apiKey = screen.getByTestId("llm-api-key-input");
// select model
await userEvent.click(model);
const modelOption = screen.getByText("gpt-4o-mini");
await userEvent.click(modelOption);
expect(model).toHaveValue("gpt-4o-mini");
expect(submitButton).not.toBeDisabled();
// reset model
await userEvent.click(model);
const modelOption2 = screen.getByText("gpt-4o");
await userEvent.click(modelOption2);
expect(model).toHaveValue("gpt-4o");
expect(submitButton).toBeDisabled();
// set api key
await userEvent.type(apiKey, "test-api-key");
expect(apiKey).toHaveValue("test-api-key");
expect(submitButton).not.toBeDisabled();
// reset api key
await userEvent.clear(apiKey);
expect(apiKey).toHaveValue("");
expect(submitButton).toBeDisabled();
});
it("should disable the button if there are no changes in the advanced form", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "openai/gpt-4o",
llm_base_url: "https://api.openai.com/v1/chat/completions",
llm_api_key_set: true,
confirmation_mode: true,
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
screen.getByTestId("llm-settings-form-advanced");
const submitButton = screen.getByTestId("submit-button");
expect(submitButton).toBeDisabled();
const model = screen.getByTestId("llm-custom-model-input");
const baseUrl = screen.getByTestId("base-url-input");
const apiKey = screen.getByTestId("llm-api-key-input");
const agent = screen.getByTestId("agent-input");
const confirmation = screen.getByTestId("enable-confirmation-mode-switch");
const condensor = screen.getByTestId("enable-memory-condenser-switch");
// enter custom model
await userEvent.type(model, "-mini");
expect(model).toHaveValue("openai/gpt-4o-mini");
expect(submitButton).not.toBeDisabled();
// reset model
await userEvent.clear(model);
expect(model).toHaveValue("");
expect(submitButton).toBeDisabled();
await userEvent.type(model, "openai/gpt-4o");
expect(model).toHaveValue("openai/gpt-4o");
expect(submitButton).toBeDisabled();
// enter base url
await userEvent.type(baseUrl, "/extra");
expect(baseUrl).toHaveValue(
"https://api.openai.com/v1/chat/completions/extra",
);
expect(submitButton).not.toBeDisabled();
await userEvent.clear(baseUrl);
expect(baseUrl).toHaveValue("");
expect(submitButton).not.toBeDisabled();
await userEvent.type(baseUrl, "https://api.openai.com/v1/chat/completions");
expect(baseUrl).toHaveValue("https://api.openai.com/v1/chat/completions");
expect(submitButton).toBeDisabled();
// set api key
await userEvent.type(apiKey, "test-api-key");
expect(apiKey).toHaveValue("test-api-key");
expect(submitButton).not.toBeDisabled();
// reset api key
await userEvent.clear(apiKey);
expect(apiKey).toHaveValue("");
expect(submitButton).toBeDisabled();
// set agent
await userEvent.clear(agent);
await userEvent.type(agent, "test-agent");
expect(agent).toHaveValue("test-agent");
expect(submitButton).not.toBeDisabled();
// reset agent
await userEvent.clear(agent);
expect(agent).toHaveValue("");
expect(submitButton).toBeDisabled();
await userEvent.type(agent, "CodeActAgent");
expect(agent).toHaveValue("CodeActAgent");
expect(submitButton).toBeDisabled();
// toggle confirmation mode
await userEvent.click(confirmation);
expect(confirmation).not.toBeChecked();
expect(submitButton).not.toBeDisabled();
await userEvent.click(confirmation);
expect(confirmation).toBeChecked();
expect(submitButton).toBeDisabled();
// toggle memory condensor
await userEvent.click(condensor);
expect(condensor).not.toBeChecked();
expect(submitButton).not.toBeDisabled();
await userEvent.click(condensor);
expect(condensor).toBeChecked();
expect(submitButton).toBeDisabled();
// select security analyzer
const securityAnalyzer = screen.getByTestId("security-analyzer-input");
await userEvent.click(securityAnalyzer);
const securityAnalyzerOption = screen.getByText("mock-invariant");
await userEvent.click(securityAnalyzerOption);
expect(securityAnalyzer).toHaveValue("mock-invariant");
expect(submitButton).not.toBeDisabled();
await userEvent.clear(securityAnalyzer);
expect(securityAnalyzer).toHaveValue("");
expect(submitButton).toBeDisabled();
});
it("should reset button state when switching between forms", async () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
const submitButton = screen.getByTestId("submit-button");
expect(submitButton).toBeDisabled();
// dirty the basic form
const apiKey = screen.getByTestId("llm-api-key-input");
await userEvent.type(apiKey, "test-api-key");
expect(submitButton).not.toBeDisabled();
await userEvent.click(advancedSwitch);
expect(submitButton).toBeDisabled();
// dirty the advanced form
const model = screen.getByTestId("llm-custom-model-input");
await userEvent.type(model, "openai/gpt-4o");
expect(submitButton).not.toBeDisabled();
await userEvent.click(advancedSwitch);
expect(submitButton).toBeDisabled();
});
// flaky test
it.skip("should disable the button when submitting changes", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const apiKey = screen.getByTestId("llm-api-key-input");
await userEvent.type(apiKey, "test-api-key");
const submitButton = screen.getByTestId("submit-button");
await userEvent.click(submitButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_api_key: "test-api-key",
}),
);
expect(submitButton).toHaveTextContent("Saving...");
expect(submitButton).toBeDisabled();
await waitFor(() => {
expect(submitButton).toHaveTextContent("Save");
expect(submitButton).toBeDisabled();
});
});
});
describe("Status toasts", () => {
describe("Basic form", () => {
it("should call displaySuccessToast when the settings are saved", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const displaySuccessToastSpy = vi.spyOn(
ToastHandlers,
"displaySuccessToast",
);
renderLlmSettingsScreen();
// Toggle setting to change
const apiKeyInput = await screen.findByTestId("llm-api-key-input");
await userEvent.type(apiKeyInput, "test-api-key");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
await waitFor(() => expect(displaySuccessToastSpy).toHaveBeenCalled());
});
it("should call displayErrorToast when the settings fail to save", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings"));
renderLlmSettingsScreen();
// Toggle setting to change
const apiKeyInput = await screen.findByTestId("llm-api-key-input");
await userEvent.type(apiKeyInput, "test-api-key");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(displayErrorToastSpy).toHaveBeenCalled();
});
});
describe("Advanced form", () => {
it("should call displaySuccessToast when the settings are saved", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const displaySuccessToastSpy = vi.spyOn(
ToastHandlers,
"displaySuccessToast",
);
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
await screen.findByTestId("llm-settings-form-advanced");
// Toggle setting to change
const apiKeyInput = await screen.findByTestId("llm-api-key-input");
await userEvent.type(apiKeyInput, "test-api-key");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
await waitFor(() => expect(displaySuccessToastSpy).toHaveBeenCalled());
});
it("should call displayErrorToast when the settings fail to save", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings"));
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
await screen.findByTestId("llm-settings-form-advanced");
// Toggle setting to change
const apiKeyInput = await screen.findByTestId("llm-api-key-input");
await userEvent.type(apiKeyInput, "test-api-key");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(displayErrorToastSpy).toHaveBeenCalled();
});
});
});
describe("SaaS mode", () => {
it("should not render the runtime settings input in oss mode", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return mode
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
await screen.findByTestId("llm-settings-form-advanced");
const runtimeSettingsInput = screen.queryByTestId("runtime-settings-input");
expect(runtimeSettingsInput).not.toBeInTheDocument();
});
it("should render the runtime settings input in saas mode", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return mode
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
await screen.findByTestId("llm-settings-form-advanced");
const runtimeSettingsInput = screen.queryByTestId("runtime-settings-input");
expect(runtimeSettingsInput).toBeInTheDocument();
});
it("should always render the runtime settings input as disabled", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return mode
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
await screen.findByTestId("llm-settings-form-advanced");
const runtimeSettingsInput = screen.queryByTestId("runtime-settings-input");
expect(runtimeSettingsInput).toBeInTheDocument();
expect(runtimeSettingsInput).toBeDisabled();
});
});
@@ -1,4 +1,4 @@
import { screen, waitFor, within } from "@testing-library/react";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRoutesStub } from "react-router";
@@ -7,6 +7,30 @@ import OpenHands from "#/api/open-hands";
import SettingsScreen from "#/routes/settings";
import { PaymentForm } from "#/components/features/payment/payment-form";
// Mock the i18next hook
vi.mock("react-i18next", async () => {
const actual = await vi.importActual<typeof import("react-i18next")>("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"SETTINGS$NAV_GIT": "Git",
"SETTINGS$NAV_APPLICATION": "Application",
"SETTINGS$NAV_CREDITS": "Credits",
"SETTINGS$NAV_API_KEYS": "API Keys",
"SETTINGS$NAV_LLM": "LLM",
"SETTINGS$TITLE": "Settings"
};
return translations[key] || key;
},
i18n: {
changeLanguage: vi.fn(),
},
}),
};
});
describe("Settings Billing", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
@@ -19,18 +43,22 @@ describe("Settings Billing", () => {
Component: () => <PaymentForm />,
path: "/settings/billing",
},
{
Component: () => <div data-testid="git-settings-screen" />,
path: "/settings/git",
},
],
},
]);
const renderSettingsScreen = () =>
renderWithProviders(<RoutesStub initialEntries={["/settings"]} />);
renderWithProviders(<RoutesStub initialEntries={["/settings/billing"]} />);
afterEach(() => {
vi.clearAllMocks();
});
it("should not render the navbar if OSS mode", async () => {
it("should not render the credits tab if OSS mode", async () => {
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
GITHUB_CLIENT_ID: "123",
@@ -43,15 +71,12 @@ describe("Settings Billing", () => {
renderSettingsScreen();
// Wait for the settings screen to be rendered
await screen.findByTestId("settings-screen");
// Then check that the navbar is not present
const navbar = screen.queryByTestId("settings-navbar");
expect(navbar).not.toBeInTheDocument();
const navbar = await screen.findByTestId("settings-navbar");
const credits = within(navbar).queryByText("Credits");
expect(credits).not.toBeInTheDocument();
});
it("should render the navbar if SaaS mode", async () => {
it("should render the credits tab if SaaS mode and billing is enabled", async () => {
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
@@ -64,11 +89,8 @@ describe("Settings Billing", () => {
renderSettingsScreen();
await waitFor(() => {
const navbar = screen.getByTestId("settings-navbar");
within(navbar).getByText("Account");
within(navbar).getByText("Credits");
});
const navbar = await screen.findByTestId("settings-navbar");
within(navbar).getByText("Credits");
});
it("should render the billing settings if clicking the credits item", async () => {
@@ -90,6 +112,6 @@ describe("Settings Billing", () => {
await user.click(credits);
const billingSection = await screen.findByTestId("billing-settings");
within(billingSection).getByText("PAYMENT$MANAGE_CREDITS");
expect(billingSection).toBeInTheDocument();
});
});
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,10 @@ describe("hasAdvancedSettingsSet", () => {
expect(hasAdvancedSettingsSet(DEFAULT_SETTINGS)).toBe(false);
});
it("should return false if an empty object", () => {
expect(hasAdvancedSettingsSet({})).toBe(false);
});
describe("should be true if", () => {
test("LLM_BASE_URL is set", () => {
expect(
@@ -26,15 +30,6 @@ describe("hasAdvancedSettingsSet", () => {
).toBe(true);
});
test("REMOTE_RUNTIME_RESOURCE_FACTOR is not default value", () => {
expect(
hasAdvancedSettingsSet({
...DEFAULT_SETTINGS,
REMOTE_RUNTIME_RESOURCE_FACTOR: 999,
}),
).toBe(true);
});
test("CONFIRMATION_MODE is true", () => {
expect(
hasAdvancedSettingsSet({
+127 -127
View File
@@ -17,22 +17,22 @@
"@reduxjs/toolkit": "^2.7.0",
"@stripe/react-stripe-js": "^3.6.0",
"@stripe/stripe-js": "^7.2.0",
"@tanstack/react-query": "^5.74.4",
"@tanstack/react-query": "^5.74.7",
"@vitejs/plugin-react": "^4.4.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.8.4",
"axios": "^1.9.0",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.9.1",
"framer-motion": "^12.9.2",
"i18next": "^25.0.1",
"i18next-browser-languagedetector": "^8.0.5",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.25",
"isbot": "^5.1.27",
"jose": "^6.0.10",
"lucide-react": "^0.503.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.236.6",
"posthog-js": "^1.236.7",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-highlight": "^0.15.0",
@@ -60,12 +60,12 @@
"@playwright/test": "^1.52.0",
"@react-router/dev": "^7.5.2",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.73.3",
"@tanstack/eslint-plugin-query": "^5.74.7",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.14.1",
"@types/node": "^22.15.3",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.1",
"@types/react-highlight": "^0.12.8",
@@ -136,9 +136,9 @@
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.1.4.tgz",
"integrity": "sha512-SeuBV4rnjpFNjI8HSgKUwteuFdkHwkboq31HWzznuqgySQir+jSTczoWVVL4jvOjKjuH80fMDG0Fvg1Sb+OJsA==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.1.5.tgz",
"integrity": "sha512-w7AmVyTTiU41fNLsFDf+gA2Dwtbx2EJtn2pbJNAGSRAg50loXy1uLXA3hEpD8+eydcomTurw09tq5/AyceCaGg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5977,9 +5977,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.40.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.0.tgz",
"integrity": "sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg==",
"version": "4.40.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.1.tgz",
"integrity": "sha512-kxz0YeeCrRUHz3zyqvd7n+TVRlNyTifBsmnmNPtk3hQURUyG9eAB+usz6DAwagMusjx/zb3AjvDUvhFGDAexGw==",
"cpu": [
"arm"
],
@@ -5990,9 +5990,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.40.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.0.tgz",
"integrity": "sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w==",
"version": "4.40.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.1.tgz",
"integrity": "sha512-PPkxTOisoNC6TpnDKatjKkjRMsdaWIhyuMkA4UsBXT9WEZY4uHezBTjs6Vl4PbqQQeu6oION1w2voYZv9yquCw==",
"cpu": [
"arm64"
],
@@ -6003,9 +6003,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.40.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.0.tgz",
"integrity": "sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ==",
"version": "4.40.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.1.tgz",
"integrity": "sha512-VWXGISWFY18v/0JyNUy4A46KCFCb9NVsH+1100XP31lud+TzlezBbz24CYzbnA4x6w4hx+NYCXDfnvDVO6lcAA==",
"cpu": [
"arm64"
],
@@ -6016,9 +6016,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.40.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.0.tgz",
"integrity": "sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA==",
"version": "4.40.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.1.tgz",
"integrity": "sha512-nIwkXafAI1/QCS7pxSpv/ZtFW6TXcNUEHAIA9EIyw5OzxJZQ1YDrX+CL6JAIQgZ33CInl1R6mHet9Y/UZTg2Bw==",
"cpu": [
"x64"
],
@@ -6029,9 +6029,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.40.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.0.tgz",
"integrity": "sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg==",
"version": "4.40.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.1.tgz",
"integrity": "sha512-BdrLJ2mHTrIYdaS2I99mriyJfGGenSaP+UwGi1kB9BLOCu9SR8ZpbkmmalKIALnRw24kM7qCN0IOm6L0S44iWw==",
"cpu": [
"arm64"
],
@@ -6042,9 +6042,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.40.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.0.tgz",
"integrity": "sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw==",
"version": "4.40.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.1.tgz",
"integrity": "sha512-VXeo/puqvCG8JBPNZXZf5Dqq7BzElNJzHRRw3vjBE27WujdzuOPecDPc/+1DcdcTptNBep3861jNq0mYkT8Z6Q==",
"cpu": [
"x64"
],
@@ -6055,9 +6055,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.40.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.0.tgz",
"integrity": "sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==",
"version": "4.40.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.1.tgz",
"integrity": "sha512-ehSKrewwsESPt1TgSE/na9nIhWCosfGSFqv7vwEtjyAqZcvbGIg4JAcV7ZEh2tfj/IlfBeZjgOXm35iOOjadcg==",
"cpu": [
"arm"
],
@@ -6068,9 +6068,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.40.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.0.tgz",
"integrity": "sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==",
"version": "4.40.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.1.tgz",
"integrity": "sha512-m39iO/aaurh5FVIu/F4/Zsl8xppd76S4qoID8E+dSRQvTyZTOI2gVk3T4oqzfq1PtcvOfAVlwLMK3KRQMaR8lg==",
"cpu": [
"arm"
],
@@ -6081,9 +6081,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.40.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.0.tgz",
"integrity": "sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==",
"version": "4.40.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.1.tgz",
"integrity": "sha512-Y+GHnGaku4aVLSgrT0uWe2o2Rq8te9hi+MwqGF9r9ORgXhmHK5Q71N757u0F8yU1OIwUIFy6YiJtKjtyktk5hg==",
"cpu": [
"arm64"
],
@@ -6094,9 +6094,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.40.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.0.tgz",
"integrity": "sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==",
"version": "4.40.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.1.tgz",
"integrity": "sha512-jEwjn3jCA+tQGswK3aEWcD09/7M5wGwc6+flhva7dsQNRZZTe30vkalgIzV4tjkopsTS9Jd7Y1Bsj6a4lzz8gQ==",
"cpu": [
"arm64"
],
@@ -6107,9 +6107,9 @@
]
},
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
"version": "4.40.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.0.tgz",
"integrity": "sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==",
"version": "4.40.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.1.tgz",
"integrity": "sha512-ySyWikVhNzv+BV/IDCsrraOAZ3UaC8SZB67FZlqVwXwnFhPihOso9rPOxzZbjp81suB1O2Topw+6Ug3JNegejQ==",
"cpu": [
"loong64"
],
@@ -6120,9 +6120,9 @@
]
},
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
"version": "4.40.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.0.tgz",
"integrity": "sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==",
"version": "4.40.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.1.tgz",
"integrity": "sha512-BvvA64QxZlh7WZWqDPPdt0GH4bznuL6uOO1pmgPnnv86rpUpc8ZxgZwcEgXvo02GRIZX1hQ0j0pAnhwkhwPqWg==",
"cpu": [
"ppc64"
],
@@ -6133,9 +6133,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.40.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.0.tgz",
"integrity": "sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==",
"version": "4.40.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.1.tgz",
"integrity": "sha512-EQSP+8+1VuSulm9RKSMKitTav89fKbHymTf25n5+Yr6gAPZxYWpj3DzAsQqoaHAk9YX2lwEyAf9S4W8F4l3VBQ==",
"cpu": [
"riscv64"
],
@@ -6146,9 +6146,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.40.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.0.tgz",
"integrity": "sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==",
"version": "4.40.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.1.tgz",
"integrity": "sha512-n/vQ4xRZXKuIpqukkMXZt9RWdl+2zgGNx7Uda8NtmLJ06NL8jiHxUawbwC+hdSq1rrw/9CghCpEONor+l1e2gA==",
"cpu": [
"riscv64"
],
@@ -6159,9 +6159,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.40.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.0.tgz",
"integrity": "sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==",
"version": "4.40.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.1.tgz",
"integrity": "sha512-h8d28xzYb98fMQKUz0w2fMc1XuGzLLjdyxVIbhbil4ELfk5/orZlSTpF/xdI9C8K0I8lCkq+1En2RJsawZekkg==",
"cpu": [
"s390x"
],
@@ -6172,9 +6172,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.40.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.0.tgz",
"integrity": "sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==",
"version": "4.40.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.1.tgz",
"integrity": "sha512-XiK5z70PEFEFqcNj3/zRSz/qX4bp4QIraTy9QjwJAb/Z8GM7kVUsD0Uk8maIPeTyPCP03ChdI+VVmJriKYbRHQ==",
"cpu": [
"x64"
],
@@ -6185,9 +6185,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.40.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.0.tgz",
"integrity": "sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==",
"version": "4.40.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.1.tgz",
"integrity": "sha512-2BRORitq5rQ4Da9blVovzNCMaUlyKrzMSvkVR0D4qPuOy/+pMCrh1d7o01RATwVy+6Fa1WBw+da7QPeLWU/1mQ==",
"cpu": [
"x64"
],
@@ -6198,9 +6198,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.40.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.0.tgz",
"integrity": "sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==",
"version": "4.40.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.1.tgz",
"integrity": "sha512-b2bcNm9Kbde03H+q+Jjw9tSfhYkzrDUf2d5MAd1bOJuVplXvFhWz7tRtWvD8/ORZi7qSCy0idW6tf2HgxSXQSg==",
"cpu": [
"arm64"
],
@@ -6211,9 +6211,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.40.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.0.tgz",
"integrity": "sha512-+m03kvI2f5syIqHXCZLPVYplP8pQch9JHyXKZ3AGMKlg8dCyr2PKHjwRLiW53LTrN/Nc3EqHOKxUxzoSPdKddA==",
"version": "4.40.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.1.tgz",
"integrity": "sha512-DfcogW8N7Zg7llVEfpqWMZcaErKfsj9VvmfSyRjCyo4BI3wPEfrzTtJkZG6gKP/Z92wFm6rz2aDO7/JfiR/whA==",
"cpu": [
"ia32"
],
@@ -6224,9 +6224,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.40.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.0.tgz",
"integrity": "sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ==",
"version": "4.40.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.1.tgz",
"integrity": "sha512-ECyOuDeH3C1I8jH2MK1RtBJW+YPMvSfT0a5NN0nHfQYnDSJ6tUiZH3gzwVP5/Kfh/+Tt7tpWVF9LXNTnhTJ3kA==",
"cpu": [
"x64"
],
@@ -6548,9 +6548,9 @@
}
},
"node_modules/@tanstack/eslint-plugin-query": {
"version": "5.73.3",
"resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.73.3.tgz",
"integrity": "sha512-GmUtnOkRzDuNOq96g3eW5ADKC1nWfrM9RI0kRyQVr87rOl6y+PUgkuVaPxh3R2C0EVODxCS07b9aaWphidl/OA==",
"version": "5.74.7",
"resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.74.7.tgz",
"integrity": "sha512-EeHuaaYiCOD+XOGyB7LMNEx9OEByAa5lkgP+S3ZggjKJpmIO6iRWeoIYYDKo2F8uc3qXcVhTfC7pn7NddQiNtA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6565,9 +6565,9 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "5.74.4",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.74.4.tgz",
"integrity": "sha512-YuG0A0+3i9b2Gfo9fkmNnkUWh5+5cFhWBN0pJAHkHilTx6A0nv8kepkk4T4GRt4e5ahbtFj2eTtkiPcVU1xO4A==",
"version": "5.74.7",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.74.7.tgz",
"integrity": "sha512-X3StkN/Y6KGHndTjJf8H8th7AX4bKfbRpiVhVqevf0QWlxl6DhyJ0TYG3R0LARa/+xqDwzU9mA4pbJxzPCI29A==",
"license": "MIT",
"funding": {
"type": "github",
@@ -6575,12 +6575,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.74.4",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.74.4.tgz",
"integrity": "sha512-mAbxw60d4ffQ4qmRYfkO1xzRBPUEf/72Dgo3qqea0J66nIKuDTLEqQt0ku++SDFlMGMnB6uKDnEG1xD/TDse4Q==",
"version": "5.74.7",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.74.7.tgz",
"integrity": "sha512-u4o/RIWnnrq26orGZu2NDPwmVof1vtAiiV6KYUXd49GuK+8HX+gyxoAYqIaZogvCE1cqOuZAhQKcrKGYGkrLxg==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.74.4"
"@tanstack/query-core": "5.74.7"
},
"funding": {
"type": "github",
@@ -6853,9 +6853,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.14.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
"integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
"version": "22.15.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.3.tgz",
"integrity": "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@@ -7925,9 +7925,9 @@
}
},
"node_modules/axios": {
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz",
"integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==",
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz",
"integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
@@ -9165,9 +9165,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.142",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.142.tgz",
"integrity": "sha512-Ah2HgkTu/9RhTDNThBtzu2Wirdy4DC9b0sMT1pUhbkZQ5U/iwmE+PHZX1MpjD5IkJCc2wSghgGG/B04szAx07w==",
"version": "1.5.143",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.143.tgz",
"integrity": "sha512-QqklJMOFBMqe46k8iIOwA9l2hz57V2OKMmP5eSWcUvwx+mASAsbU+wkF1pHjn9ZVSBPrsYWr4/W/95y5SwYg2g==",
"license": "ISC"
},
"node_modules/emoji-regex": {
@@ -10623,9 +10623,9 @@
}
},
"node_modules/framer-motion": {
"version": "12.9.1",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.9.1.tgz",
"integrity": "sha512-dZBp2TO0a39Cc24opshlLoM0/OdTZVKzcXWuhntfwy2Qgz3t9+N4sTyUqNANyHaRFiJUWbwwsXeDvQkEBPky+g==",
"version": "12.9.2",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.9.2.tgz",
"integrity": "sha512-R0O3Jdqbfwywpm45obP+8sTgafmdEcUoShQTAV+rB5pi+Y1Px/FYL5qLLRe5tPtBdN1J4jos7M+xN2VV2oEAbQ==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.9.1",
@@ -10968,9 +10968,9 @@
"license": "MIT"
},
"node_modules/graphql": {
"version": "16.10.0",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.10.0.tgz",
"integrity": "sha512-AjqGKbDGUFRKIRCP9tCKiIGHyriz2oHEbPIbEtcSLSs4YjReZOIPQQWek4+6hjw62H9QShXHyaGivGiYVLeYFQ==",
"version": "16.11.0",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz",
"integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -12031,9 +12031,9 @@
"license": "MIT"
},
"node_modules/isbot": {
"version": "5.1.26",
"resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.26.tgz",
"integrity": "sha512-3wqJEYSIm59dYQjEF7zJ7T42aqaqxbCyJQda5rKCudJykuAnISptCHR/GSGpOnw8UrvU+mGueNLRJS5HXnbsXQ==",
"version": "5.1.27",
"resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.27.tgz",
"integrity": "sha512-V3W56Hnztt4Wdh3VUlAMbdNicX/tOM38eChW3a2ixP6KEBJAeehxzYzTD59JrU5NCTgBZwRt9lRWr8D7eMZVYQ==",
"license": "Unlicense",
"engines": {
"node": ">=18"
@@ -14919,9 +14919,9 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.236.6",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.236.6.tgz",
"integrity": "sha512-IX4fkn3HCK+ObdHr/AuWd+Ks7bgMpRpOQB93b5rDJAWkG4if4xFVUn5pgEjyCNeOO2GM1ECnp08q9tYNYEfwbA==",
"version": "1.236.7",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.236.7.tgz",
"integrity": "sha512-HatTinqAt/6aAraCgbnP+2MTeVTChdf6TDsQkef4/yUnXeA4tsHmXnGGJ3vnzQk7N//R6lIHN189BZDO9kuKAg==",
"license": "MIT",
"dependencies": {
"core-js": "^3.38.1",
@@ -15922,9 +15922,9 @@
}
},
"node_modules/rollup": {
"version": "4.40.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.0.tgz",
"integrity": "sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w==",
"version": "4.40.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.1.tgz",
"integrity": "sha512-C5VvvgCCyfyotVITIAv+4efVytl5F7wt+/I2i9q9GZcEXW9BP52YYOXC58igUi+LFZVHukErIIqQSWwv/M3WRw==",
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.7"
@@ -15937,26 +15937,26 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.40.0",
"@rollup/rollup-android-arm64": "4.40.0",
"@rollup/rollup-darwin-arm64": "4.40.0",
"@rollup/rollup-darwin-x64": "4.40.0",
"@rollup/rollup-freebsd-arm64": "4.40.0",
"@rollup/rollup-freebsd-x64": "4.40.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.40.0",
"@rollup/rollup-linux-arm-musleabihf": "4.40.0",
"@rollup/rollup-linux-arm64-gnu": "4.40.0",
"@rollup/rollup-linux-arm64-musl": "4.40.0",
"@rollup/rollup-linux-loongarch64-gnu": "4.40.0",
"@rollup/rollup-linux-powerpc64le-gnu": "4.40.0",
"@rollup/rollup-linux-riscv64-gnu": "4.40.0",
"@rollup/rollup-linux-riscv64-musl": "4.40.0",
"@rollup/rollup-linux-s390x-gnu": "4.40.0",
"@rollup/rollup-linux-x64-gnu": "4.40.0",
"@rollup/rollup-linux-x64-musl": "4.40.0",
"@rollup/rollup-win32-arm64-msvc": "4.40.0",
"@rollup/rollup-win32-ia32-msvc": "4.40.0",
"@rollup/rollup-win32-x64-msvc": "4.40.0",
"@rollup/rollup-android-arm-eabi": "4.40.1",
"@rollup/rollup-android-arm64": "4.40.1",
"@rollup/rollup-darwin-arm64": "4.40.1",
"@rollup/rollup-darwin-x64": "4.40.1",
"@rollup/rollup-freebsd-arm64": "4.40.1",
"@rollup/rollup-freebsd-x64": "4.40.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.40.1",
"@rollup/rollup-linux-arm-musleabihf": "4.40.1",
"@rollup/rollup-linux-arm64-gnu": "4.40.1",
"@rollup/rollup-linux-arm64-musl": "4.40.1",
"@rollup/rollup-linux-loongarch64-gnu": "4.40.1",
"@rollup/rollup-linux-powerpc64le-gnu": "4.40.1",
"@rollup/rollup-linux-riscv64-gnu": "4.40.1",
"@rollup/rollup-linux-riscv64-musl": "4.40.1",
"@rollup/rollup-linux-s390x-gnu": "4.40.1",
"@rollup/rollup-linux-x64-gnu": "4.40.1",
"@rollup/rollup-linux-x64-musl": "4.40.1",
"@rollup/rollup-win32-arm64-msvc": "4.40.1",
"@rollup/rollup-win32-ia32-msvc": "4.40.1",
"@rollup/rollup-win32-x64-msvc": "4.40.1",
"fsevents": "~2.3.2"
}
},
@@ -17507,9 +17507,9 @@
}
},
"node_modules/type-fest": {
"version": "4.40.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.40.0.tgz",
"integrity": "sha512-ABHZ2/tS2JkvH1PEjxFDTUWC8dB5OsIGZP4IFLhR293GqT5Y5qB1WwL2kMPYhQW9DVgVD8Hd7I8gjwPIf5GFkw==",
"version": "4.40.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.40.1.tgz",
"integrity": "sha512-9YvLNnORDpI+vghLU/Nf+zSv0kL47KbVJ1o3sKgoTefl6i+zebxbiDQWoe/oWWqPhIgQdRZRT1KA9sCPL810SA==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
+7 -7
View File
@@ -16,22 +16,22 @@
"@reduxjs/toolkit": "^2.7.0",
"@stripe/react-stripe-js": "^3.6.0",
"@stripe/stripe-js": "^7.2.0",
"@tanstack/react-query": "^5.74.4",
"@tanstack/react-query": "^5.74.7",
"@vitejs/plugin-react": "^4.4.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.8.4",
"axios": "^1.9.0",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.9.1",
"framer-motion": "^12.9.2",
"i18next": "^25.0.1",
"i18next-browser-languagedetector": "^8.0.5",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.25",
"isbot": "^5.1.27",
"jose": "^6.0.10",
"lucide-react": "^0.503.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.236.6",
"posthog-js": "^1.236.7",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-highlight": "^0.15.0",
@@ -84,12 +84,12 @@
"@playwright/test": "^1.52.0",
"@react-router/dev": "^7.5.2",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.73.3",
"@tanstack/eslint-plugin-query": "^5.74.7",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.14.1",
"@types/node": "^22.15.3",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.1",
"@types/react-highlight": "^0.12.8",
@@ -107,6 +107,10 @@ function isRawTranslationKey(str) {
const EXCLUDED_TECHNICAL_STRINGS = [
"openid email profile", // OAuth scope string - not user-facing
"OPEN_ISSUE", // Task type identifier, not a UI string
"Merge Request", // Git provider specific terminology
"GitLab API", // Git provider specific terminology
"Pull Request", // Git provider specific terminology
"GitHub API", // Git provider specific terminology
];
function isExcludedTechnicalString(str) {
@@ -1,4 +1,4 @@
import { DiffEditor } from "@monaco-editor/react";
import { DiffEditor, Monaco } from "@monaco-editor/react";
import React from "react";
import { editor as editor_t } from "monaco-editor";
import { LuFileDiff, LuFileMinus, LuFilePlus } from "react-icons/lu";
@@ -88,6 +88,29 @@ export function FileDiffViewer({ path, type }: FileDiffViewerProps) {
}
}, []);
const beforeMount = (monaco: Monaco) => {
monaco.editor.defineTheme("custom-diff-theme", {
base: "vs-dark",
inherit: true,
rules: [
{ token: "comment", foreground: "6a9955" },
{ token: "keyword", foreground: "569cd6" },
{ token: "string", foreground: "ce9178" },
{ token: "number", foreground: "b5cea8" },
],
colors: {
"diffEditor.insertedTextBackground": "#014b01AA", // Stronger green background
"diffEditor.removedTextBackground": "#750000AA", // Stronger red background
"diffEditor.insertedLineBackground": "#003f00AA", // Dark green for added lines
"diffEditor.removedLineBackground": "#5a0000AA", // Dark red for removed lines
"diffEditor.border": "#444444", // Border between diff editors
"editorUnnecessaryCode.border": "#00000000", // No border for unnecessary code
"editorUnnecessaryCode.opacity": "#00000077", // Slightly faded
},
});
};
const handleEditorDidMount = (editor: editor_t.IStandaloneDiffEditor) => {
diffEditorRef.current = editor;
updateEditorHeight();
@@ -145,8 +168,9 @@ export function FileDiffViewer({ path, type }: FileDiffViewerProps) {
language={getLanguageFromPath(filePath)}
original={isAdded ? "" : diff.original}
modified={isDeleted ? "" : diff.modified}
theme="vs-dark"
theme="custom-diff-theme"
onMount={handleEditorDidMount}
beforeMount={beforeMount}
options={{
renderValidationDecorations: "off",
readOnly: true,
@@ -0,0 +1,15 @@
import { InputSkeleton } from "../input-skeleton";
import { SwitchSkeleton } from "../switch-skeleton";
export function AppSettingsInputsSkeleton() {
return (
<div
data-testid="app-settings-skeleton"
className="px-11 py-9 flex flex-col gap-6"
>
<InputSkeleton />
<SwitchSkeleton />
<SwitchSkeleton />
</div>
);
}
@@ -0,0 +1,34 @@
import { useTranslation } from "react-i18next";
import { AvailableLanguages } from "#/i18n";
import { I18nKey } from "#/i18n/declaration";
import { SettingsDropdownInput } from "../settings-dropdown-input";
interface LanguageInputProps {
name: string;
onChange: (value: string) => void;
defaultKey: string;
}
export function LanguageInput({
defaultKey,
onChange,
name,
}: LanguageInputProps) {
const { t } = useTranslation();
return (
<SettingsDropdownInput
testId={name}
name={name}
onInputChange={onChange}
label={t(I18nKey.SETTINGS$LANGUAGE)}
items={AvailableLanguages.map((l) => ({
key: l.value,
label: l.label,
}))}
defaultSelectedKey={defaultKey}
isClearable={false}
wrapperClassName="w-[680px]"
/>
);
}
@@ -2,6 +2,7 @@ import { cn } from "#/utils/utils";
interface BrandButtonProps {
testId?: string;
name?: string;
variant: "primary" | "secondary" | "danger";
type: React.ButtonHTMLAttributes<HTMLButtonElement>["type"];
isDisabled?: boolean;
@@ -12,6 +13,7 @@ interface BrandButtonProps {
export function BrandButton({
testId,
name,
children,
variant,
type,
@@ -22,6 +24,7 @@ export function BrandButton({
}: React.PropsWithChildren<BrandButtonProps>) {
return (
<button
name={name}
data-testid={testId}
disabled={isDisabled}
// The type is alreadt passed as a prop to the button component
@@ -0,0 +1,27 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../brand-button";
interface ConfigureGitHubRepositoriesAnchorProps {
slug: string;
}
export function ConfigureGitHubRepositoriesAnchor({
slug,
}: ConfigureGitHubRepositoriesAnchorProps) {
const { t } = useTranslation();
return (
<a
data-testid="configure-github-repositories-button"
href={`https://github.com/apps/${slug}/installations/new`}
target="_blank"
rel="noreferrer noopener"
className="px-11 py-9"
>
<BrandButton type="button" variant="secondary">
{t(I18nKey.GITHUB$CONFIGURE_REPOS)}
</BrandButton>
</a>
);
}
@@ -0,0 +1,18 @@
import { InputSkeleton } from "../input-skeleton";
import { SubtextSkeleton } from "../subtext-skeleton";
export function GitSettingInputsSkeleton() {
return (
<div className="px-11 py-9 flex flex-col gap-12">
<div className="flex flex-col gap-6">
<InputSkeleton />
<SubtextSkeleton />
</div>
<div className="flex flex-col gap-6">
<InputSkeleton />
<SubtextSkeleton />
</div>
</div>
);
}
@@ -0,0 +1,30 @@
import { Trans } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export function GitHubTokenHelpAnchor() {
return (
<p data-testid="github-token-help-anchor" className="text-xs">
<Trans
i18nKey={I18nKey.GITHUB$TOKEN_HELP_TEXT}
components={[
<a
key="github-token-help-anchor-link"
aria-label="GitHub token help link"
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
/>,
<a
key="github-token-help-anchor-link-2"
aria-label="GitHub token see more link"
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
/>,
]}
/>
</p>
);
}
@@ -0,0 +1,43 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { SettingsInput } from "../settings-input";
import { GitHubTokenHelpAnchor } from "./github-token-help-anchor";
import { KeyStatusIcon } from "../key-status-icon";
interface GitHubTokenInputProps {
onChange: (value: string) => void;
isGitHubTokenSet: boolean;
name: string;
}
export function GitHubTokenInput({
onChange,
isGitHubTokenSet,
name,
}: GitHubTokenInputProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-6">
<SettingsInput
testId={name}
name={name}
onChange={onChange}
label={t(I18nKey.GITHUB$TOKEN_LABEL)}
type="password"
className="w-[680px]"
placeholder={isGitHubTokenSet ? "<hidden>" : ""}
startContent={
isGitHubTokenSet && (
<KeyStatusIcon
testId="gh-set-token-indicator"
isSet={isGitHubTokenSet}
/>
)
}
/>
<GitHubTokenHelpAnchor />
</div>
);
}
@@ -0,0 +1,30 @@
import { Trans } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export function GitLabTokenHelpAnchor() {
return (
<p data-testid="gitlab-token-help-anchor" className="text-xs">
<Trans
i18nKey={I18nKey.GITLAB$TOKEN_HELP_TEXT}
components={[
<a
key="gitlab-token-help-anchor-link"
aria-label="Gitlab token help link"
href="https://gitlab.com/-/user_settings/personal_access_tokens?name=openhands-app&scopes=api,read_user,read_repository,write_repository"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
/>,
<a
key="gitlab-token-help-anchor-link-2"
aria-label="GitLab token see more link"
href="https://docs.gitlab.com/user/profile/personal_access_tokens/"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
/>,
]}
/>
</p>
);
}
@@ -0,0 +1,43 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { SettingsInput } from "../settings-input";
import { GitLabTokenHelpAnchor } from "./gitlab-token-help-anchor";
import { KeyStatusIcon } from "../key-status-icon";
interface GitLabTokenInputProps {
onChange: (value: string) => void;
isGitLabTokenSet: boolean;
name: string;
}
export function GitLabTokenInput({
onChange,
isGitLabTokenSet,
name,
}: GitLabTokenInputProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-6">
<SettingsInput
testId={name}
name={name}
onChange={onChange}
label={t(I18nKey.GITLAB$TOKEN_LABEL)}
type="password"
className="w-[680px]"
placeholder={isGitLabTokenSet ? "<hidden>" : ""}
startContent={
isGitLabTokenSet && (
<KeyStatusIcon
testId="gl-set-token-indicator"
isSet={isGitLabTokenSet}
/>
)
}
/>
<GitLabTokenHelpAnchor />
</div>
);
}
@@ -0,0 +1,8 @@
export function InputSkeleton() {
return (
<div className="flex flex-col gap-2.5">
<div className="w-[70px] h-[20px] skeleton" />
<div className="w-[680px] h-[40px] skeleton" />
</div>
);
}
@@ -2,12 +2,13 @@ import SuccessIcon from "#/icons/success.svg?react";
import { cn } from "#/utils/utils";
interface KeyStatusIconProps {
testId?: string;
isSet: boolean;
}
export function KeyStatusIcon({ isSet }: KeyStatusIconProps) {
export function KeyStatusIcon({ testId, isSet }: KeyStatusIconProps) {
return (
<span data-testid={isSet ? "set-indicator" : "unset-indicator"}>
<span data-testid={testId || (isSet ? "set-indicator" : "unset-indicator")}>
<SuccessIcon className={cn(isSet ? "text-success" : "text-danger")} />
</span>
);
@@ -0,0 +1,21 @@
import { InputSkeleton } from "../input-skeleton";
import { SubtextSkeleton } from "../subtext-skeleton";
import { SwitchSkeleton } from "../switch-skeleton";
export function LlmSettingsInputsSkeleton() {
return (
<div
data-testid="app-settings-skeleton"
className="px-11 py-9 flex flex-col gap-6"
>
<SwitchSkeleton />
<InputSkeleton />
<InputSkeleton />
<InputSkeleton />
<SubtextSkeleton />
<SwitchSkeleton />
<SwitchSkeleton />
<InputSkeleton />
</div>
);
}
@@ -0,0 +1,41 @@
import { useTranslation } from "react-i18next";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../brand-button";
interface ResetSettingsModalProps {
onReset: () => void;
}
export function ResetSettingsModal({ onReset }: ResetSettingsModalProps) {
const { t } = useTranslation();
return (
<ModalBackdrop>
<div className="bg-base-secondary p-4 rounded-xl flex flex-col gap-4 border border-tertiary">
<p>{t(I18nKey.SETTINGS$RESET_CONFIRMATION)}</p>
<div className="w-full flex gap-2" data-testid="reset-settings-modal">
<BrandButton
testId="confirm-button"
type="submit"
name="reset-settings"
variant="primary"
className="grow"
>
Reset
</BrandButton>
<BrandButton
testId="cancel-button"
type="button"
variant="secondary"
className="grow"
onClick={onReset}
>
Cancel
</BrandButton>
</div>
</div>
</ModalBackdrop>
);
}
@@ -6,6 +6,7 @@ interface SettingsSwitchProps {
name?: string;
onToggle?: (value: boolean) => void;
defaultIsToggled?: boolean;
isToggled?: boolean;
isBeta?: boolean;
}
@@ -15,6 +16,7 @@ export function SettingsSwitch({
name,
onToggle,
defaultIsToggled,
isToggled: controlledIsToggled,
isBeta,
}: React.PropsWithChildren<SettingsSwitchProps>) {
const [isToggled, setIsToggled] = React.useState(defaultIsToggled ?? false);
@@ -25,17 +27,18 @@ export function SettingsSwitch({
};
return (
<label className="flex items-center gap-2 w-fit">
<label className="flex items-center gap-2 w-fit cursor-pointer">
<input
hidden
data-testid={testId}
name={name}
type="checkbox"
onChange={(e) => handleToggle(e.target.checked)}
checked={controlledIsToggled ?? isToggled}
defaultChecked={defaultIsToggled}
/>
<StyledSwitchComponent isToggled={isToggled} />
<StyledSwitchComponent isToggled={controlledIsToggled ?? isToggled} />
<div className="flex items-center gap-1">
<span className="text-sm">{children}</span>
@@ -0,0 +1,3 @@
export function SubtextSkeleton() {
return <div className="w-[250px] h-[20px] skeleton" />;
}
@@ -0,0 +1,8 @@
export function SwitchSkeleton() {
return (
<div className="flex items-center gap-2">
<div className="w-[48px] h-[24px] skeleton-round" />
<div className="w-[100px] h-[20px] skeleton" />
</div>
);
}
@@ -14,12 +14,14 @@ interface ModelSelectorProps {
isDisabled?: boolean;
models: Record<string, { separator: string; models: string[] }>;
currentModel?: string;
onChange?: (model: string | null) => void;
}
export function ModelSelector({
isDisabled,
models,
currentModel,
onChange,
}: ModelSelectorProps) {
const [, setLitellmId] = React.useState<string | null>(null);
const [selectedProvider, setSelectedProvider] = React.useState<string | null>(
@@ -55,6 +57,7 @@ export function ModelSelector({
}
setLitellmId(fullModel);
setSelectedModel(model);
onChange?.(fullModel);
};
const clear = () => {
+2 -1
View File
@@ -4,8 +4,9 @@ import posthog from "posthog-js";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { Settings } from "#/types/settings";
const getSettingsQueryFn = async () => {
const getSettingsQueryFn = async (): Promise<Settings> => {
const apiSettings = await OpenHands.getSettings();
return {
+17
View File
@@ -26,6 +26,17 @@ export enum I18nKey {
ANALYTICS$DESCRIPTION = "ANALYTICS$DESCRIPTION",
ANALYTICS$SEND_ANONYMOUS_DATA = "ANALYTICS$SEND_ANONYMOUS_DATA",
ANALYTICS$CONFIRM_PREFERENCES = "ANALYTICS$CONFIRM_PREFERENCES",
SETTINGS$SAVING = "SETTINGS$SAVING",
SETTINGS$SAVE_CHANGES = "SETTINGS$SAVE_CHANGES",
SETTINGS$NAV_GIT = "SETTINGS$NAV_GIT",
SETTINGS$NAV_APPLICATION = "SETTINGS$NAV_APPLICATION",
SETTINGS$NAV_CREDITS = "SETTINGS$NAV_CREDITS",
SETTINGS$NAV_API_KEYS = "SETTINGS$NAV_API_KEYS",
SETTINGS$NAV_LLM = "SETTINGS$NAV_LLM",
GIT$MERGE_REQUEST = "GIT$MERGE_REQUEST",
GIT$GITLAB_API = "GIT$GITLAB_API",
GIT$PULL_REQUEST = "GIT$PULL_REQUEST",
GIT$GITHUB_API = "GIT$GITHUB_API",
BUTTON$COPY = "BUTTON$COPY",
BUTTON$COPIED = "BUTTON$COPIED",
APP$TITLE = "APP$TITLE",
@@ -95,6 +106,9 @@ export enum I18nKey {
GITHUB$TOKEN_LABEL = "GITHUB$TOKEN_LABEL",
GITHUB$TOKEN_OPTIONAL = "GITHUB$TOKEN_OPTIONAL",
GITHUB$GET_TOKEN = "GITHUB$GET_TOKEN",
GITHUB$TOKEN_HELP_TEXT = "GITHUB$TOKEN_HELP_TEXT",
GITHUB$TOKEN_LINK_TEXT = "GITHUB$TOKEN_LINK_TEXT",
GITHUB$INSTRUCTIONS_LINK_TEXT = "GITHUB$INSTRUCTIONS_LINK_TEXT",
COMMON$HERE = "COMMON$HERE",
ANALYTICS$ENABLE = "ANALYTICS$ENABLE",
GITHUB$TOKEN_INVALID = "GITHUB$TOKEN_INVALID",
@@ -437,6 +451,9 @@ export enum I18nKey {
MODEL_SELECTOR$OTHERS = "MODEL_SELECTOR$OTHERS",
GITLAB$TOKEN_LABEL = "GITLAB$TOKEN_LABEL",
GITLAB$GET_TOKEN = "GITLAB$GET_TOKEN",
GITLAB$TOKEN_HELP_TEXT = "GITLAB$TOKEN_HELP_TEXT",
GITLAB$TOKEN_LINK_TEXT = "GITLAB$TOKEN_LINK_TEXT",
GITLAB$INSTRUCTIONS_LINK_TEXT = "GITLAB$INSTRUCTIONS_LINK_TEXT",
GITLAB$OR_SEE = "GITLAB$OR_SEE",
COMMON$DOCUMENTATION = "COMMON$DOCUMENTATION",
DIFF_VIEWER$LOADING = "DIFF_VIEWER$LOADING",
+255
View File
@@ -389,6 +389,171 @@
"tr": "Tercihleri Onayla",
"de": "Einstellungen bestätigen"
},
"SETTINGS$SAVING": {
"en": "Saving...",
"ja": "保存中...",
"zh-CN": "保存中...",
"zh-TW": "儲存中...",
"ko-KR": "저장 중...",
"no": "Lagrer...",
"it": "Salvataggio in corso...",
"pt": "Salvando...",
"es": "Guardando...",
"ar": "جار الحفظ...",
"fr": "Enregistrement en cours...",
"tr": "Kayıt yapılıyor...",
"de": "Speichern..."
},
"SETTINGS$SAVE_CHANGES": {
"en": "Save Changes",
"ja": "変更を保存",
"zh-CN": "保存更改",
"zh-TW": "儲存變更",
"ko-KR": "변경 사항 저장",
"no": "Lagre endringer",
"it": "Salva modifiche",
"pt": "Salvar alterações",
"es": "Guardar cambios",
"ar": "حفظ التغييرات",
"fr": "Enregistrer les modifications",
"tr": "Değişiklikleri Kaydet",
"de": "Änderungen speichern"
},
"SETTINGS$NAV_GIT": {
"en": "Git",
"ja": "Git",
"zh-CN": "Git",
"zh-TW": "Git",
"ko-KR": "Git",
"no": "Git",
"it": "Git",
"pt": "Git",
"es": "Git",
"ar": "Git",
"fr": "Git",
"tr": "Git",
"de": "Git"
},
"SETTINGS$NAV_APPLICATION": {
"en": "Application",
"ja": "アプリケーション",
"zh-CN": "应用程序",
"zh-TW": "應用程式",
"ko-KR": "애플리케이션",
"no": "Applikasjon",
"it": "Applicazione",
"pt": "Aplicação",
"es": "Aplicación",
"ar": "التطبيق",
"fr": "Application",
"tr": "Uygulama",
"de": "Anwendung"
},
"SETTINGS$NAV_CREDITS": {
"en": "Credits",
"ja": "クレジット",
"zh-CN": "积分",
"zh-TW": "點數",
"ko-KR": "크레딧",
"no": "Kreditter",
"it": "Crediti",
"pt": "Créditos",
"es": "Créditos",
"ar": "الرصيد",
"fr": "Crédits",
"tr": "Krediler",
"de": "Guthaben"
},
"SETTINGS$NAV_API_KEYS": {
"en": "API Keys",
"ja": "APIキー",
"zh-CN": "API密钥",
"zh-TW": "API金鑰",
"ko-KR": "API 키",
"no": "API-nøkler",
"it": "Chiavi API",
"pt": "Chaves de API",
"es": "Claves API",
"ar": "مفاتيح API",
"fr": "Clés API",
"tr": "API Anahtarları",
"de": "API-Schlüssel"
},
"SETTINGS$NAV_LLM": {
"en": "LLM",
"ja": "LLM",
"zh-CN": "LLM",
"zh-TW": "LLM",
"ko-KR": "LLM",
"no": "LLM",
"it": "LLM",
"pt": "LLM",
"es": "LLM",
"ar": "LLM",
"fr": "LLM",
"tr": "LLM",
"de": "LLM"
},
"GIT$MERGE_REQUEST": {
"en": "Merge Request",
"ja": "マージリクエスト",
"zh-CN": "合并请求",
"zh-TW": "合併請求",
"ko-KR": "머지 요청",
"no": "Fletteforespørsel",
"it": "Richiesta di fusione",
"pt": "Solicitação de mesclagem",
"es": "Solicitud de fusión",
"ar": "طلب الدمج",
"fr": "Demande de fusion",
"tr": "Birleştirme İsteği",
"de": "Merge-Anfrage"
},
"GIT$GITLAB_API": {
"en": "GitLab API",
"ja": "GitLab API",
"zh-CN": "GitLab API",
"zh-TW": "GitLab API",
"ko-KR": "GitLab API",
"no": "GitLab API",
"it": "API GitLab",
"pt": "API do GitLab",
"es": "API de GitLab",
"ar": "واجهة برمجة تطبيقات GitLab",
"fr": "API GitLab",
"tr": "GitLab API",
"de": "GitLab API"
},
"GIT$PULL_REQUEST": {
"en": "Pull Request",
"ja": "プルリクエスト",
"zh-CN": "拉取请求",
"zh-TW": "拉取請求",
"ko-KR": "풀 리퀘스트",
"no": "Trekkforespørsel",
"it": "Richiesta di pull",
"pt": "Solicitação de pull",
"es": "Solicitud de extracción",
"ar": "طلب السحب",
"fr": "Demande de tirage",
"tr": "Çekme İsteği",
"de": "Pull Request"
},
"GIT$GITHUB_API": {
"en": "GitHub API",
"ja": "GitHub API",
"zh-CN": "GitHub API",
"zh-TW": "GitHub API",
"ko-KR": "GitHub API",
"no": "GitHub API",
"it": "API GitHub",
"pt": "API do GitHub",
"es": "API de GitHub",
"ar": "واجهة برمجة تطبيقات GitHub",
"fr": "API GitHub",
"tr": "GitHub API",
"de": "GitHub API"
},
"BUTTON$COPY": {
"en": "Copy to clipboard",
"ja": "クリップボードにコピー",
@@ -1434,6 +1599,51 @@
"tr": "Jetonunuzu alın",
"de": "Token abrufen"
},
"GITHUB$TOKEN_HELP_TEXT": {
"en": "Get your <0>GitHub token</0> or <1>click here for instructions</1>",
"ja": "<0>GitHubトークン</0>を取得するか、<1>手順についてはここをクリック</1>",
"zh-CN": "获取您的<0>GitHub令牌</0>或<1>点击此处获取说明</1>",
"zh-TW": "取得您的<0>GitHub權杖</0>或<1>點擊此處獲取說明</1>",
"ko-KR": "<0>GitHub 토큰</0>을 받거나 <1>지침을 보려면 여기를 클릭</1>",
"no": "Få din <0>GitHub-token</0> eller <1>klikk her for instruksjoner</1>",
"it": "Ottieni il tuo <0>token GitHub</0> o <1>clicca qui per istruzioni</1>",
"pt": "Obtenha seu <0>token GitHub</0> ou <1>clique aqui para instruções</1>",
"es": "Obtenga su <0>token de GitHub</0> o <1>haga clic aquí para obtener instrucciones</1>",
"ar": "احصل على <0>رمز GitHub</0> الخاص بك أو <1>انقر هنا للحصول على تعليمات</1>",
"fr": "Obtenez votre <0>jeton GitHub</0> ou <1>cliquez ici pour les instructions</1>",
"tr": "<0>GitHub jetonu</0> alın veya <1>talimatlar için buraya tıklayın</1>",
"de": "Holen Sie sich Ihren <0>GitHub-Token</0> oder <1>klicken Sie hier für Anweisungen</1>"
},
"GITHUB$TOKEN_LINK_TEXT": {
"en": "GitHub token",
"ja": "GitHubトークン",
"zh-CN": "GitHub令牌",
"zh-TW": "GitHub權杖",
"ko-KR": "GitHub 토큰",
"no": "GitHub-token",
"it": "token GitHub",
"pt": "token GitHub",
"es": "token de GitHub",
"ar": "رمز GitHub",
"fr": "jeton GitHub",
"tr": "GitHub jetonu",
"de": "GitHub-Token"
},
"GITHUB$INSTRUCTIONS_LINK_TEXT": {
"en": "click here for instructions",
"ja": "手順についてはここをクリック",
"zh-CN": "点击此处获取说明",
"zh-TW": "點擊此處獲取說明",
"ko-KR": "지침을 보려면 여기를 클릭",
"no": "klikk her for instruksjoner",
"it": "clicca qui per istruzioni",
"pt": "clique aqui para instruções",
"es": "haga clic aquí para obtener instrucciones",
"ar": "انقر هنا للحصول على تعليمات",
"fr": "cliquez ici pour les instructions",
"tr": "talimatlar için buraya tıklayın",
"de": "klicken Sie hier für Anweisungen"
},
"COMMON$HERE": {
"en": "here",
"ja": "こちら",
@@ -6274,6 +6484,51 @@
"tr": "Üzerinde bir jeton oluştur",
"de": "Token generieren auf"
},
"GITLAB$TOKEN_HELP_TEXT": {
"en": "Get your <0>GitLab token</0> or <1>click here for instructions</1>",
"ja": "<0>GitLabトークン</0>を取得するか、<1>手順についてはここをクリック</1>",
"zh-CN": "获取您的<0>GitLab令牌</0>或<1>点击此处获取说明</1>",
"zh-TW": "取得您的<0>GitLab權杖</0>或<1>點擊此處獲取說明</1>",
"ko-KR": "<0>GitLab 토큰</0>을 받거나 <1>지침을 보려면 여기를 클릭</1>",
"no": "Få din <0>GitLab-token</0> eller <1>klikk her for instruksjoner</1>",
"it": "Ottieni il tuo <0>token GitLab</0> o <1>clicca qui per istruzioni</1>",
"pt": "Obtenha seu <0>token GitLab</0> ou <1>clique aqui para instruções</1>",
"es": "Obtenga su <0>token de GitLab</0> o <1>haga clic aquí para obtener instrucciones</1>",
"ar": "احصل على <0>رمز GitLab</0> الخاص بك أو <1>انقر هنا للحصول على تعليمات</1>",
"fr": "Obtenez votre <0>jeton GitLab</0> ou <1>cliquez ici pour les instructions</1>",
"tr": "<0>GitLab jetonu</0> alın veya <1>talimatlar için buraya tıklayın</1>",
"de": "Holen Sie sich Ihren <0>GitLab-Token</0> oder <1>klicken Sie hier für Anweisungen</1>"
},
"GITLAB$TOKEN_LINK_TEXT": {
"en": "GitLab token",
"ja": "GitLabトークン",
"zh-CN": "GitLab令牌",
"zh-TW": "GitLab權杖",
"ko-KR": "GitLab 토큰",
"no": "GitLab-token",
"it": "token GitLab",
"pt": "token GitLab",
"es": "token de GitLab",
"ar": "رمز GitLab",
"fr": "jeton GitLab",
"tr": "GitLab jetonu",
"de": "GitLab-Token"
},
"GITLAB$INSTRUCTIONS_LINK_TEXT": {
"en": "click here for instructions",
"ja": "手順についてはここをクリック",
"zh-CN": "点击此处获取说明",
"zh-TW": "點擊此處獲取說明",
"ko-KR": "지침을 보려면 여기를 클릭",
"no": "klikk her for instruksjoner",
"it": "clicca qui per istruzioni",
"pt": "clique aqui para instruções",
"es": "haga clic aquí para obtener instrucciones",
"ar": "انقر هنا للحصول على تعليمات",
"fr": "cliquez ici pour les instructions",
"tr": "talimatlar için buraya tıklayın",
"de": "klicken Sie hier für Anweisungen"
},
"GITLAB$OR_SEE": {
"en": "or see the",
"ja": "または参照",
+11
View File
@@ -35,6 +35,15 @@ const MOCK_USER_PREFERENCES: {
settings: null,
};
/**
* Set the user settings to the default settings
*
* Useful for resetting the settings in tests
*/
export const resetTestHandlersMockSettings = () => {
MOCK_USER_PREFERENCES.settings = MOCK_DEFAULT_USER_SETTINGS;
};
const conversations: Conversation[] = [
{
conversation_id: "1",
@@ -80,6 +89,7 @@ const openHandsHandlers = [
HttpResponse.json([
"gpt-3.5-turbo",
"gpt-4o",
"gpt-4o-mini",
"anthropic/claude-3.5",
"anthropic/claude-3-5-sonnet-20241022",
]),
@@ -173,6 +183,7 @@ export const handlers = [
return HttpResponse.json(settings);
}),
http.post("/api/settings", async ({ request }) => {
await delay();
const body = await request.json();
if (body) {
+3 -1
View File
@@ -9,7 +9,9 @@ export default [
layout("routes/root-layout.tsx", [
index("routes/home.tsx"),
route("settings", "routes/settings.tsx", [
index("routes/account-settings.tsx"),
index("routes/llm-settings.tsx"),
route("git", "routes/git-settings.tsx"),
route("app", "routes/app-settings.tsx"),
route("billing", "routes/billing.tsx"),
route("api-keys", "routes/api-keys.tsx"),
]),
-531
View File
@@ -1,531 +0,0 @@
import React from "react";
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "#/components/features/settings/brand-button";
import { HelpLink } from "#/components/features/settings/help-link";
import { KeyStatusIcon } from "#/components/features/settings/key-status-icon";
import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
import { useConfig } from "#/hooks/query/use-config";
import { useSettings } from "#/hooks/query/use-settings";
import { useAppLogout } from "#/hooks/use-app-logout";
import { AvailableLanguages } from "#/i18n";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import { hasAdvancedSettingsSet } from "#/utils/has-advanced-settings-set";
import { isCustomModel } from "#/utils/is-custom-model";
import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { ProviderOptions } from "#/types/settings";
import { useAuth } from "#/context/auth-context";
// Define REMOTE_RUNTIME_OPTIONS for testing
const REMOTE_RUNTIME_OPTIONS = [
{ key: "1", label: "Standard" },
{ key: "2", label: "Enhanced" },
{ key: "4", label: "Premium" },
];
function AccountSettings() {
const { t } = useTranslation();
const {
data: settings,
isFetching: isFetchingSettings,
isFetched,
isSuccess: isSuccessfulSettings,
} = useSettings();
const { data: config } = useConfig();
const {
data: resources,
isFetching: isFetchingResources,
isSuccess: isSuccessfulResources,
} = useAIConfigOptions();
const { mutate: saveSettings } = useSaveSettings();
const { handleLogout } = useAppLogout();
const { providerTokensSet, providersAreSet } = useAuth();
const isFetching = isFetchingSettings || isFetchingResources;
const isSuccess = isSuccessfulSettings && isSuccessfulResources;
const isSaas = config?.APP_MODE === "saas";
const shouldHandleSpecialSaasCase =
config?.FEATURE_FLAGS.HIDE_LLM_SETTINGS && isSaas;
const determineWhetherToToggleAdvancedSettings = () => {
if (shouldHandleSpecialSaasCase) return true;
if (isSuccess) {
return (
isCustomModel(resources.models, settings.LLM_MODEL) ||
hasAdvancedSettingsSet({
...settings,
})
);
}
return false;
};
const hasAppSlug = !!config?.APP_SLUG;
const isGitHubTokenSet =
providerTokensSet.includes(ProviderOptions.github) || false;
const isGitLabTokenSet =
providerTokensSet.includes(ProviderOptions.gitlab) || false;
const isLLMKeySet = settings?.LLM_API_KEY_SET;
const isAnalyticsEnabled = settings?.USER_CONSENTS_TO_ANALYTICS;
const isAdvancedSettingsSet = determineWhetherToToggleAdvancedSettings();
const modelsAndProviders = organizeModelsAndProviders(
resources?.models || [],
);
const [llmConfigMode, setLlmConfigMode] = React.useState<
"basic" | "advanced"
>(isAdvancedSettingsSet ? "advanced" : "basic");
const [confirmationModeIsEnabled, setConfirmationModeIsEnabled] =
React.useState(!!settings?.SECURITY_ANALYZER);
const formRef = React.useRef<HTMLFormElement>(null);
const onSubmit = async (formData: FormData) => {
const languageLabel = formData.get("language-input")?.toString();
const languageValue = AvailableLanguages.find(
({ label }) => label === languageLabel,
)?.value;
const llmProvider = formData.get("llm-provider-input")?.toString();
const llmModel = formData.get("llm-model-input")?.toString();
const fullLlmModel = `${llmProvider}/${llmModel}`.toLowerCase();
const customLlmModel = formData.get("llm-custom-model-input")?.toString();
const rawRemoteRuntimeResourceFactor = formData
.get("runtime-settings-input")
?.toString();
const remoteRuntimeResourceFactor = REMOTE_RUNTIME_OPTIONS.find(
({ label }) => label === rawRemoteRuntimeResourceFactor,
)?.key;
const userConsentsToAnalytics =
formData.get("enable-analytics-switch")?.toString() === "on";
const enableMemoryCondenser =
formData.get("enable-memory-condenser-switch")?.toString() === "on";
const enableSoundNotifications =
formData.get("enable-sound-notifications-switch")?.toString() === "on";
const llmBaseUrl = formData.get("base-url-input")?.toString().trim() || "";
const inputApiKey = formData.get("llm-api-key-input")?.toString() || "";
const llmApiKey =
inputApiKey === "" && isLLMKeySet
? undefined // don't update if it's already set and input is empty
: inputApiKey; // otherwise use the input value
const githubToken = formData.get("github-token-input")?.toString();
const gitlabToken = formData.get("gitlab-token-input")?.toString();
// we don't want the user to be able to modify these settings in SaaS
const finalLlmModel = shouldHandleSpecialSaasCase
? undefined
: customLlmModel || fullLlmModel;
const finalLlmBaseUrl = shouldHandleSpecialSaasCase
? undefined
: llmBaseUrl;
const finalLlmApiKey = shouldHandleSpecialSaasCase ? undefined : llmApiKey;
const newSettings = {
provider_tokens:
githubToken || gitlabToken
? {
github: githubToken || "",
gitlab: gitlabToken || "",
}
: undefined,
LANGUAGE: languageValue,
user_consents_to_analytics: userConsentsToAnalytics,
ENABLE_DEFAULT_CONDENSER: enableMemoryCondenser,
ENABLE_SOUND_NOTIFICATIONS: enableSoundNotifications,
LLM_MODEL: finalLlmModel,
LLM_BASE_URL: finalLlmBaseUrl,
llm_api_key: finalLlmApiKey,
AGENT: formData.get("agent-input")?.toString(),
SECURITY_ANALYZER:
formData.get("security-analyzer-input")?.toString() || "",
REMOTE_RUNTIME_RESOURCE_FACTOR:
remoteRuntimeResourceFactor !== null
? Number(remoteRuntimeResourceFactor)
: DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
CONFIRMATION_MODE: confirmationModeIsEnabled,
};
saveSettings(newSettings, {
onSuccess: () => {
handleCaptureConsent(userConsentsToAnalytics);
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic");
},
onError: (error) => {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
},
});
};
React.useEffect(() => {
// If settings is still loading by the time the state is set, it will always
// default to basic settings. This is a workaround to ensure the correct
// settings are displayed.
setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic");
}, [isAdvancedSettingsSet]);
if (isFetched && !settings) {
return <div>Failed to fetch settings. Please try reloading.</div>;
}
const onToggleAdvancedMode = (isToggled: boolean) => {
setLlmConfigMode(isToggled ? "advanced" : "basic");
if (!isToggled) {
// reset advanced state
setConfirmationModeIsEnabled(!!settings?.SECURITY_ANALYZER);
}
};
if (isFetching || !settings) {
return (
<div className="flex grow p-4">
<LoadingSpinner size="large" />
</div>
);
}
return (
<>
<form
data-testid="account-settings-form"
ref={formRef}
action={onSubmit}
className="flex flex-col grow overflow-auto"
>
<div className="flex flex-col gap-12 px-11 py-9">
{!shouldHandleSpecialSaasCase && (
<section
data-testid="llm-settings-section"
className="flex flex-col gap-6"
>
<div className="flex items-center gap-7">
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
{t(I18nKey.SETTINGS$LLM_SETTINGS)}
</h2>
{!shouldHandleSpecialSaasCase && (
<SettingsSwitch
testId="advanced-settings-switch"
defaultIsToggled={isAdvancedSettingsSet}
onToggle={onToggleAdvancedMode}
>
{t(I18nKey.SETTINGS$ADVANCED)}
</SettingsSwitch>
)}
</div>
{llmConfigMode === "basic" && !shouldHandleSpecialSaasCase && (
<ModelSelector
models={modelsAndProviders}
currentModel={settings.LLM_MODEL}
/>
)}
{llmConfigMode === "advanced" && !shouldHandleSpecialSaasCase && (
<SettingsInput
testId="llm-custom-model-input"
name="llm-custom-model-input"
label={t(I18nKey.SETTINGS$CUSTOM_MODEL)}
defaultValue={settings.LLM_MODEL}
placeholder="anthropic/claude-3-5-sonnet-20241022"
type="text"
className="w-[680px]"
/>
)}
{llmConfigMode === "advanced" && !shouldHandleSpecialSaasCase && (
<SettingsInput
testId="base-url-input"
name="base-url-input"
label={t(I18nKey.SETTINGS$BASE_URL)}
defaultValue={settings.LLM_BASE_URL}
placeholder="https://api.openai.com"
type="text"
className="w-[680px]"
/>
)}
{!shouldHandleSpecialSaasCase && (
<SettingsInput
testId="llm-api-key-input"
name="llm-api-key-input"
label={t(I18nKey.SETTINGS_FORM$API_KEY)}
type="password"
className="w-[680px]"
placeholder={isLLMKeySet ? "<hidden>" : ""}
startContent={
isLLMKeySet && <KeyStatusIcon isSet={isLLMKeySet} />
}
/>
)}
{!shouldHandleSpecialSaasCase && (
<HelpLink
testId="llm-api-key-help-anchor"
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
href="https://docs.all-hands.dev/modules/usage/installation#getting-an-api-key"
/>
)}
{llmConfigMode === "advanced" && (
<SettingsDropdownInput
testId="agent-input"
name="agent-input"
label={t(I18nKey.SETTINGS$AGENT)}
items={
resources?.agents.map((agent) => ({
key: agent,
label: agent,
})) || []
}
wrapperClassName="w-[680px]"
defaultSelectedKey={settings.AGENT}
isClearable={false}
/>
)}
{isSaas && llmConfigMode === "advanced" && (
<SettingsDropdownInput
testId="runtime-settings-input"
name="runtime-settings-input"
label={
<>
{t(I18nKey.SETTINGS$RUNTIME_SETTINGS)}
<a href="mailto:contact@all-hands.dev">
{t(I18nKey.SETTINGS$GET_IN_TOUCH)}
</a>
)
</>
}
items={REMOTE_RUNTIME_OPTIONS}
defaultSelectedKey={settings.REMOTE_RUNTIME_RESOURCE_FACTOR?.toString()}
isDisabled
isClearable={false}
/>
)}
{llmConfigMode === "advanced" && (
<SettingsSwitch
testId="enable-confirmation-mode-switch"
onToggle={setConfirmationModeIsEnabled}
defaultIsToggled={!!settings.CONFIRMATION_MODE}
isBeta
>
{t(I18nKey.SETTINGS$CONFIRMATION_MODE)}
</SettingsSwitch>
)}
{llmConfigMode === "advanced" && (
<SettingsSwitch
testId="enable-memory-condenser-switch"
name="enable-memory-condenser-switch"
defaultIsToggled={!!settings.ENABLE_DEFAULT_CONDENSER}
>
{t(I18nKey.SETTINGS$ENABLE_MEMORY_CONDENSATION)}
</SettingsSwitch>
)}
{llmConfigMode === "advanced" && confirmationModeIsEnabled && (
<div>
<SettingsDropdownInput
testId="security-analyzer-input"
name="security-analyzer-input"
label={t(I18nKey.SETTINGS$SECURITY_ANALYZER)}
items={
resources?.securityAnalyzers.map((analyzer) => ({
key: analyzer,
label: analyzer,
})) || []
}
defaultSelectedKey={settings.SECURITY_ANALYZER}
isClearable
showOptionalTag
/>
</div>
)}
</section>
)}
<section className="flex flex-col gap-6">
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
{t(I18nKey.SETTINGS$GIT_SETTINGS)}
</h2>
{isSaas && hasAppSlug && (
<Link
to={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
target="_blank"
rel="noreferrer noopener"
>
<BrandButton type="button" variant="secondary">
{t(I18nKey.GITHUB$CONFIGURE_REPOS)}
</BrandButton>
</Link>
)}
{!isSaas && (
<>
<SettingsInput
testId="github-token-input"
name="github-token-input"
label={t(I18nKey.GITHUB$TOKEN_LABEL)}
type="password"
className="w-[680px]"
startContent={
isGitHubTokenSet && (
<KeyStatusIcon isSet={!!isGitHubTokenSet} />
)
}
placeholder={isGitHubTokenSet ? "<hidden>" : ""}
/>
<p data-testid="github-token-help-anchor" className="text-xs">
{" "}
{t(I18nKey.GITHUB$GET_TOKEN)}{" "}
<b>
{" "}
<a
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
>
GitHub
</a>{" "}
</b>
{t(I18nKey.COMMON$HERE)}{" "}
<b>
<a
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
>
{t(I18nKey.COMMON$CLICK_FOR_INSTRUCTIONS)}
</a>
</b>
.
</p>
<SettingsInput
testId="gitlab-token-input"
name="gitlab-token-input"
label={t(I18nKey.GITLAB$TOKEN_LABEL)}
type="password"
className="w-[680px]"
startContent={
isGitLabTokenSet && (
<KeyStatusIcon isSet={!!isGitLabTokenSet} />
)
}
placeholder={isGitHubTokenSet ? "<hidden>" : ""}
/>
<p data-testid="gitlab-token-help-anchor" className="text-xs">
{" "}
{t(I18nKey.GITLAB$GET_TOKEN)}{" "}
<b>
{" "}
<a
href="https://gitlab.com/-/user_settings/personal_access_tokens?name=openhands-app&scopes=api,read_user,read_repository,write_repository"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
>
GitLab
</a>{" "}
</b>
{t(I18nKey.GITLAB$OR_SEE)}{" "}
<b>
<a
href="https://docs.gitlab.com/user/profile/personal_access_tokens/"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
>
{t(I18nKey.COMMON$DOCUMENTATION)}
</a>
</b>
.
</p>
<BrandButton
type="button"
variant="secondary"
onClick={handleLogout}
isDisabled={!providersAreSet}
>
Disconnect Tokens
</BrandButton>
</>
)}
</section>
<section className="flex flex-col gap-6">
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
{t(I18nKey.ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS)}
</h2>
<SettingsDropdownInput
testId="language-input"
name="language-input"
label={t(I18nKey.SETTINGS$LANGUAGE)}
items={AvailableLanguages.map((language) => ({
key: language.value,
label: language.label,
}))}
defaultSelectedKey={settings.LANGUAGE}
wrapperClassName="w-[680px]"
isClearable={false}
/>
<SettingsSwitch
testId="enable-analytics-switch"
name="enable-analytics-switch"
defaultIsToggled={!!isAnalyticsEnabled}
>
{t(I18nKey.ANALYTICS$ENABLE)}
</SettingsSwitch>
<SettingsSwitch
testId="enable-sound-notifications-switch"
name="enable-sound-notifications-switch"
defaultIsToggled={!!settings.ENABLE_SOUND_NOTIFICATIONS}
>
{t(I18nKey.SETTINGS$SOUND_NOTIFICATIONS)}
</SettingsSwitch>
</section>
</div>
</form>
<footer className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
<BrandButton
type="button"
variant="primary"
onClick={() => {
formRef.current?.requestSubmit();
}}
>
{t(I18nKey.BUTTON$SAVE)}
</BrandButton>
</footer>
</>
);
}
export default AccountSettings;
+1 -1
View File
@@ -3,7 +3,7 @@ import { ApiKeysManager } from "#/components/features/settings/api-keys-manager"
function ApiKeysScreen() {
return (
<div className="flex flex-col grow overflow-auto p-11">
<div className="flex flex-col grow overflow-auto p-9">
<ApiKeysManager />
</div>
);
+150
View File
@@ -0,0 +1,150 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { useSettings } from "#/hooks/query/use-settings";
import { AvailableLanguages } from "#/i18n";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { BrandButton } from "#/components/features/settings/brand-button";
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
import { I18nKey } from "#/i18n/declaration";
import { LanguageInput } from "#/components/features/settings/app-settings/language-input";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import { AppSettingsInputsSkeleton } from "#/components/features/settings/app-settings/app-settings-inputs-skeleton";
function AppSettingsScreen() {
const { t } = useTranslation();
const { mutate: saveSettings, isPending } = useSaveSettings();
const { data: settings, isLoading } = useSettings();
const [languageInputHasChanged, setLanguageInputHasChanged] =
React.useState(false);
const [analyticsSwitchHasChanged, setAnalyticsSwitchHasChanged] =
React.useState(false);
const [
soundNotificationsSwitchHasChanged,
setSoundNotificationsSwitchHasChanged,
] = React.useState(false);
const formAction = (formData: FormData) => {
const languageLabel = formData.get("language-input")?.toString();
const languageValue = AvailableLanguages.find(
({ label }) => label === languageLabel,
)?.value;
const language = languageValue || DEFAULT_SETTINGS.LANGUAGE;
const enableAnalytics =
formData.get("enable-analytics-switch")?.toString() === "on";
const enableSoundNotifications =
formData.get("enable-sound-notifications-switch")?.toString() === "on";
saveSettings(
{
LANGUAGE: language,
user_consents_to_analytics: enableAnalytics,
ENABLE_SOUND_NOTIFICATIONS: enableSoundNotifications,
},
{
onSuccess: () => {
handleCaptureConsent(enableAnalytics);
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
},
onError: (error) => {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
},
onSettled: () => {
setLanguageInputHasChanged(false);
setAnalyticsSwitchHasChanged(false);
setSoundNotificationsSwitchHasChanged(false);
},
},
);
};
const checkIfLanguageInputHasChanged = (value: string) => {
const selectedLanguage = AvailableLanguages.find(
({ label: langValue }) => langValue === value,
)?.label;
const currentLanguage = AvailableLanguages.find(
({ value: langValue }) => langValue === settings?.LANGUAGE,
)?.label;
setLanguageInputHasChanged(selectedLanguage !== currentLanguage);
};
const checkIfAnalyticsSwitchHasChanged = (checked: boolean) => {
const currentAnalytics = !!settings?.USER_CONSENTS_TO_ANALYTICS;
setAnalyticsSwitchHasChanged(checked !== currentAnalytics);
};
const checkIfSoundNotificationsSwitchHasChanged = (checked: boolean) => {
const currentSoundNotifications = !!settings?.ENABLE_SOUND_NOTIFICATIONS;
setSoundNotificationsSwitchHasChanged(
checked !== currentSoundNotifications,
);
};
const formIsClean =
!languageInputHasChanged &&
!analyticsSwitchHasChanged &&
!soundNotificationsSwitchHasChanged;
const shouldBeLoading = !settings || isLoading || isPending;
return (
<form
data-testid="app-settings-screen"
action={formAction}
className="flex flex-col h-full justify-between"
>
{shouldBeLoading && <AppSettingsInputsSkeleton />}
{!shouldBeLoading && (
<div className="p-9 flex flex-col gap-6">
<LanguageInput
name="language-input"
defaultKey={settings.LANGUAGE}
onChange={checkIfLanguageInputHasChanged}
/>
<SettingsSwitch
testId="enable-analytics-switch"
name="enable-analytics-switch"
defaultIsToggled={!!settings.USER_CONSENTS_TO_ANALYTICS}
onToggle={checkIfAnalyticsSwitchHasChanged}
>
{t(I18nKey.ANALYTICS$ENABLE)}
</SettingsSwitch>
<SettingsSwitch
testId="enable-sound-notifications-switch"
name="enable-sound-notifications-switch"
defaultIsToggled={!!settings.ENABLE_SOUND_NOTIFICATIONS}
onToggle={checkIfSoundNotificationsSwitchHasChanged}
>
{t(I18nKey.SETTINGS$SOUND_NOTIFICATIONS)}
</SettingsSwitch>
</div>
)}
<div className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
<BrandButton
testId="submit-button"
variant="primary"
type="submit"
isDisabled={isPending || formIsClean}
>
{!isPending && t("SETTINGS$SAVE_CHANGES")}
{isPending && t("SETTINGS$SAVING")}
</BrandButton>
</div>
</form>
);
}
export default AppSettingsScreen;
+134
View File
@@ -0,0 +1,134 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { useConfig } from "#/hooks/query/use-config";
import { useSettings } from "#/hooks/query/use-settings";
import { BrandButton } from "#/components/features/settings/brand-button";
import { useLogout } from "#/hooks/mutation/use-logout";
import { GitHubTokenInput } from "#/components/features/settings/git-settings/github-token-input";
import { GitLabTokenInput } from "#/components/features/settings/git-settings/gitlab-token-input";
import { ConfigureGitHubRepositoriesAnchor } from "#/components/features/settings/git-settings/configure-github-repositories-anchor";
import { I18nKey } from "#/i18n/declaration";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import { GitSettingInputsSkeleton } from "#/components/features/settings/git-settings/github-settings-inputs-skeleton";
function GitSettingsScreen() {
const { t } = useTranslation();
const { mutate: saveSettings, isPending } = useSaveSettings();
const { mutate: disconnectGitTokens } = useLogout();
const { data: settings, isLoading } = useSettings();
const { data: config } = useConfig();
const [githubTokenInputHasValue, setGithubTokenInputHasValue] =
React.useState(false);
const [gitlabTokenInputHasValue, setGitlabTokenInputHasValue] =
React.useState(false);
const isSaas = config?.APP_MODE === "saas";
const isGitHubTokenSet = !!settings?.PROVIDER_TOKENS_SET.github;
const isGitLabTokenSet = !!settings?.PROVIDER_TOKENS_SET.gitlab;
const formAction = async (formData: FormData) => {
const disconnectButtonClicked =
formData.get("disconnect-tokens-button") !== null;
if (disconnectButtonClicked) {
disconnectGitTokens();
return;
}
const githubToken = formData.get("github-token-input")?.toString() || "";
const gitlabToken = formData.get("gitlab-token-input")?.toString() || "";
saveSettings(
{
provider_tokens: {
github: githubToken,
gitlab: gitlabToken,
},
},
{
onSuccess: () => {
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
},
onError: (error) => {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
},
onSettled: () => {
setGithubTokenInputHasValue(false);
setGitlabTokenInputHasValue(false);
},
},
);
};
const formIsClean = !githubTokenInputHasValue && !gitlabTokenInputHasValue;
const shouldRenderExternalConfigureButtons = isSaas && config.APP_SLUG;
return (
<form
data-testid="git-settings-screen"
action={formAction}
className="flex flex-col h-full justify-between"
>
{isLoading && <GitSettingInputsSkeleton />}
{shouldRenderExternalConfigureButtons && !isLoading && (
<ConfigureGitHubRepositoriesAnchor slug={config.APP_SLUG!} />
)}
{!isSaas && !isLoading && (
<div className="p-9 flex flex-col gap-12">
<GitHubTokenInput
name="github-token-input"
isGitHubTokenSet={isGitHubTokenSet}
onChange={(value) => {
setGithubTokenInputHasValue(!!value);
}}
/>
<GitLabTokenInput
name="gitlab-token-input"
isGitLabTokenSet={isGitLabTokenSet}
onChange={(value) => {
setGitlabTokenInputHasValue(!!value);
}}
/>
</div>
)}
{!shouldRenderExternalConfigureButtons && (
<div className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
<BrandButton
testId="disconnect-tokens-button"
name="disconnect-tokens-button"
type="submit"
variant="secondary"
isDisabled={!isGitHubTokenSet && !isGitLabTokenSet}
>
Disconnect Tokens
</BrandButton>
<BrandButton
testId="submit-button"
type="submit"
variant="primary"
isDisabled={isPending || formIsClean}
>
{!isPending && t("SETTINGS$SAVE_CHANGES")}
{isPending && t("SETTINGS$SAVING")}
</BrandButton>
</div>
)}
</form>
);
}
export default GitSettingsScreen;
+1 -1
View File
@@ -22,7 +22,7 @@ function HomeScreen() {
<hr className="border-[#717888]" />
<main className="flex justify-between gap-4">
<main className="flex flex-col md:flex-row justify-between gap-4">
<RepoConnector
onRepoSelection={(title) => setSelectedRepoTitle(title)}
/>
+430
View File
@@ -0,0 +1,430 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { AxiosError } from "axios";
import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers";
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
import { useSettings } from "#/hooks/query/use-settings";
import { hasAdvancedSettingsSet } from "#/utils/has-advanced-settings-set";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
import { I18nKey } from "#/i18n/declaration";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { HelpLink } from "#/components/features/settings/help-link";
import { BrandButton } from "#/components/features/settings/brand-button";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input";
import { useConfig } from "#/hooks/query/use-config";
import { isCustomModel } from "#/utils/is-custom-model";
import { LlmSettingsInputsSkeleton } from "#/components/features/settings/llm-settings/llm-settings-inputs-skeleton";
import { KeyStatusIcon } from "#/components/features/settings/key-status-icon";
function LlmSettingsScreen() {
const { t } = useTranslation();
const { mutate: saveSettings, isPending } = useSaveSettings();
const { data: resources } = useAIConfigOptions();
const { data: settings, isLoading, isFetching } = useSettings();
const { data: config } = useConfig();
const [view, setView] = React.useState<"basic" | "advanced">("basic");
const [securityAnalyzerInputIsVisible, setSecurityAnalyzerInputIsVisible] =
React.useState(false);
const [dirtyInputs, setDirtyInputs] = React.useState({
model: false,
apiKey: false,
baseUrl: false,
agent: false,
confirmationMode: false,
enableDefaultCondenser: false,
securityAnalyzer: false,
});
const modelsAndProviders = organizeModelsAndProviders(
resources?.models || [],
);
React.useEffect(() => {
const determineWhetherToToggleAdvancedSettings = () => {
if (resources && settings) {
return (
isCustomModel(resources.models, settings.LLM_MODEL) ||
hasAdvancedSettingsSet({
...settings,
})
);
}
return false;
};
const userSettingsIsAdvanced = determineWhetherToToggleAdvancedSettings();
if (settings) setSecurityAnalyzerInputIsVisible(settings.CONFIRMATION_MODE);
if (userSettingsIsAdvanced) setView("advanced");
else setView("basic");
}, [settings, resources]);
const handleSuccessfulMutation = () => {
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
setDirtyInputs({
model: false,
apiKey: false,
baseUrl: false,
agent: false,
confirmationMode: false,
enableDefaultCondenser: false,
securityAnalyzer: false,
});
};
const handleErrorMutation = (error: AxiosError) => {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
};
const basicFormAction = (formData: FormData) => {
const provider = formData.get("llm-provider-input")?.toString();
const model = formData.get("llm-model-input")?.toString();
const apiKey = formData.get("llm-api-key-input")?.toString();
const fullLlmModel =
provider && model && `${provider}/${model}`.toLowerCase();
saveSettings(
{
LLM_MODEL: fullLlmModel,
llm_api_key: apiKey || null,
},
{
onSuccess: handleSuccessfulMutation,
onError: handleErrorMutation,
},
);
};
const advancedFormAction = (formData: FormData) => {
const model = formData.get("llm-custom-model-input")?.toString();
const baseUrl = formData.get("base-url-input")?.toString();
const apiKey = formData.get("llm-api-key-input")?.toString();
const agent = formData.get("agent-input")?.toString();
const confirmationMode =
formData.get("enable-confirmation-mode-switch")?.toString() === "on";
const enableDefaultCondenser =
formData.get("enable-memory-condenser-switch")?.toString() === "on";
const securityAnalyzer = formData
.get("security-analyzer-input")
?.toString();
saveSettings(
{
LLM_MODEL: model,
LLM_BASE_URL: baseUrl,
llm_api_key: apiKey,
AGENT: agent,
CONFIRMATION_MODE: confirmationMode,
ENABLE_DEFAULT_CONDENSER: enableDefaultCondenser,
SECURITY_ANALYZER: confirmationMode ? securityAnalyzer : undefined,
},
{
onSuccess: handleSuccessfulMutation,
onError: handleErrorMutation,
},
);
};
const formAction = (formData: FormData) => {
if (view === "basic") basicFormAction(formData);
else advancedFormAction(formData);
};
const handleToggleAdvancedSettings = (isToggled: boolean) => {
setSecurityAnalyzerInputIsVisible(!!settings?.CONFIRMATION_MODE);
setView(isToggled ? "advanced" : "basic");
setDirtyInputs({
model: false,
apiKey: false,
baseUrl: false,
agent: false,
confirmationMode: false,
enableDefaultCondenser: false,
securityAnalyzer: false,
});
};
const handleModelIsDirty = (model: string | null) => {
// openai providers are special case; see ModelSelector
// component for details
const modelIsDirty = model !== settings?.LLM_MODEL.replace("openai/", "");
setDirtyInputs((prev) => ({
...prev,
model: modelIsDirty,
}));
};
const handleApiKeyIsDirty = (apiKey: string) => {
const apiKeyIsDirty = apiKey !== "";
setDirtyInputs((prev) => ({
...prev,
apiKey: apiKeyIsDirty,
}));
};
const handleCustomModelIsDirty = (model: string) => {
const modelIsDirty = model !== settings?.LLM_MODEL && model !== "";
setDirtyInputs((prev) => ({
...prev,
model: modelIsDirty,
}));
};
const handleBaseUrlIsDirty = (baseUrl: string) => {
const baseUrlIsDirty = baseUrl !== settings?.LLM_BASE_URL;
setDirtyInputs((prev) => ({
...prev,
baseUrl: baseUrlIsDirty,
}));
};
const handleAgentIsDirty = (agent: string) => {
const agentIsDirty = agent !== settings?.AGENT && agent !== "";
setDirtyInputs((prev) => ({
...prev,
agent: agentIsDirty,
}));
};
const handleConfirmationModeIsDirty = (isToggled: boolean) => {
setSecurityAnalyzerInputIsVisible(isToggled);
const confirmationModeIsDirty = isToggled !== settings?.CONFIRMATION_MODE;
setDirtyInputs((prev) => ({
...prev,
confirmationMode: confirmationModeIsDirty,
}));
};
const handleEnableDefaultCondenserIsDirty = (isToggled: boolean) => {
const enableDefaultCondenserIsDirty =
isToggled !== settings?.ENABLE_DEFAULT_CONDENSER;
setDirtyInputs((prev) => ({
...prev,
enableDefaultCondenser: enableDefaultCondenserIsDirty,
}));
};
const handleSecurityAnalyzerIsDirty = (securityAnalyzer: string) => {
const securityAnalyzerIsDirty =
securityAnalyzer !== settings?.SECURITY_ANALYZER;
setDirtyInputs((prev) => ({
...prev,
securityAnalyzer: securityAnalyzerIsDirty,
}));
};
const formIsDirty = Object.values(dirtyInputs).some((isDirty) => isDirty);
if (!settings || isFetching) return <LlmSettingsInputsSkeleton />;
return (
<div data-testid="llm-settings-screen" className="h-full">
<form
action={formAction}
className="flex flex-col h-full justify-between"
>
<div className="p-9 flex flex-col gap-6">
<SettingsSwitch
testId="advanced-settings-switch"
defaultIsToggled={view === "advanced"}
onToggle={handleToggleAdvancedSettings}
isToggled={view === "advanced"}
>
{t(I18nKey.SETTINGS$ADVANCED)}
</SettingsSwitch>
{view === "basic" && (
<div
data-testid="llm-settings-form-basic"
className="flex flex-col gap-6"
>
{!isLoading && !isFetching && (
<ModelSelector
models={modelsAndProviders}
currentModel={
settings.LLM_MODEL || "anthropic/claude-3-5-sonnet-20241022"
}
onChange={handleModelIsDirty}
/>
)}
<SettingsInput
testId="llm-api-key-input"
name="llm-api-key-input"
label={t(I18nKey.SETTINGS_FORM$API_KEY)}
type="password"
className="w-[680px]"
placeholder={settings.LLM_API_KEY_SET ? "<hidden>" : ""}
onChange={handleApiKeyIsDirty}
startContent={
settings.LLM_API_KEY_SET && (
<KeyStatusIcon isSet={settings.LLM_API_KEY_SET} />
)
}
/>
<HelpLink
testId="llm-api-key-help-anchor"
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
href="https://docs.all-hands.dev/modules/usage/installation#getting-an-api-key"
/>
</div>
)}
{view === "advanced" && (
<div
data-testid="llm-settings-form-advanced"
className="flex flex-col gap-6"
>
<SettingsInput
testId="llm-custom-model-input"
name="llm-custom-model-input"
label={t(I18nKey.SETTINGS$CUSTOM_MODEL)}
defaultValue={
settings.LLM_MODEL || "anthropic/claude-3-5-sonnet-20241022"
}
placeholder="anthropic/claude-3-5-sonnet-20241022"
type="text"
className="w-[680px]"
onChange={handleCustomModelIsDirty}
/>
<SettingsInput
testId="base-url-input"
name="base-url-input"
label={t(I18nKey.SETTINGS$BASE_URL)}
defaultValue={settings.LLM_BASE_URL}
placeholder="https://api.openai.com"
type="text"
className="w-[680px]"
onChange={handleBaseUrlIsDirty}
/>
<SettingsInput
testId="llm-api-key-input"
name="llm-api-key-input"
label={t(I18nKey.SETTINGS_FORM$API_KEY)}
type="password"
className="w-[680px]"
placeholder={settings.LLM_API_KEY_SET ? "<hidden>" : ""}
onChange={handleApiKeyIsDirty}
startContent={
settings.LLM_API_KEY_SET && (
<KeyStatusIcon isSet={settings.LLM_API_KEY_SET} />
)
}
/>
<HelpLink
testId="llm-api-key-help-anchor"
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
href="https://docs.all-hands.dev/modules/usage/installation#getting-an-api-key"
/>
<SettingsDropdownInput
testId="agent-input"
name="agent-input"
label={t(I18nKey.SETTINGS$AGENT)}
items={
resources?.agents.map((agent) => ({
key: agent,
label: agent,
})) || []
}
defaultSelectedKey={settings.AGENT}
isClearable={false}
onInputChange={handleAgentIsDirty}
wrapperClassName="w-[680px]"
/>
{config?.APP_MODE === "saas" && (
<SettingsDropdownInput
testId="runtime-settings-input"
name="runtime-settings-input"
label={
<>
{t(I18nKey.SETTINGS$RUNTIME_SETTINGS)}
<a href="mailto:contact@all-hands.dev">
{t(I18nKey.SETTINGS$GET_IN_TOUCH)}
</a>
)
</>
}
items={[]}
isDisabled
/>
)}
<SettingsSwitch
testId="enable-memory-condenser-switch"
name="enable-memory-condenser-switch"
defaultIsToggled={settings.ENABLE_DEFAULT_CONDENSER}
onToggle={handleEnableDefaultCondenserIsDirty}
>
{t(I18nKey.SETTINGS$ENABLE_MEMORY_CONDENSATION)}
</SettingsSwitch>
<SettingsSwitch
testId="enable-confirmation-mode-switch"
name="enable-confirmation-mode-switch"
onToggle={handleConfirmationModeIsDirty}
defaultIsToggled={settings.CONFIRMATION_MODE}
isBeta
>
{t(I18nKey.SETTINGS$CONFIRMATION_MODE)}
</SettingsSwitch>
{securityAnalyzerInputIsVisible && (
<SettingsDropdownInput
testId="security-analyzer-input"
name="security-analyzer-input"
label={t(I18nKey.SETTINGS$SECURITY_ANALYZER)}
items={
resources?.securityAnalyzers.map((analyzer) => ({
key: analyzer,
label: analyzer,
})) || []
}
defaultSelectedKey={settings.SECURITY_ANALYZER}
isClearable
showOptionalTag
onInputChange={handleSecurityAnalyzerIsDirty}
wrapperClassName="w-[680px]"
/>
)}
</div>
)}
</div>
<div className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
<BrandButton
testId="submit-button"
type="submit"
variant="primary"
isDisabled={!formIsDirty || isPending}
>
{!isPending && t("SETTINGS$SAVE_CHANGES")}
{isPending && t("SETTINGS$SAVING")}
</BrandButton>
</div>
</form>
</div>
);
}
export default LlmSettingsScreen;
+57 -27
View File
@@ -1,5 +1,6 @@
import { NavLink, Outlet } from "react-router";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import React from "react";
import SettingsIcon from "#/icons/settings.svg?react";
import { cn } from "#/utils/utils";
import { useConfig } from "#/hooks/query/use-config";
@@ -7,9 +8,44 @@ import { I18nKey } from "#/i18n/declaration";
function SettingsScreen() {
const { t } = useTranslation();
const navigate = useNavigate();
const { pathname } = useLocation();
const { data: config } = useConfig();
const isSaas = config?.APP_MODE === "saas";
const saasNavItems = [
{ to: "/settings/git", text: t("SETTINGS$NAV_GIT") },
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
{ to: "/settings/billing", text: t("SETTINGS$NAV_CREDITS") },
{ to: "/settings/api-keys", text: t("SETTINGS$NAV_API_KEYS") },
];
const ossNavItems = [
{ to: "/settings", text: t("SETTINGS$NAV_LLM") },
{ to: "/settings/git", text: t("SETTINGS$NAV_GIT") },
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
];
React.useEffect(() => {
if (isSaas) {
if (pathname === "/settings") {
navigate("/settings/git");
}
} else {
const noEnteringPaths = [
"/settings/billing",
"/settings/credits",
"/settings/api-keys",
];
if (noEnteringPaths.includes(pathname)) {
navigate("/settings");
}
}
}, [isSaas, pathname]);
const navItems = isSaas ? saasNavItems : ossNavItems;
return (
<main
data-testid="settings-screen"
@@ -20,32 +56,26 @@ function SettingsScreen() {
<h1 className="text-sm leading-6">{t(I18nKey.SETTINGS$TITLE)}</h1>
</header>
{isSaas && (
<nav
data-testid="settings-navbar"
className="flex items-end gap-12 px-11 border-b border-tertiary"
>
{[
{ to: "/settings", text: "Account" },
{ to: "/settings/billing", text: "Credits" },
{ to: "/settings/api-keys", text: "API Keys" },
].map(({ to, text }) => (
<NavLink
end
key={to}
to={to}
className={({ isActive }) =>
cn(
"border-b-2 border-transparent py-2.5",
isActive && "border-primary",
)
}
>
<ul className="text-[#F9FBFE] text-sm">{text}</ul>
</NavLink>
))}
</nav>
)}
<nav
data-testid="settings-navbar"
className="flex items-end gap-12 px-9 border-b border-tertiary"
>
{navItems.map(({ to, text }) => (
<NavLink
end
key={to}
to={to}
className={({ isActive }) =>
cn(
"border-b-2 border-transparent py-2.5",
isActive && "border-primary",
)
}
>
<ul className="text-[#F9FBFE] text-sm">{text}</ul>
</NavLink>
))}
</nav>
<div className="flex flex-col grow overflow-auto">
<Outlet />
+8 -4
View File
@@ -6,10 +6,14 @@
@apply bg-tertiary border border-neutral-600 rounded;
}
.heading {
@apply text-[28px] leading-8 -tracking-[0.02em] font-bold text-content-2;
}
.skeleton {
@apply bg-gray-400 rounded-md animate-pulse;
}
.skeleton-round {
@apply bg-gray-400 rounded-full animate-pulse;
}
.heading {
@apply text-[28px] leading-8 -tracking-[0.02em] font-bold text-content-2;
}
@@ -2,9 +2,8 @@ import { DEFAULT_SETTINGS } from "#/services/settings";
import { Settings } from "#/types/settings";
export const hasAdvancedSettingsSet = (settings: Partial<Settings>): boolean =>
!!settings.LLM_BASE_URL ||
settings.AGENT !== DEFAULT_SETTINGS.AGENT ||
settings.REMOTE_RUNTIME_RESOURCE_FACTOR !==
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR ||
settings.CONFIRMATION_MODE ||
!!settings.SECURITY_ANALYZER;
Object.keys(settings).length > 0 &&
(!!settings.LLM_BASE_URL ||
settings.AGENT !== DEFAULT_SETTINGS.AGENT ||
settings.CONFIRMATION_MODE ||
!!settings.SECURITY_ANALYZER);
+11 -41
View File
@@ -15,11 +15,11 @@ This directory (`OpenHands/microagents/`) contains shareable microagents that ar
Directory structure:
```
OpenHands/microagents/
├── knowledge/ # Keyword-triggered expertise
│ ├── git.md # Git operations
│ ├── testing.md # Testing practices
│ └── docker.md # Docker guidelines
└── tasks/ # Interactive workflows
├── # Keyword-triggered expertise
│ ├── git.md # Git operations
│ ├── testing.md # Testing practices
│ └── docker.md # Docker guidelines
└── # These microagents are always loaded
├── pr_review.md # PR review process
├── bug_fix.md # Bug fixing workflow
└── feature.md # Feature implementation
@@ -37,8 +37,7 @@ your-repository/
└── .openhands/
└── microagents/
└── repo.md # Repository-specific instructions
└── knowledges/ # Private micro-agents that are only available inside this repo
└── tasks/ # Private micro-agents that are only available inside this repo
└── ... # Private micro-agents that are only available inside this repo
```
@@ -47,7 +46,6 @@ your-repository/
When OpenHands works with a repository, it:
1. Loads repository-specific instructions from `.openhands/microagents/repo.md` if present
2. Loads relevant knowledge agents based on keywords in conversations
3. Enable task agent if user select one of them
## Types of Microagents
@@ -68,7 +66,7 @@ Key characteristics:
- **Reusable**: Knowledge can be applied across multiple projects
- **Versioned**: Support multiple versions of tools/frameworks
You can see an example of a knowledge-based agent in [OpenHands's github microagent](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge/github.md).
You can see an example of a knowledge-based agent in [OpenHands's github microagent](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/github.md).
### 2. Repository Agents
@@ -86,22 +84,6 @@ Key features:
You can see an example of a repo agent in [the agent for the OpenHands repo itself](https://github.com/All-Hands-AI/OpenHands/blob/main/.openhands/microagents/repo.md).
### 3. Task Agents
Task agents provide interactive workflows that guide users through common development tasks. They:
- Accept user inputs
- Follow predefined steps
- Adapt to context
- Provide consistent results
Key capabilities:
- **Interactive**: Guide users through complex processes
- **Validating**: Check inputs and conditions
- **Flexible**: Adapt to different scenarios
- **Reproducible**: Ensure consistent outcomes
Example workflow:
You can see an example of a task-based agent in [OpenHands's pull request updating microagent](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/tasks/update_pr_description.md).
## Contributing
@@ -113,13 +95,8 @@ You can see an example of a task-based agent in [OpenHands's pull request updati
- Common problem solutions
- General development guidelines
2. **Task Agents** - When you have:
- Repeatable workflows
- Multi-step processes
- Common development tasks
- Standard procedures
3. **Repository Agents** - When you need:
2. **Repository Agents** - When you need:
- Project-specific guidelines
- Team conventions and practices
- Custom workflow documentation
@@ -134,14 +111,8 @@ You can see an example of a task-based agent in [OpenHands's pull request updati
- Use file patterns when relevant
- Keep knowledge general and reusable
2. **For Task Agents**:
- Break workflows into clear steps
- Validate user inputs
- Provide helpful defaults
- Include usage examples
- Make steps adaptable
3. **For Repository Agents**:
2. **For Repository Agents**:
- Document clear setup instructions
- Include repository structure details
- Specify testing and build procedures
@@ -152,9 +123,8 @@ You can see an example of a task-based agent in [OpenHands's pull request updati
### Submission Process
1. Create your agent file in the appropriate directory:
- `knowledge/` for expertise (public, shareable)
- `tasks/` for workflows (public, shareable)
- Note: Repository agents should remain in their respective repositories' `.openhands/microagents/` directory
- `microagents/` for expertise (public, shareable)
- Note: Repository-specific agents should remain in their respective repositories' `.openhands/microagents/` directory
2. Test thoroughly
3. Submit a pull request to OpenHands
@@ -38,4 +38,4 @@ For detailed information, see:
- [Microagents Overview](https://docs.all-hands.dev/modules/usage/prompting/microagents-overview)
- [Microagents Syntax](https://docs.all-hands.dev/modules/usage/prompting/microagents-syntax)
- [Example GitHub Microagent](https://github.com/All-Hands-AI/OpenHands/blob/main/microagents/knowledge/github.md)
- [Example GitHub Microagent](https://github.com/All-Hands-AI/OpenHands/blob/main/microagents/github.md)
+32
View File
@@ -0,0 +1,32 @@
---
name: agent_memory
type: knowledge
version: 1.0.0
agent: CodeActAgent
triggers:
- /remember
---
* Repository memory: Use .openhands/microagents/repo.md under each repository root to store and access important information.
- If this file exists, it will be added to your context automatically.
- If missing, you should create it unless the user has explicitly asked you to not do so.
* Store and maintain **general knowledge** that will be helpful for most future tasks:
1. Repository structure
2. Common commands (build, lint, test, pre-commit, etc.)
3. Code style preferences
4. Workflows and best practices
5. Any other repository-specific knowledge you learn
* IMPORTANT: ONLY LOG the information that would be helpful for different future tasks, for example, how to configure the settings, how to setup the repository. Do NOT add issue-specific information (e.g., what specific error you have ran into and how you fix it).
* When adding new information:
- ALWAYS ask for user confirmation first by listing the exact items (numbered 1, 2, 3, etc.) you plan to save to repo.md
- Only save the items the user approves (they may ask you to save a subset)
- Ensure it integrates nicely with existing knowledge in repo.md
- Reorganize the content if needed to maintain clarity and organization
- Group related information together under appropriate sections or headings
- If you've only explored a portion of the codebase, clearly note this limitation in the repository structure documentation
- If you don't know the essential commands for working with the repository, such as lint or typecheck, ask the user and suggest adding them to repo.md for future reference (with permission)
When you receive this message, please review and summarize your recent actions and observations, then present a list of valuable information that should be saved in repo.md to the user.
@@ -1,65 +0,0 @@
---
name: add_openhands_repo_instruction
type: task
version: 1.0.0
author: openhands
agent: CodeActAgent
inputs:
- name: REPO_FOLDER_NAME
description: "Branch for the agent to work on"
required: false
---
Please browse the current repository under /workspace/{{ REPO_FOLDER_NAME }}, look at the documentation and relevant code, and understand the purpose of this repository.
Specifically, I want you to create a `.openhands/microagents/repo.md` file. This file should contain succinct information that summarizes (1) the purpose of this repository, (2) the general setup of this repo, and (3) a brief description of the structure of this repo.
Here's an example:
```markdown
---
name: repo
type: repo
agent: CodeActAgent
---
This repository contains the code for OpenHands, an automated AI software engineer. It has a Python backend
(in the `openhands` directory) and React frontend (in the `frontend` directory).
## General Setup:
To set up the entire repo, including frontend and backend, run `make build`.
You don't need to do this unless the user asks you to, or if you're trying to run the entire application.
Before pushing any changes, you should ensure that any lint errors or simple test errors have been fixed.
* If you've made changes to the backend, you should run `pre-commit run --all-files --config ./dev_config/python/.pre-commit-config.yaml`
* If you've made changes to the frontend, you should run `cd frontend && npm run lint:fix && npm run build ; cd ..`
If either command fails, it may have automatically fixed some issues. You should fix any issues that weren't automatically fixed,
then re-run the command to ensure it passes.
## Repository Structure
Backend:
- Located in the `openhands` directory
- Testing:
- All tests are in `tests/unit/test_*.py`
- To test new code, run `poetry run pytest tests/unit/test_xxx.py` where `xxx` is the appropriate file for the current functionality
- Write all tests with pytest
Frontend:
- Located in the `frontend` directory
- Prerequisites: A recent version of NodeJS / NPM
- Setup: Run `npm install` in the frontend directory
- Testing:
- Run tests: `npm run test`
- To run specific tests: `npm run test -- -t "TestName"`
- Building:
- Build for production: `npm run build`
- Environment Variables:
- Set in `frontend/.env` or as environment variables
- Available variables: VITE_BACKEND_HOST, VITE_USE_TLS, VITE_INSECURE_SKIP_VERIFY, VITE_FRONTEND_PORT
- Internationalization:
- Generate i18n declaration file: `npm run make-i18n`
```
Now, please write a similar markdown for the current repository.
Read all the GitHub workflows under .github/ of the repository (if this folder exists) to understand the CI checks (e.g., linter, pre-commit), and include those in the repo.md file.
-20
View File
@@ -1,20 +0,0 @@
---
name: address_pr_comments
type: task
version: 1.0.0
author: openhands
agent: CodeActAgent
inputs:
- name: PR_URL
description: "URL of the pull request"
required: true
- name: BRANCH_NAME
description: "Branch name corresponds to the pull request"
required: true
---
First, check the branch {{ BRANCH_NAME }} and read the diff against the main branch to understand the purpose.
This branch corresponds to this PR {{ PR_URL }}
Next, you should use the GitHub API to read the reviews and comments on this PR and address them.
-28
View File
@@ -1,28 +0,0 @@
---
name: get_test_to_pass
type: task
version: 1.0.0
author: openhands
agent: CodeActAgent
inputs:
- name: BRANCH_NAME
description: "Branch for the agent to work on"
required: true
- name: TEST_COMMAND_TO_RUN
description: "The test command you want the agent to work on. For example, `pytest tests/unit/test_bash_parsing.py`"
required: true
- name: FUNCTION_TO_FIX
description: "The name of function to fix"
required: false
- name: FILE_FOR_FUNCTION
description: "The path of the file that contains the function"
required: false
---
Can you check out branch "{{ BRANCH_NAME }}", and run {{ TEST_COMMAND_TO_RUN }}.
{%- if FUNCTION_TO_FIX and FILE_FOR_FUNCTION %}
Help me fix these tests to pass by fixing the {{ FUNCTION_TO_FIX }} function in file {{ FILE_FOR_FUNCTION }}.
{%- endif %}
PLEASE DO NOT modify the tests by yourselves -- Let me know if you think some of the tests are incorrect.
@@ -1,22 +0,0 @@
---
name: update_pr_description
type: task
version: 1.0.0
author: openhands
agent: CodeActAgent
inputs:
- name: PR_URL
description: "URL of the pull request"
type: string
required: true
validation:
pattern: "^https://github.com/.+/.+/pull/[0-9]+$"
- name: BRANCH_NAME
description: "Branch name corresponds to the pull request"
type: string
required: true
---
Please check the branch "{{ BRANCH_NAME }}" and look at the diff against the main branch. This branch belongs to this PR "{{ PR_URL }}".
Once you understand the purpose of the diff, please use Github API to read the existing PR description, and update it to be more reflective of the changes we've made when necessary.
@@ -1,22 +0,0 @@
---
name: update_test_for_new_implementation
type: task
version: 1.0.0
author: openhands
agent: CodeActAgent
inputs:
- name: BRANCH_NAME
description: "Branch for the agent to work on"
required: true
- name: TEST_COMMAND_TO_RUN
description: "The test command you want the agent to work on. For example, `pytest tests/unit/test_bash_parsing.py`"
required: true
---
Can you check out branch "{{ BRANCH_NAME }}", and run {{ TEST_COMMAND_TO_RUN }}.
{%- if FUNCTION_TO_FIX and FILE_FOR_FUNCTION %}
Help me fix these tests to pass by fixing the {{ FUNCTION_TO_FIX }} function in file {{ FILE_FOR_FUNCTION }}.
{%- endif %}
PLEASE DO NOT modify the tests by yourselves -- Let me know if you think some of the tests are incorrect.
@@ -20,10 +20,7 @@ from openhands.controller.state.state import State
from openhands.core.config import AgentConfig
from openhands.core.logger import openhands_logger as logger
from openhands.core.message import Message
from openhands.events.action import (
Action,
AgentFinishAction,
)
from openhands.events.action import Action, AgentFinishAction, MessageAction
from openhands.events.event import Event
from openhands.llm.llm import LLM
from openhands.memory.condenser import Condenser
@@ -173,7 +170,8 @@ class CodeActAgent(Agent):
f'Processing {len(condensed_history)} events from a total of {len(state.history)} events'
)
messages = self._get_messages(condensed_history)
initial_user_message = self._get_initial_user_message(state.history)
messages = self._get_messages(condensed_history, initial_user_message)
params: dict = {
'messages': self.llm.format_messages_for_llm(messages),
}
@@ -216,7 +214,29 @@ class CodeActAgent(Agent):
self.pending_actions.append(action)
return self.pending_actions.popleft()
def _get_messages(self, events: list[Event]) -> list[Message]:
def _get_initial_user_message(self, history: list[Event]) -> MessageAction:
"""Finds the initial user message action from the full history."""
initial_user_message: MessageAction | None = None
for event in history:
if isinstance(event, MessageAction) and event.source == 'user':
initial_user_message = event
break
if initial_user_message is None:
# This should not happen in a valid conversation
logger.error(
f'CRITICAL: Could not find the initial user MessageAction in the full {len(history)} events history.'
)
# Depending on desired robustness, could raise error or create a dummy action
# and log the error
raise ValueError(
'Initial user message not found in history. Please report this issue.'
)
return initial_user_message
def _get_messages(
self, events: list[Event], initial_user_message: MessageAction
) -> list[Message]:
"""Constructs the message history for the LLM conversation.
This method builds a structured conversation history by processing events from the state
@@ -253,6 +273,7 @@ class CodeActAgent(Agent):
# Use ConversationMemory to process events (including SystemMessageAction)
messages = self.conversation_memory.process_events(
condensed_history=events,
initial_user_action=initial_user_message,
max_message_chars=self.llm.config.max_message_chars,
vision_is_active=self.llm.vision_is_active(),
)
+139 -40
View File
@@ -190,7 +190,7 @@ class AgentController:
logger.debug(f'System message got from agent: {system_message}')
if system_message:
self.event_stream.add_event(system_message, EventSource.AGENT)
logger.debug(f'System message added to event stream: {system_message}')
logger.info(f'System message added to event stream: {system_message}')
async def close(self, set_stop_state: bool = True) -> None:
"""Closes the agent controller, canceling any ongoing tasks and unsubscribing from the event stream.
@@ -1020,7 +1020,7 @@ class AgentController:
self.state.start_id = 0
self.log(
'debug',
'info',
f'AgentController {self.id} - created new state. start_id: {self.state.start_id}',
)
else:
@@ -1030,7 +1030,7 @@ class AgentController:
self.state.start_id = 0
self.log(
'debug',
'info',
f'AgentController {self.id} initializing history from event {self.state.start_id}',
)
@@ -1143,70 +1143,169 @@ class AgentController:
def _handle_long_context_error(self) -> None:
# When context window is exceeded, keep roughly half of agent interactions
kept_event_ids = {
e.id for e in self._apply_conversation_window(self.state.history)
}
kept_events = self._apply_conversation_window()
kept_event_ids = {e.id for e in kept_events}
self.log(
'info',
f'Context window exceeded. Keeping events with IDs: {kept_event_ids}',
)
# The events to forget are those that are not in the kept set
forgotten_event_ids = {e.id for e in self.state.history} - kept_event_ids
# Save the ID of the first event in our truncated history for future reloading
if self.state.history:
self.state.start_id = self.state.history[0].id
if len(kept_event_ids) == 0:
self.log(
'warning',
'No events kept after applying conversation window. This should not happen.',
)
# verify that the first event id in kept_event_ids is the same as the start_id
if len(kept_event_ids) > 0 and self.state.history[0].id not in kept_event_ids:
self.log(
'warning',
f'First event after applying conversation window was not kept: {self.state.history[0].id} not in {kept_event_ids}',
)
# Add an error event to trigger another step by the agent
self.event_stream.add_event(
CondensationAction(
forgotten_events_start_id=min(forgotten_event_ids),
forgotten_events_end_id=max(forgotten_event_ids),
forgotten_events_start_id=min(forgotten_event_ids)
if forgotten_event_ids
else 0,
forgotten_events_end_id=max(forgotten_event_ids)
if forgotten_event_ids
else 0,
),
EventSource.AGENT,
)
def _apply_conversation_window(self, events: list[Event]) -> list[Event]:
def _apply_conversation_window(self) -> list[Event]:
"""Cuts history roughly in half when context window is exceeded.
It preserves action-observation pairs and ensures that the first user message is always included.
It preserves action-observation pairs and ensures that the system message,
the first user message, and its associated recall observation are always included
at the beginning of the context window.
The algorithm:
1. Cut history in half
2. Check first event in new history:
- If Observation: find and include its Action
- If MessageAction: ensure its related Action-Observation pair isn't split
3. Always include the first user message
1. Identify essential initial events: System Message, First User Message, Recall Observation.
2. Determine the slice of recent events to potentially keep.
3. Validate the start of the recent slice for dangling observations.
4. Combine essential events and validated recent events, ensuring essentials come first.
Args:
events: List of events to filter
Returns:
Filtered list of events keeping newest half while preserving pairs
Filtered list of events keeping newest half while preserving pairs and essential initial events.
"""
if not events:
return events
if not self.state.history:
return []
# Find first user message - we'll need to ensure it's included
first_user_msg = next(
(
e
for e in events
if isinstance(e, MessageAction) and e.source == EventSource.USER
),
None,
history = self.state.history
# 1. Identify essential initial events
system_message: SystemMessageAction | None = None
first_user_msg: MessageAction | None = None
recall_action: RecallAction | None = None
recall_observation: Observation | None = None
# Find System Message (should be the first event, if it exists)
system_message = next(
(e for e in history if isinstance(e, SystemMessageAction)), None
)
assert (
system_message is None
or isinstance(system_message, SystemMessageAction)
and system_message.id == history[0].id
)
# cut in half
mid_point = max(1, len(events) // 2)
kept_events = events[mid_point:]
if len(kept_events) > 0 and isinstance(kept_events[0], Observation):
kept_events = kept_events[1:]
# Find First User Message, which MUST exist
first_user_msg = self._first_user_message()
if first_user_msg is None:
raise RuntimeError('No first user message found in the event stream.')
# Ensure first user message is included
if first_user_msg and first_user_msg not in kept_events:
kept_events = [first_user_msg] + kept_events
first_user_msg_index = -1
for i, event in enumerate(history):
if isinstance(event, MessageAction) and event.source == EventSource.USER:
first_user_msg = event
first_user_msg_index = i
break
# start_id points to first user message
# Find Recall Action and Observation related to the First User Message
if first_user_msg is not None and first_user_msg_index != -1:
# Look for RecallAction after the first user message
for i in range(first_user_msg_index + 1, len(history)):
event = history[i]
if (
isinstance(event, RecallAction)
and event.query == first_user_msg.content
):
# Found RecallAction, now look for its Observation
recall_action = event
for j in range(i + 1, len(history)):
obs_event = history[j]
# Check for Observation caused by this RecallAction
if (
isinstance(obs_event, Observation)
and obs_event.cause == recall_action.id
):
recall_observation = obs_event
break # Found the observation, stop inner loop
break # Found the recall action (and maybe obs), stop outer loop
essential_events: list[Event] = []
if system_message:
essential_events.append(system_message)
if first_user_msg:
self.state.start_id = first_user_msg.id
essential_events.append(first_user_msg)
# Also keep the RecallAction that triggered the essential RecallObservation
if recall_action:
essential_events.append(recall_action)
if recall_observation:
essential_events.append(recall_observation)
return kept_events
# 2. Determine the slice of recent events to potentially keep
num_non_essential_events = len(history) - len(essential_events)
# Keep roughly half of the non-essential events, minimum 1
num_recent_to_keep = max(1, num_non_essential_events // 2)
# Calculate the starting index for the recent slice
slice_start_index = len(history) - num_recent_to_keep
slice_start_index = max(0, slice_start_index) # Ensure index is not negative
recent_events_slice = history[slice_start_index:]
# 3. Validate the start of the recent slice for dangling observations
# IMPORTANT: Most observations in history are tool call results, which cannot be without their action, or we get an LLM API error
first_valid_event_index = 0
for i, event in enumerate(recent_events_slice):
if isinstance(event, Observation):
first_valid_event_index += 1
else:
break
# If all events in the slice are dangling observations, we need to keep at least one
if first_valid_event_index == len(recent_events_slice):
self.log(
'warning',
'All recent events are dangling observations, which we truncate. This means the agent has only the essential first events. This should not happen.',
)
# Adjust the recent_events_slice if dangling observations were found at the start
if first_valid_event_index < len(recent_events_slice):
validated_recent_events = recent_events_slice[first_valid_event_index:]
if first_valid_event_index > 0:
self.log(
'debug',
f'Removed {first_valid_event_index} dangling observation(s) from the start of recent event slice.',
)
else:
validated_recent_events = []
# 4. Combine essential events and validated recent events
events_to_keep: list[Event] = essential_events + validated_recent_events
self.log('debug', f'History truncated. Kept {len(events_to_keep)} events.')
return events_to_keep
def _is_stuck(self) -> bool:
"""Checks if the agent or its delegate is stuck in a loop.
+191 -673
View File
@@ -1,32 +1,39 @@
import asyncio
import logging
import sys
import time
from pathlib import Path
from typing import List, Optional
from uuid import uuid4
import toml
from prompt_toolkit import PromptSession, print_formatted_text
from prompt_toolkit.application import Application
from prompt_toolkit.completion import Completer, Completion
from prompt_toolkit.formatted_text import HTML, FormattedText
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.layout.containers import HSplit, Window
from prompt_toolkit.layout.controls import FormattedTextControl
from prompt_toolkit.layout.layout import Layout
from prompt_toolkit.patch_stdout import patch_stdout
from prompt_toolkit.shortcuts import clear, print_container
from prompt_toolkit.styles import Style
from prompt_toolkit.widgets import Frame, TextArea
from prompt_toolkit.shortcuts import clear
import openhands.agenthub # noqa F401 (we import this to get the agents registered)
from openhands import __version__
from openhands.controller import AgentController
from openhands.controller.agent import Agent
from openhands.core.cli_commands import (
check_folder_security_agreement,
handle_commands,
)
from openhands.core.cli_tui import (
UsageMetrics,
display_agent_running_message,
display_banner,
display_event,
display_initial_user_prompt,
display_initialization_animation,
display_runtime_initialization_message,
display_welcome_message,
process_agent_pause,
read_confirmation_input,
read_prompt_input,
)
from openhands.core.cli_utils import (
update_usage_metrics,
)
from openhands.core.config import (
AppConfig,
parse_arguments,
setup_config_from_args,
)
from openhands.core.config.condenser_config import NoOpCondenserConfig
from openhands.core.logger import openhands_logger as logger
from openhands.core.loop import run_agent_until_done
from openhands.core.schema import AgentState
@@ -39,634 +46,65 @@ from openhands.core.setup import (
)
from openhands.events import EventSource, EventStreamSubscriber
from openhands.events.action import (
Action,
ActionConfirmationStatus,
ChangeAgentStateAction,
CmdRunAction,
FileEditAction,
MessageAction,
)
from openhands.events.event import Event
from openhands.events.observation import (
AgentStateChangedObservation,
CmdOutputObservation,
FileEditObservation,
FileReadObservation,
)
from openhands.io import read_task
from openhands.llm.metrics import Metrics
from openhands.mcp import fetch_mcp_tools_from_config
from openhands.microagent.microagent import BaseMicroagent
# Color and styling constants
COLOR_GOLD = '#FFD700'
COLOR_GREY = '#808080'
DEFAULT_STYLE = Style.from_dict(
{
'gold': COLOR_GOLD,
'grey': COLOR_GREY,
'prompt': f'{COLOR_GOLD} bold',
}
from openhands.memory.condenser.impl.llm_summarizing_condenser import (
LLMSummarizingCondenserConfig,
)
COMMANDS = {
'/exit': 'Exit the application',
'/help': 'Display available commands',
'/init': 'Initialize a new repository',
}
REPO_MD_CREATE_PROMPT = """
Please explore this repository. Create the file .openhands/microagents/repo.md with:
- A description of the project
- An overview of the file structure
- Any information on how to run tests or other relevant commands
- Any other information that would be helpful to a brand new developer
Keep it short--just a few paragraphs will do.
"""
from openhands.microagent.microagent import BaseMicroagent
from openhands.runtime.base import Runtime
from openhands.storage.settings.file_settings_store import FileSettingsStore
class CommandCompleter(Completer):
"""Custom completer for commands."""
def get_completions(self, document, complete_event):
text = document.text
# Only show completions if the user has typed '/'
if text.startswith('/'):
# If just '/' is typed, show all commands
if text == '/':
for command, description in COMMANDS.items():
yield Completion(
command[1:], # Remove the leading '/' as it's already typed
start_position=0,
display=f'{command} - {description}',
)
# Otherwise show matching commands
else:
for command, description in COMMANDS.items():
if command.startswith(text):
yield Completion(
command[len(text) :], # Complete the remaining part
start_position=0,
display=f'{command} - {description}',
)
class UsageMetrics:
def __init__(self):
self.total_cost: float = 0.00
self.total_input_tokens: int = 0
self.total_output_tokens: int = 0
self.total_cache_read: int = 0
self.total_cache_write: int = 0
prompt_session = PromptSession(style=DEFAULT_STYLE, completer=CommandCompleter())
def display_message(message: str):
message = message.strip()
if message:
print_formatted_text(f'\n{message}\n')
def display_command(command: str):
container = Frame(
TextArea(
text=command,
read_only=True,
style=COLOR_GREY,
wrap_lines=True,
),
title='Command Run',
style=f'fg:{COLOR_GREY}',
)
print_container(container)
print_formatted_text('')
def display_confirmation(confirmation_state: ActionConfirmationStatus):
status_map = {
ActionConfirmationStatus.CONFIRMED: ('ansigreen', ''),
ActionConfirmationStatus.REJECTED: ('ansired', ''),
ActionConfirmationStatus.AWAITING_CONFIRMATION: ('ansiyellow', ''),
}
color, icon = status_map.get(confirmation_state, ('ansiyellow', ''))
print_formatted_text(
FormattedText(
[
(color, f'{icon} '),
(color, str(confirmation_state)),
('', '\n'),
]
)
)
def display_command_output(output: str):
lines = output.split('\n')
formatted_lines = []
for line in lines:
if line.startswith('[Python Interpreter') or line.startswith('openhands@'):
# TODO: clean this up once we clean up terminal output
continue
formatted_lines.append(line)
formatted_lines.append('\n')
# Remove the last newline if it exists
if formatted_lines:
formatted_lines.pop()
container = Frame(
TextArea(
text=''.join(formatted_lines),
read_only=True,
style=COLOR_GREY,
wrap_lines=True,
),
title='Command Output',
style=f'fg:{COLOR_GREY}',
)
print_container(container)
print_formatted_text('')
def display_file_edit(event: FileEditAction | FileEditObservation):
container = Frame(
TextArea(
text=f'{event}',
read_only=True,
style=COLOR_GREY,
wrap_lines=True,
),
title='File Edit',
style=f'fg:{COLOR_GREY}',
)
print_container(container)
print_formatted_text('')
def display_file_read(event: FileReadObservation):
container = Frame(
TextArea(
text=f'{event}',
read_only=True,
style=COLOR_GREY,
wrap_lines=True,
),
title='File Read',
style=f'fg:{COLOR_GREY}',
)
print_container(container)
print_formatted_text('')
def display_event(event: Event, config: AppConfig) -> None:
if isinstance(event, Action):
if hasattr(event, 'thought'):
display_message(event.thought)
if isinstance(event, MessageAction):
if event.source == EventSource.AGENT:
display_message(event.content)
if isinstance(event, CmdRunAction):
display_command(event.command)
if isinstance(event, CmdOutputObservation):
display_command_output(event.content)
if isinstance(event, FileEditAction):
display_file_edit(event)
if isinstance(event, FileEditObservation):
display_file_edit(event)
if isinstance(event, FileReadObservation):
display_file_read(event)
if hasattr(event, 'confirmation_state') and config.security.confirmation_mode:
display_confirmation(event.confirmation_state)
def display_help(style=DEFAULT_STYLE):
print_formatted_text(
HTML(f'\n<grey>OpenHands CLI v{__version__}</grey>\n'), style=style
)
print_formatted_text(
HTML(
'<gold>OpenHands CLI lets you interact with the OpenHands agent from the command line.</gold>'
)
)
print_formatted_text('')
print_formatted_text('Things that you can try:')
print_formatted_text(
HTML('• Ask questions about the codebase <grey>> How does main.py work?</grey>')
)
print_formatted_text(
HTML(
'• Edit files or add new features <grey>> Add a new function to ...</grey>'
)
)
print_formatted_text(
HTML('• Find and fix issues <grey>> Fix the type error in ...</grey>')
)
print_formatted_text('')
print_formatted_text('Some tips to get the most out of OpenHands:')
print_formatted_text(
'• Be as specific as possible about the desired outcome or the problem to be solved.'
)
print_formatted_text(
'• Provide context, including relevant file paths and line numbers if available.'
)
print_formatted_text('• Break large tasks into smaller, manageable prompts.')
print_formatted_text('• Include relevant error messages or logs.')
print_formatted_text(
'• Specify the programming language or framework, if not obvious.'
)
print_formatted_text('')
print_formatted_text(HTML('Interactive commands:'), style=style)
for command, description in COMMANDS.items():
print_formatted_text(
HTML(f'<gold><b>{command}</b></gold> - <grey>{description}</grey>'),
style=style,
)
print_formatted_text('')
print_formatted_text(
HTML(
'<grey>Learn more at: https://docs.all-hands.dev/modules/usage/getting-started</grey>'
)
)
print_formatted_text('')
def display_banner(session_id: str, is_loaded: asyncio.Event):
print_formatted_text(
HTML(r"""<gold>
___ _ _ _
/ _ \ _ __ ___ _ __ | | | | __ _ _ __ __| |___
| | | | '_ \ / _ \ '_ \| |_| |/ _` | '_ \ / _` / __|
| |_| | |_) | __/ | | | _ | (_| | | | | (_| \__ \
\___ /| .__/ \___|_| |_|_| |_|\__,_|_| |_|\__,_|___/
|_|
</gold>"""),
style=DEFAULT_STYLE,
)
print_formatted_text(HTML(f'<grey>OpenHands CLI v{__version__}</grey>'))
banner_text = (
'Initialized session' if is_loaded.is_set() else 'Initializing session'
)
print_formatted_text(HTML(f'\n<grey>{banner_text} {session_id}</grey>\n'))
def display_welcome_message():
print_formatted_text(
HTML("<gold>Let's start building!</gold>\n"), style=DEFAULT_STYLE
)
print_formatted_text(
HTML('What do you want to build? <grey>Type /help for help</grey>\n'),
style=DEFAULT_STYLE,
)
def display_initialization_animation(text, is_loaded: asyncio.Event):
ANIMATION_FRAMES = ['', '', '', '', '', '', '', '', '', '']
i = 0
while not is_loaded.is_set():
sys.stdout.write('\n')
sys.stdout.write(
f'\033[s\033[J\033[38;2;255;215;0m[{ANIMATION_FRAMES[i % len(ANIMATION_FRAMES)]}] {text}\033[0m\033[u\033[1A'
)
sys.stdout.flush()
time.sleep(0.1)
i += 1
sys.stdout.write('\r' + ' ' * (len(text) + 10) + '\r')
sys.stdout.flush()
async def read_prompt_input(multiline=False):
async def cleanup_session(
loop: asyncio.AbstractEventLoop,
agent: Agent,
runtime: Runtime,
controller: AgentController,
):
"""Clean up all resources from the current session."""
try:
if multiline:
kb = KeyBindings()
# Cancel all running tasks except the current one
current_task = asyncio.current_task(loop)
pending = [task for task in asyncio.all_tasks(loop) if task is not current_task]
for task in pending:
task.cancel()
@kb.add('c-d')
def _(event):
event.current_buffer.validate_and_handle()
# Wait for all tasks to complete with a timeout
if pending:
await asyncio.wait(pending, timeout=5.0)
with patch_stdout():
message = await prompt_session.prompt_async(
'Enter your message and press Ctrl+D to finish:\n',
multiline=True,
key_bindings=kb,
)
else:
with patch_stdout():
message = await prompt_session.prompt_async(
'> ',
)
return message
except KeyboardInterrupt:
return '/exit'
except EOFError:
return '/exit'
# Reset agent, close runtime and controller
agent.reset()
runtime.close()
await controller.close()
except Exception as e:
logger.error(f'Error during session cleanup: {e}')
async def read_confirmation_input():
try:
confirmation = await prompt_session.prompt_async(
'Confirm action (possible security risk)? (y/n) > ',
)
return confirmation.lower() == 'y'
except (KeyboardInterrupt, EOFError):
return False
async def init_repository(current_dir: str) -> bool:
repo_file_path = Path(current_dir) / '.openhands' / 'microagents' / 'repo.md'
init_repo = False
if repo_file_path.exists():
try:
content = await asyncio.get_event_loop().run_in_executor(
None, read_file, repo_file_path
)
print_formatted_text(
'Repository instructions file (repo.md) already exists.\n'
)
container = Frame(
TextArea(
text=content,
read_only=True,
style=COLOR_GREY,
wrap_lines=True,
),
title='Repository Instructions (repo.md)',
style=f'fg:{COLOR_GREY}',
)
print_container(container)
print_formatted_text('') # Add a newline after the frame
init_repo = cli_confirm(
'Do you want to re-initialize?',
['Yes, re-initialize', 'No, dismiss'],
)
if init_repo:
write_to_file(repo_file_path, '')
except Exception:
print_formatted_text('Error reading repository instructions file (repo.md)')
init_repo = False
else:
print_formatted_text(
'\nRepository instructions file will be created by exploring the repository.\n'
)
init_repo = cli_confirm(
'Do you want to proceed?',
['Yes, create', 'No, dismiss'],
)
return init_repo
def read_file(file_path):
with open(file_path, 'r') as f:
return f.read()
def write_to_file(file_path, content):
with open(file_path, 'w') as f:
f.write(content)
def cli_confirm(question: str = 'Are you sure?', choices: Optional[List[str]] = None):
if choices is None:
choices = ['Yes', 'No']
selected = [0] # Using list to allow modification in closure
def get_choice_text():
return [
('class:question', f'{question}\n\n'),
] + [
(
'class:selected' if i == selected[0] else 'class:unselected',
f"{'> ' if i == selected[0] else ' '}{choice}\n",
)
for i, choice in enumerate(choices)
]
kb = KeyBindings()
@kb.add('up')
def _(event):
selected[0] = (selected[0] - 1) % len(choices)
@kb.add('down')
def _(event):
selected[0] = (selected[0] + 1) % len(choices)
@kb.add('enter')
def _(event):
event.app.exit(result=selected[0] == 0)
style = Style.from_dict({'selected': COLOR_GOLD, 'unselected': ''})
layout = Layout(
HSplit(
[
Window(
FormattedTextControl(get_choice_text),
always_hide_cursor=True,
)
]
)
)
app = Application(
layout=layout,
key_bindings=kb,
style=style,
mouse_support=True,
full_screen=False,
)
return app.run(in_thread=True)
def update_usage_metrics(event: Event, usage_metrics: UsageMetrics):
"""Updates the UsageMetrics object with data from an event's llm_metrics."""
if hasattr(event, 'llm_metrics'):
llm_metrics: Metrics | None = getattr(event, 'llm_metrics', None)
if llm_metrics:
# Safely get accumulated_cost
cost = getattr(llm_metrics, 'accumulated_cost', 0)
# Ensure cost is a number before adding
usage_metrics.total_cost += cost if isinstance(cost, float) else 0
# Safely get token usage details object/dict
token_usage = getattr(llm_metrics, 'accumulated_token_usage', None)
if token_usage:
# Assume object access using getattr, providing defaults
prompt_tokens = getattr(token_usage, 'prompt_tokens', 0)
completion_tokens = getattr(token_usage, 'completion_tokens', 0)
cache_read = getattr(token_usage, 'cache_read_tokens', 0)
cache_write = getattr(token_usage, 'cache_write_tokens', 0)
# Ensure tokens are numbers before adding
usage_metrics.total_input_tokens += (
prompt_tokens if isinstance(prompt_tokens, int) else 0
)
usage_metrics.total_output_tokens += (
completion_tokens if isinstance(completion_tokens, int) else 0
)
usage_metrics.total_cache_read += (
cache_read if isinstance(cache_read, int) else 0
)
usage_metrics.total_cache_write += (
cache_write if isinstance(cache_write, int) else 0
)
def shutdown(usage_metrics: UsageMetrics, session_id: str):
cost_str = f'${usage_metrics.total_cost:.6f}'
input_tokens_str = f'{usage_metrics.total_input_tokens:,}'
cache_read_str = f'{usage_metrics.total_cache_read:,}'
cache_write_str = f'{usage_metrics.total_cache_write:,}'
output_tokens_str = f'{usage_metrics.total_output_tokens:,}'
total_tokens_str = (
f'{usage_metrics.total_input_tokens + usage_metrics.total_output_tokens:,}'
)
labels_and_values = [
(' Total Cost (USD):', cost_str),
(' Total Input Tokens:', input_tokens_str),
(' Cache Hits:', cache_read_str),
(' Cache Writes:', cache_write_str),
(' Total Output Tokens:', output_tokens_str),
(' Total Tokens:', total_tokens_str),
]
# Calculate max widths for alignment
max_label_width = max(len(label) for label, _ in labels_and_values)
max_value_width = max(len(value) for _, value in labels_and_values)
# Construct the summary text with aligned columns
summary_lines = [
f'{label:<{max_label_width}} {value:>{max_value_width}}'
for label, value in labels_and_values
]
summary_text = '\n'.join(summary_lines)
container = Frame(
TextArea(
text=summary_text,
read_only=True,
style=COLOR_GREY,
wrap_lines=True,
),
title='Session Summary',
style=f'fg:{COLOR_GREY}',
)
print_container(container)
print_formatted_text(HTML(f'\n<grey>Closed session {session_id}</grey>\n'))
def manage_openhands_file(folder_path=None, add_to_trusted=False):
openhands_file = Path.home() / '.openhands.toml'
default_content: dict = {'trusted_dirs': []}
if not openhands_file.exists():
with open(openhands_file, 'w') as f:
toml.dump(default_content, f)
if folder_path:
with open(openhands_file, 'r') as f:
try:
config = toml.load(f)
except Exception:
config = default_content
if 'trusted_dirs' not in config:
config['trusted_dirs'] = []
if folder_path in config['trusted_dirs']:
return True
if add_to_trusted:
config['trusted_dirs'].append(folder_path)
with open(openhands_file, 'w') as f:
toml.dump(config, f)
return False
return False
def check_folder_security_agreement(current_dir):
is_trusted = manage_openhands_file(current_dir)
if not is_trusted:
security_frame = Frame(
TextArea(
text=(
f'Do you trust the files in this folder?\n\n'
f'{current_dir}\n\n'
'OpenHands may read and execute files in this folder with your permission.'
),
style=COLOR_GREY,
read_only=True,
wrap_lines=True,
),
style=f'fg:{COLOR_GREY}',
)
clear()
print_container(security_frame)
confirm = cli_confirm('Do you wish to continue?', ['Yes, proceed', 'No, exit'])
if confirm:
manage_openhands_file(current_dir, add_to_trusted=True)
return confirm
return True
async def main(loop: asyncio.AbstractEventLoop):
"""Runs the agent in CLI mode."""
async def run_session(
loop: asyncio.AbstractEventLoop,
config: AppConfig,
settings_store: FileSettingsStore,
current_dir: str,
initial_user_action: str | None = None,
) -> bool:
reload_microagents = False
args = parse_arguments()
logger.setLevel(logging.WARNING)
# Load config from toml and override with command line arguments
config: AppConfig = setup_config_from_args(args)
# TODO: Set working directory from config or use current working directory?
current_dir = config.workspace_base
if not current_dir:
raise ValueError('Workspace base directory not specified')
# Read task from file, CLI args, or stdin
task_str = read_task(args, config.cli_multiline_input)
# If we have a task, create initial user action
initial_user_action = MessageAction(content=task_str) if task_str else None
new_session_requested = False
sid = str(uuid4())
is_loaded = asyncio.Event()
is_paused = asyncio.Event()
# Show OpenHands banner and session ID
display_banner(session_id=sid, is_loaded=is_loaded)
# Show runtime initialization message
display_runtime_initialization_message(config.runtime)
# Show Initialization loader
loop.run_in_executor(
@@ -689,52 +127,51 @@ async def main(loop: asyncio.AbstractEventLoop):
usage_metrics = UsageMetrics()
async def prompt_for_next_task():
nonlocal reload_microagents
async def prompt_for_next_task(agent_state: str):
nonlocal reload_microagents, new_session_requested
while True:
next_message = await read_prompt_input(config.cli_multiline_input)
next_message = await read_prompt_input(
agent_state, multiline=config.cli_multiline_input
)
if not next_message.strip():
continue
if next_message == '/exit':
event_stream.add_event(
ChangeAgentStateAction(AgentState.STOPPED), EventSource.ENVIRONMENT
)
shutdown(usage_metrics, sid)
return
elif next_message == '/help':
display_help()
continue
elif next_message == '/init':
if config.runtime == 'local':
init_repo = await init_repository(current_dir)
if init_repo:
event_stream.add_event(
MessageAction(content=REPO_MD_CREATE_PROMPT),
EventSource.USER,
)
reload_microagents = True
return
else:
print_formatted_text(
'\nRepository initialization through the CLI is only supported for local runtime.\n'
)
continue
(
close_repl,
reload_microagents,
new_session_requested,
) = await handle_commands(
next_message,
event_stream,
usage_metrics,
sid,
config,
current_dir,
settings_store,
)
action = MessageAction(content=next_message)
event_stream.add_event(action, EventSource.USER)
return
if close_repl:
return
async def on_event_async(event: Event) -> None:
nonlocal reload_microagents
nonlocal reload_microagents, is_paused
display_event(event, config)
update_usage_metrics(event, usage_metrics)
# Pause the agent if the pause event is set (if Ctrl-P is pressed)
if is_paused.is_set():
event_stream.add_event(
ChangeAgentStateAction(AgentState.PAUSED),
EventSource.USER,
)
is_paused.clear()
if isinstance(event, AgentStateChangedObservation):
if event.agent_state in [
AgentState.AWAITING_USER_INPUT,
AgentState.FINISHED,
AgentState.PAUSED,
]:
# Reload microagents after initialization of repo.md
if reload_microagents:
@@ -743,20 +180,28 @@ async def main(loop: asyncio.AbstractEventLoop):
)
memory.load_user_workspace_microagents(microagents)
reload_microagents = False
await prompt_for_next_task()
await prompt_for_next_task(event.agent_state)
if event.agent_state == AgentState.AWAITING_USER_CONFIRMATION:
user_confirmed = await read_confirmation_input()
if user_confirmed:
event_stream.add_event(
ChangeAgentStateAction(AgentState.USER_CONFIRMED),
EventSource.USER,
)
else:
event_stream.add_event(
ChangeAgentStateAction(AgentState.USER_REJECTED),
EventSource.USER,
)
# Only display the confirmation prompt if the agent is not paused
if not is_paused.is_set():
user_confirmed = await read_confirmation_input()
if user_confirmed:
event_stream.add_event(
ChangeAgentStateAction(AgentState.USER_CONFIRMED),
EventSource.USER,
)
else:
event_stream.add_event(
ChangeAgentStateAction(AgentState.USER_REJECTED),
EventSource.USER,
)
if event.agent_state == AgentState.RUNNING:
# Enable pause/resume functionality only if the confirmation mode is disabled
if not config.security.confirmation_mode:
display_agent_running_message()
loop.create_task(process_agent_pause(is_paused))
def on_event(event: Event) -> None:
loop.create_task(on_event_async(event))
@@ -785,30 +230,102 @@ async def main(loop: asyncio.AbstractEventLoop):
# Clear loading animation
is_loaded.set()
if not check_folder_security_agreement(current_dir):
# User rejected, exit application
return
# Clear the terminal
clear()
# Show OpenHands banner and session ID
display_banner(session_id=sid, is_loaded=is_loaded)
display_banner(session_id=sid)
# Show OpenHands welcome
display_welcome_message()
if initial_user_action:
# If there's an initial user action, enqueue it and do not prompt again
event_stream.add_event(initial_user_action, EventSource.USER)
display_initial_user_prompt(initial_user_action)
event_stream.add_event(
MessageAction(content=initial_user_action), EventSource.USER
)
else:
# Otherwise prompt for the user's first message right away
asyncio.create_task(prompt_for_next_task())
asyncio.create_task(prompt_for_next_task(''))
await run_agent_until_done(
controller, runtime, memory, [AgentState.STOPPED, AgentState.ERROR]
)
await cleanup_session(loop, agent, runtime, controller)
return new_session_requested
async def main(loop: asyncio.AbstractEventLoop):
"""Runs the agent in CLI mode."""
args = parse_arguments()
logger.setLevel(logging.WARNING)
# Load config from toml and override with command line arguments
config: AppConfig = setup_config_from_args(args)
# Load settings from Settings Store
# TODO: Make this generic?
settings_store = await FileSettingsStore.get_instance(config=config, user_id=None)
settings = await settings_store.load()
# Use settings from settings store if available and override with command line arguments
if settings:
config.default_agent = args.agent_cls if args.agent_cls else settings.agent
if not args.llm_config and settings.llm_model and settings.llm_api_key:
llm_config = config.get_llm_config()
llm_config.model = settings.llm_model
llm_config.api_key = settings.llm_api_key
llm_config.base_url = settings.llm_base_url
config.set_llm_config(llm_config)
config.security.confirmation_mode = (
settings.confirmation_mode if settings.confirmation_mode else False
)
if settings.enable_default_condenser:
# TODO: Make this generic?
llm_config = config.get_llm_config()
agent_config = config.get_agent_config(config.default_agent)
agent_config.condenser = LLMSummarizingCondenserConfig(
llm_config=llm_config,
type='llm',
)
config.set_agent_config(agent_config)
config.enable_default_condenser = True
else:
agent_config = config.get_agent_config(config.default_agent)
agent_config.condenser = NoOpCondenserConfig(type='noop')
config.set_agent_config(agent_config)
config.enable_default_condenser = False
# TODO: Set working directory from config or use current working directory?
current_dir = config.workspace_base
if not current_dir:
raise ValueError('Workspace base directory not specified')
if not check_folder_security_agreement(config, current_dir):
# User rejected, exit application
return
# Read task from file, CLI args, or stdin
task_str = read_task(args, config.cli_multiline_input)
# Run the first session
new_session_requested = await run_session(
loop, config, settings_store, current_dir, task_str
)
# If a new session was requested, run it
while new_session_requested:
new_session_requested = await run_session(
loop, config, settings_store, current_dir, None
)
if __name__ == '__main__':
loop = asyncio.new_event_loop()
@@ -829,6 +346,7 @@ if __name__ == '__main__':
pending = asyncio.all_tasks(loop)
for task in pending:
task.cancel()
# Wait for all tasks to complete with a timeout
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
loop.close()
+307
View File
@@ -0,0 +1,307 @@
import asyncio
from pathlib import Path
from prompt_toolkit import print_formatted_text
from prompt_toolkit.shortcuts import clear, print_container
from prompt_toolkit.widgets import Frame, TextArea
from openhands.core.cli_settings import (
display_settings,
modify_llm_settings_advanced,
modify_llm_settings_basic,
)
from openhands.core.cli_tui import (
COLOR_GREY,
UsageMetrics,
cli_confirm,
display_help,
display_shutdown_message,
display_status,
)
from openhands.core.cli_utils import (
add_local_config_trusted_dir,
get_local_config_trusted_dirs,
read_file,
write_to_file,
)
from openhands.core.config import (
AppConfig,
)
from openhands.core.schema import AgentState
from openhands.events import EventSource
from openhands.events.action import (
ChangeAgentStateAction,
MessageAction,
)
from openhands.events.stream import EventStream
from openhands.storage.settings.file_settings_store import FileSettingsStore
async def handle_commands(
command: str,
event_stream: EventStream,
usage_metrics: UsageMetrics,
sid: str,
config: AppConfig,
current_dir: str,
settings_store: FileSettingsStore,
) -> tuple[bool, bool, bool]:
close_repl = False
reload_microagents = False
new_session_requested = False
if command == '/exit':
close_repl = handle_exit_command(
event_stream,
usage_metrics,
sid,
)
elif command == '/help':
handle_help_command()
elif command == '/init':
close_repl, reload_microagents = await handle_init_command(
config, event_stream, current_dir
)
elif command == '/status':
handle_status_command(usage_metrics, sid)
elif command == '/new':
close_repl, new_session_requested = handle_new_command(
event_stream, usage_metrics, sid
)
elif command == '/settings':
await handle_settings_command(config, settings_store)
elif command == '/resume':
close_repl, new_session_requested = await handle_resume_command(event_stream)
else:
close_repl = True
action = MessageAction(content=command)
event_stream.add_event(action, EventSource.USER)
return close_repl, reload_microagents, new_session_requested
def handle_exit_command(
event_stream: EventStream, usage_metrics: UsageMetrics, sid: str
) -> bool:
close_repl = False
confirm_exit = (
cli_confirm('\nTerminate session?', ['Yes, proceed', 'No, dismiss']) == 0
)
if confirm_exit:
event_stream.add_event(
ChangeAgentStateAction(AgentState.STOPPED),
EventSource.ENVIRONMENT,
)
display_shutdown_message(usage_metrics, sid)
close_repl = True
return close_repl
def handle_help_command():
display_help()
async def handle_init_command(
config: AppConfig, event_stream: EventStream, current_dir: str
) -> tuple[bool, bool]:
REPO_MD_CREATE_PROMPT = """
Please explore this repository. Create the file .openhands/microagents/repo.md with:
- A description of the project
- An overview of the file structure
- Any information on how to run tests or other relevant commands
- Any other information that would be helpful to a brand new developer
Keep it short--just a few paragraphs will do.
"""
close_repl = False
reload_microagents = False
if config.runtime == 'local':
init_repo = await init_repository(current_dir)
if init_repo:
event_stream.add_event(
MessageAction(content=REPO_MD_CREATE_PROMPT),
EventSource.USER,
)
reload_microagents = True
close_repl = True
else:
print_formatted_text(
'\nRepository initialization through the CLI is only supported for local runtime.\n'
)
return close_repl, reload_microagents
def handle_status_command(usage_metrics: UsageMetrics, sid: str):
display_status(usage_metrics, sid)
def handle_new_command(
event_stream: EventStream, usage_metrics: UsageMetrics, sid: str
) -> tuple[bool, bool]:
close_repl = False
new_session_requested = False
new_session_requested = (
cli_confirm(
'\nCurrent session will be terminated and you will lose the conversation history.\n\nContinue?',
['Yes, proceed', 'No, dismiss'],
)
== 0
)
if new_session_requested:
close_repl = True
new_session_requested = True
event_stream.add_event(
ChangeAgentStateAction(AgentState.STOPPED),
EventSource.ENVIRONMENT,
)
display_shutdown_message(usage_metrics, sid)
return close_repl, new_session_requested
async def handle_settings_command(
config: AppConfig,
settings_store: FileSettingsStore,
):
display_settings(config)
modify_settings = cli_confirm(
'\nWhich settings would you like to modify?',
[
'Basic',
'Advanced',
'Go back',
],
)
if modify_settings == 0:
await modify_llm_settings_basic(config, settings_store)
elif modify_settings == 1:
await modify_llm_settings_advanced(config, settings_store)
# FIXME: Currently there's an issue with the actual 'resume' behavior.
# Setting the agent state to RUNNING will currently freeze the agent without continuing with the rest of the task.
# This is a workaround to handle the resume command for the time being. Replace user message with the state change event once the issue is fixed.
async def handle_resume_command(
event_stream: EventStream,
) -> tuple[bool, bool]:
close_repl = True
new_session_requested = False
event_stream.add_event(
MessageAction(content='continue'),
EventSource.USER,
)
# event_stream.add_event(
# ChangeAgentStateAction(AgentState.RUNNING),
# EventSource.ENVIRONMENT,
# )
return close_repl, new_session_requested
async def init_repository(current_dir: str) -> bool:
repo_file_path = Path(current_dir) / '.openhands' / 'microagents' / 'repo.md'
init_repo = False
if repo_file_path.exists():
try:
content = await asyncio.get_event_loop().run_in_executor(
None, read_file, repo_file_path
)
print_formatted_text(
'Repository instructions file (repo.md) already exists.\n'
)
container = Frame(
TextArea(
text=content,
read_only=True,
style=COLOR_GREY,
wrap_lines=True,
),
title='Repository Instructions (repo.md)',
style=f'fg:{COLOR_GREY}',
)
print_container(container)
print_formatted_text('') # Add a newline after the frame
init_repo = (
cli_confirm(
'Do you want to re-initialize?',
['Yes, re-initialize', 'No, dismiss'],
)
== 0
)
if init_repo:
write_to_file(repo_file_path, '')
except Exception:
print_formatted_text('Error reading repository instructions file (repo.md)')
init_repo = False
else:
print_formatted_text(
'\nRepository instructions file will be created by exploring the repository.\n'
)
init_repo = (
cli_confirm(
'Do you want to proceed?',
['Yes, create', 'No, dismiss'],
)
== 0
)
return init_repo
def check_folder_security_agreement(config: AppConfig, current_dir):
# Directories trusted by user for the CLI to use as workspace
# Config from ~/.openhands/config.toml overrides the app config
app_config_trusted_dirs = config.sandbox.trusted_dirs
local_config_trusted_dirs = get_local_config_trusted_dirs()
trusted_dirs = local_config_trusted_dirs
if not local_config_trusted_dirs:
trusted_dirs = app_config_trusted_dirs
is_trusted = current_dir in trusted_dirs
if not is_trusted:
security_frame = Frame(
TextArea(
text=(
f' Do you trust the files in this folder?\n\n'
f' {current_dir}\n\n'
' OpenHands may read and execute files in this folder with your permission.'
),
style=COLOR_GREY,
read_only=True,
wrap_lines=True,
),
style=f'fg:{COLOR_GREY}',
)
clear()
print_container(security_frame)
print_formatted_text('')
confirm = (
cli_confirm('Do you wish to continue?', ['Yes, proceed', 'No, exit']) == 0
)
if confirm:
add_local_config_trusted_dir(current_dir)
return confirm
return True
+348
View File
@@ -0,0 +1,348 @@
from prompt_toolkit import PromptSession, print_formatted_text
from prompt_toolkit.completion import FuzzyWordCompleter
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.shortcuts import print_container
from prompt_toolkit.widgets import Frame, TextArea
from pydantic import SecretStr
from openhands.controller.agent import Agent
from openhands.core.cli_tui import (
COLOR_GREY,
UserCancelledError,
cli_confirm,
kb_cancel,
)
from openhands.core.cli_utils import (
VERIFIED_ANTHROPIC_MODELS,
VERIFIED_OPENAI_MODELS,
VERIFIED_PROVIDERS,
organize_models_and_providers,
)
from openhands.core.config import AppConfig
from openhands.core.config.condenser_config import NoOpCondenserConfig
from openhands.core.config.utils import OH_DEFAULT_AGENT
from openhands.memory.condenser.impl.llm_summarizing_condenser import (
LLMSummarizingCondenserConfig,
)
from openhands.storage.data_models.settings import Settings
from openhands.storage.settings.file_settings_store import FileSettingsStore
from openhands.utils.llm import get_supported_llm_models
def display_settings(config: AppConfig):
llm_config = config.get_llm_config()
advanced_llm_settings = True if llm_config.base_url else False
# Prepare labels and values based on settings
labels_and_values = []
if not advanced_llm_settings:
# Attempt to determine provider, fallback if not directly available
provider = getattr(
llm_config,
'provider',
llm_config.model.split('/')[0] if '/' in llm_config.model else 'Unknown',
)
labels_and_values.extend(
[
(' LLM Provider', str(provider)),
(' LLM Model', str(llm_config.model)),
(' API Key', '********' if llm_config.api_key else 'Not Set'),
]
)
else:
labels_and_values.extend(
[
(' Custom Model', str(llm_config.model)),
(' Base URL', str(llm_config.base_url)),
(' API Key', '********' if llm_config.api_key else 'Not Set'),
]
)
# Common settings
labels_and_values.extend(
[
(' Agent', str(config.default_agent)),
(
' Confirmation Mode',
'Enabled' if config.security.confirmation_mode else 'Disabled',
),
(
' Memory Condensation',
'Enabled' if config.enable_default_condenser else 'Disabled',
),
]
)
# Calculate max widths for alignment
# Ensure values are strings for len() calculation
str_labels_and_values = [(label, str(value)) for label, value in labels_and_values]
max_label_width = (
max(len(label) for label, _ in str_labels_and_values)
if str_labels_and_values
else 0
)
# Construct the summary text with aligned columns
settings_lines = [
f'{label+":":<{max_label_width+1}} {value:<}' # Changed value alignment to left (<)
for label, value in str_labels_and_values
]
settings_text = '\n'.join(settings_lines)
container = Frame(
TextArea(
text=settings_text,
read_only=True,
style=COLOR_GREY,
wrap_lines=True,
),
title='Settings',
style=f'fg:{COLOR_GREY}',
)
print_container(container)
async def get_validated_input(
session: PromptSession,
prompt_text: str,
completer=None,
validator=None,
error_message='Input cannot be empty',
):
session.completer = completer
value = None
while True:
value = await session.prompt_async(prompt_text)
if validator:
is_valid = validator(value)
if not is_valid:
print_formatted_text('')
print_formatted_text(HTML(f'<grey>{error_message}: {value}</grey>'))
print_formatted_text('')
continue
elif not value:
print_formatted_text('')
print_formatted_text(HTML(f'<grey>{error_message}</grey>'))
print_formatted_text('')
continue
break
return value
def save_settings_confirmation() -> bool:
return (
cli_confirm(
'\nSave new settings? (They will take effect after restart)',
['Yes, save', 'No, discard'],
)
== 0
)
async def modify_llm_settings_basic(
config: AppConfig, settings_store: FileSettingsStore
):
model_list = get_supported_llm_models(config)
organized_models = organize_models_and_providers(model_list)
provider_list = list(organized_models.keys())
verified_providers = [p for p in VERIFIED_PROVIDERS if p in provider_list]
provider_list = [p for p in provider_list if p not in verified_providers]
provider_list = verified_providers + provider_list
provider_completer = FuzzyWordCompleter(provider_list)
session = PromptSession(key_bindings=kb_cancel())
provider = None
model = None
api_key = None
try:
provider = await get_validated_input(
session,
'(Step 1/3) Select LLM Provider (TAB for options, CTRL-c to cancel): ',
completer=provider_completer,
validator=lambda x: x in organized_models,
error_message='Invalid provider selected',
)
model_list = organized_models[provider]['models']
if provider == 'openai':
model_list = [m for m in model_list if m not in VERIFIED_OPENAI_MODELS]
model_list = VERIFIED_OPENAI_MODELS + model_list
if provider == 'anthropic':
model_list = [m for m in model_list if m not in VERIFIED_ANTHROPIC_MODELS]
model_list = VERIFIED_ANTHROPIC_MODELS + model_list
model_completer = FuzzyWordCompleter(model_list)
model = await get_validated_input(
session,
'(Step 2/3) Select LLM Model (TAB for options, CTRL-c to cancel): ',
completer=model_completer,
validator=lambda x: x in organized_models[provider]['models'],
error_message=f'Invalid model selected for provider {provider}',
)
api_key = await get_validated_input(
session,
'(Step 3/3) Enter API Key (CTRL-c to cancel): ',
error_message='API Key cannot be empty',
)
except (
UserCancelledError,
KeyboardInterrupt,
EOFError,
):
return # Return on exception
# TODO: check for empty string inputs?
# Handle case where a prompt might return None unexpectedly
if provider is None or model is None or api_key is None:
return
save_settings = save_settings_confirmation()
if not save_settings:
return
llm_config = config.get_llm_config()
llm_config.model = provider + organized_models[provider]['separator'] + model
llm_config.api_key = SecretStr(api_key)
llm_config.base_url = None
config.set_llm_config(llm_config)
config.default_agent = OH_DEFAULT_AGENT
config.security.confirmation_mode = False
config.enable_default_condenser = True
agent_config = config.get_agent_config(config.default_agent)
agent_config.condenser = LLMSummarizingCondenserConfig(
llm_config=llm_config,
type='llm',
)
config.set_agent_config(agent_config, config.default_agent)
settings = await settings_store.load()
if not settings:
settings = Settings()
settings.llm_model = provider + organized_models[provider]['separator'] + model
settings.llm_api_key = SecretStr(api_key)
settings.llm_base_url = None
settings.agent = OH_DEFAULT_AGENT
settings.confirmation_mode = False
settings.enable_default_condenser = True
await settings_store.store(settings)
async def modify_llm_settings_advanced(
config: AppConfig, settings_store: FileSettingsStore
):
session = PromptSession(key_bindings=kb_cancel())
custom_model = None
base_url = None
api_key = None
agent = None
try:
custom_model = await get_validated_input(
session,
'(Step 1/6) Custom Model (CTRL-c to cancel): ',
error_message='Custom Model cannot be empty',
)
base_url = await get_validated_input(
session,
'(Step 2/6) Base URL (CTRL-c to cancel): ',
error_message='Base URL cannot be empty',
)
api_key = await get_validated_input(
session,
'(Step 3/6) API Key (CTRL-c to cancel): ',
error_message='API Key cannot be empty',
)
agent_list = Agent.list_agents()
agent_completer = FuzzyWordCompleter(agent_list)
agent = await get_validated_input(
session,
'(Step 4/6) Agent (TAB for options, CTRL-c to cancel): ',
completer=agent_completer,
validator=lambda x: x in agent_list,
error_message='Invalid agent selected',
)
enable_confirmation_mode = (
cli_confirm(
question='(Step 5/6) Confirmation Mode (CTRL-c to cancel):',
choices=['Enable', 'Disable'],
)
== 0
)
enable_memory_condensation = (
cli_confirm(
question='(Step 6/6) Memory Condensation (CTRL-c to cancel):',
choices=['Enable', 'Disable'],
)
== 0
)
except (
UserCancelledError,
KeyboardInterrupt,
EOFError,
):
return # Return on exception
# TODO: check for empty string inputs?
# Handle case where a prompt might return None unexpectedly
if custom_model is None or base_url is None or api_key is None or agent is None:
return
save_settings = save_settings_confirmation()
if not save_settings:
return
llm_config = config.get_llm_config()
llm_config.model = custom_model
llm_config.base_url = base_url
llm_config.api_key = SecretStr(api_key)
config.set_llm_config(llm_config)
config.default_agent = agent
config.security.confirmation_mode = enable_confirmation_mode
agent_config = config.get_agent_config(config.default_agent)
if enable_memory_condensation:
agent_config.condenser = LLMSummarizingCondenserConfig(
llm_config=llm_config,
type='llm',
)
else:
agent_config.condenser = NoOpCondenserConfig(type='noop')
config.set_agent_config(agent_config)
settings = await settings_store.load()
if not settings:
settings = Settings()
settings.llm_model = custom_model
settings.llm_api_key = SecretStr(api_key)
settings.llm_base_url = base_url
settings.agent = agent
settings.confirmation_mode = enable_confirmation_mode
settings.enable_default_condenser = enable_memory_condensation
await settings_store.store(settings)
+581
View File
@@ -0,0 +1,581 @@
# CLI TUI input and output functions
# Handles all input and output to the console
# CLI Settings are handled separately in cli_settings.py
import asyncio
import sys
import time
from prompt_toolkit import PromptSession, print_formatted_text
from prompt_toolkit.application import Application
from prompt_toolkit.completion import Completer, Completion
from prompt_toolkit.formatted_text import HTML, FormattedText, StyleAndTextTuples
from prompt_toolkit.input import create_input
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.keys import Keys
from prompt_toolkit.layout.containers import HSplit, Window
from prompt_toolkit.layout.controls import FormattedTextControl
from prompt_toolkit.layout.layout import Layout
from prompt_toolkit.lexers import Lexer
from prompt_toolkit.patch_stdout import patch_stdout
from prompt_toolkit.shortcuts import print_container
from prompt_toolkit.styles import Style
from prompt_toolkit.widgets import Frame, TextArea
from openhands import __version__
from openhands.core.config import AppConfig
from openhands.core.schema import AgentState
from openhands.events import EventSource
from openhands.events.action import (
Action,
ActionConfirmationStatus,
CmdRunAction,
FileEditAction,
MessageAction,
)
from openhands.events.event import Event
from openhands.events.observation import (
AgentStateChangedObservation,
CmdOutputObservation,
FileEditObservation,
FileReadObservation,
)
from openhands.llm.metrics import Metrics
# Color and styling constants
COLOR_GOLD = '#FFD700'
COLOR_GREY = '#808080'
DEFAULT_STYLE = Style.from_dict(
{
'gold': COLOR_GOLD,
'grey': COLOR_GREY,
'prompt': f'{COLOR_GOLD} bold',
}
)
COMMANDS = {
'/exit': 'Exit the application',
'/help': 'Display available commands',
'/init': 'Initialize a new repository',
'/status': 'Display session details and usage metrics',
'/new': 'Create a new session',
'/settings': 'Display and modify current settings',
'/resume': 'Resume the agent',
}
class UsageMetrics:
def __init__(self):
self.metrics: Metrics = Metrics()
self.session_init_time: float = time.time()
class CustomDiffLexer(Lexer):
"""Custom lexer for the specific diff format."""
def lex_document(self, document) -> StyleAndTextTuples:
lines = document.lines
def get_line(lineno: int) -> StyleAndTextTuples:
line = lines[lineno]
if line.startswith('+'):
return [('ansigreen', line)]
elif line.startswith('-'):
return [('ansired', line)]
elif line.startswith('[') or line.startswith('('):
# Style for metadata lines like [Existing file...] or (content...)
return [('bold', line)]
else:
# Default style for other lines
return [('', line)]
return get_line
# CLI initialization and startup display functions
def display_runtime_initialization_message(runtime: str):
print_formatted_text('')
if runtime == 'local':
print_formatted_text(HTML('<grey>⚙️ Starting local runtime...</grey>'))
elif runtime == 'docker':
print_formatted_text(HTML('<grey>🐳 Starting Docker runtime...</grey>'))
print_formatted_text('')
def display_initialization_animation(text, is_loaded: asyncio.Event):
ANIMATION_FRAMES = ['', '', '', '', '', '', '', '', '', '']
i = 0
while not is_loaded.is_set():
sys.stdout.write('\n')
sys.stdout.write(
f'\033[s\033[J\033[38;2;255;215;0m[{ANIMATION_FRAMES[i % len(ANIMATION_FRAMES)]}] {text}\033[0m\033[u\033[1A'
)
sys.stdout.flush()
time.sleep(0.1)
i += 1
sys.stdout.write('\r' + ' ' * (len(text) + 10) + '\r')
sys.stdout.flush()
def display_banner(session_id: str):
print_formatted_text(
HTML(r"""<gold>
___ _ _ _
/ _ \ _ __ ___ _ __ | | | | __ _ _ __ __| |___
| | | | '_ \ / _ \ '_ \| |_| |/ _` | '_ \ / _` / __|
| |_| | |_) | __/ | | | _ | (_| | | | | (_| \__ \
\___ /| .__/ \___|_| |_|_| |_|\__,_|_| |_|\__,_|___/
|_|
</gold>"""),
style=DEFAULT_STYLE,
)
print_formatted_text(HTML(f'<grey>OpenHands CLI v{__version__}</grey>'))
print_formatted_text('')
print_formatted_text(HTML(f'<grey>Initialized session {session_id}</grey>'))
print_formatted_text('')
def display_welcome_message():
print_formatted_text(
HTML("<gold>Let's start building!</gold>\n"), style=DEFAULT_STYLE
)
print_formatted_text(
HTML('What do you want to build? <grey>Type /help for help</grey>'),
style=DEFAULT_STYLE,
)
def display_initial_user_prompt(prompt: str):
print_formatted_text(
FormattedText(
[
('', '\n'),
(COLOR_GOLD, '> '),
('', prompt),
]
)
)
# Prompt output display functions
def display_event(event: Event, config: AppConfig) -> None:
if isinstance(event, Action):
if hasattr(event, 'thought'):
display_message(event.thought)
if isinstance(event, MessageAction):
if event.source == EventSource.AGENT:
display_message(event.content)
if isinstance(event, CmdRunAction):
display_command(event)
if isinstance(event, CmdOutputObservation):
display_command_output(event.content)
if isinstance(event, FileEditAction):
display_file_edit(event)
if isinstance(event, FileEditObservation):
display_file_edit(event)
if isinstance(event, FileReadObservation):
display_file_read(event)
if isinstance(event, AgentStateChangedObservation):
display_agent_paused_message(event.agent_state)
def display_message(message: str):
time.sleep(0.2)
message = message.strip()
if message:
print_formatted_text(f'\n{message}')
def display_command(event: CmdRunAction):
if event.confirmation_state == ActionConfirmationStatus.AWAITING_CONFIRMATION:
container = Frame(
TextArea(
text=f'$ {event.command}',
read_only=True,
style=COLOR_GREY,
wrap_lines=True,
),
title='Action',
style='ansired',
)
print_formatted_text('')
print_container(container)
def display_command_output(output: str):
lines = output.split('\n')
formatted_lines = []
for line in lines:
if line.startswith('[Python Interpreter') or line.startswith('openhands@'):
# TODO: clean this up once we clean up terminal output
continue
formatted_lines.append(line)
formatted_lines.append('\n')
# Remove the last newline if it exists
if formatted_lines:
formatted_lines.pop()
container = Frame(
TextArea(
text=''.join(formatted_lines),
read_only=True,
style=COLOR_GREY,
wrap_lines=True,
),
title='Action Output',
style=f'fg:{COLOR_GREY}',
)
print_formatted_text('')
print_container(container)
def display_file_edit(event: FileEditAction | FileEditObservation):
if isinstance(event, FileEditObservation):
container = Frame(
TextArea(
text=event.visualize_diff(n_context_lines=4),
read_only=True,
wrap_lines=True,
lexer=CustomDiffLexer(),
),
title='File Edit',
style=f'fg:{COLOR_GREY}',
)
print_container(container)
def display_file_read(event: FileReadObservation):
container = Frame(
TextArea(
text=f'{event}',
read_only=True,
style=COLOR_GREY,
wrap_lines=True,
),
title='File Read',
style=f'fg:{COLOR_GREY}',
)
print_container(container)
# Interactive command output display functions
def display_help():
# Version header and introduction
print_formatted_text(
HTML(
f'\n<grey>OpenHands CLI v{__version__}</grey>\n'
'<gold>OpenHands CLI lets you interact with the OpenHands agent from the command line.</gold>\n'
)
)
# Usage examples
print_formatted_text('Things that you can try:')
print_formatted_text(
HTML(
'• Ask questions about the codebase <grey>> How does main.py work?</grey>\n'
'• Edit files or add new features <grey>> Add a new function to ...</grey>\n'
'• Find and fix issues <grey>> Fix the type error in ...</grey>\n'
)
)
# Tips section
print_formatted_text(
'Some tips to get the most out of OpenHands:\n'
'• Be as specific as possible about the desired outcome or the problem to be solved.\n'
'• Provide context, including relevant file paths and line numbers if available.\n'
'• Break large tasks into smaller, manageable prompts.\n'
'• Include relevant error messages or logs.\n'
'• Specify the programming language or framework, if not obvious.\n'
)
# Commands section
print_formatted_text(HTML('Interactive commands:'))
commands_html = ''
for command, description in COMMANDS.items():
commands_html += f'<gold><b>{command}</b></gold> - <grey>{description}</grey>\n'
print_formatted_text(HTML(commands_html))
# Footer
print_formatted_text(
HTML(
'<grey>Learn more at: https://docs.all-hands.dev/modules/usage/getting-started</grey>'
)
)
def display_usage_metrics(usage_metrics: UsageMetrics):
cost_str = f'${usage_metrics.metrics.accumulated_cost:.6f}'
input_tokens_str = (
f'{usage_metrics.metrics.accumulated_token_usage.prompt_tokens:,}'
)
cache_read_str = (
f'{usage_metrics.metrics.accumulated_token_usage.cache_read_tokens:,}'
)
cache_write_str = (
f'{usage_metrics.metrics.accumulated_token_usage.cache_write_tokens:,}'
)
output_tokens_str = (
f'{usage_metrics.metrics.accumulated_token_usage.completion_tokens:,}'
)
total_tokens_str = f'{usage_metrics.metrics.accumulated_token_usage.prompt_tokens + usage_metrics.metrics.accumulated_token_usage.completion_tokens:,}'
labels_and_values = [
(' Total Cost (USD):', cost_str),
('', ''),
(' Total Input Tokens:', input_tokens_str),
(' Cache Hits:', cache_read_str),
(' Cache Writes:', cache_write_str),
(' Total Output Tokens:', output_tokens_str),
('', ''),
(' Total Tokens:', total_tokens_str),
]
# Calculate max widths for alignment
max_label_width = max(len(label) for label, _ in labels_and_values)
max_value_width = max(len(value) for _, value in labels_and_values)
# Construct the summary text with aligned columns
summary_lines = [
f'{label:<{max_label_width}} {value:<{max_value_width}}'
for label, value in labels_and_values
]
summary_text = '\n'.join(summary_lines)
container = Frame(
TextArea(
text=summary_text,
read_only=True,
style=COLOR_GREY,
wrap_lines=True,
),
title='Usage Metrics',
style=f'fg:{COLOR_GREY}',
)
print_container(container)
def get_session_duration(session_init_time: float) -> str:
current_time = time.time()
session_duration = current_time - session_init_time
hours, remainder = divmod(session_duration, 3600)
minutes, seconds = divmod(remainder, 60)
return f'{int(hours)}h {int(minutes)}m {int(seconds)}s'
def display_shutdown_message(usage_metrics: UsageMetrics, session_id: str):
duration_str = get_session_duration(usage_metrics.session_init_time)
print_formatted_text(HTML('<grey>Closing current session...</grey>'))
print_formatted_text('')
display_usage_metrics(usage_metrics)
print_formatted_text('')
print_formatted_text(HTML(f'<grey>Session duration: {duration_str}</grey>'))
print_formatted_text('')
print_formatted_text(HTML(f'<grey>Closed session {session_id}</grey>'))
print_formatted_text('')
def display_status(usage_metrics: UsageMetrics, session_id: str):
duration_str = get_session_duration(usage_metrics.session_init_time)
print_formatted_text('')
print_formatted_text(HTML(f'<grey>Session ID: {session_id}</grey>'))
print_formatted_text(HTML(f'<grey>Uptime: {duration_str}</grey>'))
print_formatted_text('')
display_usage_metrics(usage_metrics)
def display_agent_running_message():
print_formatted_text('')
print_formatted_text(
HTML('<gold>Agent running...</gold> <grey>(Ctrl-P to pause)</grey>')
)
def display_agent_paused_message(agent_state: str):
if agent_state != AgentState.PAUSED:
return
print_formatted_text('')
print_formatted_text(
HTML('<gold>Agent paused</gold> <grey>(type /resume to resume)</grey>')
)
# Common input functions
class CommandCompleter(Completer):
"""Custom completer for commands."""
def __init__(self, agent_state: str):
super().__init__()
self.agent_state = agent_state
def get_completions(self, document, complete_event):
text = document.text_before_cursor.lstrip()
if text.startswith('/'):
available_commands = dict(COMMANDS)
if self.agent_state != AgentState.PAUSED:
available_commands.pop('/resume', None)
for command, description in available_commands.items():
if command.startswith(text):
yield Completion(
command,
start_position=-len(text),
display_meta=description,
style='bg:ansidarkgray fg:ansiwhite',
)
def create_prompt_session():
return PromptSession(style=DEFAULT_STYLE)
async def read_prompt_input(agent_state: str, multiline=False):
try:
prompt_session = create_prompt_session()
prompt_session.completer = (
CommandCompleter(agent_state) if not multiline else None
)
if multiline:
kb = KeyBindings()
@kb.add('c-d')
def _(event):
event.current_buffer.validate_and_handle()
with patch_stdout():
print_formatted_text('')
message = await prompt_session.prompt_async(
HTML(
'<gold>Enter your message and press Ctrl-D to finish:</gold>\n'
),
multiline=True,
key_bindings=kb,
)
else:
with patch_stdout():
print_formatted_text('')
message = await prompt_session.prompt_async(
HTML('<gold>> </gold>'),
)
return message if message is not None else ''
except (KeyboardInterrupt, EOFError):
return '/exit'
async def read_confirmation_input() -> bool:
try:
prompt_session = create_prompt_session()
with patch_stdout():
print_formatted_text('')
confirmation: str = await prompt_session.prompt_async(
HTML('<gold>Proceed with action? (y)es/(n)o > </gold>'),
)
confirmation = '' if confirmation is None else confirmation.strip().lower()
return confirmation in ['y', 'yes']
except (KeyboardInterrupt, EOFError):
return False
async def process_agent_pause(done: asyncio.Event) -> None:
input = create_input()
def keys_ready():
for key_press in input.read_keys():
if key_press.key == Keys.ControlP:
print_formatted_text('')
print_formatted_text(HTML('<gold>Pausing the agent...</gold>'))
done.set()
with input.raw_mode():
with input.attach(keys_ready):
await done.wait()
def cli_confirm(
question: str = 'Are you sure?', choices: list[str] | None = None
) -> int:
"""
Display a confirmation prompt with the given question and choices.
Returns the index of the selected choice.
"""
if choices is None:
choices = ['Yes', 'No']
selected = [0] # Using list to allow modification in closure
def get_choice_text():
return [
('class:question', f'{question}\n\n'),
] + [
(
'class:selected' if i == selected[0] else 'class:unselected',
f"{'> ' if i == selected[0] else ' '}{choice}\n",
)
for i, choice in enumerate(choices)
]
kb = KeyBindings()
@kb.add('up')
def _(event):
selected[0] = (selected[0] - 1) % len(choices)
@kb.add('down')
def _(event):
selected[0] = (selected[0] + 1) % len(choices)
@kb.add('enter')
def _(event):
event.app.exit(result=selected[0])
style = Style.from_dict({'selected': COLOR_GOLD, 'unselected': ''})
layout = Layout(
HSplit(
[
Window(
FormattedTextControl(get_choice_text),
always_hide_cursor=True,
)
]
)
)
app = Application(
layout=layout,
key_bindings=kb,
style=style,
mouse_support=True,
full_screen=False,
)
return app.run(in_thread=True)
def kb_cancel():
"""Custom key bindings to handle ESC as a user cancellation."""
bindings = KeyBindings()
@bindings.add('escape')
def _(event):
event.app.exit(exception=UserCancelledError, style='class:aborting')
return bindings
class UserCancelledError(Exception):
"""Raised when the user cancels an operation via key binding."""
pass
+152
View File
@@ -0,0 +1,152 @@
from pathlib import Path
from typing import Dict, List
import toml
from openhands.core.cli_tui import (
UsageMetrics,
)
from openhands.events.event import Event
from openhands.llm.metrics import Metrics
_LOCAL_CONFIG_FILE_PATH = Path.home() / '.openhands' / 'config.toml'
_DEFAULT_CONFIG: Dict[str, Dict[str, List[str]]] = {'sandbox': {'trusted_dirs': []}}
def get_local_config_trusted_dirs() -> list[str]:
if _LOCAL_CONFIG_FILE_PATH.exists():
with open(_LOCAL_CONFIG_FILE_PATH, 'r') as f:
try:
config = toml.load(f)
except Exception:
config = _DEFAULT_CONFIG
if 'sandbox' in config and 'trusted_dirs' in config['sandbox']:
return config['sandbox']['trusted_dirs']
return []
def add_local_config_trusted_dir(folder_path: str):
config = _DEFAULT_CONFIG
if _LOCAL_CONFIG_FILE_PATH.exists():
try:
with open(_LOCAL_CONFIG_FILE_PATH, 'r') as f:
config = toml.load(f)
except Exception:
config = _DEFAULT_CONFIG
else:
_LOCAL_CONFIG_FILE_PATH.parent.mkdir(parents=True, exist_ok=True)
if 'sandbox' not in config:
config['sandbox'] = {}
if 'trusted_dirs' not in config['sandbox']:
config['sandbox']['trusted_dirs'] = []
if folder_path not in config['sandbox']['trusted_dirs']:
config['sandbox']['trusted_dirs'].append(folder_path)
with open(_LOCAL_CONFIG_FILE_PATH, 'w') as f:
toml.dump(config, f)
def update_usage_metrics(event: Event, usage_metrics: UsageMetrics):
if not hasattr(event, 'llm_metrics'):
return
llm_metrics: Metrics | None = event.llm_metrics
if not llm_metrics:
return
usage_metrics.metrics = llm_metrics
def extract_model_and_provider(model):
separator = '/'
split = model.split(separator)
if len(split) == 1:
# no "/" separator found, try with "."
separator = '.'
split = model.split(separator)
if split_is_actually_version(split):
split = [separator.join(split)] # undo the split
if len(split) == 1:
# no "/" or "." separator found
if split[0] in VERIFIED_OPENAI_MODELS:
return {'provider': 'openai', 'model': split[0], 'separator': '/'}
if split[0] in VERIFIED_ANTHROPIC_MODELS:
return {'provider': 'anthropic', 'model': split[0], 'separator': '/'}
# return as model only
return {'provider': '', 'model': model, 'separator': ''}
provider = split[0]
model_id = separator.join(split[1:])
return {'provider': provider, 'model': model_id, 'separator': separator}
def organize_models_and_providers(models):
result = {}
for model in models:
extracted = extract_model_and_provider(model)
separator = extracted['separator']
provider = extracted['provider']
model_id = extracted['model']
# Ignore "anthropic" providers with a separator of "."
# These are outdated and incompatible providers.
if provider == 'anthropic' and separator == '.':
continue
key = provider or 'other'
if key not in result:
result[key] = {'separator': separator, 'models': []}
result[key]['models'].append(model_id)
return result
VERIFIED_PROVIDERS = ['openai', 'azure', 'anthropic', 'deepseek']
VERIFIED_OPENAI_MODELS = [
'gpt-4o',
'gpt-4o-mini',
'gpt-4-turbo',
'gpt-4',
'gpt-4-32k',
'o1-mini',
'o1',
'o3-mini',
'o3-mini-2025-01-31',
]
VERIFIED_ANTHROPIC_MODELS = [
'claude-2',
'claude-2.1',
'claude-3-5-sonnet-20240620',
'claude-3-5-sonnet-20241022',
'claude-3-5-haiku-20241022',
'claude-3-haiku-20240307',
'claude-3-opus-20240229',
'claude-3-sonnet-20240229',
'claude-3-7-sonnet-20250219',
]
def is_number(char):
return char.isdigit()
def split_is_actually_version(split):
return len(split) > 1 and split[1] and split[1][0] and is_number(split[1][0])
def read_file(file_path):
with open(file_path, 'r') as f:
return f.read()
def write_to_file(file_path, content):
with open(file_path, 'w') as f:
f.write(content)
+2
View File
@@ -38,6 +38,7 @@ class SandboxConfig(BaseModel):
enable_gpu: Whether to enable GPU.
docker_runtime_kwargs: Additional keyword arguments to pass to the Docker runtime when running containers.
This should be a JSON string that will be parsed into a dictionary.
trusted_dirs: List of directories that can be trusted to run the OpenHands CLI.
"""
remote_runtime_api_url: str | None = Field(default='http://localhost:8000')
@@ -75,6 +76,7 @@ class SandboxConfig(BaseModel):
enable_gpu: bool = Field(default=False)
docker_runtime_kwargs: dict | None = Field(default=None)
selected_repo: str | None = Field(default=None)
trusted_dirs: list[str] = Field(default_factory=list)
model_config = {'extra': 'forbid'}
@@ -109,7 +109,7 @@ class GitLabService(BaseGitService, GitService):
raise self.handle_http_error(e)
async def execute_graphql_query(
self, query: str, variables: dict[str, Any]|None = None
self, query: str, variables: dict[str, Any] | None = None
) -> Any:
"""
Execute a GraphQL query against the GitLab GraphQL API
+42 -2
View File
@@ -54,6 +54,7 @@ class ConversationMemory:
def process_events(
self,
condensed_history: list[Event],
initial_user_action: MessageAction,
max_message_chars: int | None = None,
vision_is_active: bool = False,
) -> list[Message]:
@@ -66,12 +67,14 @@ class ConversationMemory:
max_message_chars: The maximum number of characters in the content of an event included
in the prompt to the LLM. Larger observations are truncated.
vision_is_active: Whether vision is active in the LLM. If True, image URLs will be included.
initial_user_action: The initial user message action, if available. Used to ensure the conversation starts correctly.
"""
events = condensed_history
# Ensure the system message exists (handles legacy cases)
# Ensure the event list starts with SystemMessageAction, then MessageAction(source='user')
self._ensure_system_message(events)
self._ensure_initial_user_message(events, initial_user_action)
# log visual browsing status
logger.debug(f'Visual browsing: {self.agent_config.enable_som_visual_browsing}')
@@ -699,6 +702,43 @@ class ConversationMemory:
system_message = SystemMessageAction(content=system_prompt)
# Insert the system message directly at the beginning of the events list
events.insert(0, system_message)
logger.debug(
logger.info(
'[ConversationMemory] Added SystemMessageAction for backward compatibility'
)
def _ensure_initial_user_message(
self, events: list[Event], initial_user_action: MessageAction
) -> None:
"""Checks if the second event is a user MessageAction and inserts the provided one if needed."""
if (
not events
): # Should have system message from previous step, but safety check
logger.error('Cannot ensure initial user message: event list is empty.')
# Or raise? Let's log for now, _ensure_system_message should handle this.
return
# We expect events[0] to be SystemMessageAction after _ensure_system_message
if len(events) == 1:
# Only system message exists
logger.info(
'Initial user message action was missing. Inserting the initial user message.'
)
events.insert(1, initial_user_action)
elif not isinstance(events[1], MessageAction) or events[1].source != 'user':
# The second event exists but is not the correct initial user message action.
# We will insert the correct one provided.
logger.info(
'Second event was not the initial user message action. Inserting correct one at index 1.'
)
# Insert the user message event at index 1. This will be the second message as LLM APIs expect
# but something was wrong with the history, so log all we can.
events.insert(1, initial_user_action)
# Else: events[1] is already a user MessageAction.
# Check if it matches the one provided (if any discrepancy, log warning but proceed).
elif events[1] != initial_user_action:
logger.debug(
'The user MessageAction at index 1 does not match the provided initial_user_action. '
'Proceeding with the one found in condensed history.'
)
+1 -1
View File
@@ -249,7 +249,7 @@ class Memory:
"""
Loads microagents from the global microagents_dir
"""
repo_agents, knowledge_agents, _ = load_microagents_from_dir(
repo_agents, knowledge_agents = load_microagents_from_dir(
GLOBAL_MICROAGENTS_DIR
)
for name, agent in knowledge_agents.items():
+3
View File
@@ -4,6 +4,7 @@ from typing import overload
from pydantic import BaseModel
from openhands.core.logger import openhands_logger as logger
from openhands.events.action.agent import CondensationAction
from openhands.events.event import Event
from openhands.events.observation.agent import AgentCondensationObservation
@@ -65,6 +66,8 @@ class View(BaseModel):
break
if summary is not None and summary_offset is not None:
logger.info(f'Inserting summary at offset {summary_offset}')
kept_events.insert(
summary_offset, AgentCondensationObservation(content=summary)
)
+1 -4
View File
@@ -2,18 +2,15 @@ from .microagent import (
BaseMicroagent,
KnowledgeMicroagent,
RepoMicroagent,
TaskMicroagent,
load_microagents_from_dir,
)
from .types import MicroagentMetadata, MicroagentType, TaskInput
from .types import MicroagentMetadata, MicroagentType
__all__ = [
'BaseMicroagent',
'KnowledgeMicroagent',
'RepoMicroagent',
'TaskMicroagent',
'MicroagentMetadata',
'MicroagentType',
'TaskInput',
'load_microagents_from_dir',
]
+42 -27
View File
@@ -23,11 +23,23 @@ class BaseMicroagent(BaseModel):
@classmethod
def load(
cls, path: Union[str, Path], file_content: str | None = None
cls,
path: Union[str, Path],
microagent_dir: Path | None = None,
file_content: str | None = None,
) -> 'BaseMicroagent':
"""Load a microagent from a markdown file with frontmatter."""
"""Load a microagent from a markdown file with frontmatter.
The agent's name is derived from its path relative to the microagent_dir.
"""
path = Path(path) if isinstance(path, str) else path
# Calculate derived name from relative path if microagent_dir is provided
# Otherwise, we will rely on the name from metadata later
derived_name = None
if microagent_dir is not None:
derived_name = str(path.relative_to(microagent_dir).with_suffix(''))
# Only load directly from path if file_content is not provided
if file_content is None:
with open(path) as f:
@@ -59,18 +71,33 @@ class BaseMicroagent(BaseModel):
subclass_map = {
MicroagentType.KNOWLEDGE: KnowledgeMicroagent,
MicroagentType.REPO_KNOWLEDGE: RepoMicroagent,
MicroagentType.TASK: TaskMicroagent,
}
if metadata.type not in subclass_map:
raise ValueError(f'Unknown microagent type: {metadata.type}')
agent_class = subclass_map[metadata.type]
# Infer the agent type:
# 1. If triggers exist -> KNOWLEDGE
# 2. Else (no triggers) -> REPO
inferred_type: MicroagentType
if metadata.triggers:
inferred_type = MicroagentType.KNOWLEDGE
else:
# No triggers, default to REPO unless metadata explicitly says otherwise (which it shouldn't for REPO)
# This handles cases where 'type' might be missing or defaulted by Pydantic
inferred_type = MicroagentType.REPO_KNOWLEDGE
if inferred_type not in subclass_map:
# This should theoretically not happen with the logic above
raise ValueError(f'Could not determine microagent type for: {path}')
# Use derived_name if available (from relative path), otherwise fallback to metadata.name
agent_name = derived_name if derived_name is not None else metadata.name
agent_class = subclass_map[inferred_type]
return agent_class(
name=metadata.name,
name=agent_name,
content=content,
metadata=metadata,
source=str(path),
type=metadata.type,
type=inferred_type,
)
@@ -118,23 +145,14 @@ class RepoMicroagent(BaseMicroagent):
def __init__(self, **data):
super().__init__(**data)
if self.type != MicroagentType.REPO_KNOWLEDGE:
raise ValueError('RepoMicroagent must have type REPO_KNOWLEDGE')
class TaskMicroagent(BaseMicroagent):
"""Microagent specialized for task-based operations."""
def __init__(self, **data):
super().__init__(**data)
if self.type != MicroagentType.TASK:
raise ValueError('TaskMicroagent must have type TASK')
raise ValueError(
f'RepoMicroagent initialized with incorrect type: {self.type}'
)
def load_microagents_from_dir(
microagent_dir: Union[str, Path],
) -> tuple[
dict[str, RepoMicroagent], dict[str, KnowledgeMicroagent], dict[str, TaskMicroagent]
]:
) -> tuple[dict[str, RepoMicroagent], dict[str, KnowledgeMicroagent]]:
"""Load all microagents from the given directory.
Note, legacy repo instructions will not be loaded here.
@@ -150,9 +168,8 @@ def load_microagents_from_dir(
repo_agents = {}
knowledge_agents = {}
task_agents = {}
# Load all agents from .openhands/microagents directory
# Load all agents from microagents directory
logger.debug(f'Loading agents from {microagent_dir}')
if microagent_dir.exists():
for file in microagent_dir.rglob('*.md'):
@@ -161,15 +178,13 @@ def load_microagents_from_dir(
if file.name == 'README.md':
continue
try:
agent = BaseMicroagent.load(file)
agent = BaseMicroagent.load(file, microagent_dir)
if isinstance(agent, RepoMicroagent):
repo_agents[agent.name] = agent
elif isinstance(agent, KnowledgeMicroagent):
knowledge_agents[agent.name] = agent
elif isinstance(agent, TaskMicroagent):
task_agents[agent.name] = agent
logger.debug(f'Loaded agent {agent.name} from {file}')
except Exception as e:
raise ValueError(f'Error loading agent from {file}: {e}')
return repo_agents, knowledge_agents, task_agents
return repo_agents, knowledge_agents
-9
View File
@@ -8,7 +8,6 @@ class MicroagentType(str, Enum):
KNOWLEDGE = 'knowledge'
REPO_KNOWLEDGE = 'repo'
TASK = 'task'
class MicroagentMetadata(BaseModel):
@@ -19,11 +18,3 @@ class MicroagentMetadata(BaseModel):
version: str = Field(default='1.0.0')
agent: str = Field(default='CodeActAgent')
triggers: list[str] = [] # optional, only exists for knowledge microagents
class TaskInput(BaseModel):
"""Input parameter for task-based agents."""
name: str
description: str
required: bool = True
File diff suppressed because it is too large Load Diff
+5 -6
View File
@@ -457,7 +457,9 @@ class Runtime(FileEditRuntimeMixin):
self.log('info', 'openhands_instructions microagent loaded.')
loaded_microagents.append(
BaseMicroagent.load(
path='.openhands_instructions', file_content=obs.content
path='.openhands_instructions',
microagent_dir=None,
file_content=obs.content,
)
)
@@ -483,16 +485,13 @@ class Runtime(FileEditRuntimeMixin):
# Clean up the temporary zip file
zip_path.unlink()
# Load all microagents using the existing function
repo_agents, knowledge_agents, task_agents = load_microagents_from_dir(
microagent_folder
)
repo_agents, knowledge_agents = load_microagents_from_dir(microagent_folder)
self.log(
'info',
f'Loaded {len(repo_agents)} repo agents, {len(knowledge_agents)} knowledge agents, and {len(task_agents)} task agents',
f'Loaded {len(repo_agents)} repo agents and {len(knowledge_agents)} knowledge agents',
)
loaded_microagents.extend(repo_agents.values())
loaded_microagents.extend(knowledge_agents.values())
loaded_microagents.extend(task_agents.values())
shutil.rmtree(microagent_folder)
return loaded_microagents
+2
View File
@@ -462,6 +462,8 @@ class BashSession:
ps1_matches[i].end() + 1 : ps1_matches[i + 1].start()
]
combined_output += output_segment + '\n'
# Add the content after the last PS1 prompt
combined_output += pane_content[ps1_matches[-1].end() + 1 :]
logger.debug(f'COMBINED OUTPUT: {combined_output}')
return combined_output
@@ -10,7 +10,7 @@ from openhands.events.event_store import EventStore
from openhands.server.config.server_config import ServerConfig
from openhands.server.monitoring import MonitoringListener
from openhands.server.session.conversation import Conversation
from openhands.server.settings import Settings
from openhands.storage.data_models.settings import Settings
from openhands.storage.conversation.conversation_store import ConversationStore
from openhands.storage.files import FileStore
@@ -18,7 +18,7 @@ from openhands.server.monitoring import MonitoringListener
from openhands.server.session.agent_session import WAIT_TIME_BEFORE_CLOSE
from openhands.server.session.conversation import Conversation
from openhands.server.session.session import ROOM_KEY, Session
from openhands.server.settings import Settings
from openhands.storage.data_models.settings import Settings
from openhands.storage.conversation.conversation_store import ConversationStore
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
from openhands.storage.files import FileStore
+2 -43
View File
@@ -1,20 +1,12 @@
import warnings
from typing import Any
import httpx
from fastapi import APIRouter
from openhands.security.options import SecurityAnalyzers
with warnings.catch_warnings():
warnings.simplefilter('ignore')
import litellm
from openhands.controller.agent import Agent
from openhands.core.config import LLMConfig
from openhands.core.logger import openhands_logger as logger
from openhands.llm import bedrock
from openhands.server.shared import config, server_config
from openhands.utils.llm import get_supported_llm_models
app = APIRouter(prefix='/api/options')
@@ -34,40 +26,7 @@ async def get_litellm_models() -> list[str]:
Returns:
list[str]: A sorted list of unique model names.
"""
litellm_model_list = litellm.model_list + list(litellm.model_cost.keys())
litellm_model_list_without_bedrock = bedrock.remove_error_modelId(
litellm_model_list
)
# TODO: for bedrock, this is using the default config
llm_config: LLMConfig = config.get_llm_config()
bedrock_model_list = []
if (
llm_config.aws_region_name
and llm_config.aws_access_key_id
and llm_config.aws_secret_access_key
):
bedrock_model_list = bedrock.list_foundation_models(
llm_config.aws_region_name,
llm_config.aws_access_key_id.get_secret_value(),
llm_config.aws_secret_access_key.get_secret_value(),
)
model_list = litellm_model_list_without_bedrock + bedrock_model_list
for llm_config in config.llms.values():
ollama_base_url = llm_config.ollama_base_url
if llm_config.model.startswith('ollama'):
if not ollama_base_url:
ollama_base_url = llm_config.base_url
if ollama_base_url:
ollama_url = ollama_base_url.strip('/') + '/api/tags'
try:
ollama_models_list = httpx.get(ollama_url, timeout=3).json()['models'] # noqa: ASYNC100
for model in ollama_models_list:
model_list.append('ollama/' + model['name'])
break
except httpx.HTTPError as e:
logger.error(f'Error getting OLLAMA models: {e}')
return list(sorted(set(model_list)))
return get_supported_llm_models(config)
@app.get('/agents', response_model=list[str])
+1 -1
View File
@@ -15,9 +15,9 @@ from openhands.server.settings import (
GETSettingsModel,
POSTSettingsCustomSecrets,
POSTSettingsModel,
Settings,
)
from openhands.server.shared import config
from openhands.storage.data_models.settings import Settings
from openhands.server.user_auth import (
get_provider_tokens,
get_user_id,
@@ -2,7 +2,7 @@ from pydantic import Field
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.integrations.service_types import Repository
from openhands.server.settings import Settings
from openhands.storage.data_models.settings import Settings
class ConversationInitData(Settings):
+5 -1
View File
@@ -28,7 +28,7 @@ from openhands.llm.llm import LLM
from openhands.mcp import fetch_mcp_tools_from_config
from openhands.server.session.agent_session import AgentSession
from openhands.server.session.conversation_init_data import ConversationInitData
from openhands.server.settings import Settings
from openhands.storage.data_models.settings import Settings
from openhands.storage.files import FileStore
ROOM_KEY = 'room:{sid}'
@@ -237,6 +237,10 @@ class Session:
await self.send(event_dict)
async def dispatch(self, data: dict):
# Update last_active_ts when receiving data from client
# This ensures the session is considered active when the client sends data
self.last_active_ts = int(time.time())
event = event_from_dict(data.copy())
# This checks if the model supports images
if isinstance(event, MessageAction) and event.image_urls:
+1 -115
View File
@@ -2,124 +2,10 @@ from __future__ import annotations
from pydantic import (
BaseModel,
Field,
SecretStr,
SerializationInfo,
field_serializer,
model_validator,
)
from pydantic.json import pydantic_encoder
from openhands.core.config.llm_config import LLMConfig
from openhands.core.config.utils import load_app_config
from openhands.integrations.provider import SecretStore
class Settings(BaseModel):
"""
Persisted settings for OpenHands sessions
"""
language: str | None = None
agent: str | None = None
max_iterations: int | None = None
security_analyzer: str | None = None
confirmation_mode: bool | None = None
llm_model: str | None = None
llm_api_key: SecretStr | None = None
llm_base_url: str | None = None
remote_runtime_resource_factor: int | None = None
secrets_store: SecretStore = Field(default_factory=SecretStore, frozen=True)
enable_default_condenser: bool = True
enable_sound_notifications: bool = False
user_consents_to_analytics: bool | None = None
sandbox_base_container_image: str | None = None
sandbox_runtime_container_image: str | None = None
model_config = {
'validate_assignment': True,
}
@field_serializer('llm_api_key')
def llm_api_key_serializer(self, llm_api_key: SecretStr, info: SerializationInfo):
"""Custom serializer for the LLM API key.
To serialize the API key instead of ********, set expose_secrets to True in the serialization context.
"""
context = info.context
if context and context.get('expose_secrets', False):
return llm_api_key.get_secret_value()
return pydantic_encoder(llm_api_key) if llm_api_key else None
@model_validator(mode='before')
@classmethod
def convert_provider_tokens(cls, data: dict | object) -> dict | object:
"""Convert provider tokens from JSON format to SecretStore format."""
if not isinstance(data, dict):
return data
secrets_store = data.get('secrets_store')
if not isinstance(secrets_store, dict):
return data
custom_secrets = secrets_store.get('custom_secrets')
tokens = secrets_store.get('provider_tokens')
secret_store = SecretStore(provider_tokens={}, custom_secrets={})
if isinstance(tokens, dict):
converted_store = SecretStore(provider_tokens=tokens)
secret_store = secret_store.model_copy(
update={'provider_tokens': converted_store.provider_tokens}
)
else:
secret_store.model_copy(update={'provider_tokens': tokens})
if isinstance(custom_secrets, dict):
converted_store = SecretStore(custom_secrets=custom_secrets)
secret_store = secret_store.model_copy(
update={'custom_secrets': converted_store.custom_secrets}
)
else:
secret_store = secret_store.model_copy(
update={'custom_secrets': custom_secrets}
)
data['secret_store'] = secret_store
return data
@field_serializer('secrets_store')
def secrets_store_serializer(self, secrets: SecretStore, info: SerializationInfo):
"""Custom serializer for secrets store."""
return {
'provider_tokens': secrets.provider_tokens_serializer(
secrets.provider_tokens, info
),
'custom_secrets': secrets.custom_secrets_serializer(
secrets.custom_secrets, info
),
}
@staticmethod
def from_config() -> Settings | None:
app_config = load_app_config()
llm_config: LLMConfig = app_config.get_llm_config()
if llm_config.api_key is None:
# If no api key has been set, we take this to mean that there is no reasonable default
return None
security = app_config.security
settings = Settings(
language='en',
agent=app_config.default_agent,
max_iterations=app_config.max_iterations,
security_analyzer=security.security_analyzer,
confirmation_mode=security.confirmation_mode,
llm_model=llm_config.model,
llm_api_key=llm_config.api_key,
llm_base_url=llm_config.base_url,
remote_runtime_resource_factor=app_config.sandbox.remote_runtime_resource_factor,
)
return settings
from openhands.storage.data_models.settings import Settings
class POSTSettingsModel(Settings):
+122
View File
@@ -0,0 +1,122 @@
from __future__ import annotations
from pydantic import (
BaseModel,
Field,
SecretStr,
SerializationInfo,
field_serializer,
model_validator,
)
from pydantic.json import pydantic_encoder
from openhands.core.config.llm_config import LLMConfig
from openhands.core.config.utils import load_app_config
from openhands.integrations.provider import SecretStore
class Settings(BaseModel):
"""
Persisted settings for OpenHands sessions
"""
language: str | None = None
agent: str | None = None
max_iterations: int | None = None
security_analyzer: str | None = None
confirmation_mode: bool | None = None
llm_model: str | None = None
llm_api_key: SecretStr | None = None
llm_base_url: str | None = None
remote_runtime_resource_factor: int | None = None
secrets_store: SecretStore = Field(default_factory=SecretStore, frozen=True)
enable_default_condenser: bool = True
enable_sound_notifications: bool = False
user_consents_to_analytics: bool | None = None
sandbox_base_container_image: str | None = None
sandbox_runtime_container_image: str | None = None
model_config = {
'validate_assignment': True,
}
@field_serializer('llm_api_key')
def llm_api_key_serializer(self, llm_api_key: SecretStr, info: SerializationInfo):
"""Custom serializer for the LLM API key.
To serialize the API key instead of ********, set expose_secrets to True in the serialization context.
"""
context = info.context
if context and context.get('expose_secrets', False):
return llm_api_key.get_secret_value()
return pydantic_encoder(llm_api_key) if llm_api_key else None
@model_validator(mode='before')
@classmethod
def convert_provider_tokens(cls, data: dict | object) -> dict | object:
"""Convert provider tokens from JSON format to SecretStore format."""
if not isinstance(data, dict):
return data
secrets_store = data.get('secrets_store')
if not isinstance(secrets_store, dict):
return data
custom_secrets = secrets_store.get('custom_secrets')
tokens = secrets_store.get('provider_tokens')
secret_store = SecretStore(provider_tokens={}, custom_secrets={})
if isinstance(tokens, dict):
converted_store = SecretStore(provider_tokens=tokens)
secret_store = secret_store.model_copy(
update={'provider_tokens': converted_store.provider_tokens}
)
else:
secret_store.model_copy(update={'provider_tokens': tokens})
if isinstance(custom_secrets, dict):
converted_store = SecretStore(custom_secrets=custom_secrets)
secret_store = secret_store.model_copy(
update={'custom_secrets': converted_store.custom_secrets}
)
else:
secret_store = secret_store.model_copy(
update={'custom_secrets': custom_secrets}
)
data['secret_store'] = secret_store
return data
@field_serializer('secrets_store')
def secrets_store_serializer(self, secrets: SecretStore, info: SerializationInfo):
"""Custom serializer for secrets store."""
return {
'provider_tokens': secrets.provider_tokens_serializer(
secrets.provider_tokens, info
),
'custom_secrets': secrets.custom_secrets_serializer(
secrets.custom_secrets, info
),
}
@staticmethod
def from_config() -> Settings | None:
app_config = load_app_config()
llm_config: LLMConfig = app_config.get_llm_config()
if llm_config.api_key is None:
# If no api key has been set, we take this to mean that there is no reasonable default
return None
security = app_config.security
settings = Settings(
language='en',
agent=app_config.default_agent,
max_iterations=app_config.max_iterations,
security_analyzer=security.security_analyzer,
confirmation_mode=security.confirmation_mode,
llm_model=llm_config.model,
llm_api_key=llm_config.api_key,
llm_base_url=llm_config.base_url,
remote_runtime_resource_factor=app_config.sandbox.remote_runtime_resource_factor,
)
return settings
@@ -4,7 +4,7 @@ import json
from dataclasses import dataclass
from openhands.core.config.app_config import AppConfig
from openhands.server.settings import Settings
from openhands.storage.data_models.settings import Settings
from openhands.storage import get_file_store
from openhands.storage.files import FileStore
from openhands.storage.settings.settings_store import SettingsStore
+1 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from openhands.core.config.app_config import AppConfig
from openhands.server.settings import Settings
from openhands.storage.data_models.settings import Settings
class SettingsStore(ABC):
+56
View File
@@ -0,0 +1,56 @@
import warnings
import httpx
with warnings.catch_warnings():
warnings.simplefilter('ignore')
import litellm
from openhands.core.config import AppConfig, LLMConfig
from openhands.core.logger import openhands_logger as logger
from openhands.llm import bedrock
def get_supported_llm_models(config: AppConfig) -> list[str]:
"""Get all models supported by LiteLLM.
This function combines models from litellm and Bedrock, removing any
error-prone Bedrock models.
Returns:
list[str]: A sorted list of unique model names.
"""
litellm_model_list = litellm.model_list + list(litellm.model_cost.keys())
litellm_model_list_without_bedrock = bedrock.remove_error_modelId(
litellm_model_list
)
# TODO: for bedrock, this is using the default config
llm_config: LLMConfig = config.get_llm_config()
bedrock_model_list = []
if (
llm_config.aws_region_name
and llm_config.aws_access_key_id
and llm_config.aws_secret_access_key
):
bedrock_model_list = bedrock.list_foundation_models(
llm_config.aws_region_name,
llm_config.aws_access_key_id.get_secret_value(),
llm_config.aws_secret_access_key.get_secret_value(),
)
model_list = litellm_model_list_without_bedrock + bedrock_model_list
for llm_config in config.llms.values():
ollama_base_url = llm_config.ollama_base_url
if llm_config.model.startswith('ollama'):
if not ollama_base_url:
ollama_base_url = llm_config.base_url
if ollama_base_url:
ollama_url = ollama_base_url.strip('/') + '/api/tags'
try:
ollama_models_list = httpx.get(ollama_url, timeout=3).json()['models'] # noqa: ASYNC100
for model in ollama_models_list:
model_list.append('ollama/' + model['name'])
break
except httpx.HTTPError as e:
logger.error(f'Error getting OLLAMA models: {e}')
return list(sorted(set(model_list)))
Generated
+81 -69
View File
@@ -496,18 +496,18 @@ files = [
[[package]]
name = "boto3"
version = "1.38.2"
version = "1.38.3"
description = "The AWS SDK for Python"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "boto3-1.38.2-py3-none-any.whl", hash = "sha256:ef3237b169cd906a44a32c03b3229833d923c9e9733355b329ded2151f91ec0b"},
{file = "boto3-1.38.2.tar.gz", hash = "sha256:53c8d44b231251fa9421dd13d968236d59fe2cf0421e077afedbf3821653fb3b"},
{file = "boto3-1.38.3-py3-none-any.whl", hash = "sha256:9218f86e2164e1bddb75d435bbde4fa651aa58687213d7e3e1b50f7eb8868f66"},
{file = "boto3-1.38.3.tar.gz", hash = "sha256:655d51abcd68a40a33c52dbaa2ca73fc63c746b894e2ae22ed8ddc1912ddd93f"},
]
[package.dependencies]
botocore = ">=1.38.2,<1.39.0"
botocore = ">=1.38.3,<1.39.0"
jmespath = ">=0.7.1,<2.0.0"
s3transfer = ">=0.12.0,<0.13.0"
@@ -516,14 +516,14 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
[[package]]
name = "boto3-stubs"
version = "1.38.2"
description = "Type annotations for boto3 1.38.2 generated with mypy-boto3-builder 8.10.1"
version = "1.38.3"
description = "Type annotations for boto3 1.38.3 generated with mypy-boto3-builder 8.10.1"
optional = false
python-versions = ">=3.8"
groups = ["evaluation"]
files = [
{file = "boto3_stubs-1.38.2-py3-none-any.whl", hash = "sha256:e18f2dc194c4b8a29f61275ba039689d063c4775a78560e35a5ce820ec257fb5"},
{file = "boto3_stubs-1.38.2.tar.gz", hash = "sha256:405cd777d41530cf8ed009d20b04daef1f7d4bd2fd9fd3636ac86eccdb55159c"},
{file = "boto3_stubs-1.38.3-py3-none-any.whl", hash = "sha256:93a2c38987dd0ee19a661e8fd9a77fb4b4a30e56f63115701c307bfc55e2695c"},
{file = "boto3_stubs-1.38.3.tar.gz", hash = "sha256:e406626de8daf537984678355ad0e32d838865c4ea3d223268964d4e6fb44534"},
]
[package.dependencies]
@@ -579,7 +579,7 @@ bedrock-data-automation-runtime = ["mypy-boto3-bedrock-data-automation-runtime (
bedrock-runtime = ["mypy-boto3-bedrock-runtime (>=1.38.0,<1.39.0)"]
billing = ["mypy-boto3-billing (>=1.38.0,<1.39.0)"]
billingconductor = ["mypy-boto3-billingconductor (>=1.38.0,<1.39.0)"]
boto3 = ["boto3 (==1.38.2)"]
boto3 = ["boto3 (==1.38.3)"]
braket = ["mypy-boto3-braket (>=1.38.0,<1.39.0)"]
budgets = ["mypy-boto3-budgets (>=1.38.0,<1.39.0)"]
ce = ["mypy-boto3-ce (>=1.38.0,<1.39.0)"]
@@ -943,14 +943,14 @@ xray = ["mypy-boto3-xray (>=1.38.0,<1.39.0)"]
[[package]]
name = "botocore"
version = "1.38.2"
version = "1.38.3"
description = "Low-level, data-driven core of boto 3."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "botocore-1.38.2-py3-none-any.whl", hash = "sha256:5d9cffedb1c759a058b43793d16647ed44ec87072f98a1bd6cd673ac0ae6b81d"},
{file = "botocore-1.38.2.tar.gz", hash = "sha256:b688a9bd17211a1eaae3a6c965ba9f3973e5435efaaa4fa201f499d3467830e1"},
{file = "botocore-1.38.3-py3-none-any.whl", hash = "sha256:96f823240fe3704b99c17d1d1b2fd2d1679cf56d2a55b095f00255b76087cbf0"},
{file = "botocore-1.38.3.tar.gz", hash = "sha256:790f8f966201781f5fcf486d48b4492e9f734446bbf9d19ef8159d08be854243"},
]
[package.dependencies]
@@ -3794,14 +3794,14 @@ files = [
[[package]]
name = "json-repair"
version = "0.42.0"
version = "0.43.0"
description = "A package to repair broken json strings"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "json_repair-0.42.0-py3-none-any.whl", hash = "sha256:7b6805162053dfe65722e961bc51b5eecec0582ec8a8e0fd218d33e8de757daf"},
{file = "json_repair-0.42.0.tar.gz", hash = "sha256:1a901f706c5b6b4325f0f79b53b0d998c5b327070e98b530da71cc5a3eda8616"},
{file = "json_repair-0.43.0-py3-none-any.whl", hash = "sha256:3f2b66819c9f5e29edd5dd4851223b72d10ed816b6423b3c92e424090c3ffc1d"},
{file = "json_repair-0.43.0.tar.gz", hash = "sha256:77cc6eda6f407ff5fe9544f962e42b332cca1e8c9f3f9f9dc660327028e0d651"},
]
[[package]]
@@ -4413,14 +4413,13 @@ types-tqdm = "*"
[[package]]
name = "litellm"
version = "1.67.2"
version = "1.67.4.post1"
description = "Library to easily interface with LLM API providers"
optional = false
python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8"
groups = ["main"]
files = [
{file = "litellm-1.67.2-py3-none-any.whl", hash = "sha256:32df4d17b3ead17d04793311858965e41e83a7bdf9bd661895c0e6bc9c78dc8b"},
{file = "litellm-1.67.2.tar.gz", hash = "sha256:9e108827bff16af04fd4c35b0c1a1d6c7746c96db3870189a60141d449797487"},
{file = "litellm-1.67.4.post1.tar.gz", hash = "sha256:057f2505f82d8c3f83d705c375b0d1931de998b13e239a6b06e16ee351fda648"},
]
[package.dependencies]
@@ -4438,7 +4437,7 @@ tokenizers = "*"
[package.extras]
extra-proxy = ["azure-identity (>=1.15.0,<2.0.0)", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (==0.11.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0,<0.9.0)"]
proxy = ["PyJWT (>=2.8.0,<3.0.0)", "apscheduler (>=3.10.4,<4.0.0)", "backoff", "boto3 (==1.34.34)", "cryptography (>=43.0.1,<44.0.0)", "fastapi (>=0.115.5,<0.116.0)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-proxy-extras (==0.1.11)", "mcp (==1.5.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rq", "uvicorn (>=0.29.0,<0.30.0)", "uvloop (>=0.21.0,<0.22.0)", "websockets (>=13.1.0,<14.0.0)"]
proxy = ["PyJWT (>=2.8.0,<3.0.0)", "apscheduler (>=3.10.4,<4.0.0)", "backoff", "boto3 (==1.34.34)", "cryptography (>=43.0.1,<44.0.0)", "fastapi (>=0.115.5,<0.116.0)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-proxy-extras (==0.1.13)", "mcp (==1.5.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rq", "uvicorn (>=0.29.0,<0.30.0)", "uvloop (>=0.21.0,<0.22.0)", "websockets (>=13.1.0,<14.0.0)"]
[[package]]
name = "lxml"
@@ -4883,14 +4882,14 @@ files = [
[[package]]
name = "modal"
version = "0.74.23"
version = "0.74.30"
description = "Python client library for Modal"
optional = false
python-versions = ">=3.9"
groups = ["main", "evaluation"]
files = [
{file = "modal-0.74.23-py3-none-any.whl", hash = "sha256:96c397487ed5f499ad040b5edf5f378ada8e0676da17523a2d6fadb3f1d384e1"},
{file = "modal-0.74.23.tar.gz", hash = "sha256:3a042cdf482975b43341da0b33fa6a6adae06978ead69a086ca658a7dcb0cd6d"},
{file = "modal-0.74.30-py3-none-any.whl", hash = "sha256:46006cb57309171fe36ee41528a7cc8c0e67c88afd9bf04a9900313c18925aa4"},
{file = "modal-0.74.30.tar.gz", hash = "sha256:14bd2ea0ebc9ab1ebce29ea76ddf12047f23599983725c5f82990ae97bea05c7"},
]
[package.dependencies]
@@ -6344,54 +6343,67 @@ files = [
[[package]]
name = "pyarrow"
version = "19.0.1"
version = "20.0.0"
description = "Python library for Apache Arrow"
optional = false
python-versions = ">=3.9"
groups = ["evaluation"]
files = [
{file = "pyarrow-19.0.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:fc28912a2dc924dddc2087679cc8b7263accc71b9ff025a1362b004711661a69"},
{file = "pyarrow-19.0.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:fca15aabbe9b8355800d923cc2e82c8ef514af321e18b437c3d782aa884eaeec"},
{file = "pyarrow-19.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad76aef7f5f7e4a757fddcdcf010a8290958f09e3470ea458c80d26f4316ae89"},
{file = "pyarrow-19.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d03c9d6f2a3dffbd62671ca070f13fc527bb1867b4ec2b98c7eeed381d4f389a"},
{file = "pyarrow-19.0.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:65cf9feebab489b19cdfcfe4aa82f62147218558d8d3f0fc1e9dea0ab8e7905a"},
{file = "pyarrow-19.0.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:41f9706fbe505e0abc10e84bf3a906a1338905cbbcf1177b71486b03e6ea6608"},
{file = "pyarrow-19.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6cb2335a411b713fdf1e82a752162f72d4a7b5dbc588e32aa18383318b05866"},
{file = "pyarrow-19.0.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:cc55d71898ea30dc95900297d191377caba257612f384207fe9f8293b5850f90"},
{file = "pyarrow-19.0.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:7a544ec12de66769612b2d6988c36adc96fb9767ecc8ee0a4d270b10b1c51e00"},
{file = "pyarrow-19.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0148bb4fc158bfbc3d6dfe5001d93ebeed253793fff4435167f6ce1dc4bddeae"},
{file = "pyarrow-19.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f24faab6ed18f216a37870d8c5623f9c044566d75ec586ef884e13a02a9d62c5"},
{file = "pyarrow-19.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:4982f8e2b7afd6dae8608d70ba5bd91699077323f812a0448d8b7abdff6cb5d3"},
{file = "pyarrow-19.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:49a3aecb62c1be1d822f8bf629226d4a96418228a42f5b40835c1f10d42e4db6"},
{file = "pyarrow-19.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:008a4009efdb4ea3d2e18f05cd31f9d43c388aad29c636112c2966605ba33466"},
{file = "pyarrow-19.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:80b2ad2b193e7d19e81008a96e313fbd53157945c7be9ac65f44f8937a55427b"},
{file = "pyarrow-19.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:ee8dec072569f43835932a3b10c55973593abc00936c202707a4ad06af7cb294"},
{file = "pyarrow-19.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d5d1ec7ec5324b98887bdc006f4d2ce534e10e60f7ad995e7875ffa0ff9cb14"},
{file = "pyarrow-19.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3ad4c0eb4e2a9aeb990af6c09e6fa0b195c8c0e7b272ecc8d4d2b6574809d34"},
{file = "pyarrow-19.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d383591f3dcbe545f6cc62daaef9c7cdfe0dff0fb9e1c8121101cabe9098cfa6"},
{file = "pyarrow-19.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b4c4156a625f1e35d6c0b2132635a237708944eb41df5fbe7d50f20d20c17832"},
{file = "pyarrow-19.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:5bd1618ae5e5476b7654c7b55a6364ae87686d4724538c24185bbb2952679960"},
{file = "pyarrow-19.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e45274b20e524ae5c39d7fc1ca2aa923aab494776d2d4b316b49ec7572ca324c"},
{file = "pyarrow-19.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:d9dedeaf19097a143ed6da37f04f4051aba353c95ef507764d344229b2b740ae"},
{file = "pyarrow-19.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ebfb5171bb5f4a52319344ebbbecc731af3f021e49318c74f33d520d31ae0c4"},
{file = "pyarrow-19.0.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a21d39fbdb948857f67eacb5bbaaf36802de044ec36fbef7a1c8f0dd3a4ab2"},
{file = "pyarrow-19.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:99bc1bec6d234359743b01e70d4310d0ab240c3d6b0da7e2a93663b0158616f6"},
{file = "pyarrow-19.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1b93ef2c93e77c442c979b0d596af45e4665d8b96da598db145b0fec014b9136"},
{file = "pyarrow-19.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9d46e06846a41ba906ab25302cf0fd522f81aa2a85a71021826f34639ad31ef"},
{file = "pyarrow-19.0.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:c0fe3dbbf054a00d1f162fda94ce236a899ca01123a798c561ba307ca38af5f0"},
{file = "pyarrow-19.0.1-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:96606c3ba57944d128e8a8399da4812f56c7f61de8c647e3470b417f795d0ef9"},
{file = "pyarrow-19.0.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f04d49a6b64cf24719c080b3c2029a3a5b16417fd5fd7c4041f94233af732f3"},
{file = "pyarrow-19.0.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a9137cf7e1640dce4c190551ee69d478f7121b5c6f323553b319cac936395f6"},
{file = "pyarrow-19.0.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:7c1bca1897c28013db5e4c83944a2ab53231f541b9e0c3f4791206d0c0de389a"},
{file = "pyarrow-19.0.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:58d9397b2e273ef76264b45531e9d552d8ec8a6688b7390b5be44c02a37aade8"},
{file = "pyarrow-19.0.1-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:b9766a47a9cb56fefe95cb27f535038b5a195707a08bf61b180e642324963b46"},
{file = "pyarrow-19.0.1-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:6c5941c1aac89a6c2f2b16cd64fe76bcdb94b2b1e99ca6459de4e6f07638d755"},
{file = "pyarrow-19.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd44d66093a239358d07c42a91eebf5015aa54fccba959db899f932218ac9cc8"},
{file = "pyarrow-19.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:335d170e050bcc7da867a1ed8ffb8b44c57aaa6e0843b156a501298657b1e972"},
{file = "pyarrow-19.0.1-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:1c7556165bd38cf0cd992df2636f8bcdd2d4b26916c6b7e646101aff3c16f76f"},
{file = "pyarrow-19.0.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:699799f9c80bebcf1da0983ba86d7f289c5a2a5c04b945e2f2bcf7e874a91911"},
{file = "pyarrow-19.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:8464c9fbe6d94a7fe1599e7e8965f350fd233532868232ab2596a71586c5a429"},
{file = "pyarrow-19.0.1.tar.gz", hash = "sha256:3bf266b485df66a400f282ac0b6d1b500b9d2ae73314a153dbe97d6d5cc8a99e"},
{file = "pyarrow-20.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:c7dd06fd7d7b410ca5dc839cc9d485d2bc4ae5240851bcd45d85105cc90a47d7"},
{file = "pyarrow-20.0.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:d5382de8dc34c943249b01c19110783d0d64b207167c728461add1ecc2db88e4"},
{file = "pyarrow-20.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6415a0d0174487456ddc9beaead703d0ded5966129fa4fd3114d76b5d1c5ceae"},
{file = "pyarrow-20.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15aa1b3b2587e74328a730457068dc6c89e6dcbf438d4369f572af9d320a25ee"},
{file = "pyarrow-20.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:5605919fbe67a7948c1f03b9f3727d82846c053cd2ce9303ace791855923fd20"},
{file = "pyarrow-20.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a5704f29a74b81673d266e5ec1fe376f060627c2e42c5c7651288ed4b0db29e9"},
{file = "pyarrow-20.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:00138f79ee1b5aca81e2bdedb91e3739b987245e11fa3c826f9e57c5d102fb75"},
{file = "pyarrow-20.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f2d67ac28f57a362f1a2c1e6fa98bfe2f03230f7e15927aecd067433b1e70ce8"},
{file = "pyarrow-20.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:4a8b029a07956b8d7bd742ffca25374dd3f634b35e46cc7a7c3fa4c75b297191"},
{file = "pyarrow-20.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:24ca380585444cb2a31324c546a9a56abbe87e26069189e14bdba19c86c049f0"},
{file = "pyarrow-20.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:95b330059ddfdc591a3225f2d272123be26c8fa76e8c9ee1a77aad507361cfdb"},
{file = "pyarrow-20.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f0fb1041267e9968c6d0d2ce3ff92e3928b243e2b6d11eeb84d9ac547308232"},
{file = "pyarrow-20.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8ff87cc837601532cc8242d2f7e09b4e02404de1b797aee747dd4ba4bd6313f"},
{file = "pyarrow-20.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:7a3a5dcf54286e6141d5114522cf31dd67a9e7c9133d150799f30ee302a7a1ab"},
{file = "pyarrow-20.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a6ad3e7758ecf559900261a4df985662df54fb7fdb55e8e3b3aa99b23d526b62"},
{file = "pyarrow-20.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6bb830757103a6cb300a04610e08d9636f0cd223d32f388418ea893a3e655f1c"},
{file = "pyarrow-20.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:96e37f0766ecb4514a899d9a3554fadda770fb57ddf42b63d80f14bc20aa7db3"},
{file = "pyarrow-20.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:3346babb516f4b6fd790da99b98bed9708e3f02e734c84971faccb20736848dc"},
{file = "pyarrow-20.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:75a51a5b0eef32727a247707d4755322cb970be7e935172b6a3a9f9ae98404ba"},
{file = "pyarrow-20.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:211d5e84cecc640c7a3ab900f930aaff5cd2702177e0d562d426fb7c4f737781"},
{file = "pyarrow-20.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ba3cf4182828be7a896cbd232aa8dd6a31bd1f9e32776cc3796c012855e1199"},
{file = "pyarrow-20.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c3a01f313ffe27ac4126f4c2e5ea0f36a5fc6ab51f8726cf41fee4b256680bd"},
{file = "pyarrow-20.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:a2791f69ad72addd33510fec7bb14ee06c2a448e06b649e264c094c5b5f7ce28"},
{file = "pyarrow-20.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4250e28a22302ce8692d3a0e8ec9d9dde54ec00d237cff4dfa9c1fbf79e472a8"},
{file = "pyarrow-20.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:89e030dc58fc760e4010148e6ff164d2f44441490280ef1e97a542375e41058e"},
{file = "pyarrow-20.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6102b4864d77102dbbb72965618e204e550135a940c2534711d5ffa787df2a5a"},
{file = "pyarrow-20.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:96d6a0a37d9c98be08f5ed6a10831d88d52cac7b13f5287f1e0f625a0de8062b"},
{file = "pyarrow-20.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a15532e77b94c61efadde86d10957950392999503b3616b2ffcef7621a002893"},
{file = "pyarrow-20.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:dd43f58037443af715f34f1322c782ec463a3c8a94a85fdb2d987ceb5658e061"},
{file = "pyarrow-20.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa0d288143a8585806e3cc7c39566407aab646fb9ece164609dac1cfff45f6ae"},
{file = "pyarrow-20.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6953f0114f8d6f3d905d98e987d0924dabce59c3cda380bdfaa25a6201563b4"},
{file = "pyarrow-20.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:991f85b48a8a5e839b2128590ce07611fae48a904cae6cab1f089c5955b57eb5"},
{file = "pyarrow-20.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:97c8dc984ed09cb07d618d57d8d4b67a5100a30c3818c2fb0b04599f0da2de7b"},
{file = "pyarrow-20.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9b71daf534f4745818f96c214dbc1e6124d7daf059167330b610fc69b6f3d3e3"},
{file = "pyarrow-20.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8b88758f9303fa5a83d6c90e176714b2fd3852e776fc2d7e42a22dd6c2fb368"},
{file = "pyarrow-20.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:30b3051b7975801c1e1d387e17c588d8ab05ced9b1e14eec57915f79869b5031"},
{file = "pyarrow-20.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:ca151afa4f9b7bc45bcc791eb9a89e90a9eb2772767d0b1e5389609c7d03db63"},
{file = "pyarrow-20.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:4680f01ecd86e0dd63e39eb5cd59ef9ff24a9d166db328679e36c108dc993d4c"},
{file = "pyarrow-20.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f4c8534e2ff059765647aa69b75d6543f9fef59e2cd4c6d18015192565d2b70"},
{file = "pyarrow-20.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e1f8a47f4b4ae4c69c4d702cfbdfe4d41e18e5c7ef6f1bb1c50918c1e81c57b"},
{file = "pyarrow-20.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:a1f60dc14658efaa927f8214734f6a01a806d7690be4b3232ba526836d216122"},
{file = "pyarrow-20.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:204a846dca751428991346976b914d6d2a82ae5b8316a6ed99789ebf976551e6"},
{file = "pyarrow-20.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f3b117b922af5e4c6b9a9115825726cac7d8b1421c37c2b5e24fbacc8930612c"},
{file = "pyarrow-20.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e724a3fd23ae5b9c010e7be857f4405ed5e679db5c93e66204db1a69f733936a"},
{file = "pyarrow-20.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:82f1ee5133bd8f49d31be1299dc07f585136679666b502540db854968576faf9"},
{file = "pyarrow-20.0.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:1bcbe471ef3349be7714261dea28fe280db574f9d0f77eeccc195a2d161fd861"},
{file = "pyarrow-20.0.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:a18a14baef7d7ae49247e75641fd8bcbb39f44ed49a9fc4ec2f65d5031aa3b96"},
{file = "pyarrow-20.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb497649e505dc36542d0e68eca1a3c94ecbe9799cb67b578b55f2441a247fbc"},
{file = "pyarrow-20.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11529a2283cb1f6271d7c23e4a8f9f8b7fd173f7360776b668e509d712a02eec"},
{file = "pyarrow-20.0.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:6fc1499ed3b4b57ee4e090e1cea6eb3584793fe3d1b4297bbf53f09b434991a5"},
{file = "pyarrow-20.0.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:db53390eaf8a4dab4dbd6d93c85c5cf002db24902dbff0ca7d988beb5c9dd15b"},
{file = "pyarrow-20.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:851c6a8260ad387caf82d2bbf54759130534723e37083111d4ed481cb253cc0d"},
{file = "pyarrow-20.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e22f80b97a271f0a7d9cd07394a7d348f80d3ac63ed7cc38b6d1b696ab3b2619"},
{file = "pyarrow-20.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:9965a050048ab02409fb7cbbefeedba04d3d67f2cc899eff505cc084345959ca"},
{file = "pyarrow-20.0.0.tar.gz", hash = "sha256:febc4a913592573c8d5805091a6c2b5064c8bd6e002131f01061797d91c783c1"},
]
[package.extras]
@@ -7978,14 +7990,14 @@ files = [
[[package]]
name = "runloop-api-client"
version = "0.31.0"
version = "0.32.0"
description = "The official Python library for the runloop API"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "runloop_api_client-0.31.0-py3-none-any.whl", hash = "sha256:1eb716a20b268e081bdbcf5b5d1df9ab6eb258a0b929130210a3b643048159c7"},
{file = "runloop_api_client-0.31.0.tar.gz", hash = "sha256:78992595fd34f98470aa73b8f5b92983414e4878218239e531a9371c5570a13d"},
{file = "runloop_api_client-0.32.0-py3-none-any.whl", hash = "sha256:37f156f711b1aa4cef86c0f779cc27afa43ce3d3f6b1976d7f68667466317a6d"},
{file = "runloop_api_client-0.32.0.tar.gz", hash = "sha256:735a967d96b5c3e8a08b89072722adcbe2b10ed904268d3f45785b7cfd5420d1"},
]
[package.dependencies]
@@ -10257,4 +10269,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"]
[metadata]
lock-version = "2.1"
python-versions = "^3.12"
content-hash = "e0d99d8657168051347da0ebbeb0ff23b3c035149627253736cf9d2ec3930435"
content-hash = "d3f933e9abf6be481ec137e14f8f7ac502afd591a9ba74b315737fd894ca5cfe"
+3 -3
View File
@@ -14,7 +14,7 @@ packages = [
[tool.poetry.dependencies]
python = "^3.12"
litellm = "^1.60.0"
litellm = "^1.60.0, !=1.64.4" # avoid 1.64.4 (known bug)
aiohttp = ">=3.9.0,!=3.11.13" # Pin to avoid yanked version 3.11.13
google-generativeai = "*" # To use litellm with Gemini Pro API
google-api-python-client = "^2.164.0" # For Google Sheets API
@@ -58,7 +58,7 @@ protobuf = "^4.21.6,<5.0.0" # chromadb currently fails on 5.0+
opentelemetry-api = "1.25.0"
opentelemetry-exporter-otlp-proto-grpc = "1.25.0"
modal = ">=0.66.26,<0.75.0"
runloop-api-client = "0.31.0"
runloop-api-client = "0.32.0"
libtmux = ">=0.37,<0.40"
pygithub = "^2.5.0"
joblib = "*"
@@ -146,7 +146,7 @@ browsergym-webarena = "0.13.3"
browsergym-miniwob = "0.13.3"
browsergym-visualwebarena = "0.13.3"
boto3-stubs = {extras = ["s3"], version = "^1.37.19"}
pyarrow = "19.0.1" # transitive dependency, pinned here to avoid conflicts
pyarrow = "20.0.0" # transitive dependency, pinned here to avoid conflicts
datasets = "*"
[tool.poetry-dynamic-versioning]
+6 -36
View File
@@ -7,7 +7,7 @@ from conftest import (
_load_runtime,
)
from openhands.microagent import KnowledgeMicroagent, RepoMicroagent, TaskMicroagent
from openhands.microagent import KnowledgeMicroagent, RepoMicroagent
def _create_test_microagents(test_dir: str):
@@ -48,22 +48,6 @@ Repository-specific test instructions.
"""
(microagents_dir / 'repo.md').write_text(repo_agent)
# Create test task agent in a nested directory
task_dir = microagents_dir / 'tasks' / 'nested'
task_dir.mkdir(parents=True, exist_ok=True)
task_agent = """---
name: test_task
type: task
version: 1.0.0
agent: CodeActAgent
---
# Test Task
Test task content
"""
(task_dir / 'task.md').write_text(task_agent)
# Create legacy repo instructions
legacy_instructions = """# Legacy Instructions
@@ -88,26 +72,20 @@ def test_load_microagents_with_trailing_slashes(
a for a in loaded_agents if isinstance(a, KnowledgeMicroagent)
]
repo_agents = [a for a in loaded_agents if isinstance(a, RepoMicroagent)]
task_agents = [a for a in loaded_agents if isinstance(a, TaskMicroagent)]
# Check knowledge agents
assert len(knowledge_agents) == 1
agent = knowledge_agents[0]
assert agent.name == 'test_knowledge_agent'
assert agent.name == 'knowledge/knowledge'
assert 'test' in agent.triggers
assert 'pytest' in agent.triggers
# Check repo agents (including legacy)
assert len(repo_agents) == 2 # repo.md + .openhands_instructions
repo_names = {a.name for a in repo_agents}
assert 'test_repo_agent' in repo_names
assert 'repo' in repo_names
assert 'repo_legacy' in repo_names
# Check task agents
assert len(task_agents) == 1
agent = task_agents[0]
assert agent.name == 'test_task'
finally:
_close_test_runtime(runtime)
@@ -131,26 +109,20 @@ def test_load_microagents_with_selected_repo(temp_dir, runtime_cls, run_as_openh
a for a in loaded_agents if isinstance(a, KnowledgeMicroagent)
]
repo_agents = [a for a in loaded_agents if isinstance(a, RepoMicroagent)]
task_agents = [a for a in loaded_agents if isinstance(a, TaskMicroagent)]
# Check knowledge agents
assert len(knowledge_agents) == 1
agent = knowledge_agents[0]
assert agent.name == 'test_knowledge_agent'
assert agent.name == 'knowledge/knowledge'
assert 'test' in agent.triggers
assert 'pytest' in agent.triggers
# Check repo agents (including legacy)
assert len(repo_agents) == 2 # repo.md + .openhands_instructions
repo_names = {a.name for a in repo_agents}
assert 'test_repo_agent' in repo_names
assert 'repo' in repo_names
assert 'repo_legacy' in repo_names
# Check task agents
assert len(task_agents) == 1
agent = task_agents[0]
assert agent.name == 'test_task'
finally:
_close_test_runtime(runtime)
@@ -184,14 +156,12 @@ Repository-specific test instructions.
a for a in loaded_agents if isinstance(a, KnowledgeMicroagent)
]
repo_agents = [a for a in loaded_agents if isinstance(a, RepoMicroagent)]
task_agents = [a for a in loaded_agents if isinstance(a, TaskMicroagent)]
assert len(knowledge_agents) == 0
assert len(repo_agents) == 1
assert len(task_agents) == 0
agent = repo_agents[0]
assert agent.name == 'test_repo_agent'
assert agent.name == 'repo'
finally:
_close_test_runtime(runtime)
+172 -148
View File
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from openhands.core.config import LLMConfig
from openhands.events.action import CmdRunAction
from openhands.events.action import CmdRunAction, MessageAction
from openhands.events.observation import (
CmdOutputMetadata,
CmdOutputObservation,
@@ -19,14 +19,47 @@ from openhands.resolver.interfaces.issue_definitions import (
ServiceContextIssue,
ServiceContextPR,
)
from openhands.resolver.resolve_issue import (
complete_runtime,
initialize_runtime,
process_issue,
)
from openhands.resolver.resolve_issue import IssueResolver
from openhands.resolver.resolver_output import ResolverOutput
@pytest.fixture
def default_mock_args():
"""Fixture that provides a default mock args object with common values.
Tests can override specific attributes as needed.
"""
mock_args = MagicMock()
mock_args.selected_repo = 'test-owner/test-repo'
mock_args.token = 'test-token'
mock_args.username = 'test-user'
mock_args.max_iterations = 5
mock_args.output_dir = '/tmp'
mock_args.llm_model = 'test'
mock_args.llm_api_key = 'test'
mock_args.llm_base_url = None
mock_args.base_domain = None
mock_args.runtime_container_image = None
mock_args.base_container_image = None
mock_args.is_experimental = False
mock_args.issue_number = None
mock_args.comment_id = None
mock_args.repo_instruction_file = None
mock_args.issue_type = 'issue'
mock_args.prompt_file = None
return mock_args
@pytest.fixture
def mock_github_token():
"""Fixture that patches the identify_token function to return GitHub provider type.
This eliminates the need for repeated patching in each test function.
"""
with patch('openhands.resolver.resolve_issue.identify_token', return_value=ProviderType.GITHUB) as patched:
yield patched
@pytest.fixture
def mock_output_dir():
with tempfile.TemporaryDirectory() as temp_dir:
@@ -72,7 +105,7 @@ def create_cmd_output(exit_code: int, content: str, command: str):
)
def test_initialize_runtime():
def test_initialize_runtime(default_mock_args, mock_github_token):
mock_runtime = MagicMock()
mock_runtime.run_action.side_effect = [
create_cmd_output(exit_code=0, content='', command='cd /workspace'),
@@ -81,7 +114,10 @@ def test_initialize_runtime():
),
]
initialize_runtime(mock_runtime, ProviderType.GITHUB)
# Create resolver with mocked token identification
resolver = IssueResolver(default_mock_args)
resolver.initialize_runtime(mock_runtime)
assert mock_runtime.run_action.call_count == 2
mock_runtime.run_action.assert_any_call(CmdRunAction(command='cd /workspace'))
@@ -91,40 +127,32 @@ def test_initialize_runtime():
@pytest.mark.asyncio
async def test_resolve_issue_no_issues_found():
from openhands.resolver.resolve_issue import resolve_issue
async def test_resolve_issue_no_issues_found(default_mock_args, mock_github_token):
"""Test the resolve_issue method when no issues are found."""
# Mock dependencies
mock_handler = MagicMock()
mock_handler.get_converted_issues.return_value = [] # Return empty list
with patch(
'openhands.resolver.resolve_issue.issue_handler_factory',
return_value=mock_handler,
):
with pytest.raises(ValueError) as exc_info:
await resolve_issue(
owner='test-owner',
repo='test-repo',
token='test-token',
username='test-user',
platform=ProviderType.GITHUB,
max_iterations=5,
output_dir='/tmp',
llm_config=LLMConfig(model='test', api_key='test'),
base_container_image='test-image',
runtime_container_image='test-image',
prompt_template='test-template',
issue_type='pr',
repo_instruction=None,
issue_number=5432,
comment_id=None,
)
# Customize the mock args for this test
default_mock_args.issue_number = 5432
assert 'No issues found for issue number 5432' in str(exc_info.value)
assert 'test-owner/test-repo' in str(exc_info.value)
assert 'exists in the repository' in str(exc_info.value)
assert 'correct permissions' in str(exc_info.value)
# Create a resolver instance with mocked token identification
resolver = IssueResolver(default_mock_args)
# Mock the issue_handler_factory method
resolver.issue_handler_factory = MagicMock(return_value=mock_handler)
# Test that the correct exception is raised
with pytest.raises(ValueError) as exc_info:
await resolver.resolve_issue()
# Verify the error message
assert 'No issues found for issue number 5432' in str(exc_info.value)
assert 'test-owner/test-repo' in str(exc_info.value)
# Verify that the handler was correctly configured and called
resolver.issue_handler_factory.assert_called_once()
mock_handler.get_converted_issues.assert_called_once_with(issue_numbers=[5432], comment_id=None)
def test_download_issues_from_github():
@@ -298,7 +326,8 @@ def test_download_pr_from_github():
@pytest.mark.asyncio
async def test_complete_runtime():
async def test_complete_runtime(default_mock_args, mock_github_token):
"""Test the complete_runtime method."""
mock_runtime = MagicMock()
mock_runtime.run_action.side_effect = [
create_cmd_output(exit_code=0, content='', command='cd /workspace'),
@@ -316,8 +345,11 @@ async def test_complete_runtime():
create_cmd_output(exit_code=0, content='git diff content', command='git apply'),
]
result = await complete_runtime(
mock_runtime, 'base_commit_hash', ProviderType.GITHUB
# Create resolver with mocked token identification
resolver = IssueResolver(default_mock_args)
result = await resolver.complete_runtime(
mock_runtime, 'base_commit_hash'
)
assert result == {'git_patch': 'git diff content'}
@@ -325,31 +357,9 @@ async def test_complete_runtime():
@pytest.mark.asyncio
async def test_process_issue(mock_output_dir, mock_prompt_template):
# Mock dependencies
mock_create_runtime = MagicMock()
mock_initialize_runtime = AsyncMock()
mock_run_controller = AsyncMock()
mock_complete_runtime = AsyncMock()
handler_instance = MagicMock()
# Set up test data
issue = Issue(
owner='test_owner',
repo='test_repo',
number=1,
title='Test Issue',
body='This is a test issue',
)
base_commit = 'abcdef1234567890'
repo_instruction = 'Resolve this repo'
max_iterations = 5
llm_config = LLMConfig(model='test_model', api_key='test_api_key')
base_container_image = 'test_image:latest'
runtime_container_image = 'test_image:latest'
# Test cases for different scenarios
test_cases = [
@pytest.mark.parametrize(
"test_case",
[
{
'name': 'successful_run',
'run_controller_return': MagicMock(
@@ -363,6 +373,8 @@ async def test_process_issue(mock_output_dir, mock_prompt_template):
'expected_success': True,
'expected_error': None,
'expected_explanation': 'Issue resolved successfully',
'is_pr': False,
'comment_success': None,
},
{
'name': 'value_error',
@@ -371,6 +383,8 @@ async def test_process_issue(mock_output_dir, mock_prompt_template):
'expected_success': False,
'expected_error': 'Agent failed to run or crashed',
'expected_explanation': 'Agent failed to run',
'is_pr': False,
'comment_success': None,
},
{
'name': 'runtime_error',
@@ -379,6 +393,8 @@ async def test_process_issue(mock_output_dir, mock_prompt_template):
'expected_success': False,
'expected_error': 'Agent failed to run or crashed',
'expected_explanation': 'Agent failed to run',
'is_pr': False,
'comment_success': None,
},
{
'name': 'json_decode_error',
@@ -394,93 +410,101 @@ async def test_process_issue(mock_output_dir, mock_prompt_template):
'expected_error': None,
'expected_explanation': 'Non-JSON explanation',
'is_pr': True,
'comment_success': [
True,
False,
], # To trigger the PR success logging code path
'comment_success': [True, False], # To trigger the PR success logging code path
},
]
],
)
async def test_process_issue(default_mock_args, mock_github_token, mock_output_dir, mock_prompt_template, test_case):
"""Test the process_issue method with different scenarios."""
for test_case in test_cases:
# Reset mocks
mock_create_runtime.reset_mock()
mock_initialize_runtime.reset_mock()
mock_run_controller.reset_mock()
mock_complete_runtime.reset_mock()
handler_instance.reset_mock()
# Set up test data
issue = Issue(
owner='test_owner',
repo='test_repo',
number=1,
title='Test Issue',
body='This is a test issue',
)
base_commit = 'abcdef1234567890'
# Customize the mock args for this test
default_mock_args.output_dir = mock_output_dir
default_mock_args.issue_type = 'pr' if test_case.get('is_pr', False) else 'issue'
# Mock return values
mock_create_runtime.return_value = MagicMock(connect=AsyncMock())
if test_case['run_controller_raises']:
mock_run_controller.side_effect = test_case['run_controller_raises']
# Create a resolver instance with mocked token identification
resolver = IssueResolver(default_mock_args)
resolver.prompt_template = mock_prompt_template
# Mock the handler
handler_instance = MagicMock()
handler_instance.guess_success.return_value = (
test_case['expected_success'],
test_case.get('comment_success', None),
test_case['expected_explanation'],
)
handler_instance.get_instruction.return_value = ('Test instruction', [])
handler_instance.issue_type = 'pr' if test_case.get('is_pr', False) else 'issue'
# Mock the runtime and its methods
mock_runtime = MagicMock()
mock_runtime.connect = AsyncMock()
mock_runtime.run_action.return_value = CmdOutputObservation(
content='test patch',
command='git diff',
metadata=CmdOutputMetadata(exit_code=0),
)
mock_runtime.event_stream.subscribe = MagicMock()
# Mock the create_runtime function
mock_create_runtime = MagicMock(return_value=mock_runtime)
# Mock the run_controller function
mock_run_controller = AsyncMock()
if test_case['run_controller_raises']:
mock_run_controller.side_effect = test_case['run_controller_raises']
else:
mock_run_controller.return_value = test_case['run_controller_return']
# Patch the necessary functions and methods
with patch('openhands.resolver.resolve_issue.create_runtime', mock_create_runtime), \
patch('openhands.resolver.resolve_issue.run_controller', mock_run_controller), \
patch.object(resolver, 'complete_runtime', return_value={'git_patch': 'test patch'}), \
patch.object(resolver, 'initialize_runtime') as mock_initialize_runtime:
# Call the process_issue method
result = await resolver.process_issue(issue, base_commit, handler_instance)
# Assert the result matches our expectations
assert isinstance(result, ResolverOutput)
assert result.issue == issue
assert result.base_commit == base_commit
assert result.git_patch == 'test patch'
assert result.success == test_case['expected_success']
assert result.result_explanation == test_case['expected_explanation']
assert result.error == test_case['expected_error']
# Assert that the mocked functions were called
mock_create_runtime.assert_called_once()
mock_runtime.connect.assert_called_once()
mock_initialize_runtime.assert_called_once()
mock_run_controller.assert_called_once()
resolver.complete_runtime.assert_awaited_once_with(mock_runtime, base_commit)
# Assert run_controller was called with the right parameters
if not test_case['run_controller_raises']:
# Check that the first positional argument is a config
assert 'config' in mock_run_controller.call_args[1]
# Check that initial_user_action is a MessageAction with the right content
assert isinstance(mock_run_controller.call_args[1]['initial_user_action'], MessageAction)
assert mock_run_controller.call_args[1]['runtime'] == mock_runtime
# Assert that guess_success was called only for successful runs
if test_case['expected_success']:
handler_instance.guess_success.assert_called_once()
else:
mock_run_controller.return_value = test_case['run_controller_return']
mock_run_controller.side_effect = None
mock_complete_runtime.return_value = {'git_patch': 'test patch'}
handler_instance.guess_success.return_value = (
test_case['expected_success'],
test_case.get('comment_success', None),
test_case['expected_explanation'],
)
handler_instance.get_instruction.return_value = ('Test instruction', [])
handler_instance.issue_type = 'pr' if test_case.get('is_pr', False) else 'issue'
with (
patch(
'openhands.resolver.resolve_issue.create_runtime', mock_create_runtime
),
patch(
'openhands.resolver.resolve_issue.initialize_runtime',
mock_initialize_runtime,
),
patch(
'openhands.resolver.resolve_issue.run_controller', mock_run_controller
),
patch(
'openhands.resolver.resolve_issue.complete_runtime',
mock_complete_runtime,
),
patch('openhands.resolver.resolve_issue.logger'),
):
# Call the function
result = await process_issue(
issue,
ProviderType.GITHUB,
base_commit,
max_iterations,
llm_config,
mock_output_dir,
base_container_image,
runtime_container_image,
mock_prompt_template,
handler_instance,
repo_instruction,
reset_logger=False,
)
# Assert the result
expected_issue_type = 'pr' if test_case.get('is_pr', False) else 'issue'
assert handler_instance.issue_type == expected_issue_type
assert isinstance(result, ResolverOutput)
assert result.issue == issue
assert result.base_commit == base_commit
assert result.git_patch == 'test patch'
assert result.success == test_case['expected_success']
assert result.result_explanation == test_case['expected_explanation']
assert result.error == test_case['expected_error']
# Assert that the mocked functions were called
mock_create_runtime.assert_called_once()
mock_initialize_runtime.assert_called_once()
mock_run_controller.assert_called_once()
mock_complete_runtime.assert_called_once()
# Assert that guess_success was called only for successful runs
if test_case['expected_success']:
handler_instance.guess_success.assert_called_once()
else:
handler_instance.guess_success.assert_not_called()
handler_instance.guess_success.assert_not_called()
def test_get_instruction(mock_prompt_template, mock_followup_prompt_template):
@@ -19,14 +19,46 @@ from openhands.resolver.interfaces.issue_definitions import (
ServiceContextIssue,
ServiceContextPR,
)
from openhands.resolver.resolve_issue import (
complete_runtime,
initialize_runtime,
process_issue,
)
from openhands.resolver.resolve_issue import IssueResolver, SandboxConfig, AppConfig, AgentConfig
from openhands.resolver.resolver_output import ResolverOutput
@pytest.fixture
def default_mock_args():
"""Fixture that provides a default mock args object with common values.
Tests can override specific attributes as needed.
"""
mock_args = MagicMock()
mock_args.selected_repo = 'test-owner/test-repo'
mock_args.token = 'test-token'
mock_args.username = 'test-user'
mock_args.max_iterations = 5
mock_args.output_dir = '/tmp'
mock_args.llm_model = 'test'
mock_args.llm_api_key = 'test'
mock_args.llm_base_url = None
mock_args.base_domain = None
mock_args.runtime_container_image = None
mock_args.is_experimental = False
mock_args.issue_number = None
mock_args.comment_id = None
mock_args.repo_instruction_file = None
mock_args.issue_type = 'issue'
mock_args.prompt_file = None
return mock_args
@pytest.fixture
def mock_gitlab_token():
"""Fixture that patches the identify_token function to return GitLab provider type.
This eliminates the need for repeated patching in each test function.
"""
with patch('openhands.resolver.resolve_issue.identify_token', return_value=ProviderType.GITLAB) as patched:
yield patched
@pytest.fixture
def mock_output_dir():
with tempfile.TemporaryDirectory() as temp_dir:
@@ -72,7 +104,7 @@ def create_cmd_output(exit_code: int, content: str, command: str):
)
def test_initialize_runtime():
def test_initialize_runtime(default_mock_args, mock_gitlab_token):
mock_runtime = MagicMock()
if os.getenv('GITLAB_CI') == 'true':
@@ -92,8 +124,11 @@ def test_initialize_runtime():
exit_code=0, content='', command='git config --global core.pager ""'
),
]
initialize_runtime(mock_runtime, ProviderType.GITLAB)
# Create resolver with mocked token identification
resolver = IssueResolver(default_mock_args)
resolver.initialize_runtime(mock_runtime)
if os.getenv('GITLAB_CI') == 'true':
assert mock_runtime.run_action.call_count == 3
@@ -111,40 +146,32 @@ def test_initialize_runtime():
@pytest.mark.asyncio
async def test_resolve_issue_no_issues_found():
from openhands.resolver.resolve_issue import resolve_issue
async def test_resolve_issue_no_issues_found(default_mock_args, mock_gitlab_token):
"""Test the resolve_issue method when no issues are found."""
# Mock dependencies
mock_handler = MagicMock()
mock_handler.get_converted_issues.return_value = [] # Return empty list
with patch(
'openhands.resolver.resolve_issue.issue_handler_factory',
return_value=mock_handler,
):
with pytest.raises(ValueError) as exc_info:
await resolve_issue(
owner='test-owner',
repo='test-repo',
token='test-token',
username='test-user',
platform=ProviderType.GITLAB,
max_iterations=5,
output_dir='/tmp',
llm_config=LLMConfig(model='test', api_key='test'),
base_container_image='test-image',
runtime_container_image='test-image',
prompt_template='test-template',
issue_type='pr',
repo_instruction=None,
issue_number=5432,
comment_id=None,
)
assert 'No issues found for issue number 5432' in str(exc_info.value)
assert 'test-owner/test-repo' in str(exc_info.value)
assert 'exists in the repository' in str(exc_info.value)
assert 'correct permissions' in str(exc_info.value)
# Customize the mock args for this test
default_mock_args.issue_number = 5432
# Create a resolver instance with mocked token identification
resolver = IssueResolver(default_mock_args)
# Mock the issue_handler_factory method
resolver.issue_handler_factory = MagicMock(return_value=mock_handler)
# Test that the correct exception is raised
with pytest.raises(ValueError) as exc_info:
await resolver.resolve_issue()
# Verify the error message
assert 'No issues found for issue number 5432' in str(exc_info.value)
assert 'test-owner/test-repo' in str(exc_info.value)
# Verify that the handler was correctly configured and called
resolver.issue_handler_factory.assert_called_once()
mock_handler.get_converted_issues.assert_called_once_with(issue_numbers=[5432], comment_id=None)
def test_download_issues_from_gitlab():
@@ -338,7 +365,7 @@ def test_download_pr_from_gitlab():
@pytest.mark.asyncio
async def test_complete_runtime():
async def test_complete_runtime(default_mock_args, mock_gitlab_token):
mock_runtime = MagicMock()
mock_runtime.run_action.side_effect = [
create_cmd_output(exit_code=0, content='', command='cd /workspace'),
@@ -351,45 +378,24 @@ async def test_complete_runtime():
command='git config --global --add safe.directory /workspace',
),
create_cmd_output(
exit_code=0, content='', command='git diff base_commit_hash fix'
exit_code=0, content='', command='git add -A'
),
create_cmd_output(exit_code=0, content='git diff content', command='git apply'),
create_cmd_output(exit_code=0, content='git diff content', command='git diff --no-color --cached base_commit_hash'),
]
# Create a resolver instance with mocked token identification
resolver = IssueResolver(default_mock_args)
result = await complete_runtime(
mock_runtime, 'base_commit_hash', ProviderType.GITLAB
)
result = await resolver.complete_runtime(mock_runtime, 'base_commit_hash')
assert result == {'git_patch': 'git diff content'}
assert mock_runtime.run_action.call_count == 5
@pytest.mark.asyncio
async def test_process_issue(mock_output_dir, mock_prompt_template):
# Mock dependencies
mock_create_runtime = MagicMock()
mock_initialize_runtime = AsyncMock()
mock_run_controller = AsyncMock()
mock_complete_runtime = AsyncMock()
handler_instance = MagicMock()
# Set up test data
issue = Issue(
owner='test_owner',
repo='test_repo',
number=1,
title='Test Issue',
body='This is a test issue',
)
base_commit = 'abcdef1234567890'
repo_instruction = 'Resolve this repo'
max_iterations = 5
llm_config = LLMConfig(model='test_model', api_key='test_api_key')
base_container_image = 'test_image:latest'
runtime_container_image = 'test_image:latest'
# Test cases for different scenarios
test_cases = [
@pytest.mark.parametrize(
"test_case",
[
{
'name': 'successful_run',
'run_controller_return': MagicMock(
@@ -403,22 +409,26 @@ async def test_process_issue(mock_output_dir, mock_prompt_template):
'expected_success': True,
'expected_error': None,
'expected_explanation': 'Issue resolved successfully',
'is_pr': False,
'comment_success': None,
},
{
'name': 'value_error',
'run_controller_return': None,
'run_controller_raises': ValueError('Test value error'),
'expected_success': False,
'expected_error': 'Agent failed to run or crashed',
'expected_explanation': 'Agent failed to run',
'is_pr': False,
'comment_success': None,
},
{
'name': 'runtime_error',
'run_controller_return': None,
'run_controller_raises': RuntimeError('Test runtime error'),
'expected_success': False,
'expected_error': 'Agent failed to run or crashed',
'expected_explanation': 'Agent failed to run',
'is_pr': False,
'comment_success': None,
},
{
'name': 'json_decode_error',
@@ -434,94 +444,82 @@ async def test_process_issue(mock_output_dir, mock_prompt_template):
'expected_error': None,
'expected_explanation': 'Non-JSON explanation',
'is_pr': True,
'comment_success': [
True,
False,
], # To trigger the PR success logging code path
'comment_success': [True, False],
},
]
],
)
async def test_process_issue(default_mock_args, mock_gitlab_token, mock_output_dir, mock_prompt_template, test_case):
"""Test the process_issue method with different scenarios."""
# Set up test data
issue = Issue(
owner='test_owner',
repo='test_repo',
number=1,
title='Test Issue',
body='This is a test issue',
)
base_commit = 'abcdef1234567890'
for test_case in test_cases:
# Reset mocks
mock_create_runtime.reset_mock()
mock_initialize_runtime.reset_mock()
mock_run_controller.reset_mock()
mock_complete_runtime.reset_mock()
handler_instance.reset_mock()
# Customize the mock args for this test
default_mock_args.output_dir = mock_output_dir
default_mock_args.issue_type = 'pr' if test_case.get('is_pr', False) else 'issue'
# Mock return values
mock_create_runtime.return_value = MagicMock(connect=AsyncMock())
if test_case['run_controller_raises']:
mock_run_controller.side_effect = test_case['run_controller_raises']
# Create a resolver instance with mocked token identification
resolver = IssueResolver(default_mock_args)
resolver.prompt_template = mock_prompt_template
# Mock the handler
handler_instance = MagicMock()
handler_instance.guess_success.return_value = (
test_case['expected_success'],
test_case.get('comment_success', None),
test_case['expected_explanation'],
)
handler_instance.get_instruction.return_value = ('Test instruction', [])
handler_instance.issue_type = 'pr' if test_case.get('is_pr', False) else 'issue'
# Create mock runtime and mock run_controller
mock_runtime = MagicMock()
mock_runtime.connect = AsyncMock()
mock_create_runtime = MagicMock(return_value=mock_runtime)
# Configure run_controller mock based on test case
mock_run_controller = AsyncMock()
if test_case.get('run_controller_raises'):
mock_run_controller.side_effect = test_case['run_controller_raises']
else:
mock_run_controller.return_value = test_case['run_controller_return']
# Patch the necessary functions and methods
with patch('openhands.resolver.resolve_issue.create_runtime', mock_create_runtime), \
patch('openhands.resolver.resolve_issue.run_controller', mock_run_controller), \
patch.object(resolver, 'complete_runtime', return_value={'git_patch': 'test patch'}), \
patch.object(resolver, 'initialize_runtime') as mock_initialize_runtime, \
patch('openhands.resolver.resolve_issue.SandboxConfig', return_value=MagicMock()), \
patch('openhands.resolver.resolve_issue.AppConfig', return_value=MagicMock()):
# Call the process_issue method
result = await resolver.process_issue(issue, base_commit, handler_instance)
mock_create_runtime.assert_called_once()
mock_runtime.connect.assert_called_once()
mock_initialize_runtime.assert_called_once()
mock_run_controller.assert_called_once()
resolver.complete_runtime.assert_awaited_once_with(mock_runtime, base_commit)
# Assert the result matches our expectations
assert isinstance(result, ResolverOutput)
assert result.issue == issue
assert result.base_commit == base_commit
assert result.git_patch == 'test patch'
assert result.success == test_case['expected_success']
assert result.result_explanation == test_case['expected_explanation']
assert result.error == test_case['expected_error']
if test_case['expected_success']:
handler_instance.guess_success.assert_called_once()
else:
mock_run_controller.return_value = test_case['run_controller_return']
mock_run_controller.side_effect = None
mock_complete_runtime.return_value = {'git_patch': 'test patch'}
handler_instance.guess_success.return_value = (
test_case['expected_success'],
test_case.get('comment_success', None),
test_case['expected_explanation'],
)
handler_instance.get_instruction.return_value = ('Test instruction', [])
handler_instance.issue_type = 'pr' if test_case.get('is_pr', False) else 'issue'
with (
patch(
'openhands.resolver.resolve_issue.create_runtime', mock_create_runtime
),
patch(
'openhands.resolver.resolve_issue.initialize_runtime',
mock_initialize_runtime,
),
patch(
'openhands.resolver.resolve_issue.run_controller', mock_run_controller
),
patch(
'openhands.resolver.resolve_issue.complete_runtime',
mock_complete_runtime,
),
patch('openhands.resolver.resolve_issue.logger'),
):
# Call the function
result = await process_issue(
issue,
ProviderType.GITLAB,
base_commit,
max_iterations,
llm_config,
mock_output_dir,
base_container_image,
runtime_container_image,
mock_prompt_template,
handler_instance,
repo_instruction,
reset_logger=False,
)
# Assert the result
expected_issue_type = 'pr' if test_case.get('is_pr', False) else 'issue'
assert handler_instance.issue_type == expected_issue_type
assert isinstance(result, ResolverOutput)
assert result.issue == issue
assert result.base_commit == base_commit
assert result.git_patch == 'test patch'
assert result.success == test_case['expected_success']
assert result.result_explanation == test_case['expected_explanation']
assert result.error == test_case['expected_error']
# Assert that the mocked functions were called
mock_create_runtime.assert_called_once()
mock_initialize_runtime.assert_called_once()
mock_run_controller.assert_called_once()
mock_complete_runtime.assert_called_once()
# Assert that guess_success was called only for successful runs
if test_case['expected_success']:
handler_instance.guess_success.assert_called_once()
else:
handler_instance.guess_success.assert_not_called()
handler_instance.guess_success.assert_not_called()
def test_get_instruction(mock_prompt_template, mock_followup_prompt_template):
issue = Issue(
@@ -925,4 +923,4 @@ def test_download_issue_with_specific_comment():
if __name__ == '__main__':
pytest.main()
pytest.main()
+10 -125
View File
@@ -22,7 +22,6 @@ from openhands.events.observation import (
ErrorObservation,
)
from openhands.events.observation.agent import RecallObservation
from openhands.events.observation.commands import CmdOutputObservation
from openhands.events.observation.empty import NullObservation
from openhands.events.serialization import event_to_dict
from openhands.llm import LLM
@@ -765,7 +764,7 @@ async def test_context_window_exceeded_error_handling(
# We do that by playing the role of the recall module -- subscribe to the
# event stream and respond to recall actions by inserting fake recall
# obesrvations.
# observations.
def on_event_memory(event: Event):
if isinstance(event, RecallAction):
microagent_obs = RecallObservation(
@@ -807,13 +806,19 @@ async def test_context_window_exceeded_error_handling(
# size (because we return a message action, which triggers a recall, which
# triggers a recall response). But if the pre/post-views are on the turn
# when we throw the context window exceeded error, we should see the
# post-step view compressed.
# post-step view compressed (or rather, a CondensationAction added).
for index, (first_view, second_view) in enumerate(
zip(step_state.views[:-1], step_state.views[1:])
):
if index == error_after:
assert len(first_view) > len(second_view)
# Verify that the CondensationAction is present in the second view (after error)
# but not in the first view (before error)
assert not any(isinstance(e, CondensationAction) for e in first_view.events)
assert any(isinstance(e, CondensationAction) for e in second_view.events)
# The length might not strictly decrease due to CondensationAction being added
assert len(first_view) == len(second_view)
else:
# Before the error, the view length should increase
assert len(first_view) < len(second_view)
# The final state's history should contain:
@@ -886,7 +891,7 @@ async def test_run_controller_with_context_window_exceeded_with_truncation(
def step(self, state: State):
# If the state has more than one message and we haven't errored yet,
# throw the context window exceeded error
if len(state.history) > 3 and not self.has_errored:
if len(state.history) > 5 and not self.has_errored:
error = ContextWindowExceededError(
message='prompt is too long: 233885 tokens > 200000 maximum',
model='',
@@ -1467,126 +1472,6 @@ def test_agent_controller_should_step_with_null_observation_cause_zero(mock_agen
), 'should_step should return False for NullObservation with cause = 0'
def test_apply_conversation_window_basic(mock_event_stream, mock_agent):
"""Test that the _apply_conversation_window method correctly prunes a list of events."""
controller = AgentController(
agent=mock_agent,
event_stream=mock_event_stream,
max_iterations=10,
sid='test_apply_conversation_window_basic',
confirmation_mode=False,
headless_mode=True,
)
# Create a sequence of events with IDs
first_msg = MessageAction(content='Hello, start task', wait_for_response=False)
first_msg._source = EventSource.USER
first_msg._id = 1
# Add agent question
agent_msg = MessageAction(
content='What task would you like me to perform?', wait_for_response=True
)
agent_msg._source = EventSource.AGENT
agent_msg._id = 2
# Add user response
user_response = MessageAction(
content='Please list all files and show me current directory',
wait_for_response=False,
)
user_response._source = EventSource.USER
user_response._id = 3
cmd1 = CmdRunAction(command='ls')
cmd1._id = 4
obs1 = CmdOutputObservation(command='ls', content='file1.txt', command_id=4)
obs1._id = 5
obs1._cause = 4
cmd2 = CmdRunAction(command='pwd')
cmd2._id = 6
obs2 = CmdOutputObservation(command='pwd', content='/home', command_id=6)
obs2._id = 7
obs2._cause = 6
events = [first_msg, agent_msg, user_response, cmd1, obs1, cmd2, obs2]
# Apply truncation
truncated = controller._apply_conversation_window(events)
# Verify truncation occured
# Should keep first user message and roughly half of other events
assert (
3 <= len(truncated) < len(events)
) # First message + at least one action-observation pair
assert truncated[0] == first_msg # First message always preserved
assert controller.state.start_id == first_msg._id
# Verify pairs aren't split
for i, event in enumerate(truncated[1:]):
if isinstance(event, CmdOutputObservation):
assert any(e._id == event._cause for e in truncated[: i + 1])
def test_history_restoration_after_truncation(mock_event_stream, mock_agent):
controller = AgentController(
agent=mock_agent,
event_stream=mock_event_stream,
max_iterations=10,
sid='test_truncation',
confirmation_mode=False,
headless_mode=True,
)
# Create events with IDs
first_msg = MessageAction(content='Start task', wait_for_response=False)
first_msg._source = EventSource.USER
first_msg._id = 1
events = [first_msg]
for i in range(5):
cmd = CmdRunAction(command=f'cmd{i}')
cmd._id = i + 2
obs = CmdOutputObservation(
command=f'cmd{i}', content=f'output{i}', command_id=cmd._id
)
obs._cause = cmd._id
events.extend([cmd, obs])
# Set up initial history
controller.state.history = events.copy()
# Force truncation
controller.state.history = controller._apply_conversation_window(
controller.state.history
)
# Save state
saved_start_id = controller.state.start_id
saved_history_len = len(controller.state.history)
# Set up mock event stream for new controller
mock_event_stream.get_events.return_value = controller.state.history
# Create new controller with saved state
new_controller = AgentController(
agent=mock_agent,
event_stream=mock_event_stream,
max_iterations=10,
sid='test_truncation',
confirmation_mode=False,
headless_mode=True,
)
new_controller.state.start_id = saved_start_id
new_controller.state.history = mock_event_stream.get_events()
# Verify restoration
assert len(new_controller.state.history) == saved_history_len
assert new_controller.state.history[0] == first_msg
assert new_controller.state.start_id == saved_start_id
def test_system_message_in_event_stream(mock_agent, test_event_stream):
"""Test that SystemMessageAction is added to event stream in AgentController."""
_ = AgentController(
+569
View File
@@ -0,0 +1,569 @@
from unittest.mock import MagicMock, patch
import pytest
from openhands.controller.agent import Agent
from openhands.controller.agent_controller import AgentController
from openhands.controller.state.state import State
from openhands.core.config import AppConfig
from openhands.events import EventSource
from openhands.events.action import CmdRunAction, MessageAction, RecallAction
from openhands.events.action.message import SystemMessageAction
from openhands.events.event import RecallType
from openhands.events.observation import (
CmdOutputObservation,
Observation,
RecallObservation,
)
from openhands.events.stream import EventStream
from openhands.llm.llm import LLM
from openhands.llm.metrics import Metrics
from openhands.storage.memory import InMemoryFileStore
# Helper function to create events with sequential IDs and causes
def create_events(event_data):
events = []
# Import necessary types here to avoid repeated imports inside the loop
from openhands.events.action import CmdRunAction, RecallAction
from openhands.events.observation import CmdOutputObservation, RecallObservation
for i, data in enumerate(event_data):
event_type = data['type']
source = data.get('source', EventSource.AGENT)
kwargs = {} # Arguments for the event constructor
# Determine arguments based on event type
if event_type == RecallAction:
kwargs['query'] = data.get('query', '')
kwargs['recall_type'] = data.get('recall_type', RecallType.KNOWLEDGE)
elif event_type == RecallObservation:
kwargs['content'] = data.get('content', '')
kwargs['recall_type'] = data.get('recall_type', RecallType.KNOWLEDGE)
elif event_type == CmdRunAction:
kwargs['command'] = data.get('command', '')
elif event_type == CmdOutputObservation:
# Required args for CmdOutputObservation
kwargs['content'] = data.get('content', '')
kwargs['command'] = data.get('command', '')
# Pass command_id via kwargs if present in data
if 'command_id' in data:
kwargs['command_id'] = data['command_id']
# Pass metadata if present
if 'metadata' in data:
kwargs['metadata'] = data['metadata']
else: # Default for MessageAction, SystemMessageAction, etc.
kwargs['content'] = data.get('content', '')
# Instantiate the event
event = event_type(**kwargs)
# Assign internal attributes AFTER instantiation
event._id = i + 1 # Assign sequential IDs starting from 1
event._source = source
# Assign _cause using cause_id from data, AFTER event._id is set
if 'cause_id' in data:
event._cause = data['cause_id']
# If command_id was NOT passed via kwargs but cause_id exists,
# pass cause_id as command_id to __init__ via kwargs for legacy handling
# This needs to happen *before* instantiation if we want __init__ to handle it
# Let's adjust the logic slightly:
if event_type == CmdOutputObservation:
if 'command_id' not in kwargs and 'cause_id' in data:
kwargs['command_id'] = data['cause_id'] # Let __init__ handle this
# Re-instantiate if we added command_id
if 'command_id' in kwargs and event.command_id != kwargs['command_id']:
event = event_type(**kwargs)
event._id = i + 1
event._source = source
# Now assign _cause if it exists in data, after potential re-instantiation
if 'cause_id' in data:
event._cause = data['cause_id']
events.append(event)
return events
@pytest.fixture
def controller_fixture():
mock_agent = MagicMock(spec=Agent)
mock_agent.llm = MagicMock(spec=LLM)
mock_agent.llm.metrics = Metrics()
mock_agent.llm.config = AppConfig().get_llm_config()
mock_agent.config = AppConfig().get_agent_config('CodeActAgent')
mock_event_stream = MagicMock(spec=EventStream)
mock_event_stream.sid = 'test_sid'
mock_event_stream.file_store = InMemoryFileStore({})
# Ensure get_latest_event_id returns an integer
mock_event_stream.get_latest_event_id.return_value = -1
controller = AgentController(
agent=mock_agent,
event_stream=mock_event_stream,
max_iterations=10,
sid='test_sid',
)
controller.state = State(session_id='test_sid')
# Mock _first_user_message directly on the instance
mock_first_user_message = MagicMock(spec=MessageAction)
controller._first_user_message = MagicMock(return_value=mock_first_user_message)
return controller, mock_first_user_message
# =============================================
# Test Cases for _apply_conversation_window
# =============================================
def test_basic_truncation(controller_fixture):
controller, mock_first_user_message = controller_fixture
controller.state.history = create_events(
[
{'type': SystemMessageAction, 'content': 'System Prompt'}, # 1
{
'type': MessageAction,
'content': 'User Task 1',
'source': EventSource.USER,
}, # 2
{'type': RecallAction, 'query': 'User Task 1'}, # 3
{'type': RecallObservation, 'content': 'Recall result', 'cause_id': 3}, # 4
{'type': CmdRunAction, 'command': 'ls'}, # 5
{
'type': CmdOutputObservation,
'content': 'file1',
'command': 'ls',
'cause_id': 5,
}, # 6
{'type': CmdRunAction, 'command': 'pwd'}, # 7
{
'type': CmdOutputObservation,
'content': '/dir',
'command': 'pwd',
'cause_id': 7,
}, # 8
{'type': CmdRunAction, 'command': 'cat file1'}, # 9
{
'type': CmdOutputObservation,
'content': 'content',
'command': 'cat file1',
'cause_id': 9,
}, # 10
]
)
mock_first_user_message.id = 2 # Set the ID of the mocked first user message
# Calculation (RecallAction now essential):
# History len = 10
# Essentials = [sys(1), user(2), recall_act(3), recall_obs(4)] (len=4)
# Non-essential count = 10 - 4 = 6
# num_recent_to_keep = max(1, 6 // 2) = 3
# slice_start_index = 10 - 3 = 7
# recent_events_slice = history[7:] = [obs2(8), cmd3(9), obs3(10)]
# Validation: remove leading obs2(8). validated_slice = [cmd3(9), obs3(10)]
# Final = essentials + validated_slice = [sys(1), user(2), recall_act(3), recall_obs(4), cmd3(9), obs3(10)]
# Expected IDs: [1, 2, 3, 4, 9, 10]. Length 6.
truncated_events = controller._apply_conversation_window()
assert len(truncated_events) == 6
expected_ids = [1, 2, 3, 4, 9, 10]
actual_ids = [e.id for e in truncated_events]
assert actual_ids == expected_ids
# Check no dangling observations at the start of the recent slice part
# The first event of the validated slice is cmd3(9)
assert not isinstance(truncated_events[4], Observation) # Index adjusted
def test_no_system_message(controller_fixture):
controller, mock_first_user_message = controller_fixture
controller.state.history = create_events(
[
{
'type': MessageAction,
'content': 'User Task 1',
'source': EventSource.USER,
}, # 1
{'type': RecallAction, 'query': 'User Task 1'}, # 2
{'type': RecallObservation, 'content': 'Recall result', 'cause_id': 2}, # 3
{'type': CmdRunAction, 'command': 'ls'}, # 4
{
'type': CmdOutputObservation,
'content': 'file1',
'command': 'ls',
'cause_id': 4,
}, # 5
{'type': CmdRunAction, 'command': 'pwd'}, # 6
{
'type': CmdOutputObservation,
'content': '/dir',
'command': 'pwd',
'cause_id': 6,
}, # 7
{'type': CmdRunAction, 'command': 'cat file1'}, # 8
{
'type': CmdOutputObservation,
'content': 'content',
'command': 'cat file1',
'cause_id': 8,
}, # 9
]
)
mock_first_user_message.id = 1
# Calculation (RecallAction now essential):
# History len = 9
# Essentials = [user(1), recall_act(2), recall_obs(3)] (len=3)
# Non-essential count = 9 - 3 = 6
# num_recent_to_keep = max(1, 6 // 2) = 3
# slice_start_index = 9 - 3 = 6
# recent_events_slice = history[6:] = [obs2(7), cmd3(8), obs3(9)]
# Validation: remove leading obs2(7). validated_slice = [cmd3(8), obs3(9)]
# Final = essentials + validated_slice = [user(1), recall_act(2), recall_obs(3), cmd3(8), obs3(9)]
# Expected IDs: [1, 2, 3, 8, 9]. Length 5.
truncated_events = controller._apply_conversation_window()
assert len(truncated_events) == 5
expected_ids = [1, 2, 3, 8, 9]
actual_ids = [e.id for e in truncated_events]
assert actual_ids == expected_ids
def test_no_recall_observation(controller_fixture):
controller, mock_first_user_message = controller_fixture
controller.state.history = create_events(
[
{'type': SystemMessageAction, 'content': 'System Prompt'}, # 1
{
'type': MessageAction,
'content': 'User Task 1',
'source': EventSource.USER,
}, # 2
{'type': RecallAction, 'query': 'User Task 1'}, # 3 (Recall Action exists)
# Recall Observation is missing
{'type': CmdRunAction, 'command': 'ls'}, # 4
{
'type': CmdOutputObservation,
'content': 'file1',
'command': 'ls',
'cause_id': 4,
}, # 5
{'type': CmdRunAction, 'command': 'pwd'}, # 6
{
'type': CmdOutputObservation,
'content': '/dir',
'command': 'pwd',
'cause_id': 6,
}, # 7
{'type': CmdRunAction, 'command': 'cat file1'}, # 8
{
'type': CmdOutputObservation,
'content': 'content',
'command': 'cat file1',
'cause_id': 8,
}, # 9
]
)
mock_first_user_message.id = 2
# Calculation (RecallAction essential only if RecallObs exists):
# History len = 9
# Essentials = [sys(1), user(2)] (len=2) - RecallObs missing, so RecallAction not essential here
# Non-essential count = 9 - 2 = 7
# num_recent_to_keep = max(1, 7 // 2) = 3
# slice_start_index = 9 - 3 = 6
# recent_events_slice = history[6:] = [obs2(7), cmd3(8), obs3(9)]
# Validation: remove leading obs2(7). validated_slice = [cmd3(8), obs3(9)]
# Final = essentials + validated_slice = [sys(1), user(2), recall_action(3), cmd_cat(8), obs_cat(9)]
# Expected IDs: [1, 2, 3, 8, 9]. Length 5.
truncated_events = controller._apply_conversation_window()
assert len(truncated_events) == 5
expected_ids = [1, 2, 3, 8, 9]
actual_ids = [e.id for e in truncated_events]
assert actual_ids == expected_ids
def test_short_history_no_truncation(controller_fixture):
controller, mock_first_user_message = controller_fixture
history = create_events(
[
{'type': SystemMessageAction, 'content': 'System Prompt'}, # 1
{
'type': MessageAction,
'content': 'User Task 1',
'source': EventSource.USER,
}, # 2
{'type': RecallAction, 'query': 'User Task 1'}, # 3
{'type': RecallObservation, 'content': 'Recall result', 'cause_id': 3}, # 4
{'type': CmdRunAction, 'command': 'ls'}, # 5
{
'type': CmdOutputObservation,
'content': 'file1',
'command': 'ls',
'cause_id': 5,
}, # 6
]
)
controller.state.history = history
mock_first_user_message.id = 2
# Calculation (RecallAction now essential):
# History len = 6
# Essentials = [sys(1), user(2), recall_act(3), recall_obs(4)] (len=4)
# Non-essential count = 6 - 4 = 2
# num_recent_to_keep = max(1, 2 // 2) = 1
# slice_start_index = 6 - 1 = 5
# recent_events_slice = history[5:] = [obs1(6)]
# Validation: remove leading obs1(6). validated_slice = []
# Final = essentials + validated_slice = [sys(1), user(2), recall_act(3), recall_obs(4)]
# Expected IDs: [1, 2, 3, 4]. Length 4.
truncated_events = controller._apply_conversation_window()
assert len(truncated_events) == 4
expected_ids = [1, 2, 3, 4]
actual_ids = [e.id for e in truncated_events]
assert actual_ids == expected_ids
def test_only_essential_events(controller_fixture):
controller, mock_first_user_message = controller_fixture
history = create_events(
[
{'type': SystemMessageAction, 'content': 'System Prompt'}, # 1
{
'type': MessageAction,
'content': 'User Task 1',
'source': EventSource.USER,
}, # 2
{'type': RecallAction, 'query': 'User Task 1'}, # 3
{'type': RecallObservation, 'content': 'Recall result', 'cause_id': 3}, # 4
]
)
controller.state.history = history
mock_first_user_message.id = 2
# Calculation (RecallAction now essential):
# History len = 4
# Essentials = [sys(1), user(2), recall_act(3), recall_obs(4)] (len=4)
# Non-essential count = 4 - 4 = 0
# num_recent_to_keep = max(1, 0 // 2) = 1
# slice_start_index = 4 - 1 = 3
# recent_events_slice = history[3:] = [recall_obs(4)]
# Validation: remove leading recall_obs(4). validated_slice = []
# Final = essentials + validated_slice = [sys(1), user(2), recall_act(3), recall_obs(4)]
# Expected IDs: [1, 2, 3, 4]. Length 4.
truncated_events = controller._apply_conversation_window()
assert len(truncated_events) == 4
expected_ids = [1, 2, 3, 4]
actual_ids = [e.id for e in truncated_events]
assert actual_ids == expected_ids
def test_dangling_observations_at_cut_point(controller_fixture):
controller, mock_first_user_message = controller_fixture
history_forced_dangle = create_events(
[
{'type': SystemMessageAction, 'content': 'System Prompt'}, # 1
{
'type': MessageAction,
'content': 'User Task 1',
'source': EventSource.USER,
}, # 2
{'type': RecallAction, 'query': 'User Task 1'}, # 3
{'type': RecallObservation, 'content': 'Recall result', 'cause_id': 3}, # 4
# --- Slice calculation should start here ---
{
'type': CmdOutputObservation,
'content': 'dangle1',
'command': 'cmd_unknown',
}, # 5 (Dangling)
{
'type': CmdOutputObservation,
'content': 'dangle2',
'command': 'cmd_unknown',
}, # 6 (Dangling)
{'type': CmdRunAction, 'command': 'cmd1'}, # 7
{
'type': CmdOutputObservation,
'content': 'obs1',
'command': 'cmd1',
'cause_id': 7,
}, # 8
{'type': CmdRunAction, 'command': 'cmd2'}, # 9
{
'type': CmdOutputObservation,
'content': 'obs2',
'command': 'cmd2',
'cause_id': 9,
}, # 10
]
) # 10 events total
controller.state.history = history_forced_dangle
mock_first_user_message.id = 2
# Calculation (RecallAction now essential):
# History len = 10
# Essentials = [sys(1), user(2), recall_act(3), recall_obs(4)] (len=4)
# Non-essential count = 10 - 4 = 6
# num_recent_to_keep = max(1, 6 // 2) = 3
# slice_start_index = 10 - 3 = 7
# recent_events_slice = history[7:] = [obs1(8), cmd2(9), obs2(10)]
# Validation: remove leading obs1(8). validated_slice = [cmd2(9), obs2(10)]
# Final = essentials + validated_slice = [sys(1), user(2), recall_act(3), recall_obs(4), cmd2(9), obs2(10)]
# Expected IDs: [1, 2, 3, 4, 9, 10]. Length 6.
truncated_events = controller._apply_conversation_window()
assert len(truncated_events) == 6
expected_ids = [1, 2, 3, 4, 9, 10]
actual_ids = [e.id for e in truncated_events]
assert actual_ids == expected_ids
# Verify dangling observations 5 and 6 were removed (implicitly by slice start and validation)
def test_only_dangling_observations_in_recent_slice(controller_fixture):
controller, mock_first_user_message = controller_fixture
history = create_events(
[
{'type': SystemMessageAction, 'content': 'System Prompt'}, # 1
{
'type': MessageAction,
'content': 'User Task 1',
'source': EventSource.USER,
}, # 2
{'type': RecallAction, 'query': 'User Task 1'}, # 3
{'type': RecallObservation, 'content': 'Recall result', 'cause_id': 3}, # 4
# --- Slice calculation should start here ---
{
'type': CmdOutputObservation,
'content': 'dangle1',
'command': 'cmd_unknown',
}, # 5 (Dangling)
{
'type': CmdOutputObservation,
'content': 'dangle2',
'command': 'cmd_unknown',
}, # 6 (Dangling)
]
) # 6 events total
controller.state.history = history
mock_first_user_message.id = 2
# Calculation (RecallAction now essential):
# History len = 6
# Essentials = [sys(1), user(2), recall_act(3), recall_obs(4)] (len=4)
# Non-essential count = 6 - 4 = 2
# num_recent_to_keep = max(1, 2 // 2) = 1
# slice_start_index = 6 - 1 = 5
# recent_events_slice = history[5:] = [dangle2(6)]
# Validation: remove leading dangle2(6). validated_slice = [] (Corrected based on user feedback/bugfix)
# Final = essentials + validated_slice = [sys(1), user(2), recall_act(3), recall_obs(4)]
# Expected IDs: [1, 2, 3, 4]. Length 4.
with patch(
'openhands.controller.agent_controller.logger.warning'
) as mock_log_warning:
truncated_events = controller._apply_conversation_window()
assert len(truncated_events) == 4
expected_ids = [1, 2, 3, 4]
actual_ids = [e.id for e in truncated_events]
assert actual_ids == expected_ids
# Verify dangling observations 5 and 6 were removed
# Check that the specific warning was logged exactly once
assert mock_log_warning.call_count == 1
# Check the essential parts of the arguments, allowing for variations like stacklevel
call_args, call_kwargs = mock_log_warning.call_args
expected_message_substring = 'All recent events are dangling observations, which we truncate. This means the agent has only the essential first events. This should not happen.'
assert expected_message_substring in call_args[0]
assert 'extra' in call_kwargs
assert call_kwargs['extra'].get('session_id') == 'test_sid'
def test_empty_history(controller_fixture):
controller, _ = controller_fixture
controller.state.history = []
truncated_events = controller._apply_conversation_window()
assert truncated_events == []
def test_multiple_user_messages(controller_fixture):
controller, mock_first_user_message = controller_fixture
history = create_events(
[
{'type': SystemMessageAction, 'content': 'System Prompt'}, # 1
{
'type': MessageAction,
'content': 'User Task 1',
'source': EventSource.USER,
}, # 2 (First)
{'type': RecallAction, 'query': 'User Task 1'}, # 3
{
'type': RecallObservation,
'content': 'Recall result 1',
'cause_id': 3,
}, # 4
{'type': CmdRunAction, 'command': 'cmd1'}, # 5
{
'type': CmdOutputObservation,
'content': 'obs1',
'command': 'cmd1',
'cause_id': 5,
}, # 6
{
'type': MessageAction,
'content': 'User Task 2',
'source': EventSource.USER,
}, # 7 (Second)
{'type': RecallAction, 'query': 'User Task 2'}, # 8
{
'type': RecallObservation,
'content': 'Recall result 2',
'cause_id': 8,
}, # 9
{'type': CmdRunAction, 'command': 'cmd2'}, # 10
{
'type': CmdOutputObservation,
'content': 'obs2',
'command': 'cmd2',
'cause_id': 10,
}, # 11
]
) # 11 events total
controller.state.history = history
mock_first_user_message.id = 2 # Explicitly set the first user message ID
# Calculation (RecallAction now essential):
# History len = 11
# Essentials = [sys(1), user1(2), recall_act1(3), recall_obs1(4)] (len=4)
# Non-essential count = 11 - 4 = 7
# num_recent_to_keep = max(1, 7 // 2) = 3
# slice_start_index = 11 - 3 = 8
# recent_events_slice = history[8:] = [recall_obs2(9), cmd2(10), obs2(11)]
# Validation: remove leading recall_obs2(9). validated_slice = [cmd2(10), obs2(11)]
# Final = essentials + validated_slice = [sys(1), user1(2), recall_act1(3), recall_obs1(4)] + [cmd2(10), obs2(11)]
# Expected IDs: [1, 2, 3, 4, 10, 11]. Length 6.
truncated_events = controller._apply_conversation_window()
assert len(truncated_events) == 6
expected_ids = [1, 2, 3, 4, 10, 11]
actual_ids = [e.id for e in truncated_events]
assert actual_ids == expected_ids
# Verify the second user message (ID 7) was NOT kept
assert not any(event.id == 7 for event in truncated_events)
# Verify the first user message (ID 2) is present
assert any(event.id == 2 for event in truncated_events)
+65 -17
View File
@@ -44,6 +44,7 @@ from openhands.events.observation.commands import (
)
from openhands.events.tool import ToolCallMetadata
from openhands.llm.llm import LLM
from openhands.memory.condenser import View
@pytest.fixture(params=['CodeActAgent', 'ReadOnlyAgent'])
@@ -97,6 +98,12 @@ def test_reset(agent):
action._source = EventSource.AGENT
agent.pending_actions.append(action)
# Create a mock state with initial user message
mock_state = Mock(spec=State)
initial_user_message = MessageAction(content='Initial user message')
initial_user_message._source = EventSource.USER
mock_state.history = [initial_user_message]
# Reset
agent.reset()
@@ -110,8 +117,14 @@ def test_step_with_pending_actions(agent):
pending_action._source = EventSource.AGENT
agent.pending_actions.append(pending_action)
# Create a mock state with initial user message
mock_state = Mock(spec=State)
initial_user_message = MessageAction(content='Initial user message')
initial_user_message._source = EventSource.USER
mock_state.history = [initial_user_message]
# Step should return the pending action
result = agent.step(Mock())
result = agent.step(mock_state)
assert result == pending_action
assert len(agent.pending_actions) == 0
@@ -260,6 +273,11 @@ def test_step_with_no_pending_actions(mock_state: State):
mock_state.latest_user_message_llm_metrics = None
mock_state.latest_user_message_tool_call_metadata = None
# Add initial user message to history
initial_user_message = MessageAction(content='Initial user message')
initial_user_message._source = EventSource.USER
mock_state.history = [initial_user_message]
action = agent.step(mock_state)
assert isinstance(action, MessageAction)
assert action.content == 'Task completed'
@@ -330,42 +348,56 @@ def test_mismatched_tool_call_events_and_auto_add_system_message(
)
action = CmdRunAction('foo')
action._source = 'agent'
action._source = EventSource.AGENT
action.tool_call_metadata = tool_call_metadata
observation = CmdOutputObservation(content='', command_id=0, command='foo')
observation.tool_call_metadata = tool_call_metadata
# Add initial user message
initial_user_message = MessageAction(content='Initial user message')
initial_user_message._source = EventSource.USER
# When both events are provided, the agent should get three messages:
# 1. The system message (added automatically for backward compatibility)
# 2. The action message
# 3. The observation message
mock_state.history = [action, observation]
messages = agent._get_messages(mock_state.history)
assert len(messages) == 3
mock_state.history = [initial_user_message, action, observation]
messages = agent._get_messages(mock_state.history, initial_user_message)
assert len(messages) == 4 # System + initial user + action + observation
assert messages[0].role == 'system' # First message should be the system message
assert messages[1].role == 'assistant' # Second message should be the action
assert messages[2].role == 'tool' # Third message should be the observation
assert (
messages[1].role == 'user'
) # Second message should be the initial user message
assert messages[2].role == 'assistant' # Third message should be the action
assert messages[3].role == 'tool' # Fourth message should be the observation
# The same should hold if the events are presented out-of-order
mock_state.history = [observation, action]
messages = agent._get_messages(mock_state.history)
assert len(messages) == 3
mock_state.history = [initial_user_message, observation, action]
messages = agent._get_messages(mock_state.history, initial_user_message)
assert len(messages) == 4
assert messages[0].role == 'system' # First message should be the system message
assert (
messages[1].role == 'user'
) # Second message should be the initial user message
# If only one of the two events is present, then we should just get the system message
# plus any valid message from the event
mock_state.history = [action]
messages = agent._get_messages(mock_state.history)
mock_state.history = [initial_user_message, action]
messages = agent._get_messages(mock_state.history, initial_user_message)
assert (
len(messages) == 1
) # Only system message, action is waiting for its observation
len(messages) == 2
) # System + initial user message, action is waiting for its observation
assert messages[0].role == 'system'
assert messages[1].role == 'user'
mock_state.history = [observation]
messages = agent._get_messages(mock_state.history)
assert len(messages) == 1 # Only system message, observation has no matching action
mock_state.history = [initial_user_message, observation]
messages = agent._get_messages(mock_state.history, initial_user_message)
assert (
len(messages) == 2
) # System + initial user message, observation has no matching action
assert messages[0].role == 'system'
assert messages[1].role == 'user'
def test_grep_tool():
@@ -470,3 +502,19 @@ def test_get_system_message():
assert len(result.tools) > 0
assert any(tool['function']['name'] == 'execute_bash' for tool in result.tools)
assert result._source == EventSource.AGENT
def test_step_raises_error_if_no_initial_user_message(
agent: CodeActAgent, mock_state: State
):
"""Tests that step raises ValueError if the initial user message is not found."""
# Ensure history does NOT contain a user MessageAction
assistant_message = MessageAction(content='Assistant message')
assistant_message._source = EventSource.AGENT
mock_state.history = [assistant_message]
# Mock the condenser to return the history as is
agent.condenser = Mock()
agent.condenser.condensed_history.return_value = View(events=mock_state.history)
with pytest.raises(ValueError, match='Initial user message not found'):
agent.step(mock_state)
+622 -19
View File
@@ -1,27 +1,630 @@
from unittest.mock import patch
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
from openhands.core.config import AppConfig
from openhands.io import read_input
import pytest
import pytest_asyncio
from openhands.core import cli
from openhands.events import EventSource
from openhands.events.action import MessageAction
def test_single_line_input():
"""Test that single line input works when cli_multiline_input is False"""
config = AppConfig()
@pytest_asyncio.fixture
def mock_agent():
agent = AsyncMock()
agent.reset = MagicMock()
return agent
@pytest_asyncio.fixture
def mock_runtime():
runtime = AsyncMock()
runtime.close = MagicMock()
runtime.event_stream = MagicMock()
return runtime
@pytest_asyncio.fixture
def mock_controller():
controller = AsyncMock()
controller.close = AsyncMock()
return controller
@pytest.mark.asyncio
async def test_cleanup_session_closes_resources(
mock_agent, mock_runtime, mock_controller
):
"""Test that cleanup_session calls close methods on agent, runtime, and controller."""
loop = asyncio.get_running_loop()
await cli.cleanup_session(loop, mock_agent, mock_runtime, mock_controller)
mock_agent.reset.assert_called_once()
mock_runtime.close.assert_called_once()
mock_controller.close.assert_called_once()
@pytest.mark.asyncio
async def test_cleanup_session_cancels_pending_tasks(
mock_agent, mock_runtime, mock_controller
):
"""Test that cleanup_session cancels other pending tasks."""
loop = asyncio.get_running_loop()
other_task_ran = False
other_task_cancelled = False
async def _other_task_func():
nonlocal other_task_ran, other_task_cancelled
try:
other_task_ran = True
await asyncio.sleep(5) # Sleep long enough to be cancelled
except asyncio.CancelledError:
other_task_cancelled = True
raise
other_task = loop.create_task(_other_task_func())
# Allow the other task to start running
await asyncio.sleep(0)
assert other_task_ran is True
# Run cleanup session directly from the test task
await cli.cleanup_session(loop, mock_agent, mock_runtime, mock_controller)
# Check that the other task was indeed cancelled
assert other_task.cancelled() or other_task_cancelled is True
# Ensure the cleanup finishes (awaiting the task raises CancelledError if cancelled)
try:
await other_task
except asyncio.CancelledError:
pass # Expected
# Verify cleanup still called mocks
mock_agent.reset.assert_called_once()
mock_runtime.close.assert_called_once()
mock_controller.close.assert_called_once()
@pytest.mark.asyncio
async def test_cleanup_session_handles_exceptions(
mock_agent, mock_runtime, mock_controller
):
"""Test that cleanup_session handles exceptions during cleanup gracefully."""
loop = asyncio.get_running_loop()
mock_controller.close.side_effect = Exception('Test cleanup error')
with patch('openhands.core.cli.logger.error') as mock_log_error:
await cli.cleanup_session(loop, mock_agent, mock_runtime, mock_controller)
# Check that cleanup continued despite the error
mock_agent.reset.assert_called_once()
mock_runtime.close.assert_called_once()
# Check that the error was logged
mock_log_error.assert_called_once()
assert 'Test cleanup error' in mock_log_error.call_args[0][0]
@pytest_asyncio.fixture
def mock_config():
config = MagicMock()
config.runtime = 'local'
config.cli_multiline_input = False
with patch('builtins.input', return_value='hello world'):
result = read_input(config.cli_multiline_input)
assert result == 'hello world'
config.workspace_base = '/test/dir'
return config
def test_multiline_input():
"""Test that multiline input works when cli_multiline_input is True"""
config = AppConfig()
config.cli_multiline_input = True
@pytest_asyncio.fixture
def mock_settings_store():
settings_store = AsyncMock()
return settings_store
# Simulate multiple lines of input followed by /exit
mock_inputs = ['line 1', 'line 2', 'line 3', '/exit']
with patch('builtins.input', side_effect=mock_inputs):
result = read_input(config.cli_multiline_input)
assert result == 'line 1\nline 2\nline 3'
@pytest.mark.asyncio
@patch('openhands.core.cli.display_runtime_initialization_message')
@patch('openhands.core.cli.display_initialization_animation')
@patch('openhands.core.cli.create_agent')
@patch('openhands.core.cli.fetch_mcp_tools_from_config')
@patch('openhands.core.cli.create_runtime')
@patch('openhands.core.cli.create_controller')
@patch('openhands.core.cli.create_memory')
@patch('openhands.core.cli.run_agent_until_done')
@patch('openhands.core.cli.cleanup_session')
@patch('openhands.core.cli.initialize_repository_for_runtime')
async def test_run_session_without_initial_action(
mock_initialize_repo,
mock_cleanup_session,
mock_run_agent_until_done,
mock_create_memory,
mock_create_controller,
mock_create_runtime,
mock_fetch_mcp_tools,
mock_create_agent,
mock_display_animation,
mock_display_runtime_init,
mock_config,
mock_settings_store,
):
"""Test run_session function with no initial user action."""
loop = asyncio.get_running_loop()
# Mock initialize_repository_for_runtime to return a valid path
mock_initialize_repo.return_value = '/test/dir'
# Mock objects returned by the setup functions
mock_agent = AsyncMock()
mock_create_agent.return_value = mock_agent
mock_mcp_tools = []
mock_fetch_mcp_tools.return_value = mock_mcp_tools
mock_runtime = AsyncMock()
mock_runtime.event_stream = MagicMock()
mock_create_runtime.return_value = mock_runtime
mock_controller = AsyncMock()
mock_controller_task = MagicMock()
mock_create_controller.return_value = (mock_controller, mock_controller_task)
mock_memory = AsyncMock()
mock_create_memory.return_value = mock_memory
with patch(
'openhands.core.cli.read_prompt_input', new_callable=AsyncMock
) as mock_read_prompt:
# Set up read_prompt_input to return a string that will trigger the command handler
mock_read_prompt.return_value = '/exit'
# Mock handle_commands to return values that will exit the loop
with patch(
'openhands.core.cli.handle_commands', new_callable=AsyncMock
) as mock_handle_commands:
mock_handle_commands.return_value = (
True,
False,
False,
) # close_repl, reload_microagents, new_session_requested
# Run the function
result = await cli.run_session(
loop, mock_config, mock_settings_store, '/test/dir'
)
# Assertions for initialization flow
mock_display_runtime_init.assert_called_once_with('local')
mock_display_animation.assert_called_once()
mock_create_agent.assert_called_once_with(mock_config)
mock_fetch_mcp_tools.assert_called_once()
mock_agent.set_mcp_tools.assert_called_once_with(mock_mcp_tools)
mock_create_runtime.assert_called_once()
mock_create_controller.assert_called_once()
mock_create_memory.assert_called_once()
# Check that run_agent_until_done was called
mock_run_agent_until_done.assert_called_once()
# Check that cleanup_session was called
mock_cleanup_session.assert_called_once()
# Check that the function returns the expected value
assert result is False
@pytest.mark.asyncio
@patch('openhands.core.cli.display_runtime_initialization_message')
@patch('openhands.core.cli.display_initialization_animation')
@patch('openhands.core.cli.create_agent')
@patch('openhands.core.cli.fetch_mcp_tools_from_config')
@patch('openhands.core.cli.create_runtime')
@patch('openhands.core.cli.create_controller')
@patch('openhands.core.cli.create_memory')
@patch('openhands.core.cli.run_agent_until_done')
@patch('openhands.core.cli.cleanup_session')
@patch('openhands.core.cli.initialize_repository_for_runtime')
async def test_run_session_with_initial_action(
mock_initialize_repo,
mock_cleanup_session,
mock_run_agent_until_done,
mock_create_memory,
mock_create_controller,
mock_create_runtime,
mock_fetch_mcp_tools,
mock_create_agent,
mock_display_animation,
mock_display_runtime_init,
mock_config,
mock_settings_store,
):
"""Test run_session function with an initial user action."""
loop = asyncio.get_running_loop()
# Mock initialize_repository_for_runtime to return a valid path
mock_initialize_repo.return_value = '/test/dir'
# Mock objects returned by the setup functions
mock_agent = AsyncMock()
mock_create_agent.return_value = mock_agent
mock_mcp_tools = []
mock_fetch_mcp_tools.return_value = mock_mcp_tools
mock_runtime = AsyncMock()
mock_runtime.event_stream = MagicMock()
mock_create_runtime.return_value = mock_runtime
mock_controller = AsyncMock()
mock_controller_task = MagicMock()
mock_create_controller.return_value = (mock_controller, mock_controller_task)
mock_memory = AsyncMock()
mock_create_memory.return_value = mock_memory
# Create an initial action
initial_action_content = 'Test initial message'
# Run the function with the initial action
with patch(
'openhands.core.cli.read_prompt_input', new_callable=AsyncMock
) as mock_read_prompt:
# Set up read_prompt_input to return a string that will trigger the command handler
mock_read_prompt.return_value = '/exit'
# Mock handle_commands to return values that will exit the loop
with patch(
'openhands.core.cli.handle_commands', new_callable=AsyncMock
) as mock_handle_commands:
mock_handle_commands.return_value = (
True,
False,
False,
) # close_repl, reload_microagents, new_session_requested
# Run the function
result = await cli.run_session(
loop,
mock_config,
mock_settings_store,
'/test/dir',
initial_action_content,
)
# Check that the initial action was added to the event stream
# It should be converted to a MessageAction in the code
mock_runtime.event_stream.add_event.assert_called_once()
call_args = mock_runtime.event_stream.add_event.call_args[0]
assert isinstance(call_args[0], MessageAction)
assert call_args[0].content == initial_action_content
assert call_args[1] == EventSource.USER
# Check that run_agent_until_done was called
mock_run_agent_until_done.assert_called_once()
# Check that cleanup_session was called
mock_cleanup_session.assert_called_once()
# Check that the function returns the expected value
assert result is False
@pytest.mark.asyncio
@patch('openhands.core.cli.parse_arguments')
@patch('openhands.core.cli.setup_config_from_args')
@patch('openhands.core.cli.FileSettingsStore.get_instance')
@patch('openhands.core.cli.check_folder_security_agreement')
@patch('openhands.core.cli.read_task')
@patch('openhands.core.cli.run_session')
@patch('openhands.core.cli.LLMSummarizingCondenserConfig')
@patch('openhands.core.cli.NoOpCondenserConfig')
async def test_main_without_task(
mock_noop_condenser,
mock_llm_condenser,
mock_run_session,
mock_read_task,
mock_check_security,
mock_get_settings_store,
mock_setup_config,
mock_parse_args,
):
"""Test main function without a task."""
loop = asyncio.get_running_loop()
# Mock arguments
mock_args = MagicMock()
mock_args.agent_cls = None
mock_args.llm_config = None
mock_parse_args.return_value = mock_args
# Mock config
mock_config = MagicMock()
mock_config.workspace_base = '/test/dir'
mock_config.cli_multiline_input = False
mock_setup_config.return_value = mock_config
# Mock settings store
mock_settings_store = AsyncMock()
mock_settings = MagicMock()
mock_settings.agent = 'test-agent'
mock_settings.llm_model = 'test-model'
mock_settings.llm_api_key = 'test-api-key'
mock_settings.llm_base_url = 'test-base-url'
mock_settings.confirmation_mode = True
mock_settings.enable_default_condenser = True
mock_settings_store.load.return_value = mock_settings
mock_get_settings_store.return_value = mock_settings_store
# Mock condenser config to return a mock instead of validating
mock_llm_condenser_instance = MagicMock()
mock_llm_condenser.return_value = mock_llm_condenser_instance
# Mock security check
mock_check_security.return_value = True
# Mock read_task to return no task
mock_read_task.return_value = None
# Mock run_session to return False (no new session requested)
mock_run_session.return_value = False
# Run the function
await cli.main(loop)
# Assertions
mock_parse_args.assert_called_once()
mock_setup_config.assert_called_once_with(mock_args)
mock_get_settings_store.assert_called_once()
mock_settings_store.load.assert_called_once()
mock_check_security.assert_called_once_with(mock_config, '/test/dir')
mock_read_task.assert_called_once()
# Check that run_session was called with expected arguments
mock_run_session.assert_called_once_with(
loop, mock_config, mock_settings_store, '/test/dir', None
)
@pytest.mark.asyncio
@patch('openhands.core.cli.parse_arguments')
@patch('openhands.core.cli.setup_config_from_args')
@patch('openhands.core.cli.FileSettingsStore.get_instance')
@patch('openhands.core.cli.check_folder_security_agreement')
@patch('openhands.core.cli.read_task')
@patch('openhands.core.cli.run_session')
@patch('openhands.core.cli.LLMSummarizingCondenserConfig')
@patch('openhands.core.cli.NoOpCondenserConfig')
async def test_main_with_task(
mock_noop_condenser,
mock_llm_condenser,
mock_run_session,
mock_read_task,
mock_check_security,
mock_get_settings_store,
mock_setup_config,
mock_parse_args,
):
"""Test main function with a task."""
loop = asyncio.get_running_loop()
# Mock arguments
mock_args = MagicMock()
mock_args.agent_cls = 'custom-agent'
mock_args.llm_config = 'custom-config'
mock_parse_args.return_value = mock_args
# Mock config
mock_config = MagicMock()
mock_config.workspace_base = '/test/dir'
mock_config.cli_multiline_input = False
mock_setup_config.return_value = mock_config
# Mock settings store
mock_settings_store = AsyncMock()
mock_settings = MagicMock()
mock_settings.agent = 'test-agent'
mock_settings.llm_model = 'test-model'
mock_settings.llm_api_key = 'test-api-key'
mock_settings.llm_base_url = 'test-base-url'
mock_settings.confirmation_mode = True
mock_settings.enable_default_condenser = False
mock_settings_store.load.return_value = mock_settings
mock_get_settings_store.return_value = mock_settings_store
# Mock condenser config to return a mock instead of validating
mock_noop_condenser_instance = MagicMock()
mock_noop_condenser.return_value = mock_noop_condenser_instance
# Mock security check
mock_check_security.return_value = True
# Mock read_task to return a task
task_str = 'Build a simple web app'
mock_read_task.return_value = task_str
# Mock run_session to return True and then False (one new session requested)
mock_run_session.side_effect = [True, False]
# Run the function
await cli.main(loop)
# Assertions
mock_parse_args.assert_called_once()
mock_setup_config.assert_called_once_with(mock_args)
mock_get_settings_store.assert_called_once()
mock_settings_store.load.assert_called_once()
mock_check_security.assert_called_once_with(mock_config, '/test/dir')
mock_read_task.assert_called_once()
# Verify that run_session was called twice:
# - First with the initial MessageAction
# - Second with None after new_session_requested=True
assert mock_run_session.call_count == 2
# First call should include a string with the task content
first_call_args = mock_run_session.call_args_list[0][0]
assert first_call_args[0] == loop
assert first_call_args[1] == mock_config
assert first_call_args[2] == mock_settings_store
assert first_call_args[3] == '/test/dir'
assert isinstance(first_call_args[4], str)
assert first_call_args[4] == task_str
# Second call should have None for the action
second_call_args = mock_run_session.call_args_list[1][0]
assert second_call_args[0] == loop
assert second_call_args[1] == mock_config
assert second_call_args[2] == mock_settings_store
assert second_call_args[3] == '/test/dir'
assert second_call_args[4] is None
@pytest.mark.asyncio
@patch('openhands.core.cli.parse_arguments')
@patch('openhands.core.cli.setup_config_from_args')
@patch('openhands.core.cli.FileSettingsStore.get_instance')
@patch('openhands.core.cli.check_folder_security_agreement')
@patch('openhands.core.cli.LLMSummarizingCondenserConfig')
@patch('openhands.core.cli.NoOpCondenserConfig')
async def test_main_security_check_fails(
mock_noop_condenser,
mock_llm_condenser,
mock_check_security,
mock_get_settings_store,
mock_setup_config,
mock_parse_args,
):
"""Test main function when security check fails."""
loop = asyncio.get_running_loop()
# Mock arguments
mock_args = MagicMock()
mock_parse_args.return_value = mock_args
# Mock config
mock_config = MagicMock()
mock_config.workspace_base = '/test/dir'
mock_setup_config.return_value = mock_config
# Mock settings store
mock_settings_store = AsyncMock()
mock_settings = MagicMock()
mock_settings.enable_default_condenser = False
mock_settings_store.load.return_value = mock_settings
mock_get_settings_store.return_value = mock_settings_store
# Mock condenser config to return a mock instead of validating
mock_noop_condenser_instance = MagicMock()
mock_noop_condenser.return_value = mock_noop_condenser_instance
# Mock security check to fail
mock_check_security.return_value = False
# Run the function
await cli.main(loop)
# Assertions
mock_parse_args.assert_called_once()
mock_setup_config.assert_called_once_with(mock_args)
mock_get_settings_store.assert_called_once()
mock_settings_store.load.assert_called_once()
mock_check_security.assert_called_once_with(mock_config, '/test/dir')
# Since security check fails, no further action should happen
# (This is an implicit assertion - we don't need to check further function calls)
@pytest.mark.asyncio
@patch('openhands.core.cli.parse_arguments')
@patch('openhands.core.cli.setup_config_from_args')
@patch('openhands.core.cli.FileSettingsStore.get_instance')
@patch('openhands.core.cli.check_folder_security_agreement')
@patch('openhands.core.cli.read_task')
@patch('openhands.core.cli.run_session')
@patch('openhands.core.cli.LLMSummarizingCondenserConfig')
@patch('openhands.core.cli.NoOpCondenserConfig')
async def test_config_loading_order(
mock_noop_condenser,
mock_llm_condenser,
mock_run_session,
mock_read_task,
mock_check_security,
mock_get_settings_store,
mock_setup_config,
mock_parse_args,
):
"""Test the order of configuration loading in the main function.
This test verifies:
1. Command line arguments override settings store values
2. Settings from store are used when command line args are not provided
3. Default condenser is configured correctly based on settings
"""
loop = asyncio.get_running_loop()
# Mock arguments with specific agent but no LLM config
mock_args = MagicMock()
mock_args.agent_cls = 'cmd-line-agent' # This should override settings
mock_args.llm_config = None # This should allow settings to be used
# Add a file property to avoid file I/O errors
mock_args.file = None
mock_parse_args.return_value = mock_args
# Mock read_task to return a dummy task
mock_read_task.return_value = 'Test task'
# Mock config with mock methods to track changes
mock_config = MagicMock()
mock_config.workspace_base = '/test/dir'
mock_config.cli_multiline_input = False
mock_config.get_llm_config = MagicMock(return_value=MagicMock())
mock_config.set_llm_config = MagicMock()
mock_config.get_agent_config = MagicMock(return_value=MagicMock())
mock_config.set_agent_config = MagicMock()
mock_setup_config.return_value = mock_config
# Mock settings store with specific values
mock_settings_store = AsyncMock()
mock_settings = MagicMock()
mock_settings.agent = 'settings-agent' # Should be overridden by cmd line
mock_settings.llm_model = 'settings-model' # Should be used (no cmd line)
mock_settings.llm_api_key = 'settings-api-key' # Should be used
mock_settings.llm_base_url = 'settings-base-url' # Should be used
mock_settings.confirmation_mode = True
mock_settings.enable_default_condenser = True # Test condenser setup
mock_settings_store.load.return_value = mock_settings
mock_get_settings_store.return_value = mock_settings_store
# Mock condenser configs
mock_llm_condenser_instance = MagicMock()
mock_llm_condenser.return_value = mock_llm_condenser_instance
# Mock security check and run_session to succeed
mock_check_security.return_value = True
mock_run_session.return_value = False # No new session requested
# Run the function
await cli.main(loop)
# Assertions for argument parsing and config setup
mock_parse_args.assert_called_once()
mock_setup_config.assert_called_once_with(mock_args)
mock_get_settings_store.assert_called_once()
mock_settings_store.load.assert_called_once()
# Verify agent is set from command line args (overriding settings)
assert mock_config.default_agent == 'cmd-line-agent'
# Verify LLM config is set from settings (since no cmd line arg)
assert mock_config.set_llm_config.called
llm_config_call = mock_config.set_llm_config.call_args[0][0]
assert llm_config_call.model == 'settings-model'
assert llm_config_call.api_key == 'settings-api-key'
assert llm_config_call.base_url == 'settings-base-url'
# Verify confirmation mode is set from settings
assert mock_config.security.confirmation_mode is True
# Verify default condenser is set up correctly
assert mock_config.set_agent_config.called
assert mock_llm_condenser.called
assert mock_config.enable_default_condenser is True
# Verify that run_session was called with the correct arguments
mock_run_session.assert_called_once()

Some files were not shown because too many files have changed in this diff Show More