Compare commits

...

27 Commits

Author SHA1 Message Date
openhands 3587bdc61e Fix git configuration for OpenHands Cloud environments
- Add environment variables as fallback for git user configuration
- Implement fallback strategy: try global config first, then local config file
- Add verification commands to ensure git config is applied
- Enhance error handling and logging for git configuration commands
- Add debug endpoint /debug/git-config for troubleshooting
- Improve test coverage for git configuration scenarios

Fixes issue where git user.name and user.email were not being set
properly in cloud/containerized environments due to permission issues
with global git configuration.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-13 10:43:42 +00:00
Hiep Le 11f364c5e4 fix(frontend): UI does not display triggered microagent knowledge well. (#10277) 2025-08-13 14:31:05 +08:00
Jesse 4e3a862571 Add llm disable stop word env var (#10274)
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2025-08-13 03:52:11 +00:00
Xingyao Wang 50aa014876 refactor(prompt): Consolidate system prompts with Jinja inheritance (#9797)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-13 03:04:08 +00:00
olyashok 500ab46918 Supprot for named volumes in docker_runtime (#10268) 2025-08-12 21:18:53 +00:00
Mislav Lukach e311f3e70f fix(ui): increase settings page spacing (#10202) 2025-08-12 22:04:47 +04:00
Mislav Lukach f68ad3695c Feat/maintenance banner dismissible (#10072)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-08-12 22:02:36 +04:00
mamoodi ed711318e4 Hide git settings again (#10261) 2025-08-12 17:30:57 +00:00
Calvin Smith 57a3d8f17d fix: Solvability setting not stored (#10258)
Co-authored-by: Calvin Smith <calvin@all-hands.dev>
2025-08-12 12:54:45 -04:00
jpelletier1 e1559651b8 Unhide Git Settings feature and add explanatory text (#10256)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-12 14:18:15 +00:00
Ibragim Badertdinov 19a6b6b618 feat(eval): Support evaluation on SWE-rebench (#10251) 2025-08-12 14:05:43 +00:00
Xingyao Wang 2b7e44819f chore(agent_prompt): Add EXTERNAL_SERVICES section to system prompt template (#10244)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-12 21:53:53 +08:00
Xingyao Wang 0699a0ce7c fix: copy microagents file into runtime image (#10245)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: mamoodi <mamoodiha@gmail.com>
2025-08-12 12:42:42 +00:00
Insop 1d0d88d491 Readability improvement & remove duplicated and unused prompts (#10241) 2025-08-12 12:42:17 +08:00
Tim O'Farrell 6f21b6700a Fix for issues where callbacks are not batched (#10235) 2025-08-11 15:44:48 -06:00
Tim O'Farrell af49b615b1 Add BatchedWebHookFileStore for batching webhook updates (#10119)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-11 12:51:08 -06:00
Tim O'Farrell 4651edd5b3 Fix circular import by moving refine_prompt to dedicated module (#10223)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-11 12:17:18 -06:00
olyashok d7f72fec9c OverlayFS support for docker runtimes (#10222) 2025-08-11 18:11:08 +00:00
mamoodi 09011c91f8 Remove rbren from UI changes reviewers (#10230) 2025-08-11 13:32:29 -04:00
Xingyao Wang e56fabfc5e feat(cli): Add markdown schema visualization in CLI (#10193)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-11 15:47:38 +00:00
Xingyao Wang 56f752557c Implement auto-pagination for conversation list with infinite scroll (#10129)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-08-11 15:03:29 +00:00
Calvin Smith 5f2ad7fbb0 Solvability setting switch (#9727)
Co-authored-by: Calvin Smith <calvin@all-hands.dev>
2025-08-11 08:57:47 -06:00
Ryan H. Tran 758e30c9a8 Remove SecretStr conversion in GAIA eval (#10204) 2025-08-11 21:30:18 +08:00
dependabot[bot] 28017f232e chore(deps): bump the version-all group across 1 directory with 9 updates (#10168)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-11 14:51:36 +04:00
Tim O'Farrell 3302c31c60 Removed Hack that is no longer required (#10195) 2025-08-10 12:13:19 -06:00
Xingyao Wang 116ba199d1 feat(agent): stop using short tool description for gpt-5 (#10184) 2025-08-09 17:56:52 -04:00
Boxuan Li 803bdced9c Fix Windows prompt refinement: ensure 'bash' is replaced with 'powershell' in all prompts (#10179)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-08 20:28:36 -07:00
73 changed files with 2130 additions and 787 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
# See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
# Frontend code owners
/frontend/ @rbren @amanape
/frontend/ @amanape
/openhands-ui/ @amanape
# Evaluation code owners
+1 -1
View File
@@ -40,7 +40,7 @@ repos:
hooks:
- id: mypy
additional_dependencies:
[types-requests, types-setuptools, types-pyyaml, types-toml, types-docker, pydantic, lxml]
[types-requests, types-setuptools, types-pyyaml, types-toml, types-docker, types-Markdown, pydantic, lxml]
# To see gaps add `--html-report mypy-report/`
entry: mypy --config-file dev_config/python/mypy.ini openhands/
always_run: true
+1 -3
View File
@@ -10,7 +10,6 @@ import huggingface_hub
import pandas as pd
from datasets import load_dataset
from PIL import Image
from pydantic import SecretStr
from evaluation.benchmarks.gaia.scorer import question_scorer
from evaluation.benchmarks.gaia.utils import (
@@ -80,8 +79,7 @@ def get_config(
config_copy = copy.deepcopy(config)
load_from_toml(config_copy)
if config_copy.search_api_key:
config.search_api_key = SecretStr(config_copy.search_api_key)
config.search_api_key = config_copy.search_api_key
return config
@@ -2,6 +2,8 @@
This folder contains the evaluation harness that we built on top of the original [SWE-Bench benchmark](https://www.swebench.com/) ([paper](https://arxiv.org/abs/2310.06770)).
**UPDATE (8/12/2025): We now support running SWE-rebench evaluation (see the paper [here](https://arxiv.org/abs/2505.20411))! For how to run it, checkout [this README](./SWE-rebench.md).**
**UPDATE (6/15/2025): We now support running SWE-bench-Live evaluation (see the paper [here](https://arxiv.org/abs/2505.23419))! For how to run it, checkout [this README](./SWE-bench-Live.md).**
**UPDATE (5/26/2025): We now support running interactive SWE-Bench evaluation (see the paper [here](https://arxiv.org/abs/2502.13069))! For how to run it, checkout [this README](./SWE-Interact.md).**
@@ -0,0 +1,84 @@
# SWE-rebench
<p align="center">
<a href="https://arxiv.org/abs/2505.20411">📃 Paper</a>
<a href="https://huggingface.co/datasets/nebius/SWE-rebench">🤗 HuggingFace</a>
<a href="https://swe-rebench.com/leaderboard">📊 Leaderboard</a>
</p>
SWE-rebench is a large-scale dataset for verifiable software engineering tasks.
It comes in **two datasets**:
* **[`nebius/SWE-rebench-leaderboard`](https://huggingface.co/datasets/nebius/SWE-rebench-leaderboard)** updatable benchmark used for [leaderboard evaluation](https://swe-rebench.com/leaderboard).
* **[`nebius/SWE-rebench`](https://huggingface.co/datasets/nebius/SWE-rebench)** full dataset with **21,302 tasks**, suitable for training or large-scale offline evaluation.
This document explains how to run OpenHands on SWE-rebench, using the leaderboard split as the main example.
To run on the full dataset, simply replace the dataset name.
## Setting Up
Set up your development environment and configure your LLM provider by following the [SWE-bench README](README.md) in this directory.
## Running Inference
Use the existing SWE-bench inference script, changing the dataset to `nebius/SWE-rebench-leaderboard` and selecting the split (`test` for leaderboard submission):
```bash
./evaluation/benchmarks/swe_bench/scripts/run_infer.sh \
llm.your_llm HEAD CodeActAgent 30 50 1 nebius/SWE-rebench-leaderboard test
```
Arguments:
* `llm.your_llm` your model configuration key
* `HEAD` commit reference for reproducibility
* `CodeActAgent` agent type
* `10` number of examples to evaluate
* `50` maximum iterations per task (increase if needed)
* `1` number of workers
* `nebius/SWE-rebench-leaderboard` Hugging Face dataset name
* `test` dataset split
**Tip:** To run on the **full 21k dataset**, replace `nebius/SWE-rebench-leaderboard` with `nebius/SWE-rebench`.
## Evaluating Results
After inference completes, evaluate using the [SWE-bench-fork evaluation harness](https://github.com/SWE-rebench/SWE-bench-fork).
1. Convert the OpenHands output to SWE-bench evaluation format:
```bash
python evaluation/benchmarks/swe_bench/scripts/live/convert.py \
--output_jsonl path/to/evaluation/output.jsonl > preds.jsonl
```
2. Clone the SWE-bench-fork repo (https://github.com/SWE-rebench/SWE-bench-fork) and follow its README to install dependencies.
3. Run the evaluation using the fork:
```bash
python -m swebench.harness.run_evaluation \
--dataset_name nebius/SWE-rebench-leaderboard \
--split test \
--predictions_path preds.jsonl \
--max_workers 10 \
--run_id openhands
```
## Citation
```bibtex
@article{badertdinov2025swerebench,
title={SWE-rebench: An Automated Pipeline for Task Collection and Decontaminated Evaluation of Software Engineering Agents},
author={Badertdinov, Ibragim and Golubev, Alexander and Nekrashevich, Maksim and Shevtsov, Anton and Karasik, Simon and Andriushchenko, Andrei and Trofimova, Maria and Litvintseva, Daria and Yangel, Boris},
journal={arXiv preprint arXiv:2505.20411},
year={2025}
}
```
@@ -1,65 +0,0 @@
<uploaded_files>
/workspace/{{ workspace_dir_name }}
</uploaded_files>
I've uploaded a python code repository in the directory {{ workspace_dir_name }}. Consider the following issue description:
<issue_description>
{{ instance.problem_statement }}
</issue_description>
Can you help me implement the necessary changes to the repository so that the requirements specified in the <issue_description> are met?
I've already taken care of all changes to any of the test files described in the <issue_description>. This means you DON'T have to modify the testing logic or any of the tests in any way!
Also the development Python environment is already set up for you (i.e., all dependencies already installed), so you don't need to install other packages.
Your task is to make the minimal changes to non-test files in the /workspace/{{ workspace_dir_name }} directory to ensure the <issue_description> is satisfied.
Follow these phases to resolve the issue:
Phase 1. READING: read the problem and reword it in clearer terms
1.1 If there are code or config snippets. Express in words any best practices or conventions in them.
1.2 Hightlight message errors, method names, variables, file names, stack traces, and technical details.
1.3 Explain the problem in clear terms.
1.4 Enumerate the steps to reproduce the problem.
1.5 Hightlight any best practices to take into account when testing and fixing the issue
Phase 2. RUNNING: install and run the tests on the repository
2.1 Follow the readme
2.2 Install the environment and anything needed
2.2 Iterate and figure out how to run the tests
Phase 3. EXPLORATION: find the files that are related to the problem and possible solutions
3.1 Use `grep` to search for relevant methods, classes, keywords and error messages.
3.2 Identify all files related to the problem statement.
3.3 Propose the methods and files to fix the issue and explain why.
3.4 From the possible file locations, select the most likely location to fix the issue.
Phase 4. TEST CREATION: before implementing any fix, create a script to reproduce and verify the issue.
4.1 Look at existing test files in the repository to understand the test format/structure.
4.2 Create a minimal reproduction script that reproduces the located issue.
4.3 Run the reproduction script to confirm you are reproducing the issue.
4.4 Adjust the reproduction script as necessary.
Phase 5. FIX ANALYSIS: state clearly the problem and how to fix it
5.1 State clearly what the problem is.
5.2 State clearly where the problem is located.
5.3 State clearly how the test reproduces the issue.
5.4 State clearly the best practices to take into account in the fix.
5.5 State clearly how to fix the problem.
Phase 6. FIX IMPLEMENTATION: Edit the source code to implement your chosen solution.
6.1 Make minimal, focused changes to fix the issue.
Phase 7. VERIFICATION: Test your implementation thoroughly.
7.1 Run your reproduction script to verify the fix works.
7.2 Add edge cases to your test script to ensure comprehensive coverage.
7.3 Run existing tests related to the modified code to ensure you haven't broken anything.
8. FINAL REVIEW: Carefully re-read the problem description and compare your changes with the base commit {{ instance.base_commit }}.
8.1 Ensure you've fully addressed all requirements.
8.2 Run any tests in the repository related to:
8.2.1 The issue you are fixing
8.2.2 The files you modified
8.2.3 The functions you changed
8.3 If any tests fail, revise your implementation until all tests pass
Be thorough in your exploration, testing, and reasoning. It's fine if your thinking process is lengthy - quality and completeness are more important than brevity.
@@ -1,45 +0,0 @@
# Task: Fix Issue in Python Repository
## Repository Context
You are provided with a Python code repository that contains an issue requiring your attention. The repository is located in a sandboxed environment, and you have access to the codebase to implement the necessary changes.
The code repository is located at: `/workspace/{{ workspace_dir_name }}`
(This path is provided for context; use file system tools to confirm paths before access).
## Goal
Your goal is to fix the issue described in the **Issue Description** section below. Implement the necessary changes to **non-test files only** within the repository, ensuring that **all relevant tests pass** after your changes.
## Key Requirements & Constraints
1. **Understand the problem** very well: it is a bug report, and you know humans don't always write good descriptions. Explore the codebase to understand the related code and the problem in depth. It is possible that the solution needs to be a bit more extensive than just the stated text. Don't exagerate though: don't do unrelated refactoring, but also don't interpret the description too strictly.
2. **Focus on the issues:** Implement the fix focusing on non-test files related to the issue.
2. **Environment Ready:** The Python environment is pre-configured with all dependencies. Do not install packages.
3. **Mandatory Testing Procedure:**
* **Create Test to Reproduce the Issue:** *Before* implementing any fix, you MUST create a *new test* (separate from existing tests) that specifically reproduces the issue.
* Take existing tests as example to understand the testing format/structure.
* Enhance this test with edge cases.
* Run this test to confirm reproduction.
* **Verify Fix:** After implementing the fix, run your test again to verify the issue is resolved.
* **Identify ALL Relevant Tests:** You MUST perform a **dedicated search and analysis** to identify **all** existing unit tests potentially affected by your changes. This includes:
* Tests in the same module/directory as the changed files (e.g., `tests/` subdirectories).
* Tests explicitly importing or using the modified code/classes/functions.
* Tests mentioned in the issue description or related documentation.
* Tests covering functionalities that *depend on* the modified code (analyze callers/dependencies if necessary).
**If you cannot confidently identify a specific subset, you MUST identify and plan to run the entire test suite for the modified application or module(s). State your identified test scope clearly.**
* **Run Identified Relevant Tests:** You MUST execute the **complete set** of relevant existing unit tests you identified in the previous step. Ensure you are running the *correct and comprehensive set* of tests. You MUST NOT modify these existing tests.
* **Final Check & Verification:** Before finishing, ensure **all** identified relevant existing tests pass. **Explicitly confirm that you have considered potential omissions in your test selection and believe the executed tests comprehensively cover the impact of your changes.** Failing to identify and run the *complete* relevant set constitutes a failure. If any identified tests fail, revise your fix. Passing all relevant tests is the primary measure of success.
4. **Defensive Programming:** Actively practice defensive programming: anticipate and handle potential edge cases, unexpected inputs, and different ways the affected code might be called **to ensure the fix works reliably and allows relevant tests to pass.** Analyze the potential impact on other parts of the codebase.
5. **Final Review:** Compare your solution against the original issue and the base commit ({{ instance.base_commit }}) to ensure completeness and test passage.
## General Workflow Guidance
* Prioritize understanding the problem, exploring the code, planning your fix, implementing it carefully using the required diff format, and **thoroughly testing** according to the **Mandatory Testing Procedure**.
* Consider trade-offs between different solutions. The goal is a **robust change that makes the relevant tests pass.** Quality, correctness, and reliability are key.
* Actively practice defensive programming: anticipate and handle potential edge cases, unexpected inputs, and different ways the affected code might be called **to ensure the fix works reliably and allows relevant tests to pass.** Analyze the potential impact on other parts of the codebase.
* IMPORTANT: Your solution will be tested by additional hidden tests, so do not assume the task is complete just because visible tests pass! Refine the solution until you are confident that it is robust and comprehensive according to the **Defensive Programming** requirement.
## Final Note
Be thorough in your exploration, testing, and reasoning. It's fine if your thinking process is lengthy - quality and completeness are more important than brevity.
## Issue Description
{{ instance.problem_statement }}
+7 -3
View File
@@ -80,6 +80,8 @@ def set_dataset_type(dataset_name: str) -> str:
DATASET_TYPE = 'SWE-Gym'
elif 'swe-bench-live' in name_lower:
DATASET_TYPE = 'SWE-bench-Live'
elif 'swe-rebench' in name_lower:
DATASET_TYPE = 'SWE-rebench'
elif 'multimodal' in name_lower:
DATASET_TYPE = 'Multimodal'
else:
@@ -109,9 +111,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata) -> MessageActio
if mode.startswith('swt'):
template_name = 'swt.j2'
elif mode == 'swe':
if 'claude' in llm_model:
template_name = 'swe_default.j2'
elif 'gpt-4.1' in llm_model:
if 'gpt-4.1' in llm_model:
template_name = 'swe_gpt4.j2'
else:
template_name = (
@@ -180,6 +180,8 @@ def get_instance_docker_image(
docker_image_prefix = 'docker.io/starryzhang/'
elif DATASET_TYPE == 'SWE-bench':
docker_image_prefix = 'docker.io/swebench/'
elif DATASET_TYPE == 'SWE-rebench':
docker_image_prefix = 'docker.io/swerebench/'
repo, name = instance_id.split('__')
image_name = f'{docker_image_prefix.rstrip("/")}/sweb.eval.x86_64.{repo}_1776_{name}:latest'.lower()
logger.debug(f'Using official SWE-Bench image: {image_name}')
@@ -320,6 +322,8 @@ def initialize_runtime(
# inject the instance swe entry
if DATASET_TYPE == 'SWE-bench-Live':
entry_script_path = 'instance_swe_entry_live.sh'
elif DATASET_TYPE == 'SWE-rebench':
entry_script_path = 'instance_swe_entry_rebench.sh'
else:
entry_script_path = 'instance_swe_entry.sh'
runtime.copy_to(
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
source ~/.bashrc
SWEUTIL_DIR=/swe_util
# FIXME: Cannot read SWE_INSTANCE_ID from the environment variable
# SWE_INSTANCE_ID=django__django-11099
if [ -z "$SWE_INSTANCE_ID" ]; then
echo "Error: SWE_INSTANCE_ID is not set." >&2
exit 1
fi
# Read the swe-bench-test-lite.json file and extract the required item based on instance_id
item=$(jq --arg INSTANCE_ID "$SWE_INSTANCE_ID" '.[] | select(.instance_id == $INSTANCE_ID)' $SWEUTIL_DIR/eval_data/instances/swe-bench-instance.json)
if [[ -z "$item" ]]; then
echo "No item found for the provided instance ID."
exit 1
fi
WORKSPACE_NAME=$(echo "$item" | jq -r '(.repo | tostring) + "__" + (.version | tostring) | gsub("/"; "__")')
echo "WORKSPACE_NAME: $WORKSPACE_NAME"
# Clear the workspace
if [ -d /workspace ]; then
rm -rf /workspace/*
else
mkdir /workspace
fi
# Copy repo to workspace
if [ -d /workspace/$WORKSPACE_NAME ]; then
rm -rf /workspace/$WORKSPACE_NAME
fi
mkdir -p /workspace
cp -r /testbed /workspace/$WORKSPACE_NAME
# Activate instance-specific environment
if [ -d /opt/miniconda3 ]; then
. /opt/miniconda3/etc/profile.d/conda.sh
conda activate testbed
fi
export PATH=/opt/conda/envs/testbed/bin:$PATH
+4 -3
View File
@@ -263,19 +263,20 @@ def prepare_dataset(
f'Randomly sampling {eval_n_limit} unique instances with random seed 42.'
)
def make_serializable(instance: pd.Series) -> dict:
def make_serializable(instance_dict: dict) -> dict:
import numpy as np
instance_dict = instance.to_dict()
for k, v in instance_dict.items():
if isinstance(v, np.ndarray):
instance_dict[k] = v.tolist()
elif isinstance(v, pd.Timestamp):
instance_dict[k] = str(v)
elif isinstance(v, dict):
instance_dict[k] = make_serializable(v)
return instance_dict
new_dataset = [
make_serializable(instance)
make_serializable(instance.to_dict())
for _, instance in dataset.iterrows()
if str(instance[id_column]) not in finished_ids
]
@@ -85,9 +85,10 @@ describe("ConversationPanel", () => {
vi.clearAllMocks();
vi.restoreAllMocks();
// Setup default mock for getUserConversations
vi.spyOn(OpenHands, "getUserConversations").mockResolvedValue([
...mockConversations,
]);
vi.spyOn(OpenHands, "getUserConversations").mockResolvedValue({
results: [...mockConversations],
next_page_id: null,
});
});
it("should render the conversations", async () => {
@@ -101,7 +102,10 @@ describe("ConversationPanel", () => {
it("should display an empty state when there are no conversations", async () => {
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockResolvedValue([]);
getUserConversationsSpy.mockResolvedValue({
results: [],
next_page_id: null,
});
renderConversationPanel();
@@ -195,7 +199,10 @@ describe("ConversationPanel", () => {
];
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockImplementation(async () => mockData);
getUserConversationsSpy.mockImplementation(async () => ({
results: mockData,
next_page_id: null,
}));
const deleteUserConversationSpy = vi.spyOn(
OpenHands,
@@ -249,7 +256,10 @@ describe("ConversationPanel", () => {
it("should refetch data on rerenders", async () => {
const user = userEvent.setup();
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockResolvedValue([...mockConversations]);
getUserConversationsSpy.mockResolvedValue({
results: [...mockConversations],
next_page_id: null,
});
function PanelWithToggle() {
const [isOpen, setIsOpen] = React.useState(true);
@@ -343,7 +353,10 @@ describe("ConversationPanel", () => {
];
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockResolvedValue(mockRunningConversations);
getUserConversationsSpy.mockResolvedValue({
results: mockRunningConversations,
next_page_id: null,
});
renderConversationPanel();
@@ -407,7 +420,10 @@ describe("ConversationPanel", () => {
];
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockImplementation(async () => mockData);
getUserConversationsSpy.mockImplementation(async () => ({
results: mockData,
next_page_id: null,
}));
const stopConversationSpy = vi.spyOn(OpenHands, "stopConversation");
stopConversationSpy.mockImplementation(async (id: string) => {
@@ -492,7 +508,10 @@ describe("ConversationPanel", () => {
];
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockResolvedValue(mockMixedStatusConversations);
getUserConversationsSpy.mockResolvedValue({
results: mockMixedStatusConversations,
next_page_id: null,
});
renderConversationPanel();
@@ -1,16 +1,18 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { act } from "react";
import { MaintenanceBanner } from "#/components/features/maintenance/maintenance-banner";
// Mock react-i18next
vi.mock("react-i18next", async () => {
const actual = await vi.importActual<typeof import("react-i18next")>("react-i18next");
const actual =
await vi.importActual<typeof import("react-i18next")>("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string, options?: { time?: string }) => {
const translations: Record<string, string> = {
"MAINTENANCE$SCHEDULED_MESSAGE": `Scheduled maintenance will begin at ${options?.time || "{{time}}"}`,
MAINTENANCE$SCHEDULED_MESSAGE: `Scheduled maintenance will begin at ${options?.time || "{{time}}"}`,
};
return translations[key] || key;
},
@@ -19,34 +21,91 @@ vi.mock("react-i18next", async () => {
});
describe("MaintenanceBanner", () => {
afterEach(() => {
localStorage.clear();
});
it("renders maintenance banner with formatted time", () => {
const startTime = "2024-01-15T10:00:00-05:00"; // EST timestamp
const { container } = render(<MaintenanceBanner startTime={startTime} />);
// Check if the banner is rendered
expect(screen.getByText(/Scheduled maintenance will begin at/)).toBeInTheDocument();
const banner = screen.queryByTestId("maintenance-banner");
expect(banner).toBeInTheDocument();
// Check if the warning icon (SVG) is present
const svgIcon = container.querySelector('svg');
const svgIcon = container.querySelector("svg");
expect(svgIcon).toBeInTheDocument();
// Check if the button to close is present
const button = within(banner!).queryByTestId("dismiss-button");
expect(button).toBeInTheDocument();
});
// maintenance-banner
it("handles invalid date gracefully", () => {
const invalidTime = "invalid-date";
render(<MaintenanceBanner startTime={invalidTime} />);
// Should still render the banner with the original string
expect(screen.getByText(/Scheduled maintenance will begin at invalid-date/)).toBeInTheDocument();
// Check if the banner is rendered
const banner = screen.queryByTestId("maintenance-banner");
expect(banner).not.toBeInTheDocument();
});
it("formats ISO date string correctly", () => {
const isoTime = "2024-01-15T15:30:00.000Z";
it("click on dismiss button removes banner", () => {
const startTime = "2024-01-15T10:00:00-05:00"; // EST timestamp
render(<MaintenanceBanner startTime={isoTime} />);
render(<MaintenanceBanner startTime={startTime} />);
// Should render the banner (exact time format will depend on user's timezone)
expect(screen.getByText(/Scheduled maintenance will begin at/)).toBeInTheDocument();
// Check if the banner is rendered
const banner = screen.queryByTestId("maintenance-banner");
const button = within(banner!).queryByTestId("dismiss-button");
act(() => {
fireEvent.click(button!);
});
expect(banner).not.toBeInTheDocument();
});
it("banner reappears after dismissing on next maintenance event(future time)", () => {
const startTime = "2024-01-15T10:00:00-05:00"; // EST timestamp
const nextStartTime = "2025-01-15T10:00:00-05:00"; // EST timestamp
const { rerender } = render(<MaintenanceBanner startTime={startTime} />);
// Check if the banner is rendered
const banner = screen.queryByTestId("maintenance-banner");
const button = within(banner!).queryByTestId("dismiss-button");
act(() => {
fireEvent.click(button!);
});
expect(banner).not.toBeInTheDocument();
rerender(<MaintenanceBanner startTime={nextStartTime} />);
expect(screen.queryByTestId("maintenance-banner")).toBeInTheDocument();
});
it("banner doesn't reappear after dismissing on next maintenance event(past time)", () => {
const startTime = "2024-01-15T10:00:00-05:00"; // EST timestamp
const nextStartTime = "2023-01-15T10:00:00-05:00"; // EST timestamp
const { rerender } = render(<MaintenanceBanner startTime={startTime} />);
// Check if the banner is rendered
const banner = screen.queryByTestId("maintenance-banner");
const button = within(banner!).queryByTestId("dismiss-button");
act(() => {
fireEvent.click(button!);
});
expect(banner).not.toBeInTheDocument();
rerender(<MaintenanceBanner startTime={nextStartTime} />);
expect(screen.queryByTestId("maintenance-banner")).not.toBeInTheDocument();
});
});
+273 -124
View File
@@ -12,20 +12,22 @@
"@heroui/use-infinite-scroll": "^2.2.10",
"@microlink/react-json-view": "^1.26.2",
"@monaco-editor/react": "^4.7.0-rc.0",
"@react-router/node": "^7.7.1",
"@react-router/serve": "^7.7.1",
"@react-router/node": "^7.8.0",
"@react-router/serve": "^7.8.0",
"@react-types/shared": "^3.31.0",
"@reduxjs/toolkit": "^2.8.2",
"@stripe/react-stripe-js": "^3.9.0",
"@stripe/stripe-js": "^7.8.0",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.84.1",
"@vitejs/plugin-react": "^4.7.0",
"@tanstack/react-query": "^5.84.2",
"@uidotdev/usehooks": "^2.4.1",
"@vitejs/plugin-react": "^5.0.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.11.0",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.23.12",
"i18next": "^25.3.2",
@@ -33,9 +35,9 @@
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.29",
"jose": "^6.0.12",
"lucide-react": "^0.536.0",
"lucide-react": "^0.539.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.258.5",
"posthog-js": "^1.259.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-highlight": "^0.15.0",
@@ -44,7 +46,7 @@
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
"react-router": "^7.7.1",
"react-router": "^7.8.0",
"react-select": "^5.10.2",
"react-syntax-highlighter": "^15.6.1",
"react-textarea-autosize": "^8.5.9",
@@ -53,7 +55,7 @@
"sirv-cli": "^3.0.1",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.3.1",
"vite": "^7.0.6",
"vite": "^7.1.1",
"web-vitals": "^5.1.0",
"ws": "^8.18.2"
},
@@ -63,7 +65,7 @@
"@babel/types": "^7.28.2",
"@mswjs/socket.io-binding": "^0.2.0",
"@playwright/test": "^1.54.2",
"@react-router/dev": "^7.7.1",
"@react-router/dev": "^7.8.0",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.83.1",
"@testing-library/dom": "^10.4.1",
@@ -3394,9 +3396,9 @@
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.12",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz",
"integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==",
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -3413,15 +3415,15 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz",
"integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==",
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.29",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz",
"integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==",
"version": "0.3.30",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz",
"integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==",
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -4388,9 +4390,9 @@
}
},
"node_modules/@react-router/dev": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.7.1.tgz",
"integrity": "sha512-ByfgHmAyfx/JQYN/QwUx1sFJlBA5Z3HQAZ638wHSb+m6khWtHqSaKCvPqQh1P00wdEAeV3tX5L1aUM/ceCF6+w==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.8.0.tgz",
"integrity": "sha512-5NA9yLZComM+kCD3zNPL3rjrAFjzzODY8hjAJlpz/6jpyXoF28W8QTSo8rxc56XVNLONM75Y5nq1wzeEcWFFKA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4402,7 +4404,9 @@
"@babel/traverse": "^7.27.7",
"@babel/types": "^7.27.7",
"@npmcli/package-json": "^4.0.1",
"@react-router/node": "7.7.1",
"@react-router/node": "7.8.0",
"@vitejs/plugin-react": "^4.5.2",
"@vitejs/plugin-rsc": "0.4.11",
"arg": "^5.0.1",
"babel-dead-code-elimination": "^1.0.6",
"chokidar": "^4.0.0",
@@ -4429,8 +4433,8 @@
"node": ">=20.0.0"
},
"peerDependencies": {
"@react-router/serve": "^7.7.1",
"react-router": "^7.7.1",
"@react-router/serve": "^7.8.0",
"react-router": "^7.8.0",
"typescript": "^5.1.0",
"vite": "^5.1.0 || ^6.0.0 || ^7.0.0",
"wrangler": "^3.28.2 || ^4.0.0"
@@ -4447,6 +4451,44 @@
}
}
},
"node_modules/@react-router/dev/node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
"dev": true,
"license": "MIT"
},
"node_modules/@react-router/dev/node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.28.0",
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
"@rolldown/pluginutils": "1.0.0-beta.27",
"@types/babel__core": "^7.20.5",
"react-refresh": "^0.17.0"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"peerDependencies": {
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@react-router/dev/node_modules/@vitejs/plugin-react/node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/@react-router/dev/node_modules/jsesc": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz",
@@ -4461,19 +4503,19 @@
}
},
"node_modules/@react-router/express": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/@react-router/express/-/express-7.7.1.tgz",
"integrity": "sha512-OEZwIM7i/KPSDjwVRg3LqeNIwG41U+SeFOwMjhZRFfyrnwghHfvWsDajf73r4ccMh+RRHcP1GIN6VSU3XZk7MA==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@react-router/express/-/express-7.8.0.tgz",
"integrity": "sha512-lNUwux5IfMqczIL3gXZ/mauPUoVz65fSLPnUTkP7hkh/P7fcsPtYkmcixuaWb+882lY+Glf157OdoIMbcSMBaA==",
"license": "MIT",
"dependencies": {
"@react-router/node": "7.7.1"
"@react-router/node": "7.8.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"express": "^4.17.1 || ^5",
"react-router": "7.7.1",
"react-router": "7.8.0",
"typescript": "^5.1.0"
},
"peerDependenciesMeta": {
@@ -4483,9 +4525,9 @@
}
},
"node_modules/@react-router/node": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.7.1.tgz",
"integrity": "sha512-EHd6PEcw2nmcJmcYTPA0MmRWSqOaJ/meycfCp0ADA9T/6b7+fUHfr9XcNyf7UeZtYwu4zGyuYfPmLU5ic6Ugyg==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.8.0.tgz",
"integrity": "sha512-/FFN9vqI2EHPwDCHTvsMInhrYvwJ5SlCeyUr1oWUxH47JyYkooVFks5++M4VkrTgj2ZBsMjPPKy0xRNTQdtBDA==",
"license": "MIT",
"dependencies": {
"@mjackson/node-fetch-server": "^0.2.0"
@@ -4494,7 +4536,7 @@
"node": ">=20.0.0"
},
"peerDependencies": {
"react-router": "7.7.1",
"react-router": "7.8.0",
"typescript": "^5.1.0"
},
"peerDependenciesMeta": {
@@ -4504,13 +4546,13 @@
}
},
"node_modules/@react-router/serve": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/@react-router/serve/-/serve-7.7.1.tgz",
"integrity": "sha512-LyAiX+oI+6O6j2xWPUoKW+cgayUf3USBosSMv73Jtwi99XUhSDu2MUhM+BB+AbrYRubauZ83QpZTROiXoaf8jA==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@react-router/serve/-/serve-7.8.0.tgz",
"integrity": "sha512-DokCv1GfOMt9KHu+k3WYY9sP5nOEzq7za+Vi3dWPHoY5oP0wgv8S4DnTPU08ASY8iFaF38NAzapbSFfu6Xfr0Q==",
"license": "MIT",
"dependencies": {
"@react-router/express": "7.7.1",
"@react-router/node": "7.7.1",
"@react-router/express": "7.8.0",
"@react-router/node": "7.8.0",
"compression": "^1.7.4",
"express": "^4.19.2",
"get-port": "5.1.1",
@@ -4524,7 +4566,7 @@
"node": ">=20.0.0"
},
"peerDependencies": {
"react-router": "7.7.1"
"react-router": "7.8.0"
}
},
"node_modules/@react-stately/calendar": {
@@ -5226,9 +5268,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
"version": "1.0.0-beta.30",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.30.tgz",
"integrity": "sha512-whXaSoNUFiyDAjkUF8OBpOm77Szdbk5lGNqFe6CbVbJFrhCCPinCbRA3NjawwlNHla1No7xvXXh+CpSxnPfUEw==",
"license": "MIT"
},
"node_modules/@rollup/pluginutils": {
@@ -6175,9 +6217,9 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.84.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.84.1.tgz",
"integrity": "sha512-zo7EUygcWJMQfFNWDSG7CBhy8irje/XY0RDVKKV4IQJAysb+ZJkkJPcnQi+KboyGUgT+SQebRFoTqLuTtfoDLw==",
"version": "5.85.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.0.tgz",
"integrity": "sha512-t1HMfToVMGfwEJRya6GG7gbK0luZJd+9IySFNePL1BforU1F3LqQ3tBC2Rpvr88bOrlU6PXyMLgJD0Yzn4ztUw==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.83.1"
@@ -6447,9 +6489,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.0.tgz",
"integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==",
"version": "24.2.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz",
"integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@@ -6463,9 +6505,9 @@
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.1.9",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz",
"integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==",
"version": "19.1.10",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz",
"integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==",
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -6633,14 +6675,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.39.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.39.0.tgz",
"integrity": "sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew==",
"version": "8.39.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.39.1.tgz",
"integrity": "sha512-8fZxek3ONTwBu9ptw5nCKqZOSkXshZB7uAxuFF0J/wTMkKydjXCzqqga7MlFMpHi9DoG4BadhmTkITBcg8Aybw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.39.0",
"@typescript-eslint/types": "^8.39.0",
"@typescript-eslint/tsconfig-utils": "^8.39.1",
"@typescript-eslint/types": "^8.39.1",
"debug": "^4.3.4"
},
"engines": {
@@ -6655,9 +6697,9 @@
}
},
"node_modules/@typescript-eslint/project-service/node_modules/@typescript-eslint/types": {
"version": "8.39.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.0.tgz",
"integrity": "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg==",
"version": "8.39.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.1.tgz",
"integrity": "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -6687,9 +6729,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.39.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.0.tgz",
"integrity": "sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ==",
"version": "8.39.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.1.tgz",
"integrity": "sha512-ePUPGVtTMR8XMU2Hee8kD0Pu4NDE1CN9Q1sxGSGd/mbOtGZDM7pnhXNJnzW63zk/q+Z54zVzj44HtwXln5CvHA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -6798,16 +6840,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.39.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.39.0.tgz",
"integrity": "sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ==",
"version": "8.39.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.39.1.tgz",
"integrity": "sha512-VF5tZ2XnUSTuiqZFXCZfZs1cgkdd3O/sSYmdo2EpSyDlC86UM/8YytTmKnehOW3TGAlivqTDT6bS87B/GQ/jyg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.39.0",
"@typescript-eslint/types": "8.39.0",
"@typescript-eslint/typescript-estree": "8.39.0"
"@typescript-eslint/scope-manager": "8.39.1",
"@typescript-eslint/types": "8.39.1",
"@typescript-eslint/typescript-estree": "8.39.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -6822,14 +6864,14 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": {
"version": "8.39.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.39.0.tgz",
"integrity": "sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A==",
"version": "8.39.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.39.1.tgz",
"integrity": "sha512-RkBKGBrjgskFGWuyUGz/EtD8AF/GW49S21J8dvMzpJitOF1slLEbbHnNEtAHtnDAnx8qDEdRrULRnWVx27wGBw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.39.0",
"@typescript-eslint/visitor-keys": "8.39.0"
"@typescript-eslint/types": "8.39.1",
"@typescript-eslint/visitor-keys": "8.39.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -6840,9 +6882,9 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": {
"version": "8.39.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.0.tgz",
"integrity": "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg==",
"version": "8.39.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.1.tgz",
"integrity": "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -6854,16 +6896,16 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.39.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.0.tgz",
"integrity": "sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw==",
"version": "8.39.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.1.tgz",
"integrity": "sha512-EKkpcPuIux48dddVDXyQBlKdeTPMmALqBUbEk38McWv0qVEZwOpVJBi7ugK5qVNgeuYjGNQxrrnoM/5+TI/BPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.39.0",
"@typescript-eslint/tsconfig-utils": "8.39.0",
"@typescript-eslint/types": "8.39.0",
"@typescript-eslint/visitor-keys": "8.39.0",
"@typescript-eslint/project-service": "8.39.1",
"@typescript-eslint/tsconfig-utils": "8.39.1",
"@typescript-eslint/types": "8.39.1",
"@typescript-eslint/visitor-keys": "8.39.1",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
@@ -6883,13 +6925,13 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.39.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.0.tgz",
"integrity": "sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA==",
"version": "8.39.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.1.tgz",
"integrity": "sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.39.0",
"@typescript-eslint/types": "8.39.1",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
@@ -6944,6 +6986,19 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@uidotdev/usehooks": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@uidotdev/usehooks/-/usehooks-2.4.1.tgz",
"integrity": "sha512-1I+RwWyS+kdv3Mv0Vmc+p0dPYH0DTRAo04HLyXReYBL9AeseDWUJyi4THuksBJcu9F0Pih69Ak150VDnqbVnXg==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@@ -6951,20 +7006,20 @@
"license": "ISC"
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.0.tgz",
"integrity": "sha512-Jx9JfsTa05bYkS9xo0hkofp2dCmp1blrKjw9JONs5BTHOvJCgLbaPSuZLGSVJW6u2qe0tc4eevY0+gSNNi0YCw==",
"license": "MIT",
"dependencies": {
"@babel/core": "^7.28.0",
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
"@rolldown/pluginutils": "1.0.0-beta.27",
"@rolldown/pluginutils": "1.0.0-beta.30",
"@types/babel__core": "^7.20.5",
"react-refresh": "^0.17.0"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
@@ -6979,6 +7034,34 @@
"node": ">=0.10.0"
}
},
"node_modules/@vitejs/plugin-rsc": {
"version": "0.4.11",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-rsc/-/plugin-rsc-0.4.11.tgz",
"integrity": "sha512-+4H4wLi+Y9yF58znBfKgGfX8zcqUGt8ngnmNgzrdGdF1SVz7EO0sg7WnhK5fFVHt6fUxsVEjmEabsCWHKPL1Tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mjackson/node-fetch-server": "^0.7.0",
"es-module-lexer": "^1.7.0",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17",
"periscopic": "^4.0.2",
"turbo-stream": "^3.1.0",
"vitefu": "^1.1.1"
},
"peerDependencies": {
"react": "*",
"react-dom": "*",
"vite": "*"
}
},
"node_modules/@vitejs/plugin-rsc/node_modules/@mjackson/node-fetch-server": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@mjackson/node-fetch-server/-/node-fetch-server-0.7.0.tgz",
"integrity": "sha512-un8diyEBKU3BTVj3GzlTPA1kIjCkGdD+AMYQy31Gf9JCkfoZzwgJ79GUtHrF2BN3XPNMLpubbzPcxys+a3uZEw==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/coverage-v8": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz",
@@ -7740,9 +7823,9 @@
}
},
"node_modules/browserslist": {
"version": "4.25.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
"integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==",
"version": "4.25.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.2.tgz",
"integrity": "sha512-0si2SJK3ooGzIawRu61ZdPCO1IncZwS8IzuX73sPZsXW6EQ/w/DAfPyKI8l1ETTCr2MnvqWitmlCUxgdul45jA==",
"funding": [
{
"type": "opencollective",
@@ -7759,8 +7842,8 @@
],
"license": "MIT",
"dependencies": {
"caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173",
"caniuse-lite": "^1.0.30001733",
"electron-to-chromium": "^1.5.199",
"node-releases": "^2.0.19",
"update-browserslist-db": "^1.1.3"
},
@@ -7867,9 +7950,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001731",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001731.tgz",
"integrity": "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==",
"version": "1.0.30001734",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001734.tgz",
"integrity": "sha512-uhE1Ye5vgqju6OI71HTQqcBCZrvHugk0MjLak7Q+HfoBgoq5Bi+5YnwjP4fjDgrtYr/l8MVRBvzz9dPD4KyK0A==",
"funding": [
{
"type": "opencollective",
@@ -8587,6 +8670,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@@ -8841,9 +8934,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.197",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.197.tgz",
"integrity": "sha512-m1xWB3g7vJ6asIFz+2pBUbq3uGmfmln1M9SSvBe4QIFWYrRHylP73zL/3nMjDmwz8V+1xAXQDfBd6+HPW0WvDQ==",
"version": "1.5.200",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.200.tgz",
"integrity": "sha512-rFCxROw7aOe4uPTfIAx+rXv9cEcGx+buAF4npnhtTqCJk5KDFRnh3+KYj7rdVh6lsFt5/aPs+Irj9rZ33WMA7w==",
"license": "ISC"
},
"node_modules/emoji-regex": {
@@ -10987,9 +11080,9 @@
}
},
"node_modules/i18next": {
"version": "25.3.2",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.2.tgz",
"integrity": "sha512-JSnbZDxRVbphc5jiptxr3o2zocy5dEqpVm9qCGdJwRNO+9saUJS0/u4LnM/13C23fUEWxAylPqKU/NpMV/IjqA==",
"version": "25.3.4",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.4.tgz",
"integrity": "sha512-AHklEYFLiRRxW1Cb6zE9lfnEtYvsydRC8nRS3RSKGX3zCqZ8nLZwMaUsrb80YuccPNv2RNokDL8LkTNnp+6mDw==",
"funding": [
{
"type": "individual",
@@ -11517,6 +11610,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/is-reference": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.6"
}
},
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -12222,13 +12325,13 @@
"license": "MIT"
},
"node_modules/lint-staged": {
"version": "16.1.4",
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.1.4.tgz",
"integrity": "sha512-xy7rnzQrhTVGKMpv6+bmIA3C0yET31x8OhKBYfvGo0/byeZ6E0BjGARrir3Kg/RhhYHutpsi01+2J5IpfVoueA==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.1.5.tgz",
"integrity": "sha512-uAeQQwByI6dfV7wpt/gVqg+jAPaSp8WwOA8kKC/dv1qw14oGpnpAisY65ibGHUGDUv0rYaZ8CAJZ/1U8hUvC2A==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^5.4.1",
"chalk": "^5.5.0",
"commander": "^14.0.0",
"debug": "^4.4.1",
"lilconfig": "^3.1.3",
@@ -12237,7 +12340,7 @@
"nano-spawn": "^1.0.2",
"pidtree": "^0.6.0",
"string-argv": "^0.3.2",
"yaml": "^2.8.0"
"yaml": "^2.8.1"
},
"bin": {
"lint-staged": "bin/lint-staged.js"
@@ -12625,9 +12728,9 @@
}
},
"node_modules/lucide-react": {
"version": "0.536.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.536.0.tgz",
"integrity": "sha512-2PgvNa9v+qz4Jt/ni8vPLt4jwoFybXHuubQT8fv4iCW5TjDxkbZjNZZHa485ad73NSEn/jdsEtU57eE1g+ma8A==",
"version": "0.539.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.539.0.tgz",
"integrity": "sha512-VVISr+VF2krO91FeuCrm1rSOLACQUYVy7NQkzrOty52Y8TlTPcXcMdQFj9bYzBgXbWCiywlwSZ3Z8u6a+6bMlg==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -14524,6 +14627,18 @@
"node": ">= 14.16"
}
},
"node_modules/periscopic": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/periscopic/-/periscopic-4.0.2.tgz",
"integrity": "sha512-sqpQDUy8vgB7ycLkendSKS6HnVz1Rneoc3Rc+ZBUCe2pbqlVuCC5vF52l0NJ1aiMg/r1qfYF9/myz8CZeI2rjA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "*",
"is-reference": "^3.0.2",
"zimmerframe": "^1.0.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -14648,9 +14763,9 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.258.6",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.258.6.tgz",
"integrity": "sha512-vL5AGG+rOoRg3LGquMfBPO55jD4bGl0CiV44SHdHAoBnOVDDAqxczRGDqMdxor+VLx3/ofTFOJ2FNprfAHp70Q==",
"version": "1.259.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.259.0.tgz",
"integrity": "sha512-6usLnJshky8fQ82ask7PIJh4BSFOU0VkRbFg8Zanm/HIlYMG1VOdRWlToA63JXeO7Bzm9TuREq1wFm5U2VEVCg==",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"core-js": "^3.38.1",
@@ -15126,9 +15241,9 @@
}
},
"node_modules/react-router": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.7.1.tgz",
"integrity": "sha512-jVKHXoWRIsD/qS6lvGveckwb862EekvapdHJN/cGmzw40KnJH5gg53ujOJ4qX6EKIK9LSBfFed/xiQ5yeXNrUA==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.0.tgz",
"integrity": "sha512-r15M3+LHKgM4SOapNmsH3smAizWds1vJ0Z9C4mWaKnT9/wD7+d/0jYcj6LmOvonkrO4Rgdyp4KQ/29gWN2i1eg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -17180,6 +17295,13 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/turbo-stream": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-3.1.0.tgz",
"integrity": "sha512-tVI25WEXl4fckNEmrq70xU1XumxUwEx/FZD5AgEcV8ri7Wvrg2o7GEq8U7htrNx3CajciGm+kDyhRf5JB6t7/A==",
"dev": true,
"license": "MIT"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -17638,16 +17760,16 @@
}
},
"node_modules/vite": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.0.6.tgz",
"integrity": "sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==",
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.2.tgz",
"integrity": "sha512-J0SQBPlQiEXAF7tajiH+rUooJPo0l8KQgyg4/aMunNtrOa7bwuZJsJbDWzeljqQpgftxuq5yNJxQ91O9ts29UQ==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.6",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
"rollup": "^4.40.0",
"rollup": "^4.43.0",
"tinyglobby": "^0.2.14"
},
"bin": {
@@ -17816,6 +17938,26 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vitefu": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz",
"integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
"dev": true,
"license": "MIT",
"workspaces": [
"tests/deps/*",
"tests/projects/*",
"tests/projects/workspace/packages/*"
],
"peerDependencies": {
"vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0"
},
"peerDependenciesMeta": {
"vite": {
"optional": true
}
}
},
"node_modules/vitest": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
@@ -18418,6 +18560,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zimmerframe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",
"integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==",
"dev": true,
"license": "MIT"
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+11 -9
View File
@@ -11,20 +11,22 @@
"@heroui/use-infinite-scroll": "^2.2.10",
"@microlink/react-json-view": "^1.26.2",
"@monaco-editor/react": "^4.7.0-rc.0",
"@react-router/node": "^7.7.1",
"@react-router/serve": "^7.7.1",
"@react-router/node": "^7.8.0",
"@react-router/serve": "^7.8.0",
"@react-types/shared": "^3.31.0",
"@reduxjs/toolkit": "^2.8.2",
"@stripe/react-stripe-js": "^3.9.0",
"@stripe/stripe-js": "^7.8.0",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.84.1",
"@vitejs/plugin-react": "^4.7.0",
"@tanstack/react-query": "^5.84.2",
"@uidotdev/usehooks": "^2.4.1",
"@vitejs/plugin-react": "^5.0.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.11.0",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.23.12",
"i18next": "^25.3.2",
@@ -32,9 +34,9 @@
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.29",
"jose": "^6.0.12",
"lucide-react": "^0.536.0",
"lucide-react": "^0.539.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.258.5",
"posthog-js": "^1.259.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-highlight": "^0.15.0",
@@ -43,7 +45,7 @@
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
"react-router": "^7.7.1",
"react-router": "^7.8.0",
"react-select": "^5.10.2",
"react-syntax-highlighter": "^15.6.1",
"react-textarea-autosize": "^8.5.9",
@@ -52,7 +54,7 @@
"sirv-cli": "^3.0.1",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.3.1",
"vite": "^7.0.6",
"vite": "^7.1.1",
"web-vitals": "^5.1.0",
"ws": "^8.18.2"
},
@@ -87,7 +89,7 @@
"@babel/types": "^7.28.2",
"@mswjs/socket.io-binding": "^0.2.0",
"@playwright/test": "^1.54.2",
"@react-router/dev": "^7.7.1",
"@react-router/dev": "^7.8.0",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.83.1",
"@testing-library/dom": "^10.4.1",
+14 -4
View File
@@ -283,17 +283,27 @@ class OpenHands {
return data;
}
static async getUserConversations(): Promise<Conversation[]> {
static async getUserConversations(
limit: number = 20,
pageId?: string,
): Promise<ResultSet<Conversation>> {
const params = new URLSearchParams();
params.append("limit", limit.toString());
if (pageId) {
params.append("page_id", pageId);
}
const { data } = await openHands.get<ResultSet<Conversation>>(
"/api/conversations?limit=100",
`/api/conversations?${params.toString()}`,
);
return data.results;
return data;
}
static async searchConversations(
selectedRepository?: string,
conversationTrigger?: string,
limit: number = 20,
limit: number = 100,
): Promise<Conversation[]> {
const params = new URLSearchParams();
params.append("limit", limit.toString());
@@ -83,7 +83,7 @@ const getRecallObservationContent = (event: RecallObservation): string => {
) {
content += `\n\n**Triggered Microagent Knowledge:**`;
for (const knowledge of event.extras.microagent_knowledge) {
content += `\n\n- **${knowledge.name}** (triggered by keyword: ${knowledge.trigger})\n\n\`\`\`\n${knowledge.content}\n\`\`\``;
content += `\n\n- **${knowledge.name}** (triggered by keyword: ${knowledge.trigger})\n\n${knowledge.content}`;
}
}
@@ -3,7 +3,8 @@ import { NavLink, useParams, useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { ConversationCard } from "./conversation-card";
import { useUserConversations } from "#/hooks/query/use-user-conversations";
import { usePaginatedConversations } from "#/hooks/query/use-paginated-conversations";
import { useInfiniteScroll } from "#/hooks/use-infinite-scroll";
import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation";
import { useStopConversation } from "#/hooks/mutation/use-stop-conversation";
import { ConfirmDeleteModal } from "./confirm-delete-modal";
@@ -40,12 +41,30 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
string | null
>(null);
const { data: conversations, isFetching, error } = useUserConversations();
const {
data,
isFetching,
error,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = usePaginatedConversations();
// Flatten all pages into a single array of conversations
const conversations = data?.pages.flatMap((page) => page.results) ?? [];
const { mutate: deleteConversation } = useDeleteConversation();
const { mutate: stopConversation } = useStopConversation();
const { mutate: updateConversation } = useUpdateConversation();
// Set up infinite scroll
const scrollContainerRef = useInfiniteScroll({
hasNextPage: !!hasNextPage,
isFetchingNextPage,
fetchNextPage,
threshold: 200, // Load more when 200px from bottom
});
const handleDeleteProject = (conversationId: string) => {
setConfirmDeleteModalVisible(true);
setSelectedConversationId(conversationId);
@@ -102,11 +121,16 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
return (
<div
ref={ref}
ref={(node) => {
// TODO: Combine both refs somehow
if (ref.current !== node) ref.current = node;
if (scrollContainerRef.current !== node)
scrollContainerRef.current = node;
}}
data-testid="conversation-panel"
className="w-[350px] h-full border border-neutral-700 bg-base-secondary rounded-xl overflow-y-auto absolute"
>
{isFetching && (
{isFetching && conversations.length === 0 && (
<div className="w-full h-full absolute flex justify-center items-center">
<LoadingSpinner size="small" />
</div>
@@ -156,6 +180,13 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
</NavLink>
))}
{/* Loading indicator for fetching more conversations */}
{isFetchingNextPage && (
<div className="flex justify-center py-4">
<LoadingSpinner size="small" />
</div>
)}
{confirmDeleteModalVisible && (
<ConfirmDeleteModal
onConfirm={() => {
@@ -1,5 +1,10 @@
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
import { isBefore } from "date-fns";
import { useLocalStorage } from "@uidotdev/usehooks";
import { FaTriangleExclamation } from "react-icons/fa6";
import CloseIcon from "#/icons/close.svg?react";
import { cn } from "#/utils/utils";
interface MaintenanceBannerProps {
startTime: string;
@@ -7,6 +12,11 @@ interface MaintenanceBannerProps {
export function MaintenanceBanner({ startTime }: MaintenanceBannerProps) {
const { t } = useTranslation();
const [dismissedAt, setDismissedAt] = useLocalStorage<string | null>(
"maintenance_banner_dismissed_at",
null,
);
// Convert EST timestamp to user's local timezone
const formatMaintenanceTime = (estTimeString: string): string => {
try {
@@ -52,8 +62,28 @@ export function MaintenanceBanner({ startTime }: MaintenanceBannerProps) {
const localTime = formatMaintenanceTime(startTime);
const isBannerVisible = useMemo(() => {
const isValid = !Number.isNaN(new Date(startTime).getTime());
if (!isValid) {
return false;
}
return !dismissedAt
? true
: isBefore(new Date(dismissedAt), new Date(startTime));
}, [dismissedAt, startTime]);
if (!isBannerVisible) {
return null;
}
return (
<div className="bg-primary text-[#0D0F11] p-4 rounded">
<div
data-testid="maintenance-banner"
className={cn(
"bg-primary text-[#0D0F11] p-4 rounded",
"flex flex-row items-center justify-between",
)}
>
<div className="flex items-center">
<div className="flex-shrink-0">
<FaTriangleExclamation className="text-white align-middle" />
@@ -64,6 +94,17 @@ export function MaintenanceBanner({ startTime }: MaintenanceBannerProps) {
</p>
</div>
</div>
<button
type="button"
data-testid="dismiss-button"
onClick={() => setDismissedAt(localTime)}
className={cn(
"bg-[#0D0F11] rounded-full w-5 h-5 flex items-center justify-center cursor-pointer",
)}
>
<CloseIcon />
</button>
</div>
);
}
@@ -3,6 +3,7 @@ import { I18nKey } from "#/i18n/declaration";
import { SettingsInput } from "../settings-input";
import { BitbucketTokenHelpAnchor } from "./bitbucket-token-help-anchor";
import { KeyStatusIcon } from "../key-status-icon";
import { cn } from "#/utils/utils";
interface BitbucketTokenInputProps {
onChange: (value: string) => void;
@@ -10,6 +11,7 @@ interface BitbucketTokenInputProps {
isBitbucketTokenSet: boolean;
name: string;
bitbucketHostSet: string | null | undefined;
className?: string;
}
export function BitbucketTokenInput({
@@ -18,11 +20,12 @@ export function BitbucketTokenInput({
isBitbucketTokenSet,
name,
bitbucketHostSet,
className,
}: BitbucketTokenInputProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-6">
<div className={cn("flex flex-col gap-6", className)}>
<SettingsInput
testId={name}
name={name}
@@ -3,6 +3,7 @@ import { I18nKey } from "#/i18n/declaration";
import { SettingsInput } from "../settings-input";
import { GitHubTokenHelpAnchor } from "./github-token-help-anchor";
import { KeyStatusIcon } from "../key-status-icon";
import { cn } from "#/utils/utils";
interface GitHubTokenInputProps {
onChange: (value: string) => void;
@@ -10,6 +11,7 @@ interface GitHubTokenInputProps {
isGitHubTokenSet: boolean;
name: string;
githubHostSet: string | null | undefined;
className?: string;
}
export function GitHubTokenInput({
@@ -18,11 +20,12 @@ export function GitHubTokenInput({
isGitHubTokenSet,
name,
githubHostSet,
className,
}: GitHubTokenInputProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-6">
<div className={cn("flex flex-col gap-6", className)}>
<SettingsInput
testId={name}
name={name}
@@ -3,6 +3,7 @@ import { I18nKey } from "#/i18n/declaration";
import { SettingsInput } from "../settings-input";
import { GitLabTokenHelpAnchor } from "./gitlab-token-help-anchor";
import { KeyStatusIcon } from "../key-status-icon";
import { cn } from "#/utils/utils";
interface GitLabTokenInputProps {
onChange: (value: string) => void;
@@ -10,6 +11,7 @@ interface GitLabTokenInputProps {
isGitLabTokenSet: boolean;
name: string;
gitlabHostSet: string | null | undefined;
className?: string;
}
export function GitLabTokenInput({
@@ -18,11 +20,12 @@ export function GitLabTokenInput({
isGitLabTokenSet,
name,
gitlabHostSet,
className,
}: GitLabTokenInputProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-6">
<div className={cn("flex flex-col gap-6", className)}>
<SettingsInput
testId={name}
name={name}
@@ -25,6 +25,7 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
mcp_config: settings.MCP_CONFIG,
enable_proactive_conversation_starters:
settings.ENABLE_PROACTIVE_CONVERSATION_STARTERS,
enable_solvability_analysis: settings.ENABLE_SOLVABILITY_ANALYSIS,
search_api_key: settings.SEARCH_API_KEY?.trim() || "",
max_budget_per_task: settings.MAX_BUDGET_PER_TASK,
git_user_name:
@@ -0,0 +1,16 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useIsAuthed } from "./use-is-authed";
export const usePaginatedConversations = (limit: number = 20) => {
const { data: userIsAuthenticated } = useIsAuthed();
return useInfiniteQuery({
queryKey: ["user", "conversations", "paginated", limit],
queryFn: ({ pageParam }) =>
OpenHands.getUserConversations(limit, pageParam),
enabled: !!userIsAuthenticated,
getNextPageParam: (lastPage) => lastPage.next_page_id,
initialPageParam: undefined as string | undefined,
});
};
@@ -4,7 +4,7 @@ import OpenHands from "#/api/open-hands";
export const useSearchConversations = (
selectedRepository?: string,
conversationTrigger?: string,
limit: number = 20,
limit: number = 100,
cacheDisabled: boolean = false,
) =>
useQuery({
+1
View File
@@ -25,6 +25,7 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
ENABLE_SOUND_NOTIFICATIONS: apiSettings.enable_sound_notifications,
ENABLE_PROACTIVE_CONVERSATION_STARTERS:
apiSettings.enable_proactive_conversation_starters,
ENABLE_SOLVABILITY_ANALYSIS: apiSettings.enable_solvability_analysis,
USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics,
SEARCH_API_KEY: apiSettings.search_api_key || "",
MAX_BUDGET_PER_TASK: apiSettings.max_budget_per_task,
@@ -1,13 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useIsAuthed } from "./use-is-authed";
export const useUserConversations = () => {
const { data: userIsAuthenticated } = useIsAuthed();
return useQuery({
queryKey: ["user", "conversations"],
queryFn: OpenHands.getUserConversations,
enabled: !!userIsAuthenticated,
});
};
+42
View File
@@ -0,0 +1,42 @@
import { useEffect, useRef, useCallback } from "react";
interface UseInfiniteScrollOptions {
hasNextPage: boolean;
isFetchingNextPage: boolean;
fetchNextPage: () => void;
threshold?: number;
}
export const useInfiniteScroll = ({
hasNextPage,
isFetchingNextPage,
fetchNextPage,
threshold = 100,
}: UseInfiniteScrollOptions) => {
const containerRef = useRef<HTMLDivElement>(null);
const handleScroll = useCallback(() => {
if (!containerRef.current || isFetchingNextPage || !hasNextPage) {
return;
}
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
const isNearBottom = scrollTop + clientHeight >= scrollHeight - threshold;
if (isNearBottom) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage, threshold]);
useEffect(() => {
const container = containerRef.current;
if (!container) return undefined;
container.addEventListener("scroll", handleScroll);
return () => {
container.removeEventListener("scroll", handleScroll);
};
}, [handleScroll]);
return containerRef;
};
+2
View File
@@ -147,10 +147,12 @@ export enum I18nKey {
SUGGESTIONS$CLEAN_DEPENDENCIES = "SUGGESTIONS$CLEAN_DEPENDENCIES",
SETTINGS$LLM_SETTINGS = "SETTINGS$LLM_SETTINGS",
SETTINGS$GIT_SETTINGS = "SETTINGS$GIT_SETTINGS",
SETTINGS$GIT_SETTINGS_DESCRIPTION = "SETTINGS$GIT_SETTINGS_DESCRIPTION",
SETTINGS$SOUND_NOTIFICATIONS = "SETTINGS$SOUND_NOTIFICATIONS",
SETTINGS$MAX_BUDGET_PER_TASK = "SETTINGS$MAX_BUDGET_PER_TASK",
SETTINGS$MAX_BUDGET_PER_CONVERSATION = "SETTINGS$MAX_BUDGET_PER_CONVERSATION",
SETTINGS$PROACTIVE_CONVERSATION_STARTERS = "SETTINGS$PROACTIVE_CONVERSATION_STARTERS",
SETTINGS$SOLVABILITY_ANALYSIS = "SETTINGS$SOLVABILITY_ANALYSIS",
SETTINGS$SEARCH_API_KEY = "SETTINGS$SEARCH_API_KEY",
SETTINGS$SEARCH_API_KEY_OPTIONAL = "SETTINGS$SEARCH_API_KEY_OPTIONAL",
SETTINGS$SEARCH_API_KEY_INSTRUCTIONS = "SETTINGS$SEARCH_API_KEY_INSTRUCTIONS",
+32
View File
@@ -2351,6 +2351,22 @@
"tr": "Git Ayarları",
"uk": "Git налаштування"
},
"SETTINGS$GIT_SETTINGS_DESCRIPTION": {
"en": "Configure the username and email that OpenHands uses to commit changes.",
"ja": "OpenHandsがコミットに使用するユーザー名とメールを設定します。",
"zh-CN": "配置OpenHands用于提交更改的用户名和电子邮件。",
"zh-TW": "配置OpenHands用於提交更改的用戶名和電子郵件。",
"ko-KR": "OpenHands가 변경 사항을 커밋할 때 사용하는 사용자 이름과 이메일을 구성합니다.",
"de": "Konfigurieren Sie den Benutzernamen und die E-Mail, die OpenHands zum Committen von Änderungen verwendet.",
"no": "Konfigurer brukernavnet og e-posten som OpenHands bruker for å committe endringer.",
"it": "Configura il nome utente e l'email che OpenHands utilizza per committare le modifiche.",
"pt": "Configure o nome de usuário e o email que o OpenHands usa para fazer commits de alterações.",
"es": "Configure el nombre de usuario y el correo electrónico que OpenHands utiliza para confirmar cambios.",
"ar": "قم بتكوين اسم المستخدم والبريد الإلكتروني الذي يستخدمه OpenHands لارتكاب التغييرات.",
"fr": "Configurez le nom d'utilisateur et l'email qu'OpenHands utilise pour valider les modifications.",
"tr": "OpenHands'ın değişiklikleri commit etmek için kullandığı kullanıcı adını ve e-postayı yapılandırın.",
"uk": "Налаштуйте ім'я користувача та електронну пошту, які OpenHands використовує для фіксації змін."
},
"SETTINGS$SOUND_NOTIFICATIONS": {
"en": "Sound Notifications",
"ja": "サウンド通知",
@@ -2415,6 +2431,22 @@
"tr": "GitHub'da Görevler Öner",
"uk": "Запропонувати завдання на GitHub"
},
"SETTINGS$SOLVABILITY_ANALYSIS": {
"en": "Enable Solvability Analysis",
"ja": "解決可能性分析を有効にする",
"zh-CN": "启用可解决性分析",
"zh-TW": "啟用可解決性分析",
"ko-KR": "해결 가능성 분석 활성화",
"de": "Lösbarkeitsanalyse aktivieren",
"no": "Aktiver løsningsanalyse",
"it": "Abilita analisi di risolvibilità",
"pt": "Ativar análise de solucionabilidade",
"es": "Habilitar análisis de solvencia",
"ar": "تمكين تحليل القابلية للحل",
"fr": "Activer l'analyse de solvabilité",
"tr": "Çözünürlük Analizini Etkinleştir",
"uk": "Увімкнути аналіз розв'язності"
},
"SETTINGS$SEARCH_API_KEY": {
"en": "Search API Key (Tavily)",
"ja": "検索APIキー (Tavily)",
+1
View File
@@ -30,6 +30,7 @@ export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
enable_sound_notifications: DEFAULT_SETTINGS.ENABLE_SOUND_NOTIFICATIONS,
enable_proactive_conversation_starters:
DEFAULT_SETTINGS.ENABLE_PROACTIVE_CONVERSATION_STARTERS,
enable_solvability_analysis: DEFAULT_SETTINGS.ENABLE_SOLVABILITY_ANALYSIS,
user_consents_to_analytics: DEFAULT_SETTINGS.USER_CONSENTS_TO_ANALYTICS,
max_budget_per_task: DEFAULT_SETTINGS.MAX_BUDGET_PER_TASK,
};
+31 -1
View File
@@ -38,6 +38,10 @@ function AppSettingsScreen() {
proactiveConversationsSwitchHasChanged,
setProactiveConversationsSwitchHasChanged,
] = React.useState(false);
const [
solvabilityAnalysisSwitchHasChanged,
setSolvabilityAnalysisSwitchHasChanged,
] = React.useState(false);
const [maxBudgetPerTaskHasChanged, setMaxBudgetPerTaskHasChanged] =
React.useState(false);
const [gitUserNameHasChanged, setGitUserNameHasChanged] =
@@ -61,6 +65,9 @@ function AppSettingsScreen() {
formData.get("enable-proactive-conversations-switch")?.toString() ===
"on";
const enableSolvabilityAnalysis =
formData.get("enable-solvability-analysis-switch")?.toString() === "on";
const maxBudgetPerTaskValue = formData
.get("max-budget-per-task-input")
?.toString();
@@ -79,6 +86,7 @@ function AppSettingsScreen() {
user_consents_to_analytics: enableAnalytics,
ENABLE_SOUND_NOTIFICATIONS: enableSoundNotifications,
ENABLE_PROACTIVE_CONVERSATION_STARTERS: enableProactiveConversations,
ENABLE_SOLVABILITY_ANALYSIS: enableSolvabilityAnalysis,
MAX_BUDGET_PER_TASK: maxBudgetPerTask,
GIT_USER_NAME: gitUserName,
GIT_USER_EMAIL: gitUserEmail,
@@ -136,6 +144,13 @@ function AppSettingsScreen() {
);
};
const checkIfSolvabilityAnalysisSwitchHasChanged = (checked: boolean) => {
const currentSolvabilityAnalysis = !!settings?.ENABLE_SOLVABILITY_ANALYSIS;
setSolvabilityAnalysisSwitchHasChanged(
checked !== currentSolvabilityAnalysis,
);
};
const checkIfMaxBudgetPerTaskHasChanged = (value: string) => {
const newValue = parseMaxBudgetPerTask(value);
const currentValue = settings?.MAX_BUDGET_PER_TASK;
@@ -157,6 +172,7 @@ function AppSettingsScreen() {
!analyticsSwitchHasChanged &&
!soundNotificationsSwitchHasChanged &&
!proactiveConversationsSwitchHasChanged &&
!solvabilityAnalysisSwitchHasChanged &&
!maxBudgetPerTaskHasChanged &&
!gitUserNameHasChanged &&
!gitUserEmailHasChanged;
@@ -209,6 +225,17 @@ function AppSettingsScreen() {
</SettingsSwitch>
)}
{config?.APP_MODE === "saas" && (
<SettingsSwitch
testId="enable-solvability-analysis-switch"
name="enable-solvability-analysis-switch"
defaultIsToggled={!!settings.ENABLE_SOLVABILITY_ANALYSIS}
onToggle={checkIfSolvabilityAnalysisSwitchHasChanged}
>
{t(I18nKey.SETTINGS$SOLVABILITY_ANALYSIS)}
</SettingsSwitch>
)}
<SettingsInput
testId="max-budget-per-task-input"
name="max-budget-per-task-input"
@@ -223,9 +250,12 @@ function AppSettingsScreen() {
/>
<div className="border-t border-t-tertiary pt-6 mt-2 hidden">
<h3 className="text-lg font-medium mb-4">
<h3 className="text-lg font-medium mb-2">
{t(I18nKey.SETTINGS$GIT_SETTINGS)}
</h3>
<p className="text-sm text-secondary mb-4">
{t(I18nKey.SETTINGS$GIT_SETTINGS_DESCRIPTION)}
</p>
<div className="flex flex-col gap-6">
<SettingsInput
testId="git-user-name-input"
+41 -39
View File
@@ -154,47 +154,49 @@ function GitSettingsScreen() {
</div>
)}
{!isSaas && (
<GitHubTokenInput
name="github-token-input"
isGitHubTokenSet={isGitHubTokenSet}
onChange={(value) => {
setGithubTokenInputHasValue(!!value);
}}
onGitHubHostChange={(value) => {
setGithubHostInputHasValue(!!value);
}}
githubHostSet={existingGithubHost}
/>
)}
<div className="flex flex-col gap-4">
{!isSaas && (
<GitHubTokenInput
name="github-token-input"
isGitHubTokenSet={isGitHubTokenSet}
onChange={(value) => {
setGithubTokenInputHasValue(!!value);
}}
onGitHubHostChange={(value) => {
setGithubHostInputHasValue(!!value);
}}
githubHostSet={existingGithubHost}
/>
)}
{!isSaas && (
<GitLabTokenInput
name="gitlab-token-input"
isGitLabTokenSet={isGitLabTokenSet}
onChange={(value) => {
setGitlabTokenInputHasValue(!!value);
}}
onGitLabHostChange={(value) => {
setGitlabHostInputHasValue(!!value);
}}
gitlabHostSet={existingGitlabHost}
/>
)}
{!isSaas && (
<GitLabTokenInput
name="gitlab-token-input"
isGitLabTokenSet={isGitLabTokenSet}
onChange={(value) => {
setGitlabTokenInputHasValue(!!value);
}}
onGitLabHostChange={(value) => {
setGitlabHostInputHasValue(!!value);
}}
gitlabHostSet={existingGitlabHost}
/>
)}
{!isSaas && (
<BitbucketTokenInput
name="bitbucket-token-input"
isBitbucketTokenSet={isBitbucketTokenSet}
onChange={(value) => {
setBitbucketTokenInputHasValue(!!value);
}}
onBitbucketHostChange={(value) => {
setBitbucketHostInputHasValue(!!value);
}}
bitbucketHostSet={existingBitbucketHost}
/>
)}
{!isSaas && (
<BitbucketTokenInput
name="bitbucket-token-input"
isBitbucketTokenSet={isBitbucketTokenSet}
onChange={(value) => {
setBitbucketTokenInputHasValue(!!value);
}}
onBitbucketHostChange={(value) => {
setBitbucketHostInputHasValue(!!value);
}}
bitbucketHostSet={existingBitbucketHost}
/>
)}
</div>
</div>
)}
+9 -8
View File
@@ -203,16 +203,17 @@ export default function MainApp() {
>
<Sidebar />
<div
id="root-outlet"
className="h-[calc(100%-50px)] md:h-full w-full relative overflow-auto"
>
<div className="flex flex-col w-full h-[calc(100%-50px)] md:h-full gap-3">
{config.data?.MAINTENANCE && (
<MaintenanceBanner startTime={config.data.MAINTENANCE.startTime} />
<div className="flex-shrink-0">
<MaintenanceBanner startTime={config.data.MAINTENANCE.startTime} />
</div>
)}
<EmailVerificationGuard>
<Outlet />
</EmailVerificationGuard>
<div id="root-outlet" className="flex-1 relative overflow-auto">
<EmailVerificationGuard>
<Outlet />
</EmailVerificationGuard>
</div>
</div>
{renderAuthModal && (
+1
View File
@@ -17,6 +17,7 @@ export const DEFAULT_SETTINGS: Settings = {
ENABLE_SOUND_NOTIFICATIONS: false,
USER_CONSENTS_TO_ANALYTICS: false,
ENABLE_PROACTIVE_CONVERSATION_STARTERS: false,
ENABLE_SOLVABILITY_ANALYSIS: false,
SEARCH_API_KEY: "",
IS_NEW_USER: true,
MAX_BUDGET_PER_TASK: null,
+2
View File
@@ -43,6 +43,7 @@ export type Settings = {
ENABLE_DEFAULT_CONDENSER: boolean;
ENABLE_SOUND_NOTIFICATIONS: boolean;
ENABLE_PROACTIVE_CONVERSATION_STARTERS: boolean;
ENABLE_SOLVABILITY_ANALYSIS: boolean;
USER_CONSENTS_TO_ANALYTICS: boolean | null;
SEARCH_API_KEY?: string;
IS_NEW_USER?: boolean;
@@ -68,6 +69,7 @@ export type ApiSettings = {
enable_default_condenser: boolean;
enable_sound_notifications: boolean;
enable_proactive_conversation_starters: boolean;
enable_solvability_analysis: boolean;
user_consents_to_analytics: boolean | null;
search_api_key?: string;
provider_tokens_set: Partial<Record<Provider, string | null>>;
@@ -106,10 +106,15 @@ class CodeActAgent(Agent):
def _get_tools(self) -> list['ChatCompletionToolParam']:
# For these models, we use short tool descriptions ( < 1024 tokens)
# to avoid hitting the OpenAI token limit for tool descriptions.
SHORT_TOOL_DESCRIPTION_LLM_SUBSTRS = ['gpt-', 'o3', 'o1', 'o4']
SHORT_TOOL_DESCRIPTION_LLM_SUBSTRS = ['gpt-4', 'o3', 'o1', 'o4']
use_short_tool_desc = False
if self.llm is not None:
# For historical reasons, previously OpenAI enforces max function description length of 1k characters
# https://community.openai.com/t/function-call-description-max-length/529902
# But it no longer seems to be an issue recently
# https://community.openai.com/t/was-the-character-limit-for-schema-descriptions-upgraded/1225975
# Tested on GPT-5 and longer description still works. But we still keep the logic to be safe for older models.
use_short_tool_desc = any(
model_substr in self.llm.config.model
for model_substr in SHORT_TOOL_DESCRIPTION_LLM_SUBSTRS
@@ -66,6 +66,11 @@ Your primary role is to assist users by executing commands, modifying code, and
* Use APIs to work with GitHub or other platforms, unless the user asks otherwise or your task requires browsing.
</SECURITY>
<EXTERNAL_SERVICES>
* When interacting with external services like GitHub, GitLab, or Bitbucket, use their respective APIs instead of browser-based interactions whenever possible.
* Only resort to browser-based interactions with these services if specifically requested by the user or if the required operation cannot be performed via API.
</EXTERNAL_SERVICES>
<ENVIRONMENT_SETUP>
* When user asks you to run an application, don't stop if the application is not installed. Instead, please install the application and run the command again.
* If you encounter missing dependencies:
@@ -1,79 +1,4 @@
You are OpenHands agent, a helpful AI assistant that can interact with a computer to solve tasks.
<ROLE>
Your primary role is to assist users by executing commands, modifying code, and solving technical problems effectively. You should be thorough, methodical, and prioritize quality over speed.
* If the user asks a question, like "why is X happening", don't try to fix the problem. Just give an answer to the question.
</ROLE>
<EFFICIENCY>
* Each action you take is somewhat expensive. Wherever possible, combine multiple actions into a single action, e.g. combine multiple bash commands into one, using sed and grep to edit/view multiple files at once.
* When exploring the codebase, use efficient tools like find, grep, and git commands with appropriate filters to minimize unnecessary operations.
</EFFICIENCY>
<FILE_SYSTEM_GUIDELINES>
* When a user provides a file path, do NOT assume it's relative to the current working directory. First explore the file system to locate the file before working on it.
* If asked to edit a file, edit the file directly, rather than creating a new file with a different filename.
* For global search-and-replace operations, consider using `sed` instead of opening file editors multiple times.
</FILE_SYSTEM_GUIDELINES>
<CODE_QUALITY>
* Write clean, efficient code with minimal comments. Avoid redundancy in comments: Do not repeat information that can be easily inferred from the code itself.
* When implementing solutions, focus on making the minimal changes needed to solve the problem.
* Before implementing any changes, first thoroughly understand the codebase through exploration.
* If you are adding a lot of code to a function or file, consider splitting the function or file into smaller pieces when appropriate.
* Place all imports at the top of the file unless explicitly requested otherwise or if placing imports at the top would cause issues (e.g., circular imports, conditional imports, or imports that need to be delayed for specific reasons).
</CODE_QUALITY>
<VERSION_CONTROL>
* If there are existing git user credentials already configured, use them and add Co-authored-by: openhands <openhands@all-hands.dev> to any commits messages you make. if a git config doesn't exist use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
* Exercise caution with git operations. Do NOT make potentially dangerous changes (e.g., pushing to main, deleting repositories) unless explicitly asked to do so.
* When committing changes, use `git status` to see all modified files, and stage all files necessary for the commit. Use `git commit -a` whenever possible.
* Do NOT commit files that typically shouldn't go into version control (e.g., node_modules/, .env files, build directories, cache files, large binaries) unless explicitly instructed by the user.
* If unsure about committing certain files, check for the presence of .gitignore files or ask the user for clarification.
</VERSION_CONTROL>
<PULL_REQUESTS>
* **Important**: Do not push to the remote branch and/or start a pull request unless explicitly asked to do so.
* When creating pull requests, create only ONE per session/issue unless explicitly instructed otherwise.
* When working with an existing PR, update it with new commits rather than creating additional PRs for the same issue.
* When updating a PR, preserve the original PR title and purpose, updating description only when necessary.
</PULL_REQUESTS>
<PROBLEM_SOLVING_WORKFLOW>
1. EXPLORATION: Thoroughly explore relevant files and understand the context before proposing solutions
2. ANALYSIS: Consider multiple approaches and select the most promising one
3. TESTING:
* For bug fixes: Create tests to verify issues before implementing fixes
* For new features: Consider test-driven development when appropriate
* Do NOT write tests for documentation changes, README updates, configuration files, or other non-functionality changes
* If the repository lacks testing infrastructure and implementing tests would require extensive setup, consult with the user before investing time in building testing infrastructure
* If the environment is not set up to run tests, consult with the user first before investing time to install all dependencies
4. IMPLEMENTATION: Make focused, minimal changes to address the problem
5. VERIFICATION: If the environment is set up to run tests, test your implementation thoroughly, including edge cases. If the environment is not set up to run tests, consult with the user first before investing time to run tests.
</PROBLEM_SOLVING_WORKFLOW>
<SECURITY>
* Only use GITHUB_TOKEN and other credentials in ways the user has explicitly requested and would expect.
* Use APIs to work with GitHub or other platforms, unless the user asks otherwise or your task requires browsing.
</SECURITY>
<ENVIRONMENT_SETUP>
* When user asks you to run an application, don't stop if the application is not installed. Instead, please install the application and run the command again.
* If you encounter missing dependencies:
1. First, look around in the repository for existing dependency files (requirements.txt, pyproject.toml, package.json, Gemfile, etc.)
2. If dependency files exist, use them to install all dependencies at once (e.g., `pip install -r requirements.txt`, `npm install`, etc.)
3. Only install individual packages directly if no dependency files are found or if only specific packages are needed
* Similarly, if you encounter missing dependencies for essential tools requested by the user, install them when possible.
</ENVIRONMENT_SETUP>
<TROUBLESHOOTING>
* If you've made repeated attempts to solve a problem but tests still fail or the user reports it's still broken:
1. Step back and reflect on 5-7 different possible sources of the problem
2. Assess the likelihood of each possible cause
3. Methodically address the most likely causes, starting with the highest probability
4. Document your reasoning process
* When you run into any major issue while executing a plan from the user, please don't try to directly work around it. Instead, propose a new plan and confirm with the user before proceeding.
</TROUBLESHOOTING>
{% include "system_prompt.j2" %}
<INTERACTION_RULES>
* When the user instructions are high-level or vague, explore the codebase before implementing solutions or interacting with users to figure out the best approach.
@@ -1,55 +1,4 @@
You are OpenHands agent, a helpful AI assistant that can interact with a computer to solve tasks.
<ROLE>
Your primary role is to assist users by executing commands, modifying code, and solving technical problems effectively. You should be thorough, methodical, and prioritize quality over speed.
* If the user asks a question, like "why is X happening", don't try to fix the problem. Just give an answer to the question.
</ROLE>
<EFFICIENCY>
* Each action you take is somewhat expensive. Wherever possible, combine multiple actions into a single action, e.g. combine multiple bash commands into one, using sed and grep to edit/view multiple files at once.
* When exploring the codebase, use efficient tools like find, grep, and git commands with appropriate filters to minimize unnecessary operations.
</EFFICIENCY>
<FILE_SYSTEM_GUIDELINES>
* When a user provides a file path, do NOT assume it's relative to the current working directory. First explore the file system to locate the file before working on it.
* If asked to edit a file, edit the file directly, rather than creating a new file with a different filename.
* For global search-and-replace operations, consider using `sed` instead of opening file editors multiple times.
</FILE_SYSTEM_GUIDELINES>
<CODE_QUALITY>
* Write clean, efficient code with minimal comments. Avoid redundancy in comments: Do not repeat information that can be easily inferred from the code itself.
* When implementing solutions, focus on making the minimal changes needed to solve the problem.
* Before implementing any changes, first thoroughly understand the codebase through exploration.
* If you are adding a lot of code to a function or file, consider splitting the function or file into smaller pieces when appropriate.
* Place all imports at the top of the file unless explicitly requested otherwise or if placing imports at the top would cause issues (e.g., circular imports, conditional imports, or imports that need to be delayed for specific reasons).
</CODE_QUALITY>
<VERSION_CONTROL>
* If there are existing git user credentials already configured, use them and add Co-authored-by: openhands <openhands@all-hands.dev> to any commits messages you make. if a git config doesn't exist use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
* Exercise caution with git operations. Do NOT make potentially dangerous changes (e.g., pushing to main, deleting repositories) unless explicitly asked to do so.
* When committing changes, use `git status` to see all modified files, and stage all files necessary for the commit. Use `git commit -a` whenever possible.
* Do NOT commit files that typically shouldn't go into version control (e.g., node_modules/, .env files, build directories, cache files, large binaries) unless explicitly instructed by the user.
* If unsure about committing certain files, check for the presence of .gitignore files or ask the user for clarification.
</VERSION_CONTROL>
<PULL_REQUESTS>
* When creating pull requests, create only ONE per session/issue unless explicitly instructed otherwise.
* When working with an existing PR, update it with new commits rather than creating additional PRs for the same issue.
* When updating a PR, preserve the original PR title and purpose, updating description only when necessary.
</PULL_REQUESTS>
<PROBLEM_SOLVING_WORKFLOW>
1. EXPLORATION: Thoroughly explore relevant files and understand the context before proposing solutions
2. ANALYSIS: Consider multiple approaches and select the most promising one
3. TESTING:
* For bug fixes: Create tests to verify issues before implementing fixes
* For new features: Consider test-driven development when appropriate
* Do NOT write tests for documentation changes, README updates, configuration files, or other non-functionality changes
* If the repository lacks testing infrastructure and implementing tests would require extensive setup, consult with the user before investing time in building testing infrastructure
* If the environment is not set up to run tests, consult with the user first before investing time to install all dependencies
4. IMPLEMENTATION: Make focused, minimal changes to address the problem
5. VERIFICATION: If the environment is set up to run tests, test your implementation thoroughly, including edge cases. If the environment is not set up to run tests, consult with the user first before investing time to run tests.
</PROBLEM_SOLVING_WORKFLOW>
{% include "system_prompt.j2" %}
<TASK_MANAGEMENT>
* For complex, long-horizon tasks, create a TODO.md file to track progress:
@@ -88,26 +37,3 @@ Your primary role is to assist users by executing commands, modifying code, and
d. Once complete, check off the item in TODO.md
e. Proceed to the next unchecked item
</TASK_MANAGEMENT>
<SECURITY>
* Only use GITHUB_TOKEN and other credentials in ways the user has explicitly requested and would expect.
* Use APIs to work with GitHub or other platforms, unless the user asks otherwise or your task requires browsing.
</SECURITY>
<ENVIRONMENT_SETUP>
* When user asks you to run an application, don't stop if the application is not installed. Instead, please install the application and run the command again.
* If you encounter missing dependencies:
1. First, look around in the repository for existing dependency files (requirements.txt, pyproject.toml, package.json, Gemfile, etc.)
2. If dependency files exist, use them to install all dependencies at once (e.g., `pip install -r requirements.txt`, `npm install`, etc.)
3. Only install individual packages directly if no dependency files are found or if only specific packages are needed
* Similarly, if you encounter missing dependencies for essential tools requested by the user, install them when possible.
</ENVIRONMENT_SETUP>
<TROUBLESHOOTING>
* If you've made repeated attempts to solve a problem but tests still fail or the user reports it's still broken:
1. Step back and reflect on 5-7 different possible sources of the problem
2. Assess the likelihood of each possible cause
3. Methodically address the most likely causes, starting with the highest probability
4. Document your reasoning process
* When you run into any major issue while executing a plan from the user, please don't try to directly work around it. Instead, propose a new plan and confirm with the user before proceeding.
</TROUBLESHOOTING>
@@ -1,7 +1,6 @@
import sys
from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk
from openhands.agenthub.codeact_agent.tools.prompt import refine_prompt
from openhands.llm.tool_names import EXECUTE_BASH_TOOL_NAME
_DETAILED_BASH_DESCRIPTION = """Execute a bash command in the terminal within a persistent shell session.
@@ -35,12 +34,6 @@ _SHORT_BASH_DESCRIPTION = """Execute a bash command in the terminal.
* One command at a time: You can only execute one bash command at a time. If you need to run multiple commands sequentially, you can use `&&` or `;` to chain them together."""
def refine_prompt(prompt: str):
if sys.platform == 'win32':
return prompt.replace('bash', 'powershell')
return prompt
def create_cmd_run_tool(
use_short_description: bool = False,
) -> ChatCompletionToolParam:
@@ -0,0 +1,29 @@
import re
import sys
def refine_prompt(prompt: str):
"""
Refines the prompt based on the platform.
On Windows systems, replaces 'bash' with 'powershell' and 'execute_bash' with 'execute_powershell'
to ensure commands work correctly on the Windows platform.
Args:
prompt: The prompt text to refine
Returns:
The refined prompt text
"""
if sys.platform == 'win32':
# Replace 'bash' with 'powershell' including tool names like 'execute_bash'
# First replace 'execute_bash' with 'execute_powershell' to handle tool names
result = re.sub(
r'\bexecute_bash\b', 'execute_powershell', prompt, flags=re.IGNORECASE
)
# Then replace standalone 'bash' with 'powershell'
result = re.sub(
r'(?<!execute_)(?<!_)\bbash\b', 'powershell', result, flags=re.IGNORECASE
)
return result
return prompt
+84 -10
View File
@@ -11,6 +11,7 @@ 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
@@ -65,6 +66,7 @@ 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,
@@ -236,13 +238,19 @@ def display_mcp_errors() -> None:
# Prompt output display functions
def display_thought_if_new(thought: str) -> None:
"""Display a thought only if it hasn't been displayed recently."""
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
"""
global recent_thoughts
if thought and thought.strip():
# Check if this thought was recently displayed
if thought not in recent_thoughts:
display_message(thought)
display_message(thought, is_agent_message=is_agent_message)
recent_thoughts.append(thought)
# Keep only the most recent thoughts
if len(recent_thoughts) > MAX_RECENT_THOUGHTS:
@@ -255,7 +263,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_message(event.thought)
display_thought_if_new(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
@@ -269,14 +277,15 @@ 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_message(event.thought)
display_thought_if_new(event.thought)
if hasattr(event, 'final_thought') and event.final_thought:
display_message(event.final_thought)
# Display final thoughts with agent styling
display_message(event.final_thought, is_agent_message=True)
if isinstance(event, MessageAction):
if event.source == EventSource.AGENT:
# Check if this message content is a duplicate thought
display_thought_if_new(event.content)
# Display agent messages with styling and markdown rendering
display_thought_if_new(event.content, is_agent_message=True)
elif isinstance(event, CmdOutputObservation):
display_command_output(event.content)
elif isinstance(event, FileEditObservation):
@@ -291,11 +300,76 @@ def display_event(event: Event, config: OpenHandsConfig) -> None:
display_error(event.content)
def display_message(message: str) -> None:
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)
"""
message = message.strip()
if message:
print_formatted_text(f'\n{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)
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
def display_error(error: str) -> None:
+1
View File
@@ -80,6 +80,7 @@ class LLMConfig(BaseModel):
# Note: this setting is actually global, unlike drop_params
modify_params: bool = Field(default=True)
disable_vision: bool | None = Field(default=None)
disable_stop_word: bool | None = Field(default=False)
caching_prompt: bool = Field(default=True)
log_completions: bool = Field(default=False)
log_completions_folder: str = Field(default=os.path.join(LOG_DIR, 'completions'))
@@ -72,6 +72,7 @@ class OpenHandsConfig(BaseModel):
file_store_path: str = Field(default='~/.openhands')
file_store_web_hook_url: str | None = Field(default=None)
file_store_web_hook_headers: dict | None = Field(default=None)
file_store_web_hook_batch: bool = Field(default=False)
enable_browser: bool = Field(default=True)
save_trajectory_path: str | None = Field(default=None)
save_screenshots_in_trajectory: bool = Field(default=False)
+1 -1
View File
@@ -383,7 +383,7 @@ Do NOT assume the environment is the same as in the example above.
"""
example = example.lstrip()
return example
return refine_prompt(example)
IN_CONTEXT_LEARNING_EXAMPLE_PREFIX = get_example_for_tools
+5 -2
View File
@@ -305,8 +305,11 @@ class LLM(RetryMixin, DebugMixin):
)
kwargs['messages'] = messages
# add stop words if the model supports it
if self.config.model not in MODELS_WITHOUT_STOP_WORDS:
# add stop words if the model supports it and stop words are not disabled
if (
self.config.model not in MODELS_WITHOUT_STOP_WORDS
and not self.config.disable_stop_word
):
kwargs['stop'] = STOP_WORDS
mock_fncall_tools = kwargs.pop('tools')
+1 -1
View File
@@ -4,6 +4,7 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING:
from openhands.controller.agent import Agent
from openhands.memory.memory import Memory
from mcp import McpError
@@ -20,7 +21,6 @@ from openhands.events.observation.mcp import MCPObservation
from openhands.events.observation.observation import Observation
from openhands.mcp.client import MCPClient
from openhands.mcp.error_collector import mcp_error_collector
from openhands.memory.memory import Memory
from openhands.runtime.base import Runtime
from openhands.runtime.impl.cli.cli_runtime import CLIRuntime
+91 -6
View File
@@ -186,6 +186,13 @@ class ActionExecutor:
self.user_id = user_id
self.git_user_name = git_user_name
self.git_user_email = git_user_email
# Set git environment variables as fallback
os.environ['GIT_AUTHOR_NAME'] = self.git_user_name
os.environ['GIT_AUTHOR_EMAIL'] = self.git_user_email
os.environ['GIT_COMMITTER_NAME'] = self.git_user_name
os.environ['GIT_COMMITTER_EMAIL'] = self.git_user_email
_updated_user_id = init_user_and_working_directory(
username=username, user_id=self.user_id, initial_cwd=work_dir
)
@@ -371,13 +378,21 @@ class ActionExecutor:
)
INIT_COMMANDS.append('export GIT_CONFIG=$(pwd)/.git_config')
else:
# Non-local (implies Linux/macOS)
# Non-local (cloud/container) - try global first, fallback to local config
INIT_COMMANDS.append(
f'git config --global user.name "{self.git_user_name}"'
f'git config --global user.name "{self.git_user_name}" 2>/dev/null || '
f'git config --file ./.git_config user.name "{self.git_user_name}"'
)
INIT_COMMANDS.append(
f'git config --global user.email "{self.git_user_email}"'
f'git config --global user.email "{self.git_user_email}" 2>/dev/null || '
f'git config --file ./.git_config user.email "{self.git_user_email}"'
)
# Set GIT_CONFIG as fallback in case global config failed
INIT_COMMANDS.append('export GIT_CONFIG=$(pwd)/.git_config')
# Add verification commands to ensure git config was applied
INIT_COMMANDS.append('git config user.name || echo "Git user.name not set"')
INIT_COMMANDS.append('git config user.email || echo "Git user.email not set"')
# Determine no-pager command
if is_windows:
@@ -394,9 +409,28 @@ class ActionExecutor:
logger.debug(f'Executing init command: {command}')
obs = await self.run(action)
assert isinstance(obs, CmdOutputObservation)
logger.debug(
f'Init command outputs (exit code: {obs.exit_code}): {obs.content}'
)
# Enhanced logging for git config commands
if 'git config' in command:
if obs.exit_code == 0:
logger.info(f'Git config command succeeded: {command}')
if obs.content.strip():
logger.info(f'Git config output: {obs.content.strip()}')
else:
logger.error(f'Git config command failed: {command}')
logger.error(f'Git config error output: {obs.content}')
else:
logger.debug(
f'Init command outputs (exit code: {obs.exit_code}): {obs.content}'
)
# Don't fail initialization if git config verification commands fail
if 'git config user.' in command and '||' in command:
# This is a verification command, log but don't fail
if obs.exit_code != 0:
logger.warning(f'Git config verification failed: {command}')
continue
assert obs.exit_code == 0
logger.debug('Bash init commands completed')
@@ -1012,6 +1046,57 @@ if __name__ == '__main__':
return {'status': 'not initialized'}
return {'status': 'ok'}
@app.get('/debug/git-config')
async def debug_git_config():
"""Debug endpoint to check current git configuration"""
assert client is not None
try:
# Check current git configuration
name_action = CmdRunAction(command='git config user.name')
email_action = CmdRunAction(command='git config user.email')
name_obs = await client.run(name_action)
email_obs = await client.run(email_action)
# Check environment variables
env_author_name = os.environ.get('GIT_AUTHOR_NAME', 'NOT SET')
env_author_email = os.environ.get('GIT_AUTHOR_EMAIL', 'NOT SET')
env_committer_name = os.environ.get('GIT_COMMITTER_NAME', 'NOT SET')
env_committer_email = os.environ.get('GIT_COMMITTER_EMAIL', 'NOT SET')
# Check if GIT_CONFIG is set
git_config_env = os.environ.get('GIT_CONFIG', 'NOT SET')
# Handle both CmdOutputObservation and ErrorObservation
name_exit_code = getattr(name_obs, 'exit_code', 1)
email_exit_code = getattr(email_obs, 'exit_code', 1)
name_content = getattr(name_obs, 'content', 'ERROR')
email_content = getattr(email_obs, 'content', 'ERROR')
return {
'current_git_user_name': name_content.strip()
if name_exit_code == 0
else 'NOT SET',
'current_git_user_email': email_content.strip()
if email_exit_code == 0
else 'NOT SET',
'configured_git_user_name': client.git_user_name,
'configured_git_user_email': client.git_user_email,
'environment_variables': {
'GIT_AUTHOR_NAME': env_author_name,
'GIT_AUTHOR_EMAIL': env_author_email,
'GIT_COMMITTER_NAME': env_committer_name,
'GIT_COMMITTER_EMAIL': env_committer_email,
'GIT_CONFIG': git_config_env,
},
'git_config_check_exit_codes': {
'name_check': name_exit_code,
'email_check': email_exit_code,
},
}
except Exception as e:
return {'error': str(e)}
# ================================
# VSCode-specific operations
# ================================
@@ -9,6 +9,7 @@ import docker
import httpx
import tenacity
from docker.models.containers import Container
from docker.types import DriverConfig, Mount
from openhands.core.config import OpenHandsConfig
from openhands.core.exceptions import (
@@ -254,10 +255,24 @@ class DockerRuntime(ActionExecutionClient):
for mount in mounts:
parts = mount.split(':')
if len(parts) >= 2:
host_path = os.path.abspath(parts[0])
# Support both bind mounts (absolute paths) and Docker named volumes.
# Named volume syntax:
# volume:<name> (explicit)
# <name> (implicit when not starting with '/')
raw_host_part = parts[0]
if raw_host_part.startswith('volume:'):
host_path = raw_host_part.split('volume:', 1)[1]
elif not os.path.isabs(raw_host_part):
host_path = raw_host_part # treat as named volume
else:
host_path = os.path.abspath(raw_host_part)
container_path = parts[1]
# Default mode is 'rw' if not specified
mount_mode = parts[2] if len(parts) > 2 else 'rw'
# Skip overlay mounts here; they will be handled separately via Mount objects
if 'overlay' in mount_mode:
continue
volumes[host_path] = {
'bind': container_path,
@@ -286,6 +301,73 @@ class DockerRuntime(ActionExecutionClient):
return volumes
def _process_overlay_mounts(self) -> list[Mount]:
"""Process overlay mounts specified in sandbox.volumes with mode containing 'overlay'.
Returns:
List of docker.types.Mount objects configured with overlay driver providing
read-only lowerdir with per-container copy-on-write upper/work layers.
"""
overlay_mounts: list[Mount] = []
# No volumes configured
if self.config.sandbox.volumes is None:
return overlay_mounts
# Base directory for overlay upper/work layers from env var
overlay_base = os.environ.get('SANDBOX_VOLUME_OVERLAYS')
if not overlay_base:
# If no base path provided, skip overlay processing
return overlay_mounts
os.makedirs(overlay_base, exist_ok=True)
mount_specs = self.config.sandbox.volumes.split(',')
for idx, mount_spec in enumerate(mount_specs):
parts = mount_spec.split(':')
if len(parts) < 2:
continue
host_path = os.path.abspath(parts[0])
container_path = parts[1]
mount_mode = parts[2] if len(parts) > 2 else 'rw'
# Only consider overlay mounts for host-bind paths (absolute)
if (not os.path.isabs(parts[0])) or ('overlay' not in mount_mode):
continue
# Prepare upper and work directories unique to this container and mount
overlay_dir = os.path.join(overlay_base, self.container_name, f'{idx}')
upper_dir = os.path.join(overlay_dir, 'upper')
work_dir = os.path.join(overlay_dir, 'work')
os.makedirs(upper_dir, exist_ok=True)
os.makedirs(work_dir, exist_ok=True)
driver_cfg = DriverConfig(
name='local',
options={
'type': 'overlay',
'device': 'overlay',
'o': f'lowerdir={host_path},upperdir={upper_dir},workdir={work_dir}',
},
)
mount = Mount(
target=container_path,
source='', # Anonymous volume
type='volume',
labels={
'app': 'openhands',
'role': 'worker',
'container': self.container_name,
},
driver_config=driver_cfg,
)
overlay_mounts.append(mount)
return overlay_mounts
def init_container(self) -> None:
self.log('debug', 'Preparing to start container...')
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
@@ -409,6 +491,9 @@ class DockerRuntime(ActionExecutionClient):
try:
if self.runtime_container_image is None:
raise ValueError('Runtime container image is not set')
# Process overlay mounts (read-only lower with per-container COW)
overlay_mounts = self._process_overlay_mounts()
self.container = self.docker_client.containers.run(
self.runtime_container_image,
command=command,
@@ -421,6 +506,7 @@ class DockerRuntime(ActionExecutionClient):
detach=True,
environment=environment,
volumes=volumes, # type: ignore
mounts=overlay_mounts, # type: ignore
device_requests=device_requests,
**(self.config.sandbox.docker_runtime_kwargs or {}),
)
@@ -609,7 +695,8 @@ class DockerRuntime(ActionExecutionClient):
def pause(self) -> None:
"""Pause the runtime by stopping the container.
This is different from container.stop() as it ensures environment variables are properly preserved."""
This is different from container.stop() as it ensures environment variables are properly preserved.
"""
if not self.container:
raise RuntimeError('Container not initialized')
@@ -622,7 +709,8 @@ class DockerRuntime(ActionExecutionClient):
def resume(self) -> None:
"""Resume the runtime by starting the container.
This is different from container.start() as it ensures environment variables are properly restored."""
This is different from container.start() as it ensures environment variables are properly restored.
"""
if not self.container:
raise RuntimeError('Container not initialized')
+5
View File
@@ -280,6 +280,11 @@ def prep_build_folder(
),
)
# Copy the 'microagents' directory (Microagents)
shutil.copytree(
Path(project_root, 'microagents'), Path(build_folder, 'code', 'microagents')
)
# Copy pyproject.toml and poetry.lock files
for file in ['pyproject.toml', 'poetry.lock']:
src = Path(openhands_source_dir, file)
@@ -239,7 +239,8 @@ COPY ./code/pyproject.toml ./code/poetry.lock /openhands/code/
# ================================================================
RUN if [ -d /openhands/code/openhands ]; then rm -rf /openhands/code/openhands; fi
COPY ./code/pyproject.toml ./code/poetry.lock /openhands/code/
RUN if [ -d /openhands/code/microagents ]; then rm -rf /openhands/code/microagents; fi
COPY ./code/microagents /openhands/code/microagents
COPY ./code/openhands /openhands/code/openhands
RUN chmod a+rwx /openhands/code/openhands/__init__.py
+5 -4
View File
@@ -27,10 +27,11 @@ assert isinstance(server_config_interface, ServerConfig), (
)
server_config: ServerConfig = server_config_interface
file_store: FileStore = get_file_store(
config.file_store,
config.file_store_path,
config.file_store_web_hook_url,
config.file_store_web_hook_headers,
file_store_type=config.file_store,
file_store_path=config.file_store_path,
file_store_web_hook_url=config.file_store_web_hook_url,
file_store_web_hook_headers=config.file_store_web_hook_headers,
file_store_web_hook_batch=config.file_store_web_hook_batch,
)
client_manager = None
+34
View File
@@ -61,9 +61,12 @@ The `WebHookFileStore` wraps another `FileStore` implementation and sends HTTP r
**Configuration Options:**
- `file_store_web_hook_url`: The base URL for webhook requests
- `file_store_web_hook_headers`: HTTP headers to include in webhook requests
- `file_store_web_hook_batch`: Whether to use batched webhook requests (default: false)
### Protocol Details
#### Standard Webhook Protocol (Non-Batched)
1. **File Write Operation**:
- When a file is written, a POST request is sent to `{base_url}{path}`
- The request body contains the file contents
@@ -73,6 +76,27 @@ The `WebHookFileStore` wraps another `FileStore` implementation and sends HTTP r
- When a file is deleted, a DELETE request is sent to `{base_url}{path}`
- The operation is retried up to 3 times with a 1-second delay between attempts
#### Batched Webhook Protocol
The `BatchedWebHookFileStore` extends the webhook functionality by batching multiple file operations into a single request, which can significantly improve performance when many files are being modified in a short period of time.
1. **Batch Request**:
- A single POST request is sent to `{base_url}` with a JSON array in the body
- Each item in the array contains:
- `method`: "POST" for write operations, "DELETE" for delete operations
- `path`: The file path
- `content`: The file contents (for write operations only)
- `encoding`: "base64" if binary content was base64-encoded (optional)
2. **Batch Triggering**:
- Batches are sent when one of the following conditions is met:
- A timeout period has elapsed (defaults to 5 seconds, configurable via constructor parameter)
- The total size of batched content exceeds a size limit (defaults to 1MB, configurable via constructor parameter)
- The `flush()` method is explicitly called
3. **Error Handling**:
- The batch request is retried up to 3 times with a 1-second delay between attempts
## Configuration
To configure the storage module in OpenHands, use the following configuration options:
@@ -90,4 +114,14 @@ file_store_web_hook_url = "https://example.com/api/files"
# Optional webhook headers (JSON string)
file_store_web_hook_headers = '{"Authorization": "Bearer token"}'
# Optional batched webhook mode (default: false)
file_store_web_hook_batch = true
```
**Batched Webhook Configuration:**
The batched webhook behavior uses predefined constants with the following default values:
- Batch timeout: 5 seconds
- Batch size limit: 1MB (1048576 bytes)
These values can be customized by passing `batch_timeout_seconds` and `batch_size_limit_bytes` parameters to the `BatchedWebHookFileStore` constructor.
+19 -5
View File
@@ -2,6 +2,7 @@ import os
import httpx
from openhands.storage.batched_web_hook import BatchedWebHookFileStore
from openhands.storage.files import FileStore
from openhands.storage.google_cloud import GoogleCloudFileStore
from openhands.storage.local import LocalFileStore
@@ -15,6 +16,7 @@ def get_file_store(
file_store_path: str | None = None,
file_store_web_hook_url: str | None = None,
file_store_web_hook_headers: dict | None = None,
file_store_web_hook_batch: bool = False,
) -> FileStore:
store: FileStore
if file_store_type == 'local':
@@ -35,9 +37,21 @@ def get_file_store(
file_store_web_hook_headers['X-Session-API-Key'] = os.getenv(
'SESSION_API_KEY'
)
store = WebHookFileStore(
store,
file_store_web_hook_url,
httpx.Client(headers=file_store_web_hook_headers or {}),
)
client = httpx.Client(headers=file_store_web_hook_headers or {})
if file_store_web_hook_batch:
# Use batched webhook file store
store = BatchedWebHookFileStore(
store,
file_store_web_hook_url,
client,
)
else:
# Use regular webhook file store
store = WebHookFileStore(
store,
file_store_web_hook_url,
client,
)
return store
+274
View File
@@ -0,0 +1,274 @@
import threading
from typing import Optional, Union
import httpx
import tenacity
from openhands.storage.files import FileStore
from openhands.utils.async_utils import EXECUTOR
# Constants for batching configuration
WEBHOOK_BATCH_TIMEOUT_SECONDS = 5.0
WEBHOOK_BATCH_SIZE_LIMIT_BYTES = 1048576 # 1MB
class BatchedWebHookFileStore(FileStore):
"""
File store which batches updates before sending them to a webhook.
This class wraps another FileStore implementation and sends HTTP requests
to a specified URL when files are written or deleted. Updates are batched
and sent together after a certain amount of time passes or if the content
size exceeds a threshold.
Attributes:
file_store: The underlying FileStore implementation
base_url: The base URL for webhook requests
client: The HTTP client used to make webhook requests
batch_timeout_seconds: Time in seconds after which a batch is sent (default: WEBHOOK_BATCH_TIMEOUT_SECONDS)
batch_size_limit_bytes: Size limit in bytes after which a batch is sent (default: WEBHOOK_BATCH_SIZE_LIMIT_BYTES)
_batch_lock: Lock for thread-safe access to the batch
_batch: Dictionary of pending file updates
_batch_timer: Timer for sending batches after timeout
_batch_size: Current size of the batch in bytes
"""
file_store: FileStore
base_url: str
client: httpx.Client
batch_timeout_seconds: float
batch_size_limit_bytes: int
_batch_lock: threading.Lock
_batch: dict[str, tuple[str, Optional[Union[str, bytes]]]]
_batch_timer: Optional[threading.Timer]
_batch_size: int
def __init__(
self,
file_store: FileStore,
base_url: str,
client: Optional[httpx.Client] = None,
batch_timeout_seconds: Optional[float] = None,
batch_size_limit_bytes: Optional[int] = None,
):
"""
Initialize a BatchedWebHookFileStore.
Args:
file_store: The underlying FileStore implementation
base_url: The base URL for webhook requests
client: Optional HTTP client to use for requests. If None, a new client will be created.
batch_timeout_seconds: Time in seconds after which a batch is sent.
If None, uses the default constant WEBHOOK_BATCH_TIMEOUT_SECONDS.
batch_size_limit_bytes: Size limit in bytes after which a batch is sent.
If None, uses the default constant WEBHOOK_BATCH_SIZE_LIMIT_BYTES.
"""
self.file_store = file_store
self.base_url = base_url
if client is None:
client = httpx.Client()
self.client = client
# Use provided values or default constants
self.batch_timeout_seconds = (
batch_timeout_seconds or WEBHOOK_BATCH_TIMEOUT_SECONDS
)
self.batch_size_limit_bytes = (
batch_size_limit_bytes or WEBHOOK_BATCH_SIZE_LIMIT_BYTES
)
# Initialize batch state
self._batch_lock = threading.Lock()
self._batch = {} # Maps path -> (operation, content)
self._batch_timer = None
self._batch_size = 0
def write(self, path: str, contents: Union[str, bytes]) -> None:
"""
Write contents to a file and queue a webhook update.
Args:
path: The path to write to
contents: The contents to write
"""
self.file_store.write(path, contents)
self._queue_update(path, 'write', contents)
def read(self, path: str) -> str:
"""
Read contents from a file.
Args:
path: The path to read from
Returns:
The contents of the file
"""
return self.file_store.read(path)
def list(self, path: str) -> list[str]:
"""
List files in a directory.
Args:
path: The directory path to list
Returns:
A list of file paths
"""
return self.file_store.list(path)
def delete(self, path: str) -> None:
"""
Delete a file and queue a webhook update.
Args:
path: The path to delete
"""
self.file_store.delete(path)
self._queue_update(path, 'delete', None)
def _queue_update(
self, path: str, operation: str, contents: Optional[Union[str, bytes]]
) -> None:
"""
Queue an update to be sent to the webhook.
Args:
path: The path that was modified
operation: The operation performed ("write" or "delete")
contents: The contents that were written (None for delete operations)
"""
with self._batch_lock:
# Calculate content size
content_size = 0
if contents is not None:
if isinstance(contents, str):
content_size = len(contents.encode('utf-8'))
else:
content_size = len(contents)
# Update batch size calculation
# If this path already exists in the batch, subtract its previous size
if path in self._batch:
prev_op, prev_contents = self._batch[path]
if prev_contents is not None:
if isinstance(prev_contents, str):
self._batch_size -= len(prev_contents.encode('utf-8'))
else:
self._batch_size -= len(prev_contents)
# Add new content size
self._batch_size += content_size
# Add to batch
self._batch[path] = (operation, contents)
# Check if we need to send the batch due to size limit
if self._batch_size >= self.batch_size_limit_bytes:
# Submit to executor to avoid blocking
EXECUTOR.submit(self._send_batch)
return
# Start or reset the timer for sending the batch
if self._batch_timer is not None:
self._batch_timer.cancel()
self._batch_timer = None
timer = threading.Timer(
self.batch_timeout_seconds, self._send_batch_from_timer
)
timer.daemon = True
timer.start()
self._batch_timer = timer
def _send_batch_from_timer(self) -> None:
"""
Send the batch from the timer thread.
This method is called by the timer and submits the actual sending to the executor.
"""
EXECUTOR.submit(self._send_batch)
def _send_batch(self) -> None:
"""
Send the current batch of updates to the webhook as a single request.
This method acquires the batch lock and processes all pending updates in one batch.
"""
batch_to_send: dict[str, tuple[str, Optional[Union[str, bytes]]]] = {}
with self._batch_lock:
if not self._batch:
return
# Copy the batch and clear the current one
batch_to_send = self._batch.copy()
self._batch.clear()
self._batch_size = 0
# Cancel any pending timer
if self._batch_timer is not None:
self._batch_timer.cancel()
self._batch_timer = None
# Process the entire batch in a single request
if batch_to_send:
try:
self._send_batch_request(batch_to_send)
except Exception as e:
# Log the error
print(f'Error sending webhook batch: {e}')
@tenacity.retry(
wait=tenacity.wait_fixed(1),
stop=tenacity.stop_after_attempt(3),
)
def _send_batch_request(
self, batch: dict[str, tuple[str, Optional[Union[str, bytes]]]]
) -> None:
"""
Send a single batch request to the webhook URL with all updates.
This method is retried up to 3 times with a 1-second delay between attempts.
Args:
batch: Dictionary mapping paths to (operation, contents) tuples
Raises:
httpx.HTTPStatusError: If the webhook request fails
"""
# Prepare the batch payload
batch_payload = []
for path, (operation, contents) in batch.items():
item = {
'method': 'POST' if operation == 'write' else 'DELETE',
'path': path,
}
if operation == 'write' and contents is not None:
# Convert bytes to string if needed
if isinstance(contents, bytes):
try:
# Try to decode as UTF-8
item['content'] = contents.decode('utf-8')
except UnicodeDecodeError:
# If not UTF-8, use base64 encoding
import base64
item['content'] = base64.b64encode(contents).decode('ascii')
item['encoding'] = 'base64'
else:
item['content'] = contents
batch_payload.append(item)
# Send the batch as a single request
response = self.client.post(self.base_url, json=batch_payload)
response.raise_for_status()
def flush(self) -> None:
"""
Immediately send any pending updates to the webhook.
This can be called to ensure all updates are sent before shutting down.
"""
self._send_batch()
@@ -106,10 +106,11 @@ class FileConversationStore(ConversationStore):
cls, config: OpenHandsConfig, user_id: str | None
) -> FileConversationStore:
file_store = get_file_store(
config.file_store,
config.file_store_path,
config.file_store_web_hook_url,
config.file_store_web_hook_headers,
file_store_type=config.file_store,
file_store_path=config.file_store_path,
file_store_web_hook_url=config.file_store_web_hook_url,
file_store_web_hook_headers=config.file_store_web_hook_headers,
file_store_web_hook_batch=config.file_store_web_hook_batch,
)
return FileConversationStore(file_store)
@@ -36,6 +36,7 @@ class Settings(BaseModel):
enable_default_condenser: bool = True
enable_sound_notifications: bool = False
enable_proactive_conversation_starters: bool = True
enable_solvability_analysis: bool = True
user_consents_to_analytics: bool | None = None
sandbox_base_container_image: str | None = None
sandbox_runtime_container_image: str | None = None
@@ -40,9 +40,10 @@ class FileSecretsStore(SecretsStore):
cls, config: OpenHandsConfig, user_id: str | None
) -> FileSecretsStore:
file_store = get_file_store(
config.file_store,
config.file_store_path,
config.file_store_web_hook_url,
config.file_store_web_hook_headers,
file_store_type=config.file_store,
file_store_path=config.file_store_path,
file_store_web_hook_url=config.file_store_web_hook_url,
file_store_web_hook_headers=config.file_store_web_hook_headers,
file_store_web_hook_batch=config.file_store_web_hook_batch,
)
return FileSecretsStore(file_store)
@@ -34,9 +34,10 @@ class FileSettingsStore(SettingsStore):
cls, config: OpenHandsConfig, user_id: str | None
) -> FileSettingsStore:
file_store = get_file_store(
config.file_store,
config.file_store_path,
config.file_store_web_hook_url,
config.file_store_web_hook_headers,
file_store_type=config.file_store,
file_store_path=config.file_store_path,
file_store_web_hook_url=config.file_store_web_hook_url,
file_store_web_hook_headers=config.file_store_web_hook_headers,
file_store_web_hook_batch=config.file_store_web_hook_batch,
)
return FileSettingsStore(file_store)
-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()
+32 -30
View File
@@ -2,7 +2,7 @@ import os
from dataclasses import dataclass, field
from itertools import islice
from jinja2 import Template
from jinja2 import Environment, FileSystemLoader, Template
from openhands.controller.state.state import State
from openhands.core.message import Message, TextContent
@@ -56,42 +56,44 @@ class PromptManager:
prompt_dir: str,
system_prompt_filename: str = 'system_prompt.j2',
):
if prompt_dir is None:
raise ValueError('Prompt directory is not set')
self.prompt_dir: str = prompt_dir
self.system_template: Template = self._load_system_template(
system_prompt_filename
self.env = Environment(loader=FileSystemLoader(prompt_dir))
self.system_template: Template = self._load_template(system_prompt_filename)
self.user_template: Template = self._load_template('user_prompt.j2')
self.additional_info_template: Template = self._load_template(
'additional_info.j2'
)
self.microagent_info_template: Template = self._load_template(
'microagent_info.j2'
)
self.user_template: Template = self._load_template('user_prompt')
self.additional_info_template: Template = self._load_template('additional_info')
self.microagent_info_template: Template = self._load_template('microagent_info')
def _load_system_template(self, system_prompt_filename: str) -> Template:
"""Load the system prompt template using the specified filename."""
# Remove .j2 extension if present to use with _load_template
template_name = system_prompt_filename
if template_name.endswith('.j2'):
template_name = template_name[:-3]
try:
return self._load_template(template_name)
except FileNotFoundError:
# Provide a more specific error message for system prompt files
template_path = os.path.join(self.prompt_dir, f'{template_name}.j2')
raise FileNotFoundError(
f'System prompt file "{system_prompt_filename}" not found at {template_path}. '
f'Please ensure the file exists in the prompt directory: {self.prompt_dir}'
)
def _load_template(self, template_name: str) -> Template:
if self.prompt_dir is None:
raise ValueError('Prompt directory is not set')
template_path = os.path.join(self.prompt_dir, f'{template_name}.j2')
if not os.path.exists(template_path):
"""
Load a template from the prompt directory.
Args:
template_name: Full filename of the template to load, including the .j2 extension.
Returns:
The loaded Jinja2 template.
Raises:
FileNotFoundError: If the template file is not found.
"""
try:
return self.env.get_template(template_name)
except Exception:
template_path = os.path.join(self.prompt_dir, template_name)
raise FileNotFoundError(f'Prompt file {template_path} not found')
with open(template_path, 'r') as file:
return Template(file.read())
def get_system_message(self) -> str:
return self.system_template.render().strip()
from openhands.agenthub.codeact_agent.tools.prompt import refine_prompt
system_message = self.system_template.render().strip()
return refine_prompt(system_message)
def get_example_user_message(self) -> str:
"""This is an initial user message that can be provided to the agent
Generated
+33 -2
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
[[package]]
name = "aiofiles"
@@ -5152,8 +5152,11 @@ 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"},
@@ -5227,6 +5230,22 @@ 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"
@@ -10446,6 +10465,18 @@ 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"
@@ -11766,4 +11797,4 @@ third-party-runtimes = ["daytona", "e2b", "modal", "runloop-api-client"]
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "8568c6ec2e11d4fcb23e206a24896b4d2d50e694c04011b668148f484e95b406"
content-hash = "9fd177a2dfa1eebb9212e515db93c58f82d6126cc2d131de5321d68772bc2a59"
+2
View File
@@ -42,6 +42,7 @@ 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"
@@ -114,6 +115,7 @@ pre-commit = "4.2.0"
build = "*"
types-setuptools = "*"
pytest = "^8.4.0"
types-markdown = "^3.8.0.20250809"
[tool.poetry.group.test]
optional = true
+5 -4
View File
@@ -260,10 +260,11 @@ def _load_runtime(
config.mcp = override_mcp_config
file_store = file_store = get_file_store(
config.file_store,
config.file_store_path,
config.file_store_web_hook_url,
config.file_store_web_hook_headers,
file_store_type=config.file_store,
file_store_path=config.file_store_path,
file_store_web_hook_url=config.file_store_web_hook_url,
file_store_web_hook_headers=config.file_store_web_hook_headers,
file_store_web_hook_batch=config.file_store_web_hook_batch,
)
event_stream = EventStream(sid, file_store)
+235
View File
@@ -0,0 +1,235 @@
import time
from unittest.mock import MagicMock
import httpx
import pytest
from openhands.storage.batched_web_hook import BatchedWebHookFileStore
from openhands.storage.files import FileStore
class MockFileStore(FileStore):
def __init__(self):
self.files = {}
def write(self, path: str, contents: str | bytes) -> None:
self.files[path] = contents
def read(self, path: str) -> str:
return self.files.get(path, '')
def list(self, path: str) -> list[str]:
return [k for k in self.files.keys() if k.startswith(path)]
def delete(self, path: str) -> None:
if path in self.files:
del self.files[path]
class TestBatchedWebHookFileStore:
@pytest.fixture
def mock_client(self):
client = MagicMock(spec=httpx.Client)
client.post.return_value.raise_for_status = MagicMock()
client.delete.return_value.raise_for_status = MagicMock()
return client
@pytest.fixture
def file_store(self):
return MockFileStore()
@pytest.fixture
def batched_store(self, file_store, mock_client):
# Use a short timeout for testing
return BatchedWebHookFileStore(
file_store=file_store,
base_url='http://example.com',
client=mock_client,
batch_timeout_seconds=0.1, # Short timeout for testing
batch_size_limit_bytes=1000,
)
def test_write_operation_batched(self, batched_store, mock_client):
# Write a file
batched_store.write('/test.txt', 'Hello, world!')
# The client should not have been called yet
mock_client.post.assert_not_called()
# Wait for the batch timeout
time.sleep(0.2)
# Now the client should have been called with a batch payload
mock_client.post.assert_called_once()
args, kwargs = mock_client.post.call_args
assert args[0] == 'http://example.com'
assert 'json' in kwargs
# Check the batch payload
batch_payload = kwargs['json']
assert isinstance(batch_payload, list)
assert len(batch_payload) == 1
assert batch_payload[0]['method'] == 'POST'
assert batch_payload[0]['path'] == '/test.txt'
assert batch_payload[0]['content'] == 'Hello, world!'
def test_delete_operation_batched(self, batched_store, mock_client):
# Write and then delete a file
batched_store.write('/test.txt', 'Hello, world!')
batched_store.delete('/test.txt')
# The client should not have been called yet
mock_client.post.assert_not_called()
# Wait for the batch timeout
time.sleep(0.2)
# Now the client should have been called with a batch payload
mock_client.post.assert_called_once()
args, kwargs = mock_client.post.call_args
assert args[0] == 'http://example.com'
assert 'json' in kwargs
# Check the batch payload
batch_payload = kwargs['json']
assert isinstance(batch_payload, list)
assert len(batch_payload) == 1
assert batch_payload[0]['method'] == 'DELETE'
assert batch_payload[0]['path'] == '/test.txt'
assert 'content' not in batch_payload[0]
def test_batch_size_limit_triggers_send(self, batched_store, mock_client):
# Write a large file that exceeds the batch size limit
large_content = 'x' * 1001 # Exceeds the 1000 byte limit
batched_store.write('/large.txt', large_content)
# The batch might be sent asynchronously, so we need to wait a bit
time.sleep(0.2)
# The client should have been called due to size limit
mock_client.post.assert_called_once()
args, kwargs = mock_client.post.call_args
assert args[0] == 'http://example.com'
assert 'json' in kwargs
# Check the batch payload
batch_payload = kwargs['json']
assert isinstance(batch_payload, list)
assert len(batch_payload) == 1
assert batch_payload[0]['method'] == 'POST'
assert batch_payload[0]['path'] == '/large.txt'
assert batch_payload[0]['content'] == large_content
def test_multiple_updates_same_file(self, batched_store, mock_client):
# Write to the same file multiple times
batched_store.write('/test.txt', 'Version 1')
batched_store.write('/test.txt', 'Version 2')
batched_store.write('/test.txt', 'Version 3')
# Wait for the batch timeout
time.sleep(0.2)
# Only the latest version should be sent
mock_client.post.assert_called_once()
args, kwargs = mock_client.post.call_args
assert args[0] == 'http://example.com'
assert 'json' in kwargs
# Check the batch payload
batch_payload = kwargs['json']
assert isinstance(batch_payload, list)
assert len(batch_payload) == 1
assert batch_payload[0]['method'] == 'POST'
assert batch_payload[0]['path'] == '/test.txt'
assert batch_payload[0]['content'] == 'Version 3'
def test_flush_sends_immediately(self, batched_store, mock_client):
# Write a file
batched_store.write('/test.txt', 'Hello, world!')
# The client should not have been called yet
mock_client.post.assert_not_called()
# Flush the batch
batched_store.flush()
# Now the client should have been called without waiting for timeout
mock_client.post.assert_called_once()
args, kwargs = mock_client.post.call_args
assert args[0] == 'http://example.com'
assert 'json' in kwargs
# Check the batch payload
batch_payload = kwargs['json']
assert isinstance(batch_payload, list)
assert len(batch_payload) == 1
assert batch_payload[0]['method'] == 'POST'
assert batch_payload[0]['path'] == '/test.txt'
assert batch_payload[0]['content'] == 'Hello, world!'
def test_multiple_operations_in_single_batch(self, batched_store, mock_client):
# Perform multiple operations
batched_store.write('/file1.txt', 'Content 1')
batched_store.write('/file2.txt', 'Content 2')
batched_store.delete('/file3.txt')
# Wait for the batch timeout
time.sleep(0.2)
# Check that only one POST request was made with all operations
mock_client.post.assert_called_once()
args, kwargs = mock_client.post.call_args
assert args[0] == 'http://example.com'
assert 'json' in kwargs
# Check the batch payload
batch_payload = kwargs['json']
assert isinstance(batch_payload, list)
assert len(batch_payload) == 3
# Check each operation in the batch
operations = {item['path']: item for item in batch_payload}
assert '/file1.txt' in operations
assert operations['/file1.txt']['method'] == 'POST'
assert operations['/file1.txt']['content'] == 'Content 1'
assert '/file2.txt' in operations
assert operations['/file2.txt']['method'] == 'POST'
assert operations['/file2.txt']['content'] == 'Content 2'
assert '/file3.txt' in operations
assert operations['/file3.txt']['method'] == 'DELETE'
assert 'content' not in operations['/file3.txt']
def test_binary_content_handling(self, batched_store, mock_client):
# Write binary content
binary_content = b'\x00\x01\x02\x03\xff\xfe\xfd\xfc'
batched_store.write('/binary.bin', binary_content)
# Wait for the batch timeout
time.sleep(0.2)
# Check that the client was called
mock_client.post.assert_called_once()
args, kwargs = mock_client.post.call_args
assert args[0] == 'http://example.com'
assert 'json' in kwargs
# Check the batch payload
batch_payload = kwargs['json']
assert isinstance(batch_payload, list)
assert len(batch_payload) == 1
# Binary content should be base64 encoded
assert batch_payload[0]['method'] == 'POST'
assert batch_payload[0]['path'] == '/binary.bin'
assert 'content' in batch_payload[0]
assert 'encoding' in batch_payload[0]
assert batch_payload[0]['encoding'] == 'base64'
# Verify the content can be decoded back to the original binary
import base64
decoded = base64.b64decode(batch_payload[0]['content'].encode('ascii'))
assert decoded == binary_content
+42 -35
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_message')
@patch('openhands.cli.tui.display_thought_if_new')
@patch('openhands.cli.tui.display_command')
def test_cmd_run_action_thought_before_command(
self, mock_display_command, mock_display_message
self, mock_display_command, mock_display_thought_if_new
):
"""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_message (for thought) was called before display_command
mock_display_message.assert_called_once_with(
# Verify that display_thought_if_new (for thought) was called before display_command
mock_display_thought_if_new.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,21 +41,24 @@ class TestThoughtDisplayOrder:
# Check the call order by examining the mock call history
all_calls = []
all_calls.extend(
[('display_message', call) for call in mock_display_message.call_args_list]
[
('display_thought_if_new', call)
for call in mock_display_thought_if_new.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_message should be called first based on our code
assert mock_display_message.called
# In practice, we know display_thought_if_new should be called first based on our code
assert mock_display_thought_if_new.called
assert mock_display_command.called
@patch('openhands.cli.tui.display_message')
@patch('openhands.cli.tui.display_thought_if_new')
@patch('openhands.cli.tui.display_command')
def test_cmd_run_action_no_thought(
self, mock_display_command, mock_display_message
self, mock_display_command, mock_display_thought_if_new
):
"""Test that CmdRunAction without thought only displays command."""
config = MagicMock(spec=OpenHandsConfig)
@@ -66,14 +69,14 @@ class TestThoughtDisplayOrder:
display_event(cmd_action, config)
# Verify that display_message was not called (no thought)
mock_display_message.assert_not_called()
# Verify that display_thought_if_new was not called (no thought)
mock_display_thought_if_new.assert_not_called()
mock_display_command.assert_called_once_with(cmd_action)
@patch('openhands.cli.tui.display_message')
@patch('openhands.cli.tui.display_thought_if_new')
@patch('openhands.cli.tui.display_command')
def test_cmd_run_action_empty_thought(
self, mock_display_command, mock_display_message
self, mock_display_command, mock_display_thought_if_new
):
"""Test that CmdRunAction with empty thought only displays command."""
config = MagicMock(spec=OpenHandsConfig)
@@ -84,15 +87,15 @@ class TestThoughtDisplayOrder:
display_event(cmd_action, config)
# Verify that display_message was not called (empty thought)
mock_display_message.assert_not_called()
# Verify that display_thought_if_new was not called (empty thought)
mock_display_thought_if_new.assert_not_called()
mock_display_command.assert_called_once_with(cmd_action)
@patch('openhands.cli.tui.display_message')
@patch('openhands.cli.tui.display_thought_if_new')
@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_message
self, mock_init_streaming, mock_display_command, mock_display_thought_if_new
):
"""Test that confirmed CmdRunAction doesn't display command again but initializes streaming."""
config = MagicMock(spec=OpenHandsConfig)
@@ -107,7 +110,7 @@ class TestThoughtDisplayOrder:
display_event(cmd_action, config)
# Verify that thought is still displayed
mock_display_message.assert_called_once_with(
mock_display_thought_if_new.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)
@@ -115,8 +118,8 @@ class TestThoughtDisplayOrder:
# Streaming should be initialized
mock_init_streaming.assert_called_once()
@patch('openhands.cli.tui.display_message')
def test_other_action_thought_display(self, mock_display_message):
@patch('openhands.cli.tui.display_thought_if_new')
def test_other_action_thought_display(self, mock_display_thought_if_new):
"""Test that other Action types still display thoughts normally."""
config = MagicMock(spec=OpenHandsConfig)
@@ -127,13 +130,13 @@ class TestThoughtDisplayOrder:
display_event(action, config)
# Verify that thought is displayed
mock_display_message.assert_called_once_with(
mock_display_thought_if_new.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."""
"""Test that other Action types display final thoughts as agent messages."""
config = MagicMock(spec=OpenHandsConfig)
# Create a generic Action with final thought
@@ -142,11 +145,13 @@ class TestThoughtDisplayOrder:
display_event(action, config)
# Verify that final thought is displayed
mock_display_message.assert_called_once_with('This is a final thought.')
# 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
)
@patch('openhands.cli.tui.display_message')
def test_message_action_from_agent(self, mock_display_message):
@patch('openhands.cli.tui.display_thought_if_new')
def test_message_action_from_agent(self, mock_display_thought_if_new):
"""Test that MessageAction from agent is displayed."""
config = MagicMock(spec=OpenHandsConfig)
@@ -156,11 +161,13 @@ class TestThoughtDisplayOrder:
display_event(message_action, config)
# Verify that message is displayed
mock_display_message.assert_called_once_with('Hello from agent')
# 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
)
@patch('openhands.cli.tui.display_message')
def test_message_action_from_user_not_displayed(self, mock_display_message):
@patch('openhands.cli.tui.display_thought_if_new')
def test_message_action_from_user_not_displayed(self, mock_display_thought_if_new):
"""Test that MessageAction from user is not displayed."""
config = MagicMock(spec=OpenHandsConfig)
@@ -171,12 +178,12 @@ class TestThoughtDisplayOrder:
display_event(message_action, config)
# Verify that message is not displayed (only agent messages are shown)
mock_display_message.assert_not_called()
mock_display_thought_if_new.assert_not_called()
@patch('openhands.cli.tui.display_message')
@patch('openhands.cli.tui.display_thought_if_new')
@patch('openhands.cli.tui.display_command')
def test_cmd_run_action_with_both_thoughts(
self, mock_display_command, mock_display_message
self, mock_display_command, mock_display_thought_if_new
):
"""Test CmdRunAction with both thought and final_thought."""
config = MagicMock(spec=OpenHandsConfig)
@@ -190,7 +197,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_message.assert_called_once_with('Initial thought')
mock_display_thought_if_new.assert_called_once_with('Initial thought')
mock_display_command.assert_called_once_with(cmd_action)
@@ -204,7 +211,7 @@ class TestThoughtDisplayIntegration:
# Track the order of calls
call_order = []
def track_display_message(message):
def track_display_message(message, is_agent_message=False):
call_order.append(f'THOUGHT: {message}')
def track_display_command(event):
+5 -9
View File
@@ -107,16 +107,14 @@ class TestDisplayFunctions:
assert 'What do you want to build?' in message_text
assert 'Type /help for help' in message_text
@patch('openhands.cli.tui.display_message')
def test_display_event_message_action(self, mock_display_message):
def test_display_event_message_action(self):
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)
@@ -172,16 +170,14 @@ class TestDisplayFunctions:
mock_display_file_read.assert_called_once_with(file_read)
@patch('openhands.cli.tui.display_message')
def test_display_event_thought(self, mock_display_message):
def test_display_event_thought(self):
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)
@@ -252,7 +248,7 @@ class TestDisplayFunctions:
message = 'Test message'
display_message(message)
mock_print.assert_called_once()
mock_print.assert_called()
args, kwargs = mock_print.call_args
assert message in str(args[0])
-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
+7 -1
View File
@@ -86,4 +86,10 @@ async def test_get_instance():
assert isinstance(store, FileSettingsStore)
assert store.file_store == mock_store
mock_get_store.assert_called_once_with('local', '/test/path', None, None)
mock_get_store.assert_called_once_with(
file_store_type='local',
file_store_path='/test/path',
file_store_web_hook_url=None,
file_store_web_hook_headers=None,
file_store_web_hook_batch=False,
)
+20
View File
@@ -74,3 +74,23 @@ class TestGitConfig:
# Empty values should fall back to defaults
assert config.git_user_name == 'openhands'
assert config.git_user_email == 'openhands@all-hands.dev'
def test_git_config_fallback_commands(self):
"""Test that git config commands include fallback logic for cloud environments."""
config = OpenHandsConfig()
config.git_user_name = 'Test User'
config.git_user_email = 'test@example.com'
cmd = get_action_execution_server_startup_command(
server_port=8000,
plugins=[],
app_config=config,
python_prefix=['python'],
python_executable='python',
)
# Check that git config arguments are in the command
assert '--git-user-name' in cmd
assert 'Test User' in cmd
assert '--git-user-email' in cmd
assert 'test@example.com' in cmd
+82 -1
View File
@@ -307,6 +307,87 @@ def test_prompt_manager_custom_system_prompt_filename_not_found(prompt_dir):
"""Test that PromptManager raises an error if custom system prompt file is not found."""
with pytest.raises(
FileNotFoundError,
match=r'System prompt file "non_existent\.j2" not found at .*/non_existent\.j2\. Please ensure the file exists in the prompt directory:',
match=r'Prompt file .*/non_existent\.j2 not found',
):
PromptManager(prompt_dir=prompt_dir, system_prompt_filename='non_existent.j2')
def test_jinja2_template_inheritance(prompt_dir):
"""Test that PromptManager._load_template works with Jinja2 template inclusion.
This test demonstrates that we can use {% include %} to import a base system_prompt.j2
into other templates without defining any blocks in the base template, and that
PromptManager._load_template can load these templates correctly.
"""
# Create base system prompt template (no blocks defined here)
with open(os.path.join(prompt_dir, 'system_prompt.j2'), 'w') as f:
f.write("""You are OpenHands agent, a helpful AI assistant that can interact with a computer to solve tasks.
<ROLE>
Your primary role is to assist users by executing commands, modifying code, and solving technical problems effectively.
</ROLE>
""")
# Create interactive system prompt that imports the base template and adds content
with open(os.path.join(prompt_dir, 'system_prompt_interactive.j2'), 'w') as f:
f.write("""{% include "system_prompt.j2" %}
<INTERACTION_RULES>
1. Always respond in a friendly, helpful manner
2. Ask clarifying questions when needed
3. Provide step-by-step explanations
</INTERACTION_RULES>
""")
# Create long horizon system prompt that imports the base template and adds content
with open(os.path.join(prompt_dir, 'system_prompt_long_horizon.j2'), 'w') as f:
f.write("""{% include "system_prompt.j2" %}
<TASK_MANAGEMENT>
1. Break down complex tasks into smaller steps
2. Track progress through a TODO list
3. Focus on one task at a time
</TASK_MANAGEMENT>
""")
# Test PromptManager._load_template with base system prompt
base_manager = PromptManager(prompt_dir=prompt_dir)
base_template = base_manager._load_template('system_prompt.j2')
base_msg = base_template.render().strip()
assert 'You are OpenHands agent' in base_msg
assert '<ROLE>' in base_msg
assert '<INTERACTION_RULES>' not in base_msg
assert '<TASK_MANAGEMENT>' not in base_msg
# Test PromptManager._load_template with interactive system prompt
interactive_manager = PromptManager(
prompt_dir=prompt_dir, system_prompt_filename='system_prompt_interactive.j2'
)
interactive_template = interactive_manager._load_template(
'system_prompt_interactive.j2'
)
interactive_msg = interactive_template.render().strip()
assert 'You are OpenHands agent' in interactive_msg
assert '<ROLE>' in interactive_msg
assert '<INTERACTION_RULES>' in interactive_msg
assert 'Ask clarifying questions when needed' in interactive_msg
assert '<TASK_MANAGEMENT>' not in interactive_msg
# Test PromptManager._load_template with long horizon system prompt
long_horizon_manager = PromptManager(
prompt_dir=prompt_dir, system_prompt_filename='system_prompt_long_horizon.j2'
)
long_horizon_template = long_horizon_manager._load_template(
'system_prompt_long_horizon.j2'
)
long_horizon_msg = long_horizon_template.render().strip()
assert 'You are OpenHands agent' in long_horizon_msg
assert '<ROLE>' in long_horizon_msg
assert '<INTERACTION_RULES>' not in long_horizon_msg
assert '<TASK_MANAGEMENT>' in long_horizon_msg
assert 'Track progress through a TODO list' in long_horizon_msg
# Clean up
os.remove(os.path.join(prompt_dir, 'system_prompt.j2'))
os.remove(os.path.join(prompt_dir, 'system_prompt_interactive.j2'))
os.remove(os.path.join(prompt_dir, 'system_prompt_long_horizon.j2'))
+2 -2
View File
@@ -89,8 +89,8 @@ def test_prep_build_folder(temp_dir):
extra_deps=None,
)
# make sure that the code was copied
shutil_mock.copytree.assert_called_once()
# make sure that the code (openhands/) and microagents folder were copied
assert shutil_mock.copytree.call_count == 2
assert shutil_mock.copy2.call_count == 2
# Now check dockerfile is in the folder
@@ -0,0 +1,179 @@
import sys
from unittest.mock import patch
import pytest
from openhands.agenthub.codeact_agent.codeact_agent import CodeActAgent
from openhands.core.config import AgentConfig
from openhands.llm.llm import LLM
# Skip all tests in this module if not running on Windows
pytestmark = pytest.mark.skipif(
sys.platform != 'win32', reason='Windows prompt refinement tests require Windows'
)
@pytest.fixture
def mock_llm():
"""Create a mock LLM for testing."""
llm = LLM(config={'model': 'gpt-4', 'api_key': 'test'})
return llm
@pytest.fixture
def agent_config():
"""Create a basic agent config for testing."""
return AgentConfig()
def test_codeact_agent_system_prompt_no_bash_on_windows(mock_llm, agent_config):
"""Test that CodeActAgent's system prompt doesn't contain 'bash' on Windows."""
# Create a CodeActAgent instance
agent = CodeActAgent(llm=mock_llm, config=agent_config)
# Get the system prompt
system_prompt = agent.prompt_manager.get_system_message()
# Assert that 'bash' doesn't exist in the system prompt (case-insensitive)
assert 'bash' not in system_prompt.lower(), (
f"System prompt contains 'bash' on Windows platform. "
f"It should be replaced with 'powershell'. "
f'System prompt: {system_prompt}'
)
# Verify that 'powershell' exists instead (case-insensitive)
assert 'powershell' in system_prompt.lower(), (
f"System prompt should contain 'powershell' on Windows platform. "
f'System prompt: {system_prompt}'
)
def test_codeact_agent_tool_descriptions_no_bash_on_windows(mock_llm, agent_config):
"""Test that CodeActAgent's tool descriptions don't contain 'bash' on Windows."""
# Create a CodeActAgent instance
agent = CodeActAgent(llm=mock_llm, config=agent_config)
# Get the tools
tools = agent.tools
# Check each tool's description and parameters
for tool in tools:
if tool['type'] == 'function':
function_info = tool['function']
# Check function description
description = function_info.get('description', '')
assert 'bash' not in description.lower(), (
f"Tool '{function_info['name']}' description contains 'bash' on Windows. "
f'Description: {description}'
)
# Check parameter descriptions
parameters = function_info.get('parameters', {})
properties = parameters.get('properties', {})
for param_name, param_info in properties.items():
param_description = param_info.get('description', '')
assert 'bash' not in param_description.lower(), (
f"Tool '{function_info['name']}' parameter '{param_name}' "
f"description contains 'bash' on Windows. "
f'Parameter description: {param_description}'
)
def test_in_context_learning_example_no_bash_on_windows():
"""Test that in-context learning examples don't contain 'bash' on Windows."""
from openhands.agenthub.codeact_agent.tools.bash import create_cmd_run_tool
from openhands.agenthub.codeact_agent.tools.finish import FinishTool
from openhands.agenthub.codeact_agent.tools.str_replace_editor import (
create_str_replace_editor_tool,
)
from openhands.llm.fn_call_converter import get_example_for_tools
# Create a sample set of tools
tools = [
create_cmd_run_tool(),
create_str_replace_editor_tool(),
FinishTool,
]
# Get the in-context learning example
example = get_example_for_tools(tools)
# Assert that 'bash' doesn't exist in the example (case-insensitive)
assert 'bash' not in example.lower(), (
f"In-context learning example contains 'bash' on Windows platform. "
f"It should be replaced with 'powershell'. "
f'Example: {example}'
)
# Verify that 'powershell' exists instead (case-insensitive)
if example: # Only check if example is not empty
assert 'powershell' in example.lower(), (
f"In-context learning example should contain 'powershell' on Windows platform. "
f'Example: {example}'
)
def test_refine_prompt_function_works():
"""Test that the refine_prompt function correctly replaces 'bash' with 'powershell'."""
from openhands.agenthub.codeact_agent.tools.bash import refine_prompt
# Test basic replacement
test_prompt = 'Execute a bash command to list files'
refined_prompt = refine_prompt(test_prompt)
assert 'bash' not in refined_prompt.lower()
assert 'powershell' in refined_prompt.lower()
assert refined_prompt == 'Execute a powershell command to list files'
# Test multiple occurrences
test_prompt = 'Use bash to run bash commands in the bash shell'
refined_prompt = refine_prompt(test_prompt)
assert 'bash' not in refined_prompt.lower()
assert (
refined_prompt
== 'Use powershell to run powershell commands in the powershell shell'
)
# Test case sensitivity
test_prompt = 'BASH and Bash and bash should all be replaced'
refined_prompt = refine_prompt(test_prompt)
assert 'bash' not in refined_prompt.lower()
assert (
refined_prompt
== 'powershell and powershell and powershell should all be replaced'
)
# Test execute_bash tool name replacement
test_prompt = 'Use the execute_bash tool to run commands'
refined_prompt = refine_prompt(test_prompt)
assert 'execute_bash' not in refined_prompt.lower()
assert 'execute_powershell' in refined_prompt.lower()
assert refined_prompt == 'Use the execute_powershell tool to run commands'
# Test that words containing 'bash' but not equal to 'bash' are preserved
test_prompt = 'The bashful person likes bash-like syntax'
refined_prompt = refine_prompt(test_prompt)
# 'bashful' should be preserved, 'bash-like' should become 'powershell-like'
assert 'bashful' in refined_prompt
assert 'powershell-like' in refined_prompt
assert refined_prompt == 'The bashful person likes powershell-like syntax'
def test_refine_prompt_function_on_non_windows():
"""Test that the refine_prompt function doesn't change anything on non-Windows platforms."""
from openhands.agenthub.codeact_agent.tools.bash import refine_prompt
# Mock sys.platform to simulate non-Windows
with patch('openhands.agenthub.codeact_agent.tools.bash.sys.platform', 'linux'):
test_prompt = 'Execute a bash command to list files'
refined_prompt = refine_prompt(test_prompt)
# On non-Windows, the prompt should remain unchanged
assert refined_prompt == test_prompt
assert 'bash' in refined_prompt.lower()