mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
21 Commits
on-prem-te
...
self-hoste
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2399174e89 | ||
|
|
7c3f4891f8 | ||
|
|
49bb7bbaba | ||
|
|
10c1252cfe | ||
|
|
911867492c | ||
|
|
85a1b47c8d | ||
|
|
d6011829a3 | ||
|
|
9200e1dbd8 | ||
|
|
06ce12eff4 | ||
|
|
88fc26d9b0 | ||
|
|
99233ec153 | ||
|
|
ae9573a503 | ||
|
|
d1343539ba | ||
|
|
8bc206833a | ||
|
|
7cf61d8c0e | ||
|
|
f2725eeb3d | ||
|
|
1b63633030 | ||
|
|
107789b5a8 | ||
|
|
04bdea5faf | ||
|
|
2bad4ea3d2 | ||
|
|
1c4c477b3f |
2
.github/workflows/openhands-resolver.yml
vendored
2
.github/workflows/openhands-resolver.yml
vendored
@@ -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
|
||||
|
||||
|
||||
291
frontend/__tests__/routes/app-settings.test.tsx
Normal file
291
frontend/__tests__/routes/app-settings.test.tsx
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
461
frontend/__tests__/routes/git-settings.test.tsx
Normal file
461
frontend/__tests__/routes/git-settings.test.tsx
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
674
frontend/__tests__/routes/llm-settings.test.tsx
Normal file
674
frontend/__tests__/routes/llm-settings.test.tsx
Normal file
@@ -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({
|
||||
|
||||
254
frontend/package-lock.json
generated
254
frontend/package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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,60 @@
|
||||
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;
|
||||
onBaseDomainChange?: (value: string) => void;
|
||||
isGitHubTokenSet: boolean;
|
||||
name: string;
|
||||
baseDomainSet?: string | null;
|
||||
isSaas: boolean;
|
||||
}
|
||||
|
||||
export function GitHubTokenInput({
|
||||
onChange,
|
||||
onBaseDomainChange,
|
||||
isGitHubTokenSet,
|
||||
name,
|
||||
baseDomainSet,
|
||||
isSaas,
|
||||
}: GitHubTokenInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{!isSaas && (
|
||||
<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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingsInput
|
||||
onChange={onBaseDomainChange || (() => {})}
|
||||
label={t(I18nKey.GITHUB$BASE_DOMAIN_LABEL)}
|
||||
type="text"
|
||||
className="w-[680px]"
|
||||
placeholder={"github.com"}
|
||||
defaultValue={baseDomainSet ? baseDomainSet : undefined}
|
||||
/>
|
||||
|
||||
{!isSaas && <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,60 @@
|
||||
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;
|
||||
onBaseDomainChange?: (value: string) => void;
|
||||
isGitLabTokenSet: boolean;
|
||||
name: string;
|
||||
baseDomainSet?: string | null;
|
||||
isSaas: boolean;
|
||||
}
|
||||
|
||||
export function GitLabTokenInput({
|
||||
onChange,
|
||||
onBaseDomainChange,
|
||||
isGitLabTokenSet,
|
||||
name,
|
||||
baseDomainSet,
|
||||
isSaas,
|
||||
}: GitLabTokenInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{!isSaas && (
|
||||
<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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingsInput
|
||||
onChange={onBaseDomainChange || (() => {})}
|
||||
label={t(I18nKey.GITLAB$BASE_DOMAIN_LABEL)}
|
||||
type="text"
|
||||
className="w-[680px]"
|
||||
placeholder={"gitlab.com"}
|
||||
defaultValue={baseDomainSet ? baseDomainSet : undefined}
|
||||
/>
|
||||
|
||||
{!isSaas && <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 = () => {
|
||||
|
||||
@@ -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 {
|
||||
@@ -53,13 +54,11 @@ export const useSettings = () => {
|
||||
React.useEffect(() => {
|
||||
if (query.data?.PROVIDER_TOKENS_SET) {
|
||||
const providers = query.data.PROVIDER_TOKENS_SET;
|
||||
const setProviders = (
|
||||
Object.keys(providers) as Array<keyof typeof providers>
|
||||
).filter((key) => providers[key]);
|
||||
const setProviders = Object.keys(providers) as Array<
|
||||
keyof typeof providers
|
||||
>;
|
||||
setProviderTokensSet(setProviders);
|
||||
const atLeastOneSet = Object.values(query.data.PROVIDER_TOKENS_SET).some(
|
||||
(value) => value,
|
||||
);
|
||||
const atLeastOneSet = setProviders.length > 0;
|
||||
setProvidersAreSet(atLeastOneSet);
|
||||
}
|
||||
}, [query.data?.PROVIDER_TOKENS_SET, query.isFetched]);
|
||||
|
||||
@@ -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",
|
||||
@@ -93,8 +104,12 @@ export enum I18nKey {
|
||||
EXIT_PROJECT$TITLE = "EXIT_PROJECT$TITLE",
|
||||
LANGUAGE$LABEL = "LANGUAGE$LABEL",
|
||||
GITHUB$TOKEN_LABEL = "GITHUB$TOKEN_LABEL",
|
||||
GITHUB$BASE_DOMAIN_LABEL = "GITHUB$BASE_DOMAIN_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",
|
||||
@@ -436,7 +451,11 @@ export enum I18nKey {
|
||||
MODEL_SELECTOR$VERIFIED = "MODEL_SELECTOR$VERIFIED",
|
||||
MODEL_SELECTOR$OTHERS = "MODEL_SELECTOR$OTHERS",
|
||||
GITLAB$TOKEN_LABEL = "GITLAB$TOKEN_LABEL",
|
||||
GITLAB$BASE_DOMAIN_LABEL = "GITLAB$BASE_DOMAIN_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",
|
||||
|
||||
@@ -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": "クリップボードにコピー",
|
||||
@@ -1404,6 +1569,21 @@
|
||||
"tr": "GitHub Jetonu",
|
||||
"de": "GitHub-Token"
|
||||
},
|
||||
"GITHUB$BASE_DOMAIN_LABEL": {
|
||||
"en": "GitHub Base Domain",
|
||||
"ja": "GitHub ベースドメイン",
|
||||
"zh-CN": "GitHub 基础域名",
|
||||
"zh-TW": "GitHub 基礎網域",
|
||||
"ko-KR": "GitHub 기본 도메인",
|
||||
"no": "GitHub Base Domain",
|
||||
"it": "Dominio Base GitHub",
|
||||
"pt": "Domínio Base do GitHub",
|
||||
"es": "Dominio Base de GitHub",
|
||||
"ar": "نطاق GitHub الأساسي",
|
||||
"fr": "Domaine de Base GitHub",
|
||||
"tr": "GitHub Temel Alan Adı",
|
||||
"de": "GitHub Basis-Domain"
|
||||
},
|
||||
"GITHUB$TOKEN_OPTIONAL": {
|
||||
"en": "GitHub Token (Optional)",
|
||||
"ja": "GitHubトークン(任意)",
|
||||
@@ -1434,6 +1614,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": "こちら",
|
||||
@@ -6259,6 +6484,21 @@
|
||||
"tr": "GitLab Jetonu",
|
||||
"de": "GitLab-Token"
|
||||
},
|
||||
"GITLAB$BASE_DOMAIN_LABEL": {
|
||||
"en": "GitLab Base Domain",
|
||||
"ja": "GitLab ベースドメイン",
|
||||
"zh-CN": "GitLab 基础域名",
|
||||
"zh-TW": "GitLab 基礎網域",
|
||||
"ko-KR": "GitLab 기본 도메인",
|
||||
"no": "GitLab Base Domain",
|
||||
"it": "Dominio Base GitLab",
|
||||
"pt": "Domínio Base do GitLab",
|
||||
"es": "Dominio Base de GitLab",
|
||||
"ar": "نطاق GitLab الأساسي",
|
||||
"fr": "Domaine de Base GitLab",
|
||||
"tr": "GitLab Temel Alan Adı",
|
||||
"de": "GitLab Basis-Domain"
|
||||
},
|
||||
"GITLAB$GET_TOKEN": {
|
||||
"en": "Generate a token on",
|
||||
"ja": "トークンを生成する",
|
||||
@@ -6274,6 +6514,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": "または参照",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"),
|
||||
]),
|
||||
|
||||
@@ -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;
|
||||
@@ -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
frontend/src/routes/app-settings.tsx
Normal file
150
frontend/src/routes/app-settings.tsx
Normal 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;
|
||||
170
frontend/src/routes/git-settings.tsx
Normal file
170
frontend/src/routes/git-settings.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
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";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
function GitSettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { mutate: saveSettings, isPending } = useSaveSettings();
|
||||
const { providerTokensSet } = useAuth();
|
||||
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 [githubBaseDomainInputHasValue, setGithubBaseDomainInputHasValue] =
|
||||
React.useState(false);
|
||||
const [gitlabBaseDomainInputHasValue, setGitlabBaseDomainInputHasValue] =
|
||||
React.useState(false);
|
||||
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
const isGitHubTokenSet = providerTokensSet.includes("github");
|
||||
const isGitLabTokenSet = providerTokensSet.includes("gitlab");
|
||||
|
||||
const existingGithubBaseDomain = settings?.PROVIDER_TOKENS_SET["github"];
|
||||
const existingGitlabBaseDomain = 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() || "";
|
||||
const githubBaseDomain =
|
||||
formData.get("github-base-domain-input")?.toString() || "";
|
||||
const gitlabBaseDomain =
|
||||
formData.get("gitlab-base-domain-input")?.toString() || "";
|
||||
|
||||
saveSettings(
|
||||
{
|
||||
provider_tokens: {
|
||||
github: {
|
||||
token: githubToken,
|
||||
base_domain: githubBaseDomain || null,
|
||||
},
|
||||
gitlab: {
|
||||
token: gitlabToken,
|
||||
base_domain: gitlabBaseDomain || null,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = retrieveAxiosErrorMessage(error);
|
||||
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
|
||||
},
|
||||
onSettled: () => {
|
||||
setGithubTokenInputHasValue(false);
|
||||
setGitlabTokenInputHasValue(false);
|
||||
setGithubBaseDomainInputHasValue(false);
|
||||
setGitlabBaseDomainInputHasValue(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const formIsClean =
|
||||
!githubTokenInputHasValue &&
|
||||
!gitlabTokenInputHasValue &&
|
||||
!githubBaseDomainInputHasValue &&
|
||||
!gitlabBaseDomainInputHasValue;
|
||||
|
||||
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!} />
|
||||
)}
|
||||
|
||||
{!isLoading && (
|
||||
<div className="p-9 flex flex-col gap-12">
|
||||
<GitHubTokenInput
|
||||
name="github-token-input"
|
||||
baseDomainSet={existingGithubBaseDomain}
|
||||
isGitHubTokenSet={isGitHubTokenSet}
|
||||
onChange={(value) => {
|
||||
setGithubTokenInputHasValue(!!value);
|
||||
}}
|
||||
onBaseDomainChange={(value) => {
|
||||
setGithubBaseDomainInputHasValue(!!value);
|
||||
}}
|
||||
isSaas={isSaas}
|
||||
/>
|
||||
|
||||
<GitLabTokenInput
|
||||
name="gitlab-token-input"
|
||||
baseDomainSet={existingGitlabBaseDomain}
|
||||
isGitLabTokenSet={isGitLabTokenSet}
|
||||
onChange={(value) => {
|
||||
setGitlabTokenInputHasValue(!!value);
|
||||
}}
|
||||
onBaseDomainChange={(value) => {
|
||||
setGitlabBaseDomainInputHasValue(!!value);
|
||||
}}
|
||||
isSaas={isSaas}
|
||||
/>
|
||||
</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;
|
||||
430
frontend/src/routes/llm-settings.tsx
Normal file
430
frontend/src/routes/llm-settings.tsx
Normal 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;
|
||||
@@ -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 />
|
||||
|
||||
@@ -11,13 +11,13 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
CONFIRMATION_MODE: false,
|
||||
SECURITY_ANALYZER: "",
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR: 1,
|
||||
PROVIDER_TOKENS_SET: { github: false, gitlab: false },
|
||||
PROVIDER_TOKENS_SET: { github: null, gitlab: null },
|
||||
ENABLE_DEFAULT_CONDENSER: true,
|
||||
ENABLE_SOUND_NOTIFICATIONS: false,
|
||||
USER_CONSENTS_TO_ANALYTICS: false,
|
||||
PROVIDER_TOKENS: {
|
||||
github: "",
|
||||
gitlab: "",
|
||||
github: { token: "", base_domain: null },
|
||||
gitlab: { token: "", base_domain: null },
|
||||
},
|
||||
IS_NEW_USER: true,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ export const ProviderOptions = {
|
||||
|
||||
export type Provider = keyof typeof ProviderOptions;
|
||||
|
||||
export type ProviderToken = {
|
||||
token: string;
|
||||
base_domain: string | null;
|
||||
};
|
||||
|
||||
export type Settings = {
|
||||
LLM_MODEL: string;
|
||||
LLM_BASE_URL: string;
|
||||
@@ -14,11 +19,11 @@ export type Settings = {
|
||||
CONFIRMATION_MODE: boolean;
|
||||
SECURITY_ANALYZER: string;
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR: number | null;
|
||||
PROVIDER_TOKENS_SET: Record<Provider, boolean>;
|
||||
PROVIDER_TOKENS_SET: Record<Provider, string | null>;
|
||||
ENABLE_DEFAULT_CONDENSER: boolean;
|
||||
ENABLE_SOUND_NOTIFICATIONS: boolean;
|
||||
USER_CONSENTS_TO_ANALYTICS: boolean | null;
|
||||
PROVIDER_TOKENS: Record<Provider, string>;
|
||||
PROVIDER_TOKENS: Record<Provider, ProviderToken>;
|
||||
IS_NEW_USER?: boolean;
|
||||
};
|
||||
|
||||
@@ -35,17 +40,17 @@ export type ApiSettings = {
|
||||
enable_default_condenser: boolean;
|
||||
enable_sound_notifications: boolean;
|
||||
user_consents_to_analytics: boolean | null;
|
||||
provider_tokens: Record<Provider, string>;
|
||||
provider_tokens_set: Record<Provider, boolean>;
|
||||
provider_tokens: Record<Provider, ProviderToken>;
|
||||
provider_tokens_set: Record<Provider, string | null>;
|
||||
};
|
||||
|
||||
export type PostSettings = Settings & {
|
||||
provider_tokens: Record<Provider, string>;
|
||||
provider_tokens: Record<Provider, ProviderToken>;
|
||||
user_consents_to_analytics: boolean | null;
|
||||
llm_api_key?: string | null;
|
||||
};
|
||||
|
||||
export type PostApiSettings = ApiSettings & {
|
||||
provider_tokens: Record<Provider, string>;
|
||||
provider_tokens: Record<Provider, ProviderToken>;
|
||||
user_consents_to_analytics: boolean | null;
|
||||
};
|
||||
|
||||
@@ -6,17 +6,10 @@
|
||||
*/
|
||||
export const generateAuthUrl = (identityProvider: string, requestUrl: URL) => {
|
||||
const redirectUri = `${requestUrl.origin}/oauth/keycloak/callback`;
|
||||
let authUrl = requestUrl.hostname
|
||||
const authUrl = requestUrl.hostname
|
||||
.replace(/(^|\.)staging\.all-hands\.dev$/, "$1auth.staging.all-hands.dev")
|
||||
.replace(/(^|\.)app\.all-hands\.dev$/, "auth.app.all-hands.dev")
|
||||
.replace(/(^|\.)localhost$/, "localhost:8080");
|
||||
|
||||
// If no replacements matched, prepend "auth." (excluding localhost)
|
||||
if (authUrl === requestUrl.hostname && requestUrl.hostname !== "localhost") {
|
||||
authUrl = `auth.${requestUrl.hostname}`;
|
||||
}
|
||||
.replace(/(^|\.)localhost$/, "auth.staging.all-hands.dev");
|
||||
const scope = "openid email profile"; // OAuth scope - not user-facing
|
||||
const isLocalhost = requestUrl.hostname === "localhost";
|
||||
const protocol = isLocalhost ? "http" : "https";
|
||||
return `${protocol}://${authUrl}/realms/testing/protocol/openid-connect/auth?client_id=testing&kc_idp_hint=${identityProvider}&response_type=code&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}`;
|
||||
return `https://${authUrl}/realms/allhands/protocol/openid-connect/auth?client_id=allhands&kc_idp_hint=${identityProvider}&response_type=code&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}`;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
name: add_openhands_repo_instruction
|
||||
type: task
|
||||
version: 1.0.0
|
||||
author: openhands
|
||||
agent: CodeActAgent
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
name: address_pr_comments
|
||||
type: task
|
||||
version: 1.0.0
|
||||
author: openhands
|
||||
agent: CodeActAgent
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
name: get_test_to_pass
|
||||
type: task
|
||||
version: 1.0.0
|
||||
author: openhands
|
||||
agent: CodeActAgent
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
name: update_pr_description
|
||||
type: task
|
||||
version: 1.0.0
|
||||
author: openhands
|
||||
agent: CodeActAgent
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
name: update_test_for_new_implementation
|
||||
type: task
|
||||
version: 1.0.0
|
||||
author: openhands
|
||||
agent: CodeActAgent
|
||||
@@ -1,32 +1,37 @@
|
||||
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_banner,
|
||||
display_event,
|
||||
display_initial_user_prompt,
|
||||
display_initialization_animation,
|
||||
display_runtime_initialization_message,
|
||||
display_welcome_message,
|
||||
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 +44,64 @@ 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()
|
||||
|
||||
# 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(
|
||||
@@ -690,41 +125,29 @@ async def main(loop: asyncio.AbstractEventLoop):
|
||||
usage_metrics = UsageMetrics()
|
||||
|
||||
async def prompt_for_next_task():
|
||||
nonlocal reload_microagents
|
||||
nonlocal reload_microagents, new_session_requested
|
||||
while True:
|
||||
next_message = await read_prompt_input(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
|
||||
@@ -785,10 +208,6 @@ 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()
|
||||
|
||||
@@ -800,7 +219,10 @@ async def main(loop: asyncio.AbstractEventLoop):
|
||||
|
||||
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())
|
||||
@@ -809,6 +231,79 @@ async def main(loop: asyncio.AbstractEventLoop):
|
||||
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 +324,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()
|
||||
|
||||
283
openhands/core/cli_commands.py
Normal file
283
openhands/core/cli_commands.py
Normal file
@@ -0,0 +1,283 @@
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
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
openhands/core/cli_settings.py
Normal file
348
openhands/core/cli_settings.py
Normal 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)
|
||||
580
openhands/core/cli_tui.py
Normal file
580
openhands/core/cli_tui.py
Normal file
@@ -0,0 +1,580 @@
|
||||
# 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.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.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.events import EventSource
|
||||
from openhands.events.action import (
|
||||
Action,
|
||||
ActionConfirmationStatus,
|
||||
CmdRunAction,
|
||||
FileEditAction,
|
||||
MessageAction,
|
||||
)
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.observation import (
|
||||
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',
|
||||
}
|
||||
|
||||
|
||||
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, 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('')
|
||||
print_formatted_text(HTML(f'<grey>{banner_text} {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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# Common input functions
|
||||
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}',
|
||||
)
|
||||
|
||||
|
||||
prompt_session = PromptSession(style=DEFAULT_STYLE)
|
||||
|
||||
# RPrompt animation related variables
|
||||
SPINNER_FRAMES = [
|
||||
'[ ■□□□ ]',
|
||||
'[ □■□□ ]',
|
||||
'[ □□■□ ]',
|
||||
'[ □□□■ ]',
|
||||
'[ □□■□ ]',
|
||||
'[ □■□□ ]',
|
||||
]
|
||||
ANIMATION_INTERVAL = 0.2 # seconds
|
||||
|
||||
current_frame_index = 0
|
||||
last_update_time = time.monotonic()
|
||||
|
||||
|
||||
# RPrompt function for the user confirmation
|
||||
def get_rprompt() -> FormattedText:
|
||||
"""
|
||||
Returns the current animation frame for the rprompt.
|
||||
This function is called by prompt_toolkit during rendering.
|
||||
"""
|
||||
global current_frame_index, last_update_time
|
||||
|
||||
# Only update the frame if enough time has passed
|
||||
# This prevents excessive recalculation during rendering
|
||||
now = time.monotonic()
|
||||
if now - last_update_time > ANIMATION_INTERVAL:
|
||||
current_frame_index = (current_frame_index + 1) % len(SPINNER_FRAMES)
|
||||
last_update_time = now
|
||||
|
||||
# Return the frame wrapped in FormattedText
|
||||
return FormattedText(
|
||||
[
|
||||
('', ' '), # Add a space before the spinner
|
||||
(COLOR_GOLD, SPINNER_FRAMES[current_frame_index]),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def read_prompt_input(multiline=False):
|
||||
try:
|
||||
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(
|
||||
'Enter your message and press Ctrl+D to finish:\n',
|
||||
multiline=True,
|
||||
key_bindings=kb,
|
||||
)
|
||||
else:
|
||||
with patch_stdout():
|
||||
print_formatted_text('')
|
||||
prompt_session.completer = CommandCompleter()
|
||||
message = await prompt_session.prompt_async(
|
||||
'> ',
|
||||
)
|
||||
return message
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
return '/exit'
|
||||
|
||||
|
||||
async def read_confirmation_input():
|
||||
try:
|
||||
with patch_stdout():
|
||||
prompt_session.completer = None
|
||||
confirmation = await prompt_session.prompt_async(
|
||||
'Proceed with action? (y)es/(n)o > ',
|
||||
rprompt=get_rprompt,
|
||||
refresh_interval=ANIMATION_INTERVAL / 2,
|
||||
)
|
||||
prompt_session.rprompt = None
|
||||
confirmation = confirmation.strip().lower()
|
||||
return confirmation in ['y', 'yes']
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
return False
|
||||
|
||||
|
||||
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
openhands/core/cli_utils.py
Normal file
152
openhands/core/cli_utils.py
Normal 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)
|
||||
@@ -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'}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ class GitHubService(BaseGitService, GitService):
|
||||
if token:
|
||||
self.token = token
|
||||
|
||||
if base_domain:
|
||||
if base_domain and base_domain != "github.com":
|
||||
self.BASE_URL = f'https://{base_domain}/api/v3'
|
||||
|
||||
@property
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,6 +34,7 @@ from openhands.server.types import AppMode
|
||||
class ProviderToken(BaseModel):
|
||||
token: SecretStr | None = Field(default=None)
|
||||
user_id: str | None = Field(default=None)
|
||||
base_domain: str | None = Field(default=None)
|
||||
|
||||
model_config = {
|
||||
'frozen': True, # Makes the entire model immutable
|
||||
@@ -43,15 +44,20 @@ class ProviderToken(BaseModel):
|
||||
@classmethod
|
||||
def from_value(cls, token_value: ProviderToken | dict[str, str]) -> ProviderToken:
|
||||
"""Factory method to create a ProviderToken from various input types"""
|
||||
if isinstance(token_value, ProviderToken):
|
||||
if isinstance(token_value, cls):
|
||||
return token_value
|
||||
elif isinstance(token_value, dict):
|
||||
token_str = token_value.get('token')
|
||||
user_id = token_value.get('user_id')
|
||||
return cls(token=SecretStr(token_str), user_id=user_id)
|
||||
base_domain = token_value.get('base_domain')
|
||||
return cls(
|
||||
token=SecretStr(token_str) if token_str is not None else None,
|
||||
user_id=user_id,
|
||||
base_domain=base_domain,
|
||||
)
|
||||
|
||||
else:
|
||||
raise ValueError('Unsupport Provider token type')
|
||||
raise ValueError('Unsupported Provider token type')
|
||||
|
||||
|
||||
PROVIDER_TOKEN_TYPE = MappingProxyType[ProviderType, ProviderToken]
|
||||
@@ -98,6 +104,7 @@ class SecretStore(BaseModel):
|
||||
if expose_secrets
|
||||
else pydantic_encoder(provider_token.token),
|
||||
'user_id': provider_token.user_id,
|
||||
'base_domain': provider_token.base_domain,
|
||||
}
|
||||
|
||||
return tokens
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ from typing import Any
|
||||
import httpx
|
||||
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.utils.http_session import HttpSession
|
||||
from openhands.utils.tenacity_stop import stop_if_should_exit
|
||||
|
||||
@@ -42,7 +41,6 @@ def send_request(
|
||||
timeout: int = 10,
|
||||
**kwargs: Any,
|
||||
) -> httpx.Response:
|
||||
logger.info(f'sending {method} request to {url} with args {kwargs}')
|
||||
response = session.request(method, url, timeout=timeout, **kwargs)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -15,12 +15,11 @@ 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,
|
||||
get_user_settings,
|
||||
get_user_settings_store,
|
||||
)
|
||||
@@ -31,7 +30,6 @@ app = APIRouter(prefix='/api')
|
||||
|
||||
@app.get('/settings', response_model=GETSettingsModel)
|
||||
async def load_settings(
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
settings: Settings | None = Depends(get_user_settings),
|
||||
) -> GETSettingsModel | JSONResponse:
|
||||
@@ -43,18 +41,10 @@ async def load_settings(
|
||||
)
|
||||
|
||||
provider_tokens_set = {}
|
||||
|
||||
if bool(user_id):
|
||||
provider_tokens_set[ProviderType.GITHUB.value] = True
|
||||
|
||||
if provider_tokens:
|
||||
all_provider_types = [provider.value for provider in ProviderType]
|
||||
provider_tokens_types = [provider.value for provider in provider_tokens]
|
||||
for provider_type in all_provider_types:
|
||||
if provider_type in provider_tokens_types:
|
||||
provider_tokens_set[provider_type] = True
|
||||
else:
|
||||
provider_tokens_set[provider_type] = False
|
||||
for provider_type, provider_token in provider_tokens.items():
|
||||
if provider_token.token or provider_token.user_id:
|
||||
provider_tokens_set[provider_type] = provider_token.base_domain
|
||||
|
||||
settings_with_token_data = GETSettingsModel(
|
||||
**settings.model_dump(exclude='secrets_store'),
|
||||
@@ -218,66 +208,80 @@ async def reset_settings() -> JSONResponse:
|
||||
)
|
||||
|
||||
|
||||
async def check_provider_tokens(settings: POSTSettingsModel) -> str:
|
||||
async def check_provider_tokens(settings: POSTSettingsModel, existing_settings: Settings | None) -> str:
|
||||
if settings.provider_tokens:
|
||||
# Remove extraneous token types
|
||||
provider_types = [provider.value for provider in ProviderType]
|
||||
provider_types = [provider for provider in ProviderType]
|
||||
settings.provider_tokens = {
|
||||
k: v for k, v in settings.provider_tokens.items() if k in provider_types
|
||||
}
|
||||
|
||||
# Determine whether tokens are valid
|
||||
for token_type, token_value in settings.provider_tokens.items():
|
||||
if token_value:
|
||||
confirmed_token_type = await validate_provider_token(
|
||||
SecretStr(token_value)
|
||||
)
|
||||
if not confirmed_token_type or confirmed_token_type.value != token_type:
|
||||
return f'Invalid token. Please make sure it is a valid {token_type} token.'
|
||||
for provider_type, provider_token in settings.provider_tokens.items():
|
||||
token_value = provider_token
|
||||
existing_token = existing_settings.secrets_store.provider_tokens.get(provider_type, None) if existing_settings else None
|
||||
|
||||
# Use incoming value otherwise default to existing value
|
||||
token = SecretStr("")
|
||||
if token_value.token:
|
||||
token = token_value.token
|
||||
elif existing_token and existing_token.token:
|
||||
token = existing_token.token
|
||||
|
||||
if not token:
|
||||
continue
|
||||
|
||||
base_domain = provider_token.base_domain # FE should always send latest base_domain param
|
||||
confirmed_token_type = await validate_provider_token(
|
||||
token,
|
||||
base_domain
|
||||
)
|
||||
|
||||
|
||||
if not confirmed_token_type or confirmed_token_type != provider_type:
|
||||
return f'Invalid {provider_type.value} token or base domain.'
|
||||
|
||||
return ''
|
||||
|
||||
|
||||
async def store_provider_tokens(
|
||||
settings: POSTSettingsModel, settings_store: SettingsStore
|
||||
settings: POSTSettingsModel, existing_settings: Settings
|
||||
):
|
||||
existing_settings = await settings_store.load()
|
||||
if existing_settings:
|
||||
if settings.provider_tokens:
|
||||
if existing_settings.secrets_store:
|
||||
existing_providers = [
|
||||
provider.value
|
||||
for provider in existing_settings.secrets_store.provider_tokens
|
||||
]
|
||||
existing_providers = [
|
||||
provider
|
||||
for provider in existing_settings.secrets_store.provider_tokens
|
||||
]
|
||||
|
||||
# Merge incoming settings store with the existing one
|
||||
for provider, token_value in list(settings.provider_tokens.items()):
|
||||
if provider in existing_providers and not token_value:
|
||||
provider_type = ProviderType(provider)
|
||||
existing_token = (
|
||||
existing_settings.secrets_store.provider_tokens.get(
|
||||
provider_type
|
||||
)
|
||||
# Merge incoming settings store with the existing one
|
||||
for provider, token_value in settings.provider_tokens.items():
|
||||
if provider in existing_providers and not token_value.token:
|
||||
provider_type = ProviderType(provider)
|
||||
existing_token = (
|
||||
existing_settings.secrets_store.provider_tokens.get(
|
||||
provider_type
|
||||
)
|
||||
if existing_token and existing_token.token:
|
||||
settings.provider_tokens[provider] = (
|
||||
existing_token.token.get_secret_value()
|
||||
)
|
||||
)
|
||||
|
||||
if existing_token:
|
||||
updated_token = ProviderToken(
|
||||
token=existing_token.token,
|
||||
user_id=existing_token.user_id,
|
||||
base_domain=token_value.base_domain
|
||||
)
|
||||
|
||||
settings.provider_tokens[provider] = updated_token
|
||||
|
||||
else: # nothing passed in means keep current settings
|
||||
provider_tokens = existing_settings.secrets_store.provider_tokens
|
||||
settings.provider_tokens = {
|
||||
provider.value: data.token.get_secret_value() if data.token else None
|
||||
for provider, data in provider_tokens.items()
|
||||
}
|
||||
settings.provider_tokens = dict(existing_settings.secrets_store.provider_tokens)
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
async def store_llm_settings(
|
||||
settings: POSTSettingsModel, settings_store: SettingsStore
|
||||
settings: POSTSettingsModel, existing_settings: Settings
|
||||
) -> POSTSettingsModel:
|
||||
existing_settings = await settings_store.load()
|
||||
|
||||
# Convert to Settings model and merge with existing settings
|
||||
if existing_settings:
|
||||
# Keep existing LLM settings if not provided
|
||||
@@ -295,9 +299,10 @@ async def store_llm_settings(
|
||||
async def store_settings(
|
||||
settings: POSTSettingsModel,
|
||||
settings_store: SettingsStore = Depends(get_user_settings_store),
|
||||
existing_settings: Settings | None = Depends(get_user_settings),
|
||||
) -> JSONResponse:
|
||||
# Check provider tokens are valid
|
||||
provider_err_msg = await check_provider_tokens(settings)
|
||||
provider_err_msg = await check_provider_tokens(settings, existing_settings)
|
||||
if provider_err_msg:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -305,11 +310,9 @@ async def store_settings(
|
||||
)
|
||||
|
||||
try:
|
||||
existing_settings = await settings_store.load()
|
||||
|
||||
# Convert to Settings model and merge with existing settings
|
||||
if existing_settings:
|
||||
settings = await store_llm_settings(settings, settings_store)
|
||||
settings = await store_llm_settings(settings, existing_settings)
|
||||
|
||||
# Keep existing analytics consent if not provided
|
||||
if settings.user_consents_to_analytics is None:
|
||||
@@ -317,7 +320,7 @@ async def store_settings(
|
||||
existing_settings.user_consents_to_analytics
|
||||
)
|
||||
|
||||
settings = await store_provider_tokens(settings, settings_store)
|
||||
settings = await store_provider_tokens(settings, existing_settings)
|
||||
|
||||
# Update sandbox config with new settings
|
||||
if settings.remote_runtime_resource_factor is not None:
|
||||
@@ -357,17 +360,9 @@ def convert_to_settings(settings_with_token_data: POSTSettingsModel) -> Settings
|
||||
|
||||
# Create new provider tokens immutably
|
||||
if settings_with_token_data.provider_tokens:
|
||||
tokens = {}
|
||||
for token_type, token_value in settings_with_token_data.provider_tokens.items():
|
||||
if token_value:
|
||||
provider = ProviderType(token_type)
|
||||
tokens[provider] = ProviderToken(
|
||||
token=SecretStr(token_value), user_id=None
|
||||
)
|
||||
|
||||
# Create new SecretStore with tokens
|
||||
settings = settings.model_copy(
|
||||
update={'secrets_store': SecretStore(provider_tokens=tokens)}
|
||||
update={'secrets_store': SecretStore(provider_tokens=settings_with_token_data.provider_tokens)}
|
||||
)
|
||||
|
||||
return settings
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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}'
|
||||
|
||||
@@ -2,124 +2,12 @@ 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.integrations.provider import ProviderToken
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
|
||||
|
||||
class POSTSettingsModel(Settings):
|
||||
@@ -127,7 +15,7 @@ class POSTSettingsModel(Settings):
|
||||
Settings for POST requests
|
||||
"""
|
||||
|
||||
provider_tokens: dict[str, str] = {}
|
||||
provider_tokens: dict[ProviderType, ProviderToken] = {}
|
||||
|
||||
|
||||
class POSTSettingsCustomSecrets(BaseModel):
|
||||
@@ -143,9 +31,14 @@ class GETSettingsModel(Settings):
|
||||
Settings with additional token data for the frontend
|
||||
"""
|
||||
|
||||
provider_tokens_set: dict[str, bool] | None = None
|
||||
provider_tokens_set: dict[ProviderType, str | None] | None = (
|
||||
None # Provider Type and base domain key-value pair
|
||||
)
|
||||
llm_api_key_set: bool
|
||||
|
||||
class Config:
|
||||
use_enum_values = True
|
||||
|
||||
|
||||
class GETSettingsCustomSecrets(BaseModel):
|
||||
"""
|
||||
|
||||
122
openhands/storage/data_models/settings.py
Normal file
122
openhands/storage/data_models/settings.py
Normal 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
|
||||
|
||||
@@ -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
openhands/utils/llm.py
Normal file
56
openhands/utils/llm.py
Normal 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)))
|
||||
150
poetry.lock
generated
150
poetry.lock
generated
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from io import StringIO
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from prompt_toolkit.application import create_app_session
|
||||
from prompt_toolkit.input import create_pipe_input
|
||||
from prompt_toolkit.output import create_output
|
||||
|
||||
from openhands.core.cli import main
|
||||
from openhands.core.config import AppConfig
|
||||
from openhands.events.action import MessageAction
|
||||
from openhands.events.event import EventSource
|
||||
|
||||
|
||||
class MockEventStream:
|
||||
def __init__(self):
|
||||
self._subscribers = {}
|
||||
self.cur_id = 0
|
||||
|
||||
def subscribe(self, subscriber_id, callback, callback_id):
|
||||
if subscriber_id not in self._subscribers:
|
||||
self._subscribers[subscriber_id] = {}
|
||||
self._subscribers[subscriber_id][callback_id] = callback
|
||||
|
||||
def unsubscribe(self, subscriber_id, callback_id):
|
||||
if (
|
||||
subscriber_id in self._subscribers
|
||||
and callback_id in self._subscribers[subscriber_id]
|
||||
):
|
||||
del self._subscribers[subscriber_id][callback_id]
|
||||
|
||||
def add_event(self, event, source):
|
||||
event._id = self.cur_id
|
||||
self.cur_id += 1
|
||||
event._source = source
|
||||
event._timestamp = datetime.now().isoformat()
|
||||
|
||||
for subscriber_id in self._subscribers:
|
||||
for callback_id, callback in self._subscribers[subscriber_id].items():
|
||||
callback(event)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_agent():
|
||||
with patch('openhands.core.cli.create_agent') as mock_create_agent:
|
||||
mock_agent_instance = AsyncMock()
|
||||
mock_agent_instance.name = 'test-agent'
|
||||
mock_agent_instance.llm = AsyncMock()
|
||||
mock_agent_instance.llm.config = AsyncMock()
|
||||
mock_agent_instance.llm.config.model = 'test-model'
|
||||
mock_agent_instance.llm.config.base_url = 'http://test'
|
||||
mock_agent_instance.llm.config.max_message_chars = 1000
|
||||
mock_agent_instance.config = AsyncMock()
|
||||
mock_agent_instance.config.disabled_microagents = []
|
||||
mock_agent_instance.sandbox_plugins = []
|
||||
mock_agent_instance.prompt_manager = AsyncMock()
|
||||
mock_create_agent.return_value = mock_agent_instance
|
||||
yield mock_agent_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_controller():
|
||||
with patch('openhands.core.cli.create_controller') as mock_create_controller:
|
||||
mock_controller_instance = AsyncMock()
|
||||
mock_controller_instance.state.agent_state = None
|
||||
# Mock run_until_done to finish immediately
|
||||
mock_controller_instance.run_until_done = AsyncMock(return_value=None)
|
||||
mock_create_controller.return_value = (mock_controller_instance, None)
|
||||
yield mock_controller_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
with patch('openhands.core.cli.parse_arguments') as mock_parse_args:
|
||||
args = Mock()
|
||||
args.file = None
|
||||
args.task = None
|
||||
args.directory = None
|
||||
mock_parse_args.return_value = args
|
||||
with patch('openhands.core.cli.setup_config_from_args') as mock_setup_config:
|
||||
mock_config = AppConfig()
|
||||
mock_config.cli_multiline_input = False
|
||||
mock_config.security = Mock()
|
||||
mock_config.security.confirmation_mode = False
|
||||
mock_config.sandbox = Mock()
|
||||
mock_config.sandbox.selected_repo = None
|
||||
mock_config.workspace_base = '/test'
|
||||
mock_setup_config.return_value = mock_config
|
||||
yield mock_config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_memory():
|
||||
with patch('openhands.core.cli.create_memory') as mock_create_memory:
|
||||
mock_memory_instance = AsyncMock()
|
||||
mock_create_memory.return_value = mock_memory_instance
|
||||
yield mock_memory_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_read_task():
|
||||
with patch('openhands.core.cli.read_task') as mock_read_task:
|
||||
mock_read_task.return_value = None
|
||||
yield mock_read_task
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_runtime():
|
||||
with patch('openhands.core.cli.create_runtime') as mock_create_runtime:
|
||||
mock_runtime_instance = AsyncMock()
|
||||
|
||||
mock_event_stream = MockEventStream()
|
||||
mock_runtime_instance.event_stream = mock_event_stream
|
||||
|
||||
mock_runtime_instance.connect = AsyncMock()
|
||||
|
||||
# Ensure status_callback is None
|
||||
mock_runtime_instance.status_callback = None
|
||||
# Mock get_microagents_from_selected_repo
|
||||
mock_runtime_instance.get_microagents_from_selected_repo = Mock(return_value=[])
|
||||
mock_create_runtime.return_value = mock_runtime_instance
|
||||
yield mock_runtime_instance
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_basic_prompt(
|
||||
mock_runtime, mock_controller, mock_config, mock_agent, mock_memory, mock_read_task
|
||||
):
|
||||
buffer = StringIO()
|
||||
|
||||
with patch('openhands.core.cli.manage_openhands_file', return_value=True):
|
||||
with patch('openhands.core.cli.cli_confirm', return_value=True):
|
||||
with create_app_session(
|
||||
input=create_pipe_input(), output=create_output(stdout=buffer)
|
||||
):
|
||||
mock_controller.status_callback = None
|
||||
|
||||
main_task = asyncio.create_task(main(asyncio.get_event_loop()))
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
hello_response = MessageAction(content='Ping')
|
||||
hello_response._source = EventSource.AGENT
|
||||
mock_runtime.event_stream.add_event(hello_response, EventSource.AGENT)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(main_task, timeout=1.0)
|
||||
except asyncio.TimeoutError:
|
||||
main_task.cancel()
|
||||
|
||||
buffer.seek(0)
|
||||
output = buffer.read()
|
||||
|
||||
assert 'Ping' in output
|
||||
@@ -1,368 +1,463 @@
|
||||
import asyncio
|
||||
from io import StringIO
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from prompt_toolkit.application import create_app_session
|
||||
from prompt_toolkit.input import create_pipe_input
|
||||
from prompt_toolkit.output import create_output
|
||||
|
||||
from openhands.core.cli import main
|
||||
from openhands.core.cli_commands import (
|
||||
handle_commands,
|
||||
handle_exit_command,
|
||||
handle_help_command,
|
||||
handle_init_command,
|
||||
handle_new_command,
|
||||
handle_settings_command,
|
||||
handle_status_command,
|
||||
)
|
||||
from openhands.core.cli_tui import UsageMetrics
|
||||
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.event import EventSource
|
||||
from openhands.events.observation import AgentStateChangedObservation
|
||||
|
||||
|
||||
class MockEventStream:
|
||||
def __init__(self):
|
||||
self._subscribers = {}
|
||||
self.cur_id = 0
|
||||
self.events = []
|
||||
|
||||
def subscribe(self, subscriber_id, callback, callback_id=None):
|
||||
if subscriber_id not in self._subscribers:
|
||||
self._subscribers[subscriber_id] = {}
|
||||
self._subscribers[subscriber_id][callback_id] = callback
|
||||
return callback_id
|
||||
|
||||
def unsubscribe(self, subscriber_id, callback_id):
|
||||
if (
|
||||
subscriber_id in self._subscribers
|
||||
and callback_id in self._subscribers[subscriber_id]
|
||||
):
|
||||
del self._subscribers[subscriber_id][callback_id]
|
||||
|
||||
def add_event(self, event, source):
|
||||
event._id = self.cur_id
|
||||
self.cur_id += 1
|
||||
event._source = source
|
||||
event._timestamp = '2023-01-01T00:00:00'
|
||||
self.events.append((event, source))
|
||||
|
||||
for subscriber_id in self._subscribers:
|
||||
for callback_id, callback in self._subscribers[subscriber_id].items():
|
||||
if asyncio.iscoroutinefunction(callback):
|
||||
asyncio.create_task(callback(event))
|
||||
else:
|
||||
callback(event)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_agent():
|
||||
with patch('openhands.core.cli.create_agent') as mock_create_agent:
|
||||
mock_agent_instance = AsyncMock()
|
||||
mock_agent_instance.name = 'test-agent'
|
||||
mock_agent_instance.llm = AsyncMock()
|
||||
mock_agent_instance.llm.config = AsyncMock()
|
||||
mock_agent_instance.llm.config.model = 'test-model'
|
||||
mock_agent_instance.llm.config.base_url = 'http://test'
|
||||
mock_agent_instance.llm.config.max_message_chars = 1000
|
||||
mock_agent_instance.config = AsyncMock()
|
||||
mock_agent_instance.config.disabled_microagents = []
|
||||
mock_agent_instance.sandbox_plugins = []
|
||||
mock_agent_instance.prompt_manager = AsyncMock()
|
||||
mock_create_agent.return_value = mock_agent_instance
|
||||
yield mock_agent_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_controller():
|
||||
with patch('openhands.core.cli.create_controller') as mock_create_controller:
|
||||
mock_controller_instance = AsyncMock()
|
||||
mock_controller_instance.state.agent_state = None
|
||||
# Mock run_until_done to finish immediately
|
||||
mock_controller_instance.run_until_done = AsyncMock(return_value=None)
|
||||
mock_create_controller.return_value = (mock_controller_instance, None)
|
||||
yield mock_controller_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
with patch('openhands.core.cli.parse_arguments') as mock_parse_args:
|
||||
args = Mock()
|
||||
args.file = None
|
||||
args.task = None
|
||||
args.directory = None
|
||||
mock_parse_args.return_value = args
|
||||
with patch('openhands.core.cli.setup_config_from_args') as mock_setup_config:
|
||||
mock_config = AppConfig()
|
||||
mock_config.cli_multiline_input = False
|
||||
mock_config.security = Mock()
|
||||
mock_config.security.confirmation_mode = False
|
||||
mock_config.sandbox = Mock()
|
||||
mock_config.sandbox.selected_repo = None
|
||||
mock_config.workspace_base = '/test'
|
||||
mock_config.runtime = 'local' # Important for /init test
|
||||
mock_setup_config.return_value = mock_config
|
||||
yield mock_config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_memory():
|
||||
with patch('openhands.core.cli.create_memory') as mock_create_memory:
|
||||
mock_memory_instance = AsyncMock()
|
||||
mock_create_memory.return_value = mock_memory_instance
|
||||
yield mock_memory_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_read_task():
|
||||
with patch('openhands.core.cli.read_task') as mock_read_task:
|
||||
mock_read_task.return_value = None
|
||||
yield mock_read_task
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_runtime():
|
||||
with patch('openhands.core.cli.create_runtime') as mock_create_runtime:
|
||||
mock_runtime_instance = AsyncMock()
|
||||
|
||||
mock_event_stream = MockEventStream()
|
||||
mock_runtime_instance.event_stream = mock_event_stream
|
||||
|
||||
mock_runtime_instance.connect = AsyncMock()
|
||||
|
||||
# Ensure status_callback is None
|
||||
mock_runtime_instance.status_callback = None
|
||||
# Mock get_microagents_from_selected_repo
|
||||
mock_runtime_instance.get_microagents_from_selected_repo = Mock(return_value=[])
|
||||
mock_create_runtime.return_value = mock_runtime_instance
|
||||
yield mock_runtime_instance
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_command(
|
||||
mock_runtime, mock_controller, mock_config, mock_agent, mock_memory, mock_read_task
|
||||
):
|
||||
buffer = StringIO()
|
||||
|
||||
with patch('openhands.core.cli.manage_openhands_file', return_value=True):
|
||||
with patch(
|
||||
'openhands.core.cli.check_folder_security_agreement', return_value=True
|
||||
):
|
||||
with patch('openhands.core.cli.read_prompt_input') as mock_prompt:
|
||||
# Setup to return /help first, then simulate an exit
|
||||
mock_prompt.side_effect = ['/help', '/exit']
|
||||
|
||||
with create_app_session(
|
||||
input=create_pipe_input(), output=create_output(stdout=buffer)
|
||||
):
|
||||
mock_controller.status_callback = None
|
||||
|
||||
main_task = asyncio.create_task(main(asyncio.get_event_loop()))
|
||||
|
||||
agent_ready_event = AgentStateChangedObservation(
|
||||
agent_state=AgentState.AWAITING_USER_INPUT,
|
||||
content='Agent is ready for user input',
|
||||
)
|
||||
mock_runtime.event_stream.add_event(
|
||||
agent_ready_event, EventSource.AGENT
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(main_task, timeout=0.5)
|
||||
except asyncio.TimeoutError:
|
||||
main_task.cancel()
|
||||
try:
|
||||
await main_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
buffer.seek(0)
|
||||
output = buffer.read()
|
||||
|
||||
# Verify help output was displayed
|
||||
assert 'OpenHands CLI' in output
|
||||
assert 'Things that you can try' in output
|
||||
assert 'Interactive commands' in output
|
||||
assert '/help' in output
|
||||
assert '/exit' in output
|
||||
|
||||
# Verify the help command didn't add a MessageAction to the event stream
|
||||
message_actions = [
|
||||
event
|
||||
for event, _ in mock_runtime.event_stream.events
|
||||
if isinstance(event, MessageAction)
|
||||
]
|
||||
assert len(message_actions) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exit_command(
|
||||
mock_runtime, mock_controller, mock_config, mock_agent, mock_memory, mock_read_task
|
||||
):
|
||||
buffer = StringIO()
|
||||
|
||||
with patch('openhands.core.cli.manage_openhands_file', return_value=True):
|
||||
with patch(
|
||||
'openhands.core.cli.check_folder_security_agreement', return_value=True
|
||||
):
|
||||
with patch('openhands.core.cli.read_prompt_input') as mock_prompt:
|
||||
# First prompt call returns /exit
|
||||
mock_prompt.side_effect = ['/exit']
|
||||
|
||||
with patch('openhands.core.cli.shutdown') as mock_shutdown:
|
||||
with create_app_session(
|
||||
input=create_pipe_input(), output=create_output(stdout=buffer)
|
||||
):
|
||||
mock_controller.status_callback = None
|
||||
|
||||
main_task = asyncio.create_task(main(asyncio.get_event_loop()))
|
||||
|
||||
agent_ready_event = AgentStateChangedObservation(
|
||||
agent_state=AgentState.AWAITING_USER_INPUT,
|
||||
content='Agent is ready for user input',
|
||||
)
|
||||
mock_runtime.event_stream.add_event(
|
||||
agent_ready_event, EventSource.AGENT
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(main_task, timeout=0.5)
|
||||
except asyncio.TimeoutError:
|
||||
main_task.cancel()
|
||||
try:
|
||||
await main_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Verify that the exit command sent a STOPPED state change event
|
||||
state_change_events = [
|
||||
event
|
||||
for event, source in mock_runtime.event_stream.events
|
||||
if isinstance(event, ChangeAgentStateAction)
|
||||
and event.agent_state == AgentState.STOPPED
|
||||
and source == EventSource.ENVIRONMENT
|
||||
]
|
||||
assert len(state_change_events) == 1
|
||||
|
||||
# Verify shutdown was called
|
||||
mock_shutdown.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_init_command(
|
||||
mock_runtime, mock_controller, mock_config, mock_agent, mock_memory, mock_read_task
|
||||
):
|
||||
buffer = StringIO()
|
||||
|
||||
with patch('openhands.core.cli.manage_openhands_file', return_value=True):
|
||||
with patch(
|
||||
'openhands.core.cli.check_folder_security_agreement', return_value=True
|
||||
):
|
||||
with patch('openhands.core.cli.read_prompt_input') as mock_prompt:
|
||||
# First prompt call returns /init, second call returns /exit
|
||||
mock_prompt.side_effect = ['/init', '/exit']
|
||||
|
||||
with patch('openhands.core.cli.init_repository') as mock_init_repo:
|
||||
with create_app_session(
|
||||
input=create_pipe_input(), output=create_output(stdout=buffer)
|
||||
):
|
||||
mock_controller.status_callback = None
|
||||
|
||||
main_task = asyncio.create_task(main(asyncio.get_event_loop()))
|
||||
|
||||
agent_ready_event = AgentStateChangedObservation(
|
||||
agent_state=AgentState.AWAITING_USER_INPUT,
|
||||
content='Agent is ready for user input',
|
||||
)
|
||||
mock_runtime.event_stream.add_event(
|
||||
agent_ready_event, EventSource.AGENT
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(main_task, timeout=0.5)
|
||||
except asyncio.TimeoutError:
|
||||
main_task.cancel()
|
||||
try:
|
||||
await main_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Verify init_repository was called with the correct directory
|
||||
mock_init_repo.assert_called_once_with('/test')
|
||||
|
||||
# Verify that a MessageAction was sent with the repository creation prompt
|
||||
message_events = [
|
||||
event
|
||||
for event, source in mock_runtime.event_stream.events
|
||||
if isinstance(event, MessageAction)
|
||||
and 'Please explore this repository' in event.content
|
||||
and source == EventSource.USER
|
||||
]
|
||||
assert len(message_events) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_init_command_non_local_runtime(
|
||||
mock_runtime, mock_controller, mock_config, mock_agent, mock_memory, mock_read_task
|
||||
):
|
||||
buffer = StringIO()
|
||||
|
||||
# Set runtime to non-local for this test
|
||||
mock_config.runtime = 'remote'
|
||||
|
||||
with patch('openhands.core.cli.manage_openhands_file', return_value=True):
|
||||
with patch(
|
||||
'openhands.core.cli.check_folder_security_agreement', return_value=True
|
||||
):
|
||||
with patch('openhands.core.cli.read_prompt_input') as mock_prompt:
|
||||
# First prompt call returns /init, second call returns /exit
|
||||
mock_prompt.side_effect = ['/init', '/exit']
|
||||
|
||||
with patch('openhands.core.cli.init_repository') as mock_init_repo:
|
||||
with create_app_session(
|
||||
input=create_pipe_input(), output=create_output(stdout=buffer)
|
||||
):
|
||||
mock_controller.status_callback = None
|
||||
|
||||
main_task = asyncio.create_task(main(asyncio.get_event_loop()))
|
||||
|
||||
# Send AgentStateChangedObservation to trigger prompt
|
||||
agent_ready_event = AgentStateChangedObservation(
|
||||
agent_state=AgentState.AWAITING_USER_INPUT,
|
||||
content='Agent is ready for user input',
|
||||
)
|
||||
mock_runtime.event_stream.add_event(
|
||||
agent_ready_event, EventSource.AGENT
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(main_task, timeout=0.5)
|
||||
except asyncio.TimeoutError:
|
||||
main_task.cancel()
|
||||
try:
|
||||
await main_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
buffer.seek(0)
|
||||
output = buffer.read()
|
||||
|
||||
# Verify error message was displayed
|
||||
assert (
|
||||
'Repository initialization through the CLI is only supported for local runtime'
|
||||
in output
|
||||
)
|
||||
|
||||
# Verify init_repository was not called
|
||||
mock_init_repo.assert_not_called()
|
||||
|
||||
# Verify no MessageAction was sent for repository creation
|
||||
message_events = [
|
||||
event
|
||||
for event, _ in mock_runtime.event_stream.events
|
||||
if isinstance(event, MessageAction)
|
||||
and 'Please explore this repository' in event.content
|
||||
]
|
||||
assert len(message_events) == 0
|
||||
from openhands.events.stream import EventStream
|
||||
from openhands.storage.settings.file_settings_store import FileSettingsStore
|
||||
|
||||
|
||||
class TestHandleCommands:
|
||||
@pytest.fixture
|
||||
def mock_dependencies(self):
|
||||
event_stream = MagicMock(spec=EventStream)
|
||||
usage_metrics = MagicMock(spec=UsageMetrics)
|
||||
sid = 'test-session-id'
|
||||
config = MagicMock(spec=AppConfig)
|
||||
current_dir = '/test/dir'
|
||||
settings_store = MagicMock(spec=FileSettingsStore)
|
||||
|
||||
return {
|
||||
'event_stream': event_stream,
|
||||
'usage_metrics': usage_metrics,
|
||||
'sid': sid,
|
||||
'config': config,
|
||||
'current_dir': current_dir,
|
||||
'settings_store': settings_store,
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.core.cli_commands.handle_exit_command')
|
||||
async def test_handle_exit_command(self, mock_handle_exit, mock_dependencies):
|
||||
mock_handle_exit.return_value = True
|
||||
|
||||
close_repl, reload_microagents, new_session = await handle_commands(
|
||||
'/exit', **mock_dependencies
|
||||
)
|
||||
|
||||
mock_handle_exit.assert_called_once_with(
|
||||
mock_dependencies['event_stream'],
|
||||
mock_dependencies['usage_metrics'],
|
||||
mock_dependencies['sid'],
|
||||
)
|
||||
assert close_repl is True
|
||||
assert reload_microagents is False
|
||||
assert new_session is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.core.cli_commands.handle_help_command')
|
||||
async def test_handle_help_command(self, mock_handle_help, mock_dependencies):
|
||||
mock_handle_help.return_value = (False, False, False)
|
||||
|
||||
close_repl, reload_microagents, new_session = await handle_commands(
|
||||
'/help', **mock_dependencies
|
||||
)
|
||||
|
||||
mock_handle_help.assert_called_once()
|
||||
assert close_repl is False
|
||||
assert reload_microagents is False
|
||||
assert new_session is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.core.cli_commands.handle_init_command')
|
||||
async def test_handle_init_command(self, mock_handle_init, mock_dependencies):
|
||||
mock_handle_init.return_value = (True, True)
|
||||
|
||||
close_repl, reload_microagents, new_session = await handle_commands(
|
||||
'/init', **mock_dependencies
|
||||
)
|
||||
|
||||
mock_handle_init.assert_called_once_with(
|
||||
mock_dependencies['config'],
|
||||
mock_dependencies['event_stream'],
|
||||
mock_dependencies['current_dir'],
|
||||
)
|
||||
assert close_repl is True
|
||||
assert reload_microagents is True
|
||||
assert new_session is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.core.cli_commands.handle_status_command')
|
||||
async def test_handle_status_command(self, mock_handle_status, mock_dependencies):
|
||||
mock_handle_status.return_value = (False, False, False)
|
||||
|
||||
close_repl, reload_microagents, new_session = await handle_commands(
|
||||
'/status', **mock_dependencies
|
||||
)
|
||||
|
||||
mock_handle_status.assert_called_once_with(
|
||||
mock_dependencies['usage_metrics'], mock_dependencies['sid']
|
||||
)
|
||||
assert close_repl is False
|
||||
assert reload_microagents is False
|
||||
assert new_session is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.core.cli_commands.handle_new_command')
|
||||
async def test_handle_new_command(self, mock_handle_new, mock_dependencies):
|
||||
mock_handle_new.return_value = (True, True)
|
||||
|
||||
close_repl, reload_microagents, new_session = await handle_commands(
|
||||
'/new', **mock_dependencies
|
||||
)
|
||||
|
||||
mock_handle_new.assert_called_once_with(
|
||||
mock_dependencies['event_stream'],
|
||||
mock_dependencies['usage_metrics'],
|
||||
mock_dependencies['sid'],
|
||||
)
|
||||
assert close_repl is True
|
||||
assert reload_microagents is False
|
||||
assert new_session is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.core.cli_commands.handle_settings_command')
|
||||
async def test_handle_settings_command(
|
||||
self, mock_handle_settings, mock_dependencies
|
||||
):
|
||||
close_repl, reload_microagents, new_session = await handle_commands(
|
||||
'/settings', **mock_dependencies
|
||||
)
|
||||
|
||||
mock_handle_settings.assert_called_once_with(
|
||||
mock_dependencies['config'],
|
||||
mock_dependencies['settings_store'],
|
||||
)
|
||||
assert close_repl is False
|
||||
assert reload_microagents is False
|
||||
assert new_session is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_unknown_command(self, mock_dependencies):
|
||||
user_message = 'Hello, this is not a command'
|
||||
|
||||
close_repl, reload_microagents, new_session = await handle_commands(
|
||||
user_message, **mock_dependencies
|
||||
)
|
||||
|
||||
# The command should be treated as a message and added to the event stream
|
||||
mock_dependencies['event_stream'].add_event.assert_called_once()
|
||||
# Check the first argument is a MessageAction with the right content
|
||||
args, kwargs = mock_dependencies['event_stream'].add_event.call_args
|
||||
assert isinstance(args[0], MessageAction)
|
||||
assert args[0].content == user_message
|
||||
assert args[1] == EventSource.USER
|
||||
|
||||
assert close_repl is True
|
||||
assert reload_microagents is False
|
||||
assert new_session is False
|
||||
|
||||
|
||||
class TestHandleExitCommand:
|
||||
@patch('openhands.core.cli_commands.cli_confirm')
|
||||
@patch('openhands.core.cli_commands.display_shutdown_message')
|
||||
def test_exit_with_confirmation(self, mock_display_shutdown, mock_cli_confirm):
|
||||
event_stream = MagicMock(spec=EventStream)
|
||||
usage_metrics = MagicMock(spec=UsageMetrics)
|
||||
sid = 'test-session-id'
|
||||
|
||||
# Mock user confirming exit
|
||||
mock_cli_confirm.return_value = 0 # First option, which is "Yes, proceed"
|
||||
|
||||
# Call the function under test
|
||||
result = handle_exit_command(event_stream, usage_metrics, sid)
|
||||
|
||||
# Verify correct behavior
|
||||
mock_cli_confirm.assert_called_once()
|
||||
event_stream.add_event.assert_called_once()
|
||||
# Check event is the right type
|
||||
args, kwargs = event_stream.add_event.call_args
|
||||
assert isinstance(args[0], ChangeAgentStateAction)
|
||||
assert args[0].agent_state == AgentState.STOPPED
|
||||
assert args[1] == EventSource.ENVIRONMENT
|
||||
|
||||
mock_display_shutdown.assert_called_once_with(usage_metrics, sid)
|
||||
assert result is True
|
||||
|
||||
@patch('openhands.core.cli_commands.cli_confirm')
|
||||
@patch('openhands.core.cli_commands.display_shutdown_message')
|
||||
def test_exit_without_confirmation(self, mock_display_shutdown, mock_cli_confirm):
|
||||
event_stream = MagicMock(spec=EventStream)
|
||||
usage_metrics = MagicMock(spec=UsageMetrics)
|
||||
sid = 'test-session-id'
|
||||
|
||||
# Mock user rejecting exit
|
||||
mock_cli_confirm.return_value = 1 # Second option, which is "No, dismiss"
|
||||
|
||||
# Call the function under test
|
||||
result = handle_exit_command(event_stream, usage_metrics, sid)
|
||||
|
||||
# Verify correct behavior
|
||||
mock_cli_confirm.assert_called_once()
|
||||
event_stream.add_event.assert_not_called()
|
||||
mock_display_shutdown.assert_not_called()
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestHandleHelpCommand:
|
||||
@patch('openhands.core.cli_commands.display_help')
|
||||
def test_help_command(self, mock_display_help):
|
||||
handle_help_command()
|
||||
mock_display_help.assert_called_once()
|
||||
|
||||
|
||||
class TestHandleStatusCommand:
|
||||
@patch('openhands.core.cli_commands.display_status')
|
||||
def test_status_command(self, mock_display_status):
|
||||
usage_metrics = MagicMock(spec=UsageMetrics)
|
||||
sid = 'test-session-id'
|
||||
|
||||
handle_status_command(usage_metrics, sid)
|
||||
|
||||
mock_display_status.assert_called_once_with(usage_metrics, sid)
|
||||
|
||||
|
||||
class TestHandleNewCommand:
|
||||
@patch('openhands.core.cli_commands.cli_confirm')
|
||||
@patch('openhands.core.cli_commands.display_shutdown_message')
|
||||
def test_new_with_confirmation(self, mock_display_shutdown, mock_cli_confirm):
|
||||
event_stream = MagicMock(spec=EventStream)
|
||||
usage_metrics = MagicMock(spec=UsageMetrics)
|
||||
sid = 'test-session-id'
|
||||
|
||||
# Mock user confirming new session
|
||||
mock_cli_confirm.return_value = 0 # First option, which is "Yes, proceed"
|
||||
|
||||
# Call the function under test
|
||||
close_repl, new_session = handle_new_command(event_stream, usage_metrics, sid)
|
||||
|
||||
# Verify correct behavior
|
||||
mock_cli_confirm.assert_called_once()
|
||||
event_stream.add_event.assert_called_once()
|
||||
# Check event is the right type
|
||||
args, kwargs = event_stream.add_event.call_args
|
||||
assert isinstance(args[0], ChangeAgentStateAction)
|
||||
assert args[0].agent_state == AgentState.STOPPED
|
||||
assert args[1] == EventSource.ENVIRONMENT
|
||||
|
||||
mock_display_shutdown.assert_called_once_with(usage_metrics, sid)
|
||||
assert close_repl is True
|
||||
assert new_session is True
|
||||
|
||||
@patch('openhands.core.cli_commands.cli_confirm')
|
||||
@patch('openhands.core.cli_commands.display_shutdown_message')
|
||||
def test_new_without_confirmation(self, mock_display_shutdown, mock_cli_confirm):
|
||||
event_stream = MagicMock(spec=EventStream)
|
||||
usage_metrics = MagicMock(spec=UsageMetrics)
|
||||
sid = 'test-session-id'
|
||||
|
||||
# Mock user rejecting new session
|
||||
mock_cli_confirm.return_value = 1 # Second option, which is "No, dismiss"
|
||||
|
||||
# Call the function under test
|
||||
close_repl, new_session = handle_new_command(event_stream, usage_metrics, sid)
|
||||
|
||||
# Verify correct behavior
|
||||
mock_cli_confirm.assert_called_once()
|
||||
event_stream.add_event.assert_not_called()
|
||||
mock_display_shutdown.assert_not_called()
|
||||
assert close_repl is False
|
||||
assert new_session is False
|
||||
|
||||
|
||||
class TestHandleInitCommand:
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.core.cli_commands.init_repository')
|
||||
async def test_init_local_runtime_successful(self, mock_init_repository):
|
||||
config = MagicMock(spec=AppConfig)
|
||||
config.runtime = 'local'
|
||||
event_stream = MagicMock(spec=EventStream)
|
||||
current_dir = '/test/dir'
|
||||
|
||||
# Mock successful repository initialization
|
||||
mock_init_repository.return_value = True
|
||||
|
||||
# Call the function under test
|
||||
close_repl, reload_microagents = await handle_init_command(
|
||||
config, event_stream, current_dir
|
||||
)
|
||||
|
||||
# Verify correct behavior
|
||||
mock_init_repository.assert_called_once_with(current_dir)
|
||||
event_stream.add_event.assert_called_once()
|
||||
# Check event is the right type
|
||||
args, kwargs = event_stream.add_event.call_args
|
||||
assert isinstance(args[0], MessageAction)
|
||||
assert 'Please explore this repository' in args[0].content
|
||||
assert args[1] == EventSource.USER
|
||||
|
||||
assert close_repl is True
|
||||
assert reload_microagents is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.core.cli_commands.init_repository')
|
||||
async def test_init_local_runtime_unsuccessful(self, mock_init_repository):
|
||||
config = MagicMock(spec=AppConfig)
|
||||
config.runtime = 'local'
|
||||
event_stream = MagicMock(spec=EventStream)
|
||||
current_dir = '/test/dir'
|
||||
|
||||
# Mock unsuccessful repository initialization
|
||||
mock_init_repository.return_value = False
|
||||
|
||||
# Call the function under test
|
||||
close_repl, reload_microagents = await handle_init_command(
|
||||
config, event_stream, current_dir
|
||||
)
|
||||
|
||||
# Verify correct behavior
|
||||
mock_init_repository.assert_called_once_with(current_dir)
|
||||
event_stream.add_event.assert_not_called()
|
||||
|
||||
assert close_repl is False
|
||||
assert reload_microagents is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.core.cli_commands.print_formatted_text')
|
||||
@patch('openhands.core.cli_commands.init_repository')
|
||||
async def test_init_non_local_runtime(self, mock_init_repository, mock_print):
|
||||
config = MagicMock(spec=AppConfig)
|
||||
config.runtime = 'remote' # Not local
|
||||
event_stream = MagicMock(spec=EventStream)
|
||||
current_dir = '/test/dir'
|
||||
|
||||
# Call the function under test
|
||||
close_repl, reload_microagents = await handle_init_command(
|
||||
config, event_stream, current_dir
|
||||
)
|
||||
|
||||
# Verify correct behavior
|
||||
mock_init_repository.assert_not_called()
|
||||
mock_print.assert_called_once()
|
||||
event_stream.add_event.assert_not_called()
|
||||
|
||||
assert close_repl is False
|
||||
assert reload_microagents is False
|
||||
|
||||
|
||||
class TestHandleSettingsCommand:
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.core.cli_commands.display_settings')
|
||||
@patch('openhands.core.cli_commands.cli_confirm')
|
||||
@patch('openhands.core.cli_commands.modify_llm_settings_basic')
|
||||
async def test_settings_basic_with_changes(
|
||||
self,
|
||||
mock_modify_basic,
|
||||
mock_cli_confirm,
|
||||
mock_display_settings,
|
||||
):
|
||||
config = MagicMock(spec=AppConfig)
|
||||
settings_store = MagicMock(spec=FileSettingsStore)
|
||||
|
||||
# Mock user selecting "Basic" settings
|
||||
mock_cli_confirm.return_value = 0
|
||||
|
||||
# Call the function under test
|
||||
await handle_settings_command(config, settings_store)
|
||||
|
||||
# Verify correct behavior
|
||||
mock_display_settings.assert_called_once_with(config)
|
||||
mock_cli_confirm.assert_called_once()
|
||||
mock_modify_basic.assert_called_once_with(config, settings_store)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.core.cli_commands.display_settings')
|
||||
@patch('openhands.core.cli_commands.cli_confirm')
|
||||
@patch('openhands.core.cli_commands.modify_llm_settings_basic')
|
||||
async def test_settings_basic_without_changes(
|
||||
self,
|
||||
mock_modify_basic,
|
||||
mock_cli_confirm,
|
||||
mock_display_settings,
|
||||
):
|
||||
config = MagicMock(spec=AppConfig)
|
||||
settings_store = MagicMock(spec=FileSettingsStore)
|
||||
|
||||
# Mock user selecting "Basic" settings
|
||||
mock_cli_confirm.return_value = 0
|
||||
|
||||
# Call the function under test
|
||||
await handle_settings_command(config, settings_store)
|
||||
|
||||
# Verify correct behavior
|
||||
mock_display_settings.assert_called_once_with(config)
|
||||
mock_cli_confirm.assert_called_once()
|
||||
mock_modify_basic.assert_called_once_with(config, settings_store)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.core.cli_commands.display_settings')
|
||||
@patch('openhands.core.cli_commands.cli_confirm')
|
||||
@patch('openhands.core.cli_commands.modify_llm_settings_advanced')
|
||||
async def test_settings_advanced_with_changes(
|
||||
self,
|
||||
mock_modify_advanced,
|
||||
mock_cli_confirm,
|
||||
mock_display_settings,
|
||||
):
|
||||
config = MagicMock(spec=AppConfig)
|
||||
settings_store = MagicMock(spec=FileSettingsStore)
|
||||
|
||||
# Mock user selecting "Advanced" settings
|
||||
mock_cli_confirm.return_value = 1
|
||||
|
||||
# Call the function under test
|
||||
await handle_settings_command(config, settings_store)
|
||||
|
||||
# Verify correct behavior
|
||||
mock_display_settings.assert_called_once_with(config)
|
||||
mock_cli_confirm.assert_called_once()
|
||||
mock_modify_advanced.assert_called_once_with(config, settings_store)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.core.cli_commands.display_settings')
|
||||
@patch('openhands.core.cli_commands.cli_confirm')
|
||||
@patch('openhands.core.cli_commands.modify_llm_settings_advanced')
|
||||
async def test_settings_advanced_without_changes(
|
||||
self,
|
||||
mock_modify_advanced,
|
||||
mock_cli_confirm,
|
||||
mock_display_settings,
|
||||
):
|
||||
config = MagicMock(spec=AppConfig)
|
||||
settings_store = MagicMock(spec=FileSettingsStore)
|
||||
|
||||
# Mock user selecting "Advanced" settings
|
||||
mock_cli_confirm.return_value = 1
|
||||
|
||||
# Call the function under test
|
||||
await handle_settings_command(config, settings_store)
|
||||
|
||||
# Verify correct behavior
|
||||
mock_display_settings.assert_called_once_with(config)
|
||||
mock_cli_confirm.assert_called_once()
|
||||
mock_modify_advanced.assert_called_once_with(config, settings_store)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.core.cli_commands.display_settings')
|
||||
@patch('openhands.core.cli_commands.cli_confirm')
|
||||
async def test_settings_go_back(self, mock_cli_confirm, mock_display_settings):
|
||||
config = MagicMock(spec=AppConfig)
|
||||
settings_store = MagicMock(spec=FileSettingsStore)
|
||||
|
||||
# Mock user selecting "Go back"
|
||||
mock_cli_confirm.return_value = 2
|
||||
|
||||
# Call the function under test
|
||||
await handle_settings_command(config, settings_store)
|
||||
|
||||
# Verify correct behavior
|
||||
mock_display_settings.assert_called_once_with(config)
|
||||
mock_cli_confirm.assert_called_once()
|
||||
|
||||
512
tests/unit/test_cli_settings.py
Normal file
512
tests/unit/test_cli_settings.py
Normal file
@@ -0,0 +1,512 @@
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.core.cli_settings import (
|
||||
display_settings,
|
||||
modify_llm_settings_advanced,
|
||||
modify_llm_settings_basic,
|
||||
)
|
||||
from openhands.core.cli_tui import UserCancelledError
|
||||
from openhands.core.config import AppConfig
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
from openhands.storage.settings.file_settings_store import FileSettingsStore
|
||||
|
||||
|
||||
# Mock classes for condensers
|
||||
class MockLLMSummarizingCondenserConfig:
|
||||
def __init__(self, llm_config, type):
|
||||
self.llm_config = llm_config
|
||||
self.type = type
|
||||
|
||||
|
||||
class MockNoOpCondenserConfig:
|
||||
def __init__(self, type):
|
||||
self.type = type
|
||||
|
||||
|
||||
class TestDisplaySettings:
|
||||
@pytest.fixture
|
||||
def app_config(self):
|
||||
config = MagicMock(spec=AppConfig)
|
||||
llm_config = MagicMock()
|
||||
llm_config.base_url = None
|
||||
llm_config.model = 'openai/gpt-4'
|
||||
llm_config.api_key = SecretStr('test-api-key')
|
||||
config.get_llm_config.return_value = llm_config
|
||||
config.default_agent = 'test-agent'
|
||||
|
||||
# Set up security as a separate mock
|
||||
security_mock = MagicMock()
|
||||
security_mock.confirmation_mode = True
|
||||
config.security = security_mock
|
||||
|
||||
config.enable_default_condenser = True
|
||||
return config
|
||||
|
||||
@pytest.fixture
|
||||
def advanced_app_config(self):
|
||||
config = MagicMock(spec=AppConfig)
|
||||
llm_config = MagicMock()
|
||||
llm_config.base_url = 'https://custom-api.com'
|
||||
llm_config.model = 'custom-model'
|
||||
llm_config.api_key = SecretStr('test-api-key')
|
||||
config.get_llm_config.return_value = llm_config
|
||||
config.default_agent = 'test-agent'
|
||||
|
||||
# Set up security as a separate mock
|
||||
security_mock = MagicMock()
|
||||
security_mock.confirmation_mode = True
|
||||
config.security = security_mock
|
||||
|
||||
config.enable_default_condenser = True
|
||||
return config
|
||||
|
||||
@patch('openhands.core.cli_settings.print_container')
|
||||
def test_display_settings_standard_config(self, mock_print_container, app_config):
|
||||
display_settings(app_config)
|
||||
mock_print_container.assert_called_once()
|
||||
|
||||
# Verify the container was created with the correct settings
|
||||
container = mock_print_container.call_args[0][0]
|
||||
text_area = container.body
|
||||
|
||||
# Check that the text area contains expected labels and values
|
||||
settings_text = text_area.text
|
||||
assert 'LLM Provider:' in settings_text
|
||||
assert 'openai' in settings_text
|
||||
assert 'LLM Model:' in settings_text
|
||||
assert 'gpt-4' in settings_text
|
||||
assert 'API Key:' in settings_text
|
||||
assert '********' in settings_text
|
||||
assert 'Agent:' in settings_text
|
||||
assert 'test-agent' in settings_text
|
||||
assert 'Confirmation Mode:' in settings_text
|
||||
assert 'Enabled' in settings_text
|
||||
assert 'Memory Condensation:' in settings_text
|
||||
assert 'Enabled' in settings_text
|
||||
|
||||
@patch('openhands.core.cli_settings.print_container')
|
||||
def test_display_settings_advanced_config(
|
||||
self, mock_print_container, advanced_app_config
|
||||
):
|
||||
display_settings(advanced_app_config)
|
||||
mock_print_container.assert_called_once()
|
||||
|
||||
# Verify the container was created with the correct settings
|
||||
container = mock_print_container.call_args[0][0]
|
||||
text_area = container.body
|
||||
|
||||
# Check that the text area contains expected labels and values
|
||||
settings_text = text_area.text
|
||||
assert 'Custom Model:' in settings_text
|
||||
assert 'custom-model' in settings_text
|
||||
assert 'Base URL:' in settings_text
|
||||
assert 'https://custom-api.com' in settings_text
|
||||
assert 'API Key:' in settings_text
|
||||
assert '********' in settings_text
|
||||
assert 'Agent:' in settings_text
|
||||
assert 'test-agent' in settings_text
|
||||
|
||||
|
||||
class TestModifyLLMSettingsBasic:
|
||||
@pytest.fixture
|
||||
def app_config(self):
|
||||
config = MagicMock(spec=AppConfig)
|
||||
llm_config = MagicMock()
|
||||
llm_config.model = 'openai/gpt-4'
|
||||
llm_config.api_key = SecretStr('test-api-key')
|
||||
llm_config.base_url = None
|
||||
config.get_llm_config.return_value = llm_config
|
||||
config.set_llm_config = MagicMock()
|
||||
config.set_agent_config = MagicMock()
|
||||
|
||||
agent_config = MagicMock()
|
||||
config.get_agent_config.return_value = agent_config
|
||||
|
||||
# Set up security as a separate mock
|
||||
security_mock = MagicMock()
|
||||
security_mock.confirmation_mode = True
|
||||
config.security = security_mock
|
||||
|
||||
return config
|
||||
|
||||
@pytest.fixture
|
||||
def settings_store(self):
|
||||
store = MagicMock(spec=FileSettingsStore)
|
||||
store.load = AsyncMock(return_value=Settings())
|
||||
store.store = AsyncMock()
|
||||
return store
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.core.cli_settings.get_supported_llm_models')
|
||||
@patch('openhands.core.cli_settings.organize_models_and_providers')
|
||||
@patch('openhands.core.cli_settings.PromptSession')
|
||||
@patch('openhands.core.cli_settings.cli_confirm')
|
||||
@patch(
|
||||
'openhands.core.cli_settings.LLMSummarizingCondenserConfig',
|
||||
MockLLMSummarizingCondenserConfig,
|
||||
)
|
||||
async def test_modify_llm_settings_basic_success(
|
||||
self,
|
||||
mock_confirm,
|
||||
mock_session,
|
||||
mock_organize,
|
||||
mock_get_models,
|
||||
app_config,
|
||||
settings_store,
|
||||
):
|
||||
# Setup mocks
|
||||
mock_get_models.return_value = ['openai/gpt-4', 'anthropic/claude-3-opus']
|
||||
mock_organize.return_value = {
|
||||
'openai': {'models': ['gpt-4', 'gpt-3.5-turbo'], 'separator': '/'},
|
||||
'anthropic': {
|
||||
'models': ['claude-3-opus', 'claude-3-sonnet'],
|
||||
'separator': '/',
|
||||
},
|
||||
}
|
||||
|
||||
session_instance = MagicMock()
|
||||
session_instance.prompt_async = AsyncMock(
|
||||
side_effect=[
|
||||
'openai', # Provider
|
||||
'gpt-4', # Model
|
||||
'new-api-key', # API Key
|
||||
]
|
||||
)
|
||||
mock_session.return_value = session_instance
|
||||
|
||||
# Mock user confirmation
|
||||
mock_confirm.return_value = 0 # User selects "Yes, proceed"
|
||||
|
||||
# Call the function
|
||||
await modify_llm_settings_basic(app_config, settings_store)
|
||||
|
||||
# Verify LLM config was updated
|
||||
app_config.set_llm_config.assert_called_once()
|
||||
args, kwargs = app_config.set_llm_config.call_args
|
||||
assert args[0].model == 'openai/gpt-4'
|
||||
assert args[0].api_key.get_secret_value() == 'new-api-key'
|
||||
assert args[0].base_url is None
|
||||
|
||||
# Verify settings were saved
|
||||
settings_store.store.assert_called_once()
|
||||
args, kwargs = settings_store.store.call_args
|
||||
settings = args[0]
|
||||
assert settings.llm_model == 'openai/gpt-4'
|
||||
assert settings.llm_api_key.get_secret_value() == 'new-api-key'
|
||||
assert settings.llm_base_url is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.core.cli_settings.get_supported_llm_models')
|
||||
@patch('openhands.core.cli_settings.organize_models_and_providers')
|
||||
@patch('openhands.core.cli_settings.PromptSession')
|
||||
@patch('openhands.core.cli_settings.cli_confirm')
|
||||
@patch(
|
||||
'openhands.core.cli_settings.LLMSummarizingCondenserConfig',
|
||||
MockLLMSummarizingCondenserConfig,
|
||||
)
|
||||
async def test_modify_llm_settings_basic_user_cancels(
|
||||
self,
|
||||
mock_confirm,
|
||||
mock_session,
|
||||
mock_organize,
|
||||
mock_get_models,
|
||||
app_config,
|
||||
settings_store,
|
||||
):
|
||||
# Setup mocks
|
||||
mock_get_models.return_value = ['openai/gpt-4', 'anthropic/claude-3-opus']
|
||||
mock_organize.return_value = {
|
||||
'openai': {'models': ['gpt-4', 'gpt-3.5-turbo'], 'separator': '/'}
|
||||
}
|
||||
|
||||
session_instance = MagicMock()
|
||||
session_instance.prompt_async = AsyncMock(side_effect=UserCancelledError())
|
||||
mock_session.return_value = session_instance
|
||||
|
||||
# Call the function
|
||||
await modify_llm_settings_basic(app_config, settings_store)
|
||||
|
||||
# Verify settings were not changed
|
||||
app_config.set_llm_config.assert_not_called()
|
||||
settings_store.store.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.core.cli_settings.get_supported_llm_models')
|
||||
@patch('openhands.core.cli_settings.organize_models_and_providers')
|
||||
@patch('openhands.core.cli_settings.PromptSession')
|
||||
@patch('openhands.core.cli_settings.cli_confirm')
|
||||
@patch('openhands.core.cli_settings.print_formatted_text')
|
||||
@patch(
|
||||
'openhands.core.cli_settings.LLMSummarizingCondenserConfig',
|
||||
MockLLMSummarizingCondenserConfig,
|
||||
)
|
||||
async def test_modify_llm_settings_basic_invalid_input(
|
||||
self,
|
||||
mock_print,
|
||||
mock_confirm,
|
||||
mock_session,
|
||||
mock_organize,
|
||||
mock_get_models,
|
||||
app_config,
|
||||
settings_store,
|
||||
):
|
||||
# Setup mocks
|
||||
mock_get_models.return_value = ['openai/gpt-4', 'anthropic/claude-3-opus']
|
||||
mock_organize.return_value = {
|
||||
'openai': {'models': ['gpt-4', 'gpt-3.5-turbo'], 'separator': '/'}
|
||||
}
|
||||
|
||||
session_instance = MagicMock()
|
||||
session_instance.prompt_async = AsyncMock(
|
||||
side_effect=[
|
||||
'invalid-provider', # First invalid provider
|
||||
'openai', # Valid provider
|
||||
'invalid-model', # Invalid model
|
||||
'gpt-4', # Valid model
|
||||
'new-api-key', # API key
|
||||
]
|
||||
)
|
||||
mock_session.return_value = session_instance
|
||||
|
||||
# Mock user confirmation to save settings
|
||||
mock_confirm.return_value = 0 # "Yes, proceed"
|
||||
|
||||
# Call the function
|
||||
await modify_llm_settings_basic(app_config, settings_store)
|
||||
|
||||
# Verify error messages were shown for invalid inputs
|
||||
assert (
|
||||
mock_print.call_count >= 2
|
||||
) # At least two error messages should be printed
|
||||
|
||||
# Check for invalid provider error
|
||||
provider_error_found = False
|
||||
model_error_found = False
|
||||
|
||||
for call in mock_print.call_args_list:
|
||||
args, _ = call
|
||||
if args and isinstance(args[0], HTML):
|
||||
if 'Invalid provider selected' in args[0].value:
|
||||
provider_error_found = True
|
||||
if 'Invalid model selected' in args[0].value:
|
||||
model_error_found = True
|
||||
|
||||
assert provider_error_found, 'No error message for invalid provider'
|
||||
assert model_error_found, 'No error message for invalid model'
|
||||
|
||||
# Verify LLM config was updated with correct values
|
||||
app_config.set_llm_config.assert_called_once()
|
||||
|
||||
# Verify settings were saved
|
||||
settings_store.store.assert_called_once()
|
||||
args, kwargs = settings_store.store.call_args
|
||||
settings = args[0]
|
||||
assert settings.llm_model == 'openai/gpt-4'
|
||||
assert settings.llm_api_key.get_secret_value() == 'new-api-key'
|
||||
assert settings.llm_base_url is None
|
||||
|
||||
|
||||
class TestModifyLLMSettingsAdvanced:
|
||||
@pytest.fixture
|
||||
def app_config(self):
|
||||
config = MagicMock(spec=AppConfig)
|
||||
llm_config = MagicMock()
|
||||
llm_config.model = 'custom-model'
|
||||
llm_config.api_key = SecretStr('test-api-key')
|
||||
llm_config.base_url = 'https://custom-api.com'
|
||||
config.get_llm_config.return_value = llm_config
|
||||
config.set_llm_config = MagicMock()
|
||||
config.set_agent_config = MagicMock()
|
||||
|
||||
agent_config = MagicMock()
|
||||
config.get_agent_config.return_value = agent_config
|
||||
|
||||
# Set up security as a separate mock
|
||||
security_mock = MagicMock()
|
||||
security_mock.confirmation_mode = True
|
||||
config.security = security_mock
|
||||
|
||||
return config
|
||||
|
||||
@pytest.fixture
|
||||
def settings_store(self):
|
||||
store = MagicMock(spec=FileSettingsStore)
|
||||
store.load = AsyncMock(return_value=Settings())
|
||||
store.store = AsyncMock()
|
||||
return store
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.core.cli_settings.Agent.list_agents')
|
||||
@patch('openhands.core.cli_settings.PromptSession')
|
||||
@patch('openhands.core.cli_settings.cli_confirm')
|
||||
@patch(
|
||||
'openhands.core.cli_settings.LLMSummarizingCondenserConfig',
|
||||
MockLLMSummarizingCondenserConfig,
|
||||
)
|
||||
@patch('openhands.core.cli_settings.NoOpCondenserConfig', MockNoOpCondenserConfig)
|
||||
async def test_modify_llm_settings_advanced_success(
|
||||
self, mock_confirm, mock_session, mock_list_agents, app_config, settings_store
|
||||
):
|
||||
# Setup mocks
|
||||
mock_list_agents.return_value = ['default', 'test-agent']
|
||||
|
||||
session_instance = MagicMock()
|
||||
session_instance.prompt_async = AsyncMock(
|
||||
side_effect=[
|
||||
'new-model', # Custom model
|
||||
'https://new-url', # Base URL
|
||||
'new-api-key', # API key
|
||||
'default', # Agent
|
||||
]
|
||||
)
|
||||
mock_session.return_value = session_instance
|
||||
|
||||
# Mock user confirmations
|
||||
mock_confirm.side_effect = [
|
||||
0, # Enable confirmation mode
|
||||
0, # Enable memory condensation
|
||||
0, # Save settings
|
||||
]
|
||||
|
||||
# Call the function
|
||||
await modify_llm_settings_advanced(app_config, settings_store)
|
||||
|
||||
# Verify LLM config was updated
|
||||
app_config.set_llm_config.assert_called_once()
|
||||
args, kwargs = app_config.set_llm_config.call_args
|
||||
assert args[0].model == 'new-model'
|
||||
assert args[0].api_key.get_secret_value() == 'new-api-key'
|
||||
assert args[0].base_url == 'https://new-url'
|
||||
|
||||
# Verify settings were saved
|
||||
settings_store.store.assert_called_once()
|
||||
args, kwargs = settings_store.store.call_args
|
||||
settings = args[0]
|
||||
assert settings.llm_model == 'new-model'
|
||||
assert settings.llm_api_key.get_secret_value() == 'new-api-key'
|
||||
assert settings.llm_base_url == 'https://new-url'
|
||||
assert settings.agent == 'default'
|
||||
assert settings.confirmation_mode is True
|
||||
assert settings.enable_default_condenser is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.core.cli_settings.Agent.list_agents')
|
||||
@patch('openhands.core.cli_settings.PromptSession')
|
||||
@patch('openhands.core.cli_settings.cli_confirm')
|
||||
@patch(
|
||||
'openhands.core.cli_settings.LLMSummarizingCondenserConfig',
|
||||
MockLLMSummarizingCondenserConfig,
|
||||
)
|
||||
@patch('openhands.core.cli_settings.NoOpCondenserConfig', MockNoOpCondenserConfig)
|
||||
async def test_modify_llm_settings_advanced_user_cancels(
|
||||
self, mock_confirm, mock_session, mock_list_agents, app_config, settings_store
|
||||
):
|
||||
# Setup mocks
|
||||
mock_list_agents.return_value = ['default', 'test-agent']
|
||||
|
||||
session_instance = MagicMock()
|
||||
session_instance.prompt_async = AsyncMock(side_effect=UserCancelledError())
|
||||
mock_session.return_value = session_instance
|
||||
|
||||
# Call the function
|
||||
await modify_llm_settings_advanced(app_config, settings_store)
|
||||
|
||||
# Verify settings were not changed
|
||||
app_config.set_llm_config.assert_not_called()
|
||||
settings_store.store.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.core.cli_settings.Agent.list_agents')
|
||||
@patch('openhands.core.cli_settings.PromptSession')
|
||||
@patch('openhands.core.cli_settings.cli_confirm')
|
||||
@patch('openhands.core.cli_settings.print_formatted_text')
|
||||
@patch(
|
||||
'openhands.core.cli_settings.LLMSummarizingCondenserConfig',
|
||||
MockLLMSummarizingCondenserConfig,
|
||||
)
|
||||
@patch('openhands.core.cli_settings.NoOpCondenserConfig', MockNoOpCondenserConfig)
|
||||
async def test_modify_llm_settings_advanced_invalid_agent(
|
||||
self,
|
||||
mock_print,
|
||||
mock_confirm,
|
||||
mock_session,
|
||||
mock_list_agents,
|
||||
app_config,
|
||||
settings_store,
|
||||
):
|
||||
# Setup mocks
|
||||
mock_list_agents.return_value = ['default', 'test-agent']
|
||||
|
||||
session_instance = MagicMock()
|
||||
session_instance.prompt_async = AsyncMock(
|
||||
side_effect=[
|
||||
'new-model', # Custom model
|
||||
'https://new-url', # Base URL
|
||||
'new-api-key', # API key
|
||||
'invalid-agent', # Invalid agent
|
||||
'default', # Valid agent on retry
|
||||
]
|
||||
)
|
||||
mock_session.return_value = session_instance
|
||||
|
||||
# Call the function
|
||||
await modify_llm_settings_advanced(app_config, settings_store)
|
||||
|
||||
# Verify error message was shown
|
||||
assert (
|
||||
mock_print.call_count == 3
|
||||
) # Called 3 times: empty line, error message, empty line
|
||||
error_message_call = mock_print.call_args_list[
|
||||
1
|
||||
] # The second call contains the error message
|
||||
args, kwargs = error_message_call
|
||||
assert isinstance(args[0], HTML)
|
||||
assert 'Invalid agent' in args[0].value
|
||||
|
||||
# Verify settings were not changed
|
||||
app_config.set_llm_config.assert_not_called()
|
||||
settings_store.store.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.core.cli_settings.Agent.list_agents')
|
||||
@patch('openhands.core.cli_settings.PromptSession')
|
||||
@patch('openhands.core.cli_settings.cli_confirm')
|
||||
@patch(
|
||||
'openhands.core.cli_settings.LLMSummarizingCondenserConfig',
|
||||
MockLLMSummarizingCondenserConfig,
|
||||
)
|
||||
@patch('openhands.core.cli_settings.NoOpCondenserConfig', MockNoOpCondenserConfig)
|
||||
async def test_modify_llm_settings_advanced_user_rejects_save(
|
||||
self, mock_confirm, mock_session, mock_list_agents, app_config, settings_store
|
||||
):
|
||||
# Setup mocks
|
||||
mock_list_agents.return_value = ['default', 'test-agent']
|
||||
|
||||
session_instance = MagicMock()
|
||||
session_instance.prompt_async = AsyncMock(
|
||||
side_effect=[
|
||||
'new-model', # Custom model
|
||||
'https://new-url', # Base URL
|
||||
'new-api-key', # API key
|
||||
'default', # Agent
|
||||
]
|
||||
)
|
||||
mock_session.return_value = session_instance
|
||||
|
||||
# Mock user confirmations
|
||||
mock_confirm.side_effect = [
|
||||
0, # Enable confirmation mode
|
||||
0, # Enable memory condensation
|
||||
1, # Reject saving settings
|
||||
]
|
||||
|
||||
# Call the function
|
||||
await modify_llm_settings_advanced(app_config, settings_store)
|
||||
|
||||
# Verify settings were not changed
|
||||
app_config.set_llm_config.assert_not_called()
|
||||
settings_store.store.assert_not_called()
|
||||
@@ -1,301 +0,0 @@
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from io import StringIO
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from prompt_toolkit.application import create_app_session
|
||||
from prompt_toolkit.input import create_pipe_input
|
||||
from prompt_toolkit.output import create_output
|
||||
|
||||
from openhands.core.cli import main
|
||||
from openhands.core.config import AppConfig
|
||||
|
||||
|
||||
class MockEventStream:
|
||||
def __init__(self):
|
||||
self._subscribers = {}
|
||||
self.cur_id = 0
|
||||
|
||||
def subscribe(self, subscriber_id, callback, callback_id):
|
||||
if subscriber_id not in self._subscribers:
|
||||
self._subscribers[subscriber_id] = {}
|
||||
self._subscribers[subscriber_id][callback_id] = callback
|
||||
|
||||
def unsubscribe(self, subscriber_id, callback_id):
|
||||
if (
|
||||
subscriber_id in self._subscribers
|
||||
and callback_id in self._subscribers[subscriber_id]
|
||||
):
|
||||
del self._subscribers[subscriber_id][callback_id]
|
||||
|
||||
def add_event(self, event, source):
|
||||
event._id = self.cur_id
|
||||
self.cur_id += 1
|
||||
event._source = source
|
||||
event._timestamp = datetime.now().isoformat()
|
||||
|
||||
for subscriber_id in self._subscribers:
|
||||
for callback_id, callback in self._subscribers[subscriber_id].items():
|
||||
callback(event)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_agent():
|
||||
with patch('openhands.core.cli.create_agent') as mock_create_agent:
|
||||
mock_agent_instance = AsyncMock()
|
||||
mock_agent_instance.name = 'test-agent'
|
||||
mock_agent_instance.llm = AsyncMock()
|
||||
mock_agent_instance.llm.config = AsyncMock()
|
||||
mock_agent_instance.llm.config.model = 'test-model'
|
||||
mock_agent_instance.llm.config.base_url = 'http://test'
|
||||
mock_agent_instance.llm.config.max_message_chars = 1000
|
||||
mock_agent_instance.config = AsyncMock()
|
||||
mock_agent_instance.config.disabled_microagents = []
|
||||
mock_agent_instance.sandbox_plugins = []
|
||||
mock_agent_instance.prompt_manager = AsyncMock()
|
||||
mock_create_agent.return_value = mock_agent_instance
|
||||
yield mock_agent_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_controller():
|
||||
with patch('openhands.core.cli.create_controller') as mock_create_controller:
|
||||
mock_controller_instance = AsyncMock()
|
||||
mock_controller_instance.state.agent_state = None
|
||||
# Mock run_until_done to finish immediately
|
||||
mock_controller_instance.run_until_done = AsyncMock(return_value=None)
|
||||
mock_create_controller.return_value = (mock_controller_instance, None)
|
||||
yield mock_controller_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
with patch('openhands.core.cli.parse_arguments') as mock_parse_args:
|
||||
args = Mock()
|
||||
args.file = None
|
||||
args.task = None
|
||||
args.directory = None
|
||||
mock_parse_args.return_value = args
|
||||
with patch('openhands.core.cli.setup_config_from_args') as mock_setup_config:
|
||||
mock_config = AppConfig()
|
||||
mock_config.cli_multiline_input = False
|
||||
mock_config.security = Mock()
|
||||
mock_config.security.confirmation_mode = False
|
||||
mock_config.sandbox = Mock()
|
||||
mock_config.sandbox.selected_repo = None
|
||||
mock_config.workspace_base = '/test'
|
||||
mock_setup_config.return_value = mock_config
|
||||
yield mock_config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_memory():
|
||||
with patch('openhands.core.cli.create_memory') as mock_create_memory:
|
||||
mock_memory_instance = AsyncMock()
|
||||
mock_create_memory.return_value = mock_memory_instance
|
||||
yield mock_memory_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_read_task():
|
||||
with patch('openhands.core.cli.read_task') as mock_read_task:
|
||||
mock_read_task.return_value = None
|
||||
yield mock_read_task
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_runtime():
|
||||
with patch('openhands.core.cli.create_runtime') as mock_create_runtime:
|
||||
mock_runtime_instance = AsyncMock()
|
||||
|
||||
mock_event_stream = MockEventStream()
|
||||
mock_runtime_instance.event_stream = mock_event_stream
|
||||
|
||||
mock_runtime_instance.connect = AsyncMock()
|
||||
|
||||
# Ensure status_callback is None
|
||||
mock_runtime_instance.status_callback = None
|
||||
# Mock get_microagents_from_selected_repo
|
||||
mock_runtime_instance.get_microagents_from_selected_repo = Mock(return_value=[])
|
||||
mock_create_runtime.return_value = mock_runtime_instance
|
||||
yield mock_runtime_instance
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_startup_folder_security_confirmation_agree(
|
||||
mock_runtime, mock_controller, mock_config, mock_agent, mock_memory, mock_read_task
|
||||
):
|
||||
buffer = StringIO()
|
||||
|
||||
with patch(
|
||||
'openhands.core.cli.manage_openhands_file', return_value=False
|
||||
) as mock_manage_openhands_file:
|
||||
with patch(
|
||||
'openhands.core.cli.cli_confirm', return_value=True
|
||||
) as mock_cli_confirm:
|
||||
with create_app_session(
|
||||
input=create_pipe_input(), output=create_output(stdout=buffer)
|
||||
):
|
||||
mock_controller.status_callback = None
|
||||
|
||||
main_task = asyncio.create_task(main(asyncio.get_event_loop()))
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(main_task, timeout=0.1)
|
||||
except Exception:
|
||||
main_task.cancel()
|
||||
|
||||
buffer.seek(0)
|
||||
output = buffer.read()
|
||||
|
||||
# ASCII art banner
|
||||
assert '___' in output
|
||||
|
||||
# Version information
|
||||
assert 'OpenHands CLI v' in output
|
||||
|
||||
# Session initialization
|
||||
assert 'Initializing session' in output
|
||||
|
||||
# Folder security confirmation
|
||||
assert 'Do you trust the files in this folder?' in output
|
||||
assert '/test' in output
|
||||
assert (
|
||||
'OpenHands may read and execute files in this folder with your permission.'
|
||||
in output
|
||||
)
|
||||
|
||||
# Confirmation prompt
|
||||
mock_manage_openhands_file.assert_any_call('/test')
|
||||
mock_cli_confirm.assert_called_once_with(
|
||||
'Do you wish to continue?', ['Yes, proceed', 'No, exit']
|
||||
)
|
||||
mock_manage_openhands_file.assert_any_call('/test', add_to_trusted=True)
|
||||
|
||||
# Session initialization complete
|
||||
assert 'Initialized session' in output
|
||||
|
||||
# Welcome message
|
||||
assert "Let's start building!" in output
|
||||
assert 'What do you want to build?' in output
|
||||
assert 'Type /help for help' in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_startup_folder_security_confirmation_disagree(
|
||||
mock_runtime, mock_controller, mock_config, mock_agent, mock_memory, mock_read_task
|
||||
):
|
||||
buffer = StringIO()
|
||||
|
||||
with patch(
|
||||
'openhands.core.cli.manage_openhands_file', return_value=False
|
||||
) as mock_manage_openhands_file:
|
||||
with patch(
|
||||
'openhands.core.cli.cli_confirm', return_value=False
|
||||
) as mock_cli_confirm:
|
||||
with create_app_session(
|
||||
input=create_pipe_input(), output=create_output(stdout=buffer)
|
||||
):
|
||||
mock_controller.status_callback = None
|
||||
|
||||
main_task = asyncio.create_task(main(asyncio.get_event_loop()))
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(main_task, timeout=0.1)
|
||||
except Exception:
|
||||
main_task.cancel()
|
||||
|
||||
buffer.seek(0)
|
||||
output = buffer.read()
|
||||
|
||||
# ASCII art banner
|
||||
assert '___' in output
|
||||
|
||||
# Version information
|
||||
assert 'OpenHands CLI v' in output
|
||||
|
||||
# Session initialization
|
||||
assert 'Initializing session' in output
|
||||
|
||||
# Folder security confirmation
|
||||
assert 'Do you trust the files in this folder?' in output
|
||||
assert '/test' in output
|
||||
assert (
|
||||
'OpenHands may read and execute files in this folder with your permission.'
|
||||
in output
|
||||
)
|
||||
|
||||
# Confirmation prompt
|
||||
mock_manage_openhands_file.assert_called_once_with('/test')
|
||||
mock_cli_confirm.assert_called_once_with(
|
||||
'Do you wish to continue?', ['Yes, proceed', 'No, exit']
|
||||
)
|
||||
|
||||
# Session initialization complete
|
||||
assert 'Initialized session' not in output
|
||||
|
||||
# Welcome message
|
||||
assert "Let's start building!" not in output
|
||||
assert 'What do you want to build?' not in output
|
||||
assert 'Type /help for help' not in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_startup_trusted_folder(
|
||||
mock_runtime, mock_controller, mock_config, mock_agent, mock_memory, mock_read_task
|
||||
):
|
||||
buffer = StringIO()
|
||||
|
||||
with patch('openhands.core.cli.manage_openhands_file', return_value=True):
|
||||
with patch(
|
||||
'openhands.core.cli.cli_confirm', return_value=True
|
||||
) as mock_cli_confirm:
|
||||
with create_app_session(
|
||||
input=create_pipe_input(), output=create_output(stdout=buffer)
|
||||
):
|
||||
mock_controller.status_callback = None
|
||||
|
||||
main_task = asyncio.create_task(main(asyncio.get_event_loop()))
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(main_task, timeout=0.1)
|
||||
except Exception:
|
||||
main_task.cancel()
|
||||
|
||||
buffer.seek(0)
|
||||
output = buffer.read()
|
||||
|
||||
# ASCII art banner
|
||||
assert '___' in output
|
||||
|
||||
# Version information
|
||||
assert 'OpenHands CLI v' in output
|
||||
|
||||
# Session initialization
|
||||
assert 'Initializing session' in output
|
||||
|
||||
# Folder security confirmation should not be shown
|
||||
assert 'Do you trust the files in this folder?' not in output
|
||||
assert '/test' not in output
|
||||
assert (
|
||||
'OpenHands may read and execute files in this folder with your permission.'
|
||||
not in output
|
||||
)
|
||||
|
||||
# Confirmation prompt should not be shown
|
||||
mock_cli_confirm.assert_not_called()
|
||||
|
||||
# Session initialization
|
||||
assert 'Initialized session' in output
|
||||
|
||||
# Welcome message
|
||||
assert "Let's start building!" in output
|
||||
assert 'What do you want to build?' in output
|
||||
assert 'Type /help for help' in output
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user