mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-09 14:57:59 -05:00
Merge branch 'main' into fix-openhands-name
This commit is contained in:
156
.github/workflows/vscode-extension-build.yml
vendored
156
.github/workflows/vscode-extension-build.yml
vendored
@@ -1,156 +0,0 @@
|
|||||||
# Workflow that validates the VSCode extension builds correctly
|
|
||||||
name: VSCode Extension CI
|
|
||||||
|
|
||||||
# * Always run on "main"
|
|
||||||
# * Run on PRs that have changes in the VSCode extension folder or this workflow
|
|
||||||
# * Run on tags that start with "ext-v"
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
tags:
|
|
||||||
- 'ext-v*'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- 'openhands/integrations/vscode/**'
|
|
||||||
- 'build_vscode.py'
|
|
||||||
- '.github/workflows/vscode-extension-build.yml'
|
|
||||||
|
|
||||||
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# Validate VSCode extension builds correctly
|
|
||||||
validate-vscode-extension:
|
|
||||||
name: Validate VSCode Extension Build
|
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Node.js
|
|
||||||
uses: useblacksmith/setup-node@v5
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v6
|
|
||||||
with:
|
|
||||||
python-version: '3.12'
|
|
||||||
|
|
||||||
- name: Install VSCode extension dependencies
|
|
||||||
working-directory: ./openhands/integrations/vscode
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Build VSCode extension via build_vscode.py
|
|
||||||
run: python build_vscode.py
|
|
||||||
env:
|
|
||||||
# Ensure we don't skip the build
|
|
||||||
SKIP_VSCODE_BUILD: ""
|
|
||||||
|
|
||||||
- name: Validate .vsix file
|
|
||||||
run: |
|
|
||||||
# Verify the .vsix was created and is valid
|
|
||||||
if [ -f "openhands/integrations/vscode/openhands-vscode-0.0.1.vsix" ]; then
|
|
||||||
echo "✅ VSCode extension built successfully"
|
|
||||||
ls -la openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
|
|
||||||
|
|
||||||
# Basic validation that the .vsix is a valid zip file
|
|
||||||
echo "🔍 Validating .vsix structure..."
|
|
||||||
file openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
|
|
||||||
unzip -t openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
|
|
||||||
|
|
||||||
echo "✅ VSCode extension validation passed"
|
|
||||||
else
|
|
||||||
echo "❌ VSCode extension build failed - .vsix not found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Upload VSCode extension artifact
|
|
||||||
uses: actions/upload-artifact@v6
|
|
||||||
with:
|
|
||||||
name: vscode-extension
|
|
||||||
path: openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
- name: Comment on PR with artifact link
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
uses: actions/github-script@v7
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
// Get file size for display
|
|
||||||
const vsixPath = 'openhands/integrations/vscode/openhands-vscode-0.0.1.vsix';
|
|
||||||
const stats = fs.statSync(vsixPath);
|
|
||||||
const fileSizeKB = Math.round(stats.size / 1024);
|
|
||||||
|
|
||||||
const comment = `## 🔧 VSCode Extension Built Successfully!
|
|
||||||
|
|
||||||
The VSCode extension has been built and is ready for testing.
|
|
||||||
|
|
||||||
**📦 Download**: [openhands-vscode-0.0.1.vsix](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) (${fileSizeKB} KB)
|
|
||||||
|
|
||||||
**🚀 To install**:
|
|
||||||
1. Download the artifact from the workflow run above
|
|
||||||
2. In VSCode: \`Ctrl+Shift+P\` → "Extensions: Install from VSIX..."
|
|
||||||
3. Select the downloaded \`.vsix\` file
|
|
||||||
|
|
||||||
**✅ Tested with**: Node.js 22
|
|
||||||
**🔍 Validation**: File structure and integrity verified
|
|
||||||
|
|
||||||
---
|
|
||||||
*Built from commit ${{ github.sha }}*`;
|
|
||||||
|
|
||||||
// Check if we already commented on this PR and delete it
|
|
||||||
const { data: comments } = await github.rest.issues.listComments({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: context.issue.number,
|
|
||||||
});
|
|
||||||
|
|
||||||
const botComment = comments.find(comment =>
|
|
||||||
comment.user.login === 'github-actions[bot]' &&
|
|
||||||
comment.body.includes('VSCode Extension Built Successfully')
|
|
||||||
);
|
|
||||||
|
|
||||||
if (botComment) {
|
|
||||||
await github.rest.issues.deleteComment({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
comment_id: botComment.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a new comment
|
|
||||||
await github.rest.issues.createComment({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: context.issue.number,
|
|
||||||
body: comment
|
|
||||||
});
|
|
||||||
|
|
||||||
release:
|
|
||||||
name: Create GitHub Release
|
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
|
||||||
needs: validate-vscode-extension
|
|
||||||
if: startsWith(github.ref, 'refs/tags/ext-v')
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Download .vsix artifact
|
|
||||||
uses: actions/download-artifact@v6
|
|
||||||
with:
|
|
||||||
name: vscode-extension
|
|
||||||
path: ./
|
|
||||||
|
|
||||||
- name: Create Release
|
|
||||||
uses: ncipollo/release-action@v1.20.0
|
|
||||||
with:
|
|
||||||
artifacts: "*.vsix"
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
draft: true
|
|
||||||
allowUpdates: true
|
|
||||||
@@ -13,7 +13,6 @@ STAGED_FILES=$(git diff --cached --name-only)
|
|||||||
# Check if any files match specific patterns
|
# Check if any files match specific patterns
|
||||||
has_frontend_changes=false
|
has_frontend_changes=false
|
||||||
has_backend_changes=false
|
has_backend_changes=false
|
||||||
has_vscode_changes=false
|
|
||||||
|
|
||||||
# Check each file individually to avoid issues with grep
|
# Check each file individually to avoid issues with grep
|
||||||
for file in $STAGED_FILES; do
|
for file in $STAGED_FILES; do
|
||||||
@@ -21,17 +20,12 @@ for file in $STAGED_FILES; do
|
|||||||
has_frontend_changes=true
|
has_frontend_changes=true
|
||||||
elif [[ $file == openhands/* || $file == evaluation/* || $file == tests/* ]]; then
|
elif [[ $file == openhands/* || $file == evaluation/* || $file == tests/* ]]; then
|
||||||
has_backend_changes=true
|
has_backend_changes=true
|
||||||
# Check for VSCode extension changes (subset of backend changes)
|
|
||||||
if [[ $file == openhands/integrations/vscode/* ]]; then
|
|
||||||
has_vscode_changes=true
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "Analyzing changes..."
|
echo "Analyzing changes..."
|
||||||
echo "- Frontend changes: $has_frontend_changes"
|
echo "- Frontend changes: $has_frontend_changes"
|
||||||
echo "- Backend changes: $has_backend_changes"
|
echo "- Backend changes: $has_backend_changes"
|
||||||
echo "- VSCode extension changes: $has_vscode_changes"
|
|
||||||
|
|
||||||
# Run frontend linting if needed
|
# Run frontend linting if needed
|
||||||
if [ "$has_frontend_changes" = true ]; then
|
if [ "$has_frontend_changes" = true ]; then
|
||||||
@@ -92,51 +86,6 @@ else
|
|||||||
echo "Skipping backend checks (no backend changes detected)."
|
echo "Skipping backend checks (no backend changes detected)."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Run VSCode extension checks if needed
|
|
||||||
if [ "$has_vscode_changes" = true ]; then
|
|
||||||
# Check if we're in a CI environment
|
|
||||||
if [ -n "$CI" ]; then
|
|
||||||
echo "Skipping VSCode extension checks (CI environment detected)."
|
|
||||||
echo "WARNING: VSCode extension files have changed but checks are being skipped."
|
|
||||||
echo "Please run VSCode extension checks manually before submitting your PR."
|
|
||||||
else
|
|
||||||
echo "Running VSCode extension checks..."
|
|
||||||
if [ -d "openhands/integrations/vscode" ]; then
|
|
||||||
cd openhands/integrations/vscode || exit 1
|
|
||||||
|
|
||||||
echo "Running npm lint:fix..."
|
|
||||||
npm run lint:fix
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "VSCode extension linting failed. Please fix the issues before committing."
|
|
||||||
EXIT_CODE=1
|
|
||||||
else
|
|
||||||
echo "VSCode extension linting passed!"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Running npm typecheck..."
|
|
||||||
npm run typecheck
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "VSCode extension type checking failed. Please fix the issues before committing."
|
|
||||||
EXIT_CODE=1
|
|
||||||
else
|
|
||||||
echo "VSCode extension type checking passed!"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Running npm compile..."
|
|
||||||
npm run compile
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "VSCode extension compilation failed. Please fix the issues before committing."
|
|
||||||
EXIT_CODE=1
|
|
||||||
else
|
|
||||||
echo "VSCode extension compilation passed!"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd ../../..
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "Skipping VSCode extension checks (no VSCode extension changes detected)."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If no specific code changes detected, run basic checks
|
# If no specific code changes detected, run basic checks
|
||||||
if [ "$has_frontend_changes" = false ] && [ "$has_backend_changes" = false ]; then
|
if [ "$has_frontend_changes" = false ] && [ "$has_backend_changes" = false ]; then
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ We're always looking to improve the look and feel of the application. If you've
|
|||||||
for something that's bugging you, feel free to open up a PR that changes the [`./frontend`](./frontend) directory.
|
for something that's bugging you, feel free to open up a PR that changes the [`./frontend`](./frontend) directory.
|
||||||
|
|
||||||
If you're looking to make a bigger change, add a new UI element, or significantly alter the style
|
If you're looking to make a bigger change, add a new UI element, or significantly alter the style
|
||||||
of the application, please open an issue first, or better, join the #eng-ui-ux channel in our Slack
|
of the application, please open an issue first, or better, join the #dev-ui-ux channel in our Slack
|
||||||
to gather consensus from our design team first.
|
to gather consensus from our design team first.
|
||||||
|
|
||||||
#### Improving the agent
|
#### Improving the agent
|
||||||
|
|||||||
113
build_vscode.py
113
build_vscode.py
@@ -1,113 +0,0 @@
|
|||||||
import os
|
|
||||||
import pathlib
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
# This script is intended to be run by Poetry during the build process.
|
|
||||||
|
|
||||||
# Define the expected name of the .vsix file based on the extension's package.json
|
|
||||||
# This should match the name and version in openhands-vscode/package.json
|
|
||||||
EXTENSION_NAME = 'openhands-vscode'
|
|
||||||
EXTENSION_VERSION = '0.0.1'
|
|
||||||
VSIX_FILENAME = f'{EXTENSION_NAME}-{EXTENSION_VERSION}.vsix'
|
|
||||||
|
|
||||||
# Paths
|
|
||||||
ROOT_DIR = pathlib.Path(__file__).parent.resolve()
|
|
||||||
VSCODE_EXTENSION_DIR = ROOT_DIR / 'openhands' / 'integrations' / 'vscode'
|
|
||||||
|
|
||||||
|
|
||||||
def check_node_version():
|
|
||||||
"""Check if Node.js version is sufficient for building the extension."""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
['node', '--version'], capture_output=True, text=True, check=True
|
|
||||||
)
|
|
||||||
version_str = result.stdout.strip()
|
|
||||||
# Extract major version number (e.g., "v12.22.9" -> 12)
|
|
||||||
major_version = int(version_str.lstrip('v').split('.')[0])
|
|
||||||
return major_version >= 18 # Align with frontend actual usage (18.20.1)
|
|
||||||
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def build_vscode_extension():
|
|
||||||
"""Builds the VS Code extension."""
|
|
||||||
vsix_path = VSCODE_EXTENSION_DIR / VSIX_FILENAME
|
|
||||||
|
|
||||||
# Check if VSCode extension build is disabled via environment variable
|
|
||||||
if os.environ.get('SKIP_VSCODE_BUILD', '').lower() in ('1', 'true', 'yes'):
|
|
||||||
print('--- Skipping VS Code extension build (SKIP_VSCODE_BUILD is set) ---')
|
|
||||||
if vsix_path.exists():
|
|
||||||
print(f'--- Using existing VS Code extension: {vsix_path} ---')
|
|
||||||
else:
|
|
||||||
print('--- No pre-built VS Code extension found ---')
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check Node.js version - if insufficient, use pre-built extension as fallback
|
|
||||||
if not check_node_version():
|
|
||||||
print('--- Warning: Node.js version < 18 detected or Node.js not found ---')
|
|
||||||
print('--- Skipping VS Code extension build (requires Node.js >= 18) ---')
|
|
||||||
print('--- Using pre-built extension if available ---')
|
|
||||||
|
|
||||||
if not vsix_path.exists():
|
|
||||||
print('--- Warning: No pre-built VS Code extension found ---')
|
|
||||||
print('--- VS Code extension will not be available ---')
|
|
||||||
else:
|
|
||||||
print(f'--- Using pre-built VS Code extension: {vsix_path} ---')
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f'--- Building VS Code extension in {VSCODE_EXTENSION_DIR} ---')
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Ensure npm dependencies are installed
|
|
||||||
print('--- Running npm install for VS Code extension ---')
|
|
||||||
subprocess.run(
|
|
||||||
['npm', 'install'],
|
|
||||||
cwd=VSCODE_EXTENSION_DIR,
|
|
||||||
check=True,
|
|
||||||
shell=os.name == 'nt',
|
|
||||||
)
|
|
||||||
|
|
||||||
# Package the extension
|
|
||||||
print(f'--- Packaging VS Code extension ({VSIX_FILENAME}) ---')
|
|
||||||
subprocess.run(
|
|
||||||
['npm', 'run', 'package-vsix'],
|
|
||||||
cwd=VSCODE_EXTENSION_DIR,
|
|
||||||
check=True,
|
|
||||||
shell=os.name == 'nt',
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify the generated .vsix file exists
|
|
||||||
if not vsix_path.exists():
|
|
||||||
raise FileNotFoundError(
|
|
||||||
f'VS Code extension package not found after build: {vsix_path}'
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f'--- VS Code extension built successfully: {vsix_path} ---')
|
|
||||||
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
print(f'--- Warning: Failed to build VS Code extension: {e} ---')
|
|
||||||
print('--- Continuing without building extension ---')
|
|
||||||
if not vsix_path.exists():
|
|
||||||
print('--- Warning: No pre-built VS Code extension found ---')
|
|
||||||
print('--- VS Code extension will not be available ---')
|
|
||||||
|
|
||||||
|
|
||||||
def build(setup_kwargs):
|
|
||||||
"""This function is called by Poetry during the build process.
|
|
||||||
`setup_kwargs` is a dictionary that will be passed to `setuptools.setup()`.
|
|
||||||
"""
|
|
||||||
print('--- Running custom Poetry build script (build_vscode.py) ---')
|
|
||||||
|
|
||||||
# Build the VS Code extension and place the .vsix file
|
|
||||||
build_vscode_extension()
|
|
||||||
|
|
||||||
# Poetry will handle including files based on pyproject.toml `include` patterns.
|
|
||||||
# Ensure openhands/integrations/vscode/*.vsix is included there.
|
|
||||||
|
|
||||||
print('--- Custom Poetry build script (build_vscode.py) finished ---')
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
print('Running build_vscode.py directly for testing VS Code extension packaging...')
|
|
||||||
build_vscode_extension()
|
|
||||||
print('Direct execution of build_vscode.py finished.')
|
|
||||||
@@ -143,7 +143,7 @@ class GitHubDataCollector:
|
|||||||
try:
|
try:
|
||||||
installation_token = self._get_installation_access_token(installation_id)
|
installation_token = self._get_installation_access_token(installation_id)
|
||||||
|
|
||||||
with Github(installation_token) as github_client:
|
with Github(auth=Auth.Token(installation_token)) as github_client:
|
||||||
repo = github_client.get_repo(repo_name)
|
repo = github_client.get_repo(repo_name)
|
||||||
issue = repo.get_issue(issue_number)
|
issue = repo.get_issue(issue_number)
|
||||||
comments = []
|
comments = []
|
||||||
@@ -237,7 +237,7 @@ class GitHubDataCollector:
|
|||||||
def _get_pr_commits(self, installation_id: str, repo_name: str, pr_number: int):
|
def _get_pr_commits(self, installation_id: str, repo_name: str, pr_number: int):
|
||||||
commits = []
|
commits = []
|
||||||
installation_token = self._get_installation_access_token(installation_id)
|
installation_token = self._get_installation_access_token(installation_id)
|
||||||
with Github(installation_token) as github_client:
|
with Github(auth=Auth.Token(installation_token)) as github_client:
|
||||||
repo = github_client.get_repo(repo_name)
|
repo = github_client.get_repo(repo_name)
|
||||||
pr = repo.get_pull(pr_number)
|
pr = repo.get_pull(pr_number)
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class GithubManager(Manager):
|
|||||||
reaction: The reaction to add (e.g. "eyes", "+1", "-1", "laugh", "confused", "heart", "hooray", "rocket")
|
reaction: The reaction to add (e.g. "eyes", "+1", "-1", "laugh", "confused", "heart", "hooray", "rocket")
|
||||||
installation_token: GitHub installation access token for API access
|
installation_token: GitHub installation access token for API access
|
||||||
"""
|
"""
|
||||||
with Github(installation_token) as github_client:
|
with Github(auth=Auth.Token(installation_token)) as github_client:
|
||||||
repo = github_client.get_repo(github_view.full_repo_name)
|
repo = github_client.get_repo(github_view.full_repo_name)
|
||||||
# Add reaction based on view type
|
# Add reaction based on view type
|
||||||
if isinstance(github_view, GithubInlinePRComment):
|
if isinstance(github_view, GithubInlinePRComment):
|
||||||
@@ -199,7 +199,7 @@ class GithubManager(Manager):
|
|||||||
outgoing_message = message.message
|
outgoing_message = message.message
|
||||||
|
|
||||||
if isinstance(github_view, GithubInlinePRComment):
|
if isinstance(github_view, GithubInlinePRComment):
|
||||||
with Github(installation_token) as github_client:
|
with Github(auth=Auth.Token(installation_token)) as github_client:
|
||||||
repo = github_client.get_repo(github_view.full_repo_name)
|
repo = github_client.get_repo(github_view.full_repo_name)
|
||||||
pr = repo.get_pull(github_view.issue_number)
|
pr = repo.get_pull(github_view.issue_number)
|
||||||
pr.create_review_comment_reply(
|
pr.create_review_comment_reply(
|
||||||
@@ -211,7 +211,7 @@ class GithubManager(Manager):
|
|||||||
or isinstance(github_view, GithubIssueComment)
|
or isinstance(github_view, GithubIssueComment)
|
||||||
or isinstance(github_view, GithubIssue)
|
or isinstance(github_view, GithubIssue)
|
||||||
):
|
):
|
||||||
with Github(installation_token) as github_client:
|
with Github(auth=Auth.Token(installation_token)) as github_client:
|
||||||
repo = github_client.get_repo(github_view.full_repo_name)
|
repo = github_client.get_repo(github_view.full_repo_name)
|
||||||
issue = repo.get_issue(number=github_view.issue_number)
|
issue = repo.get_issue(number=github_view.issue_number)
|
||||||
issue.create_comment(outgoing_message)
|
issue.create_comment(outgoing_message)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from github import Github
|
from github import Auth, Github
|
||||||
from integrations.github.github_view import (
|
from integrations.github.github_view import (
|
||||||
GithubInlinePRComment,
|
GithubInlinePRComment,
|
||||||
GithubIssueComment,
|
GithubIssueComment,
|
||||||
@@ -47,7 +47,7 @@ def fetch_github_issue_context(
|
|||||||
context_parts.append(f'Title: {github_view.title}')
|
context_parts.append(f'Title: {github_view.title}')
|
||||||
context_parts.append(f'Description:\n{github_view.description}')
|
context_parts.append(f'Description:\n{github_view.description}')
|
||||||
|
|
||||||
with Github(user_token) as github_client:
|
with Github(auth=Auth.Token(user_token)) as github_client:
|
||||||
repo = github_client.get_repo(github_view.full_repo_name)
|
repo = github_client.get_repo(github_view.full_repo_name)
|
||||||
issue = repo.get_issue(github_view.issue_number)
|
issue = repo.get_issue(github_view.issue_number)
|
||||||
if issue.labels:
|
if issue.labels:
|
||||||
|
|||||||
@@ -735,7 +735,7 @@ class GithubFactory:
|
|||||||
payload['installation']['id']
|
payload['installation']['id']
|
||||||
).token
|
).token
|
||||||
|
|
||||||
with Github(access_token) as gh:
|
with Github(auth=Auth.Token(access_token)) as gh:
|
||||||
repo = gh.get_repo(selected_repo)
|
repo = gh.get_repo(selected_repo)
|
||||||
login = (
|
login = (
|
||||||
payload['organization']['login']
|
payload['organization']['login']
|
||||||
@@ -872,7 +872,7 @@ class GithubFactory:
|
|||||||
access_token = integration.get_access_token(installation_id).token
|
access_token = integration.get_access_token(installation_id).token
|
||||||
|
|
||||||
head_ref = None
|
head_ref = None
|
||||||
with Github(access_token) as gh:
|
with Github(auth=Auth.Token(access_token)) as gh:
|
||||||
repo = gh.get_repo(selected_repo)
|
repo = gh.get_repo(selected_repo)
|
||||||
pull_request = repo.get_pull(issue_number)
|
pull_request = repo.get_pull(issue_number)
|
||||||
head_ref = pull_request.head.ref
|
head_ref = pull_request.head.ref
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from openhands.events.action import (
|
|||||||
AgentFinishAction,
|
AgentFinishAction,
|
||||||
MessageAction,
|
MessageAction,
|
||||||
)
|
)
|
||||||
|
from openhands.events.event_filter import EventFilter
|
||||||
from openhands.events.event_store_abc import EventStoreABC
|
from openhands.events.event_store_abc import EventStoreABC
|
||||||
from openhands.events.observation.agent import AgentStateChangedObservation
|
from openhands.events.observation.agent import AgentStateChangedObservation
|
||||||
from openhands.integrations.service_types import Repository
|
from openhands.integrations.service_types import Repository
|
||||||
@@ -203,18 +204,35 @@ def get_summary_for_agent_state(
|
|||||||
def get_final_agent_observation(
|
def get_final_agent_observation(
|
||||||
event_store: EventStoreABC,
|
event_store: EventStoreABC,
|
||||||
) -> list[AgentStateChangedObservation]:
|
) -> list[AgentStateChangedObservation]:
|
||||||
return event_store.get_matching_events(
|
events = list(
|
||||||
|
event_store.search_events(
|
||||||
|
filter=EventFilter(
|
||||||
source=EventSource.ENVIRONMENT,
|
source=EventSource.ENVIRONMENT,
|
||||||
event_types=(AgentStateChangedObservation,),
|
include_types=(AgentStateChangedObservation,),
|
||||||
|
),
|
||||||
limit=1,
|
limit=1,
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
result = [e for e in events if isinstance(e, AgentStateChangedObservation)]
|
||||||
|
assert len(result) == len(events)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def get_last_user_msg(event_store: EventStoreABC) -> list[MessageAction]:
|
def get_last_user_msg(event_store: EventStoreABC) -> list[MessageAction]:
|
||||||
return event_store.get_matching_events(
|
events = list(
|
||||||
source=EventSource.USER, event_types=(MessageAction,), limit=1, reverse='true'
|
event_store.search_events(
|
||||||
|
filter=EventFilter(
|
||||||
|
source=EventSource.USER,
|
||||||
|
include_types=(MessageAction,),
|
||||||
|
),
|
||||||
|
limit=1,
|
||||||
|
reverse=True,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
result = [e for e in events if isinstance(e, MessageAction)]
|
||||||
|
assert len(result) == len(events)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def extract_summary_from_event_store(
|
def extract_summary_from_event_store(
|
||||||
@@ -226,18 +244,22 @@ def extract_summary_from_event_store(
|
|||||||
conversation_link = CONVERSATION_URL.format(conversation_id)
|
conversation_link = CONVERSATION_URL.format(conversation_id)
|
||||||
summary_instruction = get_summary_instruction()
|
summary_instruction = get_summary_instruction()
|
||||||
|
|
||||||
instruction_event: list[MessageAction] = event_store.get_matching_events(
|
instruction_events = list(
|
||||||
|
event_store.search_events(
|
||||||
|
filter=EventFilter(
|
||||||
query=json.dumps(summary_instruction),
|
query=json.dumps(summary_instruction),
|
||||||
source=EventSource.USER,
|
source=EventSource.USER,
|
||||||
event_types=(MessageAction,),
|
include_types=(MessageAction,),
|
||||||
|
),
|
||||||
limit=1,
|
limit=1,
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
final_agent_observation = get_final_agent_observation(event_store)
|
final_agent_observation = get_final_agent_observation(event_store)
|
||||||
|
|
||||||
# Find summary instruction event ID
|
# Find summary instruction event ID
|
||||||
if len(instruction_event) == 0:
|
if not instruction_events:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
'no_instruction_event_found', extra={'conversation_id': conversation_id}
|
'no_instruction_event_found', extra={'conversation_id': conversation_id}
|
||||||
)
|
)
|
||||||
@@ -245,19 +267,19 @@ def extract_summary_from_event_store(
|
|||||||
final_agent_observation, conversation_link
|
final_agent_observation, conversation_link
|
||||||
) # Agent did not receive summary instruction
|
) # Agent did not receive summary instruction
|
||||||
|
|
||||||
event_id: int = instruction_event[0].id
|
summary_events = list(
|
||||||
|
event_store.search_events(
|
||||||
agent_messages: list[MessageAction | AgentFinishAction] = (
|
filter=EventFilter(
|
||||||
event_store.get_matching_events(
|
|
||||||
start_id=event_id,
|
|
||||||
source=EventSource.AGENT,
|
source=EventSource.AGENT,
|
||||||
event_types=(MessageAction, AgentFinishAction),
|
include_types=(MessageAction, AgentFinishAction),
|
||||||
reverse=True,
|
),
|
||||||
limit=1,
|
limit=1,
|
||||||
|
reverse=True,
|
||||||
|
start_id=instruction_events[0].id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(agent_messages) == 0:
|
if not summary_events:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
'no_agent_messages_found', extra={'conversation_id': conversation_id}
|
'no_agent_messages_found', extra={'conversation_id': conversation_id}
|
||||||
)
|
)
|
||||||
@@ -265,10 +287,11 @@ def extract_summary_from_event_store(
|
|||||||
final_agent_observation, conversation_link
|
final_agent_observation, conversation_link
|
||||||
) # Agent failed to generate summary
|
) # Agent failed to generate summary
|
||||||
|
|
||||||
summary_event: MessageAction | AgentFinishAction = agent_messages[0]
|
summary_event = summary_events[0]
|
||||||
if isinstance(summary_event, MessageAction):
|
if isinstance(summary_event, MessageAction):
|
||||||
return summary_event.content
|
return summary_event.content
|
||||||
|
|
||||||
|
assert isinstance(summary_event, AgentFinishAction)
|
||||||
return summary_event.final_thought
|
return summary_event.final_thought
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,13 @@ class DomainBlocker:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def is_domain_blocked(self, email: str) -> bool:
|
def is_domain_blocked(self, email: str) -> bool:
|
||||||
"""Check if email domain is blocked"""
|
"""Check if email domain is blocked
|
||||||
|
|
||||||
|
Supports blocking:
|
||||||
|
- Exact domains: 'example.com' blocks 'user@example.com'
|
||||||
|
- Subdomains: 'example.com' blocks 'user@subdomain.example.com'
|
||||||
|
- TLDs: '.us' blocks 'user@company.us' and 'user@subdomain.company.us'
|
||||||
|
"""
|
||||||
if not self.is_active():
|
if not self.is_active():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -44,13 +50,26 @@ class DomainBlocker:
|
|||||||
logger.debug(f'Could not extract domain from email: {email}')
|
logger.debug(f'Could not extract domain from email: {email}')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
is_blocked = domain in self.blocked_domains
|
# Check if domain matches any blocked pattern
|
||||||
if is_blocked:
|
for blocked_pattern in self.blocked_domains:
|
||||||
logger.warning(f'Email domain {domain} is blocked for email: {email}')
|
if blocked_pattern.startswith('.'):
|
||||||
|
# TLD pattern (e.g., '.us') - check if domain ends with it
|
||||||
|
if domain.endswith(blocked_pattern):
|
||||||
|
logger.warning(
|
||||||
|
f'Email domain {domain} is blocked by TLD pattern {blocked_pattern} for email: {email}'
|
||||||
|
)
|
||||||
|
return True
|
||||||
else:
|
else:
|
||||||
logger.debug(f'Email domain {domain} is not blocked')
|
# Full domain pattern (e.g., 'example.com')
|
||||||
|
# Block exact match or subdomains
|
||||||
|
if domain == blocked_pattern or domain.endswith(f'.{blocked_pattern}'):
|
||||||
|
logger.warning(
|
||||||
|
f'Email domain {domain} is blocked by domain pattern {blocked_pattern} for email: {email}'
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
return is_blocked
|
logger.debug(f'Email domain {domain} is not blocked')
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
domain_blocker = DomainBlocker()
|
domain_blocker = DomainBlocker()
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ from typing import Any, cast
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import socketio
|
import socketio
|
||||||
|
from pydantic import SecretStr
|
||||||
|
from server.auth.token_manager import TokenManager
|
||||||
from server.constants import PERMITTED_CORS_ORIGINS, WEB_HOST
|
from server.constants import PERMITTED_CORS_ORIGINS, WEB_HOST
|
||||||
from server.utils.conversation_callback_utils import (
|
from server.utils.conversation_callback_utils import (
|
||||||
process_event,
|
process_event,
|
||||||
@@ -29,7 +31,11 @@ from openhands.core.logger import openhands_logger as logger
|
|||||||
from openhands.events.action import MessageAction
|
from openhands.events.action import MessageAction
|
||||||
from openhands.events.event_store import EventStore
|
from openhands.events.event_store import EventStore
|
||||||
from openhands.events.serialization.event import event_to_dict
|
from openhands.events.serialization.event import event_to_dict
|
||||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderHandler
|
from openhands.integrations.provider import (
|
||||||
|
PROVIDER_TOKEN_TYPE,
|
||||||
|
ProviderHandler,
|
||||||
|
ProviderToken,
|
||||||
|
)
|
||||||
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
|
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
|
||||||
from openhands.runtime.plugins.vscode import VSCodeRequirement
|
from openhands.runtime.plugins.vscode import VSCodeRequirement
|
||||||
from openhands.runtime.runtime_status import RuntimeStatus
|
from openhands.runtime.runtime_status import RuntimeStatus
|
||||||
@@ -228,6 +234,102 @@ class SaasNestedConversationManager(ConversationManager):
|
|||||||
status=status,
|
status=status,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _refresh_provider_tokens_after_runtime_init(
|
||||||
|
self, settings: Settings, sid: str, user_id: str | None = None
|
||||||
|
) -> Settings:
|
||||||
|
"""Refresh provider tokens after runtime initialization.
|
||||||
|
|
||||||
|
During runtime initialization, tokens may be refreshed by Runtime.__init__().
|
||||||
|
This method retrieves the fresh tokens from the database and creates a new
|
||||||
|
settings object with updated tokens to avoid sending stale tokens to the
|
||||||
|
nested runtime.
|
||||||
|
|
||||||
|
The method handles two scenarios:
|
||||||
|
1. ProviderToken has user_id (IDP user ID, e.g., GitLab user ID)
|
||||||
|
→ Uses get_idp_token_from_idp_user_id()
|
||||||
|
2. ProviderToken has no user_id but Keycloak user_id is available
|
||||||
|
→ Uses load_offline_token() + get_idp_token_from_offline_token()
|
||||||
|
|
||||||
|
Args:
|
||||||
|
settings: The conversation settings that may contain provider tokens
|
||||||
|
sid: The session ID for logging purposes
|
||||||
|
user_id: The Keycloak user ID (optional, used as fallback when
|
||||||
|
ProviderToken.user_id is not available)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated settings with fresh provider tokens, or original settings
|
||||||
|
if no update is needed
|
||||||
|
"""
|
||||||
|
if not isinstance(settings, ConversationInitData):
|
||||||
|
return settings
|
||||||
|
|
||||||
|
if not settings.git_provider_tokens:
|
||||||
|
return settings
|
||||||
|
|
||||||
|
token_manager = TokenManager()
|
||||||
|
updated_tokens = {}
|
||||||
|
tokens_refreshed = 0
|
||||||
|
tokens_failed = 0
|
||||||
|
|
||||||
|
for provider_type, provider_token in settings.git_provider_tokens.items():
|
||||||
|
fresh_token = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if provider_token.user_id:
|
||||||
|
# Case 1: We have IDP user ID (e.g., GitLab user ID '32546706')
|
||||||
|
# Get the token that was just refreshed during runtime initialization
|
||||||
|
fresh_token = await token_manager.get_idp_token_from_idp_user_id(
|
||||||
|
provider_token.user_id, provider_type
|
||||||
|
)
|
||||||
|
elif user_id:
|
||||||
|
# Case 2: We have Keycloak user ID but no IDP user ID
|
||||||
|
# This happens in web UI flow where ProviderToken.user_id is None
|
||||||
|
offline_token = await token_manager.load_offline_token(user_id)
|
||||||
|
if offline_token:
|
||||||
|
fresh_token = (
|
||||||
|
await token_manager.get_idp_token_from_offline_token(
|
||||||
|
offline_token, provider_type
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if fresh_token:
|
||||||
|
updated_tokens[provider_type] = ProviderToken(
|
||||||
|
token=SecretStr(fresh_token),
|
||||||
|
user_id=provider_token.user_id,
|
||||||
|
host=provider_token.host,
|
||||||
|
)
|
||||||
|
tokens_refreshed += 1
|
||||||
|
else:
|
||||||
|
# Keep original token if we couldn't get a fresh one
|
||||||
|
updated_tokens[provider_type] = provider_token
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# If refresh fails, use original token to prevent conversation startup failure
|
||||||
|
logger.warning(
|
||||||
|
f'Failed to refresh {provider_type.value} token: {e}',
|
||||||
|
extra={'session_id': sid, 'provider': provider_type.value},
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
updated_tokens[provider_type] = provider_token
|
||||||
|
tokens_failed += 1
|
||||||
|
|
||||||
|
# Create new ConversationInitData with updated tokens
|
||||||
|
# We cannot modify the frozen field directly, so we create a new object
|
||||||
|
updated_settings = settings.model_copy(
|
||||||
|
update={'git_provider_tokens': MappingProxyType(updated_tokens)}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
'Updated provider tokens after runtime creation',
|
||||||
|
extra={
|
||||||
|
'session_id': sid,
|
||||||
|
'providers': [p.value for p in updated_tokens.keys()],
|
||||||
|
'refreshed': tokens_refreshed,
|
||||||
|
'failed': tokens_failed,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return updated_settings
|
||||||
|
|
||||||
async def _start_agent_loop(
|
async def _start_agent_loop(
|
||||||
self, sid, settings, user_id, initial_user_msg=None, replay_json=None
|
self, sid, settings, user_id, initial_user_msg=None, replay_json=None
|
||||||
):
|
):
|
||||||
@@ -249,6 +351,11 @@ class SaasNestedConversationManager(ConversationManager):
|
|||||||
|
|
||||||
session_api_key = runtime.session.headers['X-Session-API-Key']
|
session_api_key = runtime.session.headers['X-Session-API-Key']
|
||||||
|
|
||||||
|
# Update provider tokens with fresh ones after runtime creation
|
||||||
|
settings = await self._refresh_provider_tokens_after_runtime_init(
|
||||||
|
settings, sid, user_id
|
||||||
|
)
|
||||||
|
|
||||||
await self._start_conversation(
|
await self._start_conversation(
|
||||||
sid,
|
sid,
|
||||||
user_id,
|
user_id,
|
||||||
@@ -333,7 +440,12 @@ class SaasNestedConversationManager(ConversationManager):
|
|||||||
async def _setup_provider_tokens(
|
async def _setup_provider_tokens(
|
||||||
self, client: httpx.AsyncClient, api_url: str, settings: Settings
|
self, client: httpx.AsyncClient, api_url: str, settings: Settings
|
||||||
):
|
):
|
||||||
"""Setup provider tokens for the nested conversation."""
|
"""Setup provider tokens for the nested conversation.
|
||||||
|
|
||||||
|
Note: Token validation happens in the nested runtime. If tokens are revoked,
|
||||||
|
the nested runtime will return 401. The caller should handle token refresh
|
||||||
|
and retry if needed.
|
||||||
|
"""
|
||||||
provider_handler = self._get_provider_handler(settings)
|
provider_handler = self._get_provider_handler(settings)
|
||||||
provider_tokens = provider_handler.provider_tokens
|
provider_tokens = provider_handler.provider_tokens
|
||||||
if provider_tokens:
|
if provider_tokens:
|
||||||
|
|||||||
@@ -285,11 +285,18 @@ class SaasSettingsStore(SettingsStore):
|
|||||||
'x-goog-api-key': LITE_LLM_API_KEY,
|
'x-goog-api-key': LITE_LLM_API_KEY,
|
||||||
},
|
},
|
||||||
) as client:
|
) as client:
|
||||||
# Get the previous max budget to prevent accidental loss
|
# Get the previous max budget to prevent accidental loss.
|
||||||
# In Litellm a get always succeeds, regardless of whether the user actually exists
|
#
|
||||||
|
# LiteLLM v1.80+ returns 404 for non-existent users (previously returned empty user_info)
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
f'{LITE_LLM_API_URL}/user/info?user_id={self.user_id}'
|
f'{LITE_LLM_API_URL}/user/info?user_id={self.user_id}'
|
||||||
)
|
)
|
||||||
|
user_info: dict
|
||||||
|
if response.status_code == 404:
|
||||||
|
# New user - doesn't exist in LiteLLM yet (v1.80+ behavior)
|
||||||
|
user_info = {}
|
||||||
|
else:
|
||||||
|
# For any other status, use standard error handling
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
response_json = response.json()
|
response_json = response.json()
|
||||||
user_info = response_json.get('user_info') or {}
|
user_info = response_json.get('user_info') or {}
|
||||||
|
|||||||
@@ -179,3 +179,315 @@ def test_is_domain_blocked_with_whitespace(domain_blocker):
|
|||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TLD Blocking Tests (patterns starting with '.')
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_domain_blocked_tld_pattern_blocks_matching_domain(domain_blocker):
|
||||||
|
"""Test that TLD pattern blocks domains ending with that TLD."""
|
||||||
|
# Arrange
|
||||||
|
domain_blocker.blocked_domains = ['.us']
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = domain_blocker.is_domain_blocked('user@company.us')
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_domain_blocked_tld_pattern_blocks_subdomain_with_tld(domain_blocker):
|
||||||
|
"""Test that TLD pattern blocks subdomains with that TLD."""
|
||||||
|
# Arrange
|
||||||
|
domain_blocker.blocked_domains = ['.us']
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = domain_blocker.is_domain_blocked('user@subdomain.company.us')
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_domain_blocked_tld_pattern_does_not_block_different_tld(domain_blocker):
|
||||||
|
"""Test that TLD pattern does not block domains with different TLD."""
|
||||||
|
# Arrange
|
||||||
|
domain_blocker.blocked_domains = ['.us']
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = domain_blocker.is_domain_blocked('user@company.com')
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_domain_blocked_tld_pattern_does_not_block_substring_match(
|
||||||
|
domain_blocker,
|
||||||
|
):
|
||||||
|
"""Test that TLD pattern does not block domains that contain but don't end with the TLD."""
|
||||||
|
# Arrange
|
||||||
|
domain_blocker.blocked_domains = ['.us']
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = domain_blocker.is_domain_blocked('user@focus.com')
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_domain_blocked_tld_pattern_case_insensitive(domain_blocker):
|
||||||
|
"""Test that TLD pattern matching is case-insensitive."""
|
||||||
|
# Arrange
|
||||||
|
domain_blocker.blocked_domains = ['.us']
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = domain_blocker.is_domain_blocked('user@COMPANY.US')
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_domain_blocked_multiple_tld_patterns(domain_blocker):
|
||||||
|
"""Test blocking with multiple TLD patterns."""
|
||||||
|
# Arrange
|
||||||
|
domain_blocker.blocked_domains = ['.us', '.vn', '.com']
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result_us = domain_blocker.is_domain_blocked('user@test.us')
|
||||||
|
result_vn = domain_blocker.is_domain_blocked('user@test.vn')
|
||||||
|
result_com = domain_blocker.is_domain_blocked('user@test.com')
|
||||||
|
result_org = domain_blocker.is_domain_blocked('user@test.org')
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result_us is True
|
||||||
|
assert result_vn is True
|
||||||
|
assert result_com is True
|
||||||
|
assert result_org is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_domain_blocked_tld_pattern_with_multi_level_tld(domain_blocker):
|
||||||
|
"""Test that TLD pattern works with multi-level TLDs like .co.uk."""
|
||||||
|
# Arrange
|
||||||
|
domain_blocker.blocked_domains = ['.co.uk']
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result_match = domain_blocker.is_domain_blocked('user@example.co.uk')
|
||||||
|
result_subdomain = domain_blocker.is_domain_blocked('user@api.example.co.uk')
|
||||||
|
result_no_match = domain_blocker.is_domain_blocked('user@example.uk')
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result_match is True
|
||||||
|
assert result_subdomain is True
|
||||||
|
assert result_no_match is False
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Subdomain Blocking Tests (domain patterns now block subdomains)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_domain_blocked_domain_pattern_blocks_exact_match(domain_blocker):
|
||||||
|
"""Test that domain pattern blocks exact domain match."""
|
||||||
|
# Arrange
|
||||||
|
domain_blocker.blocked_domains = ['example.com']
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = domain_blocker.is_domain_blocked('user@example.com')
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_domain_blocked_domain_pattern_blocks_subdomain(domain_blocker):
|
||||||
|
"""Test that domain pattern blocks subdomains of that domain."""
|
||||||
|
# Arrange
|
||||||
|
domain_blocker.blocked_domains = ['example.com']
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = domain_blocker.is_domain_blocked('user@subdomain.example.com')
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_domain_blocked_domain_pattern_blocks_multi_level_subdomain(
|
||||||
|
domain_blocker,
|
||||||
|
):
|
||||||
|
"""Test that domain pattern blocks multi-level subdomains."""
|
||||||
|
# Arrange
|
||||||
|
domain_blocker.blocked_domains = ['example.com']
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = domain_blocker.is_domain_blocked('user@api.v2.example.com')
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_domain_blocked_domain_pattern_does_not_block_similar_domain(
|
||||||
|
domain_blocker,
|
||||||
|
):
|
||||||
|
"""Test that domain pattern does not block domains that contain but don't match the pattern."""
|
||||||
|
# Arrange
|
||||||
|
domain_blocker.blocked_domains = ['example.com']
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = domain_blocker.is_domain_blocked('user@notexample.com')
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_domain_blocked_domain_pattern_does_not_block_different_tld(
|
||||||
|
domain_blocker,
|
||||||
|
):
|
||||||
|
"""Test that domain pattern does not block same domain with different TLD."""
|
||||||
|
# Arrange
|
||||||
|
domain_blocker.blocked_domains = ['example.com']
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = domain_blocker.is_domain_blocked('user@example.org')
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_domain_blocked_subdomain_pattern_blocks_exact_and_nested(domain_blocker):
|
||||||
|
"""Test that blocking a subdomain also blocks its nested subdomains."""
|
||||||
|
# Arrange
|
||||||
|
domain_blocker.blocked_domains = ['api.example.com']
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result_exact = domain_blocker.is_domain_blocked('user@api.example.com')
|
||||||
|
result_nested = domain_blocker.is_domain_blocked('user@v1.api.example.com')
|
||||||
|
result_parent = domain_blocker.is_domain_blocked('user@example.com')
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result_exact is True
|
||||||
|
assert result_nested is True
|
||||||
|
assert result_parent is False
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Mixed Pattern Tests (TLD + domain patterns together)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_domain_blocked_mixed_patterns_tld_and_domain(domain_blocker):
|
||||||
|
"""Test blocking with both TLD and domain patterns."""
|
||||||
|
# Arrange
|
||||||
|
domain_blocker.blocked_domains = ['.us', 'openhands.dev']
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result_tld = domain_blocker.is_domain_blocked('user@company.us')
|
||||||
|
result_domain = domain_blocker.is_domain_blocked('user@openhands.dev')
|
||||||
|
result_subdomain = domain_blocker.is_domain_blocked('user@api.openhands.dev')
|
||||||
|
result_allowed = domain_blocker.is_domain_blocked('user@example.com')
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result_tld is True
|
||||||
|
assert result_domain is True
|
||||||
|
assert result_subdomain is True
|
||||||
|
assert result_allowed is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_domain_blocked_overlapping_patterns(domain_blocker):
|
||||||
|
"""Test that overlapping patterns (TLD and specific domain) both work."""
|
||||||
|
# Arrange
|
||||||
|
domain_blocker.blocked_domains = ['.us', 'test.us']
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result_specific = domain_blocker.is_domain_blocked('user@test.us')
|
||||||
|
result_other_us = domain_blocker.is_domain_blocked('user@other.us')
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result_specific is True
|
||||||
|
assert result_other_us is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_domain_blocked_complex_multi_pattern_scenario(domain_blocker):
|
||||||
|
"""Test complex scenario with multiple TLD and domain patterns."""
|
||||||
|
# Arrange
|
||||||
|
domain_blocker.blocked_domains = [
|
||||||
|
'.us',
|
||||||
|
'.vn',
|
||||||
|
'test.com',
|
||||||
|
'openhands.dev',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Act & Assert
|
||||||
|
# TLD patterns
|
||||||
|
assert domain_blocker.is_domain_blocked('user@anything.us') is True
|
||||||
|
assert domain_blocker.is_domain_blocked('user@company.vn') is True
|
||||||
|
|
||||||
|
# Domain patterns (exact)
|
||||||
|
assert domain_blocker.is_domain_blocked('user@test.com') is True
|
||||||
|
assert domain_blocker.is_domain_blocked('user@openhands.dev') is True
|
||||||
|
|
||||||
|
# Domain patterns (subdomains)
|
||||||
|
assert domain_blocker.is_domain_blocked('user@api.test.com') is True
|
||||||
|
assert domain_blocker.is_domain_blocked('user@staging.openhands.dev') is True
|
||||||
|
|
||||||
|
# Not blocked
|
||||||
|
assert domain_blocker.is_domain_blocked('user@allowed.com') is False
|
||||||
|
assert domain_blocker.is_domain_blocked('user@example.org') is False
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Edge Case Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_domain_blocked_domain_with_hyphens(domain_blocker):
|
||||||
|
"""Test that domain patterns work with hyphenated domains."""
|
||||||
|
# Arrange
|
||||||
|
domain_blocker.blocked_domains = ['my-company.com']
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result_exact = domain_blocker.is_domain_blocked('user@my-company.com')
|
||||||
|
result_subdomain = domain_blocker.is_domain_blocked('user@api.my-company.com')
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result_exact is True
|
||||||
|
assert result_subdomain is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_domain_blocked_domain_with_numbers(domain_blocker):
|
||||||
|
"""Test that domain patterns work with numeric domains."""
|
||||||
|
# Arrange
|
||||||
|
domain_blocker.blocked_domains = ['test123.com']
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result_exact = domain_blocker.is_domain_blocked('user@test123.com')
|
||||||
|
result_subdomain = domain_blocker.is_domain_blocked('user@api.test123.com')
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result_exact is True
|
||||||
|
assert result_subdomain is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_domain_blocked_short_tld(domain_blocker):
|
||||||
|
"""Test that short TLD patterns work correctly."""
|
||||||
|
# Arrange
|
||||||
|
domain_blocker.blocked_domains = ['.io']
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = domain_blocker.is_domain_blocked('user@company.io')
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_domain_blocked_very_long_subdomain_chain(domain_blocker):
|
||||||
|
"""Test that blocking works with very long subdomain chains."""
|
||||||
|
# Arrange
|
||||||
|
domain_blocker.blocked_domains = ['example.com']
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = domain_blocker.is_domain_blocked(
|
||||||
|
'user@level4.level3.level2.level1.example.com'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is True
|
||||||
|
|||||||
@@ -0,0 +1,437 @@
|
|||||||
|
"""
|
||||||
|
TDD Tests for SaasNestedConversationManager token refresh functionality.
|
||||||
|
|
||||||
|
This module tests the token refresh logic that prevents stale tokens from being
|
||||||
|
sent to nested runtimes after Runtime.__init__() refreshes them.
|
||||||
|
|
||||||
|
Test Coverage:
|
||||||
|
- Token refresh with IDP user ID (GitLab webhook flow)
|
||||||
|
- Token refresh with Keycloak user ID (Web UI flow)
|
||||||
|
- Error handling and fallback behavior
|
||||||
|
- Settings immutability handling
|
||||||
|
"""
|
||||||
|
|
||||||
|
from types import MappingProxyType
|
||||||
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import SecretStr
|
||||||
|
|
||||||
|
from enterprise.server.saas_nested_conversation_manager import (
|
||||||
|
SaasNestedConversationManager,
|
||||||
|
)
|
||||||
|
from openhands.integrations.provider import ProviderToken, ProviderType
|
||||||
|
from openhands.server.session.conversation_init_data import ConversationInitData
|
||||||
|
from openhands.storage.data_models.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
class TestRefreshProviderTokensAfterRuntimeInit:
|
||||||
|
"""Test suite for _refresh_provider_tokens_after_runtime_init method."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def conversation_manager(self):
|
||||||
|
"""Create a minimal SaasNestedConversationManager instance for testing."""
|
||||||
|
# Arrange: Create mock dependencies
|
||||||
|
mock_sio = Mock()
|
||||||
|
mock_config = Mock()
|
||||||
|
mock_config.max_concurrent_conversations = 5
|
||||||
|
mock_server_config = Mock()
|
||||||
|
mock_file_store = Mock()
|
||||||
|
|
||||||
|
# Create manager instance
|
||||||
|
manager = SaasNestedConversationManager(
|
||||||
|
sio=mock_sio,
|
||||||
|
config=mock_config,
|
||||||
|
server_config=mock_server_config,
|
||||||
|
file_store=mock_file_store,
|
||||||
|
event_retrieval=Mock(),
|
||||||
|
)
|
||||||
|
return manager
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def gitlab_provider_token_with_user_id(self):
|
||||||
|
"""Create a GitLab ProviderToken with IDP user ID (webhook flow)."""
|
||||||
|
return ProviderToken(
|
||||||
|
token=SecretStr('old_token_abc123'),
|
||||||
|
user_id='32546706', # GitLab user ID
|
||||||
|
host=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def gitlab_provider_token_without_user_id(self):
|
||||||
|
"""Create a GitLab ProviderToken without IDP user ID (web UI flow)."""
|
||||||
|
return ProviderToken(
|
||||||
|
token=SecretStr('old_token_xyz789'),
|
||||||
|
user_id=None,
|
||||||
|
host=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def conversation_init_data_with_user_id(self, gitlab_provider_token_with_user_id):
|
||||||
|
"""Create ConversationInitData with provider token containing user_id."""
|
||||||
|
return ConversationInitData(
|
||||||
|
git_provider_tokens=MappingProxyType(
|
||||||
|
{ProviderType.GITLAB: gitlab_provider_token_with_user_id}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def conversation_init_data_without_user_id(
|
||||||
|
self, gitlab_provider_token_without_user_id
|
||||||
|
):
|
||||||
|
"""Create ConversationInitData with provider token without user_id."""
|
||||||
|
return ConversationInitData(
|
||||||
|
git_provider_tokens=MappingProxyType(
|
||||||
|
{ProviderType.GITLAB: gitlab_provider_token_without_user_id}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_original_settings_when_not_conversation_init_data(
|
||||||
|
self, conversation_manager
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Test: Returns original settings when not ConversationInitData.
|
||||||
|
|
||||||
|
Arrange: Create a Settings object (not ConversationInitData)
|
||||||
|
Act: Call _refresh_provider_tokens_after_runtime_init
|
||||||
|
Assert: Returns the same settings object unchanged
|
||||||
|
"""
|
||||||
|
# Arrange
|
||||||
|
settings = Settings()
|
||||||
|
sid = 'test_session_123'
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = await conversation_manager._refresh_provider_tokens_after_runtime_init(
|
||||||
|
settings, sid
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is settings
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_original_settings_when_no_provider_tokens(
|
||||||
|
self, conversation_manager
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Test: Returns original settings when no provider tokens present.
|
||||||
|
|
||||||
|
Arrange: Create ConversationInitData without git_provider_tokens
|
||||||
|
Act: Call _refresh_provider_tokens_after_runtime_init
|
||||||
|
Assert: Returns the same settings object unchanged
|
||||||
|
"""
|
||||||
|
# Arrange
|
||||||
|
settings = ConversationInitData(git_provider_tokens=None)
|
||||||
|
sid = 'test_session_456'
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = await conversation_manager._refresh_provider_tokens_after_runtime_init(
|
||||||
|
settings, sid
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is settings
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refreshes_token_with_idp_user_id(
|
||||||
|
self, conversation_manager, conversation_init_data_with_user_id
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Test: Refreshes token using IDP user ID (GitLab webhook flow).
|
||||||
|
|
||||||
|
Arrange: ConversationInitData with GitLab token containing user_id
|
||||||
|
Act: Call _refresh_provider_tokens_after_runtime_init with mocked TokenManager
|
||||||
|
Assert: Token is refreshed using get_idp_token_from_idp_user_id
|
||||||
|
"""
|
||||||
|
# Arrange
|
||||||
|
sid = 'test_session_789'
|
||||||
|
fresh_token = 'fresh_token_def456'
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
'enterprise.server.saas_nested_conversation_manager.TokenManager'
|
||||||
|
) as mock_token_manager_class:
|
||||||
|
mock_token_manager = AsyncMock()
|
||||||
|
mock_token_manager.get_idp_token_from_idp_user_id = AsyncMock(
|
||||||
|
return_value=fresh_token
|
||||||
|
)
|
||||||
|
mock_token_manager_class.return_value = mock_token_manager
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = (
|
||||||
|
await conversation_manager._refresh_provider_tokens_after_runtime_init(
|
||||||
|
conversation_init_data_with_user_id, sid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
mock_token_manager.get_idp_token_from_idp_user_id.assert_called_once_with(
|
||||||
|
'32546706', ProviderType.GITLAB
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
result.git_provider_tokens[ProviderType.GITLAB].token.get_secret_value()
|
||||||
|
== fresh_token
|
||||||
|
)
|
||||||
|
assert result.git_provider_tokens[ProviderType.GITLAB].user_id == '32546706'
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refreshes_token_with_keycloak_user_id(
|
||||||
|
self, conversation_manager, conversation_init_data_without_user_id
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Test: Refreshes token using Keycloak user ID (Web UI flow).
|
||||||
|
|
||||||
|
Arrange: ConversationInitData without IDP user_id, but with Keycloak user_id
|
||||||
|
Act: Call _refresh_provider_tokens_after_runtime_init with mocked TokenManager
|
||||||
|
Assert: Token is refreshed using load_offline_token + get_idp_token_from_offline_token
|
||||||
|
"""
|
||||||
|
# Arrange
|
||||||
|
sid = 'test_session_101'
|
||||||
|
keycloak_user_id = 'keycloak_user_abc'
|
||||||
|
offline_token = 'offline_token_xyz'
|
||||||
|
fresh_token = 'fresh_token_ghi789'
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
'enterprise.server.saas_nested_conversation_manager.TokenManager'
|
||||||
|
) as mock_token_manager_class:
|
||||||
|
mock_token_manager = AsyncMock()
|
||||||
|
mock_token_manager.load_offline_token = AsyncMock(
|
||||||
|
return_value=offline_token
|
||||||
|
)
|
||||||
|
mock_token_manager.get_idp_token_from_offline_token = AsyncMock(
|
||||||
|
return_value=fresh_token
|
||||||
|
)
|
||||||
|
mock_token_manager_class.return_value = mock_token_manager
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = (
|
||||||
|
await conversation_manager._refresh_provider_tokens_after_runtime_init(
|
||||||
|
conversation_init_data_without_user_id, sid, keycloak_user_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
mock_token_manager.load_offline_token.assert_called_once_with(
|
||||||
|
keycloak_user_id
|
||||||
|
)
|
||||||
|
mock_token_manager.get_idp_token_from_offline_token.assert_called_once_with(
|
||||||
|
offline_token, ProviderType.GITLAB
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
result.git_provider_tokens[ProviderType.GITLAB].token.get_secret_value()
|
||||||
|
== fresh_token
|
||||||
|
)
|
||||||
|
assert result.git_provider_tokens[ProviderType.GITLAB].user_id is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_keeps_original_token_when_refresh_fails(
|
||||||
|
self, conversation_manager, conversation_init_data_with_user_id
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Test: Keeps original token when refresh fails (error handling).
|
||||||
|
|
||||||
|
Arrange: ConversationInitData with token, TokenManager raises exception
|
||||||
|
Act: Call _refresh_provider_tokens_after_runtime_init
|
||||||
|
Assert: Original token is preserved, no exception raised
|
||||||
|
"""
|
||||||
|
# Arrange
|
||||||
|
sid = 'test_session_error'
|
||||||
|
original_token = conversation_init_data_with_user_id.git_provider_tokens[
|
||||||
|
ProviderType.GITLAB
|
||||||
|
].token.get_secret_value()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
'enterprise.server.saas_nested_conversation_manager.TokenManager'
|
||||||
|
) as mock_token_manager_class:
|
||||||
|
mock_token_manager = AsyncMock()
|
||||||
|
mock_token_manager.get_idp_token_from_idp_user_id = AsyncMock(
|
||||||
|
side_effect=Exception('Token refresh failed')
|
||||||
|
)
|
||||||
|
mock_token_manager_class.return_value = mock_token_manager
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = (
|
||||||
|
await conversation_manager._refresh_provider_tokens_after_runtime_init(
|
||||||
|
conversation_init_data_with_user_id, sid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert (
|
||||||
|
result.git_provider_tokens[ProviderType.GITLAB].token.get_secret_value()
|
||||||
|
== original_token
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_keeps_original_token_when_no_fresh_token_available(
|
||||||
|
self, conversation_manager, conversation_init_data_with_user_id
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Test: Keeps original token when no fresh token is available.
|
||||||
|
|
||||||
|
Arrange: ConversationInitData with token, TokenManager returns None
|
||||||
|
Act: Call _refresh_provider_tokens_after_runtime_init
|
||||||
|
Assert: Original token is preserved
|
||||||
|
"""
|
||||||
|
# Arrange
|
||||||
|
sid = 'test_session_no_fresh'
|
||||||
|
original_token = conversation_init_data_with_user_id.git_provider_tokens[
|
||||||
|
ProviderType.GITLAB
|
||||||
|
].token.get_secret_value()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
'enterprise.server.saas_nested_conversation_manager.TokenManager'
|
||||||
|
) as mock_token_manager_class:
|
||||||
|
mock_token_manager = AsyncMock()
|
||||||
|
mock_token_manager.get_idp_token_from_idp_user_id = AsyncMock(
|
||||||
|
return_value=None
|
||||||
|
)
|
||||||
|
mock_token_manager_class.return_value = mock_token_manager
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = (
|
||||||
|
await conversation_manager._refresh_provider_tokens_after_runtime_init(
|
||||||
|
conversation_init_data_with_user_id, sid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert (
|
||||||
|
result.git_provider_tokens[ProviderType.GITLAB].token.get_secret_value()
|
||||||
|
== original_token
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_creates_new_settings_object_preserving_immutability(
|
||||||
|
self, conversation_manager, conversation_init_data_with_user_id
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Test: Creates new settings object (respects Pydantic frozen fields).
|
||||||
|
|
||||||
|
Arrange: ConversationInitData with frozen git_provider_tokens field
|
||||||
|
Act: Call _refresh_provider_tokens_after_runtime_init
|
||||||
|
Assert: Returns a new ConversationInitData object, not the same instance
|
||||||
|
"""
|
||||||
|
# Arrange
|
||||||
|
sid = 'test_session_immutable'
|
||||||
|
fresh_token = 'fresh_token_new'
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
'enterprise.server.saas_nested_conversation_manager.TokenManager'
|
||||||
|
) as mock_token_manager_class:
|
||||||
|
mock_token_manager = AsyncMock()
|
||||||
|
mock_token_manager.get_idp_token_from_idp_user_id = AsyncMock(
|
||||||
|
return_value=fresh_token
|
||||||
|
)
|
||||||
|
mock_token_manager_class.return_value = mock_token_manager
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = (
|
||||||
|
await conversation_manager._refresh_provider_tokens_after_runtime_init(
|
||||||
|
conversation_init_data_with_user_id, sid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is not conversation_init_data_with_user_id
|
||||||
|
assert isinstance(result, ConversationInitData)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handles_multiple_providers(self, conversation_manager):
|
||||||
|
"""
|
||||||
|
Test: Handles multiple provider tokens correctly.
|
||||||
|
|
||||||
|
Arrange: ConversationInitData with both GitLab and GitHub tokens
|
||||||
|
Act: Call _refresh_provider_tokens_after_runtime_init
|
||||||
|
Assert: Both tokens are refreshed independently
|
||||||
|
"""
|
||||||
|
# Arrange
|
||||||
|
sid = 'test_session_multi'
|
||||||
|
gitlab_token = ProviderToken(
|
||||||
|
token=SecretStr('old_gitlab_token'), user_id='gitlab_user_123', host=None
|
||||||
|
)
|
||||||
|
github_token = ProviderToken(
|
||||||
|
token=SecretStr('old_github_token'), user_id='github_user_456', host=None
|
||||||
|
)
|
||||||
|
settings = ConversationInitData(
|
||||||
|
git_provider_tokens=MappingProxyType(
|
||||||
|
{ProviderType.GITLAB: gitlab_token, ProviderType.GITHUB: github_token}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
fresh_gitlab_token = 'fresh_gitlab_token'
|
||||||
|
fresh_github_token = 'fresh_github_token'
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
'enterprise.server.saas_nested_conversation_manager.TokenManager'
|
||||||
|
) as mock_token_manager_class:
|
||||||
|
mock_token_manager = AsyncMock()
|
||||||
|
|
||||||
|
async def mock_get_token(user_id, provider_type):
|
||||||
|
if provider_type == ProviderType.GITLAB:
|
||||||
|
return fresh_gitlab_token
|
||||||
|
elif provider_type == ProviderType.GITHUB:
|
||||||
|
return fresh_github_token
|
||||||
|
return None
|
||||||
|
|
||||||
|
mock_token_manager.get_idp_token_from_idp_user_id = AsyncMock(
|
||||||
|
side_effect=mock_get_token
|
||||||
|
)
|
||||||
|
mock_token_manager_class.return_value = mock_token_manager
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = (
|
||||||
|
await conversation_manager._refresh_provider_tokens_after_runtime_init(
|
||||||
|
settings, sid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert (
|
||||||
|
result.git_provider_tokens[ProviderType.GITLAB].token.get_secret_value()
|
||||||
|
== fresh_gitlab_token
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
result.git_provider_tokens[ProviderType.GITHUB].token.get_secret_value()
|
||||||
|
== fresh_github_token
|
||||||
|
)
|
||||||
|
assert mock_token_manager.get_idp_token_from_idp_user_id.call_count == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_preserves_token_host_field(self, conversation_manager):
|
||||||
|
"""
|
||||||
|
Test: Preserves the host field from original token.
|
||||||
|
|
||||||
|
Arrange: ProviderToken with custom host value
|
||||||
|
Act: Call _refresh_provider_tokens_after_runtime_init
|
||||||
|
Assert: Host field is preserved in the refreshed token
|
||||||
|
"""
|
||||||
|
# Arrange
|
||||||
|
sid = 'test_session_host'
|
||||||
|
custom_host = 'gitlab.example.com'
|
||||||
|
token_with_host = ProviderToken(
|
||||||
|
token=SecretStr('old_token'), user_id='user_789', host=custom_host
|
||||||
|
)
|
||||||
|
settings = ConversationInitData(
|
||||||
|
git_provider_tokens=MappingProxyType({ProviderType.GITLAB: token_with_host})
|
||||||
|
)
|
||||||
|
|
||||||
|
fresh_token = 'fresh_token_with_host'
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
'enterprise.server.saas_nested_conversation_manager.TokenManager'
|
||||||
|
) as mock_token_manager_class:
|
||||||
|
mock_token_manager = AsyncMock()
|
||||||
|
mock_token_manager.get_idp_token_from_idp_user_id = AsyncMock(
|
||||||
|
return_value=fresh_token
|
||||||
|
)
|
||||||
|
mock_token_manager_class.return_value = mock_token_manager
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = (
|
||||||
|
await conversation_manager._refresh_provider_tokens_after_runtime_init(
|
||||||
|
settings, sid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result.git_provider_tokens[ProviderType.GITLAB].host == custom_host
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import SecretStr
|
from pydantic import SecretStr
|
||||||
from server.constants import (
|
from server.constants import (
|
||||||
@@ -335,6 +336,80 @@ async def test_update_settings_with_litellm_default_error(settings_store):
|
|||||||
assert settings is None
|
assert settings is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'status_code,user_info_response,should_succeed',
|
||||||
|
[
|
||||||
|
# 200 OK with user info - existing user (v1.79.x and v1.80+ behavior)
|
||||||
|
(200, {'user_info': {'max_budget': 10, 'spend': 5}}, True),
|
||||||
|
# 200 OK with empty user info - new user (v1.79.x behavior)
|
||||||
|
(200, {'user_info': None}, True),
|
||||||
|
# 404 Not Found - new user (v1.80+ behavior)
|
||||||
|
(404, None, True),
|
||||||
|
# 500 Internal Server Error - should fail
|
||||||
|
(500, None, False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_update_settings_with_litellm_default_handles_user_info_responses(
|
||||||
|
settings_store, session_maker, status_code, user_info_response, should_succeed
|
||||||
|
):
|
||||||
|
"""Test that various LiteLLM user/info responses are handled correctly.
|
||||||
|
|
||||||
|
LiteLLM API behavior changed between versions:
|
||||||
|
- v1.79.x and earlier: GET /user/info always succeeds with empty user_info
|
||||||
|
- v1.80.x and later: GET /user/info returns 404 for non-existent users
|
||||||
|
"""
|
||||||
|
mock_get_response = MagicMock()
|
||||||
|
mock_get_response.status_code = status_code
|
||||||
|
if user_info_response is not None:
|
||||||
|
mock_get_response.json = MagicMock(return_value=user_info_response)
|
||||||
|
mock_get_response.raise_for_status = MagicMock()
|
||||||
|
else:
|
||||||
|
mock_get_response.raise_for_status = MagicMock(
|
||||||
|
side_effect=httpx.HTTPStatusError(
|
||||||
|
'Error', request=MagicMock(), response=mock_get_response
|
||||||
|
)
|
||||||
|
if status_code >= 500
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock successful responses for POST operations (delete and create)
|
||||||
|
mock_post_response = MagicMock()
|
||||||
|
mock_post_response.is_success = True
|
||||||
|
mock_post_response.json = MagicMock(return_value={'key': 'new_user_api_key'})
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch('storage.saas_settings_store.LITE_LLM_API_KEY', 'test_key'),
|
||||||
|
patch('storage.saas_settings_store.LITE_LLM_API_URL', 'http://test.url'),
|
||||||
|
patch('storage.saas_settings_store.LITE_LLM_TEAM_ID', 'test_team'),
|
||||||
|
patch(
|
||||||
|
'server.auth.token_manager.TokenManager.get_user_info_from_user_id',
|
||||||
|
AsyncMock(return_value={'email': 'testuser@example.com'}),
|
||||||
|
),
|
||||||
|
patch('httpx.AsyncClient') as mock_client,
|
||||||
|
patch('storage.saas_settings_store.session_maker', session_maker),
|
||||||
|
):
|
||||||
|
# Set up the mock client
|
||||||
|
mock_client.return_value.__aenter__.return_value.get.return_value = (
|
||||||
|
mock_get_response
|
||||||
|
)
|
||||||
|
mock_client.return_value.__aenter__.return_value.post.return_value = (
|
||||||
|
mock_post_response
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
if should_succeed:
|
||||||
|
settings = await settings_store.update_settings_with_litellm_default(
|
||||||
|
settings
|
||||||
|
)
|
||||||
|
assert settings is not None
|
||||||
|
assert settings.llm_api_key is not None
|
||||||
|
assert settings.llm_api_key.get_secret_value() == 'new_user_api_key'
|
||||||
|
else:
|
||||||
|
with pytest.raises(httpx.HTTPStatusError):
|
||||||
|
await settings_store.update_settings_with_litellm_default(settings)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_update_settings_with_litellm_retry_on_duplicate_email(
|
async def test_update_settings_with_litellm_retry_on_duplicate_email(
|
||||||
settings_store, mock_litellm_api, session_maker
|
settings_store, mock_litellm_api, session_maker
|
||||||
|
|||||||
8
frontend/package-lock.json
generated
8
frontend/package-lock.json
generated
@@ -30,7 +30,7 @@
|
|||||||
"isbot": "^5.1.32",
|
"isbot": "^5.1.32",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"posthog-js": "^1.312.0",
|
"posthog-js": "^1.313.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
@@ -13382,9 +13382,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/posthog-js": {
|
"node_modules/posthog-js": {
|
||||||
"version": "1.312.0",
|
"version": "1.313.0",
|
||||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.312.0.tgz",
|
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.313.0.tgz",
|
||||||
"integrity": "sha512-rdXprhuRzhutU8powMJpIfC0uRtI3OyuYktmLhZRMsD4DQaO3fnudKNq4zxtNmqMPFCSTfmlBH8ByLNOppm2tg==",
|
"integrity": "sha512-CL8RkC7m9BTZrix86w0fdnSCVqC/gxrfs6c4Wfkz/CldFD7f2912S2KqnWFmwRVDGIwm9IR82YhublQ88gdDKw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/core": "1.9.0",
|
"@posthog/core": "1.9.0",
|
||||||
"core-js": "^3.38.1",
|
"core-js": "^3.38.1",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"isbot": "^5.1.32",
|
"isbot": "^5.1.32",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"posthog-js": "^1.312.0",
|
"posthog-js": "^1.313.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ class DockerSandboxService(SandboxService):
|
|||||||
health_check_path: str | None
|
health_check_path: str | None
|
||||||
httpx_client: httpx.AsyncClient
|
httpx_client: httpx.AsyncClient
|
||||||
max_num_sandboxes: int
|
max_num_sandboxes: int
|
||||||
|
extra_hosts: dict[str, str] = field(default_factory=dict)
|
||||||
docker_client: docker.DockerClient = field(default_factory=get_docker_client)
|
docker_client: docker.DockerClient = field(default_factory=get_docker_client)
|
||||||
|
|
||||||
def _find_unused_port(self) -> int:
|
def _find_unused_port(self) -> int:
|
||||||
@@ -349,6 +350,9 @@ class DockerSandboxService(SandboxService):
|
|||||||
# Use Docker's tini init process to ensure proper signal handling and reaping of
|
# Use Docker's tini init process to ensure proper signal handling and reaping of
|
||||||
# zombie child processes.
|
# zombie child processes.
|
||||||
init=True,
|
init=True,
|
||||||
|
# Allow agent-server containers to resolve host.docker.internal
|
||||||
|
# and other custom hostnames for LAN deployments
|
||||||
|
extra_hosts=self.extra_hosts if self.extra_hosts else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
sandbox_info = await self._container_to_sandbox_info(container)
|
sandbox_info = await self._container_to_sandbox_info(container)
|
||||||
@@ -469,6 +473,15 @@ class DockerSandboxServiceInjector(SandboxServiceInjector):
|
|||||||
'determine whether the server is running'
|
'determine whether the server is running'
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
extra_hosts: dict[str, str] = Field(
|
||||||
|
default_factory=lambda: {'host.docker.internal': 'host-gateway'},
|
||||||
|
description=(
|
||||||
|
'Extra hostname mappings to add to agent-server containers. '
|
||||||
|
'This allows containers to resolve hostnames like host.docker.internal '
|
||||||
|
'for LAN deployments and MCP connections. '
|
||||||
|
'Format: {"hostname": "ip_or_gateway"}'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
async def inject(
|
async def inject(
|
||||||
self, state: InjectorState, request: Request | None = None
|
self, state: InjectorState, request: Request | None = None
|
||||||
@@ -493,4 +506,5 @@ class DockerSandboxServiceInjector(SandboxServiceInjector):
|
|||||||
health_check_path=self.health_check_path,
|
health_check_path=self.health_check_path,
|
||||||
httpx_client=httpx_client,
|
httpx_client=httpx_client,
|
||||||
max_num_sandboxes=self.max_num_sandboxes,
|
max_num_sandboxes=self.max_num_sandboxes,
|
||||||
|
extra_hosts=self.extra_hosts,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -552,11 +552,11 @@ def get_uvicorn_json_log_config() -> dict:
|
|||||||
},
|
},
|
||||||
# Actual JSON formatters used by handlers below
|
# Actual JSON formatters used by handlers below
|
||||||
'json': {
|
'json': {
|
||||||
'()': 'pythonjsonlogger.jsonlogger.JsonFormatter',
|
'()': 'pythonjsonlogger.json.JsonFormatter',
|
||||||
'fmt': '%(message)s %(levelname)s %(name)s %(asctime)s %(exc_info)s',
|
'fmt': '%(message)s %(levelname)s %(name)s %(asctime)s %(exc_info)s',
|
||||||
},
|
},
|
||||||
'json_access': {
|
'json_access': {
|
||||||
'()': 'pythonjsonlogger.jsonlogger.JsonFormatter',
|
'()': 'pythonjsonlogger.json.JsonFormatter',
|
||||||
'fmt': '%(message)s %(levelname)s %(name)s %(asctime)s %(client_addr)s %(request_line)s %(status_code)s',
|
'fmt': '%(message)s %(levelname)s %(name)s %(asctime)s %(client_addr)s %(request_line)s %(status_code)s',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
out/
|
|
||||||
node_modules/
|
|
||||||
.vscode-test/
|
|
||||||
*.vsix
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
{
|
|
||||||
"parser": "@typescript-eslint/parser",
|
|
||||||
"parserOptions": {
|
|
||||||
"project": "./tsconfig.json",
|
|
||||||
"ecmaVersion": 2020,
|
|
||||||
"sourceType": "module"
|
|
||||||
},
|
|
||||||
"extends": [
|
|
||||||
"airbnb-base",
|
|
||||||
"airbnb-typescript/base",
|
|
||||||
"prettier",
|
|
||||||
"plugin:@typescript-eslint/eslint-recommended",
|
|
||||||
"plugin:@typescript-eslint/recommended"
|
|
||||||
],
|
|
||||||
"plugins": ["prettier", "unused-imports"],
|
|
||||||
"rules": {
|
|
||||||
"unused-imports/no-unused-imports": "error",
|
|
||||||
"prettier/prettier": ["error"],
|
|
||||||
// Resolves https://stackoverflow.com/questions/59265981/typescript-eslint-missing-file-extension-ts-import-extensions/59268871#59268871
|
|
||||||
"import/extensions": [
|
|
||||||
"error",
|
|
||||||
"ignorePackages",
|
|
||||||
{
|
|
||||||
"": "never",
|
|
||||||
"ts": "never"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
// Allow state modification in reduce and similar patterns
|
|
||||||
"no-param-reassign": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"props": true,
|
|
||||||
"ignorePropertyModificationsFor": ["acc", "state"]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
// For https://stackoverflow.com/questions/55844608/stuck-with-eslint-error-i-e-separately-loops-should-be-avoided-in-favor-of-arra
|
|
||||||
"no-restricted-syntax": "off",
|
|
||||||
"import/prefer-default-export": "off",
|
|
||||||
"no-underscore-dangle": "off",
|
|
||||||
"import/no-extraneous-dependencies": "off",
|
|
||||||
// VSCode extension specific - allow console for debugging
|
|
||||||
"no-console": "warn",
|
|
||||||
// Allow leading underscores for private variables in VSCode extensions
|
|
||||||
"@typescript-eslint/naming-convention": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"selector": "variable",
|
|
||||||
"format": ["camelCase", "PascalCase", "UPPER_CASE"],
|
|
||||||
"leadingUnderscore": "allow"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"env": {
|
|
||||||
"node": true,
|
|
||||||
"es6": true
|
|
||||||
},
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"files": ["src/test/**/*.ts"],
|
|
||||||
"rules": {
|
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
|
||||||
"no-console": "off",
|
|
||||||
"@typescript-eslint/no-shadow": "off",
|
|
||||||
"consistent-return": "off"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
18
openhands/integrations/vscode/.gitignore
vendored
18
openhands/integrations/vscode/.gitignore
vendored
@@ -1,18 +0,0 @@
|
|||||||
# Node modules
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Compiled TypeScript output
|
|
||||||
out/
|
|
||||||
|
|
||||||
# VS Code Extension packaging
|
|
||||||
*.vsix
|
|
||||||
|
|
||||||
# TypeScript build info
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# Test run output (if any specific folders are generated)
|
|
||||||
.vscode-test/
|
|
||||||
|
|
||||||
# OS-generated files
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"trailingComma": "all"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
.vscodeignore
|
|
||||||
.gitignore
|
|
||||||
*.vsix
|
|
||||||
node_modules/
|
|
||||||
out/src/ # We only need out/extension.js and out/extension.js.map
|
|
||||||
src/
|
|
||||||
*.tsbuildinfo
|
|
||||||
tsconfig.json
|
|
||||||
PLAN.md
|
|
||||||
README.md
|
|
||||||
# Add other files/folders to ignore during packaging if needed
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
# VSCode Extension Development
|
|
||||||
|
|
||||||
This document provides instructions for developing and contributing to the OpenHands VSCode extension.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
To get started with development, you need to install the dependencies.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building the Extension
|
|
||||||
|
|
||||||
The VSCode extension is automatically built during the main OpenHands `pip install` process. However, you can also build it manually.
|
|
||||||
|
|
||||||
- **Package the extension:** This creates a `.vsix` file that can be installed in VSCode.
|
|
||||||
```bash
|
|
||||||
npm run package-vsix
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Compile TypeScript:** This compiles the source code without creating a package.
|
|
||||||
```bash
|
|
||||||
npm run compile
|
|
||||||
```
|
|
||||||
|
|
||||||
## Code Quality and Testing
|
|
||||||
|
|
||||||
We use ESLint, Prettier, and TypeScript for code quality.
|
|
||||||
|
|
||||||
- **Run linting with auto-fixes:**
|
|
||||||
```bash
|
|
||||||
npm run lint:fix
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Run type checking:**
|
|
||||||
```bash
|
|
||||||
npm run typecheck
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Run tests:**
|
|
||||||
```bash
|
|
||||||
npm run test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Releasing a New Version
|
|
||||||
|
|
||||||
The extension has its own version number and is released independently of the main OpenHands application. The release process is automated via the `vscode-extension-build.yml` GitHub Actions workflow and is triggered by pushing a specially formatted Git tag.
|
|
||||||
|
|
||||||
### 1. Update the Version Number
|
|
||||||
|
|
||||||
Before creating a release, you must first bump the version number in the extension's `package.json` file.
|
|
||||||
|
|
||||||
1. Open `openhands/integrations/vscode/package.json`.
|
|
||||||
2. Find the `"version"` field and update it according to [Semantic Versioning](https://semver.org/) (e.g., from `"0.0.1"` to `"0.0.2"`).
|
|
||||||
|
|
||||||
### 2. Commit the Version Bump
|
|
||||||
|
|
||||||
Commit the change to `package.json` with a clear commit message.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add openhands/integrations/vscode/package.json
|
|
||||||
git commit -m "chore(vscode): bump version to 0.0.2"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Create and Push the Tag
|
|
||||||
|
|
||||||
The release is triggered by a Git tag that **must** match the version in `package.json` and be prefixed with `ext-v`.
|
|
||||||
|
|
||||||
1. **Create an annotated tag.** The tag name must be `ext-v` followed by the version number you just set.
|
|
||||||
```bash
|
|
||||||
# Example for version 0.0.2
|
|
||||||
git tag -a ext-v0.0.2 -m "Release VSCode extension v0.0.2"
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Push the commit and the tag** to the `upstream` remote.
|
|
||||||
```bash
|
|
||||||
# Push the branch with the version bump commit
|
|
||||||
git push upstream <your-branch-name>
|
|
||||||
|
|
||||||
# Push the specific tag
|
|
||||||
git push upstream ext-v0.0.2
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Finalize the Release on GitHub
|
|
||||||
|
|
||||||
Pushing the tag will automatically trigger the `VSCode Extension CI` workflow. This workflow will:
|
|
||||||
1. Build the `.vsix` file.
|
|
||||||
2. Create a new **draft release** on GitHub with the `.vsix` file attached as an asset.
|
|
||||||
|
|
||||||
To finalize the release:
|
|
||||||
1. Go to the "Releases" page of the OpenHands repository on GitHub.
|
|
||||||
2. Find the new draft release (e.g., `ext-v0.0.2`).
|
|
||||||
3. Click "Edit" to write the release notes, describing the new features and bug fixes.
|
|
||||||
4. Click the **"Publish release"** button.
|
|
||||||
|
|
||||||
The release is now public and available for users.
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
The MIT License (MIT)
|
|
||||||
=====================
|
|
||||||
|
|
||||||
Copyright © 2025
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person
|
|
||||||
obtaining a copy of this software and associated documentation
|
|
||||||
files (the “Software”), to deal in the Software without
|
|
||||||
restriction, including without limitation the rights to use,
|
|
||||||
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the
|
|
||||||
Software is furnished to do so, subject to the following
|
|
||||||
conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be
|
|
||||||
included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
||||||
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
||||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
||||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
||||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
||||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
||||||
OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# OpenHands VS Code Extension
|
|
||||||
|
|
||||||
The official OpenHands companion extension for Visual Studio Code.
|
|
||||||
|
|
||||||
This extension seamlessly integrates OpenHands into your VSCode workflow, allowing you to start coding sessions with your AI agent directly from your editor.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Start a New Conversation**: Launch OpenHands in a new terminal with a single command.
|
|
||||||
- **Use Your Current File**: Automatically send the content of your active file to OpenHands to start a task.
|
|
||||||
- **Use a Selection**: Send only the highlighted text from your editor to OpenHands for focused tasks.
|
|
||||||
- **Safe Terminal Management**: The extension intelligently reuses idle terminals or creates new ones, ensuring it never interrupts an active process.
|
|
||||||
- **Automatic Virtual Environment Detection**: Finds and uses your project's Python virtual environment (`.venv`, `venv`, etc.) automatically.
|
|
||||||
|
|
||||||
## How to Use
|
|
||||||
|
|
||||||
You can access the extension's commands in two ways:
|
|
||||||
|
|
||||||
1. **Command Palette**:
|
|
||||||
- Open the Command Palette (`Ctrl+Shift+P` or `Cmd+Shift+P`).
|
|
||||||
- Type `OpenHands` to see the available commands.
|
|
||||||
- Select the command you want to run.
|
|
||||||
|
|
||||||
2. **Editor Context Menu**:
|
|
||||||
- Right-click anywhere in your text editor.
|
|
||||||
- The OpenHands commands will appear in the context menu.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
For the best experience, the OpenHands CLI will attempt to install the extension for you automatically the first time you run it inside VSCode.
|
|
||||||
|
|
||||||
If you need to install it manually:
|
|
||||||
1. Download the latest `.vsix` file from the [GitHub Releases page](https://github.com/OpenHands/OpenHands/releases).
|
|
||||||
2. In VSCode, open the Command Palette (`Ctrl+Shift+P`).
|
|
||||||
3. Run the **"Extensions: Install from VSIX..."** command.
|
|
||||||
4. Select the `.vsix` file you downloaded.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- **OpenHands CLI**: You must have `openhands` installed and available in your system's PATH.
|
|
||||||
- **VS Code**: Version 1.98.2 or newer.
|
|
||||||
- **Shell**: For the best terminal reuse experience, a shell with [Shell Integration](https://code.visualstudio.com/docs/terminal/shell-integration) is recommended (e.g., modern versions of bash, zsh, PowerShell, or fish).
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
We welcome contributions! If you're interested in developing the extension, please see the `DEVELOPMENT.md` file in our source repository for instructions on how to get started.
|
|
||||||
8269
openhands/integrations/vscode/package-lock.json
generated
8269
openhands/integrations/vscode/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,110 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "openhands-vscode",
|
|
||||||
"displayName": "OpenHands Integration",
|
|
||||||
"description": "Integrates OpenHands with VS Code for easy conversation starting and context passing.",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"publisher": "openhands",
|
|
||||||
"license": "MIT",
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/openhands/OpenHands.git"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"vscode": "^1.98.2",
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
},
|
|
||||||
"activationEvents": [
|
|
||||||
"onCommand:openhands.startConversation",
|
|
||||||
"onCommand:openhands.startConversationWithFileContext",
|
|
||||||
"onCommand:openhands.startConversationWithSelectionContext"
|
|
||||||
],
|
|
||||||
"main": "./out/extension.js",
|
|
||||||
"contributes": {
|
|
||||||
"commands": [
|
|
||||||
{
|
|
||||||
"command": "openhands.startConversation",
|
|
||||||
"title": "Start New Conversation",
|
|
||||||
"category": "OpenHands"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "openhands.startConversationWithFileContext",
|
|
||||||
"title": "Start with File Content",
|
|
||||||
"category": "OpenHands"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "openhands.startConversationWithSelectionContext",
|
|
||||||
"title": "Start with Selected Text",
|
|
||||||
"category": "OpenHands"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"submenus": [
|
|
||||||
{
|
|
||||||
"id": "openhands.contextMenu",
|
|
||||||
"label": "OpenHands"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"menus": {
|
|
||||||
"editor/context": [
|
|
||||||
{
|
|
||||||
"submenu": "openhands.contextMenu",
|
|
||||||
"group": "navigation@1"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"openhands.contextMenu": [
|
|
||||||
{
|
|
||||||
"when": "editorHasSelection",
|
|
||||||
"command": "openhands.startConversationWithSelectionContext",
|
|
||||||
"group": "1@1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "openhands.startConversationWithFileContext",
|
|
||||||
"group": "1@2"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"commandPalette": [
|
|
||||||
{
|
|
||||||
"command": "openhands.startConversation",
|
|
||||||
"when": "true"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "openhands.startConversationWithFileContext",
|
|
||||||
"when": "editorIsOpen"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "openhands.startConversationWithSelectionContext",
|
|
||||||
"when": "editorHasSelection"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"vscode:prepublish": "npm run compile",
|
|
||||||
"compile": "tsc -p ./",
|
|
||||||
"watch": "tsc -watch -p ./",
|
|
||||||
"test": "npm run compile && node ./out/test/runTest.js",
|
|
||||||
"package-vsix": "npm run compile && npx vsce package --no-dependencies",
|
|
||||||
"lint": "npm run typecheck && eslint src --ext .ts && prettier --check src/**/*.ts",
|
|
||||||
"lint:fix": "eslint src --ext .ts --fix && prettier --write src/**/*.ts",
|
|
||||||
"typecheck": "tsc --noEmit"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/vscode": "^1.98.2",
|
|
||||||
"typescript": "^5.0.0",
|
|
||||||
"@types/mocha": "^10.0.6",
|
|
||||||
"mocha": "^10.4.0",
|
|
||||||
"@vscode/test-electron": "^2.3.9",
|
|
||||||
"@types/node": "^20.12.12",
|
|
||||||
"@types/glob": "^8.1.0",
|
|
||||||
"@vscode/vsce": "^3.5.0",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
|
||||||
"@typescript-eslint/parser": "^7.18.0",
|
|
||||||
"eslint": "^8.57.0",
|
|
||||||
"eslint-config-airbnb-base": "^15.0.0",
|
|
||||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
|
||||||
"eslint-config-prettier": "^10.1.5",
|
|
||||||
"eslint-plugin-import": "^2.29.1",
|
|
||||||
"eslint-plugin-prettier": "^5.5.0",
|
|
||||||
"eslint-plugin-unused-imports": "^4.1.4",
|
|
||||||
"prettier": "^3.5.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,380 +0,0 @@
|
|||||||
import * as vscode from "vscode";
|
|
||||||
import * as fs from "fs";
|
|
||||||
import * as path from "path";
|
|
||||||
|
|
||||||
// Create output channel for debug logging
|
|
||||||
const outputChannel = vscode.window.createOutputChannel("OpenHands Debug");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This implementation uses VSCode's Shell Integration API.
|
|
||||||
*
|
|
||||||
* VSCode API References:
|
|
||||||
* - Terminal Shell Integration: https://code.visualstudio.com/docs/terminal/shell-integration
|
|
||||||
* - VSCode Extension API: https://code.visualstudio.com/api/references/vscode-api
|
|
||||||
* - Terminal API Reference: https://code.visualstudio.com/api/references/vscode-api#Terminal
|
|
||||||
* - VSCode Source Examples: https://github.com/microsoft/vscode/blob/main/src/vscode-dts/vscode.d.ts
|
|
||||||
*
|
|
||||||
* Shell Integration Requirements:
|
|
||||||
* - Compatible shells: bash, zsh, PowerShell Core, or fish shell
|
|
||||||
* - Graceful fallback needed for Command Prompt and other shells
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Track terminals that we know are idle (just finished our commands)
|
|
||||||
const idleTerminals = new Set<string>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marks a terminal as idle after our command completes
|
|
||||||
* @param terminalName The name of the terminal
|
|
||||||
*/
|
|
||||||
function markTerminalAsIdle(terminalName: string): void {
|
|
||||||
idleTerminals.add(terminalName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marks a terminal as busy when we start a command
|
|
||||||
* @param terminalName The name of the terminal
|
|
||||||
*/
|
|
||||||
function markTerminalAsBusy(terminalName: string): void {
|
|
||||||
idleTerminals.delete(terminalName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if we know a terminal is idle (safe to reuse)
|
|
||||||
* @param terminal The terminal to check
|
|
||||||
* @returns boolean true if we know it's idle, false otherwise
|
|
||||||
*/
|
|
||||||
function isKnownIdleTerminal(terminal: vscode.Terminal): boolean {
|
|
||||||
return idleTerminals.has(terminal.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new OpenHands terminal with timestamp
|
|
||||||
* @returns vscode.Terminal
|
|
||||||
*/
|
|
||||||
function createNewOpenHandsTerminal(): vscode.Terminal {
|
|
||||||
const timestamp = new Date().toLocaleTimeString("en-US", {
|
|
||||||
hour12: false,
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
const terminalName = `OpenHands ${timestamp}`;
|
|
||||||
return vscode.window.createTerminal(terminalName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds an existing OpenHands terminal or creates a new one using safe detection
|
|
||||||
* @returns vscode.Terminal
|
|
||||||
*/
|
|
||||||
function findOrCreateOpenHandsTerminal(): vscode.Terminal {
|
|
||||||
const openHandsTerminals = vscode.window.terminals.filter((terminal) =>
|
|
||||||
terminal.name.startsWith("OpenHands"),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (openHandsTerminals.length > 0) {
|
|
||||||
// Use the most recent terminal, but only if we know it's idle
|
|
||||||
const terminal = openHandsTerminals[openHandsTerminals.length - 1];
|
|
||||||
|
|
||||||
// Only reuse terminals that we know are idle (safe to reuse)
|
|
||||||
if (isKnownIdleTerminal(terminal)) {
|
|
||||||
return terminal;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we don't know the terminal is idle, create a new one to avoid interrupting running processes
|
|
||||||
return createNewOpenHandsTerminal();
|
|
||||||
}
|
|
||||||
|
|
||||||
// No existing terminals, create new one
|
|
||||||
return createNewOpenHandsTerminal();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Executes an OpenHands command using Shell Integration when available
|
|
||||||
* @param terminal The terminal to execute the command in
|
|
||||||
* @param command The command to execute
|
|
||||||
*/
|
|
||||||
function executeOpenHandsCommand(
|
|
||||||
terminal: vscode.Terminal,
|
|
||||||
command: string,
|
|
||||||
): void {
|
|
||||||
// Mark terminal as busy when we start a command
|
|
||||||
markTerminalAsBusy(terminal.name);
|
|
||||||
|
|
||||||
if (terminal.shellIntegration) {
|
|
||||||
// Use Shell Integration for better control
|
|
||||||
const execution = terminal.shellIntegration.executeCommand(command);
|
|
||||||
|
|
||||||
// Monitor execution completion
|
|
||||||
const disposable = vscode.window.onDidEndTerminalShellExecution((event) => {
|
|
||||||
if (event.execution === execution) {
|
|
||||||
if (event.exitCode === 0) {
|
|
||||||
outputChannel.appendLine(
|
|
||||||
"DEBUG: OpenHands command completed successfully",
|
|
||||||
);
|
|
||||||
// Mark terminal as idle when command completes successfully
|
|
||||||
markTerminalAsIdle(terminal.name);
|
|
||||||
} else if (event.exitCode !== undefined) {
|
|
||||||
outputChannel.appendLine(
|
|
||||||
`DEBUG: OpenHands command exited with code ${event.exitCode}`,
|
|
||||||
);
|
|
||||||
// Mark terminal as idle even if command failed (user can reuse it)
|
|
||||||
markTerminalAsIdle(terminal.name);
|
|
||||||
}
|
|
||||||
disposable.dispose(); // Clean up the event listener
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Fallback to traditional sendText
|
|
||||||
terminal.sendText(command, true);
|
|
||||||
// For traditional sendText, we can't track completion, so don't mark as idle
|
|
||||||
// This means terminals without Shell Integration won't be reused, which is safer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detects and builds virtual environment activation command
|
|
||||||
* @returns string The activation command prefix (empty if no venv found)
|
|
||||||
*/
|
|
||||||
function detectVirtualEnvironment(): string {
|
|
||||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
|
||||||
if (!workspaceFolder) {
|
|
||||||
outputChannel.appendLine("DEBUG: No workspace folder found");
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const venvPaths = [".venv", "venv", ".virtualenv"];
|
|
||||||
for (const venvPath of venvPaths) {
|
|
||||||
const venvFullPath = path.join(workspaceFolder.uri.fsPath, venvPath);
|
|
||||||
if (fs.existsSync(venvFullPath)) {
|
|
||||||
outputChannel.appendLine(`DEBUG: Found venv at ${venvFullPath}`);
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
// For Windows, the activation command is different and typically doesn't use 'source'
|
|
||||||
// It's often a script that needs to be executed.
|
|
||||||
// This is a simplified version. A more robust solution might need to check for PowerShell, cmd, etc.
|
|
||||||
return `& "${path.join(venvFullPath, "Scripts", "Activate.ps1")}" && `;
|
|
||||||
}
|
|
||||||
// For POSIX-like shells
|
|
||||||
return `source "${path.join(venvFullPath, "bin", "activate")}" && `;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
outputChannel.appendLine(
|
|
||||||
`DEBUG: No venv found in workspace ${workspaceFolder.uri.fsPath}`,
|
|
||||||
);
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a contextual task message for file content
|
|
||||||
* @param filePath The file path (or "Untitled" for unsaved files)
|
|
||||||
* @param content The file content
|
|
||||||
* @param languageId The programming language ID
|
|
||||||
* @returns string A descriptive task message
|
|
||||||
*/
|
|
||||||
function createFileContextMessage(
|
|
||||||
filePath: string,
|
|
||||||
content: string,
|
|
||||||
languageId?: string,
|
|
||||||
): string {
|
|
||||||
const fileName =
|
|
||||||
filePath === "Untitled" ? "an untitled file" : `file ${filePath}`;
|
|
||||||
const langInfo = languageId ? ` (${languageId})` : "";
|
|
||||||
|
|
||||||
return `User opened ${fileName}${langInfo}. Here's the content:
|
|
||||||
|
|
||||||
\`\`\`${languageId || ""}
|
|
||||||
${content}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Please ask the user what they want to do with this file.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a contextual task message for selected text
|
|
||||||
* @param filePath The file path (or "Untitled" for unsaved files)
|
|
||||||
* @param content The selected content
|
|
||||||
* @param startLine 1-based start line number
|
|
||||||
* @param endLine 1-based end line number
|
|
||||||
* @param languageId The programming language ID
|
|
||||||
* @returns string A descriptive task message
|
|
||||||
*/
|
|
||||||
function createSelectionContextMessage(
|
|
||||||
filePath: string,
|
|
||||||
content: string,
|
|
||||||
startLine: number,
|
|
||||||
endLine: number,
|
|
||||||
languageId?: string,
|
|
||||||
): string {
|
|
||||||
const fileName =
|
|
||||||
filePath === "Untitled" ? "an untitled file" : `file ${filePath}`;
|
|
||||||
const langInfo = languageId ? ` (${languageId})` : "";
|
|
||||||
const lineInfo =
|
|
||||||
startLine === endLine
|
|
||||||
? `line ${startLine}`
|
|
||||||
: `lines ${startLine}-${endLine}`;
|
|
||||||
|
|
||||||
return `User selected ${lineInfo} in ${fileName}${langInfo}. Here's the selected content:
|
|
||||||
|
|
||||||
\`\`\`${languageId || ""}
|
|
||||||
${content}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Please ask the user what they want to do with this selection.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the OpenHands command with proper sanitization
|
|
||||||
* @param options Command options
|
|
||||||
* @param activationCommand Virtual environment activation prefix
|
|
||||||
* @returns string The complete command to execute
|
|
||||||
*/
|
|
||||||
function buildOpenHandsCommand(
|
|
||||||
options: { task?: string; filePath?: string },
|
|
||||||
activationCommand: string,
|
|
||||||
): string {
|
|
||||||
let commandToSend = `${activationCommand}openhands`;
|
|
||||||
|
|
||||||
if (options.filePath) {
|
|
||||||
// Ensure filePath is properly quoted if it contains spaces or special characters
|
|
||||||
const safeFilePath = options.filePath.includes(" ")
|
|
||||||
? `"${options.filePath}"`
|
|
||||||
: options.filePath;
|
|
||||||
commandToSend = `${activationCommand}openhands --file ${safeFilePath}`;
|
|
||||||
} else if (options.task) {
|
|
||||||
// Sanitize task string for command line (basic sanitization)
|
|
||||||
// Replace backticks and double quotes that might break the command
|
|
||||||
const sanitizedTask = options.task
|
|
||||||
.replace(/`/g, "\\`")
|
|
||||||
.replace(/"/g, '\\"');
|
|
||||||
commandToSend = `${activationCommand}openhands --task "${sanitizedTask}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return commandToSend;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main function to start OpenHands in terminal with safe terminal reuse
|
|
||||||
* @param options Command options
|
|
||||||
*/
|
|
||||||
function startOpenHandsInTerminal(options: {
|
|
||||||
task?: string;
|
|
||||||
filePath?: string;
|
|
||||||
}): void {
|
|
||||||
try {
|
|
||||||
// Find or create terminal using safe detection
|
|
||||||
const terminal = findOrCreateOpenHandsTerminal();
|
|
||||||
terminal.show(true); // true to preserve focus on the editor
|
|
||||||
|
|
||||||
// Detect virtual environment
|
|
||||||
const activationCommand = detectVirtualEnvironment();
|
|
||||||
|
|
||||||
// Build command
|
|
||||||
const commandToSend = buildOpenHandsCommand(options, activationCommand);
|
|
||||||
|
|
||||||
// Debug: show the actual command being sent
|
|
||||||
outputChannel.appendLine(`DEBUG: Sending command: ${commandToSend}`);
|
|
||||||
|
|
||||||
// Execute command using Shell Integration when available
|
|
||||||
executeOpenHandsCommand(terminal, commandToSend);
|
|
||||||
} catch (error) {
|
|
||||||
vscode.window.showErrorMessage(`Error starting OpenHands: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext) {
|
|
||||||
// Clean up terminal tracking when terminals are closed
|
|
||||||
const terminalCloseDisposable = vscode.window.onDidCloseTerminal(
|
|
||||||
(terminal) => {
|
|
||||||
idleTerminals.delete(terminal.name);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
context.subscriptions.push(terminalCloseDisposable);
|
|
||||||
|
|
||||||
// Command: Start New Conversation
|
|
||||||
const startConversationDisposable = vscode.commands.registerCommand(
|
|
||||||
"openhands.startConversation",
|
|
||||||
() => {
|
|
||||||
startOpenHandsInTerminal({});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
context.subscriptions.push(startConversationDisposable);
|
|
||||||
|
|
||||||
// Command: Start Conversation with Active File Content
|
|
||||||
const startWithFileContextDisposable = vscode.commands.registerCommand(
|
|
||||||
"openhands.startConversationWithFileContext",
|
|
||||||
() => {
|
|
||||||
const editor = vscode.window.activeTextEditor;
|
|
||||||
if (!editor) {
|
|
||||||
// No active editor, start conversation without task
|
|
||||||
startOpenHandsInTerminal({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editor.document.isUntitled) {
|
|
||||||
const fileContent = editor.document.getText();
|
|
||||||
if (!fileContent.trim()) {
|
|
||||||
// Empty untitled file, start conversation without task
|
|
||||||
startOpenHandsInTerminal({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Create contextual message for untitled file
|
|
||||||
const contextualTask = createFileContextMessage(
|
|
||||||
"Untitled",
|
|
||||||
fileContent,
|
|
||||||
editor.document.languageId,
|
|
||||||
);
|
|
||||||
startOpenHandsInTerminal({ task: contextualTask });
|
|
||||||
} else {
|
|
||||||
const filePath = editor.document.uri.fsPath;
|
|
||||||
// For saved files, we can still use --file flag for better performance,
|
|
||||||
// but we could also create a contextual message if preferred
|
|
||||||
startOpenHandsInTerminal({ filePath });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
context.subscriptions.push(startWithFileContextDisposable);
|
|
||||||
|
|
||||||
// Command: Start Conversation with Selected Text
|
|
||||||
const startWithSelectionContextDisposable = vscode.commands.registerCommand(
|
|
||||||
"openhands.startConversationWithSelectionContext",
|
|
||||||
() => {
|
|
||||||
outputChannel.appendLine(
|
|
||||||
"DEBUG: startConversationWithSelectionContext command triggered!",
|
|
||||||
);
|
|
||||||
const editor = vscode.window.activeTextEditor;
|
|
||||||
if (!editor) {
|
|
||||||
// No active editor, start conversation without task
|
|
||||||
startOpenHandsInTerminal({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (editor.selection.isEmpty) {
|
|
||||||
// No text selected, start conversation without task
|
|
||||||
startOpenHandsInTerminal({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedText = editor.document.getText(editor.selection);
|
|
||||||
const startLine = editor.selection.start.line + 1; // Convert to 1-based
|
|
||||||
const endLine = editor.selection.end.line + 1; // Convert to 1-based
|
|
||||||
const filePath = editor.document.isUntitled
|
|
||||||
? "Untitled"
|
|
||||||
: editor.document.uri.fsPath;
|
|
||||||
|
|
||||||
// Create contextual message with line numbers and file info
|
|
||||||
const contextualTask = createSelectionContextMessage(
|
|
||||||
filePath,
|
|
||||||
selectedText,
|
|
||||||
startLine,
|
|
||||||
endLine,
|
|
||||||
editor.document.languageId,
|
|
||||||
);
|
|
||||||
|
|
||||||
startOpenHandsInTerminal({ task: contextualTask });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
context.subscriptions.push(startWithSelectionContextDisposable);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deactivate() {
|
|
||||||
// Clean up resources if needed, though for this simple extension,
|
|
||||||
// VS Code handles terminal disposal.
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import * as path from "path";
|
|
||||||
import { runTests } from "@vscode/test-electron";
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
try {
|
|
||||||
// The folder containing the Extension Manifest package.json
|
|
||||||
// Passed to `--extensionDevelopmentPath`
|
|
||||||
const extensionDevelopmentPath = path.resolve(__dirname, "../../../");
|
|
||||||
|
|
||||||
// The path to the extension test script
|
|
||||||
// Passed to --extensionTestsPath
|
|
||||||
const extensionTestsPath = path.resolve(__dirname, "./suite/index"); // Points to the compiled version of suite/index.ts
|
|
||||||
|
|
||||||
// Download VS Code, unzip it and run the integration test
|
|
||||||
await runTests({ extensionDevelopmentPath, extensionTestsPath });
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to run tests");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,848 +0,0 @@
|
|||||||
import * as assert from "assert";
|
|
||||||
import * as vscode from "vscode";
|
|
||||||
|
|
||||||
suite("Extension Test Suite", () => {
|
|
||||||
let mockTerminal: vscode.Terminal;
|
|
||||||
let sendTextSpy: any; // Manual spy, using 'any' type
|
|
||||||
let showSpy: any; // Manual spy
|
|
||||||
let createTerminalStub: any; // Manual stub
|
|
||||||
let findTerminalStub: any; // Manual spy
|
|
||||||
let showErrorMessageSpy: any; // Manual spy
|
|
||||||
|
|
||||||
// It's better to use a proper mocking library like Sinon.JS for spies and stubs.
|
|
||||||
// For now, we'll use a simplified manual approach for spies.
|
|
||||||
const createManualSpy = () => {
|
|
||||||
const spy: any = (...args: any[]) => {
|
|
||||||
// eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
spy.called = true;
|
|
||||||
spy.callCount = (spy.callCount || 0) + 1;
|
|
||||||
spy.lastArgs = args;
|
|
||||||
spy.argsHistory = spy.argsHistory || [];
|
|
||||||
spy.argsHistory.push(args);
|
|
||||||
};
|
|
||||||
spy.called = false;
|
|
||||||
spy.callCount = 0;
|
|
||||||
spy.lastArgs = null;
|
|
||||||
spy.argsHistory = [];
|
|
||||||
spy.resetHistory = () => {
|
|
||||||
spy.called = false;
|
|
||||||
spy.callCount = 0;
|
|
||||||
spy.lastArgs = null;
|
|
||||||
spy.argsHistory = [];
|
|
||||||
};
|
|
||||||
return spy;
|
|
||||||
};
|
|
||||||
|
|
||||||
setup(() => {
|
|
||||||
// Reset spies and stubs before each test
|
|
||||||
sendTextSpy = createManualSpy();
|
|
||||||
showSpy = createManualSpy();
|
|
||||||
showErrorMessageSpy = createManualSpy();
|
|
||||||
|
|
||||||
mockTerminal = {
|
|
||||||
name: "OpenHands",
|
|
||||||
processId: Promise.resolve(123),
|
|
||||||
sendText: sendTextSpy as any,
|
|
||||||
show: showSpy as any,
|
|
||||||
hide: () => {},
|
|
||||||
dispose: () => {},
|
|
||||||
creationOptions: {},
|
|
||||||
exitStatus: undefined, // Added to satisfy Terminal interface
|
|
||||||
state: {
|
|
||||||
isInteractedWith: false,
|
|
||||||
shell: undefined as string | undefined,
|
|
||||||
}, // Added shell property
|
|
||||||
shellIntegration: undefined, // No Shell Integration in tests by default
|
|
||||||
};
|
|
||||||
|
|
||||||
// Store original functions
|
|
||||||
const _originalCreateTerminal = vscode.window.createTerminal;
|
|
||||||
const _originalTerminalsDescriptor = Object.getOwnPropertyDescriptor(
|
|
||||||
vscode.window,
|
|
||||||
"terminals",
|
|
||||||
);
|
|
||||||
const _originalShowErrorMessage = vscode.window.showErrorMessage;
|
|
||||||
|
|
||||||
// Stub vscode.window.createTerminal
|
|
||||||
createTerminalStub = createManualSpy();
|
|
||||||
vscode.window.createTerminal = (...args: any[]): vscode.Terminal => {
|
|
||||||
createTerminalStub(...args); // Call the spy with whatever arguments it received
|
|
||||||
return mockTerminal; // Return the mock terminal
|
|
||||||
};
|
|
||||||
|
|
||||||
// Stub vscode.window.terminals
|
|
||||||
findTerminalStub = createManualSpy(); // To track if vscode.window.terminals getter is accessed
|
|
||||||
Object.defineProperty(vscode.window, "terminals", {
|
|
||||||
get: () => {
|
|
||||||
findTerminalStub();
|
|
||||||
// Default to returning the mockTerminal, can be overridden in specific tests
|
|
||||||
return [mockTerminal];
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
vscode.window.showErrorMessage = showErrorMessageSpy as any;
|
|
||||||
|
|
||||||
// Restore default mock behavior before each test
|
|
||||||
setup(() => {
|
|
||||||
// Reset spies
|
|
||||||
createTerminalStub.resetHistory();
|
|
||||||
sendTextSpy.resetHistory();
|
|
||||||
showSpy.resetHistory();
|
|
||||||
findTerminalStub.resetHistory();
|
|
||||||
showErrorMessageSpy.resetHistory();
|
|
||||||
|
|
||||||
// Restore default createTerminal mock
|
|
||||||
vscode.window.createTerminal = (...args: any[]): vscode.Terminal => {
|
|
||||||
createTerminalStub(...args);
|
|
||||||
return mockTerminal; // Return the default mock terminal (no Shell Integration)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Restore default terminals mock
|
|
||||||
Object.defineProperty(vscode.window, "terminals", {
|
|
||||||
get: () => {
|
|
||||||
findTerminalStub();
|
|
||||||
return [mockTerminal]; // Default to returning the mockTerminal
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Teardown logic to restore original functions
|
|
||||||
teardown(() => {
|
|
||||||
vscode.window.createTerminal = _originalCreateTerminal;
|
|
||||||
if (_originalTerminalsDescriptor) {
|
|
||||||
Object.defineProperty(
|
|
||||||
vscode.window,
|
|
||||||
"terminals",
|
|
||||||
_originalTerminalsDescriptor,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// If it wasn't originally defined, delete it to restore to that state
|
|
||||||
delete (vscode.window as any).terminals;
|
|
||||||
}
|
|
||||||
vscode.window.showErrorMessage = _originalShowErrorMessage;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Extension should be present and activate", async () => {
|
|
||||||
const extension = vscode.extensions.getExtension(
|
|
||||||
"openhands.openhands-vscode",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
extension,
|
|
||||||
"Extension should be found (check publisher.name in package.json)",
|
|
||||||
);
|
|
||||||
if (!extension.isActive) {
|
|
||||||
await extension.activate();
|
|
||||||
}
|
|
||||||
assert.ok(extension.isActive, "Extension should be active");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Commands should be registered", async () => {
|
|
||||||
const extension = vscode.extensions.getExtension(
|
|
||||||
"openhands.openhands-vscode",
|
|
||||||
);
|
|
||||||
if (extension && !extension.isActive) {
|
|
||||||
await extension.activate();
|
|
||||||
}
|
|
||||||
const commands = await vscode.commands.getCommands(true);
|
|
||||||
const expectedCommands = [
|
|
||||||
"openhands.startConversation",
|
|
||||||
"openhands.startConversationWithFileContext",
|
|
||||||
"openhands.startConversationWithSelectionContext",
|
|
||||||
];
|
|
||||||
for (const cmd of expectedCommands) {
|
|
||||||
assert.ok(
|
|
||||||
commands.includes(cmd),
|
|
||||||
`Command '${cmd}' should be registered`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("openhands.startConversation should send correct command to terminal", async () => {
|
|
||||||
findTerminalStub.resetHistory(); // Reset for this specific test path if needed
|
|
||||||
Object.defineProperty(vscode.window, "terminals", {
|
|
||||||
get: () => {
|
|
||||||
findTerminalStub();
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
}); // Simulate no existing terminal
|
|
||||||
|
|
||||||
await vscode.commands.executeCommand("openhands.startConversation");
|
|
||||||
|
|
||||||
assert.ok(
|
|
||||||
createTerminalStub.called,
|
|
||||||
"vscode.window.createTerminal should be called",
|
|
||||||
);
|
|
||||||
assert.ok(showSpy.called, "terminal.show should be called");
|
|
||||||
assert.deepStrictEqual(
|
|
||||||
sendTextSpy.lastArgs,
|
|
||||||
["openhands", true],
|
|
||||||
"Correct command sent to terminal",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("openhands.startConversationWithFileContext (saved file) should send --file command", async () => {
|
|
||||||
const testFilePath = "/test/file.py";
|
|
||||||
// Mock activeTextEditor for a saved file
|
|
||||||
const originalActiveTextEditor = Object.getOwnPropertyDescriptor(
|
|
||||||
vscode.window,
|
|
||||||
"activeTextEditor",
|
|
||||||
);
|
|
||||||
Object.defineProperty(vscode.window, "activeTextEditor", {
|
|
||||||
get: () => ({
|
|
||||||
document: {
|
|
||||||
isUntitled: false,
|
|
||||||
uri: vscode.Uri.file(testFilePath),
|
|
||||||
fsPath: testFilePath, // fsPath is often used
|
|
||||||
getText: () => "file content", // Not used for saved files but good to have
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await vscode.commands.executeCommand(
|
|
||||||
"openhands.startConversationWithFileContext",
|
|
||||||
);
|
|
||||||
assert.ok(sendTextSpy.called, "terminal.sendText should be called");
|
|
||||||
assert.deepStrictEqual(sendTextSpy.lastArgs, [
|
|
||||||
`openhands --file ${testFilePath.includes(" ") ? `"${testFilePath}"` : testFilePath}`,
|
|
||||||
true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Restore activeTextEditor
|
|
||||||
if (originalActiveTextEditor) {
|
|
||||||
Object.defineProperty(
|
|
||||||
vscode.window,
|
|
||||||
"activeTextEditor",
|
|
||||||
originalActiveTextEditor,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("openhands.startConversationWithFileContext (untitled file) should send contextual --task command", async () => {
|
|
||||||
const untitledFileContent = "untitled content";
|
|
||||||
const languageId = "javascript";
|
|
||||||
const originalActiveTextEditor = Object.getOwnPropertyDescriptor(
|
|
||||||
vscode.window,
|
|
||||||
"activeTextEditor",
|
|
||||||
);
|
|
||||||
Object.defineProperty(vscode.window, "activeTextEditor", {
|
|
||||||
get: () => ({
|
|
||||||
document: {
|
|
||||||
isUntitled: true,
|
|
||||||
uri: vscode.Uri.parse("untitled:Untitled-1"),
|
|
||||||
getText: () => untitledFileContent,
|
|
||||||
languageId,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await vscode.commands.executeCommand(
|
|
||||||
"openhands.startConversationWithFileContext",
|
|
||||||
);
|
|
||||||
assert.ok(sendTextSpy.called, "terminal.sendText should be called");
|
|
||||||
|
|
||||||
// Check that the command contains the contextual message
|
|
||||||
const expectedMessage = `User opened an untitled file (${languageId}). Here's the content:
|
|
||||||
|
|
||||||
\`\`\`${languageId}
|
|
||||||
${untitledFileContent}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Please ask the user what they want to do with this file.`;
|
|
||||||
|
|
||||||
// Apply the same sanitization as the actual implementation
|
|
||||||
const sanitizedMessage = expectedMessage
|
|
||||||
.replace(/`/g, "\\`")
|
|
||||||
.replace(/"/g, '\\"');
|
|
||||||
|
|
||||||
assert.deepStrictEqual(sendTextSpy.lastArgs, [
|
|
||||||
`openhands --task "${sanitizedMessage}"`,
|
|
||||||
true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (originalActiveTextEditor) {
|
|
||||||
Object.defineProperty(
|
|
||||||
vscode.window,
|
|
||||||
"activeTextEditor",
|
|
||||||
originalActiveTextEditor,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("openhands.startConversationWithFileContext (no editor) should start conversation without context", async () => {
|
|
||||||
const originalActiveTextEditor = Object.getOwnPropertyDescriptor(
|
|
||||||
vscode.window,
|
|
||||||
"activeTextEditor",
|
|
||||||
);
|
|
||||||
Object.defineProperty(vscode.window, "activeTextEditor", {
|
|
||||||
get: () => undefined,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await vscode.commands.executeCommand(
|
|
||||||
"openhands.startConversationWithFileContext",
|
|
||||||
);
|
|
||||||
assert.ok(sendTextSpy.called, "terminal.sendText should be called");
|
|
||||||
assert.deepStrictEqual(sendTextSpy.lastArgs, ["openhands", true]);
|
|
||||||
|
|
||||||
if (originalActiveTextEditor) {
|
|
||||||
Object.defineProperty(
|
|
||||||
vscode.window,
|
|
||||||
"activeTextEditor",
|
|
||||||
originalActiveTextEditor,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("openhands.startConversationWithSelectionContext should send contextual --task with selection", async () => {
|
|
||||||
const selectedText = "selected text for openhands";
|
|
||||||
const filePath = "/test/file.py";
|
|
||||||
const languageId = "python";
|
|
||||||
const originalActiveTextEditor = Object.getOwnPropertyDescriptor(
|
|
||||||
vscode.window,
|
|
||||||
"activeTextEditor",
|
|
||||||
);
|
|
||||||
Object.defineProperty(vscode.window, "activeTextEditor", {
|
|
||||||
get: () => ({
|
|
||||||
document: {
|
|
||||||
isUntitled: false,
|
|
||||||
uri: vscode.Uri.file(filePath),
|
|
||||||
fsPath: filePath,
|
|
||||||
languageId,
|
|
||||||
getText: (selection?: vscode.Selection) =>
|
|
||||||
selection ? selectedText : "full content",
|
|
||||||
},
|
|
||||||
selection: {
|
|
||||||
isEmpty: false,
|
|
||||||
active: new vscode.Position(0, 0),
|
|
||||||
anchor: new vscode.Position(0, 0),
|
|
||||||
start: new vscode.Position(0, 0), // Line 0 (0-based)
|
|
||||||
end: new vscode.Position(0, 10), // Line 0 (0-based)
|
|
||||||
} as vscode.Selection, // Mock non-empty selection on line 1
|
|
||||||
}),
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await vscode.commands.executeCommand(
|
|
||||||
"openhands.startConversationWithSelectionContext",
|
|
||||||
);
|
|
||||||
assert.ok(sendTextSpy.called, "terminal.sendText should be called");
|
|
||||||
|
|
||||||
// Check that the command contains the contextual message with line numbers
|
|
||||||
const expectedMessage = `User selected line 1 in file ${filePath} (${languageId}). Here's the selected content:
|
|
||||||
|
|
||||||
\`\`\`${languageId}
|
|
||||||
${selectedText}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Please ask the user what they want to do with this selection.`;
|
|
||||||
|
|
||||||
// Apply the same sanitization as the actual implementation
|
|
||||||
const sanitizedMessage = expectedMessage
|
|
||||||
.replace(/`/g, "\\`")
|
|
||||||
.replace(/"/g, '\\"');
|
|
||||||
|
|
||||||
assert.deepStrictEqual(sendTextSpy.lastArgs, [
|
|
||||||
`openhands --task "${sanitizedMessage}"`,
|
|
||||||
true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (originalActiveTextEditor) {
|
|
||||||
Object.defineProperty(
|
|
||||||
vscode.window,
|
|
||||||
"activeTextEditor",
|
|
||||||
originalActiveTextEditor,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("openhands.startConversationWithSelectionContext (no selection) should start conversation without context", async () => {
|
|
||||||
const originalActiveTextEditor = Object.getOwnPropertyDescriptor(
|
|
||||||
vscode.window,
|
|
||||||
"activeTextEditor",
|
|
||||||
);
|
|
||||||
Object.defineProperty(vscode.window, "activeTextEditor", {
|
|
||||||
get: () => ({
|
|
||||||
document: {
|
|
||||||
isUntitled: false,
|
|
||||||
uri: vscode.Uri.file("/test/file.py"),
|
|
||||||
getText: () => "full content",
|
|
||||||
},
|
|
||||||
selection: { isEmpty: true } as vscode.Selection, // Mock empty selection
|
|
||||||
}),
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await vscode.commands.executeCommand(
|
|
||||||
"openhands.startConversationWithSelectionContext",
|
|
||||||
);
|
|
||||||
assert.ok(sendTextSpy.called, "terminal.sendText should be called");
|
|
||||||
assert.deepStrictEqual(sendTextSpy.lastArgs, ["openhands", true]);
|
|
||||||
|
|
||||||
if (originalActiveTextEditor) {
|
|
||||||
Object.defineProperty(
|
|
||||||
vscode.window,
|
|
||||||
"activeTextEditor",
|
|
||||||
originalActiveTextEditor,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("openhands.startConversationWithSelectionContext should handle multi-line selections", async () => {
|
|
||||||
const selectedText = "line 1\nline 2\nline 3";
|
|
||||||
const filePath = "/test/multiline.js";
|
|
||||||
const languageId = "javascript";
|
|
||||||
const originalActiveTextEditor = Object.getOwnPropertyDescriptor(
|
|
||||||
vscode.window,
|
|
||||||
"activeTextEditor",
|
|
||||||
);
|
|
||||||
Object.defineProperty(vscode.window, "activeTextEditor", {
|
|
||||||
get: () => ({
|
|
||||||
document: {
|
|
||||||
isUntitled: false,
|
|
||||||
uri: vscode.Uri.file(filePath),
|
|
||||||
fsPath: filePath,
|
|
||||||
languageId,
|
|
||||||
getText: (selection?: vscode.Selection) =>
|
|
||||||
selection ? selectedText : "full content",
|
|
||||||
},
|
|
||||||
selection: {
|
|
||||||
isEmpty: false,
|
|
||||||
active: new vscode.Position(4, 0),
|
|
||||||
anchor: new vscode.Position(4, 0),
|
|
||||||
start: new vscode.Position(4, 0), // Line 4 (0-based) = Line 5 (1-based)
|
|
||||||
end: new vscode.Position(6, 10), // Line 6 (0-based) = Line 7 (1-based)
|
|
||||||
} as vscode.Selection, // Mock multi-line selection from line 5 to 7
|
|
||||||
}),
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await vscode.commands.executeCommand(
|
|
||||||
"openhands.startConversationWithSelectionContext",
|
|
||||||
);
|
|
||||||
assert.ok(sendTextSpy.called, "terminal.sendText should be called");
|
|
||||||
|
|
||||||
// Check that the command contains the contextual message with line range
|
|
||||||
const expectedMessage = `User selected lines 5-7 in file ${filePath} (${languageId}). Here's the selected content:
|
|
||||||
|
|
||||||
\`\`\`${languageId}
|
|
||||||
${selectedText}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Please ask the user what they want to do with this selection.`;
|
|
||||||
|
|
||||||
// Apply the same sanitization as the actual implementation
|
|
||||||
const sanitizedMessage = expectedMessage
|
|
||||||
.replace(/`/g, "\\`")
|
|
||||||
.replace(/"/g, '\\"');
|
|
||||||
|
|
||||||
assert.deepStrictEqual(sendTextSpy.lastArgs, [
|
|
||||||
`openhands --task "${sanitizedMessage}"`,
|
|
||||||
true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (originalActiveTextEditor) {
|
|
||||||
Object.defineProperty(
|
|
||||||
vscode.window,
|
|
||||||
"activeTextEditor",
|
|
||||||
originalActiveTextEditor,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Terminal reuse should work when existing OpenHands terminal exists", async () => {
|
|
||||||
// Create a mock existing terminal
|
|
||||||
const existingTerminal = {
|
|
||||||
name: "OpenHands 10:30:15",
|
|
||||||
processId: Promise.resolve(456),
|
|
||||||
sendText: sendTextSpy as any,
|
|
||||||
show: showSpy as any,
|
|
||||||
hide: () => {},
|
|
||||||
dispose: () => {},
|
|
||||||
creationOptions: {},
|
|
||||||
exitStatus: undefined,
|
|
||||||
state: {
|
|
||||||
isInteractedWith: false,
|
|
||||||
shell: undefined as string | undefined,
|
|
||||||
},
|
|
||||||
shellIntegration: undefined, // No Shell Integration, should create new terminal
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock terminals array to return existing terminal
|
|
||||||
Object.defineProperty(vscode.window, "terminals", {
|
|
||||||
get: () => {
|
|
||||||
findTerminalStub();
|
|
||||||
return [existingTerminal];
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await vscode.commands.executeCommand("openhands.startConversation");
|
|
||||||
|
|
||||||
// Should create new terminal since no Shell Integration
|
|
||||||
assert.ok(
|
|
||||||
createTerminalStub.called,
|
|
||||||
"Should create new terminal when no Shell Integration available",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Terminal reuse with Shell Integration should reuse existing terminal", async () => {
|
|
||||||
// Create mock Shell Integration
|
|
||||||
const mockExecution = {
|
|
||||||
read: () => ({
|
|
||||||
async *[Symbol.asyncIterator]() {
|
|
||||||
yield "OPENHANDS_PROBE_123456789";
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
exitCode: Promise.resolve(0),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockShellIntegration = {
|
|
||||||
executeCommand: () => mockExecution,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create a mock existing terminal with Shell Integration
|
|
||||||
const existingTerminalWithShell = {
|
|
||||||
name: "OpenHands 10:30:15",
|
|
||||||
processId: Promise.resolve(456),
|
|
||||||
sendText: sendTextSpy as any,
|
|
||||||
show: showSpy as any,
|
|
||||||
hide: () => {},
|
|
||||||
dispose: () => {},
|
|
||||||
creationOptions: {},
|
|
||||||
exitStatus: undefined,
|
|
||||||
state: {
|
|
||||||
isInteractedWith: false,
|
|
||||||
shell: undefined as string | undefined,
|
|
||||||
},
|
|
||||||
shellIntegration: mockShellIntegration,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock terminals array to return existing terminal with Shell Integration
|
|
||||||
Object.defineProperty(vscode.window, "terminals", {
|
|
||||||
get: () => {
|
|
||||||
findTerminalStub();
|
|
||||||
return [existingTerminalWithShell];
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset create terminal stub to track if new terminal is created
|
|
||||||
createTerminalStub.resetHistory();
|
|
||||||
|
|
||||||
await vscode.commands.executeCommand("openhands.startConversation");
|
|
||||||
|
|
||||||
// Should reuse existing terminal since Shell Integration is available
|
|
||||||
// Note: The probe might timeout in test environment, but it should still reuse the terminal
|
|
||||||
assert.ok(showSpy.called, "terminal.show should be called");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Shell Integration should use executeCommand for OpenHands commands", async () => {
|
|
||||||
const executeCommandSpy = createManualSpy();
|
|
||||||
|
|
||||||
// Mock execution for OpenHands command
|
|
||||||
const mockExecution = {
|
|
||||||
read: () => ({
|
|
||||||
async *[Symbol.asyncIterator]() {
|
|
||||||
yield "OpenHands started successfully";
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
exitCode: Promise.resolve(0),
|
|
||||||
commandLine: {
|
|
||||||
value: "openhands",
|
|
||||||
isTrusted: true,
|
|
||||||
confidence: 2,
|
|
||||||
},
|
|
||||||
cwd: vscode.Uri.file("/test/directory"),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockShellIntegration = {
|
|
||||||
executeCommand: (command: string) => {
|
|
||||||
executeCommandSpy(command);
|
|
||||||
return mockExecution;
|
|
||||||
},
|
|
||||||
cwd: vscode.Uri.file("/test/directory"),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create a terminal with Shell Integration that will be created by createTerminal
|
|
||||||
const terminalWithShell = {
|
|
||||||
name: "OpenHands 10:30:15",
|
|
||||||
processId: Promise.resolve(456),
|
|
||||||
sendText: sendTextSpy as any,
|
|
||||||
show: showSpy as any,
|
|
||||||
hide: () => {},
|
|
||||||
dispose: () => {},
|
|
||||||
creationOptions: {},
|
|
||||||
exitStatus: undefined,
|
|
||||||
state: {
|
|
||||||
isInteractedWith: false,
|
|
||||||
shell: undefined as string | undefined,
|
|
||||||
},
|
|
||||||
shellIntegration: mockShellIntegration,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock createTerminal to return a terminal with Shell Integration
|
|
||||||
createTerminalStub.resetHistory();
|
|
||||||
vscode.window.createTerminal = (...args: any[]): vscode.Terminal => {
|
|
||||||
createTerminalStub(...args);
|
|
||||||
return terminalWithShell; // Return terminal with Shell Integration
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock empty terminals array so we create a new one
|
|
||||||
Object.defineProperty(vscode.window, "terminals", {
|
|
||||||
get: () => {
|
|
||||||
findTerminalStub();
|
|
||||||
return []; // No existing terminals
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await vscode.commands.executeCommand("openhands.startConversation");
|
|
||||||
|
|
||||||
// Should have called executeCommand for OpenHands command
|
|
||||||
assert.ok(
|
|
||||||
executeCommandSpy.called,
|
|
||||||
"Shell Integration executeCommand should be called for OpenHands command",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check that the command was an OpenHands command
|
|
||||||
const openhandsCall = executeCommandSpy.argsHistory.find(
|
|
||||||
(args: any[]) => args[0] && args[0].includes("openhands"),
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
openhandsCall,
|
|
||||||
`Should execute OpenHands command. Actual calls: ${JSON.stringify(executeCommandSpy.argsHistory)}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should create new terminal since none exist
|
|
||||||
assert.ok(
|
|
||||||
createTerminalStub.called,
|
|
||||||
"Should create new terminal when none exist",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Idle terminal tracking should reuse known idle terminals", async () => {
|
|
||||||
const executeCommandSpy = createManualSpy();
|
|
||||||
|
|
||||||
// Mock execution for OpenHands command
|
|
||||||
const mockExecution = {
|
|
||||||
read: () => ({
|
|
||||||
async *[Symbol.asyncIterator]() {
|
|
||||||
yield "OpenHands started successfully";
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
exitCode: Promise.resolve(0),
|
|
||||||
commandLine: {
|
|
||||||
value: "openhands",
|
|
||||||
isTrusted: true,
|
|
||||||
confidence: 2,
|
|
||||||
},
|
|
||||||
cwd: vscode.Uri.file("/test/directory"),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockShellIntegration = {
|
|
||||||
executeCommand: (command: string) => {
|
|
||||||
executeCommandSpy(command);
|
|
||||||
return mockExecution;
|
|
||||||
},
|
|
||||||
cwd: vscode.Uri.file("/test/directory"),
|
|
||||||
};
|
|
||||||
|
|
||||||
const terminalWithShell = {
|
|
||||||
name: "OpenHands 10:30:15",
|
|
||||||
processId: Promise.resolve(456),
|
|
||||||
sendText: sendTextSpy as any,
|
|
||||||
show: showSpy as any,
|
|
||||||
hide: () => {},
|
|
||||||
dispose: () => {},
|
|
||||||
creationOptions: {},
|
|
||||||
exitStatus: undefined,
|
|
||||||
state: {
|
|
||||||
isInteractedWith: false,
|
|
||||||
shell: undefined as string | undefined,
|
|
||||||
},
|
|
||||||
shellIntegration: mockShellIntegration,
|
|
||||||
};
|
|
||||||
|
|
||||||
// First, manually mark the terminal as idle (simulating a previous successful command)
|
|
||||||
// We need to access the extension's internal idle tracking
|
|
||||||
// For testing, we'll simulate this by running a command first, then another
|
|
||||||
Object.defineProperty(vscode.window, "terminals", {
|
|
||||||
get: () => {
|
|
||||||
findTerminalStub();
|
|
||||||
return [terminalWithShell];
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
createTerminalStub.resetHistory();
|
|
||||||
|
|
||||||
// First command to establish the terminal as idle
|
|
||||||
await vscode.commands.executeCommand("openhands.startConversation");
|
|
||||||
|
|
||||||
// Simulate command completion to mark terminal as idle
|
|
||||||
// This would normally happen via the onDidEndTerminalShellExecution event
|
|
||||||
|
|
||||||
createTerminalStub.resetHistory();
|
|
||||||
executeCommandSpy.resetHistory();
|
|
||||||
|
|
||||||
// Second command should reuse the terminal if it's marked as idle
|
|
||||||
await vscode.commands.executeCommand("openhands.startConversation");
|
|
||||||
|
|
||||||
// Should show terminal
|
|
||||||
assert.ok(showSpy.called, "Should show terminal");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Shell Integration should use executeCommand when available", async () => {
|
|
||||||
const executeCommandSpy = createManualSpy();
|
|
||||||
|
|
||||||
const mockExecution = {
|
|
||||||
read: () => ({
|
|
||||||
async *[Symbol.asyncIterator]() {
|
|
||||||
yield "OpenHands started successfully";
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
exitCode: Promise.resolve(0),
|
|
||||||
commandLine: {
|
|
||||||
value: "openhands",
|
|
||||||
isTrusted: true,
|
|
||||||
confidence: 2,
|
|
||||||
},
|
|
||||||
cwd: vscode.Uri.file("/test/directory"),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockShellIntegration = {
|
|
||||||
executeCommand: (command: string) => {
|
|
||||||
executeCommandSpy(command);
|
|
||||||
return mockExecution;
|
|
||||||
},
|
|
||||||
cwd: vscode.Uri.file("/test/directory"),
|
|
||||||
};
|
|
||||||
|
|
||||||
const terminalWithShell = {
|
|
||||||
name: "OpenHands 10:30:15",
|
|
||||||
processId: Promise.resolve(456),
|
|
||||||
sendText: sendTextSpy as any,
|
|
||||||
show: showSpy as any,
|
|
||||||
hide: () => {},
|
|
||||||
dispose: () => {},
|
|
||||||
creationOptions: {},
|
|
||||||
exitStatus: undefined,
|
|
||||||
state: {
|
|
||||||
isInteractedWith: false,
|
|
||||||
shell: undefined as string | undefined,
|
|
||||||
},
|
|
||||||
shellIntegration: mockShellIntegration,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock createTerminal to return a terminal with Shell Integration
|
|
||||||
createTerminalStub.resetHistory();
|
|
||||||
vscode.window.createTerminal = (...args: any[]): vscode.Terminal => {
|
|
||||||
createTerminalStub(...args);
|
|
||||||
return terminalWithShell; // Return terminal with Shell Integration
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock empty terminals array so we create a new one
|
|
||||||
Object.defineProperty(vscode.window, "terminals", {
|
|
||||||
get: () => {
|
|
||||||
findTerminalStub();
|
|
||||||
return []; // No existing terminals
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
sendTextSpy.resetHistory();
|
|
||||||
executeCommandSpy.resetHistory();
|
|
||||||
|
|
||||||
await vscode.commands.executeCommand("openhands.startConversation");
|
|
||||||
|
|
||||||
// Should use Shell Integration executeCommand, not sendText
|
|
||||||
assert.ok(
|
|
||||||
executeCommandSpy.called,
|
|
||||||
"Should use Shell Integration executeCommand",
|
|
||||||
);
|
|
||||||
|
|
||||||
// The OpenHands command should be executed via Shell Integration
|
|
||||||
const openhandsCommand = executeCommandSpy.argsHistory.find(
|
|
||||||
(args: any[]) => args[0] && args[0].includes("openhands"),
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
openhandsCommand,
|
|
||||||
"Should execute OpenHands command via Shell Integration",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Terminal creation should work when no existing terminals", async () => {
|
|
||||||
// Mock empty terminals array
|
|
||||||
Object.defineProperty(vscode.window, "terminals", {
|
|
||||||
get: () => {
|
|
||||||
findTerminalStub();
|
|
||||||
return []; // No existing terminals
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
createTerminalStub.resetHistory();
|
|
||||||
|
|
||||||
await vscode.commands.executeCommand("openhands.startConversation");
|
|
||||||
|
|
||||||
// Should create new terminal when none exist
|
|
||||||
assert.ok(
|
|
||||||
createTerminalStub.called,
|
|
||||||
"Should create new terminal when none exist",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should show the new terminal
|
|
||||||
assert.ok(showSpy.called, "Should show the new terminal");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Shell Integration fallback should work when Shell Integration unavailable", async () => {
|
|
||||||
// Create terminal without Shell Integration
|
|
||||||
const terminalWithoutShell = {
|
|
||||||
name: "OpenHands 10:30:15",
|
|
||||||
processId: Promise.resolve(456),
|
|
||||||
sendText: sendTextSpy as any,
|
|
||||||
show: showSpy as any,
|
|
||||||
hide: () => {},
|
|
||||||
dispose: () => {},
|
|
||||||
creationOptions: {},
|
|
||||||
exitStatus: undefined,
|
|
||||||
state: {
|
|
||||||
isInteractedWith: false,
|
|
||||||
shell: undefined as string | undefined,
|
|
||||||
},
|
|
||||||
shellIntegration: undefined, // No Shell Integration
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.defineProperty(vscode.window, "terminals", {
|
|
||||||
get: () => {
|
|
||||||
findTerminalStub();
|
|
||||||
return [terminalWithoutShell];
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
createTerminalStub.resetHistory();
|
|
||||||
sendTextSpy.resetHistory();
|
|
||||||
|
|
||||||
await vscode.commands.executeCommand("openhands.startConversation");
|
|
||||||
|
|
||||||
// Should create new terminal when no Shell Integration available
|
|
||||||
assert.ok(
|
|
||||||
createTerminalStub.called,
|
|
||||||
"Should create new terminal when Shell Integration unavailable",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should use sendText fallback for the new terminal
|
|
||||||
assert.ok(sendTextSpy.called, "Should use sendText fallback");
|
|
||||||
assert.ok(
|
|
||||||
sendTextSpy.lastArgs[0].includes("openhands"),
|
|
||||||
"Should send OpenHands command",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import * as path from "path";
|
|
||||||
import Mocha = require("mocha");
|
|
||||||
import { glob } from "glob"; // Updated for glob v9+ API
|
|
||||||
|
|
||||||
export async function run(): Promise<void> {
|
|
||||||
// Create the mocha test
|
|
||||||
const mocha = new Mocha({
|
|
||||||
// This should now work with the changed import
|
|
||||||
ui: "tdd", // Use TDD interface
|
|
||||||
color: true, // Colored output
|
|
||||||
timeout: 15000, // Increased timeout for extension tests
|
|
||||||
});
|
|
||||||
|
|
||||||
const testsRoot = path.resolve(__dirname, ".."); // Root of the /src/test folder (compiled to /out/test)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use glob to find all test files (ending with .test.js in the compiled output)
|
|
||||||
const files = await glob("**/**.test.js", { cwd: testsRoot });
|
|
||||||
|
|
||||||
// Add files to the test suite
|
|
||||||
files.forEach((f: string) => mocha.addFile(path.resolve(testsRoot, f)));
|
|
||||||
|
|
||||||
// Run the mocha test
|
|
||||||
return await new Promise<void>((resolve, reject) => {
|
|
||||||
mocha.run((failures: number) => {
|
|
||||||
if (failures > 0) {
|
|
||||||
reject(new Error(`${failures} tests failed.`));
|
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"module": "commonjs",
|
|
||||||
"target": "es2020",
|
|
||||||
"outDir": "out",
|
|
||||||
"lib": [
|
|
||||||
"es2020"
|
|
||||||
],
|
|
||||||
"sourceMap": true,
|
|
||||||
"strict": true,
|
|
||||||
"rootDir": "src",
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true
|
|
||||||
},
|
|
||||||
"exclude": [
|
|
||||||
"node_modules",
|
|
||||||
".vscode-test"
|
|
||||||
],
|
|
||||||
"include": [
|
|
||||||
"src"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -22,7 +22,7 @@ import base64
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import docx
|
import docx
|
||||||
import PyPDF2
|
import pypdf
|
||||||
from pptx import Presentation
|
from pptx import Presentation
|
||||||
from pylatexenc.latex2text import LatexNodes2Text
|
from pylatexenc.latex2text import LatexNodes2Text
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ def parse_pdf(file_path: str) -> None:
|
|||||||
file_path: str: The path to the file to open.
|
file_path: str: The path to the file to open.
|
||||||
"""
|
"""
|
||||||
print(f'[Reading PDF file from {file_path}]')
|
print(f'[Reading PDF file from {file_path}]')
|
||||||
content = PyPDF2.PdfReader(file_path)
|
content = pypdf.PdfReader(file_path)
|
||||||
text = ''
|
text = ''
|
||||||
for page_idx in range(len(content.pages)):
|
for page_idx in range(len(content.pages)):
|
||||||
text += (
|
text += (
|
||||||
|
|||||||
2
poetry.lock
generated
2
poetry.lock
generated
@@ -16824,4 +16824,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.1"
|
lock-version = "2.1"
|
||||||
python-versions = "^3.12,<3.14"
|
python-versions = "^3.12,<3.14"
|
||||||
content-hash = "9360db8d9ee46922f780ac13e2954c0b62166efd9c3d1b3cf61a9228889152fa"
|
content-hash = "ea3a3dcacf87517954778e7b04f0a5865bf213442a7bdbc4f2dc467713dbf82f"
|
||||||
|
|||||||
@@ -19,10 +19,8 @@ packages = [
|
|||||||
{ include = "poetry.lock", to = "openhands" },
|
{ include = "poetry.lock", to = "openhands" },
|
||||||
]
|
]
|
||||||
include = [
|
include = [
|
||||||
"openhands/integrations/vscode/openhands-vscode-0.0.1.vsix",
|
|
||||||
"skills/**/*",
|
"skills/**/*",
|
||||||
]
|
]
|
||||||
build = "build_vscode.py" # Build VSCode extension during Poetry build
|
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.12,<3.14"
|
python = "^3.12,<3.14"
|
||||||
@@ -79,7 +77,6 @@ shellingham = "^1.5.4"
|
|||||||
# TODO: Should these go into the runtime group?
|
# TODO: Should these go into the runtime group?
|
||||||
ipywidgets = "^8.1.5"
|
ipywidgets = "^8.1.5"
|
||||||
qtconsole = "^5.6.1"
|
qtconsole = "^5.6.1"
|
||||||
PyPDF2 = "*"
|
|
||||||
python-pptx = "*"
|
python-pptx = "*"
|
||||||
pylatexenc = "*"
|
pylatexenc = "*"
|
||||||
python-docx = "*"
|
python-docx = "*"
|
||||||
|
|||||||
@@ -444,6 +444,138 @@ class TestDockerSandboxService:
|
|||||||
):
|
):
|
||||||
await service.start_sandbox()
|
await service.start_sandbox()
|
||||||
|
|
||||||
|
@patch('openhands.app_server.sandbox.docker_sandbox_service.base62.encodebytes')
|
||||||
|
@patch('os.urandom')
|
||||||
|
async def test_start_sandbox_with_extra_hosts(
|
||||||
|
self,
|
||||||
|
mock_urandom,
|
||||||
|
mock_encodebytes,
|
||||||
|
mock_sandbox_spec_service,
|
||||||
|
mock_httpx_client,
|
||||||
|
mock_docker_client,
|
||||||
|
):
|
||||||
|
"""Test that extra_hosts are passed to container creation."""
|
||||||
|
# Setup
|
||||||
|
mock_urandom.side_effect = [b'container_id', b'session_key']
|
||||||
|
mock_encodebytes.side_effect = ['test_container_id', 'test_session_key']
|
||||||
|
|
||||||
|
mock_container = MagicMock()
|
||||||
|
mock_container.name = 'oh-test-test_container_id'
|
||||||
|
mock_container.status = 'running'
|
||||||
|
mock_container.image.tags = ['test-image:latest']
|
||||||
|
mock_container.attrs = {
|
||||||
|
'Created': '2024-01-15T10:30:00.000000000Z',
|
||||||
|
'Config': {
|
||||||
|
'Env': ['OH_SESSION_API_KEYS_0=test_session_key', 'TEST_VAR=test_value']
|
||||||
|
},
|
||||||
|
'NetworkSettings': {'Ports': {}},
|
||||||
|
}
|
||||||
|
mock_docker_client.containers.run.return_value = mock_container
|
||||||
|
|
||||||
|
# Create service with extra_hosts
|
||||||
|
service_with_extra_hosts = DockerSandboxService(
|
||||||
|
sandbox_spec_service=mock_sandbox_spec_service,
|
||||||
|
container_name_prefix='oh-test-',
|
||||||
|
host_port=3000,
|
||||||
|
container_url_pattern='http://localhost:{port}',
|
||||||
|
mounts=[],
|
||||||
|
exposed_ports=[
|
||||||
|
ExposedPort(
|
||||||
|
name=AGENT_SERVER, description='Agent server', container_port=8000
|
||||||
|
),
|
||||||
|
],
|
||||||
|
health_check_path='/health',
|
||||||
|
httpx_client=mock_httpx_client,
|
||||||
|
max_num_sandboxes=3,
|
||||||
|
extra_hosts={
|
||||||
|
'host.docker.internal': 'host-gateway',
|
||||||
|
'custom.host': '192.168.1.100',
|
||||||
|
},
|
||||||
|
docker_client=mock_docker_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
service_with_extra_hosts, '_find_unused_port', return_value=12345
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
service_with_extra_hosts, 'pause_old_sandboxes', return_value=[]
|
||||||
|
),
|
||||||
|
):
|
||||||
|
# Execute
|
||||||
|
await service_with_extra_hosts.start_sandbox()
|
||||||
|
|
||||||
|
# Verify extra_hosts was passed to container creation
|
||||||
|
mock_docker_client.containers.run.assert_called_once()
|
||||||
|
call_args = mock_docker_client.containers.run.call_args
|
||||||
|
assert call_args[1]['extra_hosts'] == {
|
||||||
|
'host.docker.internal': 'host-gateway',
|
||||||
|
'custom.host': '192.168.1.100',
|
||||||
|
}
|
||||||
|
|
||||||
|
@patch('openhands.app_server.sandbox.docker_sandbox_service.base62.encodebytes')
|
||||||
|
@patch('os.urandom')
|
||||||
|
async def test_start_sandbox_without_extra_hosts(
|
||||||
|
self,
|
||||||
|
mock_urandom,
|
||||||
|
mock_encodebytes,
|
||||||
|
mock_sandbox_spec_service,
|
||||||
|
mock_httpx_client,
|
||||||
|
mock_docker_client,
|
||||||
|
):
|
||||||
|
"""Test that extra_hosts is None when not configured."""
|
||||||
|
# Setup
|
||||||
|
mock_urandom.side_effect = [b'container_id', b'session_key']
|
||||||
|
mock_encodebytes.side_effect = ['test_container_id', 'test_session_key']
|
||||||
|
|
||||||
|
mock_container = MagicMock()
|
||||||
|
mock_container.name = 'oh-test-test_container_id'
|
||||||
|
mock_container.status = 'running'
|
||||||
|
mock_container.image.tags = ['test-image:latest']
|
||||||
|
mock_container.attrs = {
|
||||||
|
'Created': '2024-01-15T10:30:00.000000000Z',
|
||||||
|
'Config': {
|
||||||
|
'Env': ['OH_SESSION_API_KEYS_0=test_session_key', 'TEST_VAR=test_value']
|
||||||
|
},
|
||||||
|
'NetworkSettings': {'Ports': {}},
|
||||||
|
}
|
||||||
|
mock_docker_client.containers.run.return_value = mock_container
|
||||||
|
|
||||||
|
# Create service without extra_hosts (empty dict)
|
||||||
|
service_without_extra_hosts = DockerSandboxService(
|
||||||
|
sandbox_spec_service=mock_sandbox_spec_service,
|
||||||
|
container_name_prefix='oh-test-',
|
||||||
|
host_port=3000,
|
||||||
|
container_url_pattern='http://localhost:{port}',
|
||||||
|
mounts=[],
|
||||||
|
exposed_ports=[
|
||||||
|
ExposedPort(
|
||||||
|
name=AGENT_SERVER, description='Agent server', container_port=8000
|
||||||
|
),
|
||||||
|
],
|
||||||
|
health_check_path='/health',
|
||||||
|
httpx_client=mock_httpx_client,
|
||||||
|
max_num_sandboxes=3,
|
||||||
|
extra_hosts={},
|
||||||
|
docker_client=mock_docker_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
service_without_extra_hosts, '_find_unused_port', return_value=12345
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
service_without_extra_hosts, 'pause_old_sandboxes', return_value=[]
|
||||||
|
),
|
||||||
|
):
|
||||||
|
# Execute
|
||||||
|
await service_without_extra_hosts.start_sandbox()
|
||||||
|
|
||||||
|
# Verify extra_hosts is None when empty dict is provided
|
||||||
|
mock_docker_client.containers.run.assert_called_once()
|
||||||
|
call_args = mock_docker_client.containers.run.call_args
|
||||||
|
assert call_args[1]['extra_hosts'] is None
|
||||||
|
|
||||||
async def test_resume_sandbox_from_paused(self, service):
|
async def test_resume_sandbox_from_paused(self, service):
|
||||||
"""Test resuming a paused sandbox."""
|
"""Test resuming a paused sandbox."""
|
||||||
# Setup
|
# Setup
|
||||||
|
|||||||
Reference in New Issue
Block a user