Add CLI/vscode integration (#9085)

Co-authored-by: OpenHands-Gemini <openhands@all-hands.dev>
Co-authored-by: Claude 3.5 Sonnet <claude-3-5-sonnet@anthropic.com>
This commit is contained in:
Engel Nyst 2025-07-03 22:42:06 +02:00 committed by GitHub
parent ece556c047
commit ef502ccba8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 11497 additions and 2 deletions

View File

@ -54,6 +54,7 @@ jobs:
ghcr_build_app:
name: Build App Image
runs-on: blacksmith-4vcpu-ubuntu-2204
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
permissions:
contents: read
packages: write
@ -103,6 +104,7 @@ jobs:
ghcr_build_runtime:
name: Build Image
runs-on: blacksmith-4vcpu-ubuntu-2204
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
permissions:
contents: read
packages: write

View File

@ -0,0 +1,156 @@
# 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@v5
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@v4
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@v4
with:
name: vscode-extension
path: ./
- name: Create Release
uses: ncipollo/release-action@v1.16.0
with:
artifacts: "*.vsix"
token: ${{ secrets.GITHUB_TOKEN }}
draft: true
allowUpdates: true

View File

@ -15,10 +15,13 @@ make build && make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.
IMPORTANT: Before making any changes to the codebase, ALWAYS run `make install-pre-commit-hooks` to ensure pre-commit hooks are properly installed.
Before pushing any changes, you MUST ensure that any lint errors or simple test errors have been fixed.
* If you've made changes to the backend, you should run `pre-commit run --config ./dev_config/python/.pre-commit-config.yaml` (this will run on staged files).
* If you've made changes to the frontend, you should run `cd frontend && npm run lint:fix && npm run build ; cd ..`
* If you've made changes to the VSCode extension, you should run `cd openhands/integrations/vscode && npm run lint:fix && npm run compile ; cd ../../..`
The pre-commit hooks MUST pass successfully before pushing any changes to the repository. This is a mandatory requirement to maintain code quality and consistency.
@ -60,6 +63,22 @@ Frontend:
- Mutation hooks should follow the pattern use[Action] (e.g., `useDeleteConversation`)
- Architecture rule: UI components → TanStack Query hooks → Data Access Layer (`frontend/src/api`) → API endpoints
VSCode Extension:
- Located in the `openhands/integrations/vscode` directory
- Setup: Run `npm install` in the extension directory
- Linting:
- Run linting with fixes: `npm run lint:fix`
- Check only: `npm run lint`
- Type checking: `npm run typecheck`
- Building:
- Compile TypeScript: `npm run compile`
- Package extension: `npm run package-vsix`
- Testing:
- Run tests: `npm run test`
- Development Best Practices:
- Use `vscode.window.createOutputChannel()` for debug logging instead of `showErrorMessage()` popups
- Pre-commit process runs both frontend and backend checks when committing extension changes
## Template for Github Pull Request
If you are starting a pull request (PR), please follow the template in `.github/pull_request_template.md`.

114
build_vscode.py Normal file
View File

@ -0,0 +1,114 @@
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.')

View File

@ -32,6 +32,7 @@ from openhands.cli.tui import (
from openhands.cli.utils import (
update_usage_metrics,
)
from openhands.cli.vscode_extension import attempt_vscode_extension_install
from openhands.controller import AgentController
from openhands.controller.agent import Agent
from openhands.core.config import (
@ -368,6 +369,9 @@ async def main_with_loop(loop: asyncio.AbstractEventLoop) -> None:
# Load config from toml and override with command line arguments
config: OpenHandsConfig = setup_config_from_args(args)
# Attempt to install VS Code extension if applicable (one-time attempt)
attempt_vscode_extension_install()
# Load settings from Settings Store
# TODO: Make this generic?
settings_store = await FileSettingsStore.get_instance(config=config, user_id=None)

View File

@ -0,0 +1,318 @@
import importlib.resources
import json
import os
import pathlib
import subprocess
import tempfile
import urllib.request
from urllib.error import URLError
from openhands.core.logger import openhands_logger as logger
def download_latest_vsix_from_github() -> str | None:
"""Download latest .vsix from GitHub releases.
Returns:
Path to downloaded .vsix file, or None if failed
"""
api_url = 'https://api.github.com/repos/All-Hands-AI/OpenHands/releases'
try:
with urllib.request.urlopen(api_url, timeout=10) as response:
if response.status != 200:
logger.debug(
f'GitHub API request failed with status: {response.status}'
)
return None
releases = json.loads(response.read().decode())
# The GitHub API returns releases in reverse chronological order (newest first).
# We iterate through them and use the first one that matches our extension prefix.
for release in releases:
if release.get('tag_name', '').startswith('ext-v'):
for asset in release.get('assets', []):
if asset.get('name', '').endswith('.vsix'):
download_url = asset.get('browser_download_url')
if not download_url:
continue
with urllib.request.urlopen(
download_url, timeout=30
) as download_response:
if download_response.status != 200:
logger.debug(
f'Failed to download .vsix with status: {download_response.status}'
)
continue
with tempfile.NamedTemporaryFile(
delete=False, suffix='.vsix'
) as tmp_file:
tmp_file.write(download_response.read())
return tmp_file.name
# Found the latest extension release but no .vsix asset
return None
except (URLError, TimeoutError, json.JSONDecodeError) as e:
logger.debug(f'Failed to download from GitHub releases: {e}')
return None
return None
def attempt_vscode_extension_install():
"""
Checks if running in a supported editor and attempts to install the OpenHands companion extension.
This is a best-effort, one-time attempt.
"""
# 1. Check if we are in a supported editor environment
is_vscode_like = os.environ.get('TERM_PROGRAM') == 'vscode'
is_windsurf = (
os.environ.get('__CFBundleIdentifier') == 'com.exafunction.windsurf'
or 'windsurf' in os.environ.get('PATH', '').lower()
or any(
'windsurf' in val.lower()
for val in os.environ.values()
if isinstance(val, str)
)
)
if not (is_vscode_like or is_windsurf):
return
# 2. Determine editor-specific commands and flags
if is_windsurf:
editor_command, editor_name, flag_suffix = 'surf', 'Windsurf', 'windsurf'
else:
editor_command, editor_name, flag_suffix = 'code', 'VS Code', 'vscode'
# 3. Check if we've already successfully installed the extension.
flag_dir = pathlib.Path.home() / '.openhands'
flag_file = flag_dir / f'.{flag_suffix}_extension_installed'
extension_id = 'openhands.openhands-vscode'
try:
flag_dir.mkdir(parents=True, exist_ok=True)
if flag_file.exists():
return # Already successfully installed, exit.
except OSError as e:
logger.debug(
f'Could not create or check {editor_name} extension flag directory: {e}'
)
return # Don't proceed if we can't manage the flag.
# 4. Check if the extension is already installed (even without our flag).
if _is_extension_installed(editor_command, extension_id):
print(f'INFO: OpenHands {editor_name} extension is already installed.')
# Create flag to avoid future checks
_mark_installation_successful(flag_file, editor_name)
return
# 5. Extension is not installed, attempt installation.
print(
f'INFO: First-time setup: attempting to install the OpenHands {editor_name} extension...'
)
# Attempt 1: Download from GitHub Releases (the new primary method)
if _attempt_github_install(editor_command, editor_name):
_mark_installation_successful(flag_file, editor_name)
return # Success! We are done.
# Attempt 2: Install from bundled .vsix
if _attempt_bundled_install(editor_command, editor_name):
_mark_installation_successful(flag_file, editor_name)
return # Success! We are done.
# TODO: Attempt 3: Install from Marketplace (when extension is published)
# if _attempt_marketplace_install(editor_command, editor_name, extension_id):
# _mark_installation_successful(flag_file, editor_name)
# return # Success! We are done.
# If all attempts failed, inform the user (but don't create flag - allow retry).
print(
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
)
print(
f'INFO: Will retry installation next time you run OpenHands in {editor_name}.'
)
def _mark_installation_successful(flag_file: pathlib.Path, editor_name: str) -> None:
"""
Mark the extension installation as successful by creating the flag file.
Args:
flag_file: Path to the flag file to create
editor_name: Human-readable name of the editor for logging
"""
try:
flag_file.touch()
logger.debug(f'{editor_name} extension installation marked as successful.')
except OSError as e:
logger.debug(f'Could not create {editor_name} extension success flag file: {e}')
def _is_extension_installed(editor_command: str, extension_id: str) -> bool:
"""
Check if the OpenHands extension is already installed.
Args:
editor_command: The command to run the editor (e.g., 'code', 'windsurf')
extension_id: The extension ID to check for
Returns:
bool: True if extension is already installed, False otherwise
"""
try:
process = subprocess.run(
[editor_command, '--list-extensions'],
capture_output=True,
text=True,
check=False,
)
if process.returncode == 0:
installed_extensions = process.stdout.strip().split('\n')
return extension_id in installed_extensions
except Exception as e:
logger.debug(f'Could not check installed extensions: {e}')
return False
def _attempt_github_install(editor_command: str, editor_name: str) -> bool:
"""
Attempt to install the extension from GitHub Releases.
Downloads the latest VSIX file from GitHub releases and attempts to install it.
Ensures proper cleanup of temporary files.
Args:
editor_command: The command to run the editor (e.g., 'code', 'windsurf')
editor_name: Human-readable name of the editor (e.g., 'VS Code', 'Windsurf')
Returns:
bool: True if installation succeeded, False otherwise
"""
vsix_path_from_github = download_latest_vsix_from_github()
if not vsix_path_from_github:
return False
github_success = False
try:
process = subprocess.run(
[
editor_command,
'--install-extension',
vsix_path_from_github,
'--force',
],
capture_output=True,
text=True,
check=False,
)
if process.returncode == 0:
print(
f'INFO: OpenHands {editor_name} extension installed successfully from GitHub.'
)
github_success = True
else:
logger.debug(
f'Failed to install .vsix from GitHub: {process.stderr.strip()}'
)
finally:
# Clean up the downloaded file
if os.path.exists(vsix_path_from_github):
try:
os.remove(vsix_path_from_github)
except OSError as e:
logger.debug(
f'Failed to delete temporary file {vsix_path_from_github}: {e}'
)
return github_success
def _attempt_bundled_install(editor_command: str, editor_name: str) -> bool:
"""
Attempt to install the extension from the bundled VSIX file.
Uses the VSIX file packaged with the OpenHands installation.
Args:
editor_command: The command to run the editor (e.g., 'code', 'windsurf')
editor_name: Human-readable name of the editor (e.g., 'VS Code', 'Windsurf')
Returns:
bool: True if installation succeeded, False otherwise
"""
try:
vsix_filename = 'openhands-vscode-0.0.1.vsix'
with importlib.resources.as_file(
importlib.resources.files('openhands').joinpath(
'integrations', 'vscode', vsix_filename
)
) as vsix_path:
if vsix_path.exists():
process = subprocess.run(
[
editor_command,
'--install-extension',
str(vsix_path),
'--force',
],
capture_output=True,
text=True,
check=False,
)
if process.returncode == 0:
print(
f'INFO: Bundled {editor_name} extension installed successfully.'
)
return True
else:
logger.debug(
f'Bundled .vsix installation failed: {process.stderr.strip()}'
)
except Exception as e:
logger.debug(f'Could not locate bundled .vsix: {e}.')
return False
def _attempt_marketplace_install(
editor_command: str, editor_name: str, extension_id: str
) -> bool:
"""
Attempt to install the extension from the marketplace.
This method is currently unused as the OpenHands extension is not yet published
to the VS Code/Windsurf marketplace. It's kept here for future use when the
extension becomes available.
Args:
editor_command: The command to use ('code' or 'surf')
editor_name: Human-readable editor name ('VS Code' or 'Windsurf')
extension_id: The extension ID to install
Returns:
True if installation succeeded, False otherwise
"""
try:
process = subprocess.run(
[editor_command, '--install-extension', extension_id, '--force'],
capture_output=True,
text=True,
check=False,
)
if process.returncode == 0:
print(
f'INFO: {editor_name} extension installed successfully from the Marketplace.'
)
return True
else:
logger.debug(f'Marketplace installation failed: {process.stderr.strip()}')
return False
except FileNotFoundError:
print(
f"INFO: To complete {editor_name} integration, please ensure the '{editor_command}' command-line tool is in your PATH."
)
return False
except Exception as e:
logger.debug(
f'An unexpected error occurred trying to install from the Marketplace: {e}'
)
return False

View File

@ -0,0 +1,4 @@
out/
node_modules/
.vscode-test/
*.vsix

View File

@ -0,0 +1,68 @@
{
"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"
}
}
]
}

