Compare commits

..

10 Commits

Author SHA1 Message Date
rohitvinodmalhotra@gmail.com e5ea2ac478 Update pyproject.toml 2025-10-31 09:49:27 -04:00
Rohit Malhotra 3239eb4027 Hotfix(CLI): Update README to use V1 CLI for serve command and point to new docker image artifacts (#11584) 2025-10-31 09:34:19 -04:00
Rohit Malhotra 9be673d553 CLI: Create conversation last minute (#11576)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-10-30 23:04:41 +00:00
Tim O'Farrell 7272eae758 Fix remote sandbox permissions (#11582) 2025-10-30 22:13:02 +00:00
mamoodi ec670cd130 Rename LLM API Key to OpenHands LLM Key in settings (#11577)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-30 16:52:31 -04:00
Hiep Le 31702bf46b fix(frontend): delays in updating conversation titles before they are reflected in the user interface. (#11558)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-10-30 18:06:18 +00:00
Tim O'Farrell 5894d2675e V1 IDs without hyphens (#11564) 2025-10-30 16:33:16 +00:00
Hiep Le 59a992c0fb feat(frontend): allow all users to access the LLM page and disable Pro subscription functionality (#11573) 2025-10-30 22:01:30 +07:00
Rohit Malhotra 1939bd0fda CLI Release 1.0.3 (#11574) 2025-10-30 14:39:42 +00:00
Ray Myers 58e690ef75 Fix flaky test_condenser_metrics_included by creating new action objects (#11555)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-30 09:20:06 -05:00
30 changed files with 395 additions and 897 deletions
+2 -2
View File
@@ -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)!
+9 -9
View File
@@ -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();
});
});
});
});
+5 -413
View File
@@ -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) => {
+14 -14
View File
@@ -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.",
+2 -47
View File
@@ -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>
+2 -4
View File
@@ -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">
+37 -10
View File
@@ -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
+2 -2
View File
@@ -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>'))
+33 -51
View File
@@ -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)
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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