mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5ea2ac478 | |||
| 3239eb4027 | |||
| 9be673d553 | |||
| 7272eae758 | |||
| ec670cd130 | |||
| 31702bf46b | |||
| 5894d2675e | |||
| 59a992c0fb | |||
| 1939bd0fda | |||
| 58e690ef75 |
@@ -66,10 +66,10 @@ See the [uv installation guide](https://docs.astral.sh/uv/getting-started/instal
|
||||
**Launch OpenHands**:
|
||||
```bash
|
||||
# Launch the GUI server
|
||||
uvx --python 3.12 --from openhands-ai openhands serve
|
||||
uvx --python 3.12 openhands serve
|
||||
|
||||
# Or launch the CLI
|
||||
uvx --python 3.12 --from openhands-ai openhands
|
||||
uvx --python 3.12 openhands
|
||||
```
|
||||
|
||||
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000) (for GUI mode)!
|
||||
|
||||
Generated
+9
-9
@@ -5759,8 +5759,8 @@ wsproto = ">=1.2.0"
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/OpenHands/agent-sdk.git"
|
||||
reference = "ce0a71af55dfce101f7419fbdb0116178f01e109"
|
||||
resolved_reference = "ce0a71af55dfce101f7419fbdb0116178f01e109"
|
||||
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
subdirectory = "openhands-agent-server"
|
||||
|
||||
[[package]]
|
||||
@@ -5805,9 +5805,9 @@ memory-profiler = "^0.61.0"
|
||||
numpy = "*"
|
||||
openai = "1.99.9"
|
||||
openhands-aci = "0.3.2"
|
||||
openhands-agent-server = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "ce0a71af55dfce101f7419fbdb0116178f01e109", subdirectory = "openhands-agent-server"}
|
||||
openhands-sdk = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "ce0a71af55dfce101f7419fbdb0116178f01e109", subdirectory = "openhands-sdk"}
|
||||
openhands-tools = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "ce0a71af55dfce101f7419fbdb0116178f01e109", subdirectory = "openhands-tools"}
|
||||
openhands-agent-server = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "3d8af53b2f0259dc98555a4acd4238f90e0afbce", subdirectory = "openhands-agent-server"}
|
||||
openhands-sdk = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "3d8af53b2f0259dc98555a4acd4238f90e0afbce", subdirectory = "openhands-sdk"}
|
||||
openhands-tools = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "3d8af53b2f0259dc98555a4acd4238f90e0afbce", subdirectory = "openhands-tools"}
|
||||
opentelemetry-api = "^1.33.1"
|
||||
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
|
||||
pathspec = "^0.12.1"
|
||||
@@ -5887,8 +5887,8 @@ boto3 = ["boto3 (>=1.35.0)"]
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/OpenHands/agent-sdk.git"
|
||||
reference = "ce0a71af55dfce101f7419fbdb0116178f01e109"
|
||||
resolved_reference = "ce0a71af55dfce101f7419fbdb0116178f01e109"
|
||||
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
subdirectory = "openhands-sdk"
|
||||
|
||||
[[package]]
|
||||
@@ -5914,8 +5914,8 @@ pydantic = ">=2.11.7"
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/OpenHands/agent-sdk.git"
|
||||
reference = "ce0a71af55dfce101f7419fbdb0116178f01e109"
|
||||
resolved_reference = "ce0a71af55dfce101f7419fbdb0116178f01e109"
|
||||
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
subdirectory = "openhands-tools"
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -188,172 +188,4 @@ describe("PaymentForm", () => {
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cancel Subscription", () => {
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
const cancelSubscriptionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"cancelSubscription",
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock active subscription
|
||||
getSubscriptionAccessSpy.mockResolvedValue({
|
||||
start_at: "2024-01-01T00:00:00Z",
|
||||
end_at: "2024-12-31T23:59:59Z",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("should render cancel subscription button when user has active subscription", async () => {
|
||||
renderPaymentForm();
|
||||
|
||||
await waitFor(() => {
|
||||
const cancelButton = screen.getByTestId("cancel-subscription-button");
|
||||
expect(cancelButton).toBeInTheDocument();
|
||||
expect(cancelButton).toHaveTextContent("PAYMENT$CANCEL_SUBSCRIPTION");
|
||||
});
|
||||
});
|
||||
|
||||
it("should not render cancel subscription button when user has no subscription", async () => {
|
||||
getSubscriptionAccessSpy.mockResolvedValue(null);
|
||||
renderPaymentForm();
|
||||
|
||||
await waitFor(() => {
|
||||
const cancelButton = screen.queryByTestId("cancel-subscription-button");
|
||||
expect(cancelButton).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show confirmation modal when cancel subscription button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const cancelButton = await screen.findByTestId(
|
||||
"cancel-subscription-button",
|
||||
);
|
||||
await user.click(cancelButton);
|
||||
|
||||
// Should show confirmation modal
|
||||
expect(
|
||||
screen.getByTestId("cancel-subscription-modal"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("PAYMENT$CANCEL_SUBSCRIPTION_TITLE"),
|
||||
).toBeInTheDocument();
|
||||
// The message should be rendered (either with Trans component or regular text)
|
||||
const modalContent = screen.getByTestId("cancel-subscription-modal");
|
||||
expect(modalContent).toBeInTheDocument();
|
||||
expect(screen.getByTestId("confirm-cancel-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("modal-cancel-button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should close modal when cancel button in modal is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const cancelButton = await screen.findByTestId(
|
||||
"cancel-subscription-button",
|
||||
);
|
||||
await user.click(cancelButton);
|
||||
|
||||
// Modal should be visible
|
||||
expect(
|
||||
screen.getByTestId("cancel-subscription-modal"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Click cancel in modal
|
||||
const modalCancelButton = screen.getByTestId("modal-cancel-button");
|
||||
await user.click(modalCancelButton);
|
||||
|
||||
// Modal should be closed
|
||||
expect(
|
||||
screen.queryByTestId("cancel-subscription-modal"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call cancel subscription API when confirm button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const cancelButton = await screen.findByTestId(
|
||||
"cancel-subscription-button",
|
||||
);
|
||||
await user.click(cancelButton);
|
||||
|
||||
// Click confirm in modal
|
||||
const confirmButton = screen.getByTestId("confirm-cancel-button");
|
||||
await user.click(confirmButton);
|
||||
|
||||
// Should call the cancel subscription API
|
||||
expect(cancelSubscriptionSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should close modal after successful cancellation", async () => {
|
||||
const user = userEvent.setup();
|
||||
cancelSubscriptionSpy.mockResolvedValue({
|
||||
status: "success",
|
||||
message: "Subscription cancelled successfully",
|
||||
});
|
||||
renderPaymentForm();
|
||||
|
||||
const cancelButton = await screen.findByTestId(
|
||||
"cancel-subscription-button",
|
||||
);
|
||||
await user.click(cancelButton);
|
||||
|
||||
const confirmButton = screen.getByTestId("confirm-cancel-button");
|
||||
await user.click(confirmButton);
|
||||
|
||||
// Wait for API call to complete and modal to close
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("cancel-subscription-modal"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show next billing date for active subscription", async () => {
|
||||
// Mock active subscription with end_at as next billing date
|
||||
getSubscriptionAccessSpy.mockResolvedValue({
|
||||
start_at: "2024-01-01T00:00:00Z",
|
||||
end_at: "2025-01-01T00:00:00Z",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
cancelled_at: null,
|
||||
stripe_subscription_id: "sub_123",
|
||||
});
|
||||
|
||||
renderPaymentForm();
|
||||
|
||||
await waitFor(() => {
|
||||
const nextBillingInfo = screen.getByTestId("next-billing-date");
|
||||
expect(nextBillingInfo).toBeInTheDocument();
|
||||
// Check that it contains some date-related content (translation key or actual date)
|
||||
expect(nextBillingInfo).toHaveTextContent(
|
||||
/2025|PAYMENT.*BILLING.*DATE/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not show next billing date when subscription is cancelled", async () => {
|
||||
// Mock cancelled subscription
|
||||
getSubscriptionAccessSpy.mockResolvedValue({
|
||||
start_at: "2024-01-01T00:00:00Z",
|
||||
end_at: "2025-01-01T00:00:00Z",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
cancelled_at: "2024-06-15T10:30:00Z",
|
||||
stripe_subscription_id: "sub_123",
|
||||
});
|
||||
|
||||
renderPaymentForm();
|
||||
|
||||
await waitFor(() => {
|
||||
const nextBillingInfo = screen.queryByTestId("next-billing-date");
|
||||
expect(nextBillingInfo).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,14 +4,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import LlmSettingsScreen from "#/routes/llm-settings";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import {
|
||||
MOCK_DEFAULT_USER_SETTINGS,
|
||||
resetTestHandlersMockSettings,
|
||||
} from "#/mocks/handlers";
|
||||
import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
import BillingService from "#/api/billing-service/billing-service.api";
|
||||
|
||||
// Mock react-router hooks
|
||||
const mockUseSearchParams = vi.fn();
|
||||
@@ -25,12 +23,6 @@ vi.mock("#/hooks/query/use-is-authed", () => ({
|
||||
useIsAuthed: () => mockUseIsAuthed(),
|
||||
}));
|
||||
|
||||
// Mock useIsAllHandsSaaSEnvironment hook
|
||||
const mockUseIsAllHandsSaaSEnvironment = vi.fn();
|
||||
vi.mock("#/hooks/use-is-all-hands-saas-environment", () => ({
|
||||
useIsAllHandsSaaSEnvironment: () => mockUseIsAllHandsSaaSEnvironment(),
|
||||
}));
|
||||
|
||||
const renderLlmSettingsScreen = () =>
|
||||
render(<LlmSettingsScreen />, {
|
||||
wrapper: ({ children }) => (
|
||||
@@ -54,9 +46,6 @@ beforeEach(() => {
|
||||
|
||||
// Default mock for useIsAuthed - returns authenticated by default
|
||||
mockUseIsAuthed.mockReturnValue({ data: true, isLoading: false });
|
||||
|
||||
// Default mock for useIsAllHandsSaaSEnvironment - returns true for SaaS environment
|
||||
mockUseIsAllHandsSaaSEnvironment.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe("Content", () => {
|
||||
@@ -605,9 +594,14 @@ describe("Form submission", () => {
|
||||
renderLlmSettingsScreen();
|
||||
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
// Component automatically shows advanced view when advanced settings exist
|
||||
// Switch to basic view to test clearing advanced settings
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
await userEvent.click(advancedSwitch);
|
||||
|
||||
// Now we should be in basic view
|
||||
await screen.findByTestId("llm-settings-form-basic");
|
||||
|
||||
const provider = screen.getByTestId("llm-provider-input");
|
||||
const model = screen.getByTestId("llm-model-input");
|
||||
|
||||
@@ -731,405 +725,3 @@ describe("Status toasts", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("SaaS mode", () => {
|
||||
describe("SaaS subscription", () => {
|
||||
// Common mock configurations
|
||||
const MOCK_SAAS_CONFIG = {
|
||||
APP_MODE: "saas" as const,
|
||||
GITHUB_CLIENT_ID: "fake-github-client-id",
|
||||
POSTHOG_CLIENT_KEY: "fake-posthog-client-key",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: true,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
},
|
||||
};
|
||||
|
||||
const MOCK_ACTIVE_SUBSCRIPTION = {
|
||||
start_at: "2024-01-01",
|
||||
end_at: "2024-12-31",
|
||||
created_at: "2024-01-01",
|
||||
};
|
||||
|
||||
it("should show upgrade banner and prevent all interactions for unsubscribed SaaS users", async () => {
|
||||
// Mock SaaS mode without subscription
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return null (no subscription)
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(null);
|
||||
|
||||
// Mock saveSettings to ensure it's not called
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Should show upgrade banner
|
||||
expect(screen.getByTestId("upgrade-banner")).toBeInTheDocument();
|
||||
|
||||
// Should have a clickable upgrade button
|
||||
const upgradeButton = screen.getByRole("button", { name: /upgrade/i });
|
||||
expect(upgradeButton).toBeInTheDocument();
|
||||
expect(upgradeButton).not.toBeDisabled();
|
||||
|
||||
// Form should be disabled
|
||||
const form = screen.getByTestId("llm-settings-form-basic");
|
||||
expect(form).toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
// All form inputs should be disabled or non-interactive
|
||||
const providerInput = screen.getByTestId("llm-provider-input");
|
||||
const modelInput = screen.getByTestId("llm-model-input");
|
||||
const apiKeyInput = screen.getByTestId("llm-api-key-input");
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
|
||||
// Inputs should be disabled
|
||||
expect(providerInput).toBeDisabled();
|
||||
expect(modelInput).toBeDisabled();
|
||||
expect(apiKeyInput).toBeDisabled();
|
||||
expect(advancedSwitch).toBeDisabled();
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// Confirmation mode switch is in advanced view, so it's not visible in basic view
|
||||
expect(
|
||||
screen.queryByTestId("enable-confirmation-mode-switch"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Try to interact with inputs - they should not respond
|
||||
await userEvent.click(providerInput);
|
||||
await userEvent.type(apiKeyInput, "test-key");
|
||||
|
||||
// Values should not change
|
||||
expect(apiKeyInput).toHaveValue("");
|
||||
|
||||
// Try to submit form - should not call API
|
||||
await userEvent.click(submitButton);
|
||||
expect(saveSettingsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call subscription checkout API when upgrade button is clicked", async () => {
|
||||
// Mock SaaS mode without subscription
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return null (no subscription)
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(null);
|
||||
|
||||
// Mock the subscription checkout API call
|
||||
const createSubscriptionCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createSubscriptionCheckoutSession",
|
||||
);
|
||||
createSubscriptionCheckoutSessionSpy.mockResolvedValue({});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Click the upgrade button
|
||||
const upgradeButton = screen.getByRole("button", { name: /upgrade/i });
|
||||
await userEvent.click(upgradeButton);
|
||||
|
||||
// Should call the subscription checkout API
|
||||
expect(createSubscriptionCheckoutSessionSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should disable upgrade button for unauthenticated users in SaaS mode", async () => {
|
||||
// Mock SaaS mode without subscription
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return null (no subscription)
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(null);
|
||||
|
||||
// Mock subscription checkout API
|
||||
const createSubscriptionCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createSubscriptionCheckoutSession",
|
||||
);
|
||||
|
||||
// Mock authentication to return false (unauthenticated) from the start
|
||||
mockUseIsAuthed.mockReturnValue({ data: false, isLoading: false });
|
||||
|
||||
// Mock settings to return default settings even when unauthenticated
|
||||
// This is necessary because the useSettings hook is disabled when user is not authenticated
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
|
||||
// Wait for either the settings screen or skeleton to appear
|
||||
await waitFor(() => {
|
||||
const settingsScreen = screen.queryByTestId("llm-settings-screen");
|
||||
const skeleton = screen.queryByTestId("app-settings-skeleton");
|
||||
expect(settingsScreen || skeleton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// If we get the skeleton, the test scenario isn't valid - skip the rest
|
||||
if (screen.queryByTestId("app-settings-skeleton")) {
|
||||
// For unauthenticated users, the settings don't load, so no upgrade banner is shown
|
||||
// This is the expected behavior - unauthenticated users see a skeleton loading state
|
||||
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
|
||||
return;
|
||||
}
|
||||
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Should show upgrade banner
|
||||
expect(screen.getByTestId("upgrade-banner")).toBeInTheDocument();
|
||||
|
||||
// Upgrade button should be disabled for unauthenticated users
|
||||
const upgradeButton = screen.getByRole("button", { name: /upgrade/i });
|
||||
expect(upgradeButton).toBeInTheDocument();
|
||||
expect(upgradeButton).toBeDisabled();
|
||||
|
||||
// Clicking disabled button should not call the API
|
||||
await userEvent.click(upgradeButton);
|
||||
expect(createSubscriptionCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not show upgrade banner and allow form interaction for subscribed SaaS users", async () => {
|
||||
// Mock SaaS mode with subscription
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return active subscription
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(MOCK_ACTIVE_SUBSCRIPTION);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Wait for subscription data to load
|
||||
await waitFor(() => {
|
||||
expect(getSubscriptionAccessSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should NOT show upgrade banner
|
||||
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
|
||||
|
||||
// Form should NOT be disabled
|
||||
const form = screen.getByTestId("llm-settings-form-basic");
|
||||
expect(form).not.toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("should not call save settings API when making changes in disabled form for unsubscribed users", async () => {
|
||||
// Mock SaaS mode without subscription
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return null (no subscription)
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(null);
|
||||
|
||||
// Mock saveSettings to track calls
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Verify that basic form elements are disabled for unsubscribed users
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
|
||||
expect(advancedSwitch).toBeDisabled();
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// Confirmation mode switch is in advanced view, which can't be accessed when form is disabled
|
||||
expect(
|
||||
screen.queryByTestId("enable-confirmation-mode-switch"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Try to submit the form - button should remain disabled
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
// Should NOT call save settings API for unsubscribed users
|
||||
expect(saveSettingsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show backdrop overlay for unsubscribed users", async () => {
|
||||
// Mock SaaS mode without subscription
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return null (no subscription)
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(null);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Wait for subscription data to load
|
||||
await waitFor(() => {
|
||||
expect(getSubscriptionAccessSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should show upgrade banner
|
||||
expect(screen.getByTestId("upgrade-banner")).toBeInTheDocument();
|
||||
|
||||
// Should show backdrop overlay
|
||||
const backdrop = screen.getByTestId("settings-backdrop");
|
||||
expect(backdrop).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show backdrop overlay for subscribed users", async () => {
|
||||
// Mock SaaS mode with subscription
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return active subscription
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(MOCK_ACTIVE_SUBSCRIPTION);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Wait for subscription data to load
|
||||
await waitFor(() => {
|
||||
expect(getSubscriptionAccessSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should NOT show backdrop overlay
|
||||
expect(screen.queryByTestId("settings-backdrop")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display success toast when redirected back with ?checkout=success parameter", async () => {
|
||||
// Mock SaaS mode
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(MOCK_ACTIVE_SUBSCRIPTION);
|
||||
|
||||
// Mock toast handler
|
||||
const displaySuccessToastSpy = vi.spyOn(
|
||||
ToastHandlers,
|
||||
"displaySuccessToast",
|
||||
);
|
||||
|
||||
// Mock URL search params with ?checkout=success
|
||||
mockUseSearchParams.mockReturnValue([
|
||||
{
|
||||
get: (param: string) => (param === "checkout" ? "success" : null),
|
||||
},
|
||||
vi.fn(),
|
||||
]);
|
||||
|
||||
// Render component with checkout=success parameter
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Verify success toast is displayed with correct message
|
||||
expect(displaySuccessToastSpy).toHaveBeenCalledWith(
|
||||
"SUBSCRIPTION$SUCCESS",
|
||||
);
|
||||
});
|
||||
|
||||
it("should display error toast when redirected back with ?checkout=cancel parameter", async () => {
|
||||
// Mock SaaS mode
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(MOCK_ACTIVE_SUBSCRIPTION);
|
||||
|
||||
// Mock toast handler
|
||||
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
|
||||
|
||||
// Mock URL search params with ?checkout=cancel
|
||||
mockUseSearchParams.mockReturnValue([
|
||||
{
|
||||
get: (param: string) => (param === "checkout" ? "cancel" : null),
|
||||
},
|
||||
vi.fn(),
|
||||
]);
|
||||
|
||||
// Render component with checkout=cancel parameter
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Verify error toast is displayed with correct message
|
||||
expect(displayErrorToastSpy).toHaveBeenCalledWith("SUBSCRIPTION$FAILURE");
|
||||
});
|
||||
|
||||
it("should show upgrade banner when subscription is expired or disabled", async () => {
|
||||
// Mock SaaS mode
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return null (expired/disabled subscriptions return null from backend)
|
||||
// The backend only returns active subscriptions within their validity period
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(null);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Wait for subscription data to load
|
||||
await waitFor(() => {
|
||||
expect(getSubscriptionAccessSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should show upgrade banner for expired/disabled subscriptions (when API returns null)
|
||||
expect(screen.getByTestId("upgrade-banner")).toBeInTheDocument();
|
||||
|
||||
// Form should be disabled
|
||||
const form = screen.getByTestId("llm-settings-form-basic");
|
||||
expect(form).toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
// All form inputs should be disabled
|
||||
const providerInput = screen.getByTestId("llm-provider-input");
|
||||
const modelInput = screen.getByTestId("llm-model-input");
|
||||
const apiKeyInput = screen.getByTestId("llm-api-key-input");
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
|
||||
expect(providerInput).toBeDisabled();
|
||||
expect(modelInput).toBeDisabled();
|
||||
expect(apiKeyInput).toBeDisabled();
|
||||
expect(advancedSwitch).toBeDisabled();
|
||||
|
||||
// Confirmation mode switch is in advanced view, which can't be accessed when form is disabled
|
||||
expect(
|
||||
screen.queryByTestId("enable-confirmation-mode-switch"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCreateStripeCheckoutSession } from "#/hooks/mutation/stripe/use-create-stripe-checkout-session";
|
||||
import { useBalance } from "#/hooks/query/use-balance";
|
||||
import { useSubscriptionAccess } from "#/hooks/query/use-subscription-access";
|
||||
import { cn } from "#/utils/utils";
|
||||
import MoneyIcon from "#/icons/money.svg?react";
|
||||
import { SettingsInput } from "../settings/settings-input";
|
||||
@@ -11,24 +10,13 @@ import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { amountIsValid } from "#/utils/amount-is-valid";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { PoweredByStripeTag } from "./powered-by-stripe-tag";
|
||||
import { CancelSubscriptionModal } from "./cancel-subscription-modal";
|
||||
|
||||
export function PaymentForm() {
|
||||
const { t } = useTranslation();
|
||||
const { data: balance, isLoading } = useBalance();
|
||||
const { data: subscriptionAccess } = useSubscriptionAccess();
|
||||
const { mutate: addBalance, isPending } = useCreateStripeCheckoutSession();
|
||||
|
||||
const [buttonIsDisabled, setButtonIsDisabled] = React.useState(true);
|
||||
const [showCancelModal, setShowCancelModal] = React.useState(false);
|
||||
|
||||
const subscriptionExpiredDate =
|
||||
subscriptionAccess?.end_at &&
|
||||
new Date(subscriptionAccess.end_at).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const billingFormAction = async (formData: FormData) => {
|
||||
const amount = formData.get("top-up-input")?.toString();
|
||||
@@ -94,50 +82,7 @@ export function PaymentForm() {
|
||||
{isPending && <LoadingSpinner size="small" />}
|
||||
<PoweredByStripeTag />
|
||||
</div>
|
||||
|
||||
{/* Cancel Subscription Button or Cancellation Message */}
|
||||
{subscriptionAccess && (
|
||||
<div className="flex flex-col w-[680px] gap-2 mt-4">
|
||||
{subscriptionAccess.cancelled_at ? (
|
||||
<div className="text-red-500 text-sm">
|
||||
<Trans
|
||||
i18nKey={I18nKey.PAYMENT$SUBSCRIPTION_CANCELLED_EXPIRES}
|
||||
values={{ date: subscriptionExpiredDate }}
|
||||
components={{ date: <span className="underline" /> }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-4">
|
||||
<BrandButton
|
||||
testId="cancel-subscription-button"
|
||||
variant="ghost-danger"
|
||||
type="button"
|
||||
onClick={() => setShowCancelModal(true)}
|
||||
>
|
||||
{t(I18nKey.PAYMENT$CANCEL_SUBSCRIPTION)}
|
||||
</BrandButton>
|
||||
<div
|
||||
className="text-sm text-gray-300"
|
||||
data-testid="next-billing-date"
|
||||
>
|
||||
<Trans
|
||||
i18nKey={I18nKey.PAYMENT$NEXT_BILLING_DATE}
|
||||
values={{ date: subscriptionExpiredDate }}
|
||||
components={{ date: <span className="underline" /> }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cancel Subscription Modal */}
|
||||
<CancelSubscriptionModal
|
||||
isOpen={showCancelModal}
|
||||
onClose={() => setShowCancelModal(false)}
|
||||
endDate={subscriptionExpiredDate}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,13 +11,11 @@ interface NavigationItem {
|
||||
interface SettingsLayoutProps {
|
||||
children: React.ReactNode;
|
||||
navigationItems: NavigationItem[];
|
||||
isSaas: boolean;
|
||||
}
|
||||
|
||||
export function SettingsLayout({
|
||||
children,
|
||||
navigationItems,
|
||||
isSaas,
|
||||
}: SettingsLayoutProps) {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
@@ -44,7 +42,6 @@ export function SettingsLayout({
|
||||
isMobileMenuOpen={isMobileMenuOpen}
|
||||
onCloseMobileMenu={closeMobileMenu}
|
||||
navigationItems={navigationItems}
|
||||
isSaas={isSaas}
|
||||
/>
|
||||
|
||||
{/* Main content */}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Typography } from "#/ui/typography";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import SettingsIcon from "#/icons/settings-gear.svg?react";
|
||||
import CloseIcon from "#/icons/close.svg?react";
|
||||
import { ProPill } from "./pro-pill";
|
||||
|
||||
interface NavigationItem {
|
||||
to: string;
|
||||
@@ -17,14 +16,12 @@ interface SettingsNavigationProps {
|
||||
isMobileMenuOpen: boolean;
|
||||
onCloseMobileMenu: () => void;
|
||||
navigationItems: NavigationItem[];
|
||||
isSaas: boolean;
|
||||
}
|
||||
|
||||
export function SettingsNavigation({
|
||||
isMobileMenuOpen,
|
||||
onCloseMobileMenu,
|
||||
navigationItems,
|
||||
isSaas,
|
||||
}: SettingsNavigationProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -85,7 +82,6 @@ export function SettingsNavigation({
|
||||
<Typography.Text className="text-[#A3A3A3] whitespace-nowrap">
|
||||
{t(text as I18nKey)}
|
||||
</Typography.Text>
|
||||
{isSaas && to === "/settings" && <ProPill />}
|
||||
</div>
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
@@ -26,6 +26,13 @@ export const useUpdateConversation = () => {
|
||||
),
|
||||
);
|
||||
|
||||
// Also optimistically update the active conversation query
|
||||
queryClient.setQueryData(
|
||||
["user", "conversation", variables.conversationId],
|
||||
(old: { title: string } | undefined) =>
|
||||
old ? { ...old, title: variables.newTitle } : old,
|
||||
);
|
||||
|
||||
return { previousConversations };
|
||||
},
|
||||
onError: (err, variables, context) => {
|
||||
|
||||
@@ -6160,20 +6160,20 @@
|
||||
"uk": "Введіть свій ключ API."
|
||||
},
|
||||
"SETTINGS$LLM_API_KEY": {
|
||||
"en": "LLM API Key",
|
||||
"zh-CN": "LLM API 密钥",
|
||||
"zh-TW": "LLM API 金鑰",
|
||||
"de": "LLM API Schlüssel",
|
||||
"ko-KR": "LLM API 키",
|
||||
"no": "LLM API-nøkkel",
|
||||
"it": "Chiave API LLM",
|
||||
"pt": "Chave API LLM",
|
||||
"es": "Clave API LLM",
|
||||
"ar": "مفتاح API للنماذج اللغوية الكبيرة",
|
||||
"fr": "Clé API LLM",
|
||||
"tr": "LLM API Anahtarı",
|
||||
"ja": "LLM APIキー",
|
||||
"uk": "Ключ API LLM"
|
||||
"en": "OpenHands LLM Key",
|
||||
"zh-CN": "OpenHands LLM 密钥",
|
||||
"zh-TW": "OpenHands LLM 金鑰",
|
||||
"de": "OpenHands LLM Schlüssel",
|
||||
"ko-KR": "OpenHands LLM 키",
|
||||
"no": "OpenHands LLM-nøkkel",
|
||||
"it": "Chiave LLM OpenHands",
|
||||
"pt": "Chave LLM OpenHands",
|
||||
"es": "Clave LLM OpenHands",
|
||||
"ar": "مفتاح LLM OpenHands",
|
||||
"fr": "Clé LLM OpenHands",
|
||||
"tr": "OpenHands LLM Anahtarı",
|
||||
"ja": "OpenHands LLMキー",
|
||||
"uk": "Ключ LLM OpenHands"
|
||||
},
|
||||
"SETTINGS$LLM_API_KEY_DESCRIPTION": {
|
||||
"en": "You can use this API Key as the LLM API Key for OpenHands open-source and CLI. It will incur cost on your OpenHands Cloud account. Do NOT share this key elsewhere.",
|
||||
|
||||
@@ -28,12 +28,6 @@ import { KeyStatusIcon } from "#/components/features/settings/key-status-icon";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { getProviderId } from "#/utils/map-provider";
|
||||
import { DEFAULT_OPENHANDS_MODEL } from "#/utils/verified-models";
|
||||
import { useSubscriptionAccess } from "#/hooks/query/use-subscription-access";
|
||||
import { UpgradeBannerWithBackdrop } from "#/components/features/settings/upgrade-banner-with-backdrop";
|
||||
import { useCreateSubscriptionCheckoutSession } from "#/hooks/mutation/stripe/use-create-subscription-checkout-session";
|
||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { useIsAllHandsSaaSEnvironment } from "#/hooks/use-is-all-hands-saas-environment";
|
||||
|
||||
interface OpenHandsApiKeyHelpProps {
|
||||
testId: string;
|
||||
@@ -75,11 +69,6 @@ function LlmSettingsScreen() {
|
||||
const { data: resources } = useAIConfigOptions();
|
||||
const { data: settings, isLoading, isFetching } = useSettings();
|
||||
const { data: config } = useConfig();
|
||||
const { data: subscriptionAccess } = useSubscriptionAccess();
|
||||
const { data: isAuthed } = useIsAuthed();
|
||||
const { mutate: createSubscriptionCheckoutSession } =
|
||||
useCreateSubscriptionCheckoutSession();
|
||||
const isAllHandsSaaSEnvironment = useIsAllHandsSaaSEnvironment();
|
||||
|
||||
const [view, setView] = React.useState<"basic" | "advanced">("basic");
|
||||
|
||||
@@ -442,44 +431,16 @@ function LlmSettingsScreen() {
|
||||
|
||||
if (!settings || isFetching) return <LlmSettingsInputsSkeleton />;
|
||||
|
||||
// Show upgrade banner and disable form in SaaS mode when user doesn't have an active subscription
|
||||
// Exclude self-hosted enterprise customers (those not on all-hands.dev domains)
|
||||
const shouldShowUpgradeBanner =
|
||||
config?.APP_MODE === "saas" &&
|
||||
!subscriptionAccess &&
|
||||
isAllHandsSaaSEnvironment;
|
||||
|
||||
const formAction = (formData: FormData) => {
|
||||
// Prevent form submission for unsubscribed SaaS users
|
||||
if (shouldShowUpgradeBanner) return;
|
||||
|
||||
if (view === "basic") basicFormAction(formData);
|
||||
else advancedFormAction(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="llm-settings-screen"
|
||||
className={cn(
|
||||
"h-full relative",
|
||||
shouldShowUpgradeBanner && "overflow-hidden",
|
||||
)}
|
||||
>
|
||||
{shouldShowUpgradeBanner && (
|
||||
<UpgradeBannerWithBackdrop
|
||||
onUpgradeClick={() => {
|
||||
createSubscriptionCheckoutSession();
|
||||
}}
|
||||
isDisabled={!isAuthed}
|
||||
/>
|
||||
)}
|
||||
<div data-testid="llm-settings-screen" className="h-full relative">
|
||||
<form
|
||||
action={formAction}
|
||||
className={cn(
|
||||
"flex flex-col h-full justify-between",
|
||||
shouldShowUpgradeBanner && "h-[calc(100%-theme(spacing.12))]",
|
||||
)}
|
||||
inert={shouldShowUpgradeBanner}
|
||||
className="flex flex-col h-full justify-between"
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<SettingsSwitch
|
||||
@@ -487,7 +448,6 @@ function LlmSettingsScreen() {
|
||||
defaultIsToggled={view === "advanced"}
|
||||
onToggle={handleToggleAdvancedSettings}
|
||||
isToggled={view === "advanced"}
|
||||
isDisabled={shouldShowUpgradeBanner}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$ADVANCED)}
|
||||
</SettingsSwitch>
|
||||
@@ -496,7 +456,6 @@ function LlmSettingsScreen() {
|
||||
<div
|
||||
data-testid="llm-settings-form-basic"
|
||||
className="flex flex-col gap-6"
|
||||
aria-disabled={shouldShowUpgradeBanner ? "true" : undefined}
|
||||
>
|
||||
{!isLoading && !isFetching && (
|
||||
<>
|
||||
@@ -504,7 +463,6 @@ function LlmSettingsScreen() {
|
||||
models={modelsAndProviders}
|
||||
currentModel={settings.LLM_MODEL || DEFAULT_OPENHANDS_MODEL}
|
||||
onChange={handleModelIsDirty}
|
||||
isDisabled={shouldShowUpgradeBanner}
|
||||
wrapperClassName="!flex-col !gap-6"
|
||||
/>
|
||||
{(settings.LLM_MODEL?.startsWith("openhands/") ||
|
||||
@@ -522,7 +480,6 @@ function LlmSettingsScreen() {
|
||||
className="w-full max-w-[680px]"
|
||||
placeholder={settings.LLM_API_KEY_SET ? "<hidden>" : ""}
|
||||
onChange={handleApiKeyIsDirty}
|
||||
isDisabled={shouldShowUpgradeBanner}
|
||||
startContent={
|
||||
settings.LLM_API_KEY_SET && (
|
||||
<KeyStatusIcon isSet={settings.LLM_API_KEY_SET} />
|
||||
@@ -602,7 +559,6 @@ function LlmSettingsScreen() {
|
||||
defaultValue={settings.SEARCH_API_KEY || ""}
|
||||
onChange={handleSearchApiKeyIsDirty}
|
||||
placeholder={t(I18nKey.API$TVLY_KEY_EXAMPLE)}
|
||||
isDisabled={shouldShowUpgradeBanner}
|
||||
startContent={
|
||||
settings.SEARCH_API_KEY_SET && (
|
||||
<KeyStatusIcon isSet={settings.SEARCH_API_KEY_SET} />
|
||||
@@ -672,7 +628,6 @@ function LlmSettingsScreen() {
|
||||
onToggle={handleConfirmationModeIsDirty}
|
||||
defaultIsToggled={settings.CONFIRMATION_MODE}
|
||||
isBeta
|
||||
isDisabled={shouldShowUpgradeBanner}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$CONFIRMATION_MODE)}
|
||||
</SettingsSwitch>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Route } from "./+types/settings";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { queryClient } from "#/query-client-config";
|
||||
import { GetConfigResponse } from "#/api/option-service/option.types";
|
||||
import { useSubscriptionAccess } from "#/hooks/query/use-subscription-access";
|
||||
import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { SettingsLayout } from "#/components/features/settings/settings-layout";
|
||||
@@ -41,7 +40,6 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
|
||||
function SettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useConfig();
|
||||
const { data: subscriptionAccess } = useSubscriptionAccess();
|
||||
const location = useLocation();
|
||||
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
@@ -55,7 +53,7 @@ function SettingsScreen() {
|
||||
items.push(...OSS_NAV_ITEMS);
|
||||
}
|
||||
return items;
|
||||
}, [isSaas, !!subscriptionAccess]);
|
||||
}, [isSaas]);
|
||||
|
||||
// Current section title for the main content area
|
||||
const currentSectionTitle = useMemo(() => {
|
||||
@@ -65,7 +63,7 @@ function SettingsScreen() {
|
||||
|
||||
return (
|
||||
<main data-testid="settings-screen" className="h-full">
|
||||
<SettingsLayout navigationItems={navItems} isSaas={isSaas}>
|
||||
<SettingsLayout navigationItems={navItems}>
|
||||
<div className="flex flex-col gap-6 h-full">
|
||||
<Typography.H2>{t(currentSectionTitle)}</Typography.H2>
|
||||
<div className="flex-1 overflow-auto custom-scrollbar-always">
|
||||
|
||||
@@ -6,6 +6,7 @@ Provides a conversation interface with an AI agent using OpenHands patterns.
|
||||
|
||||
import sys
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from openhands.sdk import (
|
||||
Message,
|
||||
@@ -16,7 +17,11 @@ from prompt_toolkit import print_formatted_text
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
|
||||
from openhands_cli.runner import ConversationRunner
|
||||
from openhands_cli.setup import MissingAgentSpec, setup_conversation, start_fresh_conversation
|
||||
from openhands_cli.setup import (
|
||||
MissingAgentSpec,
|
||||
setup_conversation,
|
||||
verify_agent_exists_or_setup_agent
|
||||
)
|
||||
from openhands_cli.tui.settings.mcp_screen import MCPScreen
|
||||
from openhands_cli.tui.settings.settings_screen import SettingsScreen
|
||||
from openhands_cli.tui.status import display_status
|
||||
@@ -65,21 +70,33 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
|
||||
EOFError: If EOF is encountered
|
||||
"""
|
||||
|
||||
conversation_id = uuid.uuid4()
|
||||
if resume_conversation_id:
|
||||
try:
|
||||
conversation_id = uuid.UUID(resume_conversation_id)
|
||||
except ValueError as e:
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
f"<yellow>Warning: '{resume_conversation_id}' is not a valid UUID.</yellow>"
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
conversation = start_fresh_conversation(resume_conversation_id)
|
||||
initialized_agent = verify_agent_exists_or_setup_agent()
|
||||
except MissingAgentSpec:
|
||||
print_formatted_text(HTML('\n<yellow>Setup is required to use OpenHands CLI.</yellow>'))
|
||||
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
||||
return
|
||||
|
||||
|
||||
display_welcome(conversation.id, bool(resume_conversation_id))
|
||||
display_welcome(conversation_id, bool(resume_conversation_id))
|
||||
|
||||
# Track session start time for uptime calculation
|
||||
session_start_time = datetime.now()
|
||||
|
||||
# Create conversation runner to handle state machine logic
|
||||
runner = ConversationRunner(conversation)
|
||||
runner = None
|
||||
session = get_session_prompter()
|
||||
|
||||
# Main chat loop
|
||||
@@ -106,7 +123,7 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
|
||||
exit_confirmation = exit_session_confirmation()
|
||||
if exit_confirmation == UserConfirmation.ACCEPT:
|
||||
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
||||
_print_exit_hint(conversation.id)
|
||||
_print_exit_hint(conversation_id)
|
||||
break
|
||||
|
||||
elif command == '/settings':
|
||||
@@ -116,19 +133,19 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
|
||||
|
||||
elif command == '/mcp':
|
||||
mcp_screen = MCPScreen()
|
||||
mcp_screen.display_mcp_info(conversation.agent)
|
||||
mcp_screen.display_mcp_info(initialized_agent)
|
||||
continue
|
||||
|
||||
elif command == '/clear':
|
||||
display_welcome(conversation.id)
|
||||
display_welcome(conversation_id)
|
||||
continue
|
||||
|
||||
elif command == '/new':
|
||||
try:
|
||||
# Start a fresh conversation (no resume ID = new conversation)
|
||||
conversation = setup_conversation()
|
||||
conversation = setup_conversation(conversation_id)
|
||||
runner = ConversationRunner(conversation)
|
||||
display_welcome(conversation.id, resume=False)
|
||||
display_welcome(conversation_id, resume=False)
|
||||
print_formatted_text(
|
||||
HTML('<green>✓ Started fresh conversation</green>')
|
||||
)
|
||||
@@ -158,6 +175,13 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
|
||||
continue
|
||||
|
||||
elif command == '/resume':
|
||||
if not runner:
|
||||
print_formatted_text(
|
||||
HTML('<yellow>No active conversation running...</yellow>')
|
||||
)
|
||||
continue
|
||||
|
||||
conversation = runner.conversation
|
||||
if not (
|
||||
conversation.state.agent_status == AgentExecutionStatus.PAUSED
|
||||
or conversation.state.agent_status
|
||||
@@ -171,6 +195,9 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
|
||||
# Resume without new message
|
||||
message = None
|
||||
|
||||
if not runner:
|
||||
conversation = setup_conversation(conversation_id)
|
||||
runner = ConversationRunner(conversation)
|
||||
runner.process_message(message)
|
||||
|
||||
print() # Add spacing
|
||||
@@ -179,7 +206,7 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
|
||||
exit_confirmation = exit_session_confirmation()
|
||||
if exit_confirmation == UserConfirmation.ACCEPT:
|
||||
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
||||
_print_exit_hint(conversation.id)
|
||||
_print_exit_hint(conversation_id)
|
||||
break
|
||||
|
||||
# Clean up terminal state
|
||||
|
||||
@@ -104,8 +104,8 @@ def launch_gui_server(mount_cwd: bool = False, gpu: bool = False) -> None:
|
||||
|
||||
# Get the current version for the Docker image
|
||||
version = get_openhands_version()
|
||||
runtime_image = f'docker.all-hands.dev/openhands/runtime:{version}-nikolaik'
|
||||
app_image = f'docker.all-hands.dev/openhands/openhands:{version}'
|
||||
runtime_image = f'docker.openhands.dev/openhands/runtime:{version}-nikolaik'
|
||||
app_image = f'docker.openhands.dev/openhands/openhands:{version}'
|
||||
|
||||
print_formatted_text(HTML('<grey>Pulling required Docker images...</grey>'))
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import uuid
|
||||
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
|
||||
from openhands.sdk import BaseConversation, Conversation, Workspace, register_tool
|
||||
from openhands.sdk import Agent, BaseConversation, Conversation, Workspace, register_tool
|
||||
from openhands.tools.execute_bash import BashTool
|
||||
from openhands.tools.file_editor import FileEditorTool
|
||||
from openhands.tools.task_tracker import TaskTrackerTool
|
||||
@@ -26,8 +26,38 @@ class MissingAgentSpec(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def setup_conversation(
|
||||
|
||||
def load_agent_specs(
|
||||
conversation_id: str | None = None,
|
||||
) -> Agent:
|
||||
agent_store = AgentStore()
|
||||
agent = agent_store.load(session_id=conversation_id)
|
||||
if not agent:
|
||||
raise MissingAgentSpec(
|
||||
'Agent specification not found. Please configure your agent settings.'
|
||||
)
|
||||
return agent
|
||||
|
||||
|
||||
def verify_agent_exists_or_setup_agent() -> Agent:
|
||||
"""Verify agent specs exists by attempting to load it.
|
||||
|
||||
"""
|
||||
settings_screen = SettingsScreen()
|
||||
try:
|
||||
agent = load_agent_specs()
|
||||
return agent
|
||||
except MissingAgentSpec:
|
||||
# For first-time users, show the full settings flow with choice between basic/advanced
|
||||
settings_screen.configure_settings(first_time=True)
|
||||
|
||||
|
||||
# Try once again after settings setup attempt
|
||||
return load_agent_specs()
|
||||
|
||||
|
||||
def setup_conversation(
|
||||
conversation_id: uuid,
|
||||
include_security_analyzer: bool = True
|
||||
) -> BaseConversation:
|
||||
"""
|
||||
@@ -40,28 +70,8 @@ def setup_conversation(
|
||||
MissingAgentSpec: If agent specification is not found or invalid.
|
||||
"""
|
||||
|
||||
# Use provided conversation_id or generate a random one
|
||||
if conversation_id is None:
|
||||
conversation_id = uuid.uuid4()
|
||||
elif isinstance(conversation_id, str):
|
||||
try:
|
||||
conversation_id = uuid.UUID(conversation_id)
|
||||
except ValueError as e:
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
f"<yellow>Warning: '{conversation_id}' is not a valid UUID.</yellow>"
|
||||
)
|
||||
)
|
||||
raise e
|
||||
|
||||
with LoadingContext('Initializing OpenHands agent...'):
|
||||
agent_store = AgentStore()
|
||||
agent = agent_store.load(session_id=str(conversation_id))
|
||||
if not agent:
|
||||
raise MissingAgentSpec(
|
||||
'Agent specification not found. Please configure your agent settings.'
|
||||
)
|
||||
|
||||
agent = load_agent_specs(str(conversation_id))
|
||||
|
||||
if not include_security_analyzer:
|
||||
# Remove security analyzer from agent spec
|
||||
@@ -86,31 +96,3 @@ def setup_conversation(
|
||||
)
|
||||
return conversation
|
||||
|
||||
|
||||
|
||||
def start_fresh_conversation(
|
||||
resume_conversation_id: str | None = None
|
||||
) -> BaseConversation:
|
||||
"""Start a fresh conversation by creating a new conversation instance.
|
||||
|
||||
Handles the complete conversation setup process including settings screen
|
||||
if agent configuration is missing.
|
||||
|
||||
Args:
|
||||
resume_conversation_id: Optional conversation ID to resume
|
||||
|
||||
Returns:
|
||||
BaseConversation: A new conversation instance
|
||||
"""
|
||||
conversation = None
|
||||
settings_screen = SettingsScreen()
|
||||
try:
|
||||
conversation = setup_conversation(resume_conversation_id)
|
||||
return conversation
|
||||
except MissingAgentSpec:
|
||||
# For first-time users, show the full settings flow with choice between basic/advanced
|
||||
settings_screen.configure_settings(first_time=True)
|
||||
|
||||
|
||||
# Try once again after settings setup attempt
|
||||
return setup_conversation(resume_conversation_id)
|
||||
|
||||
@@ -4,7 +4,7 @@ requires = [ "hatchling>=1.25" ]
|
||||
|
||||
[project]
|
||||
name = "openhands"
|
||||
version = "1.0.3"
|
||||
version = "1.0.4"
|
||||
description = "OpenHands CLI - Terminal User Interface for OpenHands AI Agent"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
|
||||
@@ -4,51 +4,49 @@ from unittest.mock import MagicMock, patch
|
||||
from uuid import UUID
|
||||
from prompt_toolkit.input.defaults import create_pipe_input
|
||||
from prompt_toolkit.output.defaults import DummyOutput
|
||||
from openhands_cli.setup import MissingAgentSpec, start_fresh_conversation
|
||||
from openhands_cli.setup import MissingAgentSpec, verify_agent_exists_or_setup_agent, setup_conversation
|
||||
from openhands_cli.user_actions import UserConfirmation
|
||||
|
||||
@patch('openhands_cli.setup.setup_conversation')
|
||||
def test_start_fresh_conversation_success(mock_setup_conversation):
|
||||
"""Test that start_fresh_conversation creates a new conversation successfully."""
|
||||
# Mock the conversation object
|
||||
mock_conversation = MagicMock()
|
||||
mock_conversation.id = UUID('12345678-1234-5678-9abc-123456789abc')
|
||||
mock_setup_conversation.return_value = mock_conversation
|
||||
@patch('openhands_cli.setup.load_agent_specs')
|
||||
def test_verify_agent_exists_or_setup_agent_success(mock_load_agent_specs):
|
||||
"""Test that verify_agent_exists_or_setup_agent returns agent successfully."""
|
||||
# Mock the agent object
|
||||
mock_agent = MagicMock()
|
||||
mock_load_agent_specs.return_value = mock_agent
|
||||
|
||||
# Call the function
|
||||
result = start_fresh_conversation()
|
||||
result = verify_agent_exists_or_setup_agent()
|
||||
|
||||
# Verify the result
|
||||
assert result == mock_conversation
|
||||
mock_setup_conversation.assert_called_once_with(None)
|
||||
assert result == mock_agent
|
||||
mock_load_agent_specs.assert_called_once_with()
|
||||
|
||||
|
||||
@patch('openhands_cli.setup.SettingsScreen')
|
||||
@patch('openhands_cli.setup.setup_conversation')
|
||||
def test_start_fresh_conversation_missing_agent_spec(
|
||||
mock_setup_conversation,
|
||||
@patch('openhands_cli.setup.load_agent_specs')
|
||||
def test_verify_agent_exists_or_setup_agent_missing_agent_spec(
|
||||
mock_load_agent_specs,
|
||||
mock_settings_screen_class
|
||||
):
|
||||
"""Test that start_fresh_conversation handles MissingAgentSpec exception."""
|
||||
"""Test that verify_agent_exists_or_setup_agent handles MissingAgentSpec exception."""
|
||||
# Mock the SettingsScreen instance
|
||||
mock_settings_screen = MagicMock()
|
||||
mock_settings_screen_class.return_value = mock_settings_screen
|
||||
|
||||
# Mock setup_conversation to raise MissingAgentSpec on first call, then succeed
|
||||
mock_conversation = MagicMock()
|
||||
mock_conversation.id = UUID('12345678-1234-5678-9abc-123456789abc')
|
||||
mock_setup_conversation.side_effect = [
|
||||
# Mock load_agent_specs to raise MissingAgentSpec on first call, then succeed
|
||||
mock_agent = MagicMock()
|
||||
mock_load_agent_specs.side_effect = [
|
||||
MissingAgentSpec("Agent spec missing"),
|
||||
mock_conversation
|
||||
mock_agent
|
||||
]
|
||||
|
||||
# Call the function
|
||||
result = start_fresh_conversation()
|
||||
result = verify_agent_exists_or_setup_agent()
|
||||
|
||||
# Verify the result
|
||||
assert result == mock_conversation
|
||||
assert result == mock_agent
|
||||
# Should be called twice: first fails, second succeeds
|
||||
assert mock_setup_conversation.call_count == 2
|
||||
assert mock_load_agent_specs.call_count == 2
|
||||
# Settings screen should be called once with first_time=True (new behavior)
|
||||
mock_settings_screen.configure_settings.assert_called_once_with(first_time=True)
|
||||
|
||||
@@ -59,11 +57,11 @@ def test_start_fresh_conversation_missing_agent_spec(
|
||||
@patch('openhands_cli.agent_chat.exit_session_confirmation')
|
||||
@patch('openhands_cli.agent_chat.get_session_prompter')
|
||||
@patch('openhands_cli.agent_chat.setup_conversation')
|
||||
@patch('openhands_cli.agent_chat.start_fresh_conversation')
|
||||
@patch('openhands_cli.agent_chat.verify_agent_exists_or_setup_agent')
|
||||
@patch('openhands_cli.agent_chat.ConversationRunner')
|
||||
def test_new_command_resets_confirmation_mode(
|
||||
mock_runner_cls,
|
||||
mock_start_fresh_conversation,
|
||||
mock_verify_agent,
|
||||
mock_setup_conversation,
|
||||
mock_get_session_prompter,
|
||||
mock_exit_confirm,
|
||||
@@ -71,15 +69,17 @@ def test_new_command_resets_confirmation_mode(
|
||||
# Auto-accept the exit prompt to avoid interactive UI and EOFError
|
||||
mock_exit_confirm.return_value = UserConfirmation.ACCEPT
|
||||
|
||||
conv1 = MagicMock(); conv1.id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
||||
conv2 = MagicMock(); conv2.id = UUID('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')
|
||||
mock_start_fresh_conversation.return_value = conv1
|
||||
mock_setup_conversation.side_effect = [conv2]
|
||||
# Mock agent verification to succeed
|
||||
mock_agent = MagicMock()
|
||||
mock_verify_agent.return_value = mock_agent
|
||||
|
||||
# Distinct runner instances for each conversation
|
||||
# Mock conversation - only one is created when /new is called
|
||||
conv1 = MagicMock(); conv1.id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
||||
mock_setup_conversation.return_value = conv1
|
||||
|
||||
# One runner instance for the conversation
|
||||
runner1 = MagicMock(); runner1.is_confirmation_mode_active = True
|
||||
runner2 = MagicMock(); runner2.is_confirmation_mode_active = False
|
||||
mock_runner_cls.side_effect = [runner1, runner2]
|
||||
mock_runner_cls.return_value = runner1
|
||||
|
||||
# Real session fed by a pipe (no interactive confirmation now)
|
||||
from openhands_cli.user_actions.utils import get_session_prompter as real_get_session_prompter
|
||||
@@ -89,13 +89,12 @@ def test_new_command_resets_confirmation_mode(
|
||||
mock_get_session_prompter.return_value = session
|
||||
|
||||
from openhands_cli.agent_chat import run_cli_entry
|
||||
# Trigger /new, then /status, then /exit (exit will be auto-accepted)
|
||||
# Trigger /new, then /exit (exit will be auto-accepted)
|
||||
for ch in "/new\r/exit\r":
|
||||
pipe.send_text(ch)
|
||||
|
||||
run_cli_entry(None)
|
||||
|
||||
# Assert we switched to a new runner for conv2
|
||||
assert mock_runner_cls.call_count == 2
|
||||
# Assert we created one runner for the conversation when /new was called
|
||||
assert mock_runner_cls.call_count == 1
|
||||
assert mock_runner_cls.call_args_list[0].args[0] is conv1
|
||||
assert mock_runner_cls.call_args_list[1].args[0] is conv2
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Tests for the /resume command functionality."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import UUID
|
||||
import pytest
|
||||
from prompt_toolkit.input.defaults import create_pipe_input
|
||||
from prompt_toolkit.output.defaults import DummyOutput
|
||||
|
||||
from openhands.sdk.conversation.state import AgentExecutionStatus
|
||||
from openhands_cli.user_actions import UserConfirmation
|
||||
|
||||
|
||||
# ---------- Fixtures & helpers ----------
|
||||
|
||||
@pytest.fixture
|
||||
def mock_agent():
|
||||
"""Mock agent for verification."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_conversation():
|
||||
"""Mock conversation with default settings."""
|
||||
conv = MagicMock()
|
||||
conv.id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
||||
return conv
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_runner():
|
||||
"""Mock conversation runner."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
def run_resume_command_test(commands, agent_status=None, expect_runner_created=True):
|
||||
"""Helper function to run resume command tests with common setup."""
|
||||
with patch('openhands_cli.agent_chat.exit_session_confirmation') as mock_exit_confirm, \
|
||||
patch('openhands_cli.agent_chat.get_session_prompter') as mock_get_session_prompter, \
|
||||
patch('openhands_cli.agent_chat.setup_conversation') as mock_setup_conversation, \
|
||||
patch('openhands_cli.agent_chat.verify_agent_exists_or_setup_agent') as mock_verify_agent, \
|
||||
patch('openhands_cli.agent_chat.ConversationRunner') as mock_runner_cls:
|
||||
|
||||
# Auto-accept the exit prompt to avoid interactive UI
|
||||
mock_exit_confirm.return_value = UserConfirmation.ACCEPT
|
||||
|
||||
# Mock agent verification to succeed
|
||||
mock_agent = MagicMock()
|
||||
mock_verify_agent.return_value = mock_agent
|
||||
|
||||
# Mock conversation setup
|
||||
conv = MagicMock()
|
||||
conv.id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
||||
if agent_status:
|
||||
conv.state.agent_status = agent_status
|
||||
mock_setup_conversation.return_value = conv
|
||||
|
||||
# Mock runner
|
||||
runner = MagicMock()
|
||||
runner.conversation = conv
|
||||
mock_runner_cls.return_value = runner
|
||||
|
||||
# Real session fed by a pipe
|
||||
from openhands_cli.user_actions.utils import get_session_prompter as real_get_session_prompter
|
||||
with create_pipe_input() as pipe:
|
||||
output = DummyOutput()
|
||||
session = real_get_session_prompter(input=pipe, output=output)
|
||||
mock_get_session_prompter.return_value = session
|
||||
|
||||
from openhands_cli.agent_chat import run_cli_entry
|
||||
|
||||
# Send commands
|
||||
for ch in commands:
|
||||
pipe.send_text(ch)
|
||||
|
||||
# Capture printed output
|
||||
with patch('openhands_cli.agent_chat.print_formatted_text') as mock_print:
|
||||
run_cli_entry(None)
|
||||
|
||||
return mock_runner_cls, runner, mock_print
|
||||
|
||||
|
||||
# ---------- Warning tests (parametrized) ----------
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"commands,expected_warning,expect_runner_created",
|
||||
[
|
||||
# No active conversation - /resume immediately
|
||||
("/resume\r/exit\r", "No active conversation running", False),
|
||||
# Conversation exists but not in paused state - send message first, then /resume
|
||||
("hello\r/resume\r/exit\r", "No paused conversation to resume", True),
|
||||
],
|
||||
)
|
||||
def test_resume_command_warnings(commands, expected_warning, expect_runner_created):
|
||||
"""Test /resume command shows appropriate warnings."""
|
||||
# Set agent status to FINISHED for the "conversation exists but not paused" test
|
||||
agent_status = AgentExecutionStatus.FINISHED if expect_runner_created else None
|
||||
|
||||
mock_runner_cls, runner, mock_print = run_resume_command_test(
|
||||
commands, agent_status=agent_status, expect_runner_created=expect_runner_created
|
||||
)
|
||||
|
||||
# Verify warning message was printed
|
||||
warning_calls = [call for call in mock_print.call_args_list
|
||||
if expected_warning in str(call)]
|
||||
assert len(warning_calls) > 0, f"Expected warning about {expected_warning}"
|
||||
|
||||
# Verify runner creation expectation
|
||||
if expect_runner_created:
|
||||
assert mock_runner_cls.call_count == 1
|
||||
runner.process_message.assert_called()
|
||||
else:
|
||||
assert mock_runner_cls.call_count == 0
|
||||
|
||||
|
||||
# ---------- Successful resume tests (parametrized) ----------
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"agent_status",
|
||||
[
|
||||
AgentExecutionStatus.PAUSED,
|
||||
AgentExecutionStatus.WAITING_FOR_CONFIRMATION,
|
||||
],
|
||||
)
|
||||
def test_resume_command_successful_resume(agent_status):
|
||||
"""Test /resume command successfully resumes paused/waiting conversations."""
|
||||
commands = "hello\r/resume\r/exit\r"
|
||||
|
||||
mock_runner_cls, runner, mock_print = run_resume_command_test(
|
||||
commands, agent_status=agent_status, expect_runner_created=True
|
||||
)
|
||||
|
||||
# Verify runner was created and process_message was called
|
||||
assert mock_runner_cls.call_count == 1
|
||||
|
||||
# Verify process_message was called twice: once with the initial message, once with None for resume
|
||||
assert runner.process_message.call_count == 2
|
||||
|
||||
# Check the calls to process_message
|
||||
calls = runner.process_message.call_args_list
|
||||
|
||||
# First call should have a message (the "hello" message)
|
||||
first_call_args = calls[0][0]
|
||||
assert first_call_args[0] is not None, "First call should have a message"
|
||||
|
||||
# Second call should have None (the /resume command)
|
||||
second_call_args = calls[1][0]
|
||||
assert second_call_args[0] is None, "Second call should have None message for resume"
|
||||
@@ -4,6 +4,7 @@ Tests for confirmation mode functionality in OpenHands CLI.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any
|
||||
from unittest.mock import ANY, MagicMock, patch
|
||||
@@ -60,7 +61,7 @@ class TestConfirmationMode:
|
||||
mock_conversation_instance = MagicMock()
|
||||
mock_conversation_class.return_value = mock_conversation_instance
|
||||
|
||||
result = setup_conversation()
|
||||
result = setup_conversation(mock_conversation_id)
|
||||
|
||||
# Verify conversation was created and returned
|
||||
assert result == mock_conversation_instance
|
||||
@@ -87,7 +88,7 @@ class TestConfirmationMode:
|
||||
|
||||
# Should raise MissingAgentSpec
|
||||
with pytest.raises(MissingAgentSpec) as exc_info:
|
||||
setup_conversation()
|
||||
setup_conversation(uuid.uuid4())
|
||||
|
||||
assert 'Agent specification not found' in str(exc_info.value)
|
||||
mock_agent_store_class.assert_called_once()
|
||||
|
||||
@@ -182,7 +182,7 @@ class TestLaunchGuiServer:
|
||||
# Check pull command
|
||||
pull_call = mock_run.call_args_list[0]
|
||||
pull_cmd = pull_call[0][0]
|
||||
assert pull_cmd[0:3] == ['docker', 'pull', 'docker.all-hands.dev/openhands/runtime:latest-nikolaik']
|
||||
assert pull_cmd[0:3] == ['docker', 'pull', 'docker.openhands.dev/openhands/runtime:latest-nikolaik']
|
||||
|
||||
# Check run command
|
||||
run_call = mock_run.call_args_list[1]
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from uuid import UUID, uuid4
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from openhands.agent_server.models import SendMessageRequest
|
||||
from openhands.agent_server.utils import utc_now
|
||||
from openhands.agent_server.utils import OpenHandsUUID, utc_now
|
||||
from openhands.app_server.event_callback.event_callback_models import (
|
||||
EventCallbackProcessor,
|
||||
)
|
||||
@@ -19,7 +19,7 @@ from openhands.storage.data_models.conversation_metadata import ConversationTrig
|
||||
class AppConversationInfo(BaseModel):
|
||||
"""Conversation info which does not contain status."""
|
||||
|
||||
id: UUID = Field(default_factory=uuid4)
|
||||
id: OpenHandsUUID = Field(default_factory=uuid4)
|
||||
|
||||
created_by_user_id: str | None
|
||||
sandbox_id: str
|
||||
@@ -125,11 +125,11 @@ class AppConversationStartTask(BaseModel):
|
||||
we kick off a background task for it. Once the conversation is started, the app_conversation_id
|
||||
is populated."""
|
||||
|
||||
id: UUID = Field(default_factory=uuid4)
|
||||
id: OpenHandsUUID = Field(default_factory=uuid4)
|
||||
created_by_user_id: str | None
|
||||
status: AppConversationStartTaskStatus = AppConversationStartTaskStatus.WORKING
|
||||
detail: str | None = None
|
||||
app_conversation_id: UUID | None = Field(
|
||||
app_conversation_id: OpenHandsUUID | None = Field(
|
||||
default=None, description='The id of the app_conversation, if READY'
|
||||
)
|
||||
sandbox_id: str | None = Field(
|
||||
|
||||
@@ -56,6 +56,14 @@ class GitAppConversationService(AppConversationService, ABC):
|
||||
):
|
||||
request = task.request
|
||||
|
||||
# Create the projects directory if it does not exist yet
|
||||
parent = Path(workspace.working_dir).parent
|
||||
result = await workspace.execute_command(
|
||||
f'mkdir {workspace.working_dir}', parent
|
||||
)
|
||||
if result.exit_code:
|
||||
_logger.warning(f'mkdir failed: {result.stderr}')
|
||||
|
||||
if not request.selected_repository:
|
||||
if self.init_git_in_empty_workspace:
|
||||
_logger.debug('Initializing a new git repository in the workspace.')
|
||||
@@ -81,7 +89,8 @@ class GitAppConversationService(AppConversationService, ABC):
|
||||
# Clone the repo - this is the slow part!
|
||||
clone_command = f'git clone {remote_repo_url} {dir_name}'
|
||||
result = await workspace.execute_command(clone_command, workspace.working_dir)
|
||||
print(result)
|
||||
if result.exit_code:
|
||||
_logger.warning(f'Git clone failed: {result.stderr}')
|
||||
|
||||
# Checkout the appropriate branch
|
||||
if request.selected_branch:
|
||||
|
||||
@@ -9,7 +9,7 @@ from uuid import UUID, uuid4
|
||||
|
||||
import httpx
|
||||
from fastapi import Request
|
||||
from pydantic import Field, SecretStr, TypeAdapter
|
||||
from pydantic import Field, TypeAdapter
|
||||
|
||||
from openhands.agent_server.models import (
|
||||
ConversationInfo,
|
||||
@@ -443,7 +443,7 @@ class LiveStatusAppConversationService(GitAppConversationService):
|
||||
expires_in=self.access_token_hard_timeout,
|
||||
)
|
||||
secrets[GIT_TOKEN] = LookupSecret(
|
||||
url=self.web_url + '/ap/v1/webhooks/secrets',
|
||||
url=self.web_url + '/api/v1/webhooks/secrets',
|
||||
headers={'X-Access-Token': access_token},
|
||||
)
|
||||
else:
|
||||
@@ -452,7 +452,7 @@ class LiveStatusAppConversationService(GitAppConversationService):
|
||||
# on the type, this may eventually expire.
|
||||
static_token = await self.user_context.get_latest_token(git_provider)
|
||||
if static_token:
|
||||
secrets[GIT_TOKEN] = StaticSecret(value=SecretStr(static_token))
|
||||
secrets[GIT_TOKEN] = StaticSecret(value=static_token)
|
||||
|
||||
workspace = LocalWorkspace(working_dir=working_dir)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from uuid import UUID, uuid4
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from openhands.agent_server.utils import utc_now
|
||||
from openhands.agent_server.utils import OpenHandsUUID, utc_now
|
||||
from openhands.app_server.event_callback.event_callback_result_models import (
|
||||
EventCallbackResult,
|
||||
EventCallbackResultStatus,
|
||||
@@ -58,7 +58,7 @@ class LoggingCallbackProcessor(EventCallbackProcessor):
|
||||
|
||||
|
||||
class CreateEventCallbackRequest(OpenHandsModel):
|
||||
conversation_id: UUID | None = Field(
|
||||
conversation_id: OpenHandsUUID | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
'Optional filter on the conversation to which this callback applies'
|
||||
@@ -74,7 +74,7 @@ class CreateEventCallbackRequest(OpenHandsModel):
|
||||
|
||||
|
||||
class EventCallback(CreateEventCallbackRequest):
|
||||
id: UUID = Field(default_factory=uuid4)
|
||||
id: OpenHandsUUID = Field(default_factory=uuid4)
|
||||
created_at: datetime = Field(default_factory=utc_now)
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from uuid import UUID, uuid4
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from openhands.agent_server.utils import utc_now
|
||||
from openhands.agent_server.utils import OpenHandsUUID, utc_now
|
||||
from openhands.sdk.event.types import EventID
|
||||
|
||||
|
||||
@@ -21,11 +21,11 @@ class EventCallbackResultSortOrder(Enum):
|
||||
class EventCallbackResult(BaseModel):
|
||||
"""Object representing the result of an event callback."""
|
||||
|
||||
id: UUID = Field(default_factory=uuid4)
|
||||
id: OpenHandsUUID = Field(default_factory=uuid4)
|
||||
status: EventCallbackResultStatus
|
||||
event_callback_id: UUID
|
||||
event_callback_id: OpenHandsUUID
|
||||
event_id: EventID
|
||||
conversation_id: UUID
|
||||
conversation_id: OpenHandsUUID
|
||||
detail: str | None = None
|
||||
created_at: datetime = Field(default_factory=utc_now)
|
||||
|
||||
|
||||
@@ -124,7 +124,9 @@ class RemoteSandboxService(SandboxService):
|
||||
try:
|
||||
runtime = await self._get_runtime(stored.id)
|
||||
except Exception:
|
||||
_logger.exception('Error getting runtime: {stored.id}', stack_info=True)
|
||||
_logger.exception(
|
||||
f'Error getting runtime: {stored.id}', stack_info=True
|
||||
)
|
||||
|
||||
if runtime:
|
||||
# Translate status
|
||||
@@ -150,7 +152,7 @@ class RemoteSandboxService(SandboxService):
|
||||
exposed_urls.append(ExposedUrl(name=AGENT_SERVER, url=url))
|
||||
vscode_url = (
|
||||
_build_service_url(url, 'vscode')
|
||||
+ f'/?tkn={session_api_key}&folder={runtime["working_dir"]}'
|
||||
+ f'/?tkn={session_api_key}&folder=%2Fworkspace%2Fproject'
|
||||
)
|
||||
exposed_urls.append(ExposedUrl(name=VSCODE, url=vscode_url))
|
||||
exposed_urls.append(
|
||||
@@ -308,14 +310,13 @@ class RemoteSandboxService(SandboxService):
|
||||
start_request: dict[str, Any] = {
|
||||
'image': sandbox_spec.id, # Use sandbox_spec.id as the container image
|
||||
'command': sandbox_spec.command,
|
||||
#'command': ['python', '-c', 'import time; time.sleep(300)'],
|
||||
'working_dir': sandbox_spec.working_dir,
|
||||
'working_dir': '/workspace',
|
||||
'environment': environment,
|
||||
'session_id': sandbox_id, # Use sandbox_id as session_id
|
||||
'resource_factor': self.resource_factor,
|
||||
'run_as_user': 1000,
|
||||
'run_as_group': 1000,
|
||||
'fs_group': 1000,
|
||||
'run_as_user': 10001,
|
||||
'run_as_group': 10001,
|
||||
'fs_group': 10001,
|
||||
}
|
||||
|
||||
# Add runtime class if specified
|
||||
@@ -530,7 +531,7 @@ async def refresh_conversation(
|
||||
# TODO: It would be nice to have an updated_at__gte filter parameter in the
|
||||
# agent server so that we don't pull the full event list each time
|
||||
event_url = (
|
||||
f'{url}/ap/conversations/{app_conversation_info.id.hex}/events/search'
|
||||
f'{url}/api/conversations/{app_conversation_info.id.hex}/events/search'
|
||||
)
|
||||
page_id = None
|
||||
while True:
|
||||
|
||||
@@ -30,7 +30,7 @@ def get_default_sandbox_specs():
|
||||
'OH_BASH_EVENTS_DIR': '/workspace/bash_events',
|
||||
'OH_VSCODE_PORT': '60001',
|
||||
},
|
||||
working_dir='/workspace/projects',
|
||||
working_dir='/workspace/project',
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from openhands.sdk.utils.models import DiscriminatedUnionMixin
|
||||
|
||||
# The version of the agent server to use for deployments.
|
||||
# Typically this will be the same as the values from the pyproject.toml
|
||||
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:ce0a71a-python'
|
||||
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:3d8af53-python'
|
||||
|
||||
|
||||
class SandboxSpecService(ABC):
|
||||
|
||||
Generated
+43
-32
@@ -254,20 +254,19 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "anthropic"
|
||||
version = "0.72.0"
|
||||
version = "0.59.0"
|
||||
description = "The official Python library for the anthropic API"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "anthropic-0.72.0-py3-none-any.whl", hash = "sha256:0e9f5a7582f038cab8efbb4c959e49ef654a56bfc7ba2da51b5a7b8a84de2e4d"},
|
||||
{file = "anthropic-0.72.0.tar.gz", hash = "sha256:8971fe76dcffc644f74ac3883069beb1527641115ae0d6eb8fa21c1ce4082f7a"},
|
||||
{file = "anthropic-0.59.0-py3-none-any.whl", hash = "sha256:cbc8b3dccef66ad6435c4fa1d317e5ebb092399a4b88b33a09dc4bf3944c3183"},
|
||||
{file = "anthropic-0.59.0.tar.gz", hash = "sha256:d710d1ef0547ebbb64b03f219e44ba078e83fc83752b96a9b22e9726b523fd8f"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
anyio = ">=3.5.0,<5"
|
||||
distro = ">=1.7.0,<2"
|
||||
docstring-parser = ">=0.15,<1"
|
||||
google-auth = {version = ">=2,<3", extras = ["requests"], optional = true, markers = "extra == \"vertex\""}
|
||||
httpx = ">=0.25.0,<1"
|
||||
jiter = ">=0.4.0,<1"
|
||||
@@ -276,7 +275,7 @@ sniffio = "*"
|
||||
typing-extensions = ">=4.10,<5"
|
||||
|
||||
[package.extras]
|
||||
aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.9)"]
|
||||
aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.8)"]
|
||||
bedrock = ["boto3 (>=1.28.57)", "botocore (>=1.31.57)"]
|
||||
vertex = ["google-auth[requests] (>=2,<3)"]
|
||||
|
||||
@@ -1205,19 +1204,19 @@ botocore = ["botocore"]
|
||||
|
||||
[[package]]
|
||||
name = "browser-use"
|
||||
version = "0.8.0"
|
||||
version = "0.7.10"
|
||||
description = "Make websites accessible for AI agents"
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.11"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "browser_use-0.8.0-py3-none-any.whl", hash = "sha256:b7c299e38ec1c1aec42a236cc6ad2268a366226940d6ff9d88ed461afd5a1cc3"},
|
||||
{file = "browser_use-0.8.0.tar.gz", hash = "sha256:2136eb3251424f712a08ee379c9337237c2f93b29b566807db599cf94e6abb5e"},
|
||||
{file = "browser_use-0.7.10-py3-none-any.whl", hash = "sha256:669e12571a0c0c4c93e5fd26abf9e2534eb9bacbc510328aedcab795bd8906a9"},
|
||||
{file = "browser_use-0.7.10.tar.gz", hash = "sha256:f93ce59e06906c12d120360dee4aa33d83618ddf7c9a575dd0ac517d2de7ccbc"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
aiohttp = "3.12.15"
|
||||
anthropic = ">=0.68.1,<1.0.0"
|
||||
anthropic = ">=0.58.2,<1.0.0"
|
||||
anyio = ">=4.9.0"
|
||||
authlib = ">=1.6.0"
|
||||
bubus = ">=1.5.6"
|
||||
@@ -1249,11 +1248,11 @@ typing-extensions = ">=4.12.2"
|
||||
uuid7 = ">=0.1.0"
|
||||
|
||||
[package.extras]
|
||||
all = ["agentmail (==0.0.59)", "boto3 (>=1.38.45)", "botocore (>=1.37.23)", "click (>=8.1.8)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)", "rich (>=14.0.0)", "textual (>=3.2.0)"]
|
||||
all = ["agentmail (>=0.0.53)", "boto3 (>=1.38.45)", "botocore (>=1.37.23)", "click (>=8.1.8)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)", "rich (>=14.0.0)", "textual (>=3.2.0)"]
|
||||
aws = ["boto3 (>=1.38.45)"]
|
||||
cli = ["click (>=8.1.8)", "rich (>=14.0.0)", "textual (>=3.2.0)"]
|
||||
eval = ["anyio (>=4.9.0)", "browserbase (==1.4.0)", "datamodel-code-generator (>=0.26.0)", "hyperbrowser (==0.47.0)", "lmnr[all] (==0.7.17)", "psutil (>=7.0.0)"]
|
||||
examples = ["agentmail (==0.0.59)", "botocore (>=1.37.23)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)"]
|
||||
eval = ["anyio (>=4.9.0)", "browserbase (==1.4.0)", "datamodel-code-generator (>=0.26.0)", "hyperbrowser (==0.47.0)", "lmnr[all] (==0.7.10)", "psutil (>=7.0.0)"]
|
||||
examples = ["agentmail (>=0.0.53)", "botocore (>=1.37.23)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)"]
|
||||
video = ["imageio[ffmpeg] (>=2.37.0)", "numpy (>=2.3.2)"]
|
||||
|
||||
[[package]]
|
||||
@@ -5712,11 +5711,8 @@ files = [
|
||||
{file = "lxml-5.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:7ce1a171ec325192c6a636b64c94418e71a1964f56d002cc28122fceff0b6121"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:795f61bcaf8770e1b37eec24edf9771b307df3af74d1d6f27d812e15a9ff3872"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29f451a4b614a7b5b6c2e043d7b64a15bd8304d7e767055e8ab68387a8cacf4e"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:891f7f991a68d20c75cb13c5c9142b2a3f9eb161f1f12a9489c82172d1f133c0"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4aa412a82e460571fad592d0f93ce9935a20090029ba08eca05c614f99b0cc92"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:ac7ba71f9561cd7d7b55e1ea5511543c0282e2b6450f122672a2694621d63b7e"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:c5d32f5284012deaccd37da1e2cd42f081feaa76981f0eaa474351b68df813c5"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:ce31158630a6ac85bddd6b830cffd46085ff90498b397bd0a259f59d27a12188"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:31e63621e073e04697c1b2d23fcb89991790eef370ec37ce4d5d469f40924ed6"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-win32.whl", hash = "sha256:be2ba4c3c5b7900246a8f866580700ef0d538f2ca32535e991027bdaba944063"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:09846782b1ef650b321484ad429217f5154da4d6e786636c38e434fa32e94e49"},
|
||||
@@ -7276,15 +7272,13 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
|
||||
|
||||
[[package]]
|
||||
name = "openhands-agent-server"
|
||||
version = "1.0.0a5"
|
||||
version = "1.0.0a4"
|
||||
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_agent_server-1.0.0a5-py3-none-any.whl", hash = "sha256:823fecd33fd45ba64acc6960beda24df2af6520c26c8c110564d0e3679c53186"},
|
||||
{file = "openhands_agent_server-1.0.0a5.tar.gz", hash = "sha256:65458923905f215666e59654e47f124e4c597fe982ede7d54184c8795d810a35"},
|
||||
]
|
||||
files = []
|
||||
develop = false
|
||||
|
||||
[package.dependencies]
|
||||
aiosqlite = ">=0.19"
|
||||
@@ -7297,17 +7291,22 @@ uvicorn = ">=0.31.1"
|
||||
websockets = ">=12"
|
||||
wsproto = ">=1.2.0"
|
||||
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/OpenHands/agent-sdk.git"
|
||||
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
subdirectory = "openhands-agent-server"
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.0.0a5"
|
||||
version = "1.0.0a4"
|
||||
description = "OpenHands SDK - Core functionality for building AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_sdk-1.0.0a5-py3-none-any.whl", hash = "sha256:db20272b04cf03627f9f7d1e87992078ac4ce15d188955a2962aa9e754d0af03"},
|
||||
{file = "openhands_sdk-1.0.0a5.tar.gz", hash = "sha256:8888d6892d58cf9b11a71fa80086156c0b6c9a0b50df6839c0a9cafffba2338c"},
|
||||
]
|
||||
files = []
|
||||
develop = false
|
||||
|
||||
[package.dependencies]
|
||||
fastmcp = ">=2.11.3"
|
||||
@@ -7322,28 +7321,40 @@ websockets = ">=12"
|
||||
[package.extras]
|
||||
boto3 = ["boto3 (>=1.35.0)"]
|
||||
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/OpenHands/agent-sdk.git"
|
||||
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
subdirectory = "openhands-sdk"
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.0.0a5"
|
||||
version = "1.0.0a4"
|
||||
description = "OpenHands Tools - Runtime tools for AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_tools-1.0.0a5-py3-none-any.whl", hash = "sha256:74c27e23e6adc9a0bad00e32448bd4872019ce0786474e8de2fbf2d7c0887e8e"},
|
||||
{file = "openhands_tools-1.0.0a5.tar.gz", hash = "sha256:6c67454e612596e95c5151267659ddd3b633a5d4a1b70b348f7f913c62146562"},
|
||||
]
|
||||
files = []
|
||||
develop = false
|
||||
|
||||
[package.dependencies]
|
||||
bashlex = ">=0.18"
|
||||
binaryornot = ">=0.4.4"
|
||||
browser-use = ">=0.8.0"
|
||||
browser-use = ">=0.7.7"
|
||||
cachetools = "*"
|
||||
func-timeout = ">=4.3.5"
|
||||
libtmux = ">=0.46.2"
|
||||
openhands-sdk = "*"
|
||||
pydantic = ">=2.11.7"
|
||||
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/OpenHands/agent-sdk.git"
|
||||
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
subdirectory = "openhands-tools"
|
||||
|
||||
[[package]]
|
||||
name = "openpyxl"
|
||||
version = "3.1.5"
|
||||
@@ -16510,4 +16521,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "f2234ef5fb5e97bc187d433eae9fcab8903a830d6557fb3926b0c3f37730dd17"
|
||||
content-hash = "88c894ef3b6bb22b5e0f0dd92f3cede5f4145cb5b52d1970ff0e1d1780e7a4c9"
|
||||
|
||||
+6
-6
@@ -113,12 +113,12 @@ e2b-code-interpreter = { version = "^2.0.0", optional = true }
|
||||
pybase62 = "^1.0.0"
|
||||
|
||||
# V1 dependencies
|
||||
#openhands-agent-server = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-agent-server", rev = "ce0a71af55dfce101f7419fbdb0116178f01e109" }
|
||||
#openhands-sdk = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-sdk", rev = "ce0a71af55dfce101f7419fbdb0116178f01e109" }
|
||||
#openhands-tools = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-tools", rev = "ce0a71af55dfce101f7419fbdb0116178f01e109" }
|
||||
openhands-sdk = "1.0.0a5"
|
||||
openhands-agent-server = "1.0.0a5"
|
||||
openhands-tools = "1.0.0a5"
|
||||
openhands-agent-server = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-agent-server", rev = "3d8af53b2f0259dc98555a4acd4238f90e0afbce" }
|
||||
openhands-sdk = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-sdk", rev = "3d8af53b2f0259dc98555a4acd4238f90e0afbce" }
|
||||
openhands-tools = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-tools", rev = "3d8af53b2f0259dc98555a4acd4238f90e0afbce" }
|
||||
#openhands-sdk = "1.0.0a5"
|
||||
#openhands-agent-server = "1.0.0a5"
|
||||
#openhands-tools = "1.0.0a5"
|
||||
python-jose = { version = ">=3.3", extras = [ "cryptography" ] }
|
||||
sqlalchemy = { extras = [ "asyncio" ], version = "^2.0.40" }
|
||||
pg8000 = "^1.31.5"
|
||||
|
||||
@@ -1582,16 +1582,15 @@ async def test_condenser_metrics_included(mock_agent_with_stats, test_event_stre
|
||||
# Attach the condenser to the mock_agent
|
||||
mock_agent.condenser = condenser
|
||||
|
||||
# Create a real CondensationAction
|
||||
action = CondensationAction(
|
||||
forgotten_events_start_id=1,
|
||||
forgotten_events_end_id=5,
|
||||
summary='Test summary',
|
||||
summary_offset=1,
|
||||
)
|
||||
action._source = EventSource.AGENT # Required for event_stream.add_event
|
||||
|
||||
def agent_step_fn(state):
|
||||
# Create a new CondensationAction each time to avoid ID reuse
|
||||
action = CondensationAction(
|
||||
forgotten_events_start_id=1,
|
||||
forgotten_events_end_id=5,
|
||||
summary='Test summary',
|
||||
summary_offset=1,
|
||||
)
|
||||
action._source = EventSource.AGENT # Required for event_stream.add_event
|
||||
return action
|
||||
|
||||
mock_agent.step = agent_step_fn
|
||||
|
||||
Reference in New Issue
Block a user