View File

@ -0,0 +1,18 @@
# 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

View File

@ -0,0 +1,3 @@
{
"trailingComma": "all"
}

View File

@ -0,0 +1,11 @@
.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

View File

@ -0,0 +1,97 @@
# 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.

View File

@ -0,0 +1,25 @@
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.

View File

@ -0,0 +1,48 @@
# 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.
![OpenHands VSCode Extension Demo](https://raw.githubusercontent.com/All-Hands-AI/OpenHands/main/assets/images/vscode-extension-demo.gif)
## 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/All-Hands-AI/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.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,110 @@
{
"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/all-hands-ai/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"
}
}

View File

@ -0,0 +1,380 @@
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.
}

View File

@ -0,0 +1,22 @@
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();

View File

@ -0,0 +1,848 @@
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",
);
});
});

View File

@ -0,0 +1,45 @@
import * as path from "path";
import Mocha = require("mocha"); // Changed import style
import glob = require("glob"); // Changed import style
export 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)
return new Promise((c, e) => {
// Use glob to find all test files (ending with .test.js in the compiled output)
glob(
"**/**.test.js",
{ cwd: testsRoot },
(err: NodeJS.ErrnoException | null, files: string[]) => {
if (err) {
return e(err);
}
// Add files to the test suite
files.forEach((f: string) => mocha.addFile(path.resolve(testsRoot, f)));
try {
// Run the mocha test
mocha.run((failures: number) => {
if (failures > 0) {
e(new Error(`${failures} tests failed.`));
} else {
c();
}
});
} catch (err) {
console.error(err);
e(err);
}
},
);
});
}

