Compare commits

..

4 Commits

Author SHA1 Message Date
openhands a1cd8dd41f Update Linear install hook to not require workspace parameter
- Remove workspace_name parameter from API call
- Send empty object in POST request body
- Backend route will be updated to handle this case
2025-08-11 05:27:59 +00:00
openhands 2a7fbcd519 Refactor LinearInstallButton to use TanStack Query
- Replace direct window.location.href with TanStack Query mutation
- Add useLinearInstall hook for proper state management
- Add loading state and disabled button during installation
- Maintain same functionality while following React Query patterns
- Add proper error handling through TanStack Query
2025-08-11 05:23:05 +00:00
openhands 6ef82e1501 Update Linear integration UI and documentation 2025-08-11 02:46:24 +00:00
Tim O'Farrell 3302c31c60 Removed Hack that is no longer required (#10195) 2025-08-10 12:13:19 -06:00
14 changed files with 176 additions and 354 deletions
+1 -1
View File
@@ -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>
);
}
@@ -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));
},
});
}
+1
View File
@@ -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",
+7
View File
@@ -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
View File
@@ -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:
-78
View File
@@ -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
View File
@@ -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"
-2
View File
@@ -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
+35 -42
View File
@@ -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):
+8 -4
View File
@@ -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)
-69
View File
@@ -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