mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
12 Commits
openhands-
...
openhands-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9672f105e4 | ||
|
|
fb53ae43c0 | ||
|
|
1f8a0180d3 | ||
|
|
8cfcdd7ba3 | ||
|
|
9515ac5e62 | ||
|
|
cebd391b7a | ||
|
|
343b86429e | ||
|
|
09734467c0 | ||
|
|
17d722f3b3 | ||
|
|
e310f6b776 | ||
|
|
5626a22e42 | ||
|
|
cde8aad47f |
@@ -204,7 +204,7 @@ Then, in a separate Python environment with `streamlit` library, you can run the
|
||||
```bash
|
||||
# Make sure you are inside the cloned `evaluation` repo
|
||||
conda activate streamlit # if you follow the optional conda env setup above
|
||||
streamlit app.py --server.port 8501 --server.address 0.0.0.0
|
||||
streamlit run app.py --server.port 8501 --server.address 0.0.0.0
|
||||
```
|
||||
|
||||
Then you can access the SWE-Bench trajectory visualizer at `localhost:8501`.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { test, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ExpandableMessage } from "#/components/features/chat/expandable-message";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
// Initialize i18n for testing
|
||||
i18n.use(initReactI18next).init({
|
||||
lng: "en",
|
||||
resources: {
|
||||
en: {
|
||||
translation: {
|
||||
"ACTION_MESSAGE$RUN": "this action has not been executed",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
test("should show only the headline for unexecuted actions", () => {
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<ExpandableMessage
|
||||
type="action"
|
||||
id="ACTION_MESSAGE$RUN"
|
||||
message="Command:\n`ls -l`"
|
||||
success={undefined}
|
||||
/>
|
||||
</I18nextProvider>
|
||||
);
|
||||
|
||||
// The headline should be visible
|
||||
const headline = screen.getByText("this action has not been executed");
|
||||
expect(headline).toBeInTheDocument();
|
||||
expect(headline).toHaveClass("font-bold");
|
||||
|
||||
// The command details should not be visible
|
||||
const details = screen.queryByText(/Command:/, { exact: false });
|
||||
expect(details).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should show only the details for completed successful actions", () => {
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<ExpandableMessage
|
||||
type="action"
|
||||
id="ACTION_MESSAGE$RUN"
|
||||
message="Command executed successfully"
|
||||
success={true}
|
||||
/>
|
||||
</I18nextProvider>
|
||||
);
|
||||
|
||||
// The command details should be visible
|
||||
const details = screen.getByText("Command executed successfully");
|
||||
expect(details).toBeInTheDocument();
|
||||
|
||||
// The success icon should be visible
|
||||
const statusIcon = screen.getByTestId("status-icon");
|
||||
expect(statusIcon).toHaveClass("fill-success");
|
||||
|
||||
// The headline should not be visible
|
||||
const headline = screen.queryByText("this action has not been executed");
|
||||
expect(headline).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should show only the details for completed failed actions", () => {
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<ExpandableMessage
|
||||
type="action"
|
||||
id="ACTION_MESSAGE$RUN"
|
||||
message="Command failed"
|
||||
success={false}
|
||||
/>
|
||||
</I18nextProvider>
|
||||
);
|
||||
|
||||
// The command details should be visible
|
||||
const details = screen.getByText("Command failed");
|
||||
expect(details).toBeInTheDocument();
|
||||
|
||||
// The error icon should be visible
|
||||
const statusIcon = screen.getByTestId("status-icon");
|
||||
expect(statusIcon).toHaveClass("fill-danger");
|
||||
|
||||
// The headline should not be visible
|
||||
const headline = screen.queryByText("this action has not been executed");
|
||||
expect(headline).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -19,9 +19,9 @@ describe("ConversationCard", () => {
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
const expectedDate = `${formatTimeDelta(new Date("2021-10-01T12:00:00Z"))} ago`;
|
||||
@@ -33,20 +33,20 @@ describe("ConversationCard", () => {
|
||||
within(card).getByText(expectedDate);
|
||||
});
|
||||
|
||||
it("should render the repo if available", () => {
|
||||
it("should render the selectedRepository if available", () => {
|
||||
const { rerender } = render(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("conversation-card-repo"),
|
||||
screen.queryByTestId("conversation-card-selected-repository"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
@@ -54,13 +54,13 @@ describe("ConversationCard", () => {
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo="org/repo"
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository="org/selectedRepository"
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getByTestId("conversation-card-repo");
|
||||
screen.getByTestId("conversation-card-selected-repository");
|
||||
});
|
||||
|
||||
it("should call onClick when the card is clicked", async () => {
|
||||
@@ -70,9 +70,9 @@ describe("ConversationCard", () => {
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -89,9 +89,9 @@ describe("ConversationCard", () => {
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -114,9 +114,9 @@ describe("ConversationCard", () => {
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -131,21 +131,21 @@ describe("ConversationCard", () => {
|
||||
expect(onDelete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("clicking the repo should not trigger the onClick handler", async () => {
|
||||
test("clicking the selectedRepository should not trigger the onClick handler", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo="org/repo"
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository="org/selectedRepository"
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
const repo = screen.getByTestId("conversation-card-repo");
|
||||
await user.click(repo);
|
||||
const selectedRepository = screen.getByTestId("conversation-card-selected-repository");
|
||||
await user.click(selectedRepository);
|
||||
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -156,9 +156,9 @@ describe("ConversationCard", () => {
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
onChangeTitle={onChangeTitle}
|
||||
/>,
|
||||
);
|
||||
@@ -180,9 +180,9 @@ describe("ConversationCard", () => {
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -202,9 +202,9 @@ describe("ConversationCard", () => {
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -221,9 +221,9 @@ describe("ConversationCard", () => {
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -239,19 +239,19 @@ describe("ConversationCard", () => {
|
||||
});
|
||||
|
||||
describe("state indicator", () => {
|
||||
it("should render the 'cold' indicator by default", () => {
|
||||
it("should render the 'STOPPED' indicator by default", () => {
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getByTestId("cold-indicator");
|
||||
screen.getByTestId("STOPPED-indicator");
|
||||
});
|
||||
|
||||
it("should render the other indicators when provided", () => {
|
||||
@@ -260,15 +260,15 @@ describe("ConversationCard", () => {
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
state="warm"
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
status="RUNNING"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("cold-indicator")).not.toBeInTheDocument();
|
||||
screen.getByTestId("warm-indicator");
|
||||
expect(screen.queryByTestId("STOPPED-indicator")).not.toBeInTheDocument();
|
||||
screen.getByTestId("RUNNING-indicator");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -175,7 +175,7 @@ describe("ConversationPanel", () => {
|
||||
|
||||
// Ensure the conversation is renamed
|
||||
expect(updateUserConversationSpy).toHaveBeenCalledWith("3", {
|
||||
name: "Conversation 1 Renamed",
|
||||
title: "Conversation 1 Renamed",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { Sidebar } from "#/components/features/sidebar/sidebar";
|
||||
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
|
||||
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
|
||||
const renderSidebar = () => {
|
||||
const RouterStub = createRoutesStub([
|
||||
@@ -18,7 +18,7 @@ const renderSidebar = () => {
|
||||
};
|
||||
|
||||
describe("Sidebar", () => {
|
||||
it.skipIf(!MULTI_CONVO_UI_IS_ENABLED)(
|
||||
it.skipIf(!MULTI_CONVERSATION_UI)(
|
||||
"should have the conversation panel open by default",
|
||||
() => {
|
||||
renderSidebar();
|
||||
@@ -26,7 +26,7 @@ describe("Sidebar", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!MULTI_CONVO_UI_IS_ENABLED)(
|
||||
it.skipIf(!MULTI_CONVERSATION_UI)(
|
||||
"should toggle the conversation panel",
|
||||
async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { RuntimeSizeSelector } from "#/components/shared/modals/settings/runtime-size-selector";
|
||||
|
||||
const renderRuntimeSizeSelector = () =>
|
||||
renderWithProviders(<RuntimeSizeSelector isDisabled={false} />);
|
||||
|
||||
describe("RuntimeSizeSelector", () => {
|
||||
it("should show both runtime size options", () => {
|
||||
renderRuntimeSizeSelector();
|
||||
// The options are in the hidden select element
|
||||
const select = screen.getByRole("combobox", { hidden: true });
|
||||
expect(select).toHaveValue("1");
|
||||
expect(select).toHaveDisplayValue("1x (2 core, 8G)");
|
||||
expect(select.children).toHaveLength(3); // Empty option + 2 size options
|
||||
});
|
||||
|
||||
it("should show the full description text for disabled options", async () => {
|
||||
renderRuntimeSizeSelector();
|
||||
|
||||
// Click the button to open the dropdown
|
||||
const button = screen.getByRole("button", {
|
||||
name: "1x (2 core, 8G) SETTINGS_FORM$RUNTIME_SIZE_LABEL",
|
||||
});
|
||||
button.click();
|
||||
|
||||
// Wait for the dropdown to open and find the description text
|
||||
const description = await screen.findByText(
|
||||
"Runtime sizes over 1 are disabled by default, please contact contact@all-hands.dev to get access to larger runtimes.",
|
||||
);
|
||||
expect(description).toBeInTheDocument();
|
||||
expect(description).toHaveClass("whitespace-normal", "break-words");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { SettingsForm } from "#/components/shared/modals/settings/settings-form";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
describe("SettingsForm", () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "123",
|
||||
});
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: () => (
|
||||
<SettingsForm
|
||||
settings={DEFAULT_SETTINGS}
|
||||
models={[]}
|
||||
agents={[]}
|
||||
securityAnalyzers={[]}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
),
|
||||
path: "/",
|
||||
},
|
||||
]);
|
||||
|
||||
it("should not show runtime size selector by default", () => {
|
||||
renderWithProviders(<RouterStub />);
|
||||
expect(screen.queryByText("Runtime Size")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show runtime size selector when advanced options are enabled", async () => {
|
||||
renderWithProviders(<RouterStub />);
|
||||
const advancedSwitch = screen.getByRole("switch", {
|
||||
name: "SETTINGS_FORM$ADVANCED_OPTIONS_LABEL",
|
||||
});
|
||||
fireEvent.click(advancedSwitch);
|
||||
await screen.findByText("SETTINGS_FORM$RUNTIME_SIZE_LABEL");
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { screen, waitFor } from "@testing-library/react";
|
||||
import toast from "react-hot-toast";
|
||||
import App from "#/routes/_oh.app/route";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
|
||||
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
|
||||
describe("App", () => {
|
||||
const RouteStub = createRoutesStub([
|
||||
@@ -35,7 +35,7 @@ describe("App", () => {
|
||||
await screen.findByTestId("app-route");
|
||||
});
|
||||
|
||||
it.skipIf(!MULTI_CONVO_UI_IS_ENABLED)(
|
||||
it.skipIf(!MULTI_CONVERSATION_UI)(
|
||||
"should call endSession if the user does not have permission to view conversation",
|
||||
async () => {
|
||||
const errorToastSpy = vi.spyOn(toast, "error");
|
||||
@@ -59,10 +59,10 @@ describe("App", () => {
|
||||
|
||||
getConversationSpy.mockResolvedValue({
|
||||
conversation_id: "9999",
|
||||
lastUpdated: "",
|
||||
name: "",
|
||||
repo: "",
|
||||
state: "cold",
|
||||
last_updated_at: "",
|
||||
title: "",
|
||||
selected_repository: "",
|
||||
status: "STOPPED",
|
||||
});
|
||||
const { rerender } = renderWithProviders(
|
||||
<RouteStub initialEntries={["/conversation/9999"]} />,
|
||||
|
||||
9
frontend/package-lock.json
generated
9
frontend/package-lock.json
generated
@@ -52,7 +52,7 @@
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@react-router/dev": "^7.1.1",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tanstack/eslint-plugin-query": "^5.62.9",
|
||||
"@tanstack/eslint-plugin-query": "^5.62.15",
|
||||
"@testing-library/jest-dom": "^6.6.1",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
@@ -5344,11 +5344,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/eslint-plugin-query": {
|
||||
"version": "5.62.9",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.62.9.tgz",
|
||||
"integrity": "sha512-F3onhTcpBj7zQDo0NVtZwZQKRFx8BwpSabMJybl9no3+dFHUurvNMrH5M/6KNpkdDCf3zyHWadruZL6636B8Fw==",
|
||||
"version": "5.62.15",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.62.15.tgz",
|
||||
"integrity": "sha512-24BHoF3LIzyptjrZXc1IpaISno+fhVD3zWWso/HPSB+ZVOyOXoiQSQc2K362T13JKJ07EInhHi1+KyNoRzCCfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/utils": "^8.18.1"
|
||||
},
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@react-router/dev": "^7.1.1",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tanstack/eslint-plugin-query": "^5.62.9",
|
||||
"@tanstack/eslint-plugin-query": "^5.62.15",
|
||||
"@testing-library/jest-dom": "^6.6.1",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
GetVSCodeUrlResponse,
|
||||
AuthenticateResponse,
|
||||
Conversation,
|
||||
ResultSet,
|
||||
} from "./open-hands.types";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
import { ApiSettings } from "#/services/settings";
|
||||
@@ -222,8 +223,10 @@ class OpenHands {
|
||||
}
|
||||
|
||||
static async getUserConversations(): Promise<Conversation[]> {
|
||||
const { data } = await openHands.get<Conversation[]>("/api/conversations");
|
||||
return data;
|
||||
const { data } = await openHands.get<ResultSet<Conversation>>(
|
||||
"/api/conversations?limit=9",
|
||||
);
|
||||
return data.results;
|
||||
}
|
||||
|
||||
static async deleteUserConversation(conversationId: string): Promise<void> {
|
||||
@@ -232,9 +235,9 @@ class OpenHands {
|
||||
|
||||
static async updateUserConversation(
|
||||
conversationId: string,
|
||||
conversation: Partial<Omit<Conversation, "id">>,
|
||||
conversation: Partial<Omit<Conversation, "conversation_id">>,
|
||||
): Promise<void> {
|
||||
await openHands.put(`/api/conversations/${conversationId}`, conversation);
|
||||
await openHands.patch(`/api/conversations/${conversationId}`, conversation);
|
||||
}
|
||||
|
||||
static async createConversation(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProjectState } from "#/components/features/conversation-panel/conversation-state-indicator";
|
||||
import { ProjectStatus } from "#/components/features/conversation-panel/conversation-state-indicator";
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: string;
|
||||
@@ -62,8 +62,13 @@ export interface AuthenticateResponse {
|
||||
|
||||
export interface Conversation {
|
||||
conversation_id: string;
|
||||
name: string;
|
||||
repo: string | null;
|
||||
lastUpdated: string;
|
||||
state: ProjectState;
|
||||
title: string;
|
||||
selected_repository: string | null;
|
||||
last_updated_at: string;
|
||||
status: ProjectStatus;
|
||||
}
|
||||
|
||||
export interface ResultSet<T> {
|
||||
results: T[];
|
||||
next_page_id: string | null;
|
||||
}
|
||||
|
||||
@@ -30,11 +30,24 @@ export function ExpandableMessage({
|
||||
|
||||
useEffect(() => {
|
||||
if (id && i18n.exists(id)) {
|
||||
setHeadline(t(id));
|
||||
// Only show the headline if the action hasn't been executed yet (success is undefined)
|
||||
if (success === undefined) {
|
||||
setHeadline(t(id));
|
||||
setDetails("");
|
||||
setShowDetails(false);
|
||||
} else {
|
||||
// Only show the details if the action has been executed
|
||||
setHeadline("");
|
||||
setDetails(message);
|
||||
setShowDetails(true);
|
||||
}
|
||||
} else {
|
||||
// If no translation ID is provided, just show the message
|
||||
setHeadline("");
|
||||
setDetails(message);
|
||||
setShowDetails(false);
|
||||
setShowDetails(true);
|
||||
}
|
||||
}, [id, message, i18n.language]);
|
||||
}, [id, message, success, i18n.language, t]);
|
||||
|
||||
const statusIconClasses = "h-4 w-4 ml-2 inline";
|
||||
|
||||
@@ -46,7 +59,8 @@ export function ExpandableMessage({
|
||||
)}
|
||||
>
|
||||
<div className="text-sm w-full">
|
||||
{headline && (
|
||||
{headline ? (
|
||||
// Show headline for unexecuted actions
|
||||
<div className="flex flex-row justify-between items-center w-full">
|
||||
<span
|
||||
className={cn(
|
||||
@@ -55,30 +69,26 @@ export function ExpandableMessage({
|
||||
)}
|
||||
>
|
||||
{headline}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className="cursor-pointer text-left"
|
||||
>
|
||||
{showDetails ? (
|
||||
<ArrowUp
|
||||
className={cn(
|
||||
"h-4 w-4 ml-2 inline",
|
||||
type === "error" ? "fill-danger" : "fill-neutral-300",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<ArrowDown
|
||||
className={cn(
|
||||
"h-4 w-4 ml-2 inline",
|
||||
type === "error" ? "fill-danger" : "fill-neutral-300",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
// Show details for executed actions
|
||||
<div className="flex flex-row justify-between items-center w-full">
|
||||
<div className="flex-grow">
|
||||
<Markdown
|
||||
className="text-sm overflow-auto"
|
||||
components={{
|
||||
code,
|
||||
ul,
|
||||
ol,
|
||||
}}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
>
|
||||
{details}
|
||||
</Markdown>
|
||||
</div>
|
||||
{type === "action" && success !== undefined && (
|
||||
<span className="flex-shrink-0">
|
||||
<span className="flex-shrink-0 ml-2">
|
||||
{success ? (
|
||||
<CheckCircle
|
||||
data-testid="status-icon"
|
||||
@@ -94,19 +104,6 @@ export function ExpandableMessage({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showDetails && (
|
||||
<Markdown
|
||||
className="text-sm overflow-auto"
|
||||
components={{
|
||||
code,
|
||||
ul,
|
||||
ol,
|
||||
}}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
>
|
||||
{details}
|
||||
</Markdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ export function ContextMenu({
|
||||
<ul
|
||||
data-testid={testId}
|
||||
ref={ref}
|
||||
className={cn("bg-[#404040] rounded-md w-[224px]", className)}
|
||||
className={cn("bg-[#404040] rounded-md w-[140px]", className)}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import { formatTimeDelta } from "#/utils/format-time-delta";
|
||||
import { ConversationRepoLink } from "./conversation-repo-link";
|
||||
import {
|
||||
ProjectState,
|
||||
ProjectStatus,
|
||||
ConversationStateIndicator,
|
||||
} from "./conversation-state-indicator";
|
||||
import { ContextMenu } from "../context-menu/context-menu";
|
||||
@@ -13,20 +13,20 @@ interface ProjectCardProps {
|
||||
onClick: () => void;
|
||||
onDelete: () => void;
|
||||
onChangeTitle: (title: string) => void;
|
||||
name: string;
|
||||
repo: string | null;
|
||||
lastUpdated: string; // ISO 8601
|
||||
state?: ProjectState;
|
||||
title: string;
|
||||
selectedRepository: string | null;
|
||||
lastUpdatedAt: string; // ISO 8601
|
||||
status?: ProjectStatus;
|
||||
}
|
||||
|
||||
export function ConversationCard({
|
||||
onClick,
|
||||
onDelete,
|
||||
onChangeTitle,
|
||||
name,
|
||||
repo,
|
||||
lastUpdated,
|
||||
state = "cold",
|
||||
title,
|
||||
selectedRepository,
|
||||
lastUpdatedAt,
|
||||
status = "STOPPED",
|
||||
}: ProjectCardProps) {
|
||||
const [contextMenuVisible, setContextMenuVisible] = React.useState(false);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
@@ -38,7 +38,13 @@ export function ConversationCard({
|
||||
inputRef.current!.value = trimmed;
|
||||
} else {
|
||||
// reset the value if it's empty
|
||||
inputRef.current!.value = name;
|
||||
inputRef.current!.value = title;
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -55,47 +61,45 @@ export function ConversationCard({
|
||||
<div
|
||||
data-testid="conversation-card"
|
||||
onClick={onClick}
|
||||
className="h-[100px] w-full px-[18px] py-4 border-b border-neutral-600"
|
||||
className="h-[100px] w-full px-[18px] py-4 border-b border-neutral-600 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between space-x-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
data-testid="conversation-card-title"
|
||||
onClick={handleInputClick}
|
||||
onBlur={handleBlur}
|
||||
onKeyUp={handleKeyUp}
|
||||
type="text"
|
||||
defaultValue={name}
|
||||
className="text-sm leading-6 font-semibold bg-transparent"
|
||||
defaultValue={title}
|
||||
className="text-sm leading-6 font-semibold bg-transparent w-full"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 relative">
|
||||
<ConversationStateIndicator state={state} />
|
||||
<ConversationStateIndicator status={status} />
|
||||
<EllipsisButton
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setContextMenuVisible((prev) => !prev);
|
||||
}}
|
||||
/>
|
||||
{contextMenuVisible && (
|
||||
<ContextMenu testId="context-menu" className="absolute left-full">
|
||||
<ContextMenuListItem
|
||||
testId="delete-button"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete
|
||||
</ContextMenuListItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{repo && (
|
||||
{contextMenuVisible && (
|
||||
<ContextMenu testId="context-menu" className="left-full float-right">
|
||||
<ContextMenuListItem testId="delete-button" onClick={handleDelete}>
|
||||
Delete
|
||||
</ContextMenuListItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{selectedRepository && (
|
||||
<ConversationRepoLink
|
||||
repo={repo}
|
||||
selectedRepository={selectedRepository}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-neutral-400">
|
||||
<time>{formatTimeDelta(new Date(lastUpdated))} ago</time>
|
||||
<time>{formatTimeDelta(new Date(lastUpdatedAt))} ago</time>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -60,7 +60,7 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
if (oldTitle !== newTitle)
|
||||
updateConversation({
|
||||
id: conversationId,
|
||||
conversation: { name: newTitle },
|
||||
conversation: { title: newTitle },
|
||||
});
|
||||
};
|
||||
|
||||
@@ -72,7 +72,7 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid="conversation-panel"
|
||||
className="w-[350px] h-full border border-neutral-700 bg-neutral-800 rounded-xl"
|
||||
className="w-[350px] h-full border border-neutral-700 bg-neutral-800 rounded-xl overflow-y-auto"
|
||||
>
|
||||
<div className="pt-4 px-4 flex items-center justify-between">
|
||||
{location.pathname.startsWith("/conversation") && (
|
||||
@@ -98,12 +98,12 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
onClick={() => handleClickCard(project.conversation_id)}
|
||||
onDelete={() => handleDeleteProject(project.conversation_id)}
|
||||
onChangeTitle={(title) =>
|
||||
handleChangeTitle(project.conversation_id, project.name, title)
|
||||
handleChangeTitle(project.conversation_id, project.title, title)
|
||||
}
|
||||
name={project.name}
|
||||
repo={project.repo}
|
||||
lastUpdated={project.lastUpdated}
|
||||
state={project.state}
|
||||
title={project.title}
|
||||
selectedRepository={project.selected_repository}
|
||||
lastUpdatedAt={project.last_updated_at}
|
||||
status={project.status}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
interface ConversationRepoLinkProps {
|
||||
repo: string;
|
||||
selectedRepository: string;
|
||||
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
}
|
||||
|
||||
export function ConversationRepoLink({
|
||||
repo,
|
||||
selectedRepository,
|
||||
onClick,
|
||||
}: ConversationRepoLinkProps) {
|
||||
return (
|
||||
<a
|
||||
data-testid="conversation-card-repo"
|
||||
href={`https://github.com/${repo}`}
|
||||
data-testid="conversation-card-selected-repository"
|
||||
href={`https://github.com/${selectedRepository}`}
|
||||
target="_blank noopener noreferrer"
|
||||
onClick={onClick}
|
||||
className="text-xs text-neutral-400 hover:text-neutral-200"
|
||||
>
|
||||
{repo}
|
||||
{selectedRepository}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,39 +1,25 @@
|
||||
import ColdIcon from "./state-indicators/cold.svg?react";
|
||||
import CoolingIcon from "./state-indicators/cooling.svg?react";
|
||||
import FinishedIcon from "./state-indicators/finished.svg?react";
|
||||
import RunningIcon from "./state-indicators/running.svg?react";
|
||||
import WaitingIcon from "./state-indicators/waiting.svg?react";
|
||||
import WarmIcon from "./state-indicators/warm.svg?react";
|
||||
|
||||
type SVGIcon = React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
|
||||
export type ProjectState =
|
||||
| "cold"
|
||||
| "cooling"
|
||||
| "finished"
|
||||
| "running"
|
||||
| "waiting"
|
||||
| "warm";
|
||||
export type ProjectStatus = "RUNNING" | "STOPPED";
|
||||
|
||||
const INDICATORS: Record<ProjectState, SVGIcon> = {
|
||||
cold: ColdIcon,
|
||||
cooling: CoolingIcon,
|
||||
finished: FinishedIcon,
|
||||
running: RunningIcon,
|
||||
waiting: WaitingIcon,
|
||||
warm: WarmIcon,
|
||||
const INDICATORS: Record<ProjectStatus, SVGIcon> = {
|
||||
STOPPED: ColdIcon,
|
||||
RUNNING: RunningIcon,
|
||||
};
|
||||
|
||||
interface ConversationStateIndicatorProps {
|
||||
state: ProjectState;
|
||||
status: ProjectStatus;
|
||||
}
|
||||
|
||||
export function ConversationStateIndicator({
|
||||
state,
|
||||
status,
|
||||
}: ConversationStateIndicatorProps) {
|
||||
const StateIcon = INDICATORS[state];
|
||||
const StateIcon = INDICATORS[status];
|
||||
|
||||
return (
|
||||
<div data-testid={`${state}-indicator`}>
|
||||
<div data-testid={`${status}-indicator`}>
|
||||
<StateIcon />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import FolderIcon from "#/icons/docs.svg?react";
|
||||
import { FaListUl } from "react-icons/fa";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useGitHubUser } from "#/hooks/query/use-github-user";
|
||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
@@ -16,8 +16,7 @@ import { SettingsModal } from "#/components/shared/modals/settings/settings-moda
|
||||
import { useSettingsUpToDate } from "#/context/settings-up-to-date-context";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { ConversationPanel } from "../conversation-panel/conversation-panel";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
|
||||
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
|
||||
export function Sidebar() {
|
||||
const location = useLocation();
|
||||
@@ -32,9 +31,18 @@ export function Sidebar() {
|
||||
const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false);
|
||||
const [startNewProjectModalIsOpen, setStartNewProjectModalIsOpen] =
|
||||
React.useState(false);
|
||||
const [conversationPanelIsOpen, setConversationPanelIsOpen] = React.useState(
|
||||
MULTI_CONVO_UI_IS_ENABLED,
|
||||
);
|
||||
const [conversationPanelIsOpen, setConversationPanelIsOpen] =
|
||||
React.useState(false);
|
||||
const conversationPanelRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const conversationPanel = conversationPanelRef.current;
|
||||
if (conversationPanelIsOpen && conversationPanel) {
|
||||
if (!conversationPanel.contains(event.target as Node)) {
|
||||
setConversationPanelIsOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
// If the github token is invalid, open the account settings modal again
|
||||
@@ -43,6 +51,13 @@ export function Sidebar() {
|
||||
}
|
||||
}, [user.isError]);
|
||||
|
||||
React.useEffect(() => {
|
||||
document.addEventListener("click", handleClick);
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClick);
|
||||
};
|
||||
}, [conversationPanelIsOpen]);
|
||||
|
||||
const handleAccountSettingsModalClose = () => {
|
||||
// If the user closes the modal without connecting to GitHub,
|
||||
// we need to log them out to clear the invalid token from the
|
||||
@@ -77,16 +92,17 @@ export function Sidebar() {
|
||||
/>
|
||||
)}
|
||||
<SettingsButton onClick={() => setSettingsModalIsOpen(true)} />
|
||||
{MULTI_CONVO_UI_IS_ENABLED && (
|
||||
{MULTI_CONVERSATION_UI && (
|
||||
<button
|
||||
data-testid="toggle-conversation-panel"
|
||||
type="button"
|
||||
onClick={() => setConversationPanelIsOpen((prev) => !prev)}
|
||||
className={cn(
|
||||
conversationPanelIsOpen ? "border-b-2 border-[#FFE165]" : "",
|
||||
)}
|
||||
>
|
||||
<FolderIcon width={28} height={28} />
|
||||
<FaListUl
|
||||
width={28}
|
||||
height={28}
|
||||
fill={conversationPanelIsOpen ? "#FFE165" : "#FFFFFF"}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<DocsButton />
|
||||
@@ -97,6 +113,7 @@ export function Sidebar() {
|
||||
|
||||
{conversationPanelIsOpen && (
|
||||
<div
|
||||
ref={conversationPanelRef}
|
||||
className="absolute h-full left-[calc(100%+12px)] top-0 z-20" // 12px padding (sidebar parent)
|
||||
>
|
||||
<ConversationPanel
|
||||
|
||||
@@ -20,7 +20,7 @@ export function AdvancedOptionSwitch({
|
||||
<Switch
|
||||
isDisabled={isDisabled}
|
||||
name="use-advanced-options"
|
||||
isSelected={showAdvancedOptions}
|
||||
defaultSelected={showAdvancedOptions}
|
||||
onValueChange={setShowAdvancedOptions}
|
||||
classNames={{
|
||||
thumb: cn(
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Select, SelectItem } from "@nextui-org/react";
|
||||
|
||||
interface RuntimeSizeSelectorProps {
|
||||
isDisabled: boolean;
|
||||
defaultValue?: number;
|
||||
}
|
||||
|
||||
export function RuntimeSizeSelector({
|
||||
isDisabled,
|
||||
defaultValue,
|
||||
}: RuntimeSizeSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor="runtime-size"
|
||||
className="font-[500] text-[#A3A3A3] text-xs"
|
||||
>
|
||||
{t("SETTINGS_FORM$RUNTIME_SIZE_LABEL")}
|
||||
</label>
|
||||
<Select
|
||||
id="runtime-size"
|
||||
name="runtime-size"
|
||||
defaultSelectedKeys={[String(defaultValue || 1)]}
|
||||
isDisabled={isDisabled}
|
||||
aria-label={t("SETTINGS_FORM$RUNTIME_SIZE_LABEL")}
|
||||
classNames={{
|
||||
trigger: "bg-[#27272A] rounded-md text-sm px-3 py-[10px]",
|
||||
}}
|
||||
>
|
||||
<SelectItem key="1" value={1}>
|
||||
1x (2 core, 8G)
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
key="2"
|
||||
value={2}
|
||||
isDisabled
|
||||
classNames={{
|
||||
description:
|
||||
"whitespace-normal break-words min-w-[300px] max-w-[300px]",
|
||||
base: "min-w-[300px] max-w-[300px]",
|
||||
}}
|
||||
description="Runtime sizes over 1 are disabled by default, please contact contact@all-hands.dev to get access to larger runtimes."
|
||||
>
|
||||
2x (4 core, 16G)
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,9 @@ import { ModalBackdrop } from "../modal-backdrop";
|
||||
import { ModelSelector } from "./model-selector";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
|
||||
import { RuntimeSizeSelector } from "./runtime-size-selector";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
|
||||
interface SettingsFormProps {
|
||||
disabled?: boolean;
|
||||
settings: Settings;
|
||||
@@ -40,6 +43,7 @@ export function SettingsForm({
|
||||
}: SettingsFormProps) {
|
||||
const { mutateAsync: saveSettings } = useSaveSettings();
|
||||
const endSession = useEndSession();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
@@ -97,6 +101,8 @@ export function SettingsForm({
|
||||
posthog.capture("settings_saved", {
|
||||
LLM_MODEL: newSettings.LLM_MODEL,
|
||||
LLM_API_KEY: newSettings.LLM_API_KEY ? "SET" : "UNSET",
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR:
|
||||
newSettings.REMOTE_RUNTIME_RESOURCE_FACTOR,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -122,6 +128,8 @@ export function SettingsForm({
|
||||
}
|
||||
};
|
||||
|
||||
const isSaasMode = config?.APP_MODE === "saas";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form
|
||||
@@ -164,16 +172,21 @@ export function SettingsForm({
|
||||
isSet={settings.LLM_API_KEY === "SET"}
|
||||
/>
|
||||
|
||||
{showAdvancedOptions && (
|
||||
<AgentInput
|
||||
isDisabled={!!disabled}
|
||||
defaultValue={settings.AGENT}
|
||||
agents={agents}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showAdvancedOptions && (
|
||||
<>
|
||||
<AgentInput
|
||||
isDisabled={!!disabled}
|
||||
defaultValue={settings.AGENT}
|
||||
agents={agents}
|
||||
/>
|
||||
|
||||
{isSaasMode && (
|
||||
<RuntimeSizeSelector
|
||||
isDisabled={!!disabled}
|
||||
defaultValue={settings.REMOTE_RUNTIME_RESOURCE_FACTOR}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SecurityAnalyzerInput
|
||||
isDisabled={!!disabled}
|
||||
defaultValue={settings.SECURITY_ANALYZER}
|
||||
|
||||
@@ -5,9 +5,12 @@ import EventLogger from "#/utils/event-logger";
|
||||
import { handleAssistantMessage } from "#/services/actions";
|
||||
import { useRate } from "#/hooks/use-rate";
|
||||
import { OpenHandsParsedEvent } from "#/types/core";
|
||||
import { AgentStateChangeObservation } from "#/types/core/observations";
|
||||
import {
|
||||
AssistantMessageAction,
|
||||
UserMessageAction,
|
||||
} from "#/types/core/actions";
|
||||
|
||||
const isOpenHandsMessage = (event: unknown): event is OpenHandsParsedEvent =>
|
||||
const isOpenHandsEvent = (event: unknown): event is OpenHandsParsedEvent =>
|
||||
typeof event === "object" &&
|
||||
event !== null &&
|
||||
"id" in event &&
|
||||
@@ -15,10 +18,26 @@ const isOpenHandsMessage = (event: unknown): event is OpenHandsParsedEvent =>
|
||||
"message" in event &&
|
||||
"timestamp" in event;
|
||||
|
||||
const isAgentStateChangeObservation = (
|
||||
const isUserMessage = (
|
||||
event: OpenHandsParsedEvent,
|
||||
): event is AgentStateChangeObservation =>
|
||||
"observation" in event && event.observation === "agent_state_changed";
|
||||
): event is UserMessageAction =>
|
||||
"source" in event &&
|
||||
"type" in event &&
|
||||
event.source === "user" &&
|
||||
event.type === "message";
|
||||
|
||||
const isAssistantMessage = (
|
||||
event: OpenHandsParsedEvent,
|
||||
): event is AssistantMessageAction =>
|
||||
"source" in event &&
|
||||
"type" in event &&
|
||||
event.source === "agent" &&
|
||||
event.type === "message";
|
||||
|
||||
const isMessageAction = (
|
||||
event: OpenHandsParsedEvent,
|
||||
): event is UserMessageAction | AssistantMessageAction =>
|
||||
isUserMessage(event) || isAssistantMessage(event);
|
||||
|
||||
export enum WsClientProviderStatus {
|
||||
CONNECTED,
|
||||
@@ -43,16 +62,13 @@ const WsClientContext = React.createContext<UseWsClient>({
|
||||
|
||||
interface WsClientProviderProps {
|
||||
conversationId: string;
|
||||
ghToken: string | null;
|
||||
}
|
||||
|
||||
export function WsClientProvider({
|
||||
ghToken,
|
||||
conversationId,
|
||||
children,
|
||||
}: React.PropsWithChildren<WsClientProviderProps>) {
|
||||
const sioRef = React.useRef<Socket | null>(null);
|
||||
const ghTokenRef = React.useRef<string | null>(ghToken);
|
||||
const [status, setStatus] = React.useState(
|
||||
WsClientProviderStatus.DISCONNECTED,
|
||||
);
|
||||
@@ -74,7 +90,7 @@ export function WsClientProvider({
|
||||
}
|
||||
|
||||
function handleMessage(event: Record<string, unknown>) {
|
||||
if (isOpenHandsMessage(event) && !isAgentStateChangeObservation(event)) {
|
||||
if (isOpenHandsEvent(event) && isMessageAction(event)) {
|
||||
messageRateHandler.record(new Date().getTime());
|
||||
}
|
||||
setEvents((prevEvents) => [...prevEvents, event]);
|
||||
@@ -100,6 +116,10 @@ export function WsClientProvider({
|
||||
setStatus(WsClientProviderStatus.DISCONNECTED);
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
lastEventRef.current = null;
|
||||
}, [conversationId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!conversationId) {
|
||||
throw new Error("No conversation ID provided");
|
||||
@@ -118,9 +138,6 @@ export function WsClientProvider({
|
||||
|
||||
sio = io(baseUrl, {
|
||||
transports: ["websocket"],
|
||||
auth: {
|
||||
github_token: ghToken || undefined,
|
||||
},
|
||||
query,
|
||||
});
|
||||
sio.on("connect", handleConnect);
|
||||
@@ -130,7 +147,6 @@ export function WsClientProvider({
|
||||
sio.on("disconnect", handleDisconnect);
|
||||
|
||||
sioRef.current = sio;
|
||||
ghTokenRef.current = ghToken;
|
||||
|
||||
return () => {
|
||||
sio.off("connect", handleConnect);
|
||||
@@ -139,7 +155,7 @@ export function WsClientProvider({
|
||||
sio.off("connect_failed", handleError);
|
||||
sio.off("disconnect", handleDisconnect);
|
||||
};
|
||||
}, [ghToken, conversationId]);
|
||||
}, [conversationId]);
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
|
||||
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
|
||||
export const useUserConversation = (cid: string | null) =>
|
||||
useQuery({
|
||||
queryKey: ["user", "conversation", cid],
|
||||
queryFn: () => OpenHands.getConversation(cid!),
|
||||
enabled: MULTI_CONVO_UI_IS_ENABLED && !!cid,
|
||||
enabled: MULTI_CONVERSATION_UI && !!cid,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ export const useAppRepositories = () => {
|
||||
installationIndex || 0,
|
||||
installations,
|
||||
repoPage || 1,
|
||||
1000,
|
||||
30,
|
||||
);
|
||||
},
|
||||
initialPageParam: { installationIndex: 0, repoPage: 1 },
|
||||
|
||||
@@ -18,6 +18,8 @@ const getSettingsQueryFn = async () => {
|
||||
CONFIRMATION_MODE: apiSettings.confirmation_mode,
|
||||
SECURITY_ANALYZER: apiSettings.security_analyzer,
|
||||
LLM_API_KEY: apiSettings.llm_api_key,
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR:
|
||||
apiSettings.remote_runtime_resource_factor,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export const useUserRepositories = () => {
|
||||
const repos = useInfiniteQuery({
|
||||
queryKey: ["repositories", gitHubToken],
|
||||
queryFn: async ({ pageParam }) =>
|
||||
retrieveGitHubUserRepositories(pageParam, 1000),
|
||||
retrieveGitHubUserRepositories(pageParam, 100),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) => lastPage.nextPage,
|
||||
enabled: !!gitHubToken && config?.APP_MODE === "oss",
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import React from "react";
|
||||
import { useSettingsUpToDate } from "#/context/settings-up-to-date-context";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
getCurrentSettingsVersion,
|
||||
DEFAULT_SETTINGS,
|
||||
getLocalStorageSettings,
|
||||
} from "#/services/settings";
|
||||
import { useSaveSettings } from "./mutation/use-save-settings";
|
||||
|
||||
@@ -426,6 +426,20 @@
|
||||
"fr": "Réinitialiser aux valeurs par défaut",
|
||||
"tr": "Varsayılanlara Sıfırla"
|
||||
},
|
||||
"SETTINGS_FORM$RUNTIME_SIZE_LABEL": {
|
||||
"en": "Runtime Settings",
|
||||
"zh-CN": "运行时设置",
|
||||
"de": "Laufzeiteinstellungen",
|
||||
"ko-KR": "런타임 설정",
|
||||
"no": "Kjøretidsinnstillinger",
|
||||
"zh-TW": "運行時設定",
|
||||
"it": "Impostazioni Runtime",
|
||||
"pt": "Configurações de Runtime",
|
||||
"es": "Configuración de Runtime",
|
||||
"ar": "إعدادات وقت التشغيل",
|
||||
"fr": "Paramètres d'exécution",
|
||||
"tr": "Çalışma Zamanı Ayarları"
|
||||
},
|
||||
"CONFIGURATION$SETTINGS_NEED_UPDATE_MESSAGE": {
|
||||
"en": "We've changed some settings in the latest update. Take a minute to review.",
|
||||
"de": "Mit dem letzten Update haben wir ein paar Einstellungen geändert. Bitte kontrollieren Ihre Einstellungen.",
|
||||
|
||||
@@ -17,26 +17,30 @@ const userPreferences = {
|
||||
const conversations: Conversation[] = [
|
||||
{
|
||||
conversation_id: "1",
|
||||
name: "My New Project",
|
||||
repo: null,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
state: "running",
|
||||
title: "My New Project",
|
||||
selected_repository: null,
|
||||
last_updated_at: new Date().toISOString(),
|
||||
status: "RUNNING",
|
||||
},
|
||||
{
|
||||
conversation_id: "2",
|
||||
name: "Repo Testing",
|
||||
repo: "octocat/hello-world",
|
||||
title: "Repo Testing",
|
||||
selected_repository: "octocat/hello-world",
|
||||
// 2 days ago
|
||||
lastUpdated: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
state: "cold",
|
||||
last_updated_at: new Date(
|
||||
Date.now() - 2 * 24 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
status: "STOPPED",
|
||||
},
|
||||
{
|
||||
conversation_id: "3",
|
||||
name: "Another Project",
|
||||
repo: "octocat/earth",
|
||||
title: "Another Project",
|
||||
selected_repository: "octocat/earth",
|
||||
// 5 days ago
|
||||
lastUpdated: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
state: "finished",
|
||||
last_updated_at: new Date(
|
||||
Date.now() - 5 * 24 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
status: "STOPPED",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -182,8 +186,11 @@ export const handlers = [
|
||||
|
||||
http.get("/api/options/config", () => HttpResponse.json({ APP_MODE: "oss" })),
|
||||
|
||||
http.get("/api/conversations", async () =>
|
||||
HttpResponse.json(Array.from(CONVERSATIONS.values())),
|
||||
http.get("/api/conversations?limit=9", async () =>
|
||||
HttpResponse.json({
|
||||
results: Array.from(CONVERSATIONS.values()),
|
||||
next_page_id: null,
|
||||
}),
|
||||
),
|
||||
|
||||
http.delete("/api/conversations/:conversationId", async ({ params }) => {
|
||||
@@ -197,7 +204,7 @@ export const handlers = [
|
||||
return HttpResponse.json(null, { status: 404 });
|
||||
}),
|
||||
|
||||
http.put(
|
||||
http.patch(
|
||||
"/api/conversations/:conversationId",
|
||||
async ({ params, request }) => {
|
||||
const { conversationId } = params;
|
||||
@@ -207,10 +214,10 @@ export const handlers = [
|
||||
|
||||
if (conversation) {
|
||||
const body = await request.json();
|
||||
if (typeof body === "object" && body?.name) {
|
||||
if (typeof body === "object" && body?.title) {
|
||||
CONVERSATIONS.set(conversationId, {
|
||||
...conversation,
|
||||
name: body.name,
|
||||
title: body.title,
|
||||
});
|
||||
return HttpResponse.json(null, { status: 200 });
|
||||
}
|
||||
@@ -224,10 +231,10 @@ export const handlers = [
|
||||
http.post("/api/conversations", () => {
|
||||
const conversation: Conversation = {
|
||||
conversation_id: (Math.random() * 100).toString(),
|
||||
name: "New Conversation",
|
||||
repo: null,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
state: "warm",
|
||||
title: "New Conversation",
|
||||
selected_repository: null,
|
||||
last_updated_at: new Date().toISOString(),
|
||||
status: "RUNNING",
|
||||
};
|
||||
|
||||
CONVERSATIONS.set(conversation.conversation_id, conversation);
|
||||
|
||||
@@ -34,7 +34,7 @@ import { useUserConversation } from "#/hooks/query/get-conversation-permissions"
|
||||
import { CountBadge } from "#/components/layout/count-badge";
|
||||
import { TerminalStatusLabel } from "#/components/features/terminal/terminal-status-label";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
|
||||
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
|
||||
function AppContent() {
|
||||
const { gitHubToken } = useAuth();
|
||||
@@ -73,7 +73,7 @@ function AppContent() {
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (MULTI_CONVO_UI_IS_ENABLED && isFetched && !conversation) {
|
||||
if (MULTI_CONVERSATION_UI && isFetched && !conversation) {
|
||||
toast.error(
|
||||
"This conversation does not exist, or you do not have permission to access it.",
|
||||
);
|
||||
@@ -175,7 +175,7 @@ function AppContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<WsClientProvider ghToken={gitHubToken} conversationId={conversationId}>
|
||||
<WsClientProvider conversationId={conversationId}>
|
||||
<EventHandler>
|
||||
<div data-testid="app-route" className="flex flex-col h-full gap-3">
|
||||
<div className="flex h-full overflow-auto">{renderMain()}</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ export type Settings = {
|
||||
LLM_API_KEY: string | null;
|
||||
CONFIRMATION_MODE: boolean;
|
||||
SECURITY_ANALYZER: string;
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR: number;
|
||||
};
|
||||
|
||||
export type ApiSettings = {
|
||||
@@ -18,6 +19,7 @@ export type ApiSettings = {
|
||||
llm_api_key: string | null;
|
||||
confirmation_mode: boolean;
|
||||
security_analyzer: string;
|
||||
remote_runtime_resource_factor: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
@@ -28,6 +30,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
LLM_API_KEY: null,
|
||||
CONFIRMATION_MODE: false,
|
||||
SECURITY_ANALYZER: "",
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR: 1,
|
||||
};
|
||||
|
||||
export const getCurrentSettingsVersion = () => {
|
||||
@@ -66,6 +69,8 @@ export const getLocalStorageSettings = (): Settings => {
|
||||
LLM_API_KEY: llmApiKey || DEFAULT_SETTINGS.LLM_API_KEY,
|
||||
CONFIRMATION_MODE: confirmationMode || DEFAULT_SETTINGS.CONFIRMATION_MODE,
|
||||
SECURITY_ANALYZER: securityAnalyzer || DEFAULT_SETTINGS.SECURITY_ANALYZER,
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR:
|
||||
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -73,3 +78,8 @@ export const getLocalStorageSettings = (): Settings => {
|
||||
* Get the default settings
|
||||
*/
|
||||
export const getDefaultSettings = (): Settings => DEFAULT_SETTINGS;
|
||||
|
||||
/**
|
||||
* Get the current settings, either from local storage or defaults
|
||||
*/
|
||||
export const getSettings = (): Settings => getLocalStorageSettings();
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const MULTI_CONVO_UI_IS_ENABLED = false;
|
||||
15
frontend/src/utils/feature-flags.ts
Normal file
15
frontend/src/utils/feature-flags.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
function loadFeatureFlag(
|
||||
flagName: string,
|
||||
defaultValue: boolean = false,
|
||||
): boolean {
|
||||
try {
|
||||
const stringValue =
|
||||
localStorage.getItem(`FEATURE_${flagName}`) || defaultValue.toString();
|
||||
const value = !!JSON.parse(stringValue);
|
||||
return value;
|
||||
} catch (e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
export const MULTI_CONVERSATION_UI = loadFeatureFlag("MULTI_CONVERSATION_UI");
|
||||
@@ -58,7 +58,7 @@ class SandboxConfig:
|
||||
runtime_startup_env_vars: dict[str, str] = field(default_factory=dict)
|
||||
browsergym_eval_env: str | None = None
|
||||
platform: str | None = None
|
||||
close_delay: int = 15
|
||||
close_delay: int = 900
|
||||
remote_runtime_resource_factor: int = 1
|
||||
enable_gpu: bool = False
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import asyncio
|
||||
import queue
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from queue import Queue
|
||||
from functools import partial
|
||||
from typing import Callable, Iterable
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -61,12 +62,19 @@ class EventStream:
|
||||
_subscribers: dict[str, dict[str, Callable]]
|
||||
_cur_id: int = 0
|
||||
_lock: threading.Lock
|
||||
_queue: queue.Queue[Event]
|
||||
_queue_thread: threading.Thread
|
||||
_queue_loop: asyncio.AbstractEventLoop | None
|
||||
_thread_loops: dict[str, dict[str, asyncio.AbstractEventLoop]]
|
||||
|
||||
def __init__(self, sid: str, file_store: FileStore, num_workers: int = 1):
|
||||
def __init__(self, sid: str, file_store: FileStore):
|
||||
self.sid = sid
|
||||
self.file_store = file_store
|
||||
self._queue: Queue[Event] = Queue()
|
||||
self._stop_flag = threading.Event()
|
||||
self._queue: queue.Queue[Event] = queue.Queue()
|
||||
self._thread_pools: dict[str, dict[str, ThreadPoolExecutor]] = {}
|
||||
self._thread_loops: dict[str, dict[str, asyncio.AbstractEventLoop]] = {}
|
||||
self._queue_loop = None
|
||||
self._queue_thread = threading.Thread(target=self._run_queue_loop)
|
||||
self._queue_thread.daemon = True
|
||||
self._queue_thread.start()
|
||||
@@ -91,9 +99,54 @@ class EventStream:
|
||||
if id >= self._cur_id:
|
||||
self._cur_id = id + 1
|
||||
|
||||
def _init_thread_loop(self):
|
||||
def _init_thread_loop(self, subscriber_id: str, callback_id: str):
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
if subscriber_id not in self._thread_loops:
|
||||
self._thread_loops[subscriber_id] = {}
|
||||
self._thread_loops[subscriber_id][callback_id] = loop
|
||||
|
||||
def close(self):
|
||||
self._stop_flag.set()
|
||||
if self._queue_thread.is_alive():
|
||||
self._queue_thread.join()
|
||||
|
||||
subscriber_ids = list(self._subscribers.keys())
|
||||
for subscriber_id in subscriber_ids:
|
||||
callback_ids = list(self._subscribers[subscriber_id].keys())
|
||||
for callback_id in callback_ids:
|
||||
self._clean_up_subscriber(subscriber_id, callback_id)
|
||||
|
||||
def _clean_up_subscriber(self, subscriber_id: str, callback_id: str):
|
||||
if subscriber_id not in self._subscribers:
|
||||
logger.warning(f'Subscriber not found during cleanup: {subscriber_id}')
|
||||
return
|
||||
if callback_id not in self._subscribers[subscriber_id]:
|
||||
logger.warning(f'Callback not found during cleanup: {callback_id}')
|
||||
return
|
||||
if (
|
||||
subscriber_id in self._thread_loops
|
||||
and callback_id in self._thread_loops[subscriber_id]
|
||||
):
|
||||
loop = self._thread_loops[subscriber_id][callback_id]
|
||||
try:
|
||||
loop.stop()
|
||||
loop.close()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Error closing loop for {subscriber_id}/{callback_id}: {e}'
|
||||
)
|
||||
del self._thread_loops[subscriber_id][callback_id]
|
||||
|
||||
if (
|
||||
subscriber_id in self._thread_pools
|
||||
and callback_id in self._thread_pools[subscriber_id]
|
||||
):
|
||||
pool = self._thread_pools[subscriber_id][callback_id]
|
||||
pool.shutdown()
|
||||
del self._thread_pools[subscriber_id][callback_id]
|
||||
|
||||
del self._subscribers[subscriber_id][callback_id]
|
||||
|
||||
def _get_filename_for_id(self, id: int) -> str:
|
||||
return get_conversation_event_filename(self.sid, id)
|
||||
@@ -176,7 +229,8 @@ class EventStream:
|
||||
def subscribe(
|
||||
self, subscriber_id: EventStreamSubscriber, callback: Callable, callback_id: str
|
||||
):
|
||||
pool = ThreadPoolExecutor(max_workers=1, initializer=self._init_thread_loop)
|
||||
initializer = partial(self._init_thread_loop, subscriber_id, callback_id)
|
||||
pool = ThreadPoolExecutor(max_workers=1, initializer=initializer)
|
||||
if subscriber_id not in self._subscribers:
|
||||
self._subscribers[subscriber_id] = {}
|
||||
self._thread_pools[subscriber_id] = {}
|
||||
@@ -198,7 +252,7 @@ class EventStream:
|
||||
logger.warning(f'Callback not found during unsubscribe: {callback_id}')
|
||||
return
|
||||
|
||||
del self._subscribers[subscriber_id][callback_id]
|
||||
self._clean_up_subscriber(subscriber_id, callback_id)
|
||||
|
||||
def add_event(self, event: Event, source: EventSource):
|
||||
if hasattr(event, '_id') and event.id is not None:
|
||||
@@ -217,13 +271,20 @@ class EventStream:
|
||||
self._queue.put(event)
|
||||
|
||||
def _run_queue_loop(self):
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(self._process_queue())
|
||||
self._queue_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self._queue_loop)
|
||||
try:
|
||||
self._queue_loop.run_until_complete(self._process_queue())
|
||||
finally:
|
||||
self._queue_loop.close()
|
||||
|
||||
async def _process_queue(self):
|
||||
while should_continue():
|
||||
event = self._queue.get()
|
||||
while should_continue() and not self._stop_flag.is_set():
|
||||
event = None
|
||||
try:
|
||||
event = self._queue.get(timeout=0.1)
|
||||
except queue.Empty:
|
||||
continue
|
||||
for key in sorted(self._subscribers.keys()):
|
||||
callbacks = self._subscribers[key]
|
||||
for callback_id in callbacks:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from enum import Enum
|
||||
|
||||
@@ -23,11 +24,11 @@ def split_bash_commands(commands):
|
||||
return ['']
|
||||
try:
|
||||
parsed = bashlex.parse(commands)
|
||||
except bashlex.errors.ParsingError as e:
|
||||
except (bashlex.errors.ParsingError, NotImplementedError):
|
||||
logger.debug(
|
||||
f'Failed to parse bash commands\n'
|
||||
f'[input]: {commands}\n'
|
||||
f'[warning]: {e}\n'
|
||||
f'[warning]: {traceback.format_exc()}\n'
|
||||
f'The original command will be returned as is.'
|
||||
)
|
||||
# If parsing fails, return the original commands
|
||||
@@ -143,9 +144,13 @@ def escape_bash_special_chars(command: str) -> str:
|
||||
remaining = command[last_pos:]
|
||||
parts.append(remaining)
|
||||
return ''.join(parts)
|
||||
except bashlex.errors.ParsingError:
|
||||
# Fallback if parsing fails
|
||||
logger.warning(f'Failed to parse command: {command}')
|
||||
except (bashlex.errors.ParsingError, NotImplementedError):
|
||||
logger.debug(
|
||||
f'Failed to parse bash commands for special characters escape\n'
|
||||
f'[input]: {command}\n'
|
||||
f'[warning]: {traceback.format_exc()}\n'
|
||||
f'The original command will be returned as is.'
|
||||
)
|
||||
return command
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import jwt
|
||||
from fastapi import Request
|
||||
from jwt.exceptions import InvalidTokenError
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
def get_user_id(request: Request) -> int:
|
||||
return getattr(request.state, 'github_user_id', 0)
|
||||
|
||||
|
||||
def get_sid_from_token(token: str, jwt_secret: str) -> str:
|
||||
"""Retrieves the session id from a JWT token.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from github import Github
|
||||
import jwt
|
||||
from socketio.exceptions import ConnectionRefusedError
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -18,7 +18,6 @@ from openhands.server.routes.settings import ConversationStoreImpl, SettingsStor
|
||||
from openhands.server.session.manager import ConversationDoesNotExistError
|
||||
from openhands.server.shared import config, openhands_config, session_manager, sio
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
|
||||
@sio.event
|
||||
@@ -31,20 +30,20 @@ async def connect(connection_id: str, environ, auth):
|
||||
logger.error('No conversation_id in query params')
|
||||
raise ConnectionRefusedError('No conversation_id in query params')
|
||||
|
||||
github_token = ''
|
||||
user_id = -1
|
||||
if openhands_config.app_mode != AppMode.OSS:
|
||||
user_id = ''
|
||||
if auth and 'github_token' in auth:
|
||||
github_token = auth['github_token']
|
||||
with Github(github_token) as g:
|
||||
gh_user = await call_sync_from_async(g.get_user)
|
||||
user_id = gh_user.id
|
||||
cookies_str = environ.get('HTTP_COOKIE', '')
|
||||
cookies = dict(cookie.split('=', 1) for cookie in cookies_str.split('; '))
|
||||
signed_token = cookies.get('github_auth', '')
|
||||
if not signed_token:
|
||||
logger.error('No github_auth cookie')
|
||||
raise ConnectionRefusedError('No github_auth cookie')
|
||||
decoded = jwt.decode(signed_token, config.jwt_secret, algorithms=['HS256'])
|
||||
user_id = decoded['github_user_id']
|
||||
|
||||
logger.info(f'User {user_id} is connecting to conversation {conversation_id}')
|
||||
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, github_token
|
||||
)
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
|
||||
metadata = await conversation_store.get_metadata(conversation_id)
|
||||
if metadata.github_user_id != user_id:
|
||||
logger.error(
|
||||
@@ -54,7 +53,7 @@ async def connect(connection_id: str, environ, auth):
|
||||
f'User {user_id} is not allowed to join conversation {conversation_id}'
|
||||
)
|
||||
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, github_token)
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, user_id)
|
||||
settings = await settings_store.load()
|
||||
|
||||
if not settings:
|
||||
|
||||
@@ -4,11 +4,11 @@ from typing import Callable
|
||||
|
||||
from fastapi import APIRouter, Body, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from github import Github
|
||||
from pydantic import BaseModel
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.stream import EventStreamSubscriber
|
||||
from openhands.server.auth import get_user_id
|
||||
from openhands.server.routes.settings import ConversationStoreImpl, SettingsStoreImpl
|
||||
from openhands.server.session.conversation_init_data import ConversationInitData
|
||||
from openhands.server.shared import config, session_manager
|
||||
@@ -21,7 +21,6 @@ from openhands.storage.data_models.conversation_status import ConversationStatus
|
||||
from openhands.utils.async_utils import (
|
||||
GENERAL_TIMEOUT,
|
||||
call_async_from_sync,
|
||||
call_sync_from_async,
|
||||
wait_all,
|
||||
)
|
||||
|
||||
@@ -43,10 +42,9 @@ async def new_conversation(request: Request, data: InitSessionRequest):
|
||||
using the returned conversation ID
|
||||
"""
|
||||
logger.info('Initializing new conversation')
|
||||
github_token = data.github_token or ''
|
||||
|
||||
logger.info('Loading settings')
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, github_token)
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, get_user_id(request))
|
||||
settings = await settings_store.load()
|
||||
logger.info('Settings loaded')
|
||||
|
||||
@@ -54,11 +52,14 @@ async def new_conversation(request: Request, data: InitSessionRequest):
|
||||
if settings:
|
||||
session_init_args = {**settings.__dict__, **session_init_args}
|
||||
|
||||
github_token = getattr(request.state, 'github_token', '')
|
||||
session_init_args['github_token'] = github_token
|
||||
session_init_args['selected_repository'] = data.selected_repository
|
||||
conversation_init_data = ConversationInitData(**session_init_args)
|
||||
logger.info('Loading conversation store')
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, github_token)
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, get_user_id(request)
|
||||
)
|
||||
logger.info('Conversation store loaded')
|
||||
|
||||
conversation_id = uuid.uuid4().hex
|
||||
@@ -67,18 +68,11 @@ async def new_conversation(request: Request, data: InitSessionRequest):
|
||||
conversation_id = uuid.uuid4().hex
|
||||
logger.info(f'New conversation ID: {conversation_id}')
|
||||
|
||||
user_id = ''
|
||||
if data.github_token:
|
||||
logger.info('Fetching Github user ID')
|
||||
with Github(data.github_token) as g:
|
||||
gh_user = await call_sync_from_async(g.get_user)
|
||||
user_id = gh_user.id
|
||||
|
||||
logger.info(f'Saving metadata for conversation {conversation_id}')
|
||||
await conversation_store.save_metadata(
|
||||
ConversationMetadata(
|
||||
conversation_id=conversation_id,
|
||||
github_user_id=user_id,
|
||||
github_user_id=get_user_id(request),
|
||||
selected_repository=data.selected_repository,
|
||||
)
|
||||
)
|
||||
@@ -90,9 +84,7 @@ async def new_conversation(request: Request, data: InitSessionRequest):
|
||||
try:
|
||||
event_stream.subscribe(
|
||||
EventStreamSubscriber.SERVER,
|
||||
_create_conversation_update_callback(
|
||||
data.github_token or '', conversation_id
|
||||
),
|
||||
_create_conversation_update_callback(get_user_id(request), conversation_id),
|
||||
UPDATED_AT_CALLBACK_ID,
|
||||
)
|
||||
except ValueError:
|
||||
@@ -107,8 +99,9 @@ async def search_conversations(
|
||||
page_id: str | None = None,
|
||||
limit: int = 20,
|
||||
) -> ConversationInfoResultSet:
|
||||
github_token = getattr(request.state, 'github_token', '') or ''
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, github_token)
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, get_user_id(request)
|
||||
)
|
||||
conversation_metadata_result_set = await conversation_store.search(page_id, limit)
|
||||
conversation_ids = set(
|
||||
conversation.conversation_id
|
||||
@@ -134,8 +127,9 @@ async def search_conversations(
|
||||
async def get_conversation(
|
||||
conversation_id: str, request: Request
|
||||
) -> ConversationInfo | None:
|
||||
github_token = getattr(request.state, 'github_token', '') or ''
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, github_token)
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, get_user_id(request)
|
||||
)
|
||||
try:
|
||||
metadata = await conversation_store.get_metadata(conversation_id)
|
||||
is_running = await session_manager.is_agent_loop_running(conversation_id)
|
||||
@@ -149,8 +143,9 @@ async def get_conversation(
|
||||
async def update_conversation(
|
||||
request: Request, conversation_id: str, title: str = Body(embed=True)
|
||||
) -> bool:
|
||||
github_token = getattr(request.state, 'github_token', '') or ''
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, github_token)
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, get_user_id(request)
|
||||
)
|
||||
metadata = await conversation_store.get_metadata(conversation_id)
|
||||
if not metadata:
|
||||
return False
|
||||
@@ -164,15 +159,16 @@ async def delete_conversation(
|
||||
conversation_id: str,
|
||||
request: Request,
|
||||
) -> bool:
|
||||
github_token = getattr(request.state, 'github_token', '') or ''
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, github_token)
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, get_user_id(request)
|
||||
)
|
||||
try:
|
||||
await conversation_store.get_metadata(conversation_id)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
is_running = await session_manager.is_agent_loop_running(conversation_id)
|
||||
if is_running:
|
||||
return False
|
||||
await session_manager.close_session(conversation_id)
|
||||
await conversation_store.delete_metadata(conversation_id)
|
||||
return True
|
||||
|
||||
@@ -189,6 +185,7 @@ async def _get_conversation_info(
|
||||
conversation_id=conversation.conversation_id,
|
||||
title=title,
|
||||
last_updated_at=conversation.last_updated_at,
|
||||
created_at=conversation.created_at,
|
||||
selected_repository=conversation.selected_repository,
|
||||
status=ConversationStatus.RUNNING
|
||||
if is_running
|
||||
@@ -204,21 +201,21 @@ async def _get_conversation_info(
|
||||
|
||||
|
||||
def _create_conversation_update_callback(
|
||||
github_token: str, conversation_id: str
|
||||
user_id: int, conversation_id: str
|
||||
) -> Callable:
|
||||
def callback(*args, **kwargs):
|
||||
call_async_from_sync(
|
||||
_update_timestamp_for_conversation,
|
||||
GENERAL_TIMEOUT,
|
||||
github_token,
|
||||
user_id,
|
||||
conversation_id,
|
||||
)
|
||||
|
||||
return callback
|
||||
|
||||
|
||||
async def _update_timestamp_for_conversation(github_token: str, conversation_id: str):
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, github_token)
|
||||
async def _update_timestamp_for_conversation(user_id: int, conversation_id: str):
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
|
||||
conversation = await conversation_store.get_metadata(conversation_id)
|
||||
conversation.last_updated_at = datetime.now()
|
||||
await conversation_store.save_metadata(conversation)
|
||||
|
||||
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.auth import get_user_id
|
||||
from openhands.server.settings import Settings
|
||||
from openhands.server.shared import config, openhands_config
|
||||
from openhands.storage.conversation.conversation_store import ConversationStore
|
||||
@@ -19,9 +20,10 @@ ConversationStoreImpl = get_impl(
|
||||
|
||||
@app.get('/settings')
|
||||
async def load_settings(request: Request) -> Settings | None:
|
||||
github_token = getattr(request.state, 'github_token', '') or ''
|
||||
try:
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, github_token)
|
||||
settings_store = await SettingsStoreImpl.get_instance(
|
||||
config, get_user_id(request)
|
||||
)
|
||||
settings = await settings_store.load()
|
||||
if not settings:
|
||||
return JSONResponse(
|
||||
@@ -45,17 +47,23 @@ async def store_settings(
|
||||
request: Request,
|
||||
settings: Settings,
|
||||
) -> JSONResponse:
|
||||
github_token = ''
|
||||
if hasattr(request.state, 'github_token'):
|
||||
github_token = request.state.github_token
|
||||
try:
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, github_token)
|
||||
settings_store = await SettingsStoreImpl.get_instance(
|
||||
config, get_user_id(request)
|
||||
)
|
||||
existing_settings = await settings_store.load()
|
||||
|
||||
if existing_settings:
|
||||
# LLM key isn't on the frontend, so we need to keep it if unset
|
||||
if settings.llm_api_key is None:
|
||||
settings.llm_api_key = existing_settings.llm_api_key
|
||||
|
||||
# Update sandbox config with new settings
|
||||
if settings.remote_runtime_resource_factor is not None:
|
||||
config.sandbox.remote_runtime_resource_factor = (
|
||||
settings.remote_runtime_resource_factor
|
||||
)
|
||||
|
||||
await settings_store.store(settings)
|
||||
|
||||
return JSONResponse(
|
||||
|
||||
@@ -131,6 +131,8 @@ class AgentSession:
|
||||
f'Waited too long for initialization to finish before closing session {self.sid}'
|
||||
)
|
||||
break
|
||||
if self.event_stream is not None:
|
||||
self.event_stream.close()
|
||||
if self.controller is not None:
|
||||
end_state = self.controller.get_state()
|
||||
end_state.save_to_session(self.sid, self.file_store)
|
||||
|
||||
@@ -43,4 +43,6 @@ class Conversation:
|
||||
await self.runtime.connect()
|
||||
|
||||
async def disconnect(self):
|
||||
if self.event_stream:
|
||||
self.event_stream.close()
|
||||
asyncio.create_task(call_sync_from_async(self.runtime.close))
|
||||
|
||||
@@ -156,6 +156,10 @@ class SessionManager:
|
||||
flag = self._has_remote_connections_flags.get(sid)
|
||||
if flag:
|
||||
flag.set()
|
||||
elif message_type == 'close_session':
|
||||
sid = data['sid']
|
||||
if sid in self._local_agent_loops_by_sid:
|
||||
await self._on_close_session(sid)
|
||||
elif message_type == 'session_closing':
|
||||
# Session closing event - We only get this in the event of graceful shutdown,
|
||||
# which can't be guaranteed - nodes can simply vanish unexpectedly!
|
||||
@@ -197,6 +201,7 @@ class SessionManager:
|
||||
await c.connect()
|
||||
except AgentRuntimeUnavailableError as e:
|
||||
logger.error(f'Error connecting to conversation {c.sid}: {e}')
|
||||
await c.disconnect()
|
||||
return None
|
||||
end_time = time.time()
|
||||
logger.info(
|
||||
@@ -419,7 +424,7 @@ class SessionManager:
|
||||
if should_continue():
|
||||
asyncio.create_task(self._cleanup_session_later(sid))
|
||||
else:
|
||||
await self._close_session(sid)
|
||||
await self._on_close_session(sid)
|
||||
|
||||
async def _cleanup_session_later(self, sid: str):
|
||||
# Once there have been no connections to a session for a reasonable period, we close it
|
||||
@@ -451,10 +456,22 @@ class SessionManager:
|
||||
json.dumps({'sid': sid, 'message_type': 'session_closing'}),
|
||||
)
|
||||
|
||||
await self._close_session(sid)
|
||||
await self._on_close_session(sid)
|
||||
return True
|
||||
|
||||
async def _close_session(self, sid: str):
|
||||
async def close_session(self, sid: str):
|
||||
session = self._local_agent_loops_by_sid.get(sid)
|
||||
if session:
|
||||
await self._on_close_session(sid)
|
||||
|
||||
redis_client = self._get_redis_client()
|
||||
if redis_client:
|
||||
await redis_client.publish(
|
||||
'oh_event',
|
||||
json.dumps({'sid': sid, 'message_type': 'close_session'}),
|
||||
)
|
||||
|
||||
async def _on_close_session(self, sid: str):
|
||||
logger.info(f'_close_session:{sid}')
|
||||
|
||||
# Clear up local variables
|
||||
|
||||
@@ -15,3 +15,4 @@ class Settings:
|
||||
llm_model: str | None = None
|
||||
llm_api_key: str | None = None
|
||||
llm_base_url: str | None = None
|
||||
remote_runtime_resource_factor: int | None = None
|
||||
|
||||
@@ -40,7 +40,5 @@ class ConversationStore(ABC):
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
async def get_instance(
|
||||
cls, config: AppConfig, token: str | None
|
||||
) -> ConversationStore:
|
||||
async def get_instance(cls, config: AppConfig, user_id: int) -> ConversationStore:
|
||||
"""Get a store for the user represented by the token given"""
|
||||
|
||||
@@ -90,13 +90,15 @@ class FileConversationStore(ConversationStore):
|
||||
return get_conversation_metadata_filename(conversation_id)
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls, config: AppConfig, token: str | None):
|
||||
async def get_instance(
|
||||
cls, config: AppConfig, user_id: int
|
||||
) -> FileConversationStore:
|
||||
file_store = get_file_store(config.file_store, config.file_store_path)
|
||||
return FileConversationStore(file_store)
|
||||
|
||||
|
||||
def _sort_key(conversation: ConversationMetadata) -> str:
|
||||
last_updated_at = conversation.last_updated_at
|
||||
if last_updated_at:
|
||||
return last_updated_at.isoformat() # YYYY-MM-DDTHH:MM:SS for sorting
|
||||
created_at = conversation.created_at
|
||||
if created_at:
|
||||
return created_at.isoformat() # YYYY-MM-DDTHH:MM:SS for sorting
|
||||
return ''
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from openhands.storage.data_models.conversation_status import ConversationStatus
|
||||
@@ -13,3 +13,4 @@ class ConversationInfo:
|
||||
last_updated_at: datetime | None = None
|
||||
status: ConversationStatus = ConversationStatus.STOPPED
|
||||
selected_repository: str | None = None
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversationMetadata:
|
||||
conversation_id: str
|
||||
github_user_id: int | str
|
||||
github_user_id: int
|
||||
selected_repository: str | None
|
||||
title: str | None = None
|
||||
last_updated_at: datetime | None = None
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
@@ -30,6 +30,6 @@ class FileSettingsStore(SettingsStore):
|
||||
await call_sync_from_async(self.file_store.write, self.path, json_str)
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls, config: AppConfig, token: str | None):
|
||||
async def get_instance(cls, config: AppConfig, user_id: int) -> FileSettingsStore:
|
||||
file_store = get_file_store(config.file_store, config.file_store_path)
|
||||
return FileSettingsStore(file_store)
|
||||
|
||||
@@ -21,5 +21,5 @@ class SettingsStore(ABC):
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
async def get_instance(cls, config: AppConfig, token: str | None) -> SettingsStore:
|
||||
async def get_instance(cls, config: AppConfig, user_id: int) -> SettingsStore:
|
||||
"""Get a store for the user represented by the token given"""
|
||||
|
||||
20
poetry.lock
generated
20
poetry.lock
generated
@@ -5422,6 +5422,24 @@ typing-extensions = ">=4.11,<5"
|
||||
datalib = ["numpy (>=1)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)"]
|
||||
realtime = ["websockets (>=13,<15)"]
|
||||
|
||||
[[package]]
|
||||
name = "opencv-python"
|
||||
version = "4.10.0.84"
|
||||
description = "Wrapper package for OpenCV python bindings."
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
{file = "opencv-python-4.10.0.84.tar.gz", hash = "sha256:72d234e4582e9658ffea8e9cae5b63d488ad06994ef12d81dc303b17472f3526"},
|
||||
{file = "opencv_python-4.10.0.84-cp37-abi3-macosx_12_0_x86_64.whl", hash = "sha256:71e575744f1d23f79741450254660442785f45a0797212852ee5199ef12eed98"},
|
||||
{file = "opencv_python-4.10.0.84-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09a332b50488e2dda866a6c5573ee192fe3583239fb26ff2f7f9ceb0bc119ea6"},
|
||||
{file = "opencv_python-4.10.0.84-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ace140fc6d647fbe1c692bcb2abce768973491222c067c131d80957c595b71f"},
|
||||
{file = "opencv_python-4.10.0.84-cp37-abi3-win32.whl", hash = "sha256:2db02bb7e50b703f0a2d50c50ced72e95c574e1e5a0bb35a8a86d0b35c98c236"},
|
||||
{file = "opencv_python-4.10.0.84-cp37-abi3-win_amd64.whl", hash = "sha256:32dbbd94c26f611dc5cc6979e6b7aa1f55a64d6b463cc1dcd3c95505a63e48fe"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
numpy = {version = ">=1.26.0", markers = "python_version >= \"3.12\""}
|
||||
|
||||
[[package]]
|
||||
name = "openhands-aci"
|
||||
version = "0.1.6"
|
||||
@@ -10065,4 +10083,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"]
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "3c4cae19fcbd9183bde1bd88cea55454921281e26447d9a2c64404a5defffb3e"
|
||||
content-hash = "6f8fd9ffcc411aed1c8f50aff98e36bf06932c27b82485e4f9fd05bbe7b195c4"
|
||||
|
||||
@@ -95,6 +95,7 @@ pytest-forked = "*"
|
||||
pytest-xdist = "*"
|
||||
flake8 = "*"
|
||||
openai = "*"
|
||||
opencv-python = "*"
|
||||
pandas = "*"
|
||||
reportlab = "*"
|
||||
|
||||
@@ -107,6 +108,7 @@ jupyterlab = "*"
|
||||
notebook = "*"
|
||||
jupyter_kernel_gateway = "*"
|
||||
flake8 = "*"
|
||||
opencv-python = "*"
|
||||
|
||||
[build-system]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
@@ -153,6 +153,20 @@ def test_multiple_multiline_commands(temp_dir, runtime_cls, run_as_openhands):
|
||||
_close_test_runtime(runtime)
|
||||
|
||||
|
||||
def test_complex_commands(temp_dir, runtime_cls):
|
||||
cmd = """count=0; tries=0; while [ $count -lt 3 ]; do result=$(echo "Heads"); tries=$((tries+1)); echo "Flip $tries: $result"; if [ "$result" = "Heads" ]; then count=$((count+1)); else count=0; fi; done; echo "Got 3 heads in a row after $tries flips!";"""
|
||||
|
||||
runtime = _load_runtime(temp_dir, runtime_cls)
|
||||
try:
|
||||
obs = _run_cmd_action(runtime, cmd)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert obs.exit_code == 0, 'The exit code should be 0.'
|
||||
assert 'Got 3 heads in a row after 3 flips!' in obs.content
|
||||
|
||||
finally:
|
||||
_close_test_runtime(runtime)
|
||||
|
||||
|
||||
def test_no_ps2_in_output(temp_dir, runtime_cls, run_as_openhands):
|
||||
"""Test that the PS2 sign is not added to the output of a multiline command."""
|
||||
runtime = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
|
||||
|
||||
35
tests/runtime/test_stress_docker_runtime.py
Normal file
35
tests/runtime/test_stress_docker_runtime.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Stress tests for the DockerRuntime, which connects to the ActionExecutor running in the sandbox."""
|
||||
|
||||
import pytest
|
||||
from conftest import TEST_IN_CI, _close_test_runtime, _load_runtime
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action import CmdRunAction
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
TEST_IN_CI,
|
||||
reason='This test should only be run locally, not in CI.',
|
||||
)
|
||||
def test_stress_docker_runtime(temp_dir, runtime_cls, repeat=1):
|
||||
runtime = _load_runtime(temp_dir, runtime_cls)
|
||||
|
||||
action = CmdRunAction(
|
||||
command='sudo apt-get update && sudo apt-get install -y stress-ng'
|
||||
)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert obs.exit_code == 0
|
||||
|
||||
for _ in range(repeat):
|
||||
# run stress-ng stress tests for 5 minutes
|
||||
# FIXME: this would make Docker daemon die, even though running this
|
||||
# command on its own in the same container is fine
|
||||
action = CmdRunAction(command='stress-ng --all 1 -t 5m')
|
||||
action.timeout = 600
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
|
||||
_close_test_runtime(runtime)
|
||||
@@ -28,8 +28,9 @@ def _patch_store():
|
||||
'title': 'Some Conversation',
|
||||
'selected_repository': 'foobar',
|
||||
'conversation_id': 'some_conversation_id',
|
||||
'github_user_id': 'github_user',
|
||||
'last_updated_at': '2025-01-01T00:00:00',
|
||||
'github_user_id': 12345,
|
||||
'created_at': '2025-01-01T00:00:00',
|
||||
'last_updated_at': '2025-01-01T00:01:00',
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -55,7 +56,8 @@ async def test_search_conversations():
|
||||
ConversationInfo(
|
||||
conversation_id='some_conversation_id',
|
||||
title='Some Conversation',
|
||||
last_updated_at=datetime.fromisoformat('2025-01-01T00:00:00'),
|
||||
created_at=datetime.fromisoformat('2025-01-01T00:00:00'),
|
||||
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00'),
|
||||
status=ConversationStatus.STOPPED,
|
||||
selected_repository='foobar',
|
||||
)
|
||||
@@ -73,7 +75,8 @@ async def test_get_conversation():
|
||||
expected = ConversationInfo(
|
||||
conversation_id='some_conversation_id',
|
||||
title='Some Conversation',
|
||||
last_updated_at=datetime.fromisoformat('2025-01-01T00:00:00'),
|
||||
created_at=datetime.fromisoformat('2025-01-01T00:00:00'),
|
||||
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00'),
|
||||
status=ConversationStatus.STOPPED,
|
||||
selected_repository='foobar',
|
||||
)
|
||||
@@ -105,7 +108,8 @@ async def test_update_conversation():
|
||||
expected = ConversationInfo(
|
||||
conversation_id='some_conversation_id',
|
||||
title='New Title',
|
||||
last_updated_at=datetime.fromisoformat('2025-01-01T00:00:00'),
|
||||
created_at=datetime.fromisoformat('2025-01-01T00:00:00'),
|
||||
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00'),
|
||||
status=ConversationStatus.STOPPED,
|
||||
selected_repository='foobar',
|
||||
)
|
||||
|
||||
@@ -286,7 +286,7 @@ async def test_cleanup_session_connections():
|
||||
}
|
||||
)
|
||||
|
||||
await session_manager._close_session('session1')
|
||||
await session_manager._on_close_session('session1')
|
||||
|
||||
remaining_connections = session_manager.local_connection_id_to_session_id
|
||||
assert 'conn1' not in remaining_connections
|
||||
|
||||
85
tests/unit/test_settings_api.py
Normal file
85
tests/unit/test_settings_api.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from openhands.core.config.sandbox_config import SandboxConfig
|
||||
from openhands.server.app import app
|
||||
from openhands.server.settings import Settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_client():
|
||||
# Mock the middleware that adds github_token
|
||||
class MockMiddleware:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope['type'] == 'http':
|
||||
scope['state'] = {'github_token': 'test-token'}
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
# Replace the middleware
|
||||
app.middleware_stack = None # Clear existing middleware
|
||||
app.add_middleware(MockMiddleware)
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings_store():
|
||||
with patch('openhands.server.routes.settings.SettingsStoreImpl') as mock:
|
||||
store_instance = MagicMock()
|
||||
mock.get_instance = AsyncMock(return_value=store_instance)
|
||||
store_instance.load = AsyncMock()
|
||||
store_instance.store = AsyncMock()
|
||||
yield store_instance
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_api_runtime_factor(test_client, mock_settings_store):
|
||||
# Mock the settings store to return None initially (no existing settings)
|
||||
mock_settings_store.load.return_value = None
|
||||
|
||||
# Test data with remote_runtime_resource_factor
|
||||
settings_data = {
|
||||
'language': 'en',
|
||||
'agent': 'test-agent',
|
||||
'max_iterations': 100,
|
||||
'security_analyzer': 'default',
|
||||
'confirmation_mode': True,
|
||||
'llm_model': 'test-model',
|
||||
'llm_api_key': None,
|
||||
'llm_base_url': 'https://test.com',
|
||||
'remote_runtime_resource_factor': 2,
|
||||
}
|
||||
|
||||
# The test_client fixture already handles authentication
|
||||
|
||||
# Make the POST request to store settings
|
||||
response = test_client.post('/api/settings', json=settings_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the settings were stored with the correct runtime factor
|
||||
stored_settings = mock_settings_store.store.call_args[0][0]
|
||||
assert stored_settings.remote_runtime_resource_factor == 2
|
||||
|
||||
# Mock settings store to return our settings for the GET request
|
||||
mock_settings_store.load.return_value = Settings(**settings_data)
|
||||
|
||||
# Make a GET request to retrieve settings
|
||||
response = test_client.get('/api/settings')
|
||||
assert response.status_code == 200
|
||||
assert response.json()['remote_runtime_resource_factor'] == 2
|
||||
|
||||
# Verify that the sandbox config gets updated when settings are loaded
|
||||
with patch('openhands.server.shared.config') as mock_config:
|
||||
mock_config.sandbox = SandboxConfig()
|
||||
response = test_client.get('/api/settings')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify that the sandbox config was updated with the new value
|
||||
mock_settings_store.store.assert_called()
|
||||
stored_settings = mock_settings_store.store.call_args[0][0]
|
||||
assert stored_settings.remote_runtime_resource_factor == 2
|
||||
Reference in New Issue
Block a user