View File

@ -0,0 +1,23 @@
{
"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"
]
}

View File

@ -86,8 +86,7 @@ def check_dependencies(code_repo_path: str, poetry_venvs_path: str) -> None:
# Check jupyter is installed
logger.debug('Checking dependencies: Jupyter')
output = subprocess.check_output(
'poetry run jupyter --version',
shell=True,
['poetry', 'run', 'jupyter', '--version'],
text=True,
cwd=code_repo_path,
)

View File

@ -18,6 +18,10 @@ packages = [
{ include = "pyproject.toml", to = "openhands" },
{ include = "poetry.lock", to = "openhands" },
]
include = [
"openhands/integrations/vscode/openhands-vscode-0.0.1.vsix",
]
build = "build_vscode.py" # Build VSCode extension during Poetry build
[tool.poetry.dependencies]
python = "^3.12,<3.14"

View File

@ -0,0 +1,818 @@
import os
import pathlib
import subprocess
from unittest import mock
import pytest
from openhands.cli import vscode_extension
@pytest.fixture
def mock_env_and_dependencies():
"""A fixture to mock all external dependencies and manage the environment."""
with (
mock.patch.dict(os.environ, {}, clear=True),
mock.patch('pathlib.Path.home') as mock_home,
mock.patch('pathlib.Path.exists') as mock_exists,
mock.patch('pathlib.Path.touch') as mock_touch,
mock.patch('pathlib.Path.mkdir') as mock_mkdir,
mock.patch('subprocess.run') as mock_subprocess,
mock.patch('importlib.resources.as_file') as mock_as_file,
mock.patch(
'openhands.cli.vscode_extension.download_latest_vsix_from_github'
) as mock_download,
mock.patch('builtins.print') as mock_print,
mock.patch('openhands.cli.vscode_extension.logger.debug') as mock_logger,
):
# Setup a temporary directory for home
temp_dir = pathlib.Path.cwd() / 'temp_test_home'
temp_dir.mkdir(exist_ok=True)
mock_home.return_value = temp_dir
try:
yield {
'home': mock_home,
'exists': mock_exists,
'touch': mock_touch,
'mkdir': mock_mkdir,
'subprocess': mock_subprocess,
'as_file': mock_as_file,
'download': mock_download,
'print': mock_print,
'logger': mock_logger,
}
finally:
# Teardown the temporary directory, ignoring errors if files don't exist
openhands_dir = temp_dir / '.openhands'
if openhands_dir.exists():
for f in openhands_dir.glob('*'):
if f.is_file():
f.unlink()
try:
openhands_dir.rmdir()
except FileNotFoundError:
pass
try:
temp_dir.rmdir()
except (FileNotFoundError, OSError):
pass
def test_not_in_vscode_environment(mock_env_and_dependencies):
"""Should not attempt any installation if not in a VSCode-like environment."""
os.environ['TERM_PROGRAM'] = 'not_vscode'
vscode_extension.attempt_vscode_extension_install()
mock_env_and_dependencies['download'].assert_not_called()
mock_env_and_dependencies['subprocess'].assert_not_called()
def test_already_attempted_flag_prevents_execution(mock_env_and_dependencies):
"""Should do nothing if the installation flag file already exists."""
os.environ['TERM_PROGRAM'] = 'vscode'
mock_env_and_dependencies['exists'].return_value = True # Simulate flag file exists
vscode_extension.attempt_vscode_extension_install()
mock_env_and_dependencies['download'].assert_not_called()
mock_env_and_dependencies['subprocess'].assert_not_called()
def test_extension_already_installed_detected(mock_env_and_dependencies):
"""Should detect already installed extension and create flag."""
os.environ['TERM_PROGRAM'] = 'vscode'
mock_env_and_dependencies['exists'].return_value = False
# Mock subprocess call for --list-extensions (returns extension as installed)
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
returncode=0,
args=[],
stdout='openhands.openhands-vscode\nother.extension',
stderr='',
)
vscode_extension.attempt_vscode_extension_install()
# Should only call --list-extensions, no installation attempts
assert mock_env_and_dependencies['subprocess'].call_count == 1
mock_env_and_dependencies['subprocess'].assert_called_with(
['code', '--list-extensions'],
capture_output=True,
text=True,
check=False,
)
mock_env_and_dependencies['print'].assert_any_call(
'INFO: OpenHands VS Code extension is already installed.'
)
mock_env_and_dependencies['touch'].assert_called_once()
mock_env_and_dependencies['download'].assert_not_called()
def test_extension_detection_in_middle_of_list(mock_env_and_dependencies):
"""Should detect extension even when it's not the first in the list."""
os.environ['TERM_PROGRAM'] = 'vscode'
mock_env_and_dependencies['exists'].return_value = False
# Extension is in the middle of the list
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
returncode=0,
args=[],
stdout='first.extension\nopenhands.openhands-vscode\nlast.extension',
stderr='',
)
vscode_extension.attempt_vscode_extension_install()
mock_env_and_dependencies['print'].assert_any_call(
'INFO: OpenHands VS Code extension is already installed.'
)
mock_env_and_dependencies['touch'].assert_called_once()
def test_extension_detection_partial_match_ignored(mock_env_and_dependencies):
"""Should not match partial extension IDs."""
os.environ['TERM_PROGRAM'] = 'vscode'
mock_env_and_dependencies['exists'].return_value = False
# Partial match should not trigger detection
mock_env_and_dependencies['subprocess'].side_effect = [
subprocess.CompletedProcess(
returncode=0,
args=[],
stdout='other.openhands-vscode-fork\nsome.extension',
stderr='',
),
subprocess.CompletedProcess(
returncode=0, args=[], stdout='', stderr=''
), # GitHub install succeeds
]
mock_env_and_dependencies['download'].return_value = '/fake/path/to/github.vsix'
with mock.patch('os.remove'), mock.patch('os.path.exists', return_value=True):
vscode_extension.attempt_vscode_extension_install()
# Should proceed with installation since exact match not found
assert mock_env_and_dependencies['subprocess'].call_count == 2
mock_env_and_dependencies['download'].assert_called_once()
def test_list_extensions_fails_continues_installation(mock_env_and_dependencies):
"""Should continue with installation if --list-extensions fails."""
os.environ['TERM_PROGRAM'] = 'vscode'
mock_env_and_dependencies['exists'].return_value = False
# --list-extensions fails, but GitHub install succeeds
mock_env_and_dependencies['subprocess'].side_effect = [
subprocess.CompletedProcess(
returncode=1, args=[], stdout='', stderr='Command failed'
),
subprocess.CompletedProcess(
returncode=0, args=[], stdout='', stderr=''
), # GitHub install succeeds
]
mock_env_and_dependencies['download'].return_value = '/fake/path/to/github.vsix'
with mock.patch('os.remove'), mock.patch('os.path.exists', return_value=True):
vscode_extension.attempt_vscode_extension_install()
# Should proceed with installation
assert mock_env_and_dependencies['subprocess'].call_count == 2
mock_env_and_dependencies['download'].assert_called_once()
def test_list_extensions_exception_continues_installation(mock_env_and_dependencies):
"""Should continue with installation if --list-extensions throws exception."""
os.environ['TERM_PROGRAM'] = 'vscode'
mock_env_and_dependencies['exists'].return_value = False
# --list-extensions throws exception, but GitHub install succeeds
mock_env_and_dependencies['subprocess'].side_effect = [
FileNotFoundError('code command not found'),
subprocess.CompletedProcess(
returncode=0, args=[], stdout='', stderr=''
), # GitHub install succeeds
]
mock_env_and_dependencies['download'].return_value = '/fake/path/to/github.vsix'
with mock.patch('os.remove'), mock.patch('os.path.exists', return_value=True):
vscode_extension.attempt_vscode_extension_install()
# Should proceed with installation
assert mock_env_and_dependencies['subprocess'].call_count == 2
mock_env_and_dependencies['download'].assert_called_once()
def test_mark_installation_successful_os_error(mock_env_and_dependencies):
"""Should log error but continue if flag file creation fails."""
os.environ['TERM_PROGRAM'] = 'vscode'
mock_env_and_dependencies['exists'].return_value = False
mock_env_and_dependencies['subprocess'].side_effect = [
subprocess.CompletedProcess(
returncode=0, args=[], stdout='', stderr=''
), # --list-extensions (empty)
subprocess.CompletedProcess(
returncode=0, args=[], stdout='', stderr=''
), # GitHub install succeeds
]
mock_env_and_dependencies['download'].return_value = '/fake/path/to/github.vsix'
mock_env_and_dependencies['touch'].side_effect = OSError('Permission denied')
with mock.patch('os.remove'), mock.patch('os.path.exists', return_value=True):
vscode_extension.attempt_vscode_extension_install()
# Should still complete installation
mock_env_and_dependencies['download'].assert_called_once()
mock_env_and_dependencies['touch'].assert_called_once()
# Should log the error
mock_env_and_dependencies['logger'].assert_any_call(
'Could not create VS Code extension success flag file: Permission denied'
)
def test_installation_failure_no_flag_created(mock_env_and_dependencies):
"""Should NOT create flag when all installation methods fail (allow retry)."""
os.environ['TERM_PROGRAM'] = 'vscode'
mock_env_and_dependencies['exists'].return_value = False
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
returncode=0,
args=[],
stdout='',
stderr='', # --list-extensions (empty)
)
mock_env_and_dependencies['download'].return_value = None # GitHub fails
mock_env_and_dependencies[
'as_file'
].side_effect = FileNotFoundError # Bundled fails
vscode_extension.attempt_vscode_extension_install()
# Should NOT create flag file - this is the key behavior change
mock_env_and_dependencies['touch'].assert_not_called()
mock_env_and_dependencies['print'].assert_any_call(
'INFO: Will retry installation next time you run OpenHands in VS Code.'
)
def test_install_succeeds_from_github(mock_env_and_dependencies):
"""Should successfully install from GitHub on the first try."""
os.environ['TERM_PROGRAM'] = 'vscode'
mock_env_and_dependencies['exists'].return_value = False
mock_env_and_dependencies['download'].return_value = '/fake/path/to/github.vsix'
# Mock subprocess calls: first --list-extensions (returns empty), then install
mock_env_and_dependencies['subprocess'].side_effect = [
subprocess.CompletedProcess(
returncode=0, args=[], stdout='', stderr=''
), # --list-extensions
subprocess.CompletedProcess(
returncode=0, args=[], stdout='', stderr=''
), # --install-extension
]
with (
mock.patch('os.remove') as mock_os_remove,
mock.patch('os.path.exists', return_value=True),
):
vscode_extension.attempt_vscode_extension_install()
mock_env_and_dependencies['download'].assert_called_once()
# Should have two subprocess calls: list-extensions and install-extension
assert mock_env_and_dependencies['subprocess'].call_count == 2
mock_env_and_dependencies['subprocess'].assert_any_call(
['code', '--list-extensions'],
capture_output=True,
text=True,
check=False,
)
mock_env_and_dependencies['subprocess'].assert_any_call(
['code', '--install-extension', '/fake/path/to/github.vsix', '--force'],
capture_output=True,
text=True,
check=False,
)
mock_env_and_dependencies['print'].assert_any_call(
'INFO: OpenHands VS Code extension installed successfully from GitHub.'
)
mock_os_remove.assert_called_once_with('/fake/path/to/github.vsix')
mock_env_and_dependencies['touch'].assert_called_once()
def test_github_fails_falls_back_to_bundled(mock_env_and_dependencies):
"""Should fall back to bundled VSIX if GitHub download fails."""
os.environ['TERM_PROGRAM'] = 'vscode'
mock_env_and_dependencies['exists'].return_value = False
mock_env_and_dependencies['download'].return_value = None
mock_vsix_path = mock.MagicMock()
mock_vsix_path.exists.return_value = True
mock_vsix_path.__str__.return_value = '/fake/path/to/bundled.vsix'
mock_env_and_dependencies[
'as_file'
].return_value.__enter__.return_value = mock_vsix_path
# Mock subprocess calls: first --list-extensions (returns empty), then install
mock_env_and_dependencies['subprocess'].side_effect = [
subprocess.CompletedProcess(
returncode=0, args=[], stdout='', stderr=''
), # --list-extensions
subprocess.CompletedProcess(
returncode=0, args=[], stdout='', stderr=''
), # --install-extension
]
vscode_extension.attempt_vscode_extension_install()
mock_env_and_dependencies['download'].assert_called_once()
mock_env_and_dependencies['as_file'].assert_called_once()
# Should have two subprocess calls: list-extensions and install-extension
assert mock_env_and_dependencies['subprocess'].call_count == 2
mock_env_and_dependencies['subprocess'].assert_any_call(
['code', '--list-extensions'],
capture_output=True,
text=True,
check=False,
)
mock_env_and_dependencies['subprocess'].assert_any_call(
['code', '--install-extension', '/fake/path/to/bundled.vsix', '--force'],
capture_output=True,
text=True,
check=False,
)
mock_env_and_dependencies['touch'].assert_called_once()
def test_all_methods_fail(mock_env_and_dependencies):
"""Should show a final failure message if all installation methods fail."""
os.environ['TERM_PROGRAM'] = 'vscode'
mock_env_and_dependencies['exists'].return_value = False
mock_env_and_dependencies['download'].return_value = None
mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
# Mock subprocess call for --list-extensions (returns empty, extension not installed)
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
returncode=0, args=[], stdout='', stderr=''
)
vscode_extension.attempt_vscode_extension_install()
mock_env_and_dependencies['download'].assert_called_once()
mock_env_and_dependencies['as_file'].assert_called_once()
# Only one subprocess call for --list-extensions, no installation attempts
assert mock_env_and_dependencies['subprocess'].call_count == 1
mock_env_and_dependencies['subprocess'].assert_called_with(
['code', '--list-extensions'],
capture_output=True,
text=True,
check=False,
)
mock_env_and_dependencies['print'].assert_any_call(
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
)
mock_env_and_dependencies['print'].assert_any_call(
'INFO: Will retry installation next time you run OpenHands in VS Code.'
)
# Should NOT create flag file on failure - that's the point of our new approach
mock_env_and_dependencies['touch'].assert_not_called()
def test_windsurf_detection_and_install(mock_env_and_dependencies):
"""Should correctly detect Windsurf but not attempt marketplace installation."""
os.environ['__CFBundleIdentifier'] = 'com.exafunction.windsurf'
mock_env_and_dependencies['exists'].return_value = False
mock_env_and_dependencies['download'].return_value = None
mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
# Mock subprocess call for --list-extensions (returns empty, extension not installed)
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
returncode=0, args=[], stdout='', stderr=''
)
vscode_extension.attempt_vscode_extension_install()
# Only one subprocess call for --list-extensions, no installation attempts
assert mock_env_and_dependencies['subprocess'].call_count == 1
mock_env_and_dependencies['subprocess'].assert_called_with(
['surf', '--list-extensions'],
capture_output=True,
text=True,
check=False,
)
mock_env_and_dependencies['print'].assert_any_call(
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
)
mock_env_and_dependencies['print'].assert_any_call(
'INFO: Will retry installation next time you run OpenHands in Windsurf.'
)
# Should NOT create flag file on failure
mock_env_and_dependencies['touch'].assert_not_called()
def test_os_error_on_mkdir(mock_env_and_dependencies):
"""Should log a debug message if creating the flag directory fails."""
os.environ['TERM_PROGRAM'] = 'vscode'
mock_env_and_dependencies['mkdir'].side_effect = OSError('Permission denied')
vscode_extension.attempt_vscode_extension_install()
mock_env_and_dependencies['logger'].assert_called_once_with(
'Could not create or check VS Code extension flag directory: Permission denied'
)
mock_env_and_dependencies['download'].assert_not_called()
def test_os_error_on_touch(mock_env_and_dependencies):
"""Should log a debug message if creating the flag file fails."""
os.environ['TERM_PROGRAM'] = 'vscode'
mock_env_and_dependencies['exists'].return_value = False
mock_env_and_dependencies['download'].return_value = None
mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
# Mock subprocess call for --list-extensions (returns empty, extension not installed)
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
returncode=0, args=[], stdout='', stderr=''
)
mock_env_and_dependencies['touch'].side_effect = OSError('Permission denied')
vscode_extension.attempt_vscode_extension_install()
# Should NOT create flag file on failure - this is the new behavior
mock_env_and_dependencies['touch'].assert_not_called()
mock_env_and_dependencies['print'].assert_any_call(
'INFO: Will retry installation next time you run OpenHands in VS Code.'
)
def test_flag_file_exists_windsurf(mock_env_and_dependencies):
"""Should not attempt install if flag file already exists (Windsurf)."""
os.environ['__CFBundleIdentifier'] = 'com.exafunction.windsurf'
mock_env_and_dependencies['exists'].return_value = True
vscode_extension.attempt_vscode_extension_install()
mock_env_and_dependencies['download'].assert_not_called()
mock_env_and_dependencies['subprocess'].assert_not_called()
def test_successful_install_attempt_vscode(mock_env_and_dependencies):
"""Test that VS Code is detected but marketplace installation is not attempted."""
os.environ['TERM_PROGRAM'] = 'vscode'
mock_env_and_dependencies['exists'].return_value = False
mock_env_and_dependencies['download'].return_value = None
mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
# Mock subprocess call for --list-extensions (returns empty, extension not installed)
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
returncode=0, args=[], stdout='', stderr=''
)
vscode_extension.attempt_vscode_extension_install()
# One subprocess call for --list-extensions, no installation attempts
assert mock_env_and_dependencies['subprocess'].call_count == 1
mock_env_and_dependencies['subprocess'].assert_called_with(
['code', '--list-extensions'],
capture_output=True,
text=True,
check=False,
)
mock_env_and_dependencies['print'].assert_any_call(
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
)
def test_successful_install_attempt_windsurf(mock_env_and_dependencies):
"""Test that Windsurf is detected but marketplace installation is not attempted."""
os.environ['__CFBundleIdentifier'] = 'com.exafunction.windsurf'
mock_env_and_dependencies['exists'].return_value = False
mock_env_and_dependencies['download'].return_value = None
mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
# Mock subprocess call for --list-extensions (returns empty, extension not installed)
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
returncode=0, args=[], stdout='', stderr=''
)
vscode_extension.attempt_vscode_extension_install()
# One subprocess call for --list-extensions, no installation attempts
assert mock_env_and_dependencies['subprocess'].call_count == 1
mock_env_and_dependencies['subprocess'].assert_called_with(
['surf', '--list-extensions'],
capture_output=True,
text=True,
check=False,
)
mock_env_and_dependencies['print'].assert_any_call(
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
)
def test_install_attempt_code_command_fails(mock_env_and_dependencies):
"""Test that VS Code is detected but marketplace installation is not attempted."""
os.environ['TERM_PROGRAM'] = 'vscode'
mock_env_and_dependencies['exists'].return_value = False
mock_env_and_dependencies['download'].return_value = None
mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
# Mock subprocess call for --list-extensions (returns empty, extension not installed)
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
returncode=0, args=[], stdout='', stderr=''
)
vscode_extension.attempt_vscode_extension_install()
# One subprocess call for --list-extensions, no installation attempts
assert mock_env_and_dependencies['subprocess'].call_count == 1
mock_env_and_dependencies['print'].assert_any_call(
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
)
def test_install_attempt_code_not_found(mock_env_and_dependencies):
"""Test that VS Code is detected but marketplace installation is not attempted."""
os.environ['TERM_PROGRAM'] = 'vscode'
mock_env_and_dependencies['exists'].return_value = False
mock_env_and_dependencies['download'].return_value = None
mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
# Mock subprocess call for --list-extensions (returns empty, extension not installed)
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
returncode=0, args=[], stdout='', stderr=''
)
vscode_extension.attempt_vscode_extension_install()
# One subprocess call for --list-extensions, no installation attempts
assert mock_env_and_dependencies['subprocess'].call_count == 1
mock_env_and_dependencies['print'].assert_any_call(
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
)
def test_flag_dir_creation_os_error_windsurf(mock_env_and_dependencies):
"""Test OSError during flag directory creation (Windsurf)."""
os.environ['__CFBundleIdentifier'] = 'com.exafunction.windsurf'
mock_env_and_dependencies['mkdir'].side_effect = OSError('Permission denied')
vscode_extension.attempt_vscode_extension_install()
mock_env_and_dependencies['logger'].assert_called_once_with(
'Could not create or check Windsurf extension flag directory: Permission denied'
)
mock_env_and_dependencies['download'].assert_not_called()
def test_flag_file_touch_os_error_vscode(mock_env_and_dependencies):
"""Test OSError during flag file touch (VS Code)."""
os.environ['TERM_PROGRAM'] = 'vscode'
mock_env_and_dependencies['exists'].return_value = False
mock_env_and_dependencies['download'].return_value = None
mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
# Mock subprocess call for --list-extensions (returns empty, extension not installed)
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
returncode=0, args=[], stdout='', stderr=''
)
mock_env_and_dependencies['touch'].side_effect = OSError('Permission denied')
vscode_extension.attempt_vscode_extension_install()
# Should NOT create flag file on failure - this is the new behavior
mock_env_and_dependencies['touch'].assert_not_called()
mock_env_and_dependencies['print'].assert_any_call(
'INFO: Will retry installation next time you run OpenHands in VS Code.'
)
def test_flag_file_touch_os_error_windsurf(mock_env_and_dependencies):
"""Test OSError during flag file touch (Windsurf)."""
os.environ['__CFBundleIdentifier'] = 'com.exafunction.windsurf'
mock_env_and_dependencies['exists'].return_value = False
mock_env_and_dependencies['download'].return_value = None
mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
# Mock subprocess call for --list-extensions (returns empty, extension not installed)
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
returncode=0, args=[], stdout='', stderr=''
)
mock_env_and_dependencies['touch'].side_effect = OSError('Permission denied')
vscode_extension.attempt_vscode_extension_install()
# Should NOT create flag file on failure - this is the new behavior
mock_env_and_dependencies['touch'].assert_not_called()
mock_env_and_dependencies['print'].assert_any_call(
'INFO: Will retry installation next time you run OpenHands in Windsurf.'
)
def test_bundled_vsix_installation_failure_fallback_to_marketplace(
mock_env_and_dependencies,
):
"""Test bundled VSIX failure shows appropriate message."""
os.environ['TERM_PROGRAM'] = 'vscode'
mock_env_and_dependencies['exists'].return_value = False
mock_env_and_dependencies['download'].return_value = None
mock_vsix_path = mock.MagicMock()
mock_vsix_path.exists.return_value = True
mock_vsix_path.__str__.return_value = '/mock/path/openhands-vscode-0.0.1.vsix'
mock_env_and_dependencies[
'as_file'
].return_value.__enter__.return_value = mock_vsix_path
# Mock subprocess calls: first --list-extensions (empty), then bundled install (fails)
mock_env_and_dependencies['subprocess'].side_effect = [
subprocess.CompletedProcess(
returncode=0, args=[], stdout='', stderr=''
), # --list-extensions
subprocess.CompletedProcess(
args=[
'code',
'--install-extension',
'/mock/path/openhands-vscode-0.0.1.vsix',
'--force',
],
returncode=1,
stdout='Installation failed',
stderr='Error installing extension',
),
]
vscode_extension.attempt_vscode_extension_install()
# Two subprocess calls: --list-extensions and bundled VSIX install
assert mock_env_and_dependencies['subprocess'].call_count == 2
mock_env_and_dependencies['print'].assert_any_call(
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
)
def test_bundled_vsix_not_found_fallback_to_marketplace(mock_env_and_dependencies):
"""Test bundled VSIX not found shows appropriate message."""
os.environ['TERM_PROGRAM'] = 'vscode'
mock_env_and_dependencies['exists'].return_value = False
mock_env_and_dependencies['download'].return_value = None
mock_vsix_path = mock.MagicMock()
mock_vsix_path.exists.return_value = False
mock_env_and_dependencies[
'as_file'
].return_value.__enter__.return_value = mock_vsix_path
# Mock subprocess call for --list-extensions (returns empty, extension not installed)
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
returncode=0, args=[], stdout='', stderr=''
)
vscode_extension.attempt_vscode_extension_install()
# One subprocess call for --list-extensions, no installation attempts
assert mock_env_and_dependencies['subprocess'].call_count == 1
mock_env_and_dependencies['print'].assert_any_call(
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
)
def test_importlib_resources_exception_fallback_to_marketplace(
mock_env_and_dependencies,
):
"""Test importlib.resources exception shows appropriate message."""
os.environ['TERM_PROGRAM'] = 'vscode'
mock_env_and_dependencies['exists'].return_value = False
mock_env_and_dependencies['download'].return_value = None
mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError(
'Resource not found'
)
# Mock subprocess call for --list-extensions (returns empty, extension not installed)
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
returncode=0, args=[], stdout='', stderr=''
)
vscode_extension.attempt_vscode_extension_install()
# One subprocess call for --list-extensions, no installation attempts
assert mock_env_and_dependencies['subprocess'].call_count == 1
mock_env_and_dependencies['print'].assert_any_call(
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
)
def test_comprehensive_windsurf_detection_path_based(mock_env_and_dependencies):
"""Test Windsurf detection via PATH environment variable but no marketplace installation."""
os.environ['PATH'] = (
'/usr/local/bin:/Applications/Windsurf.app/Contents/Resources/app/bin:/usr/bin'
)
mock_env_and_dependencies['exists'].return_value = False
mock_env_and_dependencies['download'].return_value = None
mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
# Mock subprocess call for --list-extensions (returns empty, extension not installed)
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
returncode=0, args=[], stdout='', stderr=''
)
vscode_extension.attempt_vscode_extension_install()
# One subprocess call for --list-extensions, no installation attempts
assert mock_env_and_dependencies['subprocess'].call_count == 1
mock_env_and_dependencies['subprocess'].assert_called_with(
['surf', '--list-extensions'],
capture_output=True,
text=True,
check=False,
)
mock_env_and_dependencies['print'].assert_any_call(
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
)
def test_comprehensive_windsurf_detection_env_value_based(mock_env_and_dependencies):
"""Test Windsurf detection via environment variable values but no marketplace installation."""
os.environ['SOME_APP_PATH'] = '/Applications/Windsurf.app/Contents/MacOS/Windsurf'
mock_env_and_dependencies['exists'].return_value = False
mock_env_and_dependencies['download'].return_value = None
mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
# Mock subprocess call for --list-extensions (returns empty, extension not installed)
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
returncode=0, args=[], stdout='', stderr=''
)
vscode_extension.attempt_vscode_extension_install()
# One subprocess call for --list-extensions, no installation attempts
assert mock_env_and_dependencies['subprocess'].call_count == 1
mock_env_and_dependencies['print'].assert_any_call(
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
)
def test_comprehensive_windsurf_detection_multiple_indicators(
mock_env_and_dependencies,
):
"""Test Windsurf detection with multiple environment indicators."""
os.environ['__CFBundleIdentifier'] = 'com.exafunction.windsurf'
os.environ['PATH'] = (
'/usr/local/bin:/Applications/Windsurf.app/Contents/Resources/app/bin:/usr/bin'
)
os.environ['WINDSURF_CONFIG'] = '/Users/test/.windsurf/config'
mock_env_and_dependencies['exists'].return_value = False
mock_env_and_dependencies['download'].return_value = None
mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
# Mock subprocess call for --list-extensions (returns empty, extension not installed)
mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
returncode=0, args=[], stdout='', stderr=''
)
vscode_extension.attempt_vscode_extension_install()
# One subprocess call for --list-extensions, no installation attempts
assert mock_env_and_dependencies['subprocess'].call_count == 1
mock_env_and_dependencies['print'].assert_any_call(
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
)
def test_no_editor_detection_skips_installation(mock_env_and_dependencies):
"""Test that no installation is attempted when no supported editor is detected."""
os.environ['TERM_PROGRAM'] = 'iTerm.app'
os.environ['PATH'] = '/usr/local/bin:/usr/bin:/bin'
vscode_extension.attempt_vscode_extension_install()
mock_env_and_dependencies['exists'].assert_not_called()
mock_env_and_dependencies['touch'].assert_not_called()
mock_env_and_dependencies['subprocess'].assert_not_called()
mock_env_and_dependencies['print'].assert_not_called()
def test_both_bundled_and_marketplace_fail(mock_env_and_dependencies):
"""Test when bundled VSIX installation fails."""
os.environ['TERM_PROGRAM'] = 'vscode'
mock_env_and_dependencies['exists'].return_value = False
mock_env_and_dependencies['download'].return_value = None
mock_vsix_path = mock.MagicMock()
mock_vsix_path.exists.return_value = True
mock_vsix_path.__str__.return_value = '/mock/path/openhands-vscode-0.0.1.vsix'
mock_env_and_dependencies[
'as_file'
].return_value.__enter__.return_value = mock_vsix_path
# Mock subprocess calls: first --list-extensions (empty), then bundled install (fails)
mock_env_and_dependencies['subprocess'].side_effect = [
subprocess.CompletedProcess(
returncode=0, args=[], stdout='', stderr=''
), # --list-extensions
subprocess.CompletedProcess(
args=[
'code',
'--install-extension',
'/mock/path/openhands-vscode-0.0.1.vsix',
'--force',
],
returncode=1,
stdout='Bundled installation failed',
stderr='Error installing bundled extension',
),
]
vscode_extension.attempt_vscode_extension_install()
# Two subprocess calls: --list-extensions and bundled VSIX install
assert mock_env_and_dependencies['subprocess'].call_count == 2
mock_env_and_dependencies['print'].assert_any_call(
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
)