mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1cd8dd41f | |||
| 2a7fbcd519 | |||
| 6ef82e1501 | |||
| 3302c31c60 |
@@ -40,7 +40,7 @@ repos:
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies:
|
||||
[types-requests, types-setuptools, types-pyyaml, types-toml, types-docker, types-Markdown, pydantic, lxml]
|
||||
[types-requests, types-setuptools, types-pyyaml, types-toml, types-docker, pydantic, lxml]
|
||||
# To see gaps add `--html-report mypy-report/`
|
||||
entry: mypy --config-file dev_config/python/mypy.ini openhands/
|
||||
always_run: true
|
||||
|
||||
@@ -67,24 +67,27 @@ description: Complete guide for setting up Linear integration with OpenHands Clo
|
||||
- Sign in with your Git provider (GitHub, GitLab, or BitBucket)
|
||||
- **Important:** Make sure you're signing in with the same Git provider account that contains the repositories you want the OpenHands agent to work on.
|
||||
|
||||
### Step 2: Configure Linear Integration
|
||||
### Step 2: View Available Workspaces
|
||||
|
||||
1. **Access Integration Settings**
|
||||
- Navigate to **Settings** > **Integrations**
|
||||
- Locate **Linear** section
|
||||
|
||||
2. **Configure Workspace**
|
||||
- Click **Configure** button
|
||||
- Enter your workspace name and click **Connect**
|
||||
- If no integration exists, you'll be prompted to enter additional credentials required for the workspace integration:
|
||||
- **Webhook Secret**: The webhook secret from Step 3 above
|
||||
- **Service Account Email**: The service account email from Step 1 above
|
||||
- **Service Account API Key**: The API key from Step 2 above
|
||||
- Ensure **Active** toggle is enabled
|
||||
2. **Install Linear App**
|
||||
- Click the **Install Linear App** button
|
||||
- You'll be redirected to a page showing available Linear workspaces
|
||||
|
||||
3. **Complete OAuth Flow**
|
||||
3. **Select Workspace**
|
||||
- Choose the workspace you want to connect to
|
||||
- If no integration exists, you'll be prompted to enter additional credentials required for the workspace integration:
|
||||
- **Webhook Secret**: The webhook secret from Step 3 above
|
||||
- **Service Account Email**: The service account email from Step 1 above
|
||||
- **Service Account API Key**: The API key from Step 2 above
|
||||
- Ensure **Active** toggle is enabled
|
||||
|
||||
4. **Complete OAuth Flow**
|
||||
- You'll be redirected to Linear to complete OAuth verification
|
||||
- Grant the necessary permissions to verify your workspace access. If you have access to multiple workspaces, select the correct one that you initially provided
|
||||
- Grant the necessary permissions to verify your workspace access
|
||||
- If successful, you will be redirected back to the **Integrations** settings in the OpenHands Cloud UI
|
||||
|
||||
### Managing Your Integration
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ConfigureButton,
|
||||
ConfigureModal,
|
||||
} from "#/components/features/settings/project-management/configure-modal";
|
||||
import { LinearInstallButton } from "#/components/features/settings/project-management/linear-install-button";
|
||||
|
||||
interface IntegrationRowProps {
|
||||
platform: "jira" | "jira-dc" | "linear";
|
||||
@@ -88,23 +89,29 @@ export function IntegrationRow({
|
||||
<div className="flex items-center justify-between" data-testid={dataTestId}>
|
||||
<span className="font-medium">{platformName}</span>
|
||||
<div className="flex items-center gap-6">
|
||||
<ConfigureButton
|
||||
onClick={handleConfigure}
|
||||
isDisabled={isLoading}
|
||||
text={buttonText}
|
||||
data-testid={`${platform}-configure-button`}
|
||||
/>
|
||||
{platform === "linear" ? (
|
||||
<LinearInstallButton data-testid={`${platform}-install-button`} />
|
||||
) : (
|
||||
<ConfigureButton
|
||||
onClick={handleConfigure}
|
||||
isDisabled={isLoading}
|
||||
text={buttonText}
|
||||
data-testid={`${platform}-configure-button`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ConfigureModal
|
||||
isOpen={isConfigureModalOpen}
|
||||
onClose={() => setConfigureModalOpen(false)}
|
||||
onConfirm={handleConfigureConfirm}
|
||||
onLink={handleLink}
|
||||
onUnlink={handleUnlink}
|
||||
platformName={platformName}
|
||||
platform={platform}
|
||||
integrationData={integrationData}
|
||||
/>
|
||||
{platform !== "linear" && (
|
||||
<ConfigureModal
|
||||
isOpen={isConfigureModalOpen}
|
||||
onClose={() => setConfigureModalOpen(false)}
|
||||
onConfirm={handleConfigureConfirm}
|
||||
onLink={handleLink}
|
||||
onUnlink={handleUnlink}
|
||||
platformName={platformName}
|
||||
platform={platform}
|
||||
integrationData={integrationData}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useLinearInstall } from "#/hooks/mutation/use-linear-install";
|
||||
|
||||
interface LinearInstallButtonProps {
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
export function LinearInstallButton({
|
||||
"data-testid": dataTestId,
|
||||
}: LinearInstallButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const linearInstallMutation = useLinearInstall();
|
||||
|
||||
const handleInstallClick = () => {
|
||||
linearInstallMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInstallClick}
|
||||
disabled={linearInstallMutation.isPending}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
{linearInstallMutation.isPending
|
||||
? "Installing..."
|
||||
: t(I18nKey.PROJECT_MANAGEMENT$INSTALL_LINEAR_APP)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { openHands } from "#/api/open-hands-axios";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
|
||||
|
||||
export function useLinearInstall() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await openHands.post(
|
||||
"/integration/linear/workspaces/link",
|
||||
{},
|
||||
);
|
||||
|
||||
const { success, redirect, authorizationUrl } = response.data;
|
||||
|
||||
if (success) {
|
||||
if (redirect) {
|
||||
if (authorizationUrl) {
|
||||
window.location.href = authorizationUrl;
|
||||
} else {
|
||||
throw new Error("Could not get authorization URL from the server.");
|
||||
}
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
} else {
|
||||
throw new Error("Linear installation failed");
|
||||
}
|
||||
|
||||
return response.data;
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = retrieveAxiosErrorMessage(error);
|
||||
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -747,6 +747,7 @@ export enum I18nKey {
|
||||
PROJECT_MANAGEMENT$UNLINK_BUTTON_LABEL = "PROJECT_MANAGEMENT$UNLINK_BUTTON_LABEL",
|
||||
PROJECT_MANAGEMENT$LINK_CONFIRMATION_TITLE = "PROJECT_MANAGEMENT$LINK_CONFIRMATION_TITLE",
|
||||
PROJECT_MANAGEMENT$CONFIGURE_BUTTON_LABEL = "PROJECT_MANAGEMENT$CONFIGURE_BUTTON_LABEL",
|
||||
PROJECT_MANAGEMENT$INSTALL_LINEAR_APP = "PROJECT_MANAGEMENT$INSTALL_LINEAR_APP",
|
||||
PROJECT_MANAGEMENT$EDIT_BUTTON_LABEL = "PROJECT_MANAGEMENT$EDIT_BUTTON_LABEL",
|
||||
PROJECT_MANAGEMENT$WORKSPACE_NAME_LABEL = "PROJECT_MANAGEMENT$WORKSPACE_NAME_LABEL",
|
||||
PROJECT_MANAGEMENT$WORKSPACE_NAME_PLACEHOLDER = "PROJECT_MANAGEMENT$WORKSPACE_NAME_PLACEHOLDER",
|
||||
|
||||
@@ -11951,6 +11951,13 @@
|
||||
"de": "Konfigurieren",
|
||||
"uk": "Налаштувати"
|
||||
},
|
||||
"PROJECT_MANAGEMENT$INSTALL_LINEAR_APP": {
|
||||
"en": "Install Linear App",
|
||||
"ja": "Linear アプリをインストール",
|
||||
"zh-CN": "安装 Linear 应用",
|
||||
"zh-TW": "安裝 Linear 應用",
|
||||
"ko-KR": "Linear 앱 설치"
|
||||
},
|
||||
"PROJECT_MANAGEMENT$EDIT_BUTTON_LABEL": {
|
||||
"en": "Edit",
|
||||
"ja": "編集",
|
||||
|
||||
+10
-98
@@ -6,13 +6,11 @@ import asyncio
|
||||
import contextlib
|
||||
import datetime
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Generator
|
||||
|
||||
import markdown # type: ignore
|
||||
from prompt_toolkit import PromptSession, print_formatted_text
|
||||
from prompt_toolkit.application import Application
|
||||
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
|
||||
@@ -67,7 +65,6 @@ MAX_RECENT_THOUGHTS = 5
|
||||
# Color and styling constants
|
||||
COLOR_GOLD = '#FFD700'
|
||||
COLOR_GREY = '#808080'
|
||||
COLOR_AGENT_BLUE = '#4682B4' # Steel blue - less saturated, works well on both light and dark backgrounds
|
||||
DEFAULT_STYLE = Style.from_dict(
|
||||
{
|
||||
'gold': COLOR_GOLD,
|
||||
@@ -239,19 +236,13 @@ def display_mcp_errors() -> None:
|
||||
|
||||
|
||||
# Prompt output display functions
|
||||
def display_thought_if_new(thought: str, is_agent_message: bool = False) -> None:
|
||||
"""
|
||||
Display a thought only if it hasn't been displayed recently.
|
||||
|
||||
Args:
|
||||
thought: The thought to display
|
||||
is_agent_message: If True, apply agent styling and markdown rendering
|
||||
"""
|
||||
def display_thought_if_new(thought: str) -> None:
|
||||
"""Display a thought only if it hasn't been displayed recently."""
|
||||
global recent_thoughts
|
||||
if thought and thought.strip():
|
||||
# Check if this thought was recently displayed
|
||||
if thought not in recent_thoughts:
|
||||
display_message(thought, is_agent_message=is_agent_message)
|
||||
display_message(thought)
|
||||
recent_thoughts.append(thought)
|
||||
# Keep only the most recent thoughts
|
||||
if len(recent_thoughts) > MAX_RECENT_THOUGHTS:
|
||||
@@ -264,7 +255,7 @@ def display_event(event: Event, config: OpenHandsConfig) -> None:
|
||||
if isinstance(event, CmdRunAction):
|
||||
# For CmdRunAction, display thought first, then command
|
||||
if hasattr(event, 'thought') and event.thought:
|
||||
display_thought_if_new(event.thought)
|
||||
display_message(event.thought)
|
||||
|
||||
# Only display the command if it's not already confirmed
|
||||
# Commands are always shown when AWAITING_CONFIRMATION, so we don't need to show them again when CONFIRMED
|
||||
@@ -278,15 +269,14 @@ def display_event(event: Event, config: OpenHandsConfig) -> None:
|
||||
elif isinstance(event, Action):
|
||||
# For other actions, display thoughts normally
|
||||
if hasattr(event, 'thought') and event.thought:
|
||||
display_thought_if_new(event.thought)
|
||||
display_message(event.thought)
|
||||
if hasattr(event, 'final_thought') and event.final_thought:
|
||||
# Display final thoughts with agent styling
|
||||
display_message(event.final_thought, is_agent_message=True)
|
||||
display_message(event.final_thought)
|
||||
|
||||
if isinstance(event, MessageAction):
|
||||
if event.source == EventSource.AGENT:
|
||||
# Display agent messages with styling and markdown rendering
|
||||
display_thought_if_new(event.content, is_agent_message=True)
|
||||
# Check if this message content is a duplicate thought
|
||||
display_thought_if_new(event.content)
|
||||
elif isinstance(event, CmdOutputObservation):
|
||||
display_command_output(event.content)
|
||||
elif isinstance(event, FileEditObservation):
|
||||
@@ -301,89 +291,11 @@ def display_event(event: Event, config: OpenHandsConfig) -> None:
|
||||
display_error(event.content)
|
||||
|
||||
|
||||
def display_message(message: str, is_agent_message: bool = False) -> None:
|
||||
"""
|
||||
Display a message in the terminal with markdown rendering.
|
||||
|
||||
Args:
|
||||
message: The message to display
|
||||
is_agent_message: If True, apply agent styling (blue color)
|
||||
"""
|
||||
def display_message(message: str) -> None:
|
||||
message = message.strip()
|
||||
|
||||
if message:
|
||||
# Add spacing before the message
|
||||
print_formatted_text('')
|
||||
|
||||
try:
|
||||
# Convert markdown to HTML for all messages
|
||||
html_content = convert_markdown_to_html(message)
|
||||
|
||||
if is_agent_message:
|
||||
# Use prompt_toolkit's HTML renderer with the agent color
|
||||
print_formatted_text(
|
||||
HTML(f'<style fg="{COLOR_AGENT_BLUE}">{html_content}</style>')
|
||||
)
|
||||
else:
|
||||
# Regular message display with HTML rendering but default color
|
||||
print_formatted_text(HTML(html_content))
|
||||
except Exception as e:
|
||||
# If HTML rendering fails, fall back to plain text
|
||||
print(f'Warning: HTML rendering failed: {str(e)}', file=sys.stderr)
|
||||
if is_agent_message:
|
||||
print_formatted_text(
|
||||
FormattedText([('fg:' + COLOR_AGENT_BLUE, message)])
|
||||
)
|
||||
else:
|
||||
print_formatted_text(message)
|
||||
|
||||
# Add spacing after the message
|
||||
print_formatted_text('')
|
||||
|
||||
|
||||
def display_agent_message(message: str) -> None:
|
||||
"""
|
||||
Display an agent message in the terminal with markdown rendering and agent styling.
|
||||
|
||||
Args:
|
||||
message: The message to display
|
||||
"""
|
||||
display_message(message, is_agent_message=True)
|
||||
|
||||
|
||||
def convert_markdown_to_html(text: str) -> str:
|
||||
"""
|
||||
Convert markdown to HTML for prompt_toolkit's HTML renderer using the markdown library.
|
||||
|
||||
Args:
|
||||
text: Markdown text to convert
|
||||
|
||||
Returns:
|
||||
HTML formatted text with custom styling for headers and bullet points
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
# Use the markdown library to convert markdown to HTML
|
||||
# Enable the 'extra' extension for tables, fenced code, etc.
|
||||
html = markdown.markdown(text, extensions=['extra'])
|
||||
|
||||
# Customize headers
|
||||
for i in range(1, 7):
|
||||
# Get the appropriate number of # characters for this heading level
|
||||
prefix = '#' * i + ' '
|
||||
|
||||
# Replace <h1> with the prefix and bold text
|
||||
html = html.replace(f'<h{i}>', f'<b>{prefix}')
|
||||
html = html.replace(f'</h{i}>', '</b>\n')
|
||||
|
||||
# Customize bullet points to use dashes instead of dots with compact spacing
|
||||
html = html.replace('<ul>', '')
|
||||
html = html.replace('</ul>', '')
|
||||
html = html.replace('<li>', '- ')
|
||||
html = html.replace('</li>', '')
|
||||
|
||||
return html
|
||||
print_formatted_text(f'\n{message}')
|
||||
|
||||
|
||||
def display_error(error: str) -> None:
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
"""
|
||||
LiteLLM currently have an issue where HttpHandlers are being created but not
|
||||
closed. We have submitted a PR to them, (https://github.com/BerriAI/litellm/pull/8711)
|
||||
and their dev team say they are in the process of a refactor that will fix this, but
|
||||
in the meantime, we need to manage the lifecycle of the httpx.Client manually.
|
||||
|
||||
We can't simply pass in our own client object, because all the different implementations use
|
||||
different types of client object.
|
||||
|
||||
So we monkey patch the httpx.Client class to track newly created instances and close these
|
||||
when the operations complete. (Since some paths create a single shared client and reuse these,
|
||||
we actually need to create a proxy object that allows these clients to be reusable.)
|
||||
|
||||
Hopefully, this will be fixed soon and we can remove this abomination.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
from typing import Callable
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def ensure_httpx_close():
|
||||
wrapped_class = httpx.Client
|
||||
proxys = []
|
||||
|
||||
class ClientProxy:
|
||||
"""
|
||||
Sometimes LiteLLM opens a new httpx client for each connection, and does not close them.
|
||||
Sometimes it does close them. Sometimes, it reuses a client between connections. For cases
|
||||
where a client is reused, we need to be able to reuse the client even after closing it.
|
||||
"""
|
||||
|
||||
client_constructor: Callable
|
||||
args: tuple
|
||||
kwargs: dict
|
||||
client: httpx.Client
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
self.client = wrapped_class(*self.args, **self.kwargs)
|
||||
proxys.append(self)
|
||||
|
||||
def __getattr__(self, name):
|
||||
# Invoke a method on the proxied client - create one if required
|
||||
if self.client is None:
|
||||
self.client = wrapped_class(*self.args, **self.kwargs)
|
||||
return getattr(self.client, name)
|
||||
|
||||
def close(self):
|
||||
# Close the client if it is open
|
||||
if self.client:
|
||||
self.client.close()
|
||||
self.client = None
|
||||
|
||||
def __iter__(self, *args, **kwargs):
|
||||
# We have to override this as debuggers invoke it causing the client to reopen
|
||||
if self.client:
|
||||
return self.client.iter(*args, **kwargs)
|
||||
return object.__getattribute__(self, 'iter')(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def is_closed(self):
|
||||
# Check if closed
|
||||
if self.client is None:
|
||||
return True
|
||||
return self.client.is_closed
|
||||
|
||||
httpx.Client = ClientProxy
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
httpx.Client = wrapped_class
|
||||
while proxys:
|
||||
proxy = proxys.pop()
|
||||
proxy.close()
|
||||
Generated
+2
-33
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "aiofiles"
|
||||
@@ -5152,11 +5152,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"},
|
||||
@@ -5230,22 +5227,6 @@ files = [
|
||||
[package.dependencies]
|
||||
cobble = ">=0.1.3,<0.2"
|
||||
|
||||
[[package]]
|
||||
name = "markdown"
|
||||
version = "3.8.2"
|
||||
description = "Python implementation of John Gruber's Markdown."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "markdown-3.8.2-py3-none-any.whl", hash = "sha256:5c83764dbd4e00bdd94d85a19b8d55ccca20fe35b2e678a1422b380324dd5f24"},
|
||||
{file = "markdown-3.8.2.tar.gz", hash = "sha256:247b9a70dd12e27f67431ce62523e675b866d254f900c4fe75ce3dda62237c45"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"]
|
||||
testing = ["coverage", "pyyaml"]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "3.0.0"
|
||||
@@ -10465,18 +10446,6 @@ files = [
|
||||
]
|
||||
markers = {main = "extra == \"third-party-runtimes\""}
|
||||
|
||||
[[package]]
|
||||
name = "types-markdown"
|
||||
version = "3.8.0.20250809"
|
||||
description = "Typing stubs for Markdown"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "types_markdown-3.8.0.20250809-py3-none-any.whl", hash = "sha256:3f34a38c2259a3158e90ab0cb058cd8f4fdd3d75e2a0b335cb57f25dc2bc77d3"},
|
||||
{file = "types_markdown-3.8.0.20250809.tar.gz", hash = "sha256:fa619e735878a244332a4bbe16bcfc44e49ff6264c2696056278f0642cdfa223"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-python-dateutil"
|
||||
version = "2.9.0.20250516"
|
||||
@@ -11797,4 +11766,4 @@ third-party-runtimes = ["daytona", "e2b", "modal", "runloop-api-client"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "9fd177a2dfa1eebb9212e515db93c58f82d6126cc2d131de5321d68772bc2a59"
|
||||
content-hash = "8568c6ec2e11d4fcb23e206a24896b4d2d50e694c04011b668148f484e95b406"
|
||||
|
||||
@@ -42,7 +42,6 @@ numpy = "*"
|
||||
json-repair = "*"
|
||||
browsergym-core = "0.13.3" # integrate browsergym-core as the browsing interface
|
||||
html2text = "*"
|
||||
markdown = "*" # For markdown to HTML conversion
|
||||
deprecated = "*"
|
||||
pexpect = "*"
|
||||
jinja2 = "^3.1.3"
|
||||
@@ -115,7 +114,6 @@ pre-commit = "4.2.0"
|
||||
build = "*"
|
||||
types-setuptools = "*"
|
||||
pytest = "^8.4.0"
|
||||
types-markdown = "^3.8.0.20250809"
|
||||
|
||||
[tool.poetry.group.test]
|
||||
optional = true
|
||||
|
||||
@@ -15,10 +15,10 @@ from openhands.events.action.message import MessageAction
|
||||
class TestThoughtDisplayOrder:
|
||||
"""Test that thoughts are displayed in the correct order relative to commands."""
|
||||
|
||||
@patch('openhands.cli.tui.display_thought_if_new')
|
||||
@patch('openhands.cli.tui.display_message')
|
||||
@patch('openhands.cli.tui.display_command')
|
||||
def test_cmd_run_action_thought_before_command(
|
||||
self, mock_display_command, mock_display_thought_if_new
|
||||
self, mock_display_command, mock_display_message
|
||||
):
|
||||
"""Test that for CmdRunAction, thought is displayed before command."""
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
@@ -32,8 +32,8 @@ class TestThoughtDisplayOrder:
|
||||
|
||||
display_event(cmd_action, config)
|
||||
|
||||
# Verify that display_thought_if_new (for thought) was called before display_command
|
||||
mock_display_thought_if_new.assert_called_once_with(
|
||||
# Verify that display_message (for thought) was called before display_command
|
||||
mock_display_message.assert_called_once_with(
|
||||
'I need to install the dependencies first before running the tests.'
|
||||
)
|
||||
mock_display_command.assert_called_once_with(cmd_action)
|
||||
@@ -41,24 +41,21 @@ class TestThoughtDisplayOrder:
|
||||
# Check the call order by examining the mock call history
|
||||
all_calls = []
|
||||
all_calls.extend(
|
||||
[
|
||||
('display_thought_if_new', call)
|
||||
for call in mock_display_thought_if_new.call_args_list
|
||||
]
|
||||
[('display_message', call) for call in mock_display_message.call_args_list]
|
||||
)
|
||||
all_calls.extend(
|
||||
[('display_command', call) for call in mock_display_command.call_args_list]
|
||||
)
|
||||
|
||||
# Sort by the order they were called (this is a simplified check)
|
||||
# In practice, we know display_thought_if_new should be called first based on our code
|
||||
assert mock_display_thought_if_new.called
|
||||
# In practice, we know display_message should be called first based on our code
|
||||
assert mock_display_message.called
|
||||
assert mock_display_command.called
|
||||
|
||||
@patch('openhands.cli.tui.display_thought_if_new')
|
||||
@patch('openhands.cli.tui.display_message')
|
||||
@patch('openhands.cli.tui.display_command')
|
||||
def test_cmd_run_action_no_thought(
|
||||
self, mock_display_command, mock_display_thought_if_new
|
||||
self, mock_display_command, mock_display_message
|
||||
):
|
||||
"""Test that CmdRunAction without thought only displays command."""
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
@@ -69,14 +66,14 @@ class TestThoughtDisplayOrder:
|
||||
|
||||
display_event(cmd_action, config)
|
||||
|
||||
# Verify that display_thought_if_new was not called (no thought)
|
||||
mock_display_thought_if_new.assert_not_called()
|
||||
# Verify that display_message was not called (no thought)
|
||||
mock_display_message.assert_not_called()
|
||||
mock_display_command.assert_called_once_with(cmd_action)
|
||||
|
||||
@patch('openhands.cli.tui.display_thought_if_new')
|
||||
@patch('openhands.cli.tui.display_message')
|
||||
@patch('openhands.cli.tui.display_command')
|
||||
def test_cmd_run_action_empty_thought(
|
||||
self, mock_display_command, mock_display_thought_if_new
|
||||
self, mock_display_command, mock_display_message
|
||||
):
|
||||
"""Test that CmdRunAction with empty thought only displays command."""
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
@@ -87,15 +84,15 @@ class TestThoughtDisplayOrder:
|
||||
|
||||
display_event(cmd_action, config)
|
||||
|
||||
# Verify that display_thought_if_new was not called (empty thought)
|
||||
mock_display_thought_if_new.assert_not_called()
|
||||
# Verify that display_message was not called (empty thought)
|
||||
mock_display_message.assert_not_called()
|
||||
mock_display_command.assert_called_once_with(cmd_action)
|
||||
|
||||
@patch('openhands.cli.tui.display_thought_if_new')
|
||||
@patch('openhands.cli.tui.display_message')
|
||||
@patch('openhands.cli.tui.display_command')
|
||||
@patch('openhands.cli.tui.initialize_streaming_output')
|
||||
def test_cmd_run_action_confirmed_no_display(
|
||||
self, mock_init_streaming, mock_display_command, mock_display_thought_if_new
|
||||
self, mock_init_streaming, mock_display_command, mock_display_message
|
||||
):
|
||||
"""Test that confirmed CmdRunAction doesn't display command again but initializes streaming."""
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
@@ -110,7 +107,7 @@ class TestThoughtDisplayOrder:
|
||||
display_event(cmd_action, config)
|
||||
|
||||
# Verify that thought is still displayed
|
||||
mock_display_thought_if_new.assert_called_once_with(
|
||||
mock_display_message.assert_called_once_with(
|
||||
'I need to install the dependencies first before running the tests.'
|
||||
)
|
||||
# But command should not be displayed again (already shown when awaiting confirmation)
|
||||
@@ -118,8 +115,8 @@ class TestThoughtDisplayOrder:
|
||||
# Streaming should be initialized
|
||||
mock_init_streaming.assert_called_once()
|
||||
|
||||
@patch('openhands.cli.tui.display_thought_if_new')
|
||||
def test_other_action_thought_display(self, mock_display_thought_if_new):
|
||||
@patch('openhands.cli.tui.display_message')
|
||||
def test_other_action_thought_display(self, mock_display_message):
|
||||
"""Test that other Action types still display thoughts normally."""
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
|
||||
@@ -130,13 +127,13 @@ class TestThoughtDisplayOrder:
|
||||
display_event(action, config)
|
||||
|
||||
# Verify that thought is displayed
|
||||
mock_display_thought_if_new.assert_called_once_with(
|
||||
mock_display_message.assert_called_once_with(
|
||||
'This is a thought for a generic action.'
|
||||
)
|
||||
|
||||
@patch('openhands.cli.tui.display_message')
|
||||
def test_other_action_final_thought_display(self, mock_display_message):
|
||||
"""Test that other Action types display final thoughts as agent messages."""
|
||||
"""Test that other Action types display final thoughts."""
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
|
||||
# Create a generic Action with final thought
|
||||
@@ -145,13 +142,11 @@ class TestThoughtDisplayOrder:
|
||||
|
||||
display_event(action, config)
|
||||
|
||||
# Verify that final thought is displayed as an agent message
|
||||
mock_display_message.assert_called_once_with(
|
||||
'This is a final thought.', is_agent_message=True
|
||||
)
|
||||
# Verify that final thought is displayed
|
||||
mock_display_message.assert_called_once_with('This is a final thought.')
|
||||
|
||||
@patch('openhands.cli.tui.display_thought_if_new')
|
||||
def test_message_action_from_agent(self, mock_display_thought_if_new):
|
||||
@patch('openhands.cli.tui.display_message')
|
||||
def test_message_action_from_agent(self, mock_display_message):
|
||||
"""Test that MessageAction from agent is displayed."""
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
|
||||
@@ -161,13 +156,11 @@ class TestThoughtDisplayOrder:
|
||||
|
||||
display_event(message_action, config)
|
||||
|
||||
# Verify that agent message is displayed with agent styling
|
||||
mock_display_thought_if_new.assert_called_once_with(
|
||||
'Hello from agent', is_agent_message=True
|
||||
)
|
||||
# Verify that message is displayed
|
||||
mock_display_message.assert_called_once_with('Hello from agent')
|
||||
|
||||
@patch('openhands.cli.tui.display_thought_if_new')
|
||||
def test_message_action_from_user_not_displayed(self, mock_display_thought_if_new):
|
||||
@patch('openhands.cli.tui.display_message')
|
||||
def test_message_action_from_user_not_displayed(self, mock_display_message):
|
||||
"""Test that MessageAction from user is not displayed."""
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
|
||||
@@ -178,12 +171,12 @@ class TestThoughtDisplayOrder:
|
||||
display_event(message_action, config)
|
||||
|
||||
# Verify that message is not displayed (only agent messages are shown)
|
||||
mock_display_thought_if_new.assert_not_called()
|
||||
mock_display_message.assert_not_called()
|
||||
|
||||
@patch('openhands.cli.tui.display_thought_if_new')
|
||||
@patch('openhands.cli.tui.display_message')
|
||||
@patch('openhands.cli.tui.display_command')
|
||||
def test_cmd_run_action_with_both_thoughts(
|
||||
self, mock_display_command, mock_display_thought_if_new
|
||||
self, mock_display_command, mock_display_message
|
||||
):
|
||||
"""Test CmdRunAction with both thought and final_thought."""
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
@@ -197,7 +190,7 @@ class TestThoughtDisplayOrder:
|
||||
|
||||
# For CmdRunAction, only the regular thought should be displayed
|
||||
# (final_thought is handled by the general Action case, but CmdRunAction is handled first)
|
||||
mock_display_thought_if_new.assert_called_once_with('Initial thought')
|
||||
mock_display_message.assert_called_once_with('Initial thought')
|
||||
mock_display_command.assert_called_once_with(cmd_action)
|
||||
|
||||
|
||||
@@ -211,7 +204,7 @@ class TestThoughtDisplayIntegration:
|
||||
# Track the order of calls
|
||||
call_order = []
|
||||
|
||||
def track_display_message(message, is_agent_message=False):
|
||||
def track_display_message(message):
|
||||
call_order.append(f'THOUGHT: {message}')
|
||||
|
||||
def track_display_command(event):
|
||||
|
||||
@@ -107,14 +107,16 @@ class TestDisplayFunctions:
|
||||
assert 'What do you want to build?' in message_text
|
||||
assert 'Type /help for help' in message_text
|
||||
|
||||
def test_display_event_message_action(self):
|
||||
@patch('openhands.cli.tui.display_message')
|
||||
def test_display_event_message_action(self, mock_display_message):
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
message = MessageAction(content='Test message')
|
||||
message._source = EventSource.AGENT
|
||||
|
||||
# Directly test the function without mocking
|
||||
display_event(message, config)
|
||||
|
||||
mock_display_message.assert_called_once_with('Test message')
|
||||
|
||||
@patch('openhands.cli.tui.display_command')
|
||||
def test_display_event_cmd_action(self, mock_display_command):
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
@@ -170,14 +172,16 @@ class TestDisplayFunctions:
|
||||
|
||||
mock_display_file_read.assert_called_once_with(file_read)
|
||||
|
||||
def test_display_event_thought(self):
|
||||
@patch('openhands.cli.tui.display_message')
|
||||
def test_display_event_thought(self, mock_display_message):
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
action = Action()
|
||||
action.thought = 'Thinking about this...'
|
||||
|
||||
# Directly test the function without mocking
|
||||
display_event(action, config)
|
||||
|
||||
mock_display_message.assert_called_once_with('Thinking about this...')
|
||||
|
||||
@patch('openhands.cli.tui.display_mcp_action')
|
||||
def test_display_event_mcp_action(self, mock_display_mcp_action):
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import httpx
|
||||
|
||||
from openhands.utils.ensure_httpx_close import ensure_httpx_close
|
||||
|
||||
|
||||
def test_ensure_httpx_close_basic():
|
||||
"""Test basic functionality of ensure_httpx_close."""
|
||||
ctx = ensure_httpx_close()
|
||||
with ctx:
|
||||
# Create a client - should be tracked
|
||||
client = httpx.Client()
|
||||
|
||||
# After context exit, client should be closed
|
||||
assert client.is_closed
|
||||
|
||||
|
||||
def test_ensure_httpx_close_multiple_clients():
|
||||
"""Test ensure_httpx_close with multiple clients."""
|
||||
ctx = ensure_httpx_close()
|
||||
with ctx:
|
||||
client1 = httpx.Client()
|
||||
client2 = httpx.Client()
|
||||
|
||||
assert client1.is_closed
|
||||
assert client2.is_closed
|
||||
|
||||
|
||||
def test_ensure_httpx_close_nested():
|
||||
"""Test nested usage of ensure_httpx_close."""
|
||||
with ensure_httpx_close():
|
||||
client1 = httpx.Client()
|
||||
|
||||
with ensure_httpx_close():
|
||||
client2 = httpx.Client()
|
||||
assert not client2.is_closed
|
||||
|
||||
# After inner context, client2 should be closed
|
||||
assert client2.is_closed
|
||||
# client1 should still be open since outer context is still active
|
||||
assert not client1.is_closed
|
||||
|
||||
# After outer context, both clients should be closed
|
||||
assert client1.is_closed
|
||||
assert client2.is_closed
|
||||
|
||||
|
||||
def test_ensure_httpx_close_exception():
|
||||
"""Test ensure_httpx_close when an exception occurs."""
|
||||
client = None
|
||||
try:
|
||||
with ensure_httpx_close():
|
||||
client = httpx.Client()
|
||||
raise ValueError('Test exception')
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Client should be closed even if an exception occurred
|
||||
assert client is not None
|
||||
assert client.is_closed
|
||||
|
||||
|
||||
def test_ensure_httpx_close_restore_client():
|
||||
"""Test that the original client is restored after context exit."""
|
||||
original_client = httpx.Client
|
||||
with ensure_httpx_close():
|
||||
assert httpx.Client != original_client
|
||||
|
||||
# Original __init__ should be restored
|
||||
assert httpx.Client == original_client
|
||||
Reference in New Issue
Block a user