Compare commits

...

21 Commits

Author SHA1 Message Date
openhands
f76c8ebd17 Fix issue #5888: [Bug]: guess_success for github resolver should consider more context in issue resolution 2024-12-28 23:46:36 +00:00
Boxuan Li
ebb2d86ce3 Headless or endless? Rewrite auto continue response in headless mode (#5879) 2024-12-28 10:25:50 -08:00
Boxuan Li
6a4442e590 [Evaluation] Add summarise_results script for TheAgentCompany benchmark (#5811) 2024-12-27 20:33:41 -08:00
mamoodi
157ff4a4b9 Fix: Prevent submission of empty prompts with spaces (#5874)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-12-27 15:31:28 -05:00
mamoodi
cc928e6d3f Fix: Add vertical scrolling to file content viewer (#5872)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-12-27 15:03:15 -05:00
Robert Brennan
6a75800e1b fix github auth for settings (#5871) 2024-12-27 14:15:55 -05:00
tofarr
c9cecbc461 Responsive splash screen (#5864) 2024-12-27 11:12:48 -07:00
Robert Brennan
97b1867ea1 Fix for settings update (#5858) 2024-12-27 16:28:11 +00:00
dependabot[bot]
9bdc1df2df chore(deps): bump boto3 from 1.35.87 to 1.35.88 in the version-all group (#5861)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-27 17:15:29 +01:00
sp.wack
9d984aaa30 chore(frontend): Upgrade to React 19 (#5835) 2024-12-27 19:10:41 +04:00
Boxuan Li
5ed80b5c32 [doc] Fix link in TheAgentCompany benchmark's README.md (#5848) 2024-12-27 22:21:02 +08:00
mamoodi
df82202178 Fix formatting in docs (#5842) 2024-12-26 20:06:27 -05:00
tofarr
500598666e Feat: Allow checking multiple conversations running at the same time (#5843) 2024-12-26 23:46:54 +00:00
Robert Brennan
69a9080480 fix install instructions (#5844) 2024-12-27 00:16:23 +01:00
Robert Brennan
b72f50cc4a Remove file editing functionality from UI (#5823)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-12-26 18:02:38 -05:00
mamoodi
f1a8be3817 Update Installation to align with README (#5841) 2024-12-26 17:44:54 -05:00
Robert Brennan
b34209c9a0 Fix state dir in docker mode (#5840) 2024-12-26 22:42:04 +00:00
Xingyao Wang
a021045dce fix(#5818): Force to use string serializer for deepseek function calling (#5824)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2024-12-26 20:45:39 +00:00
Robert Brennan
ad45f8dab0 Add loading spinner to task form during conversation creation (#5828)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-12-26 15:22:03 -05:00
Rohit Malhotra
3bf5956493 [Regression]: Fix modal orders (#5779)
Co-authored-by: Graham Neubig <neubig@gmail.com>
2024-12-26 19:12:27 +00:00
sp.wack
d86b536d2f chore(frontend): Update dependencies safely (#5829) 2024-12-26 18:47:23 +00:00
65 changed files with 1010 additions and 976 deletions

View File

@@ -49,7 +49,7 @@ docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/home/openhands/.openhands-state \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \

View File

@@ -43,7 +43,8 @@ ENV WORKSPACE_BASE=/opt/workspace_base
ENV OPENHANDS_BUILD_VERSION=$OPENHANDS_BUILD_VERSION
ENV SANDBOX_USER_ID=0
ENV FILE_STORE=local
ENV FILE_STORE_PATH=~/.openhands-state
ENV FILE_STORE_PATH=/.openhands-state
RUN mkdir -p $FILE_STORE_PATH
RUN mkdir -p $WORKSPACE_BASE
RUN apt-get update -y \

View File

@@ -17,6 +17,7 @@ docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \

View File

@@ -176,24 +176,20 @@ Guidelines:
Examples:
1. Creating a Dockerfile:
```dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
CMD ["npm", "start"]
```
2. Docker Compose usage:
```yaml
version: '3'
services:
web:
build: .
ports:
- "3000:3000"
```
Remember to:
- Validate Dockerfile syntax

View File

@@ -1,14 +1,13 @@
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import Layout from "@theme/Layout";
import { HomepageHeader } from "../components/HomepageHeader/HomepageHeader";
import { Welcome } from "../components/Welcome/Welcome";
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import Layout from '@theme/Layout';
import { HomepageHeader } from '../components/HomepageHeader/HomepageHeader';
import { translate } from '@docusaurus/Translate';
export function Header({ title, summary }): JSX.Element {
return (
<div>
<h1>{title}</h1>
<h2 style={{ fontSize: "3rem" }}>{summary}</h2>
<h2 style={{ fontSize: '3rem' }}>{summary}</h2>
</div>
);
}
@@ -23,7 +22,7 @@ export default function Home(): JSX.Element {
message: 'Code Less, Make More',
})}
>
<HomepageHeader />
<HomepageHeader />
</Layout>
);
}

View File

@@ -5,7 +5,7 @@ This folder contains the evaluation harness that we built on top of the original
The evaluation consists of three steps:
1. Environment setup: [install python environment](../../README.md#development-environment), [configure LLM config](../../README.md#configure-openhands-and-your-llm), [launch services](https://github.com/TheAgentCompany/TheAgentCompany/blob/main/docs/SETUP.md).
2. [Run Evaluation](#run-inference-on-the-agent-company-instances): Run all tasks and get the evaluation results.
2. [Run Evaluation](#run-inference-on-the-agent-company-tasks): Run all tasks and get the evaluation results.
## Setup Environment and LLM Configuration

View File

@@ -0,0 +1,316 @@
###########################################################################################################
# Adapted from https://github.com/TheAgentCompany/TheAgentCompany/blob/main/evaluation/summarise_results.py
###########################################################################################################
import glob
import json
import os
import re
import sys
from typing import Dict, Tuple
def calculate_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float:
"""
Calculate the cost of the model call.
"""
if 'claude-3-5-sonnet' in model.lower():
# https://www.anthropic.com/pricing#anthropic-api, accessed 12/11/2024
return 0.000003 * prompt_tokens + 0.000015 * completion_tokens
elif 'gpt-4o' in model.lower():
# https://openai.com/api/pricing/, accessed 12/11/2024
return 0.0000025 * prompt_tokens + 0.00001 * completion_tokens
elif 'gemini-1.5-pro' in model.lower():
# https://ai.google.dev/pricing#1_5pro, accessed 12/11/2024
# assuming prompts up to 128k tokens
cost = 0.00000125 * prompt_tokens + 0.000005 * completion_tokens
if prompt_tokens > 128000:
cost *= 2
return cost
elif 'gemini-2.0-flash-exp' in model.lower():
# price unknown for gemini-2.0-flash-exp, assuming same price as gemini-1.5-flash
cost = 0.000000075 * prompt_tokens + 0.0000003 * completion_tokens
if prompt_tokens > 128000:
cost *= 2
return cost
elif 'qwen2-72b' in model.lower():
# assuming hosted on Together
# https://www.together.ai/pricing, accessed 12/11/2024
return 0.0000009 * (prompt_tokens + completion_tokens)
elif 'qwen2p5-72b' in model.lower():
# assuming hosted on Together
# https://www.together.ai/pricing, accessed 12/14/2024
return 0.0000012 * (prompt_tokens + completion_tokens)
elif 'llama-v3p1-405b-instruct' in model.lower():
# assuming hosted on Fireworks AI
# https://fireworks.ai/pricing, accessed 12/11/2024
return 0.000003 * (prompt_tokens + completion_tokens)
elif 'llama-v3p1-70b-instruct' in model.lower():
# assuming hosted on Fireworks AI
return 0.0000009 * (prompt_tokens + completion_tokens)
elif 'llama-v3p3-70b-instruct' in model.lower():
# assuming hosted on Fireworks AI
return 0.0000009 * (prompt_tokens + completion_tokens)
elif 'amazon.nova-pro-v1:0' in model.lower():
# assuming hosted on Amazon Bedrock
# https://aws.amazon.com/bedrock/pricing/, accessed 12/11/2024
return 0.0000008 * prompt_tokens + 0.0000032 * completion_tokens
else:
raise ValueError(f'Unknown model: {model}')
def analyze_eval_json_file(filepath: str) -> Tuple[int, int]:
"""
Analyze a single eval JSON file and extract the total and result from final_score.
Args:
filepath: Path to the JSON file
Returns:
Tuple containing (total, result) from final_score
"""
try:
with open(filepath, 'r') as f:
data = json.load(f)
final_score = data.get('final_score', {})
return (final_score.get('total', 0), final_score.get('result', 0))
except json.JSONDecodeError as e:
print(f'Error decoding JSON in {filepath}: {e}')
return (0, 0)
except Exception as e:
print(f'Error processing {filepath}: {e}')
return (0, 0)
def analyze_traj_json_file(filepath: str) -> Tuple[int, float]:
"""
Analyze a single trajectory JSON file and extract the steps and tokens
for each step. Then estimate the cost based on the tokens and the model type.
Note: this is assuming there's no prompt caching at all.
"""
steps: int = 0
cost: float = 0.0
with open(filepath, 'r') as f:
data = json.load(f)
response_id = None
for action in data:
if 'tool_call_metadata' in action:
if action['tool_call_metadata']['model_response']['id'] != response_id:
response_id = action['tool_call_metadata']['model_response']['id']
else:
# openhands displays the same model response meta data multiple times, when
# a single LLM call leads to multiple actions and observations.
continue
steps += 1
usage = action['tool_call_metadata']['model_response']['usage']
model: str = action['tool_call_metadata']['model_response']['model']
prompt_tokens = usage['prompt_tokens']
completion_tokens = usage['completion_tokens']
cost += calculate_cost(model, prompt_tokens, completion_tokens)
return (steps, cost)
def analyze_folder(
folder_path: str,
) -> Tuple[Dict[str, Tuple[int, int]], Dict[str, Tuple[int, float]]]:
"""
Analyze all eval_*.json & traj_*.json files in the specified folder.
Args:
folder_path: Path to the folder containing JSON files
Returns:
dictionaries:
- eval_results: Dictionary with filename as key and (total, result) tuple as value
- traj_results: Dictionary with filename as key and (steps, cost) tuple as value
"""
eval_results = {}
traj_results = {}
eval_pattern = os.path.join(folder_path, 'eval_*.json')
traj_pattern = os.path.join(folder_path, 'traj_*.json')
for filepath in glob.glob(eval_pattern):
filename = os.path.basename(filepath)
total, result = analyze_eval_json_file(filepath)
key = re.search(r'eval_(.+)\.json', filename).group(1)
eval_results[key] = (total, result)
for filepath in glob.glob(traj_pattern):
filename = os.path.basename(filepath)
steps, cost = analyze_traj_json_file(filepath)
key = re.search(r'traj_(.+)\.json', filename).group(1)
traj_results[key] = (steps, cost)
return eval_results, traj_results
def get_task_nature_category(task_name: str) -> str:
"""
Get the nature category of the task.
"""
task_nature = task_name.split('-')[0]
if task_nature.lower() in ['sde', 'pm', 'ds', 'admin', 'hr', 'finance']:
return task_nature
else:
return 'other'
def calculate_score(total: int, result: int) -> float:
"""
Calculate the score as a number between 0 and 1.
Formula: score = (result / total) * 0.5 + (result // total) * 0.5
Explanation:
- (result / total) * 0.5: This is the completion ratio, scaled down to a 0-0.5 range.
- (result // total) * 0.5: This is a binary score indicating whether the task was completed or not.
Args:
total: Total possible points
result: Actual points achieved
Returns:
Score as a number between 0 and 1
"""
return (result / total * 0.5) + (result // total * 0.5)
def is_perfect_completion(total: int, result: int) -> bool:
"""
Check if the task achieved perfect completion.
Args:
total: Total possible points
result: Actual points achieved
Returns:
True if result equals total, False otherwise
"""
return total > 0 and total == result
def main():
if len(sys.argv) != 2:
print('Usage: poetry run python summarise_results.py <folder_path>')
sys.exit(1)
folder_path = sys.argv[1]
if not os.path.isdir(folder_path):
print(f"Error: '{folder_path}' is not a valid directory")
sys.exit(1)
eval_results, traj_results = analyze_folder(folder_path)
if not eval_results:
print(f'No eval_*.json files found in {folder_path}')
return
# Create list of results with completion ratios for sorting
detailed_results = [
(
task_name,
total,
result,
calculate_score(total, result),
is_perfect_completion(total, result),
get_task_nature_category(task_name),
)
for task_name, (total, result) in eval_results.items()
]
# Sort by score in descending order
detailed_results.sort(key=lambda x: (-x[3], x[0]))
# Calculate perfect completion stats
perfect_completions = sum(
1 for _, _, _, _, is_perfect, _ in detailed_results if is_perfect
)
# Print header
print('\n# Evaluation Results Report')
print('\n## Results per File')
print('\n*Sorted by score (⭐ indicates perfect completion)*\n')
# Print table header
print(
'| Filename | Total | Result | Score | Steps | Cost (assuming no prompt caching)|'
)
print('|----------|--------|---------|-------|-------|------|')
# Print individual file results
for task_name, total, result, score, is_perfect, task_nature in detailed_results:
perfect_marker = '' if is_perfect else ''
print(
f'| {task_name} | {total:,} | {result:,} | {score:.2f}{perfect_marker} | {traj_results[task_name][0]} | {traj_results[task_name][1]:.2f} |'
)
# Print summary section
print('\n## Summary\n')
print(f'**Tasks Evaluated:** {len(eval_results)}\n')
print(
f'**Perfect Completions:** {perfect_completions}/{len(eval_results)} ({(perfect_completions/len(eval_results)*100):.2f}%)\n'
)
overall_score = (
sum(score for _, _, _, score, _, _ in detailed_results)
/ len(detailed_results)
* 100
)
avg_steps = sum(steps for steps, _ in traj_results.values()) / len(traj_results)
avg_cost = sum(cost for _, cost in traj_results.values()) / len(traj_results)
print(f'**Overall Score:** {overall_score:.2f}%\n')
print(f'**Average Steps:** {avg_steps:.2f}\n')
print(f'**Average Cost (USD):** {avg_cost:.2f}\n')
# Additional statistics
if detailed_results:
highest_score = max(score for _, _, _, score, _, _ in detailed_results)
lowest_score = min(score for _, _, _, score, _, _ in detailed_results)
median_score = detailed_results[len(detailed_results) // 2][3]
avg_score = sum(score for _, _, _, score, _, _ in detailed_results) / len(
detailed_results
)
print('\n## Statistics\n')
print('| Metric | Value |')
print('|---------|--------|')
print(f'| Highest Task Score | {highest_score*100:.2f}% |')
print(f'| Lowest Task Score | {lowest_score*100:.2f}% |')
print(f'| Median Task Score | {median_score*100:.2f}% |')
print(f'| Average Task Score | {avg_score*100:.2f}% |')
# compute avg score per nature category
print('\n## Statistics per Nature Category\n')
print('| Metric | Value |')
print('|---------|--------|')
for task_nature in ['sde', 'pm', 'ds', 'admin', 'hr', 'finance', 'other']:
num_of_tasks = sum(
1
for _, _, _, _, _, nature_category in detailed_results
if nature_category == task_nature
)
task_nature_score = (
sum(
score
for _, _, _, score, _, nature_category in detailed_results
if nature_category == task_nature
)
/ num_of_tasks
)
perfect_completions = sum(
1
for _, _, _, _, is_perfect, nature_category in detailed_results
if nature_category == task_nature and is_perfect
)
print(
f'| Perfect Completions for {task_nature} | {perfect_completions}/{num_of_tasks} ({perfect_completions/num_of_tasks*100:.2f}%) |'
)
print(f'| Average Score for {task_nature} | {task_nature_score*100:.2f}% |')
if __name__ == '__main__':
main()

View File

@@ -40,7 +40,7 @@ describe("frontend/routes/_oh", () => {
await screen.findByTestId("root-layout");
});
it("should render the AI config modal if settings are not up-to-date", async () => {
it.skip("should render the AI config modal if settings are not up-to-date", async () => {
settingsAreUpToDateMock.mockReturnValue(false);
renderWithProviders(<RouteStub />);

View File

@@ -8,56 +8,56 @@
"name": "openhands-frontend",
"version": "0.17.0",
"dependencies": {
"@monaco-editor/react": "^4.6.0",
"@nextui-org/react": "^2.4.8",
"@react-router/node": "^7.0.1",
"@react-router/serve": "^7.0.1",
"@monaco-editor/react": "^4.7.0-rc.0",
"@nextui-org/react": "^2.6.10",
"@react-router/node": "^7.1.1",
"@react-router/serve": "^7.1.1",
"@react-types/shared": "^3.25.0",
"@reduxjs/toolkit": "^2.3.0",
"@tanstack/react-query": "^5.60.5",
"@reduxjs/toolkit": "^2.5.0",
"@tanstack/react-query": "^5.62.10",
"@vitejs/plugin-react": "^4.3.2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.7.7",
"axios": "^1.7.9",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"i18next": "^23.15.2",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-http-backend": "^2.6.2",
"isbot": "^5.1.17",
"i18next": "^24.2.0",
"i18next-browser-languagedetector": "^8.0.2",
"i18next-http-backend": "^3.0.1",
"isbot": "^5.1.19",
"jose": "^5.9.4",
"monaco-editor": "^0.52.0",
"posthog-js": "^1.184.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.203.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-highlight": "^0.15.0",
"react-hot-toast": "^2.4.1",
"react-i18next": "^15.0.2",
"react-icons": "^5.3.0",
"react-i18next": "^15.2.0",
"react-icons": "^5.4.0",
"react-markdown": "^9.0.1",
"react-redux": "^9.1.2",
"react-router": "^7.0.1",
"react-redux": "^9.2.0",
"react-router": "^7.1.1",
"react-syntax-highlighter": "^15.6.1",
"react-textarea-autosize": "^8.5.4",
"remark-gfm": "^4.0.0",
"sirv-cli": "^3.0.0",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^2.5.4",
"tailwind-merge": "^2.6.0",
"vite": "^5.4.9",
"web-vitals": "^3.5.2",
"ws": "^8.18.0"
},
"devDependencies": {
"@playwright/test": "^1.48.2",
"@react-router/dev": "^7.0.1",
"@playwright/test": "^1.49.1",
"@react-router/dev": "^7.1.1",
"@tailwindcss/typography": "^0.5.15",
"@tanstack/eslint-plugin-query": "^5.62.1",
"@tanstack/eslint-plugin-query": "^5.62.9",
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.0.1",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^22.7.6",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@types/react-highlight": "^0.12.8",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/ws": "^8.5.12",
@@ -73,18 +73,18 @@
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react": "^7.37.3",
"eslint-plugin-react-hooks": "^4.6.2",
"husky": "^9.1.6",
"jsdom": "^25.0.1",
"lint-staged": "^15.2.10",
"lint-staged": "^15.2.11",
"msw": "^2.6.6",
"postcss": "^8.4.47",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.14",
"prettier": "^3.4.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.6.3",
"vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^5.0.1",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^1.6.0"
},
"engines": {
@@ -1595,17 +1595,17 @@
}
},
"node_modules/@monaco-editor/react": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.6.0.tgz",
"integrity": "sha512-RFkU9/i7cN2bsq/iTkurMWOEErmYcY6JiQI3Jn+WeR/FGISH8JbHERjpS9oRuSOPvDMJI0Z8nJeKkbOs9sBYQw==",
"version": "4.7.0-rc.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0-rc.0.tgz",
"integrity": "sha512-YfjXkDK0bcwS0zo8PXptvQdCQfOPPtzGsAzmIv7PnoUGFdIohsR+NVDyjbajMddF+3cWUm/3q9NzP/DUke9a+w==",
"license": "MIT",
"dependencies": {
"@monaco-editor/loader": "^1.4.0"
},
"peerDependencies": {
"monaco-editor": ">= 0.25.0 < 1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@mswjs/interceptors": {
@@ -2241,6 +2241,23 @@
"react-dom": ">=18 || >=19.0.0-rc.0"
}
},
"node_modules/@nextui-org/listbox/node_modules/@tanstack/react-virtual": {
"version": "3.10.9",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.10.9.tgz",
"integrity": "sha512-OXO2uBjFqA4Ibr2O3y0YMnkrRWGVNqcvHQXmGvMu6IK8chZl3PrDxFXdGZ2iZkSrKh3/qUYoFqYe+Rx23RoU0g==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.10.9"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@nextui-org/menu": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/@nextui-org/menu/-/menu-2.2.8.tgz",
@@ -2579,6 +2596,23 @@
"react-dom": ">=18 || >=19.0.0-rc.0"
}
},
"node_modules/@nextui-org/select/node_modules/@tanstack/react-virtual": {
"version": "3.10.9",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.10.9.tgz",
"integrity": "sha512-OXO2uBjFqA4Ibr2O3y0YMnkrRWGVNqcvHQXmGvMu6IK8chZl3PrDxFXdGZ2iZkSrKh3/qUYoFqYe+Rx23RoU0g==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.10.9"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@nextui-org/shared-icons": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@nextui-org/shared-icons/-/shared-icons-2.1.1.tgz",
@@ -5336,23 +5370,6 @@
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.10.9",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.10.9.tgz",
"integrity": "sha512-OXO2uBjFqA4Ibr2O3y0YMnkrRWGVNqcvHQXmGvMu6IK8chZl3PrDxFXdGZ2iZkSrKh3/qUYoFqYe+Rx23RoU0g==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.10.9"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.10.9",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.10.9.tgz",
@@ -5603,30 +5620,23 @@
"undici-types": "~6.20.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.14",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
"integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==",
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz",
"integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==",
"version": "19.0.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.2.tgz",
"integrity": "sha512-USU8ZI/xyKJwFTpjSVIrSeHBVAGagkHQKPNbxeWwql/vDmnTIBgx+TJnhFnj1NXgz8XfprU0egV2dROLGpsBEg==",
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.5",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz",
"integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==",
"version": "19.0.2",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.2.tgz",
"integrity": "sha512-c1s+7TKFaDRRxr1TxccIX2u7sfCnc3RxkVyBIUA2lCpyqCF+QoAwQ/CBg7bsMdVwP120HEH143VQezKtef5nCg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
"@types/react": "^19.0.0"
}
},
"node_modules/@types/react-highlight": {
@@ -10141,9 +10151,9 @@
}
},
"node_modules/i18next": {
"version": "23.16.8",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz",
"integrity": "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==",
"version": "24.2.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.0.tgz",
"integrity": "sha512-ArJJTS1lV6lgKH7yEf4EpgNZ7+THl7bsGxxougPYiXRTJ/Fe1j08/TBpV9QsXCIYVfdE/HWG/xLezJ5DOlfBOA==",
"funding": [
{
"type": "individual",
@@ -10161,6 +10171,14 @@
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
@@ -10173,9 +10191,9 @@
}
},
"node_modules/i18next-http-backend": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-2.7.1.tgz",
"integrity": "sha512-vPksHIckysGgykCD8JwCr2YsJEml9Cyw+Yu2wtb4fQ7xIn9RH/hkUDh5UkwnIzb0kSL4SJ30Ab/sCInhQxbCgg==",
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.1.tgz",
"integrity": "sha512-XT2lYSkbAtDE55c6m7CtKxxrsfuRQO3rUfHzj8ZyRtY9CkIX3aRGwXGTkUhpGWce+J8n7sfu3J0f2wTzo7Lw0A==",
"license": "MIT",
"dependencies": {
"cross-fetch": "4.0.0"
@@ -11536,6 +11554,7 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@@ -14059,28 +14078,24 @@
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
"scheduler": "^0.25.0"
},
"peerDependencies": {
"react": "^18.3.1"
"react": "^19.0.0"
}
},
"node_modules/react-highlight": {
@@ -14918,13 +14933,10 @@
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==",
"license": "MIT"
},
"node_modules/scroll-into-view-if-needed": {
"version": "3.0.10",

View File

@@ -7,41 +7,41 @@
"node": ">=20.0.0"
},
"dependencies": {
"@monaco-editor/react": "^4.6.0",
"@nextui-org/react": "^2.4.8",
"@react-router/node": "^7.0.1",
"@react-router/serve": "^7.0.1",
"@monaco-editor/react": "^4.7.0-rc.0",
"@nextui-org/react": "^2.6.10",
"@react-router/node": "^7.1.1",
"@react-router/serve": "^7.1.1",
"@react-types/shared": "^3.25.0",
"@reduxjs/toolkit": "^2.3.0",
"@tanstack/react-query": "^5.60.5",
"@reduxjs/toolkit": "^2.5.0",
"@tanstack/react-query": "^5.62.10",
"@vitejs/plugin-react": "^4.3.2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.7.7",
"axios": "^1.7.9",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"i18next": "^23.15.2",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-http-backend": "^2.6.2",
"isbot": "^5.1.17",
"i18next": "^24.2.0",
"i18next-browser-languagedetector": "^8.0.2",
"i18next-http-backend": "^3.0.1",
"isbot": "^5.1.19",
"jose": "^5.9.4",
"monaco-editor": "^0.52.0",
"posthog-js": "^1.184.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.203.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-highlight": "^0.15.0",
"react-hot-toast": "^2.4.1",
"react-i18next": "^15.0.2",
"react-icons": "^5.3.0",
"react-i18next": "^15.2.0",
"react-icons": "^5.4.0",
"react-markdown": "^9.0.1",
"react-redux": "^9.1.2",
"react-router": "^7.0.1",
"react-redux": "^9.2.0",
"react-router": "^7.1.1",
"react-syntax-highlighter": "^15.6.1",
"react-textarea-autosize": "^8.5.4",
"remark-gfm": "^4.0.0",
"sirv-cli": "^3.0.0",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^2.5.4",
"tailwind-merge": "^2.6.0",
"vite": "^5.4.9",
"web-vitals": "^3.5.2",
"ws": "^8.18.0"
@@ -75,16 +75,16 @@
]
},
"devDependencies": {
"@playwright/test": "^1.48.2",
"@react-router/dev": "^7.0.1",
"@playwright/test": "^1.49.1",
"@react-router/dev": "^7.1.1",
"@tailwindcss/typography": "^0.5.15",
"@tanstack/eslint-plugin-query": "^5.62.1",
"@tanstack/eslint-plugin-query": "^5.62.9",
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.0.1",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^22.7.6",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@types/react-highlight": "^0.12.8",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/ws": "^8.5.12",
@@ -100,18 +100,18 @@
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react": "^7.37.3",
"eslint-plugin-react-hooks": "^4.6.2",
"husky": "^9.1.6",
"jsdom": "^25.0.1",
"lint-staged": "^15.2.10",
"lint-staged": "^15.2.11",
"msw": "^2.6.6",
"postcss": "^8.4.47",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.14",
"prettier": "^3.4.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.6.3",
"vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^5.0.1",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^1.6.0"
},
"packageManager": "npm@10.5.0",

View File

@@ -8,8 +8,8 @@
* - Please do NOT serve this file on production.
*/
const PACKAGE_VERSION = '2.6.6'
const INTEGRITY_CHECKSUM = 'ca7800994cc8bfb5eb961e037c877074'
const PACKAGE_VERSION = '2.7.0'
const INTEGRITY_CHECKSUM = '00729d72e3b82faf54ca8b9621dbb96f'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
@@ -199,7 +199,19 @@ async function getResponse(event, client, requestId) {
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
headers.delete('accept', 'msw/passthrough')
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}

View File

@@ -250,14 +250,12 @@ class OpenHands {
static async newConversation(params: {
githubToken?: string;
args?: Record<string, unknown>;
selectedRepository?: string;
}): Promise<{ conversation_id: string }> {
const { data } = await openHands.post<{
conversation_id: string;
}>("/api/conversations", {
github_token: params.githubToken,
args: params.args,
selected_repository: params.selectedRepository,
});
// TODO: remove this once we have a multi-conversation UI

View File

@@ -1,6 +1,6 @@
import React from "react";
function ArrowIcon(): JSX.Element {
function ArrowIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -1,6 +1,6 @@
import React from "react";
function CogTooth(): JSX.Element {
function CogTooth() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -1,6 +1,6 @@
import React from "react";
function ConfirmIcon(): JSX.Element {
function ConfirmIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -1,6 +1,6 @@
import React from "react";
function PauseIcon(): JSX.Element {
function PauseIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -1,6 +1,6 @@
import React from "react";
function PlayIcon(): JSX.Element {
function PlayIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -1,6 +1,6 @@
import React from "react";
function RejectIcon(): JSX.Element {
function RejectIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -1,6 +1,6 @@
import React from "react";
function StopIcon(): JSX.Element {
function StopIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -8,7 +8,7 @@ import {
FaPython,
} from "react-icons/fa";
export const EXTENSION_ICON_MAP: Record<string, JSX.Element> = {
export const EXTENSION_ICON_MAP: Record<string, React.ReactNode> = {
js: <DiJavascript />,
ts: <DiJavascript />,
py: <FaPython />,

View File

@@ -2,13 +2,19 @@ import React from "react";
import { cn } from "#/utils/utils";
interface ContextMenuProps {
ref: React.RefObject<HTMLUListElement | null>;
testId?: string;
children: React.ReactNode;
className?: React.HTMLAttributes<HTMLUListElement>["className"];
}
export const ContextMenu = React.forwardRef<HTMLUListElement, ContextMenuProps>(
({ testId, children, className }, ref) => (
export function ContextMenu({
testId,
children,
className,
ref,
}: ContextMenuProps) {
return (
<ul
data-testid={testId}
ref={ref}
@@ -16,7 +22,5 @@ export const ContextMenu = React.forwardRef<HTMLUListElement, ContextMenuProps>(
>
{children}
</ul>
),
);
ContextMenu.displayName = "ContextMenu";
);
}

View File

@@ -1,115 +0,0 @@
import { Editor, EditorProps } from "@monaco-editor/react";
import React from "react";
import { useTranslation } from "react-i18next";
import { VscCode } from "react-icons/vsc";
import { I18nKey } from "#/i18n/declaration";
import { useFiles } from "#/context/files";
import { useSaveFile } from "#/hooks/mutation/use-save-file";
interface CodeEditorComponentProps {
onMount: EditorProps["onMount"];
isReadOnly: boolean;
}
function CodeEditorComponent({
onMount,
isReadOnly,
}: CodeEditorComponentProps) {
const { t } = useTranslation();
const {
files,
selectedPath,
modifiedFiles,
modifyFileContent,
saveFileContent: saveNewFileContent,
} = useFiles();
const { mutate: saveFile } = useSaveFile();
const handleEditorChange = (value: string | undefined) => {
if (selectedPath && value) modifyFileContent(selectedPath, value);
};
const isBase64Image = (content: string) => content.startsWith("data:image/");
const isPDF = (content: string) => content.startsWith("data:application/pdf");
const isVideo = (content: string) => content.startsWith("data:video/");
React.useEffect(() => {
const handleSave = async (event: KeyboardEvent) => {
if (selectedPath && event.metaKey && event.key === "s") {
const content = saveNewFileContent(selectedPath);
if (content) {
saveFile({ path: selectedPath, content });
}
}
};
document.addEventListener("keydown", handleSave);
return () => {
document.removeEventListener("keydown", handleSave);
};
}, [saveNewFileContent]);
if (!selectedPath) {
return (
<div
data-testid="code-editor-empty-message"
className="flex flex-col h-full items-center justify-center text-neutral-400"
>
<VscCode size={100} />
{t(I18nKey.CODE_EDITOR$EMPTY_MESSAGE)}
</div>
);
}
const fileContent: string | undefined =
modifiedFiles[selectedPath] || files[selectedPath];
if (fileContent) {
if (isBase64Image(fileContent)) {
return (
<section className="flex flex-col relative items-center overflow-auto h-[90%]">
<img
src={fileContent}
alt={selectedPath}
className="object-contain"
/>
</section>
);
}
if (isPDF(fileContent)) {
return (
<iframe
src={fileContent}
title={selectedPath}
width="100%"
height="100%"
/>
);
}
if (isVideo(fileContent)) {
return (
<video controls src={fileContent} width="100%" height="100%">
<track kind="captions" label="English captions" />
</video>
);
}
}
return (
<Editor
data-testid="code-editor"
path={selectedPath ?? undefined}
defaultValue=""
value={selectedPath ? fileContent : undefined}
onMount={onMount}
onChange={handleEditorChange}
options={{ readOnly: isReadOnly }}
/>
);
}
export default React.memo(CodeEditorComponent);

View File

@@ -1,33 +0,0 @@
import { EditorActionButton } from "#/components/shared/buttons/editor-action-button";
interface EditorActionsProps {
onSave: () => void;
onDiscard: () => void;
isDisabled: boolean;
}
export function EditorActions({
onSave,
onDiscard,
isDisabled,
}: EditorActionsProps) {
return (
<div className="flex gap-2">
<EditorActionButton
onClick={onSave}
disabled={isDisabled}
className="bg-neutral-800 disabled:hover:bg-neutral-800"
>
Save
</EditorActionButton>
<EditorActionButton
onClick={onDiscard}
disabled={isDisabled}
className="border border-neutral-800 disabled:hover:bg-transparent"
>
Discard
</EditorActionButton>
</div>
);
}

View File

@@ -1,27 +0,0 @@
import { useTranslation } from "react-i18next";
import { IoFileTray } from "react-icons/io5";
import { I18nKey } from "#/i18n/declaration";
interface DropzoneProps {
onDragLeave: () => void;
onDrop: (event: React.DragEvent<HTMLDivElement>) => void;
}
export function Dropzone({ onDragLeave, onDrop }: DropzoneProps) {
const { t } = useTranslation();
return (
<div
data-testid="dropzone"
onDragLeave={onDragLeave}
onDrop={onDrop}
onDragOver={(event) => event.preventDefault()}
className="z-10 absolute flex flex-col justify-center items-center bg-black top-0 bottom-0 left-0 right-0 opacity-65"
>
<IoFileTray size={32} />
<p className="font-bold text-xl">
{t(I18nKey.EXPLORER$LABEL_DROP_FILES)}
</p>
</div>
);
}

View File

@@ -1,11 +1,9 @@
import { RefreshIconButton } from "#/components/shared/buttons/refresh-icon-button";
import { ToggleWorkspaceIconButton } from "#/components/shared/buttons/toggle-workspace-icon-button";
import { UploadIconButton } from "#/components/shared/buttons/upload-icon-button";
import { cn } from "#/utils/utils";
interface ExplorerActionsProps {
onRefresh: () => void;
onUpload: () => void;
toggleHidden: () => void;
isHidden: boolean;
}
@@ -13,7 +11,6 @@ interface ExplorerActionsProps {
export function ExplorerActions({
toggleHidden,
onRefresh,
onUpload,
isHidden,
}: ExplorerActionsProps) {
return (
@@ -23,12 +20,7 @@ export function ExplorerActions({
isHidden ? "right-3" : "right-2",
)}
>
{!isHidden && (
<>
<RefreshIconButton onClick={onRefresh} />
<UploadIconButton onClick={onUpload} />
</>
)}
{!isHidden && <RefreshIconButton onClick={onRefresh} />}
<ToggleWorkspaceIconButton isHidden={isHidden} onClick={toggleHidden} />
</div>

View File

@@ -7,14 +7,12 @@ interface FileExplorerHeaderProps {
isOpen: boolean;
onToggle: () => void;
onRefreshWorkspace: () => void;
onUploadFile: () => void;
}
export function FileExplorerHeader({
isOpen,
onToggle,
onRefreshWorkspace,
onUploadFile,
}: FileExplorerHeaderProps) {
const { t } = useTranslation();
@@ -35,7 +33,6 @@ export function FileExplorerHeader({
isHidden={!isOpen}
toggleHidden={onToggle}
onRefresh={onRefreshWorkspace}
onUpload={onUploadFile}
/>
</div>
);

View File

@@ -1,5 +1,5 @@
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { ExplorerTree } from "#/components/features/file-explorer/explorer-tree";
@@ -7,14 +7,10 @@ import toast from "#/utils/toast";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
import { useListFiles } from "#/hooks/query/use-list-files";
import { FileUploadSuccessResponse } from "#/api/open-hands.types";
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
import { cn } from "#/utils/utils";
import { Dropzone } from "./dropzone";
import { FileExplorerHeader } from "./file-explorer-header";
import { useVSCodeUrl } from "#/hooks/query/use-vscode-url";
import { OpenVSCodeButton } from "#/components/shared/buttons/open-vscode-button";
import { addAssistantMessage } from "#/state/chat-slice";
interface FileExplorerProps {
isOpen: boolean;
@@ -23,26 +19,16 @@ interface FileExplorerProps {
export function FileExplorer({ isOpen, onToggle }: FileExplorerProps) {
const { t } = useTranslation();
const dispatch = useDispatch();
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const [isDragging, setIsDragging] = React.useState(false);
const { curAgentState } = useSelector((state: RootState) => state.agent);
const { data: paths, refetch, error } = useListFiles();
const { mutate: uploadFiles } = useUploadFiles();
const { data: vscodeUrl } = useVSCodeUrl({
enabled: !RUNTIME_INACTIVE_STATES.includes(curAgentState),
});
const handleOpenVSCode = () => {
if (vscodeUrl?.vscode_url) {
dispatch(
addAssistantMessage(
"You opened VS Code. Please inform the agent of any changes you made to the workspace or environment. To avoid conflicts, it's best to pause the agent before making any changes.",
),
);
window.open(vscodeUrl.vscode_url, "_blank");
} else if (vscodeUrl?.error) {
toast.error(
@@ -54,86 +40,18 @@ export function FileExplorer({ isOpen, onToggle }: FileExplorerProps) {
}
};
const selectFileInput = () => {
fileInputRef.current?.click(); // Trigger the file browser
};
const handleUploadSuccess = (data: FileUploadSuccessResponse) => {
const uploadedCount = data.uploaded_files.length;
const skippedCount = data.skipped_files.length;
if (uploadedCount > 0) {
toast.success(
`upload-success-${new Date().getTime()}`,
t(I18nKey.EXPLORER$UPLOAD_SUCCESS_MESSAGE, {
count: uploadedCount,
}),
);
}
if (skippedCount > 0) {
const message = t(I18nKey.EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE, {
count: skippedCount,
});
toast.info(message);
}
if (uploadedCount === 0 && skippedCount === 0) {
toast.info(t(I18nKey.EXPLORER$NO_FILES_UPLOADED_MESSAGE));
}
};
const handleUploadError = (uploadError: Error) => {
toast.error(
`upload-error-${new Date().getTime()}`,
uploadError.message || t(I18nKey.EXPLORER$UPLOAD_ERROR_MESSAGE),
);
};
const refreshWorkspace = () => {
if (!RUNTIME_INACTIVE_STATES.includes(curAgentState)) {
refetch();
}
};
const uploadFileData = (files: FileList) => {
uploadFiles(
{ files: Array.from(files) },
{ onSuccess: handleUploadSuccess, onError: handleUploadError },
);
refreshWorkspace();
};
const handleDropFiles = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
const { files: droppedFiles } = event.dataTransfer;
if (droppedFiles.length > 0) {
uploadFileData(droppedFiles);
}
setIsDragging(false);
};
React.useEffect(() => {
refreshWorkspace();
}, [curAgentState]);
return (
<div
data-testid="file-explorer"
className="relative h-full"
onDragEnter={() => {
setIsDragging(true);
}}
onDragEnd={() => {
setIsDragging(false);
}}
>
{isDragging && (
<Dropzone
onDragLeave={() => setIsDragging(false)}
onDrop={handleDropFiles}
/>
)}
<div data-testid="file-explorer" className="relative h-full">
<div
className={cn(
"bg-neutral-800 h-full border-r-1 border-r-neutral-600 flex flex-col",
@@ -145,7 +63,6 @@ export function FileExplorer({ isOpen, onToggle }: FileExplorerProps) {
isOpen={isOpen}
onToggle={onToggle}
onRefreshWorkspace={refreshWorkspace}
onUploadFile={selectFileInput}
/>
{!error && (
<div className="overflow-auto flex-grow min-h-0">

View File

@@ -4,7 +4,7 @@ interface FolderIconProps {
isOpen: boolean;
}
export function FolderIcon({ isOpen }: FolderIconProps): JSX.Element {
export function FolderIcon({ isOpen }: FolderIconProps) {
return isOpen ? (
<FaFolderOpen color="D9D3D0" className="icon" />
) : (

View File

@@ -14,13 +14,7 @@ interface TreeNodeProps {
}
function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
const {
setFileContent,
modifiedFiles,
setSelectedPath,
files,
selectedPath,
} = useFiles();
const { setFileContent, setSelectedPath, files, selectedPath } = useFiles();
const [isOpen, setIsOpen] = React.useState(defaultOpen);
const { curAgentState } = useSelector((state: RootState) => state.agent);
@@ -35,8 +29,7 @@ function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
React.useEffect(() => {
if (fileContent) {
const code = modifiedFiles[path] || files[path];
if (!code || fileContent !== files[path]) {
if (fileContent !== files[path]) {
setFileContent(path, fileContent);
}
}
@@ -79,10 +72,6 @@ function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
type={isDirectory ? "folder" : "file"}
isOpen={isOpen}
/>
{modifiedFiles[path] && (
<div className="w-2 h-2 rounded-full bg-neutral-500" />
)}
</button>
{isOpen && paths && (

View File

@@ -1,22 +0,0 @@
import { IoIosCloudUpload } from "react-icons/io";
import { IconButton } from "./icon-button";
interface UploadIconButtonProps {
onClick: () => void;
}
export function UploadIconButton({ onClick }: UploadIconButtonProps) {
return (
<IconButton
icon={
<IoIosCloudUpload
size={16}
className="text-neutral-400 hover:text-neutral-100 transition"
/>
}
testId="upload"
ariaLabel="Upload File"
onClick={onClick}
/>
);
}

View File

@@ -25,7 +25,6 @@ export function SecurityAnalyzerInput({
</label>
<Autocomplete
isDisabled={isDisabled}
isRequired
id="security-analyzer"
name="security-analyzer"
aria-label="Security Analyzer"

View File

@@ -4,7 +4,7 @@ interface InvariantLogoIconProps {
className?: string;
}
function InvariantLogoIcon({ className }: InvariantLogoIconProps): JSX.Element {
function InvariantLogoIcon({ className }: InvariantLogoIconProps) {
return (
<svg
width="39"

View File

@@ -24,7 +24,7 @@ import { useGetTraces } from "#/hooks/query/use-get-traces";
type SectionType = "logs" | "policy" | "settings";
function SecurityInvariant(): JSX.Element {
function SecurityInvariant() {
const { t } = useTranslation();
const { logs } = useSelector((state: RootState) => state.securityAnalyzer);
@@ -122,7 +122,7 @@ function SecurityInvariant(): JSX.Element {
[],
);
const sections: { [key in SectionType]: JSX.Element } = {
const sections: Record<SectionType, React.ReactNode> = {
logs: (
<>
<div className="flex justify-between items-center border-b border-neutral-600 mb-4 p-4">

View File

@@ -7,11 +7,7 @@ import { getDefaultSettings, Settings } from "#/services/settings";
import { extractModelAndProvider } from "#/utils/extract-model-and-provider";
import { DangerModal } from "../confirmation-modals/danger-modal";
import { I18nKey } from "#/i18n/declaration";
import {
extractSettings,
saveSettingsView,
updateSettingsVersion,
} from "#/utils/settings-utils";
import { extractSettings, saveSettingsView } from "#/utils/settings-utils";
import { useEndSession } from "#/hooks/use-end-session";
import { useSettings } from "#/context/settings-context";
import { ModalButton } from "../../buttons/modal-button";
@@ -24,7 +20,6 @@ import { CustomModelInput } from "../../inputs/custom-model-input";
import { SecurityAnalyzerInput } from "../../inputs/security-analyzers-input";
import { ModalBackdrop } from "../modal-backdrop";
import { ModelSelector } from "./model-selector";
import { useAuth } from "#/context/auth-context";
interface SettingsFormProps {
disabled?: boolean;
@@ -45,7 +40,6 @@ export function SettingsForm({
}: SettingsFormProps) {
const { saveSettings } = useSettings();
const endSession = useEndSession();
const { logout } = useAuth();
const location = useLocation();
const { t } = useTranslation();
@@ -92,14 +86,13 @@ export function SettingsForm({
}
};
const handleFormSubmission = (formData: FormData) => {
const handleFormSubmission = async (formData: FormData) => {
const keys = Array.from(formData.keys());
const isUsingAdvancedOptions = keys.includes("use-advanced-options");
const newSettings = extractSettings(formData);
saveSettingsView(isUsingAdvancedOptions ? "advanced" : "basic");
updateSettingsVersion(logout);
saveSettings(newSettings);
await saveSettings(newSettings);
resetOngoingSession();
posthog.capture("settings_saved", {
@@ -108,8 +101,8 @@ export function SettingsForm({
});
};
const handleConfirmResetSettings = () => {
saveSettings(getDefaultSettings());
const handleConfirmResetSettings = async () => {
await saveSettings(getDefaultSettings());
resetOngoingSession();
posthog.capture("settings_reset");

View File

@@ -11,7 +11,6 @@ import {
} from "#/state/initial-query-slice";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
import { useSettings } from "#/context/settings-context";
import { SuggestionBubble } from "#/components/features/suggestions/suggestion-bubble";
import { SUGGESTIONS } from "#/utils/suggestions";
@@ -22,13 +21,17 @@ import { cn } from "#/utils/utils";
import { AttachImageLabel } from "../features/images/attach-image-label";
import { ImageCarousel } from "../features/images/image-carousel";
import { UploadImageInput } from "../features/images/upload-image-input";
import { LoadingSpinner } from "./loading-spinner";
export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
interface TaskFormProps {
ref: React.RefObject<HTMLFormElement | null>;
}
export function TaskForm({ ref }: TaskFormProps) {
const dispatch = useDispatch();
const navigation = useNavigation();
const navigate = useNavigate();
const { gitHubToken } = useAuth();
const { settings } = useSettings();
const { selectedRepository, files } = useSelector(
(state: RootState) => state.initialQuery,
@@ -45,7 +48,6 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
return OpenHands.newConversation({
githubToken: gitHubToken || undefined,
selectedRepository: selectedRepository || undefined,
args: settings || undefined,
});
},
onSuccess: ({ conversation_id: conversationId }, { q }) => {
@@ -88,7 +90,9 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
const formData = new FormData(event.currentTarget);
const q = formData.get("q")?.toString();
newConversationMutation.mutate({ q });
if (q?.trim()) {
newConversationMutation.mutate({ q });
}
};
return (
@@ -110,32 +114,35 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
"hover:border-neutral-500 focus-within:border-neutral-500",
)}
>
<ChatInput
name="q"
onSubmit={() => {
if (typeof ref !== "function") ref?.current?.requestSubmit();
}}
onChange={(message) => setText(message)}
onFocus={() => setInputIsFocused(true)}
onBlur={() => setInputIsFocused(false)}
onImagePaste={async (imageFiles) => {
const promises = imageFiles.map(convertImageToBase64);
const base64Images = await Promise.all(promises);
base64Images.forEach((base64) => {
dispatch(addFile(base64));
});
}}
placeholder={placeholder}
value={text}
maxRows={15}
showButton={!!text}
className="text-[17px] leading-5 py-[17px]"
buttonClassName="pb-[17px]"
disabled={
navigation.state === "submitting" ||
newConversationMutation.isPending
}
/>
{newConversationMutation.isPending ? (
<div className="flex justify-center py-[17px]">
<LoadingSpinner size="small" />
</div>
) : (
<ChatInput
name="q"
onSubmit={() => {
if (typeof ref !== "function") ref?.current?.requestSubmit();
}}
onChange={(message) => setText(message)}
onFocus={() => setInputIsFocused(true)}
onBlur={() => setInputIsFocused(false)}
onImagePaste={async (imageFiles) => {
const promises = imageFiles.map(convertImageToBase64);
const base64Images = await Promise.all(promises);
base64Images.forEach((base64) => {
dispatch(addFile(base64));
});
}}
placeholder={placeholder}
value={text}
maxRows={15}
showButton={!!text}
className="text-[17px] leading-5 py-[17px]"
buttonClassName="pb-[17px]"
disabled={navigation.state === "submitting"}
/>
)}
</div>
</form>
<UploadImageInput
@@ -157,6 +164,4 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
)}
</div>
);
});
TaskForm.displayName = "TaskForm";
}

View File

@@ -102,7 +102,7 @@ function AuthProvider({ children }: React.PropsWithChildren) {
[gitHubTokenState],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
return <AuthContext value={value}>{children}</AuthContext>;
}
function useAuth() {

View File

@@ -24,11 +24,7 @@ export function ConversationProvider({
const value = useMemo(() => ({ conversationId }), [conversationId]);
return (
<ConversationContext.Provider value={value}>
{children}
</ConversationContext.Provider>
);
return <ConversationContext value={value}>{children}</ConversationContext>;
}
export function useConversation() {

View File

@@ -24,10 +24,6 @@ interface FilesContextType {
setFileContent: (path: string, content: string) => void;
selectedPath: string | null;
setSelectedPath: (path: string | null) => void;
modifiedFiles: Record<string, string>;
modifyFileContent: (path: string, content: string) => void;
saveFileContent: (path: string) => string | undefined;
discardChanges: (path: string) => void;
}
const FilesContext = React.createContext<FilesContextType | undefined>(
@@ -41,49 +37,12 @@ interface FilesProviderProps {
function FilesProvider({ children }: FilesProviderProps) {
const [paths, setPaths] = React.useState<string[]>([]);
const [files, setFiles] = React.useState<Record<string, string>>({});
const [modifiedFiles, setModifiedFiles] = React.useState<
Record<string, string>
>({});
const [selectedPath, setSelectedPath] = React.useState<string | null>(null);
const setFileContent = React.useCallback((path: string, content: string) => {
setFiles((prev) => ({ ...prev, [path]: content }));
}, []);
const modifyFileContent = React.useCallback(
(path: string, content: string) => {
if (files[path] !== content) {
setModifiedFiles((prev) => ({ ...prev, [path]: content }));
} else {
const newModifiedFiles = { ...modifiedFiles };
delete newModifiedFiles[path];
setModifiedFiles(newModifiedFiles);
}
},
[files, modifiedFiles],
);
const discardChanges = React.useCallback((path: string) => {
setModifiedFiles((prev) => {
const newModifiedFiles = { ...prev };
delete newModifiedFiles[path];
return newModifiedFiles;
});
}, []);
const saveFileContent = React.useCallback(
(path: string): string | undefined => {
const content = modifiedFiles[path];
if (content) {
setFiles((prev) => ({ ...prev, [path]: content }));
discardChanges(path);
}
return content;
},
[files, modifiedFiles, selectedPath, discardChanges],
);
const value = React.useMemo(
() => ({
paths,
@@ -92,28 +51,11 @@ function FilesProvider({ children }: FilesProviderProps) {
setFileContent,
selectedPath,
setSelectedPath,
modifiedFiles,
modifyFileContent,
saveFileContent,
discardChanges,
}),
[
paths,
setPaths,
files,
setFileContent,
selectedPath,
setSelectedPath,
modifiedFiles,
modifyFileContent,
saveFileContent,
discardChanges,
],
[paths, setPaths, files, setFileContent, selectedPath, setSelectedPath],
);
return (
<FilesContext.Provider value={value}>{children}</FilesContext.Provider>
);
return <FilesContext value={value}>{children}</FilesContext>;
}
function useFiles() {

View File

@@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
getSettings,
Settings,
saveSettings as updateAndSaveSettingsToLocalStorage,
saveSettings,
settingsAreUpToDate as checkIfSettingsAreUpToDate,
DEFAULT_SETTINGS,
} from "#/services/settings";
@@ -33,8 +33,8 @@ function SettingsProvider({ children }: React.PropsWithChildren) {
);
const queryClient = useQueryClient();
const saveSettings = (newSettings: Partial<Settings>) => {
updateAndSaveSettingsToLocalStorage(newSettings);
const handleSaveSettings = async (newSettings: Partial<Settings>) => {
await saveSettings(newSettings);
queryClient.invalidateQueries({ queryKey: SETTINGS_QUERY_KEY });
setSettingsAreUpToDate(checkIfSettingsAreUpToDate());
};
@@ -49,16 +49,12 @@ function SettingsProvider({ children }: React.PropsWithChildren) {
() => ({
settings,
settingsAreUpToDate,
saveSettings,
saveSettings: handleSaveSettings,
}),
[settings, settingsAreUpToDate],
);
return (
<SettingsContext.Provider value={value}>
{children}
</SettingsContext.Provider>
);
return <SettingsContext value={value}>{children}</SettingsContext>;
}
function useSettings() {

View File

@@ -151,11 +151,7 @@ export function WsClientProvider({
[status, messageRateHandler.isUnderThreshold, events],
);
return (
<WsClientContext.Provider value={value}>
{children}
</WsClientContext.Provider>
);
return <WsClientContext value={value}>{children}</WsClientContext>;
}
export function useWsClient() {

View File

@@ -1,20 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import toast from "react-hot-toast";
import OpenHands from "#/api/open-hands";
import { useConversation } from "#/context/conversation-context";
type SaveFileArgs = {
path: string;
content: string;
};
export const useSaveFile = () => {
const { conversationId } = useConversation();
return useMutation({
mutationFn: ({ path, content }: SaveFileArgs) =>
OpenHands.saveFile(conversationId, path, content),
onError: (error) => {
toast.error(error.message);
},
});
};

View File

@@ -1,15 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useConversation } from "#/context/conversation-context";
type UploadFilesArgs = {
files: File[];
};
export const useUploadFiles = () => {
const { conversationId } = useConversation();
return useMutation({
mutationFn: ({ files }: UploadFilesArgs) =>
OpenHands.uploadFiles(conversationId, files),
});
};

View File

@@ -20,7 +20,7 @@ export function useDownloadProgress(
const [progress, setProgress] =
useState<DownloadProgressState>(INITIAL_PROGRESS);
const progressRef = useRef<DownloadProgressState>(INITIAL_PROGRESS);
const abortController = useRef<AbortController>();
const abortController = useRef<AbortController>(null);
const { conversationId } = useConversation();
// Create AbortController on mount
@@ -31,7 +31,7 @@ export function useDownloadProgress(
progressRef.current = INITIAL_PROGRESS;
return () => {
controller.abort();
abortController.current = undefined;
abortController.current = null;
};
}, []); // Empty deps array - only run on mount/unmount

View File

@@ -1,6 +1,6 @@
import { RefObject, useEffect, useState } from "react";
export function useScrollToBottom(scrollRef: RefObject<HTMLDivElement>) {
export function useScrollToBottom(scrollRef: RefObject<HTMLDivElement | null>) {
// for auto-scroll
const [autoScroll, setAutoScroll] = useState(true);

View File

@@ -36,15 +36,15 @@ function Home() {
return (
<div
data-testid="root-index"
className="bg-root-secondary h-full rounded-xl flex flex-col items-center justify-center relative overflow-y-auto"
className="bg-root-secondary h-full rounded-xl flex flex-col items-center justify-center relative overflow-y-auto px-2"
>
<HeroHeading />
<div className="flex flex-col gap-8 w-[600px] items-center">
<div className="flex flex-col gap-8 w-full md:w-[600px] items-center">
<div className="flex flex-col gap-2 w-full">
<TaskForm ref={formRef} />
</div>
<div className="flex gap-4 w-full">
<div className="flex gap-4 w-full flex-col md:flex-row">
<GitHubRepositoriesSuggestionBox
handleSubmit={() => formRef.current?.requestSubmit()}
repositories={

View File

@@ -1,16 +1,9 @@
import React from "react";
import { useSelector } from "react-redux";
import { useRouteError } from "react-router";
import { editor } from "monaco-editor";
import { EditorProps } from "@monaco-editor/react";
import { RootState } from "#/store";
import { AgentState } from "#/types/agent-state";
import CodeEditorComponent from "../../components/features/editor/code-editor-component";
import { useFiles } from "#/context/files";
import { useSaveFile } from "#/hooks/mutation/use-save-file";
import { ASSET_FILE_TYPES } from "./constants";
import { EditorActions } from "#/components/features/editor/editor-actions";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
import { FileExplorer } from "#/components/features/file-explorer/file-explorer";
import { useFiles } from "#/context/files";
export function ErrorBoundary() {
const error = useRouteError();
@@ -23,90 +16,91 @@ export function ErrorBoundary() {
);
}
function CodeEditor() {
const {
selectedPath,
modifiedFiles,
saveFileContent: saveNewFileContent,
discardChanges,
} = useFiles();
function getLanguageFromPath(path: string): string {
const extension = path.split(".").pop()?.toLowerCase();
switch (extension) {
case "js":
case "jsx":
return "javascript";
case "ts":
case "tsx":
return "typescript";
case "py":
return "python";
case "html":
return "html";
case "css":
return "css";
case "json":
return "json";
case "md":
return "markdown";
case "yml":
case "yaml":
return "yaml";
case "sh":
case "bash":
return "bash";
case "dockerfile":
return "dockerfile";
case "rs":
return "rust";
case "go":
return "go";
case "java":
return "java";
case "cpp":
case "cc":
case "cxx":
return "cpp";
case "c":
return "c";
case "rb":
return "ruby";
case "php":
return "php";
case "sql":
return "sql";
default:
return "text";
}
}
function FileViewer() {
const [fileExplorerIsOpen, setFileExplorerIsOpen] = React.useState(true);
const editorRef = React.useRef<editor.IStandaloneCodeEditor | null>(null);
const { mutate: saveFile } = useSaveFile();
const { selectedPath, files } = useFiles();
const toggleFileExplorer = () => {
setFileExplorerIsOpen((prev) => !prev);
editorRef.current?.layout({ width: 0, height: 0 });
};
const handleEditorDidMount: EditorProps["onMount"] = (e, monaco) => {
editorRef.current = e;
monaco.editor.defineTheme("oh-dark", {
base: "vs-dark",
inherit: true,
rules: [],
colors: {
"editor.background": "#171717",
},
});
monaco.editor.setTheme("oh-dark");
};
const agentState = useSelector(
(state: RootState) => state.agent.curAgentState,
);
// Code editing is only allowed when the agent is paused, finished, or awaiting user input (server rules)
const isEditingAllowed = React.useMemo(
() =>
agentState === AgentState.PAUSED ||
agentState === AgentState.FINISHED ||
agentState === AgentState.AWAITING_USER_INPUT,
[agentState],
);
const handleSave = async () => {
if (selectedPath) {
const content = modifiedFiles[selectedPath];
if (content) {
saveFile({ path: selectedPath, content });
saveNewFileContent(selectedPath);
}
}
};
const handleDiscard = () => {
if (selectedPath) discardChanges(selectedPath);
};
const isAssetFileType = selectedPath
? ASSET_FILE_TYPES.some((ext) => selectedPath.endsWith(ext))
: false;
return (
<div className="flex h-full bg-neutral-900 relative">
<FileExplorer isOpen={fileExplorerIsOpen} onToggle={toggleFileExplorer} />
<div className="w-full">
{selectedPath && !isAssetFileType && (
<div className="w-full h-full flex flex-col">
{selectedPath && (
<div className="flex w-full items-center justify-between self-end p-2">
<span className="text-sm text-neutral-500">{selectedPath}</span>
<EditorActions
onSave={handleSave}
onDiscard={handleDiscard}
isDisabled={!isEditingAllowed || !modifiedFiles[selectedPath]}
/>
</div>
)}
<CodeEditorComponent
onMount={handleEditorDidMount}
isReadOnly={!isEditingAllowed}
/>
{selectedPath && files[selectedPath] && (
<div className="p-4 flex-1 overflow-auto">
<SyntaxHighlighter
language={getLanguageFromPath(selectedPath)}
style={vscDarkPlus}
customStyle={{
margin: 0,
background: "#171717",
fontSize: "0.875rem",
}}
>
{files[selectedPath]}
</SyntaxHighlighter>
</div>
)}
</div>
</div>
);
}
export default CodeEditor;
export default FileViewer;

View File

@@ -1,13 +1,9 @@
import React from "react";
import toast from "react-hot-toast";
import { useDispatch, useSelector } from "react-redux";
import { useSelector } from "react-redux";
import { useAuth } from "#/context/auth-context";
import { useWsClient } from "#/context/ws-client-provider";
import { getGitHubTokenCommand } from "#/services/terminal-service";
import { setImportedProjectZip } from "#/state/initial-query-slice";
import { RootState } from "#/store";
import { base64ToBlob } from "#/utils/base64-to-blob";
import { useUploadFiles } from "../../../hooks/mutation/use-upload-files";
import { useGitHubUser } from "../../../hooks/query/use-github-user";
import { isGitHubErrorReponse } from "#/api/github-axios-instance";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
@@ -17,48 +13,19 @@ export const useHandleRuntimeActive = () => {
const { send } = useWsClient();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const dispatch = useDispatch();
const { data: user } = useGitHubUser();
const { mutate: uploadFiles } = useUploadFiles();
const runtimeActive = !RUNTIME_INACTIVE_STATES.includes(curAgentState);
const { importedProjectZip } = useSelector(
(state: RootState) => state.initialQuery,
);
const userId = React.useMemo(() => {
if (user && !isGitHubErrorReponse(user)) return user.id;
return null;
}, [user]);
const handleUploadFiles = (zip: string) => {
const blob = base64ToBlob(zip);
const file = new File([blob], "imported-project.zip", {
type: blob.type,
});
uploadFiles(
{ files: [file] },
{
onError: () => {
toast.error("Failed to upload project files.");
},
},
);
dispatch(setImportedProjectZip(null));
};
React.useEffect(() => {
if (runtimeActive && userId && gitHubToken) {
// Export if the user valid, this could happen mid-session so it is handled here
send(getGitHubTokenCommand(gitHubToken));
}
}, [userId, gitHubToken, runtimeActive]);
React.useEffect(() => {
if (runtimeActive && importedProjectZip) {
handleUploadFiles(importedProjectZip);
}
}, [runtimeActive, importedProjectZip]);
};

View File

@@ -5,11 +5,11 @@ import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
import { useAuth } from "#/context/auth-context";
import { useSettings } from "#/context/settings-context";
import { updateSettingsVersion } from "#/utils/settings-utils";
import { useConfig } from "#/hooks/query/use-config";
import { Sidebar } from "#/components/features/sidebar/sidebar";
import { WaitlistModal } from "#/components/features/waitlist/waitlist-modal";
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
import { SettingsModal } from "#/components/shared/modals/settings/settings-modal";
export function ErrorBoundary() {
const error = useRouteError();
@@ -45,15 +45,13 @@ export function ErrorBoundary() {
export default function MainApp() {
const { gitHubToken } = useAuth();
const { settings, settingsAreUpToDate } = useSettings();
const { settings } = useSettings();
const { logout } = useAuth();
const [consentFormIsOpen, setConsentFormIsOpen] = React.useState(
!localStorage.getItem("analytics-consent"),
);
const [aiConfigModalIsOpen, setAiConfigModalIsOpen] =
React.useState(!settingsAreUpToDate);
const config = useConfig();
const { data: isAuthed, isFetching: isFetchingAuth } = useIsAuthed();
@@ -69,6 +67,10 @@ export default function MainApp() {
}
}, [settings.LANGUAGE]);
React.useEffect(() => {
updateSettingsVersion(logout);
}, []);
const isInWaitlist =
!isFetchingAuth && !isAuthed && config.data?.APP_MODE === "saas";
@@ -92,13 +94,6 @@ export default function MainApp() {
onClose={() => setConsentFormIsOpen(false)}
/>
)}
{aiConfigModalIsOpen && (
<SettingsModal
onClose={() => setAiConfigModalIsOpen(false)}
data-testid="ai-config-modal"
/>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { openHands } from "#/api/open-hands-axios";
export const LATEST_SETTINGS_VERSION = 4;
export const LATEST_SETTINGS_VERSION = 5;
export type Settings = {
LLM_MODEL: string;
@@ -45,54 +45,8 @@ export const getCurrentSettingsVersion = () => {
export const settingsAreUpToDate = () =>
getCurrentSettingsVersion() === LATEST_SETTINGS_VERSION;
export const maybeMigrateSettings = (logout: () => void) => {
// Sometimes we ship major changes, like a new default agent.
// In this case, we may want to override a previous choice made by the user.
const currentVersion = getCurrentSettingsVersion();
if (currentVersion < 1) {
localStorage.setItem("AGENT", DEFAULT_SETTINGS.AGENT);
}
if (currentVersion < 2) {
const customModel = localStorage.getItem("CUSTOM_LLM_MODEL");
if (customModel) {
localStorage.setItem("LLM_MODEL", customModel);
}
localStorage.removeItem("CUSTOM_LLM_MODEL");
localStorage.removeItem("USING_CUSTOM_MODEL");
}
if (currentVersion < 3) {
localStorage.removeItem("token");
}
if (currentVersion < 4) {
logout();
}
};
/**
* Get the default settings
*/
export const getDefaultSettings = (): Settings => DEFAULT_SETTINGS;
/**
* Get the settings from the server or use the default settings if not found
*/
export const getSettings = async (): Promise<Settings> => {
const { data: apiSettings } =
await openHands.get<ApiSettings>("/api/settings");
if (apiSettings != null) {
return {
LLM_MODEL: apiSettings.llm_model,
LLM_BASE_URL: apiSettings.llm_base_url,
AGENT: apiSettings.agent,
LANGUAGE: apiSettings.language,
CONFIRMATION_MODE: apiSettings.confirmation_mode,
SECURITY_ANALYZER: apiSettings.security_analyzer,
LLM_API_KEY: "",
};
}
// TODO: localStorage settings are deprecated. Remove this after 1/31/2025
export const getLocalStorageSettings = (): Settings => {
const llmModel = localStorage.getItem("LLM_MODEL");
const baseUrl = localStorage.getItem("LLM_BASE_URL");
const agent = localStorage.getItem("AGENT");
@@ -133,7 +87,61 @@ export const saveSettings = async (
const { data } = await openHands.post("/api/settings", apiSettings);
return data;
} catch (error) {
console.error("Error saving settings:", error);
return false;
}
};
export const maybeMigrateSettings = async (logout: () => void) => {
// Sometimes we ship major changes, like a new default agent.
// In this case, we may want to override a previous choice made by the user.
const currentVersion = getCurrentSettingsVersion();
if (currentVersion < 1) {
localStorage.setItem("AGENT", DEFAULT_SETTINGS.AGENT);
}
if (currentVersion < 2) {
const customModel = localStorage.getItem("CUSTOM_LLM_MODEL");
if (customModel) {
localStorage.setItem("LLM_MODEL", customModel);
}
localStorage.removeItem("CUSTOM_LLM_MODEL");
localStorage.removeItem("USING_CUSTOM_MODEL");
}
if (currentVersion < 3) {
localStorage.removeItem("token");
}
if (currentVersion < 4) {
logout();
}
if (currentVersion < 5) {
const localSettings = getLocalStorageSettings();
await saveSettings(localSettings);
}
};
/**
* Get the default settings
*/
export const getDefaultSettings = (): Settings => DEFAULT_SETTINGS;
/**
* Get the settings from the server or use the default settings if not found
*/
export const getSettings = async (): Promise<Settings> => {
const { data: apiSettings } =
await openHands.get<ApiSettings>("/api/settings");
if (apiSettings != null) {
return {
LLM_MODEL: apiSettings.llm_model,
LLM_BASE_URL: apiSettings.llm_base_url,
AGENT: apiSettings.agent,
LANGUAGE: apiSettings.language,
CONFIRMATION_MODE: apiSettings.confirmation_mode,
SECURITY_ANALYZER: apiSettings.security_analyzer,
LLM_API_KEY: "",
};
}
return getLocalStorageSettings();
};

View File

@@ -1,17 +0,0 @@
import toast from "react-hot-toast";
import { ErrorToast } from "#/components/shared/error-toast";
export const displayErrorToast = (error: string) =>
toast((t) => <ErrorToast id={t.id} error={error} />, {
style: {
background: "#C63143",
color: "#fff",
fontSize: "12px",
fontWeight: "500",
lineHeight: "20px",
borderRadius: "4px",
width: "336px",
},
duration: Infinity,
position: "bottom-right",
});

View File

@@ -82,9 +82,9 @@ const saveSettingsView = (view: "basic" | "advanced") => {
* Updates the settings version in local storage if the current settings are not up to date.
* If the settings are outdated, it attempts to migrate them before updating the version.
*/
const updateSettingsVersion = (logout: () => void) => {
const updateSettingsVersion = async (logout: () => void) => {
if (!settingsAreUpToDate()) {
maybeMigrateSettings(logout);
await maybeMigrateSettings(logout);
localStorage.setItem(
"SETTINGS_VERSION",
LATEST_SETTINGS_VERSION.toString(),

View File

@@ -3,14 +3,12 @@
import React, { PropsWithChildren } from "react";
import { Provider } from "react-redux";
import { configureStore } from "@reduxjs/toolkit";
// eslint-disable-next-line import/no-extraneous-dependencies
import { RenderOptions, render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nextProvider } from "react-i18next";
import { I18nextProvider, initReactI18next } from "react-i18next";
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { AppStore, RootState, rootReducer } from "./src/store";
import { vi } from "vitest";
import { AppStore, RootState, rootReducer } from "./src/store";
import { AuthProvider } from "#/context/auth-context";
import { SettingsProvider } from "#/context/settings-context";
import { ConversationProvider } from "#/context/conversation-context";
@@ -26,22 +24,20 @@ vi.mock("react-router", async () => {
});
// Initialize i18n for tests
i18n
.use(initReactI18next)
.init({
lng: "en",
fallbackLng: "en",
ns: ["translation"],
defaultNS: "translation",
resources: {
en: {
translation: {},
},
i18n.use(initReactI18next).init({
lng: "en",
fallbackLng: "en",
ns: ["translation"],
defaultNS: "translation",
resources: {
en: {
translation: {},
},
interpolation: {
escapeValue: false,
},
});
},
interpolation: {
escapeValue: false,
},
});
const setupStore = (preloadedState?: Partial<RootState>): AppStore =>
configureStore({
@@ -67,16 +63,14 @@ export function renderWithProviders(
...renderOptions
}: ExtendedRenderOptions = {},
) {
function Wrapper({ children }: PropsWithChildren<object>): JSX.Element {
function Wrapper({ children }: PropsWithChildren) {
return (
<Provider store={store}>
<QueryClientProvider client={new QueryClient()}>
<SettingsProvider>
<AuthProvider>
<ConversationProvider>
<I18nextProvider i18n={i18n}>
{children}
</I18nextProvider>
<I18nextProvider i18n={i18n}>{children}</I18nextProvider>
</ConversationProvider>
</AuthProvider>
</SettingsProvider>

View File

@@ -249,9 +249,14 @@ def auto_continue_response(
try_parse: Callable[[Action | None], str] | None = None,
) -> str:
"""Default function to generate user responses.
Returns 'continue' to tell the agent to proceed without asking for more input.
Tell the agent to proceed without asking for more input, or finish the interaction.
"""
return 'continue'
message = (
'Please continue on whatever approach you think is suitable.\n'
'If you think you have solved the task, please finish the interaction.\n'
'IMPORTANT: YOU SHOULD NEVER ASK FOR HUMAN RESPONSE.\n'
)
return message
if __name__ == '__main__':

View File

@@ -62,6 +62,8 @@ class Message(BaseModel):
# - tool execution result (to LLM)
tool_call_id: str | None = None
name: str | None = None # name of the tool
# force string serializer
force_string_serializer: bool = False
@property
def contains_image(self) -> bool:
@@ -73,7 +75,9 @@ class Message(BaseModel):
# - into a single string: for providers that don't support list of content items (e.g. no vision, no tool calls)
# - into a list of content items: the new APIs of providers with vision/prompt caching/tool calls
# NOTE: remove this when litellm or providers support the new API
if self.cache_enabled or self.vision_enabled or self.function_calling_enabled:
if not self.force_string_serializer and (
self.cache_enabled or self.vision_enabled or self.function_calling_enabled
):
return self._list_serializer()
# some providers, like HF and Groq/llama, don't support a list here, but a single string
return self._string_serializer()

View File

@@ -122,6 +122,12 @@ class LLM(RetryMixin, DebugMixin):
if self.is_function_calling_active():
logger.debug('LLM: model supports function calling')
# Compatibility flag: use string serializer for DeepSeek models
# See this issue: https://github.com/All-Hands-AI/OpenHands/issues/5818
self._use_string_serializer = False
if 'deepseek' in self.config.model:
self._use_string_serializer = True
# if using a custom tokenizer, make sure it's loaded and accessible in the format expected by litellm
if self.config.custom_tokenizer is not None:
self.tokenizer = create_pretrained_tokenizer(self.config.custom_tokenizer)
@@ -618,6 +624,8 @@ class LLM(RetryMixin, DebugMixin):
message.cache_enabled = self.is_caching_prompt_active()
message.vision_enabled = self.vision_is_active()
message.function_calling_enabled = self.is_function_calling_active()
if 'deepseek' in self.config.model:
message.force_string_serializer = True
# let pydantic handle the serialization
return [message.model_dump() for message in messages]

View File

@@ -3,11 +3,17 @@ Given the following issue description and the last message from an AI agent atte
Issue description:
{{ issue_context }}
Last message from AI agent:
Last message from AI agent (including any patch content):
{{ last_message }}
(1) has the issue been successfully resolved?
(2) If the issue has been resolved, please provide an explanation of what was done in the PR that can be sent to a human reviewer on github. If the issue has not been resolved, please provide an explanation of why.
Please analyze:
1. Has the issue been successfully resolved? Look for concrete evidence such as:
- Patch content showing actual code changes
- Clear description of what was fixed
- Specific files that were modified
2. If patch content is present, carefully examine the changes to verify they address the issue
3. If the issue has been resolved, provide an explanation of what was done in the PR that can be sent to a human reviewer on github
4. If the issue has not been resolved, explain why
Answer in exactly the format below, with only true or false for success, and an explanation of the result.

View File

@@ -13,6 +13,7 @@ from openhands.events.observation import (
from openhands.events.observation.agent import AgentStateChangedObservation
from openhands.events.serialization import event_to_dict
from openhands.events.stream import AsyncEventStreamWrapper
from openhands.server.routes.settings import SettingsStoreImpl
from openhands.server.session.manager import ConversationDoesNotExistError
from openhands.server.shared import config, openhands_config, session_manager, sio
from openhands.server.types import AppMode
@@ -32,10 +33,12 @@ async def connect(connection_id: str, environ, auth):
logger.error('No conversation_id in query params')
raise ConnectionRefusedError('No conversation_id in query params')
github_token = ''
if openhands_config.app_mode != AppMode.OSS:
user_id = ''
if auth and 'github_token' in auth:
with Github(auth['github_token']) as g:
github_token = auth['github_token']
with Github(github_token) as g:
gh_user = await call_sync_from_async(g.get_user)
user_id = gh_user.id
@@ -51,9 +54,15 @@ async def connect(connection_id: str, environ, auth):
f'User {user_id} is not allowed to join conversation {conversation_id}'
)
settings_store = await SettingsStoreImpl.get_instance(config, github_token)
settings = await settings_store.load()
if not settings:
raise ConnectionRefusedError('Settings not found')
try:
event_stream = await session_manager.join_conversation(
conversation_id, connection_id
conversation_id, connection_id, settings
)
except ConversationDoesNotExistError:
logger.error(f'Conversation {conversation_id} does not exist')

View File

@@ -1,6 +1,4 @@
from typing import Annotated
from fastapi import APIRouter, Header, status
from fastapi import APIRouter, Request, status
from fastapi.responses import JSONResponse
from openhands.core.logger import openhands_logger as logger
@@ -16,10 +14,13 @@ SettingsStoreImpl = get_impl(SettingsStore, openhands_config.settings_store_clas
@app.get('/settings')
async def load_settings(
github_auth: Annotated[str | None, Header()] = None,
request: Request,
) -> Settings | None:
github_token = ''
if hasattr(request.state, 'github_token'):
github_token = request.state.github_token
try:
settings_store = await SettingsStoreImpl.get_instance(config, github_auth)
settings_store = await SettingsStoreImpl.get_instance(config, github_token)
settings = await settings_store.load()
if settings:
# For security reasons we don't ever send the api key to the client
@@ -35,18 +36,24 @@ async def load_settings(
@app.post('/settings')
async def store_settings(
request: Request,
settings: Settings,
github_auth: Annotated[str | None, Header()] = None,
) -> bool:
) -> JSONResponse:
github_token = ''
if hasattr(request.state, 'github_token'):
github_token = request.state.github_token
try:
settings_store = await SettingsStoreImpl.get_instance(config, github_auth)
settings_store = await SettingsStoreImpl.get_instance(config, github_token)
existing_settings = await settings_store.load()
if existing_settings:
settings = Settings(**{**existing_settings.__dict__, **settings.__dict__})
if settings.llm_api_key is None:
settings.llm_api_key = existing_settings.llm_api_key
await settings_store.store(settings)
return True
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'message': 'Settings stored'},
)
except Exception as e:
logger.warning(f'Invalid token: {e}')
return JSONResponse(

View File

@@ -2,6 +2,7 @@ import asyncio
import json
import time
from dataclasses import dataclass, field
from uuid import uuid4
import socketio
@@ -10,8 +11,8 @@ from openhands.core.exceptions import AgentRuntimeUnavailableError
from openhands.core.logger import openhands_logger as logger
from openhands.events.stream import EventStream, session_exists
from openhands.server.session.conversation import Conversation
from openhands.server.session.conversation_init_data import ConversationInitData
from openhands.server.session.session import ROOM_KEY, Session
from openhands.server.settings import Settings
from openhands.storage.files import FileStore
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.shutdown_listener import should_continue
@@ -27,6 +28,14 @@ class ConversationDoesNotExistError(Exception):
pass
@dataclass
class _SessionIsRunningCheck:
request_id: str
request_sids: list[str]
running_sids: set[str] = field(default_factory=set)
flag: asyncio.Event = field(default_factory=asyncio.Event)
@dataclass
class SessionManager:
sio: socketio.AsyncServer
@@ -36,7 +45,9 @@ class SessionManager:
local_connection_id_to_session_id: dict[str, str] = field(default_factory=dict)
_last_alive_timestamps: dict[str, float] = field(default_factory=dict)
_redis_listen_task: asyncio.Task | None = None
_session_is_running_flags: dict[str, asyncio.Event] = field(default_factory=dict)
_session_is_running_checks: dict[str, _SessionIsRunningCheck] = field(
default_factory=dict
)
_active_conversations: dict[str, tuple[Conversation, int]] = field(
default_factory=dict
)
@@ -97,27 +108,41 @@ class SessionManager:
async def _process_message(self, message: dict):
data = json.loads(message['data'])
logger.debug(f'got_published_message:{message}')
sid = data['sid']
message_type = data['message_type']
if message_type == 'event':
sid = data['sid']
session = self._local_agent_loops_by_sid.get(sid)
if session:
await session.dispatch(data['data'])
elif message_type == 'is_session_running':
# Another node in the cluster is asking if the current node is running the session given.
session = self._local_agent_loops_by_sid.get(sid)
if session:
request_id = data['request_id']
sids = [
sid for sid in data['sids'] if sid in self._local_agent_loops_by_sid
]
if sids:
await self._get_redis_client().publish(
'oh_event',
json.dumps({'sid': sid, 'message_type': 'session_is_running'}),
json.dumps(
{
'request_id': request_id,
'sids': sids,
'message_type': 'session_is_running',
}
),
)
elif message_type == 'session_is_running':
self._last_alive_timestamps[sid] = time.time()
flag = self._session_is_running_flags.get(sid)
if flag:
flag.set()
request_id = data['request_id']
for sid in data['sids']:
self._last_alive_timestamps[sid] = time.time()
check = self._session_is_running_checks.get(request_id)
if check:
check.running_sids.update(data['sids'])
if len(check.request_sids) == len(check.running_sids):
check.flag.set()
elif message_type == 'has_remote_connections_query':
# Another node in the cluster is asking if the current node is connected to a session
sid = data['sid']
required = sid in self.local_connection_id_to_session_id.values()
if required:
await self._get_redis_client().publish(
@@ -127,12 +152,14 @@ class SessionManager:
),
)
elif message_type == 'has_remote_connections_response':
sid = data['sid']
flag = self._has_remote_connections_flags.get(sid)
if flag:
flag.set()
elif message_type == 'session_closing':
# Session closing event - We only get this in the event of graceful shutdown,
# which can't be guaranteed - nodes can simply vanish unexpectedly!
sid = data['sid']
logger.debug(f'session_closing:{sid}')
for (
connection_id,
@@ -178,13 +205,13 @@ class SessionManager:
self._active_conversations[sid] = (c, 1)
return c
async def join_conversation(self, sid: str, connection_id: str) -> EventStream:
async def join_conversation(self, sid: str, connection_id: str, settings: Settings):
logger.info(f'join_conversation:{sid}:{connection_id}')
await self.sio.enter_room(connection_id, ROOM_KEY.format(sid=sid))
self.local_connection_id_to_session_id[connection_id] = sid
event_stream = await self._get_event_stream(sid)
if not event_stream:
return await self.maybe_start_agent_loop(sid)
return await self.maybe_start_agent_loop(sid, settings)
return event_stream
async def detach_from_conversation(self, conversation: Conversation):
@@ -234,33 +261,47 @@ class SessionManager:
logger.warning('error_cleaning_detached_conversations', exc_info=True)
await asyncio.sleep(_CLEANUP_EXCEPTION_WAIT_TIME)
async def _is_agent_loop_running(self, sid: str) -> bool:
if await self._is_agent_loop_running_locally(sid):
async def get_agent_loop_running(self, sids: set[str]) -> set[str]:
running_sids = set(sid for sid in sids if sid in self._local_agent_loops_by_sid)
check_cluster_sids = [sid for sid in sids if sid not in running_sids]
running_cluster_sids = await self.get_agent_loop_running_in_cluster(
check_cluster_sids
)
running_sids.union(running_cluster_sids)
return running_sids
async def is_agent_loop_running(self, sid: str) -> bool:
if await self.is_agent_loop_running_locally(sid):
return True
if await self._is_agent_loop_running_in_cluster(sid):
if await self.is_agent_loop_running_in_cluster(sid):
return True
return False
async def _is_agent_loop_running_locally(self, sid: str) -> bool:
if self._local_agent_loops_by_sid.get(sid, None):
return True
return False
async def is_agent_loop_running_locally(self, sid: str) -> bool:
return sid in self._local_agent_loops_by_sid
async def _is_agent_loop_running_in_cluster(self, sid: str) -> bool:
async def is_agent_loop_running_in_cluster(self, sid: str) -> bool:
running_sids = await self.get_agent_loop_running_in_cluster([sid])
return bool(running_sids)
async def get_agent_loop_running_in_cluster(self, sids: list[str]) -> set[str]:
"""As the rest of the cluster if a session is running. Wait a for a short timeout for a reply"""
redis_client = self._get_redis_client()
if not redis_client:
return False
return set()
flag = asyncio.Event()
self._session_is_running_flags[sid] = flag
request_id = str(uuid4())
check = _SessionIsRunningCheck(request_id=request_id, request_sids=sids)
self._session_is_running_checks[request_id] = check
try:
logger.debug(f'publish:is_session_running:{sid}')
logger.debug(f'publish:is_session_running:{sids}')
await redis_client.publish(
'oh_event',
json.dumps(
{
'sid': sid,
'request_id': request_id,
'sids': sids,
'message_type': 'is_session_running',
}
),
@@ -268,13 +309,12 @@ class SessionManager:
async with asyncio.timeout(_REDIS_POLL_TIMEOUT):
await flag.wait()
result = flag.is_set()
return result
return check.running_sids
except TimeoutError:
# Nobody replied in time
return False
return check.running_sids
finally:
self._session_is_running_flags.pop(sid, None)
self._session_is_running_checks.pop(request_id, None)
async def _has_remote_connections(self, sid: str) -> bool:
"""As the rest of the cluster if they still want this session running. Wait a for a short timeout for a reply"""
@@ -302,18 +342,16 @@ class SessionManager:
finally:
self._has_remote_connections_flags.pop(sid, None)
async def maybe_start_agent_loop(
self, sid: str, conversation_init_data: ConversationInitData | None = None
) -> EventStream:
async def maybe_start_agent_loop(self, sid: str, settings: Settings) -> EventStream:
logger.info(f'maybe_start_agent_loop:{sid}')
session: Session | None = None
if not await self._is_agent_loop_running(sid):
if not await self.is_agent_loop_running(sid):
logger.info(f'start_agent_loop:{sid}')
session = Session(
sid=sid, file_store=self.file_store, config=self.config, sio=self.sio
)
self._local_agent_loops_by_sid[sid] = session
await session.initialize_agent(conversation_init_data)
await session.initialize_agent(settings)
event_stream = await self._get_event_stream(sid)
if not event_stream:
@@ -328,7 +366,7 @@ class SessionManager:
logger.info(f'found_local_agent_loop:{sid}')
return session.agent_session.event_stream
if await self._is_agent_loop_running_in_cluster(sid):
if await self.is_agent_loop_running_in_cluster(sid):
logger.info(f'found_remote_agent_loop:{sid}')
return EventStream(sid, self.file_store)
@@ -352,7 +390,7 @@ class SessionManager:
next_alive_check = last_alive_at + _CHECK_ALIVE_INTERVAL
if (
next_alive_check > time.time()
or await self._is_agent_loop_running_in_cluster(sid)
or await self.is_agent_loop_running_in_cluster(sid)
):
# Send the event to the other pod
await redis_client.publish(

View File

@@ -1,5 +1,4 @@
import asyncio
import json
import time
from copy import deepcopy
@@ -23,9 +22,8 @@ from openhands.events.stream import EventStreamSubscriber
from openhands.llm.llm import LLM
from openhands.server.session.agent_session import AgentSession
from openhands.server.session.conversation_init_data import ConversationInitData
from openhands.server.settings import Settings
from openhands.storage.files import FileStore
from openhands.storage.locations import get_conversation_init_data_filename
from openhands.utils.async_utils import call_sync_from_async
ROOM_KEY = 'room:{sid}'
@@ -65,63 +63,33 @@ class Session:
self.is_alive = False
self.agent_session.close()
async def _restore_init_data(self, sid: str) -> ConversationInitData:
# FIXME: we should not store/restore this data once we have server-side
# LLM configs. Should be done by 1/1/2025
json_str = await call_sync_from_async(
self.file_store.read, get_conversation_init_data_filename(sid)
)
data = json.loads(json_str)
return ConversationInitData(**data)
async def _save_init_data(self, sid: str, init_data: ConversationInitData):
# FIXME: we should not store/restore this data once we have server-side
# LLM configs. Should be done by 1/1/2025
json_str = json.dumps(init_data.__dict__)
await call_sync_from_async(
self.file_store.write, get_conversation_init_data_filename(sid), json_str
)
async def initialize_agent(
self, conversation_init_data: ConversationInitData | None = None
self,
settings: Settings,
):
self.agent_session.event_stream.add_event(
AgentStateChangedObservation('', AgentState.LOADING),
EventSource.ENVIRONMENT,
)
if conversation_init_data is None:
try:
conversation_init_data = await self._restore_init_data(self.sid)
except FileNotFoundError:
logger.error(f'User settings not found for session {self.sid}')
raise RuntimeError('User settings not found')
agent_cls = conversation_init_data.agent or self.config.default_agent
agent_cls = settings.agent or self.config.default_agent
self.config.security.confirmation_mode = (
self.config.security.confirmation_mode
if conversation_init_data.confirmation_mode is None
else conversation_init_data.confirmation_mode
if settings.confirmation_mode is None
else settings.confirmation_mode
)
self.config.security.security_analyzer = (
conversation_init_data.security_analyzer
or self.config.security.security_analyzer
)
max_iterations = (
conversation_init_data.max_iterations or self.config.max_iterations
settings.security_analyzer or self.config.security.security_analyzer
)
max_iterations = settings.max_iterations or self.config.max_iterations
# override default LLM config
default_llm_config = self.config.get_llm_config()
default_llm_config.model = (
conversation_init_data.llm_model or default_llm_config.model
)
default_llm_config.api_key = (
conversation_init_data.llm_api_key or default_llm_config.api_key
)
default_llm_config.model = settings.llm_model or default_llm_config.model
default_llm_config.api_key = settings.llm_api_key or default_llm_config.api_key
default_llm_config.base_url = (
conversation_init_data.llm_base_url or default_llm_config.base_url
settings.llm_base_url or default_llm_config.base_url
)
await self._save_init_data(self.sid, conversation_init_data)
# TODO: override other LLM config & agent config groups (#2075)
@@ -129,6 +97,12 @@ class Session:
agent_config = self.config.get_agent_config(agent_cls)
agent = Agent.get_cls(agent_cls)(llm, agent_config)
github_token = None
selected_repository = None
if isinstance(settings, ConversationInitData):
github_token = settings.github_token
selected_repository = settings.selected_repository
try:
await self.agent_session.start(
runtime_name=self.config.runtime,
@@ -138,8 +112,8 @@ class Session:
max_budget_per_task=self.config.max_budget_per_task,
agent_to_llm_config=self.config.get_agent_to_llm_config_map(),
agent_configs=self.config.get_agent_configs(),
github_token=conversation_init_data.github_token,
selected_repository=conversation_init_data.selected_repository,
github_token=github_token,
selected_repository=selected_repository,
)
except Exception as e:
logger.exception(f'Error creating controller: {e}')

View File

@@ -9,8 +9,8 @@ IN_MEMORY_FILES: dict = {}
class InMemoryFileStore(FileStore):
files: dict[str, str]
def __init__(self):
self.files = IN_MEMORY_FILES
def __init__(self, files: dict[str, str] = IN_MEMORY_FILES):
self.files = files
def write(self, path: str, contents: str) -> None:
self.files[path] = contents

14
poetry.lock generated
View File

@@ -553,17 +553,17 @@ files = [
[[package]]
name = "boto3"
version = "1.35.87"
version = "1.35.88"
description = "The AWS SDK for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "boto3-1.35.87-py3-none-any.whl", hash = "sha256:588ab05e2771c50fca5c242be14e7a25200ffd3dd95c45950ce40993473864c7"},
{file = "boto3-1.35.87.tar.gz", hash = "sha256:341c58602889078a4a25dc4331b832b5b600a33acd73471d2532c6f01b16fbb4"},
{file = "boto3-1.35.88-py3-none-any.whl", hash = "sha256:7bc9b27ad87607256470c70a86c8b8c319ddd6ecae89cc191687cbf8ccb7b6a6"},
{file = "boto3-1.35.88.tar.gz", hash = "sha256:43c6a7a70bb226770a82a601870136e3bb3bf2808f4576ab5b9d7d140dbf1323"},
]
[package.dependencies]
botocore = ">=1.35.87,<1.36.0"
botocore = ">=1.35.88,<1.36.0"
jmespath = ">=0.7.1,<2.0.0"
s3transfer = ">=0.10.0,<0.11.0"
@@ -572,13 +572,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
[[package]]
name = "botocore"
version = "1.35.87"
version = "1.35.88"
description = "Low-level, data-driven core of boto 3."
optional = false
python-versions = ">=3.8"
files = [
{file = "botocore-1.35.87-py3-none-any.whl", hash = "sha256:81cf84f12030d9ab3829484b04765d5641697ec53c2ac2b3987a99eefe501692"},
{file = "botocore-1.35.87.tar.gz", hash = "sha256:3062d073ce4170a994099270f469864169dc1a1b8b3d4a21c14ce0ae995e0f89"},
{file = "botocore-1.35.88-py3-none-any.whl", hash = "sha256:e60cc3fbe8d7a10f70e7e852d76be2b29f23ead418a5899d366ea32b1eacb5a5"},
{file = "botocore-1.35.88.tar.gz", hash = "sha256:58dcd9a464c354b8c6c25261d8de830d175d9739eae568bf0c52e57116fb03c6"},
]
[package.dependencies]

View File

@@ -0,0 +1,87 @@
import os
import pytest
from openhands.resolver.issue_definitions import IssueHandler
from openhands.resolver.github_issue import GithubIssue
from openhands.events.event import Event
from openhands.core.config import LLMConfig
@pytest.fixture
def issue_handler():
return IssueHandler(
owner="test-owner",
repo="test-repo",
token="test-token",
llm_config=LLMConfig(model="gpt-4", api_key="test-key")
)
def test_guess_success_with_patch_content(issue_handler, mocker):
# Mock the issue
issue = GithubIssue(
owner="test-owner",
repo="test-repo",
number=1,
title="Test Issue",
body="Fix the bug in the code",
thread_comments=None,
review_comments=None
)
# Mock the history with patch content
event = Event()
event._message = "All done! I've fixed the issue by making the following changes:\n\nPatch content:\n```diff\n--- a/src/file.py\n+++ b/src/file.py\n@@ -10,7 +10,7 @@\n- buggy_code()\n+ fixed_code()\n```"
history = [event]
# Mock LLM response
mock_response = mocker.MagicMock()
mock_response.choices = [
mocker.MagicMock(
message=mocker.MagicMock(
content="""--- success
true
--- explanation
The issue has been resolved. The patch shows that the buggy code was replaced with fixed code."""
)
)
]
mocker.patch.object(issue_handler.llm, '_completion', return_value=mock_response)
# Test the function
success, _, explanation = issue_handler.guess_success(issue, history)
assert success is True
assert "patch shows" in explanation.lower()
def test_guess_success_without_patch_content(issue_handler, mocker):
# Mock the issue
issue = GithubIssue(
owner="test-owner",
repo="test-repo",
number=1,
title="Test Issue",
body="Fix the bug in the code",
thread_comments=None,
review_comments=None
)
# Mock the history without patch content
event = Event()
event._message = "All done!"
history = [event]
# Mock LLM response
mock_response = mocker.MagicMock()
mock_response.choices = [
mocker.MagicMock(
message=mocker.MagicMock(
content="""--- success
false
--- explanation
Cannot verify the resolution as no patch content is provided."""
)
)
]
mocker.patch.object(issue_handler.llm, '_completion', return_value=mock_response)
# Test the function
success, _, explanation = issue_handler.guess_success(issue, history)
assert success is False
assert "no patch content" in explanation.lower()

View File

@@ -20,6 +20,7 @@ from openhands.llm import LLM
from openhands.llm.metrics import Metrics
from openhands.runtime.base import Runtime
from openhands.storage import get_file_store
from openhands.storage.memory import InMemoryFileStore
@pytest.fixture
@@ -168,7 +169,7 @@ async def test_run_controller_with_fatal_error(mock_agent, mock_event_stream):
@pytest.mark.asyncio
async def test_run_controller_stop_with_stuck():
config = AppConfig()
file_store = get_file_store(config.file_store, config.file_store_path)
file_store = InMemoryFileStore({})
event_stream = EventStream(sid='test', file_store=file_store)
agent = MagicMock(spec=Agent)

View File

@@ -2,6 +2,7 @@ import asyncio
import json
from dataclasses import dataclass
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
@@ -35,44 +36,56 @@ def get_mock_sio(get_message: GetMessageMock | None = None):
@pytest.mark.asyncio
async def test_session_not_running_in_cluster():
sio = get_mock_sio()
id = uuid4()
with (
patch('openhands.server.session.manager._REDIS_POLL_TIMEOUT', 0.01),
patch('openhands.server.session.manager.uuid4', MagicMock(return_value=id)),
):
async with SessionManager(
sio, AppConfig(), InMemoryFileStore()
) as session_manager:
result = await session_manager._is_agent_loop_running_in_cluster(
result = await session_manager.is_agent_loop_running_in_cluster(
'non-existant-session'
)
assert result is False
assert sio.manager.redis.publish.await_count == 1
sio.manager.redis.publish.assert_called_once_with(
'oh_event',
'{"sid": "non-existant-session", "message_type": "is_session_running"}',
'{"request_id": "'
+ str(id)
+ '", "sids": ["non-existant-session"], "message_type": "is_session_running"}',
)
@pytest.mark.asyncio
async def test_session_is_running_in_cluster():
id = uuid4()
sio = get_mock_sio(
GetMessageMock(
{'sid': 'existing-session', 'message_type': 'session_is_running'}
{
'request_id': str(id),
'sids': ['existing-session'],
'message_type': 'session_is_running',
}
)
)
with (
patch('openhands.server.session.manager._REDIS_POLL_TIMEOUT', 0.1),
patch('openhands.server.session.manager.uuid4', MagicMock(return_value=id)),
):
async with SessionManager(
sio, AppConfig(), InMemoryFileStore()
) as session_manager:
result = await session_manager._is_agent_loop_running_in_cluster(
result = await session_manager.is_agent_loop_running_in_cluster(
'existing-session'
)
assert result is True
assert sio.manager.redis.publish.await_count == 1
sio.manager.redis.publish.assert_called_once_with(
'oh_event',
'{"sid": "existing-session", "message_type": "is_session_running"}',
'{"request_id": "'
+ str(id)
+ '", "sids": ["existing-session"], "message_type": "is_session_running"}',
)
@@ -93,7 +106,7 @@ async def test_init_new_local_session():
AsyncMock(),
),
patch(
'openhands.server.session.manager.SessionManager._is_agent_loop_running_in_cluster',
'openhands.server.session.manager.SessionManager.is_agent_loop_running_in_cluster',
is_agent_loop_running_in_cluster_mock,
),
):
@@ -103,7 +116,9 @@ async def test_init_new_local_session():
await session_manager.maybe_start_agent_loop(
'new-session-id', ConversationInitData()
)
await session_manager.join_conversation('new-session-id', 'new-session-id')
await session_manager.join_conversation(
'new-session-id', 'new-session-id', ConversationInitData()
)
assert session_instance.initialize_agent.call_count == 1
assert sio.enter_room.await_count == 1
@@ -125,7 +140,7 @@ async def test_join_local_session():
AsyncMock(),
),
patch(
'openhands.server.session.manager.SessionManager._is_agent_loop_running_in_cluster',
'openhands.server.session.manager.SessionManager.is_agent_loop_running_in_cluster',
is_agent_loop_running_in_cluster_mock,
),
):
@@ -135,8 +150,12 @@ async def test_join_local_session():
await session_manager.maybe_start_agent_loop(
'new-session-id', ConversationInitData()
)
await session_manager.join_conversation('new-session-id', 'new-session-id')
await session_manager.join_conversation('new-session-id', 'new-session-id')
await session_manager.join_conversation(
'new-session-id', 'new-session-id', ConversationInitData()
)
await session_manager.join_conversation(
'new-session-id', 'new-session-id', ConversationInitData()
)
assert session_instance.initialize_agent.call_count == 1
assert sio.enter_room.await_count == 2
@@ -158,14 +177,16 @@ async def test_join_cluster_session():
AsyncMock(),
),
patch(
'openhands.server.session.manager.SessionManager._is_agent_loop_running_in_cluster',
'openhands.server.session.manager.SessionManager.is_agent_loop_running_in_cluster',
is_agent_loop_running_in_cluster_mock,
),
):
async with SessionManager(
sio, AppConfig(), InMemoryFileStore()
) as session_manager:
await session_manager.join_conversation('new-session-id', 'new-session-id')
await session_manager.join_conversation(
'new-session-id', 'new-session-id', ConversationInitData()
)
assert session_instance.initialize_agent.call_count == 0
assert sio.enter_room.await_count == 1
@@ -187,7 +208,7 @@ async def test_add_to_local_event_stream():
AsyncMock(),
),
patch(
'openhands.server.session.manager.SessionManager._is_agent_loop_running_in_cluster',
'openhands.server.session.manager.SessionManager.is_agent_loop_running_in_cluster',
is_agent_loop_running_in_cluster_mock,
),
):
@@ -197,7 +218,9 @@ async def test_add_to_local_event_stream():
await session_manager.maybe_start_agent_loop(
'new-session-id', ConversationInitData()
)
await session_manager.join_conversation('new-session-id', 'connection-id')
await session_manager.join_conversation(
'new-session-id', 'connection-id', ConversationInitData()
)
await session_manager.send_to_event_stream(
'connection-id', {'event_type': 'some_event'}
)
@@ -221,14 +244,16 @@ async def test_add_to_cluster_event_stream():
AsyncMock(),
),
patch(
'openhands.server.session.manager.SessionManager._is_agent_loop_running_in_cluster',
'openhands.server.session.manager.SessionManager.is_agent_loop_running_in_cluster',
is_agent_loop_running_in_cluster_mock,
),
):
async with SessionManager(
sio, AppConfig(), InMemoryFileStore()
) as session_manager:
await session_manager.join_conversation('new-session-id', 'connection-id')
await session_manager.join_conversation(
'new-session-id', 'connection-id', ConversationInitData()
)
await session_manager.send_to_event_stream(
'connection-id', {'event_type': 'some_event'}
)